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;
+}