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
This commit is contained in:
@ -121,6 +121,7 @@ export class Challenge extends PureComponent {
|
||||
return (
|
||||
<div
|
||||
className={ challengeClassName }
|
||||
data-challenge={dashedName}
|
||||
key={ title }
|
||||
>
|
||||
<Link
|
||||
|
@ -6,21 +6,84 @@ import { Col, Row } from 'react-bootstrap';
|
||||
import ns from './ns.json';
|
||||
import { Loader } from '../helperComponents';
|
||||
import SuperBlock from './Super-Block.jsx';
|
||||
import { superBlocksSelector } from '../redux';
|
||||
import { currentChallengeSelector, superBlocksSelector } from '../redux';
|
||||
import { fetchMapUi } from './redux';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
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,6 +104,7 @@ export class ShowMap extends PureComponent {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className = { `${ns}-container`} ref={ ref => { this._row = ref; }}>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<div className={ `${ns}-accordion center-block` }>
|
||||
@ -49,6 +113,7 @@ export class ShowMap extends PureComponent {
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
);
|
||||
|
||||
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
34
common/app/analytics/index.js
Normal file
34
common/app/analytics/index.js
Normal file
@ -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
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user