@ -7,16 +7,21 @@ import { Panel } from 'react-bootstrap';
|
||||
|
||||
import Challenge from './Challenge.jsx';
|
||||
import { toggleThisPanel } from '../../redux/actions';
|
||||
import {
|
||||
makePanelOpenSelector,
|
||||
makePanelHiddenSelector
|
||||
} from '../../redux/selectors';
|
||||
|
||||
const dispatchActions = { toggleThisPanel };
|
||||
const mapStateToProps = createSelector(
|
||||
const makeMapStateToProps = () => createSelector(
|
||||
(_, props) => props.dashedName,
|
||||
(state, props) => state.entities.block[props.dashedName],
|
||||
state => state.entities.challenge,
|
||||
(state, props) => state.challengesApp.mapUi[props.dashedName],
|
||||
(dashedName, block, challengeMap, isOpen) => {
|
||||
makePanelOpenSelector(),
|
||||
makePanelHiddenSelector(),
|
||||
(dashedName, block, isOpen, isHidden) => {
|
||||
return {
|
||||
isOpen,
|
||||
isHidden,
|
||||
dashedName,
|
||||
title: block.title,
|
||||
time: block.time,
|
||||
@ -35,6 +40,7 @@ export class Block extends PureComponent {
|
||||
dashedName: PropTypes.string,
|
||||
time: PropTypes.string,
|
||||
isOpen: PropTypes.bool,
|
||||
isHidden: PropTypes.bool,
|
||||
challenges: PropTypes.array,
|
||||
toggleThisPanel: PropTypes.func
|
||||
};
|
||||
@ -79,8 +85,12 @@ export class Block extends PureComponent {
|
||||
time,
|
||||
dashedName,
|
||||
isOpen,
|
||||
isHidden,
|
||||
challenges
|
||||
} = this.props;
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Panel
|
||||
bsClass='map-accordion-panel-nested'
|
||||
@ -98,4 +108,4 @@ export class Block extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, dispatchActions)(Block);
|
||||
export default connect(makeMapStateToProps, dispatchActions)(Block);
|
||||
|
@ -7,16 +7,19 @@ import classnames from 'classnames';
|
||||
import debug from 'debug';
|
||||
|
||||
import { updateCurrentChallenge } from '../../redux/actions';
|
||||
import { makePanelHiddenSelector } from '../../redux/selectors';
|
||||
|
||||
const bindableActions = { updateCurrentChallenge };
|
||||
const mapStateToProps = createSelector(
|
||||
const makeMapStateToProps = () => createSelector(
|
||||
(_, props) => props.dashedName,
|
||||
state => state.entities.challenge,
|
||||
(dashedName, challengeMap) => {
|
||||
makePanelHiddenSelector(),
|
||||
(dashedName, challengeMap, isHidden) => {
|
||||
const challenge = challengeMap[dashedName] || {};
|
||||
return {
|
||||
dashedName,
|
||||
challenge,
|
||||
isHidden,
|
||||
title: challenge.title,
|
||||
block: challenge.block,
|
||||
isLocked: challenge.isLocked,
|
||||
@ -27,6 +30,7 @@ const mapStateToProps = createSelector(
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export class Challenge extends PureComponent {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
@ -39,6 +43,7 @@ export class Challenge extends PureComponent {
|
||||
isLocked: PropTypes.bool,
|
||||
isRequired: PropTypes.bool,
|
||||
isCompleted: PropTypes.bool,
|
||||
isHidden: PropTypes.bool,
|
||||
challenge: PropTypes.object,
|
||||
updateCurrentChallenge: PropTypes.func
|
||||
};
|
||||
@ -95,9 +100,13 @@ export class Challenge extends PureComponent {
|
||||
isCompleted,
|
||||
isComingSoon,
|
||||
isDev,
|
||||
isHidden,
|
||||
challenge,
|
||||
updateCurrentChallenge
|
||||
} = this.props;
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
const challengeClassName = classnames({
|
||||
'text-primary': true,
|
||||
'padded-ionic-icon': true,
|
||||
@ -133,4 +142,4 @@ export class Challenge extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, bindableActions)(Challenge);
|
||||
export default connect(makeMapStateToProps, bindableActions)(Challenge);
|
||||
|
@ -27,6 +27,7 @@ export class Header extends PureComponent {
|
||||
constructor(...props) {
|
||||
super(...props);
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
this.handleClearButton = this.handleClearButton.bind(this);
|
||||
}
|
||||
static displayName = 'MapHeader';
|
||||
static propTypes = {
|
||||
@ -44,18 +45,22 @@ export class Header extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
renderSearchAddon(filter, clearFilter) {
|
||||
handleClearButton(e) {
|
||||
e.preventDefault();
|
||||
this.props.clearFilter();
|
||||
}
|
||||
|
||||
renderSearchAddon(filter) {
|
||||
if (!filter) {
|
||||
return searchIcon;
|
||||
}
|
||||
return <span onClick={ clearFilter }>{ clearIcon }</span>;
|
||||
return <span onClick={this.handleClearButton }>{ clearIcon }</span>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
filter,
|
||||
updateFilter,
|
||||
clearFilter,
|
||||
collapseAll,
|
||||
expandAll,
|
||||
isAllCollapsed
|
||||
@ -100,7 +105,7 @@ export class Header extends PureComponent {
|
||||
value={ filter }
|
||||
/>
|
||||
<InputGroup.Addon>
|
||||
{ this.renderSearchAddon(filter, clearFilter) }
|
||||
{ this.renderSearchAddon(filter) }
|
||||
</InputGroup.Addon>
|
||||
</InputGroup>
|
||||
</Row>
|
||||
|
@ -7,20 +7,35 @@ import { Panel } from 'react-bootstrap';
|
||||
|
||||
import Block from './Block.jsx';
|
||||
import { toggleThisPanel } from '../../redux/actions';
|
||||
import {
|
||||
makePanelOpenSelector,
|
||||
makePanelHiddenSelector
|
||||
} from '../../redux/selectors';
|
||||
|
||||
const dispatchActions = { toggleThisPanel };
|
||||
const mapStateToProps = createSelector(
|
||||
(_, props) => props.dashedName,
|
||||
(state, props) => state.entities.superBlock[props.dashedName],
|
||||
(state, props) => state.challengesApp.mapUi[props.dashedName],
|
||||
(dashedName, superBlock, isOpen) => ({
|
||||
isOpen,
|
||||
dashedName,
|
||||
title: superBlock.title,
|
||||
blocks: superBlock.blocks,
|
||||
message: superBlock.message
|
||||
})
|
||||
);
|
||||
// make selectors unique to each component
|
||||
// see
|
||||
// reactjs/reselect
|
||||
// sharing-selectors-with-props-across-multiple-components
|
||||
const makeMapStateToProps = () => {
|
||||
const panelOpenSelector = makePanelOpenSelector();
|
||||
const panelHiddenSelector = makePanelHiddenSelector();
|
||||
return createSelector(
|
||||
(_, props) => props.dashedName,
|
||||
(state, props) => state.entities.superBlock[props.dashedName],
|
||||
panelOpenSelector,
|
||||
panelHiddenSelector,
|
||||
(dashedName, superBlock, isOpen, isHidden) => ({
|
||||
isOpen,
|
||||
isHidden,
|
||||
dashedName,
|
||||
title: superBlock.title,
|
||||
blocks: superBlock.blocks,
|
||||
message: superBlock.message
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export class SuperBlock extends PureComponent {
|
||||
constructor(...props) {
|
||||
super(...props);
|
||||
@ -32,6 +47,7 @@ export class SuperBlock extends PureComponent {
|
||||
dashedName: PropTypes.string,
|
||||
blocks: PropTypes.array,
|
||||
isOpen: PropTypes.bool,
|
||||
isHidden: PropTypes.bool,
|
||||
message: PropTypes.string,
|
||||
toggleThisPanl: PropTypes.func
|
||||
};
|
||||
@ -82,8 +98,12 @@ export class SuperBlock extends PureComponent {
|
||||
dashedName,
|
||||
blocks,
|
||||
message,
|
||||
isOpen
|
||||
isOpen,
|
||||
isHidden
|
||||
} = this.props;
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Panel
|
||||
bsClass='map-accordion-panel'
|
||||
@ -107,6 +127,6 @@ export class SuperBlock extends PureComponent {
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
makeMapStateToProps,
|
||||
dispatchActions
|
||||
)(SuperBlock);
|
||||
|
@ -43,27 +43,7 @@ export const updateFilter = createAction(
|
||||
e => e.target.value
|
||||
);
|
||||
|
||||
function createMapKey(map, key) {
|
||||
map[key] = true;
|
||||
return map;
|
||||
}
|
||||
export const initMap = createAction(
|
||||
types.initMap,
|
||||
(
|
||||
{ superBlock: superBlockMap },
|
||||
superBlocks
|
||||
) => {
|
||||
if (!superBlocks || !superBlockMap) {
|
||||
return {};
|
||||
}
|
||||
const blocks = superBlocks
|
||||
.map(superBlock => superBlockMap[superBlock].blocks)
|
||||
.reduce((blocks, block) => blocks.concat(block));
|
||||
return superBlocks
|
||||
.concat(blocks)
|
||||
.reduce(createMapKey, {});
|
||||
}
|
||||
);
|
||||
export const initMap = createAction(types.initMap);
|
||||
export const toggleThisPanel = createAction(types.toggleThisPanel);
|
||||
export const collapseAll = createAction(types.collapseAll);
|
||||
export const expandAll = createAction(types.expandAll);
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
updateCurrentChallenge,
|
||||
initMap
|
||||
} from './actions';
|
||||
import { createMapUi } from '../utils';
|
||||
import {
|
||||
delayedRedirect,
|
||||
createErrorObservable
|
||||
@ -72,7 +73,7 @@ export default function fetchChallengesSaga(action$, getState, { services }) {
|
||||
createNameIdMap(entities),
|
||||
result
|
||||
),
|
||||
initMap(entities, result),
|
||||
initMap(createMapUi(entities, result)),
|
||||
);
|
||||
})
|
||||
.catch(createErrorObservable);
|
||||
|
@ -4,6 +4,7 @@ import nextChallengeSaga from './next-challenge-saga';
|
||||
import answerSaga from './answer-saga';
|
||||
import resetChallengeSaga from './reset-challenge-saga';
|
||||
import bugSaga from './bug-saga';
|
||||
import mapUiSaga from './map-ui-saga';
|
||||
|
||||
export * as actions from './actions';
|
||||
export reducer from './reducer';
|
||||
@ -17,5 +18,6 @@ export const sagas = [
|
||||
nextChallengeSaga,
|
||||
answerSaga,
|
||||
resetChallengeSaga,
|
||||
bugSaga
|
||||
bugSaga,
|
||||
mapUiSaga
|
||||
];
|
||||
|
34
common/app/routes/challenges/redux/map-ui-saga.js
Normal file
34
common/app/routes/challenges/redux/map-ui-saga.js
Normal file
@ -0,0 +1,34 @@
|
||||
import types from './types';
|
||||
import { initMap } from './actions';
|
||||
import { unfilterMapUi, applyFilterToMap } from '../utils';
|
||||
|
||||
export default function mapUiSaga(actions$, getState) {
|
||||
return actions$
|
||||
.filter(({ type }) => (
|
||||
type === types.updateFilter ||
|
||||
type === types.clearFilter
|
||||
))
|
||||
.debounce(250)
|
||||
.map(({ payload: filter = '' }) => filter)
|
||||
.distinctUntilChanged()
|
||||
.map(filter => {
|
||||
const { challengesApp: { mapUi = {} } } = getState();
|
||||
let newMapUi;
|
||||
if (filter.length <= 3) {
|
||||
newMapUi = unfilterMapUi(mapUi);
|
||||
} else {
|
||||
const regexString = filter
|
||||
// replace spaces with any key to match dashes
|
||||
.replace(/ /g, '.')
|
||||
// makes search more fuzzy (thanks @xRahul)
|
||||
.split('')
|
||||
.join('.*');
|
||||
const filterRegex = new RegExp(regexString, 'i');
|
||||
newMapUi = applyFilterToMap(mapUi, filterRegex);
|
||||
}
|
||||
if (!newMapUi || newMapUi === mapUi) {
|
||||
return null;
|
||||
}
|
||||
return initMap(newMapUi);
|
||||
});
|
||||
}
|
@ -8,7 +8,10 @@ import {
|
||||
buildSeed,
|
||||
createTests,
|
||||
getPreFile,
|
||||
getFileKey
|
||||
getFileKey,
|
||||
toggleThisPanel,
|
||||
collapseAllPanels,
|
||||
expandAllPanels
|
||||
} from '../utils';
|
||||
|
||||
const initialUiState = {
|
||||
@ -180,7 +183,7 @@ const mainReducer = handleActions(
|
||||
mouse: [ 0, 0 ]
|
||||
}),
|
||||
|
||||
[types.videoCompleted]: (state, { payload: userAnswer } ) => ({
|
||||
[types.videoCompleted]: (state, { payload: userAnswer }) => ({
|
||||
...state,
|
||||
isCorrect: true,
|
||||
isPressed: false,
|
||||
@ -238,35 +241,37 @@ const filesReducer = handleActions(
|
||||
);
|
||||
|
||||
// {
|
||||
// // show
|
||||
// [(super)BlockName]: { open: Boolean }
|
||||
// // do not show
|
||||
// [(super)BlockName]: null
|
||||
// children: [...{
|
||||
// name: (superBlock: String),
|
||||
// isOpen: Boolean,
|
||||
// isHidden: Boolean,
|
||||
// children: [...{
|
||||
// name: (blockName: String),
|
||||
// isOpen: Boolean,
|
||||
// isHidden: Boolean,
|
||||
// children: [...{
|
||||
// name: (challengeName: String),
|
||||
// isHidden: Boolean
|
||||
// }]
|
||||
// }]
|
||||
// }
|
||||
// }
|
||||
const mapReducer = handleActions(
|
||||
{
|
||||
[types.initMap]: (state, { payload }) => ({
|
||||
...state,
|
||||
...payload
|
||||
}),
|
||||
[types.toggleThisPanel]: (state, { payload }) => ({
|
||||
...state,
|
||||
[payload]: !state[payload]
|
||||
}),
|
||||
[types.collapseAll]: state => ({
|
||||
...Object.keys(state).reduce((newState, key) => {
|
||||
newState[key] = false;
|
||||
return newState;
|
||||
}, {}),
|
||||
isAllCollapsed: true
|
||||
}),
|
||||
[types.expandAll]: state => ({
|
||||
...Object.keys(state).reduce((newState, key) => {
|
||||
newState[key] = true;
|
||||
return newState;
|
||||
}, {}),
|
||||
isAllCollapsed: false
|
||||
})
|
||||
[types.initMap]: (state, { payload }) => payload,
|
||||
[types.toggleThisPanel]: (state, { payload: name }) => {
|
||||
return toggleThisPanel(state, name);
|
||||
},
|
||||
[types.collapseAll]: state => {
|
||||
const newState = collapseAllPanels(state);
|
||||
newState.isAllCollapsed = true;
|
||||
return newState;
|
||||
},
|
||||
[types.expandAll]: state => {
|
||||
const newState = expandAllPanels(state);
|
||||
newState.isAllCollapsed = false;
|
||||
return newState;
|
||||
}
|
||||
},
|
||||
initialState.mapUi
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import * as challengeTypes from '../../../utils/challengeTypes';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as challengeTypes from '../../../utils/challengeTypes';
|
||||
import { getNode } from '../utils';
|
||||
|
||||
const viewTypes = {
|
||||
[ challengeTypes.html]: 'classic',
|
||||
@ -52,3 +53,21 @@ export const challengeSelector = createSelector(
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const makePanelOpenSelector = () => createSelector(
|
||||
state => state.challengesApp.mapUi,
|
||||
(_, props) => props.dashedName,
|
||||
(mapUi, name) => {
|
||||
const node = getNode(mapUi, name);
|
||||
return node ? node.isOpen : true;
|
||||
}
|
||||
);
|
||||
|
||||
export const makePanelHiddenSelector = () => createSelector(
|
||||
state => state.challengesApp.mapUi,
|
||||
(_, props) => props.dashedName,
|
||||
(mapUi, name) => {
|
||||
const node = getNode(mapUi, name);
|
||||
return node ? node.isHidden : false;
|
||||
}
|
||||
);
|
||||
|
@ -284,3 +284,224 @@ export function getMouse(e, [dx, dy]) {
|
||||
|
||||
return [pageX - dx, pageY - dy];
|
||||
}
|
||||
|
||||
const emptyProtector = {
|
||||
blocks: [],
|
||||
challenges: []
|
||||
};
|
||||
// protect against malformed data
|
||||
function protect(block) {
|
||||
// if no block or block has no challenges or blocks
|
||||
// use protector
|
||||
if (!block || !(block.challenges || block.blocks)) {
|
||||
return emptyProtector;
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
// interface Node {
|
||||
// isHidden: Boolean,
|
||||
// children: Void|[ ...Node ],
|
||||
// isOpen?: Boolean
|
||||
// }
|
||||
//
|
||||
// interface MapUi
|
||||
// {
|
||||
// children: [...{
|
||||
// name: (superBlock: String),
|
||||
// isOpen: Boolean,
|
||||
// isHidden: Boolean,
|
||||
// children: [...{
|
||||
// name: (blockName: String),
|
||||
// isOpen: Boolean,
|
||||
// isHidden: Boolean,
|
||||
// children: [...{
|
||||
// name: (challengeName: String),
|
||||
// isHidden: Boolean
|
||||
// }]
|
||||
// }]
|
||||
// }]
|
||||
// }
|
||||
export function createMapUi(
|
||||
{ superBlock: superBlockMap, block: blockMap } = {},
|
||||
superBlocks
|
||||
) {
|
||||
if (!superBlocks || !superBlockMap || !blockMap) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
children: superBlocks.map(superBlock => {
|
||||
return {
|
||||
name: superBlock,
|
||||
isOpen: true,
|
||||
isHidden: false,
|
||||
children: protect(superBlockMap[superBlock]).blocks.map(block => {
|
||||
return {
|
||||
name: block,
|
||||
isOpen: true,
|
||||
isHidden: false,
|
||||
children: protect(blockMap[block]).challenges.map(challenge => {
|
||||
return {
|
||||
name: challenge,
|
||||
isHidden: false,
|
||||
children: null
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// synchronise
|
||||
// traverseMapUi(
|
||||
// tree: MapUi|Node,
|
||||
// update: ((MapUi|Node) => MapUi|Node)
|
||||
// ) => MapUi|Node
|
||||
export function traverseMapUi(tree, update) {
|
||||
let childrenChanged;
|
||||
if (!Array.isArray(tree.children)) {
|
||||
return update(tree);
|
||||
}
|
||||
const newChildren = tree.children.map(node => {
|
||||
const newNode = traverseMapUi(node, update);
|
||||
if (!childrenChanged && newNode !== node) {
|
||||
childrenChanged = true;
|
||||
}
|
||||
return newNode;
|
||||
});
|
||||
if (childrenChanged) {
|
||||
tree = {
|
||||
...tree,
|
||||
children: newChildren
|
||||
};
|
||||
}
|
||||
return update(tree);
|
||||
}
|
||||
|
||||
// synchronise
|
||||
// getNode(tree: MapUi, name: String) => MapUi
|
||||
export function getNode(tree, name) {
|
||||
let node;
|
||||
traverseMapUi(tree, thisNode => {
|
||||
if (thisNode.name === name) {
|
||||
node = thisNode;
|
||||
}
|
||||
return thisNode;
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
// synchronise
|
||||
// updateSingelNode(
|
||||
// tree: MapUi,
|
||||
// name: String,
|
||||
// update(MapUi|Node) => MapUi|Node
|
||||
// ) => MapUi
|
||||
export function updateSingleNode(tree, name, update) {
|
||||
return traverseMapUi(tree, node => {
|
||||
if (name !== node.name) {
|
||||
return node;
|
||||
}
|
||||
return update(node);
|
||||
});
|
||||
}
|
||||
|
||||
// synchronise
|
||||
// toggleThisPanel(tree: MapUi, name: String) => MapUi
|
||||
export function toggleThisPanel(tree, name) {
|
||||
return updateSingleNode(tree, name, node => {
|
||||
return {
|
||||
...node,
|
||||
isOpen: !node.isOpen
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// toggleAllPanels(tree: MapUi, isOpen: Boolean = false ) => MapUi
|
||||
export function toggleAllPanels(tree, isOpen = false) {
|
||||
return traverseMapUi(tree, node => {
|
||||
if (!Array.isArray(node.children) || node.isOpen === isOpen) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
isOpen
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// collapseAllPanels(tree: MapUi) => MapUi
|
||||
export function collapseAllPanels(tree) {
|
||||
return toggleAllPanels(tree);
|
||||
}
|
||||
|
||||
// expandAllPanels(tree: MapUi) => MapUi
|
||||
export function expandAllPanels(tree) {
|
||||
return toggleAllPanels(tree, true);
|
||||
}
|
||||
|
||||
// applyFilterToMap(tree: MapUi, filterRegex: RegExp) => MapUi
|
||||
export function applyFilterToMap(tree, filterRegex) {
|
||||
return traverseMapUi(
|
||||
tree,
|
||||
node => {
|
||||
// no children indicates a challenge node
|
||||
// if leaf (challenge) then test if regex is a match
|
||||
if (!Array.isArray(node.children)) {
|
||||
// does challenge name meet filter criteria?
|
||||
if (filterRegex.test(node.name)) {
|
||||
// is challenge currently hidden?
|
||||
if (node.isHidden) {
|
||||
// unhide challenge, it matches
|
||||
return {
|
||||
...node,
|
||||
isHidden: false
|
||||
};
|
||||
}
|
||||
} else if (!node.isHidden) {
|
||||
return {
|
||||
...node,
|
||||
isHidden: true
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}
|
||||
// if not leaf node (challenge) then
|
||||
// test to see if all its children are hidden
|
||||
if (node.children.every(node => node.isHidden)) {
|
||||
if (node.isHidden) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
isHidden: true
|
||||
};
|
||||
} else if (node.isHidden) {
|
||||
return {
|
||||
...node,
|
||||
isHidden: false
|
||||
};
|
||||
}
|
||||
// nothing has changed
|
||||
return node;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// unfilterMapUi(tree: MapUi) => MapUi
|
||||
export function unfilterMapUi(tree) {
|
||||
return traverseMapUi(
|
||||
tree,
|
||||
node => {
|
||||
if (!node.isHidden) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
isHidden: false
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,18 @@
|
||||
import test from 'tape';
|
||||
import sinon from 'sinon';
|
||||
import {
|
||||
getNextChallenge,
|
||||
getFirstChallengeOfNextBlock,
|
||||
getFirstChallengeOfNextSuperBlock
|
||||
getFirstChallengeOfNextSuperBlock,
|
||||
createMapUi,
|
||||
traverseMapUi,
|
||||
getNode,
|
||||
updateSingleNode,
|
||||
toggleThisPanel,
|
||||
expandAllPanels,
|
||||
collapseAllPanels,
|
||||
applyFilterToMap,
|
||||
unfilterMapUi
|
||||
} from './utils.js';
|
||||
|
||||
|
||||
@ -774,4 +784,317 @@ test('common/app/routes/challenges/utils', function(t) {
|
||||
);
|
||||
});
|
||||
});
|
||||
t.test('createMapUi', t => {
|
||||
t.plan(3);
|
||||
t.test('should return an `{}` when proper args not supplied', t => {
|
||||
t.plan(3);
|
||||
t.equal(
|
||||
Object.keys(createMapUi()).length,
|
||||
0
|
||||
);
|
||||
t.equal(
|
||||
Object.keys(createMapUi({}, [])).length,
|
||||
0
|
||||
);
|
||||
t.equal(
|
||||
Object.keys(createMapUi({ superBlock: {} }, [])).length,
|
||||
0
|
||||
);
|
||||
});
|
||||
t.test('should return a map tree', t => {
|
||||
const expected = {
|
||||
children: [{
|
||||
name: 'superBlockA',
|
||||
children: [{
|
||||
name: 'blockA',
|
||||
children: [{
|
||||
name: 'challengeA'
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const actual = createMapUi({
|
||||
superBlock: {
|
||||
superBlockA: {
|
||||
blocks: [
|
||||
'blockA'
|
||||
]
|
||||
}
|
||||
},
|
||||
block: {
|
||||
blockA: {
|
||||
challenges: [
|
||||
'challengeA'
|
||||
]
|
||||
}
|
||||
}
|
||||
}, ['superBlockA']);
|
||||
t.plan(3);
|
||||
t.equal(actual.children[0].name, expected.children[0].name);
|
||||
t.equal(
|
||||
actual.children[0].children[0].name,
|
||||
expected.children[0].children[0].name
|
||||
);
|
||||
t.equal(
|
||||
actual.children[0].children[0].children[0].name,
|
||||
expected.children[0].children[0].children[0].name
|
||||
);
|
||||
});
|
||||
t.test('should protect against malformed data', t => {
|
||||
t.plan(2);
|
||||
t.equal(
|
||||
createMapUi({
|
||||
superBlock: {},
|
||||
block: {
|
||||
blockA: {
|
||||
challenges: [
|
||||
'challengeA'
|
||||
]
|
||||
}
|
||||
}
|
||||
}, ['superBlockA']).children[0].children.length,
|
||||
0
|
||||
);
|
||||
t.equal(
|
||||
createMapUi({
|
||||
superBlock: {
|
||||
superBlockA: {
|
||||
blocks: [
|
||||
'blockA'
|
||||
]
|
||||
}
|
||||
},
|
||||
block: {}
|
||||
}, ['superBlockA']).children[0].children[0].children.length,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
t.test('traverseMapUi', t => {
|
||||
t.test('should return tree', t => {
|
||||
t.plan(2);
|
||||
const expectedTree = {};
|
||||
const actaulTree = traverseMapUi(expectedTree, tree => {
|
||||
t.equal(tree, expectedTree);
|
||||
return tree;
|
||||
});
|
||||
t.equal(actaulTree, expectedTree);
|
||||
});
|
||||
t.test('should hit every node', t => {
|
||||
t.plan(4);
|
||||
const expected = { children: [{ children: [{}] }] };
|
||||
const spy = sinon.spy(t => t);
|
||||
spy.withArgs(expected);
|
||||
spy.withArgs(expected.children[0]);
|
||||
spy.withArgs(expected.children[0].children[0]);
|
||||
traverseMapUi(expected, spy);
|
||||
t.equal(spy.callCount, 3);
|
||||
t.ok(spy.withArgs(expected).calledOnce, 'foo');
|
||||
t.ok(spy.withArgs(expected.children[0]).calledOnce, 'bar');
|
||||
t.ok(spy.withArgs(expected.children[0].children[0]).calledOnce, 'baz');
|
||||
});
|
||||
t.test('should create new object when children change', t => {
|
||||
t.plan(9);
|
||||
const expected = { children: [{ bar: true }, {}] };
|
||||
const actual = traverseMapUi(expected, node => ({ ...node, foo: true }));
|
||||
t.notEqual(actual, expected);
|
||||
t.notEqual(actual.children, expected.children);
|
||||
t.notEqual(actual.children[0], expected.children[0]);
|
||||
t.notEqual(actual.children[1], expected.children[1]);
|
||||
t.equal(actual.children[0].bar, expected.children[0].bar);
|
||||
t.notOk(expected.children[0].foo);
|
||||
t.notOk(expected.children[1].foo);
|
||||
t.true(actual.children[0].foo);
|
||||
t.true(actual.children[1].foo);
|
||||
});
|
||||
});
|
||||
t.test('getNode', t => {
|
||||
t.test('should return node', t => {
|
||||
t.plan(1);
|
||||
const expected = { name: 'foo' };
|
||||
const tree = { children: [{ name: 'notfoo' }, expected ] };
|
||||
const actual = getNode(tree, 'foo');
|
||||
t.equal(expected, actual);
|
||||
});
|
||||
t.test('should returned undefined if not found', t => {
|
||||
t.plan(1);
|
||||
const tree = {
|
||||
children: [ { name: 'foo' }, { children: [ { name: 'bar' } ] } ]
|
||||
};
|
||||
const actual = getNode(tree, 'baz');
|
||||
t.notOk(actual);
|
||||
});
|
||||
});
|
||||
t.test('updateSingleNode', t => {
|
||||
t.test('should update single node', t => {
|
||||
const expected = { name: 'foo' };
|
||||
const untouched = { name: 'notFoo' };
|
||||
const actual = updateSingleNode(
|
||||
{ children: [ untouched, expected ] },
|
||||
'foo',
|
||||
node => ({ ...node, tag: true })
|
||||
);
|
||||
t.plan(4);
|
||||
t.ok(actual.children[1].tag);
|
||||
t.equal(actual.children[1].name, expected.name);
|
||||
t.notEqual(actual.children[1], expected);
|
||||
t.equal(actual.children[0], untouched);
|
||||
});
|
||||
});
|
||||
t.test('toggleThisPanel', t => {
|
||||
t.test('should update single node', t => {
|
||||
const expected = { name: 'foo', isOpen: true };
|
||||
const actual = toggleThisPanel(
|
||||
{ children: [ { name: 'foo', isOpen: false }] },
|
||||
'foo'
|
||||
);
|
||||
t.plan(1);
|
||||
t.deepLooseEqual(actual.children[0], expected);
|
||||
});
|
||||
});
|
||||
t.test('toggleAllPanels', t => {
|
||||
t.test('should add `isOpen: true` to every node without children', t => {
|
||||
const expected = {
|
||||
isOpen: true,
|
||||
children: [{
|
||||
isOpen: true,
|
||||
children: [{}, {}]
|
||||
}]
|
||||
};
|
||||
const actual = expandAllPanels({ children: [{ children: [{}, {}] }] });
|
||||
t.plan(1);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
});
|
||||
t.test('should add `isOpen: false` to every node without children', t => {
|
||||
const leaf = {};
|
||||
const expected = {
|
||||
isOpen: false,
|
||||
children: [{
|
||||
isOpen: false,
|
||||
children: [{}, leaf]
|
||||
}]
|
||||
};
|
||||
const actual = collapseAllPanels(
|
||||
{ isOpen: true, children: [{ children: [{}, leaf]}]},
|
||||
);
|
||||
t.plan(2);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
t.equal(actual.children[0].children[1], leaf);
|
||||
});
|
||||
});
|
||||
t.test('applyFilterToMap', t => {
|
||||
t.test('should not touch child that is already hidden', t => {
|
||||
t.plan(1);
|
||||
const expected = { name: 'bar', isHidden: true };
|
||||
const actual = applyFilterToMap(
|
||||
expected,
|
||||
/foo/
|
||||
);
|
||||
t.equal(actual, expected);
|
||||
});
|
||||
t.test('should update child that is hidden', t => {
|
||||
t.plan(1);
|
||||
const expected = { name: 'bar', isHidden: false };
|
||||
const input = { name: 'bar', isHidden: true };
|
||||
const actual = applyFilterToMap(input, /bar/);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
});
|
||||
t.test('should unhide child that matches filter regex', t => {
|
||||
t.plan(1);
|
||||
const expected = { name: 'foo' };
|
||||
const actual = applyFilterToMap({ name: 'foo' }, /foo/);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
});
|
||||
t.test('should hide child that does not match filter', t => {
|
||||
t.plan(1);
|
||||
const expected = { name: 'bar', isHidden: true };
|
||||
const actual = applyFilterToMap({ name: 'bar' }, /foo/);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
});
|
||||
t.test('should not touch node that is already hidden', t => {
|
||||
t.plan(1);
|
||||
const expected = {
|
||||
name: 'bar',
|
||||
isHidden: true,
|
||||
children: [
|
||||
{ name: 'baz', isHidden: true },
|
||||
{ name: 'baz2', isHidden: true }
|
||||
]
|
||||
};
|
||||
const actual = applyFilterToMap(expected, /foo/);
|
||||
t.equal(actual, expected);
|
||||
});
|
||||
t.test('should not touch node that is unhidden', t => {
|
||||
t.plan(1);
|
||||
const expected = {
|
||||
name: 'bar',
|
||||
isHidden: false,
|
||||
children: [
|
||||
{ name: 'baz', isHidden: true },
|
||||
{ name: 'foo', isHidden: false }
|
||||
]
|
||||
};
|
||||
const actual = applyFilterToMap(expected, /foo/);
|
||||
t.equal(actual, expected);
|
||||
});
|
||||
t.test('should hide node if all children are hidden', t => {
|
||||
t.plan(1);
|
||||
const input = {
|
||||
name: 'bar',
|
||||
isHidden: false,
|
||||
children: [
|
||||
{ name: 'baz' },
|
||||
{ name: 'baz2', isHidden: false }
|
||||
]
|
||||
};
|
||||
const expected = {
|
||||
name: 'bar',
|
||||
isHidden: true,
|
||||
children: [
|
||||
{ name: 'baz', isHidden: true },
|
||||
{ name: 'baz2', isHidden: true }
|
||||
]
|
||||
};
|
||||
const actual = applyFilterToMap(input, /foo/);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
});
|
||||
t.test('should unhide node some children unhidden', t => {
|
||||
t.plan(1);
|
||||
const input = {
|
||||
name: 'bar',
|
||||
isHidden: true,
|
||||
children: [
|
||||
{ name: 'baz', isHidden: true },
|
||||
{ name: 'foo', isHidden: false }
|
||||
]
|
||||
};
|
||||
const expected = {
|
||||
name: 'bar',
|
||||
isHidden: false,
|
||||
children: [
|
||||
{ name: 'baz', isHidden: true },
|
||||
{ name: 'foo', isHidden: false }
|
||||
]
|
||||
};
|
||||
const actual = applyFilterToMap(input, /foo/);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
t.test('unfilterMapUi', t => {
|
||||
t.test('should not touch node that is already hidden', t => {
|
||||
const expected = { isHidden: false };
|
||||
const actual = unfilterMapUi(expected);
|
||||
t.plan(1);
|
||||
t.equal(actual, expected);
|
||||
});
|
||||
t.test('should update node that is not hidden', t => {
|
||||
const expected = { isHidden: false };
|
||||
const input = { isHidden: true };
|
||||
const actual = unfilterMapUi(input);
|
||||
t.plan(2);
|
||||
t.notEqual(actual, input);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user