Merge pull request #16531 from vbelolapotkov/fix/initial-map-expand
fix(map): Expand map when challenge opened
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