@ -7,16 +7,21 @@ import { Panel } from 'react-bootstrap';
|
|||||||
|
|
||||||
import Challenge from './Challenge.jsx';
|
import Challenge from './Challenge.jsx';
|
||||||
import { toggleThisPanel } from '../../redux/actions';
|
import { toggleThisPanel } from '../../redux/actions';
|
||||||
|
import {
|
||||||
|
makePanelOpenSelector,
|
||||||
|
makePanelHiddenSelector
|
||||||
|
} from '../../redux/selectors';
|
||||||
|
|
||||||
const dispatchActions = { toggleThisPanel };
|
const dispatchActions = { toggleThisPanel };
|
||||||
const mapStateToProps = createSelector(
|
const makeMapStateToProps = () => createSelector(
|
||||||
(_, props) => props.dashedName,
|
(_, props) => props.dashedName,
|
||||||
(state, props) => state.entities.block[props.dashedName],
|
(state, props) => state.entities.block[props.dashedName],
|
||||||
state => state.entities.challenge,
|
makePanelOpenSelector(),
|
||||||
(state, props) => state.challengesApp.mapUi[props.dashedName],
|
makePanelHiddenSelector(),
|
||||||
(dashedName, block, challengeMap, isOpen) => {
|
(dashedName, block, isOpen, isHidden) => {
|
||||||
return {
|
return {
|
||||||
isOpen,
|
isOpen,
|
||||||
|
isHidden,
|
||||||
dashedName,
|
dashedName,
|
||||||
title: block.title,
|
title: block.title,
|
||||||
time: block.time,
|
time: block.time,
|
||||||
@ -35,6 +40,7 @@ export class Block extends PureComponent {
|
|||||||
dashedName: PropTypes.string,
|
dashedName: PropTypes.string,
|
||||||
time: PropTypes.string,
|
time: PropTypes.string,
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
|
isHidden: PropTypes.bool,
|
||||||
challenges: PropTypes.array,
|
challenges: PropTypes.array,
|
||||||
toggleThisPanel: PropTypes.func
|
toggleThisPanel: PropTypes.func
|
||||||
};
|
};
|
||||||
@ -79,8 +85,12 @@ export class Block extends PureComponent {
|
|||||||
time,
|
time,
|
||||||
dashedName,
|
dashedName,
|
||||||
isOpen,
|
isOpen,
|
||||||
|
isHidden,
|
||||||
challenges
|
challenges
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
if (isHidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
bsClass='map-accordion-panel-nested'
|
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 debug from 'debug';
|
||||||
|
|
||||||
import { updateCurrentChallenge } from '../../redux/actions';
|
import { updateCurrentChallenge } from '../../redux/actions';
|
||||||
|
import { makePanelHiddenSelector } from '../../redux/selectors';
|
||||||
|
|
||||||
const bindableActions = { updateCurrentChallenge };
|
const bindableActions = { updateCurrentChallenge };
|
||||||
const mapStateToProps = createSelector(
|
const makeMapStateToProps = () => createSelector(
|
||||||
(_, props) => props.dashedName,
|
(_, props) => props.dashedName,
|
||||||
state => state.entities.challenge,
|
state => state.entities.challenge,
|
||||||
(dashedName, challengeMap) => {
|
makePanelHiddenSelector(),
|
||||||
|
(dashedName, challengeMap, isHidden) => {
|
||||||
const challenge = challengeMap[dashedName] || {};
|
const challenge = challengeMap[dashedName] || {};
|
||||||
return {
|
return {
|
||||||
dashedName,
|
dashedName,
|
||||||
challenge,
|
challenge,
|
||||||
|
isHidden,
|
||||||
title: challenge.title,
|
title: challenge.title,
|
||||||
block: challenge.block,
|
block: challenge.block,
|
||||||
isLocked: challenge.isLocked,
|
isLocked: challenge.isLocked,
|
||||||
@ -27,6 +30,7 @@ const mapStateToProps = createSelector(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export class Challenge extends PureComponent {
|
export class Challenge extends PureComponent {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
@ -39,6 +43,7 @@ export class Challenge extends PureComponent {
|
|||||||
isLocked: PropTypes.bool,
|
isLocked: PropTypes.bool,
|
||||||
isRequired: PropTypes.bool,
|
isRequired: PropTypes.bool,
|
||||||
isCompleted: PropTypes.bool,
|
isCompleted: PropTypes.bool,
|
||||||
|
isHidden: PropTypes.bool,
|
||||||
challenge: PropTypes.object,
|
challenge: PropTypes.object,
|
||||||
updateCurrentChallenge: PropTypes.func
|
updateCurrentChallenge: PropTypes.func
|
||||||
};
|
};
|
||||||
@ -95,9 +100,13 @@ export class Challenge extends PureComponent {
|
|||||||
isCompleted,
|
isCompleted,
|
||||||
isComingSoon,
|
isComingSoon,
|
||||||
isDev,
|
isDev,
|
||||||
|
isHidden,
|
||||||
challenge,
|
challenge,
|
||||||
updateCurrentChallenge
|
updateCurrentChallenge
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
if (isHidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const challengeClassName = classnames({
|
const challengeClassName = classnames({
|
||||||
'text-primary': true,
|
'text-primary': true,
|
||||||
'padded-ionic-icon': 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) {
|
constructor(...props) {
|
||||||
super(...props);
|
super(...props);
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||||
|
this.handleClearButton = this.handleClearButton.bind(this);
|
||||||
}
|
}
|
||||||
static displayName = 'MapHeader';
|
static displayName = 'MapHeader';
|
||||||
static propTypes = {
|
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) {
|
if (!filter) {
|
||||||
return searchIcon;
|
return searchIcon;
|
||||||
}
|
}
|
||||||
return <span onClick={ clearFilter }>{ clearIcon }</span>;
|
return <span onClick={this.handleClearButton }>{ clearIcon }</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
filter,
|
filter,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
clearFilter,
|
|
||||||
collapseAll,
|
collapseAll,
|
||||||
expandAll,
|
expandAll,
|
||||||
isAllCollapsed
|
isAllCollapsed
|
||||||
@ -100,7 +105,7 @@ export class Header extends PureComponent {
|
|||||||
value={ filter }
|
value={ filter }
|
||||||
/>
|
/>
|
||||||
<InputGroup.Addon>
|
<InputGroup.Addon>
|
||||||
{ this.renderSearchAddon(filter, clearFilter) }
|
{ this.renderSearchAddon(filter) }
|
||||||
</InputGroup.Addon>
|
</InputGroup.Addon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -7,20 +7,35 @@ import { Panel } from 'react-bootstrap';
|
|||||||
|
|
||||||
import Block from './Block.jsx';
|
import Block from './Block.jsx';
|
||||||
import { toggleThisPanel } from '../../redux/actions';
|
import { toggleThisPanel } from '../../redux/actions';
|
||||||
|
import {
|
||||||
|
makePanelOpenSelector,
|
||||||
|
makePanelHiddenSelector
|
||||||
|
} from '../../redux/selectors';
|
||||||
|
|
||||||
const dispatchActions = { toggleThisPanel };
|
const dispatchActions = { toggleThisPanel };
|
||||||
const mapStateToProps = createSelector(
|
// make selectors unique to each component
|
||||||
(_, props) => props.dashedName,
|
// see
|
||||||
(state, props) => state.entities.superBlock[props.dashedName],
|
// reactjs/reselect
|
||||||
(state, props) => state.challengesApp.mapUi[props.dashedName],
|
// sharing-selectors-with-props-across-multiple-components
|
||||||
(dashedName, superBlock, isOpen) => ({
|
const makeMapStateToProps = () => {
|
||||||
isOpen,
|
const panelOpenSelector = makePanelOpenSelector();
|
||||||
dashedName,
|
const panelHiddenSelector = makePanelHiddenSelector();
|
||||||
title: superBlock.title,
|
return createSelector(
|
||||||
blocks: superBlock.blocks,
|
(_, props) => props.dashedName,
|
||||||
message: superBlock.message
|
(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 {
|
export class SuperBlock extends PureComponent {
|
||||||
constructor(...props) {
|
constructor(...props) {
|
||||||
super(...props);
|
super(...props);
|
||||||
@ -32,6 +47,7 @@ export class SuperBlock extends PureComponent {
|
|||||||
dashedName: PropTypes.string,
|
dashedName: PropTypes.string,
|
||||||
blocks: PropTypes.array,
|
blocks: PropTypes.array,
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
|
isHidden: PropTypes.bool,
|
||||||
message: PropTypes.string,
|
message: PropTypes.string,
|
||||||
toggleThisPanl: PropTypes.func
|
toggleThisPanl: PropTypes.func
|
||||||
};
|
};
|
||||||
@ -82,8 +98,12 @@ export class SuperBlock extends PureComponent {
|
|||||||
dashedName,
|
dashedName,
|
||||||
blocks,
|
blocks,
|
||||||
message,
|
message,
|
||||||
isOpen
|
isOpen,
|
||||||
|
isHidden
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
if (isHidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
bsClass='map-accordion-panel'
|
bsClass='map-accordion-panel'
|
||||||
@ -107,6 +127,6 @@ export class SuperBlock extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default connect(
|
||||||
mapStateToProps,
|
makeMapStateToProps,
|
||||||
dispatchActions
|
dispatchActions
|
||||||
)(SuperBlock);
|
)(SuperBlock);
|
||||||
|
@ -43,27 +43,7 @@ export const updateFilter = createAction(
|
|||||||
e => e.target.value
|
e => e.target.value
|
||||||
);
|
);
|
||||||
|
|
||||||
function createMapKey(map, key) {
|
export const initMap = createAction(types.initMap);
|
||||||
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 toggleThisPanel = createAction(types.toggleThisPanel);
|
export const toggleThisPanel = createAction(types.toggleThisPanel);
|
||||||
export const collapseAll = createAction(types.collapseAll);
|
export const collapseAll = createAction(types.collapseAll);
|
||||||
export const expandAll = createAction(types.expandAll);
|
export const expandAll = createAction(types.expandAll);
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
updateCurrentChallenge,
|
updateCurrentChallenge,
|
||||||
initMap
|
initMap
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
import { createMapUi } from '../utils';
|
||||||
import {
|
import {
|
||||||
delayedRedirect,
|
delayedRedirect,
|
||||||
createErrorObservable
|
createErrorObservable
|
||||||
@ -72,7 +73,7 @@ export default function fetchChallengesSaga(action$, getState, { services }) {
|
|||||||
createNameIdMap(entities),
|
createNameIdMap(entities),
|
||||||
result
|
result
|
||||||
),
|
),
|
||||||
initMap(entities, result),
|
initMap(createMapUi(entities, result)),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(createErrorObservable);
|
.catch(createErrorObservable);
|
||||||
|
@ -4,6 +4,7 @@ import nextChallengeSaga from './next-challenge-saga';
|
|||||||
import answerSaga from './answer-saga';
|
import answerSaga from './answer-saga';
|
||||||
import resetChallengeSaga from './reset-challenge-saga';
|
import resetChallengeSaga from './reset-challenge-saga';
|
||||||
import bugSaga from './bug-saga';
|
import bugSaga from './bug-saga';
|
||||||
|
import mapUiSaga from './map-ui-saga';
|
||||||
|
|
||||||
export * as actions from './actions';
|
export * as actions from './actions';
|
||||||
export reducer from './reducer';
|
export reducer from './reducer';
|
||||||
@ -17,5 +18,6 @@ export const sagas = [
|
|||||||
nextChallengeSaga,
|
nextChallengeSaga,
|
||||||
answerSaga,
|
answerSaga,
|
||||||
resetChallengeSaga,
|
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,
|
buildSeed,
|
||||||
createTests,
|
createTests,
|
||||||
getPreFile,
|
getPreFile,
|
||||||
getFileKey
|
getFileKey,
|
||||||
|
toggleThisPanel,
|
||||||
|
collapseAllPanels,
|
||||||
|
expandAllPanels
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const initialUiState = {
|
const initialUiState = {
|
||||||
@ -180,7 +183,7 @@ const mainReducer = handleActions(
|
|||||||
mouse: [ 0, 0 ]
|
mouse: [ 0, 0 ]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
[types.videoCompleted]: (state, { payload: userAnswer } ) => ({
|
[types.videoCompleted]: (state, { payload: userAnswer }) => ({
|
||||||
...state,
|
...state,
|
||||||
isCorrect: true,
|
isCorrect: true,
|
||||||
isPressed: false,
|
isPressed: false,
|
||||||
@ -238,35 +241,37 @@ const filesReducer = handleActions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// {
|
// {
|
||||||
// // show
|
// children: [...{
|
||||||
// [(super)BlockName]: { open: Boolean }
|
// name: (superBlock: String),
|
||||||
// // do not show
|
// isOpen: Boolean,
|
||||||
// [(super)BlockName]: null
|
// isHidden: Boolean,
|
||||||
|
// children: [...{
|
||||||
|
// name: (blockName: String),
|
||||||
|
// isOpen: Boolean,
|
||||||
|
// isHidden: Boolean,
|
||||||
|
// children: [...{
|
||||||
|
// name: (challengeName: String),
|
||||||
|
// isHidden: Boolean
|
||||||
|
// }]
|
||||||
|
// }]
|
||||||
|
// }
|
||||||
// }
|
// }
|
||||||
const mapReducer = handleActions(
|
const mapReducer = handleActions(
|
||||||
{
|
{
|
||||||
[types.initMap]: (state, { payload }) => ({
|
[types.initMap]: (state, { payload }) => payload,
|
||||||
...state,
|
[types.toggleThisPanel]: (state, { payload: name }) => {
|
||||||
...payload
|
return toggleThisPanel(state, name);
|
||||||
}),
|
},
|
||||||
[types.toggleThisPanel]: (state, { payload }) => ({
|
[types.collapseAll]: state => {
|
||||||
...state,
|
const newState = collapseAllPanels(state);
|
||||||
[payload]: !state[payload]
|
newState.isAllCollapsed = true;
|
||||||
}),
|
return newState;
|
||||||
[types.collapseAll]: state => ({
|
},
|
||||||
...Object.keys(state).reduce((newState, key) => {
|
[types.expandAll]: state => {
|
||||||
newState[key] = false;
|
const newState = expandAllPanels(state);
|
||||||
return newState;
|
newState.isAllCollapsed = false;
|
||||||
}, {}),
|
return newState;
|
||||||
isAllCollapsed: true
|
}
|
||||||
}),
|
|
||||||
[types.expandAll]: state => ({
|
|
||||||
...Object.keys(state).reduce((newState, key) => {
|
|
||||||
newState[key] = true;
|
|
||||||
return newState;
|
|
||||||
}, {}),
|
|
||||||
isAllCollapsed: false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
initialState.mapUi
|
initialState.mapUi
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import * as challengeTypes from '../../../utils/challengeTypes';
|
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import * as challengeTypes from '../../../utils/challengeTypes';
|
||||||
|
import { getNode } from '../utils';
|
||||||
|
|
||||||
const viewTypes = {
|
const viewTypes = {
|
||||||
[ challengeTypes.html]: 'classic',
|
[ 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];
|
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 test from 'tape';
|
||||||
|
import sinon from 'sinon';
|
||||||
import {
|
import {
|
||||||
getNextChallenge,
|
getNextChallenge,
|
||||||
getFirstChallengeOfNextBlock,
|
getFirstChallengeOfNextBlock,
|
||||||
getFirstChallengeOfNextSuperBlock
|
getFirstChallengeOfNextSuperBlock,
|
||||||
|
createMapUi,
|
||||||
|
traverseMapUi,
|
||||||
|
getNode,
|
||||||
|
updateSingleNode,
|
||||||
|
toggleThisPanel,
|
||||||
|
expandAllPanels,
|
||||||
|
collapseAllPanels,
|
||||||
|
applyFilterToMap,
|
||||||
|
unfilterMapUi
|
||||||
} from './utils.js';
|
} 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