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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={ challengeClassName }
|
className={ challengeClassName }
|
||||||
|
data-challenge={dashedName}
|
||||||
key={ title }
|
key={ title }
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
|
@ -6,21 +6,84 @@ import { Col, Row } from 'react-bootstrap';
|
|||||||
import ns from './ns.json';
|
import ns from './ns.json';
|
||||||
import { Loader } from '../helperComponents';
|
import { Loader } from '../helperComponents';
|
||||||
import SuperBlock from './Super-Block.jsx';
|
import SuperBlock from './Super-Block.jsx';
|
||||||
import { superBlocksSelector } from '../redux';
|
import { currentChallengeSelector, superBlocksSelector } from '../redux';
|
||||||
import { fetchMapUi } from './redux';
|
import { fetchMapUi } from './redux';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
|
currentChallenge: currentChallengeSelector(state),
|
||||||
superBlocks: superBlocksSelector(state)
|
superBlocks: superBlocksSelector(state)
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = { fetchMapUi };
|
const mapDispatchToProps = { fetchMapUi };
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
currentChallenge: PropTypes.string,
|
||||||
fetchMapUi: PropTypes.func.isRequired,
|
fetchMapUi: PropTypes.func.isRequired,
|
||||||
params: PropTypes.object,
|
params: PropTypes.object,
|
||||||
superBlocks: PropTypes.array
|
superBlocks: PropTypes.array
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ShowMap extends PureComponent {
|
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() {
|
renderSuperBlocks() {
|
||||||
const { superBlocks } = this.props;
|
const { superBlocks } = this.props;
|
||||||
@ -41,6 +104,7 @@ export class ShowMap extends PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
|
<div className = { `${ns}-container`} ref={ ref => { this._row = ref; }}>
|
||||||
<Row>
|
<Row>
|
||||||
<Col xs={ 12 }>
|
<Col xs={ 12 }>
|
||||||
<div className={ `${ns}-accordion center-block` }>
|
<div className={ `${ns}-accordion center-block` }>
|
||||||
@ -49,6 +113,7 @@ export class ShowMap extends PureComponent {
|
|||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,16 @@
|
|||||||
// should be the same as the filename and ./ns.json
|
// should be the same as the filename and ./ns.json
|
||||||
@ns: map;
|
@ns: map;
|
||||||
|
|
||||||
|
.@{ns}-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.@{ns}-accordion {
|
.@{ns}-accordion {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
@ -3,7 +3,8 @@ import debug from 'debug';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
types as appTypes,
|
types as appTypes,
|
||||||
createErrorObservable
|
createErrorObservable,
|
||||||
|
currentChallengeSelector
|
||||||
} from '../../redux';
|
} from '../../redux';
|
||||||
import { types, fetchMapUiComplete } from './';
|
import { types, fetchMapUiComplete } from './';
|
||||||
import { langSelector } from '../../Router/redux';
|
import { langSelector } from '../../Router/redux';
|
||||||
@ -33,6 +34,7 @@ export default function fetchMapUiEpic(
|
|||||||
entities,
|
entities,
|
||||||
isDev
|
isDev
|
||||||
),
|
),
|
||||||
|
initialNode: currentChallengeSelector(getState()),
|
||||||
...res
|
...res
|
||||||
}))
|
}))
|
||||||
.map(fetchMapUiComplete)
|
.map(fetchMapUiComplete)
|
||||||
|
@ -105,14 +105,16 @@ export default handleActions(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
[types.fetchMapUi.complete]: (state, { payload }) => {
|
[types.fetchMapUi.complete]: (state, { payload }) => {
|
||||||
const { entities, result } = payload;
|
const { entities, result, initialNode } = payload;
|
||||||
|
const mapUi = utils.createMapUi(entities, result);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
...result,
|
...result,
|
||||||
mapUi: utils.createMapUi(entities, result)
|
mapUi: utils.openPath(mapUi, initialNode)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
initialState,
|
initialState,
|
||||||
ns
|
ns
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -157,3 +157,75 @@ export function collapseAllPanels(tree) {
|
|||||||
export function expandAllPanels(tree) {
|
export function expandAllPanels(tree) {
|
||||||
return toggleAllPanels(tree, true);
|
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,
|
updateSingleNode,
|
||||||
toggleThisPanel,
|
toggleThisPanel,
|
||||||
expandAllPanels,
|
expandAllPanels,
|
||||||
collapseAllPanels
|
collapseAllPanels,
|
||||||
|
updatePath,
|
||||||
|
openPath
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
test('createMapUi', t => {
|
test('createMapUi', t => {
|
||||||
@ -213,3 +215,105 @@ test('toggleAllPanels', t => {
|
|||||||
t.equal(actual.children[0].children[1], leaf);
|
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 loadCurrentChallengeEpic from './load-current-challenge-epic.js';
|
||||||
import ns from '../ns.json';
|
import ns from '../ns.json';
|
||||||
import { createEventMetaCreator } from '../../redux';
|
import { createEventMetaCreator } from '../../analytics/index';
|
||||||
|
|
||||||
export const epics = [
|
export const epics = [
|
||||||
loadCurrentChallengeEpic
|
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