From 0995e3bba6af05bcedb5e94f317c3853ad8ea6b2 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 14 Jul 2016 17:13:48 -0700 Subject: [PATCH] Feature(map): Filter challenges on Map closes #9346 --- .../challenges/components/map/Block.jsx | 20 +- .../challenges/components/map/Challenge.jsx | 15 +- .../challenges/components/map/Header.jsx | 13 +- .../challenges/components/map/Super-Block.jsx | 48 ++- common/app/routes/challenges/redux/actions.js | 22 +- .../challenges/redux/fetch-challenges-saga.js | 3 +- common/app/routes/challenges/redux/index.js | 4 +- .../routes/challenges/redux/map-ui-saga.js | 34 ++ common/app/routes/challenges/redux/reducer.js | 61 ++-- .../app/routes/challenges/redux/selectors.js | 21 +- common/app/routes/challenges/utils.js | 221 ++++++++++++ common/app/routes/challenges/utils.test.js | 325 +++++++++++++++++- 12 files changed, 708 insertions(+), 79 deletions(-) create mode 100644 common/app/routes/challenges/redux/map-ui-saga.js diff --git a/common/app/routes/challenges/components/map/Block.jsx b/common/app/routes/challenges/components/map/Block.jsx index 6d28040850..57f6ea2d50 100644 --- a/common/app/routes/challenges/components/map/Block.jsx +++ b/common/app/routes/challenges/components/map/Block.jsx @@ -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 ( 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); diff --git a/common/app/routes/challenges/components/map/Header.jsx b/common/app/routes/challenges/components/map/Header.jsx index ab8b4bbe63..7b5d83b85a 100644 --- a/common/app/routes/challenges/components/map/Header.jsx +++ b/common/app/routes/challenges/components/map/Header.jsx @@ -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 { clearIcon }; + return { clearIcon }; } render() { const { filter, updateFilter, - clearFilter, collapseAll, expandAll, isAllCollapsed @@ -100,7 +105,7 @@ export class Header extends PureComponent { value={ filter } /> - { this.renderSearchAddon(filter, clearFilter) } + { this.renderSearchAddon(filter) } diff --git a/common/app/routes/challenges/components/map/Super-Block.jsx b/common/app/routes/challenges/components/map/Super-Block.jsx index 220b7b26b5..1edc4a3ba4 100644 --- a/common/app/routes/challenges/components/map/Super-Block.jsx +++ b/common/app/routes/challenges/components/map/Super-Block.jsx @@ -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 ( 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); diff --git a/common/app/routes/challenges/redux/fetch-challenges-saga.js b/common/app/routes/challenges/redux/fetch-challenges-saga.js index 30ae750610..a50edcb7b9 100644 --- a/common/app/routes/challenges/redux/fetch-challenges-saga.js +++ b/common/app/routes/challenges/redux/fetch-challenges-saga.js @@ -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); diff --git a/common/app/routes/challenges/redux/index.js b/common/app/routes/challenges/redux/index.js index b5ff33b4b9..369d42f092 100644 --- a/common/app/routes/challenges/redux/index.js +++ b/common/app/routes/challenges/redux/index.js @@ -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 ]; diff --git a/common/app/routes/challenges/redux/map-ui-saga.js b/common/app/routes/challenges/redux/map-ui-saga.js new file mode 100644 index 0000000000..9d3cff1c61 --- /dev/null +++ b/common/app/routes/challenges/redux/map-ui-saga.js @@ -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); + }); +} diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index 3b8488e8ec..50cac1e333 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -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 ); diff --git a/common/app/routes/challenges/redux/selectors.js b/common/app/routes/challenges/redux/selectors.js index 2318d3eb1f..dfec02129e 100644 --- a/common/app/routes/challenges/redux/selectors.js +++ b/common/app/routes/challenges/redux/selectors.js @@ -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; + } +); diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js index 6ee92636fe..af21c8819f 100644 --- a/common/app/routes/challenges/utils.js +++ b/common/app/routes/challenges/utils.js @@ -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 + }; + } + ); +} diff --git a/common/app/routes/challenges/utils.test.js b/common/app/routes/challenges/utils.test.js index 7a472e89a5..d802692b45 100644 --- a/common/app/routes/challenges/utils.test.js +++ b/common/app/routes/challenges/utils.test.js @@ -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); + }); + }); }); +