diff --git a/common/app/Map/Challenge.jsx b/common/app/Map/Challenge.jsx
index b531d884e3..71546d91a1 100644
--- a/common/app/Map/Challenge.jsx
+++ b/common/app/Map/Challenge.jsx
@@ -121,6 +121,7 @@ export class Challenge extends PureComponent {
return (
({
+ currentChallenge: currentChallengeSelector(state),
superBlocks: superBlocksSelector(state)
});
const mapDispatchToProps = { fetchMapUi };
const propTypes = {
+ currentChallenge: PropTypes.string,
fetchMapUi: PropTypes.func.isRequired,
params: PropTypes.object,
superBlocks: PropTypes.array
};
export class ShowMap extends PureComponent {
+ componentDidMount() {
+ this.setupMapScroll();
+ }
+
+ componentDidUpdate() {
+ this.setupMapScroll();
+ }
+
+ setupMapScroll() {
+ this.updateMapScrollAttempts = 0;
+ this.updateMapScroll();
+ }
+
+ updateMapScroll() {
+ const { currentChallenge } = this.props;
+ const rowNode = this._row;
+ const challengeNode = rowNode.querySelector(
+ `[data-challenge="${currentChallenge}"]`
+ );
+
+ if ( !challengeNode ) {
+ this.retryUpdateMapScroll();
+ return;
+ }
+
+ const containerScrollHeight = rowNode.scrollHeight;
+ const containerHeight = rowNode.clientHeight;
+
+ const offset = 100;
+ const itemTop = challengeNode.offsetTop;
+ const itemBottom = itemTop + challengeNode.clientHeight;
+
+ const currentViewBottom = rowNode.scrollTop + containerHeight;
+
+ if ( itemBottom + offset < currentViewBottom ) {
+ // item is visible with enough offset from bottom => no need to scroll
+ return;
+ }
+
+ if ( containerHeight === containerScrollHeight ) {
+ /*
+ * During a first run containerNode scrollHeight may be not updated yet.
+ * In this case containerNode ignores changes of scrollTop property.
+ * So we have to wait some time before scrollTop can be updated
+ * */
+ this.retryUpdateMapScroll();
+ return;
+ }
+
+ const scrollTop = itemBottom + offset - containerHeight;
+ rowNode.scrollTop = scrollTop;
+ }
+
+ retryUpdateMapScroll() {
+ const maxAttempts = 5;
+ this.updateMapScrollAttempts++;
+
+ if (this.updateMapScrollAttempts < maxAttempts) {
+ setTimeout(() => this.updateMapScroll(), 300);
+ }
+ }
renderSuperBlocks() {
const { superBlocks } = this.props;
@@ -41,14 +104,16 @@ export class ShowMap extends PureComponent {
render() {
return (
-
-
-
- { this.renderSuperBlocks() }
-
-
-
-
+
{ this._row = ref; }}>
+
+
+
+ { this.renderSuperBlocks() }
+
+
+
+
+
);
}
}
diff --git a/common/app/Map/map.less b/common/app/Map/map.less
index 1e5aead376..c95767bf79 100644
--- a/common/app/Map/map.less
+++ b/common/app/Map/map.less
@@ -1,6 +1,16 @@
// should be the same as the filename and ./ns.json
@ns: map;
+.@{ns}-container {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
.@{ns}-accordion {
max-width: 700px;
overflow-y: auto;
diff --git a/common/app/Map/redux/fetch-map-ui-epic.js b/common/app/Map/redux/fetch-map-ui-epic.js
index 9b129f8560..37e7cfa09b 100644
--- a/common/app/Map/redux/fetch-map-ui-epic.js
+++ b/common/app/Map/redux/fetch-map-ui-epic.js
@@ -3,7 +3,8 @@ import debug from 'debug';
import {
types as appTypes,
- createErrorObservable
+ createErrorObservable,
+ currentChallengeSelector
} from '../../redux';
import { types, fetchMapUiComplete } from './';
import { langSelector } from '../../Router/redux';
@@ -33,6 +34,7 @@ export default function fetchMapUiEpic(
entities,
isDev
),
+ initialNode: currentChallengeSelector(getState()),
...res
}))
.map(fetchMapUiComplete)
diff --git a/common/app/Map/redux/index.js b/common/app/Map/redux/index.js
index 49d48752ee..f68e67c32e 100644
--- a/common/app/Map/redux/index.js
+++ b/common/app/Map/redux/index.js
@@ -105,14 +105,16 @@ export default handleActions(
};
},
[types.fetchMapUi.complete]: (state, { payload }) => {
- const { entities, result } = payload;
+ const { entities, result, initialNode } = payload;
+ const mapUi = utils.createMapUi(entities, result);
return {
...state,
...result,
- mapUi: utils.createMapUi(entities, result)
+ mapUi: utils.openPath(mapUi, initialNode)
};
}
}),
initialState,
ns
);
+
diff --git a/common/app/Map/redux/utils.js b/common/app/Map/redux/utils.js
index 4afdbdfc8e..eda0044eea 100644
--- a/common/app/Map/redux/utils.js
+++ b/common/app/Map/redux/utils.js
@@ -157,3 +157,75 @@ export function collapseAllPanels(tree) {
export function expandAllPanels(tree) {
return toggleAllPanels(tree, true);
}
+
+// synchronise
+// updatePath(
+// tree: MapUi,
+// name: String,
+// update(MapUi|Node) => MapUi|Node
+// ) => MapUi
+export function updatePath(tree, name, pathUpdater) {
+ const path = [];
+ let pathFound = false;
+
+ const isInPath = node => !!path.find(name => name === node.name);
+
+ const traverseMap = (tree, update) => {
+ if (pathFound) {
+ return isInPath(tree) ? update(tree) : tree;
+ }
+
+ if (tree.name === name) {
+ pathFound = true;
+ return update(tree);
+ }
+
+ let childrenChanged;
+
+ if (!Array.isArray(tree.children)) {
+ return tree;
+ }
+
+ if (tree.name) {
+ path.push(tree.name);
+ }
+
+ const newChildren = tree.children.map(node => {
+ const newNode = traverseMap(node, update);
+ if (!childrenChanged && newNode !== node) {
+ childrenChanged = true;
+ }
+ return newNode;
+ });
+ if (childrenChanged) {
+ tree = {
+ ...tree,
+ children: newChildren
+ };
+ }
+
+ if (pathFound && isInPath(tree)) {
+ return update(tree);
+ }
+
+ path.pop();
+ return tree;
+ };
+
+
+ return traverseMap(tree, pathUpdater);
+}
+
+// synchronise
+// openPath(tree: MapUi, name: String) => MapUi
+export function openPath(tree, name) {
+ return updatePath(tree, name, node => {
+ if (!Array.isArray(node.children)) {
+ return node;
+ }
+
+ return { ...node, isOpen: true };
+ });
+}
+
+
diff --git a/common/app/Map/redux/utils.test.js b/common/app/Map/redux/utils.test.js
index de761a9c3c..ffb448dd8d 100644
--- a/common/app/Map/redux/utils.test.js
+++ b/common/app/Map/redux/utils.test.js
@@ -8,7 +8,9 @@ import {
updateSingleNode,
toggleThisPanel,
expandAllPanels,
- collapseAllPanels
+ collapseAllPanels,
+ updatePath,
+ openPath
} from './utils.js';
test('createMapUi', t => {
@@ -213,3 +215,105 @@ test('toggleAllPanels', t => {
t.equal(actual.children[0].children[1], leaf);
});
});
+test('updatePath', t => {
+ t.test('should call update function for each node in the path', t => {
+ const expected = {
+ children: [
+ {
+ name: 'superFoo',
+ children: [
+ {
+ name: 'blockBar',
+ children: [{name: 'challBar'}]
+ },
+ {
+ name: 'blockFoo',
+ children: [{name: 'challFoo'}]
+ }
+ ]
+ },
+ {
+ name: 'superBaz',
+ isOpen: false,
+ children: []
+ }
+ ]
+ };
+
+ const spy = sinon.spy(t => ({ ...t}) );
+ spy.withArgs(expected.children[0]);
+ spy.withArgs(expected.children[0].children[1]);
+ spy.withArgs(expected.children[0].children[1].children[0]);
+ updatePath(expected, 'challFoo', spy);
+ t.plan(4);
+ t.equal(spy.callCount, 3);
+ t.ok(spy.withArgs(expected.children[0]).calledOnce, 'superBlock');
+ t.ok(spy.withArgs(expected.children[0].children[1]).calledOnce, 'block');
+ t.ok(
+ spy.withArgs(expected.children[0].children[1].children[0]).calledOnce,
+ 'chall'
+ );
+ });
+});
+test('openPath', t=> {
+ t.test('should open all nodes in the path', t => {
+ const expected = {
+ children: [
+ {
+ name: 'superFoo',
+ isOpen: true,
+ children: [
+ {
+ name: 'blockBar',
+ isOpen: false,
+ children: []
+ },
+ {
+ name: 'blockFoo',
+ isOpen: true,
+ children: [{
+ name: 'challFoo'
+ }]
+ }
+ ]
+ },
+ {
+ name: 'superBar',
+ isOpen: false,
+ children: []
+ }
+ ]
+ };
+
+ const actual = openPath({
+ children: [
+ {
+ name: 'superFoo',
+ isOpen: false,
+ children: [
+ {
+ name: 'blockBar',
+ isOpen: false,
+ children: []
+ },
+ {
+ name: 'blockFoo',
+ isOpen: false,
+ children: [{
+ name: 'challFoo'
+ }]
+ }
+ ]
+ },
+ {
+ name: 'superBar',
+ isOpen: false,
+ children: []
+ }
+ ]
+ }, 'challFoo');
+
+ t.plan(1);
+ t.deepLooseEqual(actual, expected);
+ });
+});
diff --git a/common/app/Nav/redux/index.js b/common/app/Nav/redux/index.js
index 81179b75c5..0233add11b 100644
--- a/common/app/Nav/redux/index.js
+++ b/common/app/Nav/redux/index.js
@@ -8,7 +8,7 @@ import {
import loadCurrentChallengeEpic from './load-current-challenge-epic.js';
import ns from '../ns.json';
-import { createEventMetaCreator } from '../../redux';
+import { createEventMetaCreator } from '../../analytics/index';
export const epics = [
loadCurrentChallengeEpic
diff --git a/common/app/analytics/index.js b/common/app/analytics/index.js
new file mode 100644
index 0000000000..59047cfe8b
--- /dev/null
+++ b/common/app/analytics/index.js
@@ -0,0 +1,34 @@
+const throwIfUndefined = () => {
+ throw new TypeError('Argument must not be of type `undefined`');
+};
+
+// createEventMetaCreator({
+// category: String,
+// action: String,
+// label?: String,
+// value?: Number
+// }) => () => Object
+export const createEventMetaCreator = ({
+ // categories are features or namespaces of the app (capitalized):
+ // Map, Nav, Challenges, and so on
+ category = throwIfUndefined,
+ // can be a one word the event
+ // click, play, toggle.
+ // This is not a hard and fast rule
+ action = throwIfUndefined,
+ // any additional information
+ // when in doubt use redux action type
+ // or a short sentence describing the
+ // action
+ label,
+ // used to tack some specific value for a GA event
+ value
+} = throwIfUndefined) => () => ({
+ analytics: {
+ type: 'event',
+ category,
+ action,
+ label,
+ value
+ }
+});