Merge pull request #16531 from vbelolapotkov/fix/initial-map-expand

fix(map): Expand map when challenge opened
This commit is contained in:
Berkeley Martinez
2018-03-04 20:39:57 -08:00
committed by GitHub
9 changed files with 304 additions and 14 deletions

View File

@ -121,6 +121,7 @@ export class Challenge extends PureComponent {
return (
<div
className={ challengeClassName }
data-challenge={dashedName}
key={ title }
>
<Link

View File

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

View File

@ -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;

View File

@ -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)

View File

@ -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
);

View File

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

View File

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

View File

@ -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

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