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 + } +});