diff --git a/__tests__/fixtures/version-2/structures.json b/__tests__/fixtures/version-2/structures.json new file mode 100644 index 0000000000..bcf680dc57 --- /dev/null +++ b/__tests__/fixtures/version-2/structures.json @@ -0,0 +1,254 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@type": "sc:Manifest", + "@id": "http://foo.test/1/manifest", + "label": "Manifest to be used for SidebarIndexTableOfContents.test.js", + "sequences" : [ + { + "@type": "sc:Sequence", + "canvases": [ + { + "@id": "http://foo.test/1/canvas/c1", + "@type": "sc:Canvas", + "label": "Canvas: 1" + }, + { + "@id": "http://foo.test/1/canvas/c2", + "@type": "sc:Canvas", + "label": "Canvas: 2" + }, + { + "@id": "http://foo.test/1/canvas/c3", + "@type": "sc:Canvas", + "label": "Canvas: 3" + }, + { + "@id": "http://foo.test/1/canvas/c4", + "@type": "sc:Canvas", + "label": "Canvas: 4" + }, + { + "@id": "http://foo.test/1/canvas/c5", + "@type": "sc:Canvas", + "label": "Canvas: 5" + }, + { + "@id": "http://foo.test/1/canvas/c6", + "@type": "sc:Canvas", + "label": "Canvas: 6" + }, + { + "@id": "http://foo.test/1/canvas/c7", + "@type": "sc:Canvas", + "label": "Canvas: 7" + }, + { + "@id": "http://foo.test/1/canvas/c8", + "@type": "sc:Canvas", + "label": "Canvas: 8" + }, + { + "@id": "http://foo.test/1/canvas/c9", + "@type": "sc:Canvas", + "label": "Canvas: 9" + }, + { + "@id": "http://foo.test/1/canvas/c10", + "@type": "sc:Canvas", + "label": "Canvas: 9" + }, + { + "@id": "http://foo.test/1/canvas/c11", + "@type": "sc:Canvas", + "label": "Canvas: 9" + }, + { + "@id": "http://foo.test/1/canvas/c12", + "@type": "sc:Canvas", + "label": "Canvas: 9" + } + ] + } + ], + "structures": [ + { + "@id": "http://foo.test/1/range/root", + "@type": "sc:Range", + "viewingHint": "top", + "ranges": [ + "http://foo.test/1/range/0-0", + "http://foo.test/1/range/0-1", + "http://foo.test/1/range/0-2" + ], + "canvases": [] + }, + { + "@id": "http://foo.test/1/range/0-0", + "@type": "sc:Range", + "ranges": [ + "http://foo.test/1/range/0-0-0", + "http://foo.test/1/range/0-0-1", + "http://foo.test/1/range/0-0-2" + ], + "canvases": [ + "http://foo.test/1/canvas/c1", + "http://foo.test/1/canvas/c2", + "http://foo.test/1/canvas/c3", + "http://foo.test/1/canvas/c4" + ] + }, + { + "@id": "http://foo.test/1/range/0-0-0", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c1", + "http://foo.test/1/canvas/c2" + ] + }, + { + "@id": "http://foo.test/1/range/0-0-1", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c2", + "http://foo.test/1/canvas/c3" + ] + }, + { + "@id": "http://foo.test/1/range/0-0-2", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c4" + ] + }, + { + "@id": "http://foo.test/1/range/0-1", + "@type": "sc:Range", + "ranges": [ + "http://foo.test/1/range/0-1-0", + "http://foo.test/1/range/0-1-1", + "http://foo.test/1/range/0-1-2" + ], + "canvases": [] + }, + { + "@id": "http://foo.test/1/range/0-1-0", + "@type": "sc:Range", + "ranges": [], + "canvases": [] + }, + { + "@id": "http://foo.test/1/range/0-1-1", + "@type": "sc:Range", + "ranges": [ + "http://foo.test/1/range/0-1-1-0", + "http://foo.test/1/range/0-1-1-1" + ], + "canvases": [ + "http://foo.test/1/canvas/c6" + ] + }, + { + "@id": "http://foo.test/1/range/0-1-1-0", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c5", + "http://foo.test/1/canvas/c6" + ] + }, + { + "@id": "http://foo.test/1/range/0-1-1-1", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c6", + "http://foo.test/1/canvas/c7", + "http://foo.test/1/canvas/c8" + ] + }, + { + "@id": "http://foo.test/1/range/0-1-2", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c8" + ] + }, + { + "@id": "http://foo.test/1/range/0-1-2-0", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c8", + "http://foo.test/1/canvas/c9" + ] + }, + { + "@id": "http://foo.test/1/range/0-2", + "@type": "sc:Range", + "ranges": [ + "http://foo.test/1/range/0-2-0", + "http://foo.test/1/range/0-2-1", + "http://foo.test/1/range/0-2-2" + ], + "canvases": [] + }, + { + "@id": "http://foo.test/1/range/0-2-0", + "@type": "sc:Range", + "ranges": [], + "canvases": [] + }, + { + "@id": "http://foo.test/1/range/0-2-1", + "@type": "sc:Range", + "ranges": [ + "http://foo.test/1/range/0-2-1-0", + "http://foo.test/1/range/0-2-1-1" + ], + "canvases": [ + "http://foo.test/1/canvas/c10" + ] + }, + { + "@id": "http://foo.test/1/range/0-2-1-0", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c9" + ] + }, + { + "@id": "http://foo.test/1/range/0-2-1-1", + "@type": "sc:Range", + "ranges": [], + "canvases": [] + }, + { + "@id": "http://foo.test/1/range/0-2-2", + "@type": "sc:Range", + "ranges": [ + "http://foo.test/1/range/0-2-2-0", + "http://foo.test/1/range/0-2-2-1" + ], + "canvases": [] + }, + { + "@id": "http://foo.test/1/range/0-2-2-0", + "@type": "sc:Range", + "ranges": [], + "canvases": [] + }, + { + "@id": "http://foo.test/1/range/0-2-2-1", + "@type": "sc:Range", + "ranges": [], + "canvases": [ + "http://foo.test/1/canvas/c9" + ] + } + ] +} \ No newline at end of file diff --git a/__tests__/integration/mirador/toc.html b/__tests__/integration/mirador/toc.html index 875765b48b..04330f477f 100644 --- a/__tests__/integration/mirador/toc.html +++ b/__tests__/integration/mirador/toc.html @@ -22,6 +22,13 @@ defaultSideBarPanel: 'canvas' }, manifests: { + 'https://iiif.bodleian.ox.ac.uk/iiif/manifest/390fd0e8-9eae-475d-9564-ed916ab9035c.json': { provider: 'Bodleian Libraries' }, + 'http://dams.llgc.org.uk/iiif/newspaper/issue/3100021/manifest.json': { provider: 'The National Library of Wales' }, + 'https://iiif.lib.harvard.edu/manifests/drs:5981093': { provider: 'Houghton Library (Harvard University)' }, + 'https://cudl.lib.cam.ac.uk/iiif/MS-ADD-03965' : {}, + 'https://iiif.bodleian.ox.ac.uk/iiif/manifest/ca3dc326-4a7b-479f-a754-5aed9d2f2cb4.json': {}, + // 'https://gist.githubusercontent.com/jeffreycwitt/90b33c1c4e119e7a48b7a66ea41a48c1/raw/522b132409d6c67a78f8f26b0ceb7346a52cfe62/test-manifest-with-complicated-toc.json': {}, + // 'https://gist.githubusercontent.com/jeffreycwitt/1a75fdb4a97e1c2a98bd35797aad263d/raw/859104cb6cd7bd99f3be668f064066f4b3ba2b29/manifest-with-three-level-deep-toc.json': {}, } }); diff --git a/__tests__/src/actions/companionWindow.test.js b/__tests__/src/actions/companionWindow.test.js index 23633844fa..916fa42030 100644 --- a/__tests__/src/actions/companionWindow.test.js +++ b/__tests__/src/actions/companionWindow.test.js @@ -1,6 +1,11 @@ import * as actions from '../../../src/state/actions'; import ActionTypes from '../../../src/state/actions/action-types'; +jest.mock('../../../src/state/selectors', () => ({ + getManuallyExpandedNodeIds: (state, args, expanded) => (expanded ? ['openVisible', 'open'] : ['closedVisible', 'closed']), + getVisibleNodeIds: (state, args) => ['openVisible', 'closedVisible', 'visible'], +})); + describe('companionWindow actions', () => { describe('addCompanionWindow', () => { it('should return correct action object', () => { @@ -96,4 +101,68 @@ describe('companionWindow actions', () => { expect(action.windowId).toBe('window'); }); }); + + describe('toggleNode', () => { + let mockDispatch; + let mockGetState; + + beforeEach(() => { + mockDispatch = jest.fn(() => ({})); + mockGetState = jest.fn(() => ({})); + }); + + it('returns a collapsing action for visible nodes that are not present in the state', () => { + const thunk = actions.toggleNode('window1', 'cw1', 'visible'); + thunk(mockDispatch, mockGetState); + + const action = mockDispatch.mock.calls[0][0]; + expect(action.id).toBe('cw1'); + expect(action.windowId).toBe('window1'); + expect(action.type).toBe(ActionTypes.TOGGLE_TOC_NODE); + expect(action.payload).toMatchObject({ visible: { expanded: false } }); + }); + + it('returns an expanding action for non visible nodes that are not present in the state', () => { + const thunk = actions.toggleNode('window1', 'cw1', 'foo'); + thunk(mockDispatch, mockGetState); + + const action = mockDispatch.mock.calls[0][0]; + expect(action.id).toBe('cw1'); + expect(action.windowId).toBe('window1'); + expect(action.type).toBe(ActionTypes.TOGGLE_TOC_NODE); + expect(action.payload).toMatchObject({ foo: { expanded: true } }); + }); + + it('returns a correct action any node that is present in the state', () => { + const openVisibleThunk = actions.toggleNode('window1', 'cw1', 'openVisible'); + openVisibleThunk(mockDispatch, mockGetState); + + const openThunk = actions.toggleNode('window1', 'cw1', 'open'); + openThunk(mockDispatch, mockGetState); + + const closedVisibleThunk = actions.toggleNode('window1', 'cw1', 'closedVisible'); + closedVisibleThunk(mockDispatch, mockGetState); + + const closedThunk = actions.toggleNode('window1', 'cw1', 'closed'); + closedThunk(mockDispatch, mockGetState); + + const actionForOpenVisible = mockDispatch.mock.calls[0][0]; + expect(actionForOpenVisible.id).toBe('cw1'); + expect(actionForOpenVisible.windowId).toBe('window1'); + expect(actionForOpenVisible.type).toBe(ActionTypes.TOGGLE_TOC_NODE); + expect(actionForOpenVisible.payload).toMatchObject({ openVisible: { expanded: false } }); + + const actionForOpen = mockDispatch.mock.calls[1][0]; + expect(actionForOpen.type).toBe(ActionTypes.TOGGLE_TOC_NODE); + expect(actionForOpen.payload).toMatchObject({ open: { expanded: false } }); + + const actionForClosedVisible = mockDispatch.mock.calls[2][0]; + expect(actionForClosedVisible.type).toBe(ActionTypes.TOGGLE_TOC_NODE); + expect(actionForClosedVisible.payload).toMatchObject({ closedVisible: { expanded: true } }); + + const actionForClosed = mockDispatch.mock.calls[3][0]; + expect(actionForClosed.type).toBe(ActionTypes.TOGGLE_TOC_NODE); + expect(actionForClosed.payload).toMatchObject({ closed: { expanded: true } }); + }); + }); }); diff --git a/__tests__/src/components/SidebarIndexTableOfContents.test.js b/__tests__/src/components/SidebarIndexTableOfContents.test.js new file mode 100644 index 0000000000..0f2b565897 --- /dev/null +++ b/__tests__/src/components/SidebarIndexTableOfContents.test.js @@ -0,0 +1,220 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import manifesto from 'manifesto.js'; +import TreeItem from '@material-ui/lab/TreeItem'; +import TreeView from '@material-ui/lab/TreeView'; +import { SidebarIndexTableOfContents } from '../../../src/components/SidebarIndexTableOfContents'; +import manifestJson from '../../fixtures/version-2/structures.json'; + +/** + * Create shallow enzyme wrapper for SidebarIndexTableOfContents component + * @param {*} props + */ +function createWrapper(props) { + const manifest = manifesto.create(manifestJson); + return shallow( + , + ); +} + +/** + * Create necessary props to simulate keydown event with specific key + */ +function createKeydownProps(key) { + return [ + 'keydown', + { + key, + }, + ]; +} + +describe('SidebarIndexTableOfContents', () => { + let toggleNode; + let setCanvas; + + beforeEach(() => { + toggleNode = jest.fn(); + setCanvas = jest.fn(); + }); + + it('does not render a TreeView if the tree structure is missing', () => { + const wrapper = createWrapper({ + treeStructure: undefined, + }); + expect(wrapper.children().length).toBe(0); + }); + + it('renders a tree item for every node', () => { + const structuresWrapper = createWrapper({}); + expect(structuresWrapper.find(TreeItem)).toHaveLength(18); + const simpleTreeWrapper = createWrapper({ + treeStructure: { + nodes: [ + { + id: '0', + nodes: [ + { + id: '0-0', + nodes: [], + }, + { + id: '0-1', + nodes: [], + }, + ], + }, + ], + }, + }); + expect(simpleTreeWrapper.find(TreeItem)).toHaveLength(3); + }); + + it('accepts missing nodes property for tress structure and tree nodes', () => { + const noNodesWrapper = createWrapper({ + treeStructure: { nodes: undefined }, + }); + expect(noNodesWrapper.find(TreeItem)).toHaveLength(0); + const noChildNodesWrapper = createWrapper({ + treeStructure: { + nodes: [{ id: '0' }], + }, + }); + expect(noChildNodesWrapper.find(TreeItem)).toHaveLength(1); + }); + + it('toggles branch nodes on click, but not leaf nodes', () => { + const wrapper = createWrapper({ setCanvas, toggleNode }); + const treeView = wrapper.children(TreeView).at(0); + + const node0 = treeView.childAt(0); + expect(node0.prop('nodeId')).toBe('0-0'); + node0.simulate('click'); + node0.simulate('click'); + expect(toggleNode).toHaveBeenCalledTimes(2); + + const node00 = node0.children().at(0); + expect(node00.prop('nodeId')).toBe('0-0-0'); + node00.simulate('click'); + node00.simulate('click'); + expect(toggleNode).toHaveBeenCalledTimes(2); + + const node1 = treeView.childAt(1); + expect(node1.prop('nodeId')).toBe('0-1'); + node1.simulate('click'); + expect(toggleNode).toHaveBeenCalledTimes(3); + }); + + it('collapses branch nodes (i.e. toggles open branch nodes) with left arrow key', () => { + const wrapper = createWrapper({ + expandedNodeIds: ['0-0'], + setCanvas, + toggleNode, + }); + const treeView = wrapper.children(TreeView).at(0); + + const node0 = treeView.childAt(0); + expect(node0.prop('nodeId')).toBe('0-0'); + node0.simulate(...createKeydownProps('ArrowLeft')); + expect(toggleNode).toHaveBeenCalledTimes(1); + + const node00 = node0.children().at(0); + expect(node00.prop('nodeId')).toBe('0-0-0'); + const node1 = treeView.childAt(1); + expect(node1.prop('nodeId')).toBe('0-1'); + + node00.simulate(...createKeydownProps('ArrowLeft')); + node1.simulate(...createKeydownProps('ArrowLeft')); + expect(toggleNode).toHaveBeenCalledTimes(1); + }); + + it('expands branch nodes (i.e. toggles closed branch nodes) with right arrow key', () => { + const wrapper = createWrapper({ + expandedNodeIds: ['0-0'], + setCanvas, + toggleNode, + }); + const treeView = wrapper.children(TreeView).at(0); + const node0 = treeView.childAt(0); + expect(node0.prop('nodeId')).toBe('0-0'); + const node00 = node0.children().at(0); + expect(node00.prop('nodeId')).toBe('0-0-0'); + + node0.simulate(...createKeydownProps('ArrowRight')); + node00.simulate(...createKeydownProps('ArrowRight')); + expect(toggleNode).toHaveBeenCalledTimes(0); + + const node1 = treeView.childAt(1); + expect(node1.prop('nodeId')).toBe('0-1'); + node1.simulate(...createKeydownProps('ArrowRight')); + expect(toggleNode).toHaveBeenCalledTimes(1); + }); + + it('toggles branch nodes (but not leaf nodes) with Space or Enter key', () => { + const wrapper = createWrapper({ setCanvas, toggleNode }); + const treeView = wrapper.children(TreeView).at(0); + const node0 = treeView.childAt(0); + node0.simulate(...createKeydownProps('Enter')); + expect(toggleNode).toHaveBeenCalledTimes(1); + node0.simulate(...createKeydownProps(' ')); + expect(toggleNode).toHaveBeenCalledTimes(2); + node0.simulate(...createKeydownProps('Spacebar')); + expect(toggleNode).toHaveBeenCalledTimes(3); + node0.simulate(...createKeydownProps('Tab')); + node0.children().at(0).simulate(...createKeydownProps('Enter')); + node0.children().at(0).simulate(...createKeydownProps(' ')); + expect(toggleNode).toHaveBeenCalledTimes(3); + treeView.childAt(1).simulate(...createKeydownProps('Enter')); + treeView.childAt(1).simulate(...createKeydownProps(' ')); + expect(toggleNode).toHaveBeenCalledTimes(5); + }); + + it('calls setCanvas only on click for ranges with canvases', () => { + const wrapper = createWrapper({ setCanvas, toggleNode }); + const treeView = wrapper.children(TreeView).at(0); + const node0 = treeView.childAt(0); + expect(node0.prop('nodeId')).toBe('0-0'); + node0.simulate('click'); + expect(setCanvas).toHaveBeenCalledTimes(1); + node0.childAt(0).simulate('click'); + expect(setCanvas).toHaveBeenCalledTimes(2); + node0.childAt(1).simulate('click'); + expect(setCanvas).toHaveBeenCalledTimes(3); + node0.childAt(2).simulate('click'); + expect(setCanvas).toHaveBeenCalledTimes(4); + + const node1 = treeView.childAt(1); + expect(node1.prop('nodeId')).toBe('0-1'); + node1.simulate(...createKeydownProps('ArrowRight')); + expect(setCanvas).toHaveBeenCalledTimes(4); + }); + + it('does not select a canvas when opening a node with the right arrow key', () => { + const wrapper = createWrapper({ setCanvas, toggleNode }); + const treeView = wrapper.children(TreeView).at(0); + const node0 = treeView.childAt(0); + expect(node0.prop('nodeId')).toBe('0-0'); + node0.simulate(...createKeydownProps('ArrowRight')); + expect(setCanvas).toHaveBeenCalledTimes(0); + expect(toggleNode).toHaveBeenCalledTimes(1); + }); + + it('does not select a canvas when closing a node with the left arrow key', () => { + const wrapper = createWrapper({ expandedNodeIds: ['0-0'], setCanvas, toggleNode }); + const treeView = wrapper.children(TreeView).at(0); + const node0 = treeView.childAt(0); + expect(node0.prop('nodeId')).toBe('0-0'); + node0.simulate(...createKeydownProps('ArrowLeft')); + expect(setCanvas).toHaveBeenCalledTimes(0); + expect(toggleNode).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/src/components/WindowSideBarCanvasPanel.test.js b/__tests__/src/components/WindowSideBarCanvasPanel.test.js index 5f6cf795da..92a12bc2f5 100644 --- a/__tests__/src/components/WindowSideBarCanvasPanel.test.js +++ b/__tests__/src/components/WindowSideBarCanvasPanel.test.js @@ -25,6 +25,7 @@ function createWrapper(props) { config={{ canvasNavigation: { height: 100 } }} updateVariant={() => {}} selectedCanvases={[canvases[1]]} + variant="compact" {...props} />, ); diff --git a/__tests__/src/reducers/companionWindows.test.js b/__tests__/src/reducers/companionWindows.test.js index 1c7f2d8d61..0c7393d15c 100644 --- a/__tests__/src/reducers/companionWindows.test.js +++ b/__tests__/src/reducers/companionWindows.test.js @@ -109,6 +109,94 @@ describe('companionWindowsReducer', () => { expect(companionWindowsReducer(beforeState, action)).toEqual(expectedState); }); }); + + describe('TOGGLE_TOC_NODE', () => { + const actionOpen = { + id: 'cw123', + payload: { + '0-1': { + expanded: true, + }, + }, + type: ActionTypes.TOGGLE_TOC_NODE, + }; + const actionClose = { + id: 'cw123', + payload: { + '0-1': { + expanded: false, + }, + }, + type: ActionTypes.TOGGLE_TOC_NODE, + }; + + it('should add the id of a toggled node that do not exist in the state', () => { + const emptyBeforeState = { + cw123: {}, + cw456: {}, + }; + const expectedStateFromEmpty = { + cw123: { + tocNodes: { + '0-1': { expanded: true }, + }, + }, + cw456: {}, + }; + expect(companionWindowsReducer(emptyBeforeState, actionOpen)).toEqual(expectedStateFromEmpty); + + const beforeState = { + cw123: { + tocNodes: { + '0-0': { expanded: true }, + '0-2-0': { expanded: true }, + }, + }, + cw456: {}, + }; + const expectedStateAfterFilled = { + cw123: { + tocNodes: { + '0-0': { expanded: true }, + '0-1': { expanded: true }, + '0-2-0': { expanded: true }, + }, + }, + cw456: {}, + }; + expect(companionWindowsReducer(beforeState, actionOpen)).toEqual(expectedStateAfterFilled); + }); + + it('should update expanded value for existing nodeIds in the state', () => { + const stateWithTrue = { + cw123: { + tocNodes: { + '0-0': { expanded: true }, + '0-1': { expanded: true }, + '0-2-0': { expanded: true }, + }, + }, + cw456: {}, + }; + + const stateWithFalse = { + cw123: { + tocNodes: { + '0-0': { expanded: true }, + '0-1': { expanded: false }, + '0-2-0': { expanded: true }, + }, + }, + cw456: {}, + }; + + expect(companionWindowsReducer(stateWithTrue, actionOpen)).toEqual(stateWithTrue); + expect(companionWindowsReducer(stateWithFalse, actionOpen)).toEqual(stateWithTrue); + expect(companionWindowsReducer(stateWithTrue, actionClose)).toEqual(stateWithFalse); + expect(companionWindowsReducer(stateWithFalse, actionClose)).toEqual(stateWithFalse); + }); + }); + it('should handle IMPORT_MIRADOR_STATE', () => { expect(companionWindowsReducer({}, { state: { companionWindows: { new: 'stuff' } }, diff --git a/__tests__/src/selectors/ranges.test.js b/__tests__/src/selectors/ranges.test.js new file mode 100644 index 0000000000..cfe552b2f3 --- /dev/null +++ b/__tests__/src/selectors/ranges.test.js @@ -0,0 +1,132 @@ +import { setIn } from 'immutable'; +import manifestJson from '../../fixtures/version-2/structures.json'; +import { + getVisibleNodeIds, + getManuallyExpandedNodeIds, + getExpandedNodeIds, + getNodeIdToScrollTo, +} from '../../../src/state/selectors'; + +const state = { + companionWindows: { + cw123: {}, + cw456: {}, + }, + manifests: { + mID: { + id: 'mID', + json: manifestJson, + }, + }, + windows: { + w1: { + canvasId: 'http://foo.test/1/canvas/c6', + companionWindowIds: ['cw123', 'cw456'], + id: 'w1', + manifestId: 'mID', + view: 'book', + }, + }, +}; + +const expandedNodesState = setIn(state, ['companionWindows', 'cw123', 'tocNodes'], { + '0-0': { + expanded: false, + }, + '0-1': { + expanded: true, + }, + '0-1-1': { + expanded: true, + }, + '0-1-2': { + expanded: false, + }, +}); + +describe('getVisibleNodeIds', () => { + it('contains node ids for all ranges which contain currently visible canvases, and for their parents', () => { + const visibleNodeIds = getVisibleNodeIds(state, { windowId: 'w1' }); + expect(visibleNodeIds).toEqual(expect.arrayContaining([ + '0-1', + '0-1-1', + '0-1-1-0', + '0-1-1-1', + ])); + expect(visibleNodeIds.length).toBe(4); + }); +}); + +describe('getManuallyExpandedNodeIds', () => { + it('returns empty array if there are no manually opened or closed nodes', () => { + expect(getManuallyExpandedNodeIds(state, { companionWindowId: 'cw123', windowId: 'w1' }, true)).toEqual([]); + expect(getManuallyExpandedNodeIds(state, { companionWindowId: 'cw123', windowId: 'w1' }, false)).toEqual([]); + }); + + it('returns manually opened and closed node ids correctly', () => { + expect(getManuallyExpandedNodeIds(expandedNodesState, { companionWindowId: 'cw123' }, true)).toEqual(['0-1', '0-1-1']); + expect(getManuallyExpandedNodeIds(expandedNodesState, { companionWindowId: 'cw123' }, false)).toEqual(['0-0', '0-1-2']); + }); +}); + +describe('getExpandedNodeIds', () => { + it('returns manually expanded node ids and visible, non collapsed branch node ids', () => { + const canvas8BookViewState = setIn(expandedNodesState, ['windows', 'w1', 'canvasId'], 'http://foo.test/1/canvas/c8'); + const canvas8BookViewVisibleNodeIds = getExpandedNodeIds(canvas8BookViewState, { companionWindowId: 'cw123', windowId: 'w1' }); + expect(canvas8BookViewVisibleNodeIds).toEqual(expect.arrayContaining([ + '0-1', + '0-1-1', + '0-2', + '0-2-1', + '0-2-2', + ])); + expect(canvas8BookViewVisibleNodeIds.length).toBe(5); + + const canvas8SingleViewState = setIn(canvas8BookViewState, ['windows', 'w1', 'view'], 'single'); + const canvas8SingleViewVisibleNodeIds = getExpandedNodeIds(canvas8SingleViewState, { companionWindowId: 'cw123', windowId: 'w1' }); + expect(canvas8SingleViewVisibleNodeIds).toEqual(expect.arrayContaining([ + '0-1', + '0-1-1', + ])); + expect(canvas8SingleViewVisibleNodeIds.length).toBe(2); + }); + + it('returns a combination of manually opened and current canvas containing node ids', () => { + const canvas9State = setIn(expandedNodesState, ['windows', 'w1', 'canvasId'], 'http://foo.test/1/canvas/c9'); + const expandedNodeIds = getExpandedNodeIds(canvas9State, { companionWindowId: 'cw123', windowId: 'w1' }); + expect(expandedNodeIds).toEqual(expect.arrayContaining([ + '0-1', + '0-1-1', + '0-2', + '0-2-1', + '0-2-2', + ])); + expect(expandedNodeIds.length).toBe(5); + }); +}); + +describe('getNodeIdToScrollTo', () => { + it('returns first leaf node with visible canvas', () => { + expect(getNodeIdToScrollTo(state, { companionWindowId: 'cw123', windowId: 'w1' })).toBe('0-1-1-0'); + }); + + it('returns branch node with visible canvas if it is the deepest in the tree to contain a canvas', () => { + const canvas10State = setIn(expandedNodesState, ['windows', 'w1', 'canvasId'], 'http://foo.test/1/canvas/c10'); + expect(getNodeIdToScrollTo(canvas10State, { companionWindowId: 'cw123', windowId: 'w1' })).toBe('0-2-1'); + }); + + it('returns the deepest non hidden branch node if leaf node or its parent node are collapsed', () => { + const closedParentState1 = setIn(state, ['companionWindows', 'cw123', 'tocNodes'], { '0-1-1': { expanded: false } }); + expect(getNodeIdToScrollTo(closedParentState1, { companionWindowId: 'cw123', windowId: 'w1' })).toBe('0-1-1'); + const closedParentState2 = setIn(state, ['companionWindows', 'cw123', 'tocNodes'], { + '0-1': { expanded: false }, + '0-1-1': { expanded: false }, + }); + expect(getNodeIdToScrollTo(closedParentState2, { companionWindowId: 'cw123', windowId: 'w1' })).toBe('0-1'); + }); + + it('returns no node id if current canvas is not contained in any range', () => { + const rangeFreeCanvasState = setIn(expandedNodesState, ['windows', 'w1', 'canvasId'], 'http://foo.test/1/canvas/c12'); + expect(getNodeIdToScrollTo(rangeFreeCanvasState, { companionWindowId: 'cw123', windowId: 'w1' })).toBe(null); + }); +}); diff --git a/src/components/SidebarIndexTableOfContents.js b/src/components/SidebarIndexTableOfContents.js index 7eba105f4e..4e9e4691e6 100644 --- a/src/components/SidebarIndexTableOfContents.js +++ b/src/components/SidebarIndexTableOfContents.js @@ -3,32 +3,73 @@ import PropTypes from 'prop-types'; import TreeView from '@material-ui/lab/TreeView'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import VisibilityIcon from '@material-ui/icons/Visibility'; import TreeItem from '@material-ui/lab/TreeItem'; +import { ScrollTo } from './ScrollTo'; /** */ export class SidebarIndexTableOfContents extends Component { /** */ selectTreeItem(node) { - const { setCanvas, windowId } = this.props; - // Do not select if there are child nodes + const { setCanvas, toggleNode, windowId } = this.props; if (node.nodes.length > 0) { + toggleNode(node.id); + } + // Do not select if there are no canvases listed + if (!node.data.getCanvasIds() || node.data.getCanvasIds().length === 0) { return; } - const canvas = node.data.getCanvasIds()[0]; - setCanvas(windowId, canvas); + const target = node.data.getCanvasIds()[0]; + const canvasId = target.indexOf('#') === -1 ? target : target.substr(0, target.indexOf('#')); + setCanvas(windowId, canvasId); } /** */ - buildTreeItems(nodes) { + handleKeyPressed(event, node) { + const { expandedNodeIds, toggleNode } = this.props; + if (event.key === 'Enter' + || event.key === ' ' + || event.key === 'Spacebar') { + this.selectTreeItem(node); + } + if ((event.key === 'ArrowLeft' && expandedNodeIds.indexOf(node.id) !== -1) + || (event.key === 'ArrowRight' && expandedNodeIds.indexOf(node.id) === -1 && node.nodes.length > 0)) { + toggleNode(node.id); + } + } + + /** */ + buildTreeItems(nodes, visibleNodeIds, containerRef, nodeIdToScrollTo) { + if (!nodes) { + return null; + } return ( nodes.map(node => ( + <> + {visibleNodeIds.indexOf(node.id) !== -1 && } + {node.label} + + + )} onClick={() => this.selectTreeItem(node)} + onKeyDown={e => this.handleKeyPressed(e, node)} > - {node.nodes.length > 0 ? this.buildTreeItems(node.nodes) : null} + {node.nodes && node.nodes.length > 0 ? this.buildTreeItems( + node.nodes, + visibleNodeIds, + containerRef, + nodeIdToScrollTo, + ) : null} )) ); @@ -37,7 +78,7 @@ export class SidebarIndexTableOfContents extends Component { /** */ render() { const { - classes, treeStructure, + classes, treeStructure, visibleNodeIds, expandedNodeIds, containerRef, nodeIdToScrollTo, } = this.props; if (!treeStructure) { @@ -50,8 +91,10 @@ export class SidebarIndexTableOfContents extends Component { className={classes.root} defaultCollapseIcon={} defaultExpandIcon={} + defaultEndIcon={<>} + expanded={expandedNodeIds} > - {this.buildTreeItems(treeStructure.nodes)} + {this.buildTreeItems(treeStructure.nodes, visibleNodeIds, containerRef, nodeIdToScrollTo)} ); @@ -60,7 +103,15 @@ export class SidebarIndexTableOfContents extends Component { SidebarIndexTableOfContents.propTypes = { classes: PropTypes.objectOf(PropTypes.string).isRequired, + containerRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]).isRequired, + expandedNodeIds: PropTypes.arrayOf(PropTypes.string).isRequired, + nodeIdToScrollTo: PropTypes.func.isRequired, setCanvas: PropTypes.func.isRequired, + toggleNode: PropTypes.func.isRequired, treeStructure: PropTypes.objectOf().isRequired, + visibleNodeIds: PropTypes.arrayOf(PropTypes.string).isRequired, windowId: PropTypes.string.isRequired, }; diff --git a/src/containers/SidebarIndexTableOfContents.js b/src/containers/SidebarIndexTableOfContents.js index dc65dd848e..6771c5da64 100644 --- a/src/containers/SidebarIndexTableOfContents.js +++ b/src/containers/SidebarIndexTableOfContents.js @@ -5,9 +5,10 @@ import { withStyles } from '@material-ui/core/styles'; import { withPlugins } from '../extend/withPlugins'; import { SidebarIndexTableOfContents } from '../components/SidebarIndexTableOfContents'; import { - getManifestoInstance, getManifestTreeStructure, - getVisibleCanvases, + getVisibleNodeIds, + getExpandedNodeIds, + getNodeIdToScrollTo, } from '../state/selectors'; import * as actions from '../state/actions'; @@ -16,9 +17,10 @@ import * as actions from '../state/actions'; * mapStateToProps - to hook up connect */ const mapStateToProps = (state, { id, windowId }) => ({ - canvases: getVisibleCanvases(state, { windowId }), - manifesto: getManifestoInstance(state, { windowId }), + expandedNodeIds: getExpandedNodeIds(state, { companionWindowId: id, windowId }), + nodeIdToScrollTo: getNodeIdToScrollTo(state, { companionWindowId: id, windowId }), treeStructure: getManifestTreeStructure(state, { windowId }), + visibleNodeIds: getVisibleNodeIds(state, { companionWindowId: id, windowId }), }); /** @@ -28,6 +30,7 @@ const mapStateToProps = (state, { id, windowId }) => ({ */ const mapDispatchToProps = (dispatch, { id, windowId }) => ({ setCanvas: (...args) => dispatch(actions.setCanvas(...args)), + toggleNode: nodeId => dispatch(actions.toggleNode(windowId, id, nodeId)), }); /** diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index 3b67e7f8d0..f5c1633784 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -3,6 +3,7 @@ const ActionTypes = { ADD_COMPANION_WINDOW: 'ADD_COMPANION_WINDOW', UPDATE_COMPANION_WINDOW: 'UPDATE_COMPANION_WINDOW', REMOVE_COMPANION_WINDOW: 'REMOVE_COMPANION_WINDOW', + TOGGLE_TOC_NODE: 'TOGGLE_TOC_NODE', UPDATE_WINDOW: 'UPDATE_WINDOW', HIGHLIGHT_ANNOTATION: 'HIGHLIGHT_ANNOTATION', diff --git a/src/state/actions/companionWindow.js b/src/state/actions/companionWindow.js index 18f681ac75..bd8ac0e057 100644 --- a/src/state/actions/companionWindow.js +++ b/src/state/actions/companionWindow.js @@ -1,6 +1,6 @@ import uuid from 'uuid/v4'; import ActionTypes from './action-types'; -import { getCompanionWindowIdsForPosition } from '../selectors'; +import { getCompanionWindowIdsForPosition, getManuallyExpandedNodeIds, getVisibleNodeIds } from '../selectors'; const defaultProps = { content: null, @@ -57,3 +57,25 @@ export function removeCompanionWindow(windowId, id) { windowId, }; } + +/** */ +export function toggleNode(windowId, id, nodeId) { + return (dispatch, getState) => { + const state = getState(); + const collapsedNodeIds = getManuallyExpandedNodeIds(state, { companionWindowId: id }, false); + const expandedNodeIds = getManuallyExpandedNodeIds(state, { companionWindowId: id }, true); + const visibleNodeIds = getVisibleNodeIds(state, { id, windowId }); + const expand = collapsedNodeIds.indexOf(nodeId) !== -1 + || (expandedNodeIds.indexOf(nodeId) === -1 && visibleNodeIds.indexOf(nodeId) === -1); + return dispatch({ + id, + payload: { + [nodeId]: { + expanded: expand, + }, + }, + type: ActionTypes.TOGGLE_TOC_NODE, + windowId, + }); + }; +} diff --git a/src/state/reducers/companionWindows.js b/src/state/reducers/companionWindows.js index 26b98ff786..b4f7db43ef 100644 --- a/src/state/reducers/companionWindows.js +++ b/src/state/reducers/companionWindows.js @@ -25,6 +25,8 @@ export function companionWindowsReducer(state = {}, action) { return removeIn(state, [action.id]); case ActionTypes.IMPORT_MIRADOR_STATE: return action.state.companionWindows; + case ActionTypes.TOGGLE_TOC_NODE: + return updateIn(state, [[action.id], 'tocNodes'], {}, orig => merge(orig, action.payload)); default: return state; } diff --git a/src/state/selectors/index.js b/src/state/selectors/index.js index 8e1d1ed1dd..4c3033b5e6 100644 --- a/src/state/selectors/index.js +++ b/src/state/selectors/index.js @@ -6,3 +6,4 @@ export * from './manifests'; export * from './windows'; export * from './workspace'; export * from './searches'; +export * from './ranges'; diff --git a/src/state/selectors/ranges.js b/src/state/selectors/ranges.js new file mode 100644 index 0000000000..f3e26aa48a --- /dev/null +++ b/src/state/selectors/ranges.js @@ -0,0 +1,131 @@ +import { createSelector } from 'reselect'; +import union from 'lodash/union'; +import without from 'lodash/without'; +import { Utils } from 'manifesto.js'; +import { getManifestTreeStructure } from './manifests'; +import { getVisibleCanvases } from './canvases'; +import { getCompanionWindow } from './companionWindows'; + +/** */ +function rangeContainsCanvasId(range, canvasId) { + const canvasIds = range.getCanvasIds(); + for (let i = 0; i < canvasIds.length; i += 1) { + if (Utils.normalisedUrlsMatch(canvasIds[i], canvasId)) { + return true; + } + } + return false; +} + +/** */ +function getAllParentIds(node) { + if (node.parentNode === undefined) { + return []; + } + if (node.parentNode.parentNode === undefined) { + return [node.parentNode.id]; + } + return [...getAllParentIds(node.parentNode), node.parentNode.id]; +} + +/** */ +function getVisibleNodeIdsInSubTree(nodes, canvasIds) { + return nodes.reduce((nodeIdAcc, node) => { + const result = []; + result.push(...nodeIdAcc); + const nodeContainsVisibleCanvas = canvasIds.reduce( + (acc, canvasId) => acc || rangeContainsCanvasId(node.data, canvasId), + false, + ); + const subTreeVisibleNodeIds = node.nodes.length > 0 + ? getVisibleNodeIdsInSubTree(node.nodes, canvasIds) + : []; + result.push(...subTreeVisibleNodeIds); + if (nodeContainsVisibleCanvas || subTreeVisibleNodeIds.length > 0) { + result.push({ + containsVisibleCanvas: nodeContainsVisibleCanvas, + id: node.id, + leaf: node.nodes.length === 0, + parentIds: getAllParentIds(node), + }); + } + return result; + }, []); +} + +/** */ +const getVisibleLeafAndBranchNodeIds = createSelector( + [ + getManifestTreeStructure, + getVisibleCanvases, + ], + (tree, canvases) => { + if (!canvases) { + return []; + } + const canvasIds = canvases.map(canvas => canvas.id); + return getVisibleNodeIdsInSubTree(tree.nodes, canvasIds); + }, +); + +/** */ +export const getVisibleNodeIds = createSelector( + [ + getVisibleLeafAndBranchNodeIds, + ], + visibleLeafAndBranchNodeIds => visibleLeafAndBranchNodeIds.map(item => item.id), +); + +const getVisibleBranchNodeIds = createSelector( + [ + getVisibleLeafAndBranchNodeIds, + ], + visibleLeafAndBranchNodeIds => visibleLeafAndBranchNodeIds.reduce( + (acc, item) => (item.leaf ? acc : [...acc, item.id]), + [], + ), +); + +const getCanvasContainingNodeIds = createSelector( + [ + getVisibleLeafAndBranchNodeIds, + ], + visibleLeafAndBranchNodeIds => visibleLeafAndBranchNodeIds.reduce( + (acc, item) => (item.containsVisibleCanvas ? [...acc, item] : acc), + [], + ), +); + +/** */ +export function getManuallyExpandedNodeIds(state, { companionWindowId }, expanded) { + const companionWindow = getCompanionWindow(state, { companionWindowId }); + return companionWindow.tocNodes ? Object.keys(companionWindow.tocNodes).reduce( + (acc, nodeId) => (companionWindow.tocNodes[nodeId].expanded === expanded + ? [...acc, nodeId] + : acc), + [], + ) : []; +} + +/** */ +export function getExpandedNodeIds(state, { companionWindowId, windowId }) { + const visibleBranchNodeIds = getVisibleBranchNodeIds(state, { companionWindowId, windowId }); + const manuallyExpandedNodeIds = getManuallyExpandedNodeIds(state, { companionWindowId }, true); + const manuallyClosedNodeIds = getManuallyExpandedNodeIds(state, { companionWindowId }, false); + return without(union(manuallyExpandedNodeIds, visibleBranchNodeIds), ...manuallyClosedNodeIds); +} + +/** */ +export function getNodeIdToScrollTo(state, { ...args }) { + const canvasContainingNodeIds = getCanvasContainingNodeIds(state, { ...args }); + const collapsedNodeIds = getManuallyExpandedNodeIds(state, args, false); + if (canvasContainingNodeIds && canvasContainingNodeIds.length > 0) { + for (let i = 0; i < canvasContainingNodeIds[0].parentIds.length; i += 1) { + if (collapsedNodeIds.indexOf(canvasContainingNodeIds[0].parentIds[i]) !== -1) { + return canvasContainingNodeIds[0].parentIds[i]; + } + } + return canvasContainingNodeIds[0].id; + } + return null; +}