From decb6eb93627e2f343c85574cea3bedfe68be05f Mon Sep 17 00:00:00 2001 From: Vasily Belolapotkov Date: Tue, 9 Jan 2018 10:41:44 +0300 Subject: [PATCH] fix(map): Expand map when challenge opened changed fetchMapUi epic to add extra param - initialNode added util method to open path in the map by node name changed action handler for fetchMapUi.complete to open initialNode changed map component to set scroll on component mount and update added attribute to challenge component to find challenge node by name extracted createEventMetaCreator into separate file to break circular dependencies Closes #16248 --- common/app/Map/Challenge.jsx | 1 + common/app/Map/Map.jsx | 83 +++++++++++++++-- common/app/Map/map.less | 10 ++ common/app/Map/redux/fetch-map-ui-epic.js | 4 +- common/app/Map/redux/index.js | 6 +- common/app/Map/redux/utils.js | 72 +++++++++++++++ common/app/Map/redux/utils.test.js | 106 +++++++++++++++++++++- common/app/Nav/redux/index.js | 2 +- common/app/analytics/index.js | 34 +++++++ 9 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 common/app/analytics/index.js 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 + } +});