diff --git a/__tests__/integration/mirador/index.html b/__tests__/integration/mirador/index.html index ff2aa5e3a0de9b3bbfbcf24c5a4ee515705723a2..e947ffa013b2d36cd77a8cf63df4cab223addb61 100644 --- a/__tests__/integration/mirador/index.html +++ b/__tests__/integration/mirador/index.html @@ -17,7 +17,7 @@ }, { loadedManifest: 'https://media.nga.gov/public/manifests/nga_highlights.json', - thumbnailNavigationDisplayed: false, + thumbnailNavigationPosition: 'off', }] }); </script> diff --git a/__tests__/src/actions/window.test.js b/__tests__/src/actions/window.test.js index 7ec51aa4765fab12b2402afc8428863ebe7f6b6e..3977bd6597fb8334fd263b157787aece80ac70b0 100644 --- a/__tests__/src/actions/window.test.js +++ b/__tests__/src/actions/window.test.js @@ -17,7 +17,7 @@ describe('window actions', () => { collectionIndex: 0, manifestId: null, rangeId: null, - thumbnailNavigationDisplayed: true, + thumbnailNavigationPosition: 'bottom', xywh: [0, 0, 400, 400], rotation: null, }, @@ -46,4 +46,16 @@ describe('window actions', () => { expect(actions.toggleWindowSideBar(id)).toEqual(expectedAction); }); }); + + describe('setWindowThumbnailPosition', () => { + it('returns the appropriate action type', () => { + const id = 'abc123'; + const expectedAction = { + type: ActionTypes.SET_WINDOW_THUMBNAIL_POSITION, + windowId: id, + position: 'right', + }; + expect(actions.setWindowThumbnailPosition(id, 'right')).toEqual(expectedAction); + }); + }); }); diff --git a/__tests__/src/components/ThumbnailNavigation.test.js b/__tests__/src/components/ThumbnailNavigation.test.js index e87428fcb6e93b86acb2cf943fdef19fd4646db8..a702eeafa84f20f8e960c14aaca18f19df982897 100644 --- a/__tests__/src/components/ThumbnailNavigation.test.js +++ b/__tests__/src/components/ThumbnailNavigation.test.js @@ -27,7 +27,7 @@ describe('ThumbnailNavigation', () => { window={{ id: 'foobar', canvasIndex: 1, - thumbnailNavigationDisplayed: true, + thumbnailNavigationPosition: 'bottom', }} config={{ thumbnailNavigation: { height: 150 } }} setCanvas={setCanvas} diff --git a/__tests__/src/components/WindowThumbnailSettings.test.js b/__tests__/src/components/WindowThumbnailSettings.test.js new file mode 100644 index 0000000000000000000000000000000000000000..6453d313cf205b5511c9bb6c95ad00ecb8da5379 --- /dev/null +++ b/__tests__/src/components/WindowThumbnailSettings.test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import WindowThumbnailSettings from '../../../src/components/WindowThumbnailSettings'; + +describe('WindowThumbnailSettings', () => { + let wrapper; + const setWindowThumbnailPosition = jest.fn(); + beforeEach(() => { + wrapper = shallow( + <WindowThumbnailSettings + windowId="xyz" + setWindowThumbnailPosition={setWindowThumbnailPosition} + thumbnailNavigationPosition="bottom" + />, + ); + }); + + it('renders without an error', () => { + expect(wrapper.find('WithStyles(Typography)').dive().dive().text()).toBe('Thumbnails'); + expect(wrapper.find('RadioGroup').props().value).toBe('bottom'); + }); + + it('updates state when the thumbnail config selection changes', () => { + wrapper.find('RadioGroup').first().simulate('change', { target: { value: 'off' } }); + expect(setWindowThumbnailPosition).toHaveBeenCalledWith('xyz', 'off'); + }); +}); diff --git a/__tests__/src/components/WindowTopMenu.test.js b/__tests__/src/components/WindowTopMenu.test.js new file mode 100644 index 0000000000000000000000000000000000000000..47851603c78bd7d8081aaa8c8192344d094aae13 --- /dev/null +++ b/__tests__/src/components/WindowTopMenu.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import WindowTopMenu from '../../../src/components/WindowTopMenu'; + +describe('WindowTopMenu', () => { + let wrapper; + let handleClose; + beforeEach(() => { + handleClose = jest.fn(); + wrapper = shallow(<WindowTopMenu windowId="xyz" handleClose={handleClose} />).dive(); + }); + + it('renders without an error', () => { + expect(wrapper.find('WithStyles(Menu)').length).toBe(1); + expect(wrapper.find('Connect(WindowThumbnailSettings)').length).toBe(1); + }); +}); diff --git a/__tests__/src/components/WindowTopMenuButton.test.js b/__tests__/src/components/WindowTopMenuButton.test.js new file mode 100644 index 0000000000000000000000000000000000000000..c016c58da0146cf137c8b53a7c0b93e8f472f78a --- /dev/null +++ b/__tests__/src/components/WindowTopMenuButton.test.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import WindowTopMenuButton from '../../../src/components/WindowTopMenuButton'; + +describe('WindowTopMenuButton', () => { + let wrapper; + beforeEach(() => { + wrapper = shallow( + <WindowTopMenuButton classes={{}} windowId="xyz" />, + ).dive(); + }); + + it('renders without an error', () => { + expect(wrapper.find('WithStyles(IconButton)').length).toBe(1); + }); + it('when clicked, updates the state', () => { + wrapper.find('WithStyles(IconButton)').simulate('click', { currentTarget: 'x' }); + expect(wrapper.find('Connect(miradorWithPlugins(WithStyles(WindowTopMenu)))').props().anchorEl).toBe('x'); + }); +}); diff --git a/__tests__/src/lib/MiradorViewer.test.js b/__tests__/src/lib/MiradorViewer.test.js index c1cfaa9958c27564ac9dd47975da2d4e6a0873ae..d33c6af428fcd9ca41619e6b37a08a138aece88f 100644 --- a/__tests__/src/lib/MiradorViewer.test.js +++ b/__tests__/src/lib/MiradorViewer.test.js @@ -49,7 +49,7 @@ describe('MiradorViewer', () => { }, { loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843', - thumbnailNavigationDisplayed: false, + thumbnailNavigationPosition: 'off', }, ], }); @@ -57,8 +57,8 @@ describe('MiradorViewer', () => { const { windows } = instance.store.getState(); const windowIds = Object.keys(windows); expect(Object.keys(windowIds).length).toBe(2); - expect(windows[windowIds[0]].thumbnailNavigationDisplayed).toBe(true); - expect(windows[windowIds[1]].thumbnailNavigationDisplayed).toBe(false); + expect(windows[windowIds[0]].thumbnailNavigationPosition).toBe('bottom'); + expect(windows[windowIds[1]].thumbnailNavigationPosition).toBe('off'); }); }); }); diff --git a/__tests__/src/reducers/windows.test.js b/__tests__/src/reducers/windows.test.js index 013819417585407b40822cfe7a2e7f3885c92347..ceb3e1936cfbf3b31642ebf4b1d53967f97c9efe 100644 --- a/__tests__/src/reducers/windows.test.js +++ b/__tests__/src/reducers/windows.test.js @@ -47,6 +47,24 @@ describe('windows reducer', () => { expect(reducer(before, action)).toEqual(after); }); + it('should handle SET_WINDOW_THUMBNAIL_POSITION by changing the thumbnailNavigationPosition attribute', () => { + const action = { + type: ActionTypes.SET_WINDOW_THUMBNAIL_POSITION, + windowId: 'abc123', + position: 'right', + }; + const before = { + abc123: { thumbnailNavigationPosition: 'bottom' }, + abc321: { thumbnailNavigationPosition: 'off' }, + }; + const after = { + abc123: { thumbnailNavigationPosition: 'right' }, + abc321: { thumbnailNavigationPosition: 'off' }, + }; + + expect(reducer(before, action)).toEqual(after); + }); + it('should handle NEXT_CANVAS', () => { expect(reducer({ abc123: { diff --git a/src/components/ThumbnailNavigation.js b/src/components/ThumbnailNavigation.js index 01b09a244730f29dc2f5b49aff0b03b33027bc1b..edf65753bb3dbbb2cd800af11e63a49aefeabbee 100644 --- a/src/components/ThumbnailNavigation.js +++ b/src/components/ThumbnailNavigation.js @@ -102,7 +102,7 @@ class ThumbnailNavigation extends Component { render() { const { config, window } = this.props; const { canvases } = this.state; - if (!window.thumbnailNavigationDisplayed) { + if (window.thumbnailNavigationPosition === 'off') { return <></>; } return ( diff --git a/src/components/WindowThumbnailSettings.js b/src/components/WindowThumbnailSettings.js new file mode 100644 index 0000000000000000000000000000000000000000..ced6f4958653b9ab31e071868e424bd207faff92 --- /dev/null +++ b/src/components/WindowThumbnailSettings.js @@ -0,0 +1,91 @@ +import React, { Component } from 'react'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Radio from '@material-ui/core/Radio'; +import RadioGroup from '@material-ui/core/RadioGroup'; +import Typography from '@material-ui/core/Typography'; +import CancelPresentationIcon from '@material-ui/icons/CancelPresentation'; +import VideoLabelIcon from '@material-ui/icons/VideoLabel'; +import PropTypes from 'prop-types'; + +/** + * + */ +export default class WindowThumbnailSettings extends Component { + /** + * constructor - + */ + constructor(props) { + super(props); + this.state = { + }; + this.handleChange = this.handleChange.bind(this); + } + + /** + * @private + */ + handleChange(event) { + const { windowId, setWindowThumbnailPosition } = this.props; + + setWindowThumbnailPosition(windowId, event.target.value); + } + + /** + * render + * + * @return {type} description + */ + render() { + const { thumbnailNavigationPosition } = this.props; + + return ( + <> + <Typography>Thumbnails</Typography> + <RadioGroup aria-label="position" name="position" value={thumbnailNavigationPosition} onChange={this.handleChange} row> + <FormControlLabel + value="off" + control={<Radio color="primary" icon={<CancelPresentationIcon />} checkedIcon={<CancelPresentationIcon />} />} + label="Off" + labelPlacement="bottom" + /> + <FormControlLabel + value="bottom" + control={<Radio color="primary" icon={<ThumbnailNavigationBottomIcon />} checkedIcon={<ThumbnailNavigationBottomIcon />} />} + label="Bottom" + labelPlacement="bottom" + /> + <FormControlLabel + value="right" + control={<Radio color="primary" icon={<ThumbnailNavigationRightIcon />} checkedIcon={<ThumbnailNavigationRightIcon />} />} + label="Right" + labelPlacement="bottom" + /> + </RadioGroup> + </> + ); + } +} + +/** + * @private + */ +function ThumbnailNavigationBottomIcon(props) { + return ( + <VideoLabelIcon /> + ); +} + +/** + * @private + */ +function ThumbnailNavigationRightIcon(props) { + return ( + <VideoLabelIcon style={{ transform: 'rotate(-90deg)' }} /> + ); +} + +WindowThumbnailSettings.propTypes = { + windowId: PropTypes.string.isRequired, + setWindowThumbnailPosition: PropTypes.func.isRequired, + thumbnailNavigationPosition: PropTypes.string.isRequired, +}; diff --git a/src/components/WindowTopBar.js b/src/components/WindowTopBar.js index bf5e0527538421b859da2662fc8118efcf9c51b8..a5f61fdb2009164942a846b237c7fb5d3a9dd65e 100644 --- a/src/components/WindowTopBar.js +++ b/src/components/WindowTopBar.js @@ -7,6 +7,7 @@ import IconButton from '@material-ui/core/IconButton'; import MenuIcon from '@material-ui/icons/Menu'; import Toolbar from '@material-ui/core/Toolbar'; import classNames from 'classnames'; +import WindowTopMenuButton from './WindowTopMenuButton'; import WindowTopBarButtons from '../containers/WindowTopBarButtons'; import ns from '../config/css-ns'; @@ -48,6 +49,7 @@ class WindowTopBar extends Component { {this.titleContent()} </Typography> <WindowTopBarButtons windowId={windowId} /> + <WindowTopMenuButton className={ns('window-menu-btn')} windowId={windowId} /> <Button color="inherit" className={ns('window-close')} aria-label="Close Window" onClick={removeWindow}>×</Button> </Toolbar> ); diff --git a/src/components/WindowTopMenu.js b/src/components/WindowTopMenu.js new file mode 100644 index 0000000000000000000000000000000000000000..a7e41a2f84ee04866fef1eee2fec2ad5bf2d5e44 --- /dev/null +++ b/src/components/WindowTopMenu.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import { compose } from 'redux'; +import ListItem from '@material-ui/core/ListItem'; +import Menu from '@material-ui/core/Menu'; +import Divider from '@material-ui/core/Divider'; +import { withStyles } from '@material-ui/core/styles'; +import PropTypes from 'prop-types'; +import WindowThumbnailSettings from '../containers/WindowThumbnailSettings'; + +/** + */ +class WindowTopMenu extends Component { + /** + * constructor - + */ + constructor(props) { + super(props); + this.state = { + }; + } + + /** + * render + * @return + */ + render() { + const { handleClose, anchorEl, windowId } = this.props; + // const {} = this.state; + + return ( + <> + <Menu id={`window-menu_${windowId}`} anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleClose}> + <ListItem> + <WindowThumbnailSettings windowId={windowId} /> + </ListItem> + <Divider /> + </Menu> + </> + ); + } +} + +WindowTopMenu.propTypes = { + windowId: PropTypes.string.isRequired, + handleClose: PropTypes.func.isRequired, + anchorEl: PropTypes.object, // eslint-disable-line react/forbid-prop-types +}; + +WindowTopMenu.defaultProps = { + anchorEl: null, +}; + +/** + * @private + */ +const styles = theme => ({ +}); + +const enhance = compose( + withStyles(styles), + // further HOC go here +); + +export default enhance(WindowTopMenu); diff --git a/src/components/WindowTopMenuButton.js b/src/components/WindowTopMenuButton.js new file mode 100644 index 0000000000000000000000000000000000000000..a8b66b249e84f772ca3e55f0f7670382e37851a7 --- /dev/null +++ b/src/components/WindowTopMenuButton.js @@ -0,0 +1,92 @@ +import React, { Component } from 'react'; +import { compose } from 'redux'; +import IconButton from '@material-ui/core/IconButton'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import { withStyles } from '@material-ui/core/styles'; +import PropTypes from 'prop-types'; +import WindowTopMenu from '../containers/WindowTopMenu'; + +/** + */ +class WindowTopMenuButton extends Component { + /** + * constructor - + */ + constructor(props) { + super(props); + this.state = { + anchorEl: null, + }; + this.handleMenuClick = this.handleMenuClick.bind(this); + this.handleMenuClose = this.handleMenuClose.bind(this); + } + + /** + * @private + */ + handleMenuClick(event) { + this.setState({ + anchorEl: event.currentTarget, + }); + } + + /** + * @private + */ + handleMenuClose() { + this.setState({ + anchorEl: null, + }); + } + + /** + * render + * @return + */ + render() { + const { classes, windowId } = this.props; + const { anchorEl } = this.state; + + return ( + <> + <IconButton + color="primary" + aria-label="Menu" + className={classes.ctrlBtn} + aria-haspopup="true" + onClick={this.handleMenuClick} + aria-owns={anchorEl ? `window-menu_${windowId}` : undefined} + > + <MoreVertIcon /> + </IconButton> + <WindowTopMenu + windowId={windowId} + anchorEl={anchorEl} + handleClose={this.handleMenuClose} + /> + </> + ); + } +} + +WindowTopMenuButton.propTypes = { + windowId: PropTypes.string.isRequired, + classes: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +/** + * @private + */ +const styles = theme => ({ + ctrlBtn: { + margin: theme.spacing.unit, + }, +}); + + +const enhance = compose( + withStyles(styles), + // further HOC go here +); + +export default enhance(WindowTopMenuButton); diff --git a/src/config/settings.js b/src/config/settings.js index b84d0e81884cda6cfaa9cbc3fc67281e4d43125c..258339b2c14090a3b0fb18690ae23cc00f5821cb 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -1,7 +1,7 @@ export default { windows: [], thumbnailNavigation: { - displayedByDefault: true, + defaultPosition: 'bottom', height: 150, }, }; diff --git a/src/containers/WindowThumbnailSettings.js b/src/containers/WindowThumbnailSettings.js new file mode 100644 index 0000000000000000000000000000000000000000..75b0e947253ea17e06667eb8d7bfa27f146fb56b --- /dev/null +++ b/src/containers/WindowThumbnailSettings.js @@ -0,0 +1,29 @@ +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import * as actions from '../state/actions'; +import WindowThumbnailSettings from '../components/WindowThumbnailSettings'; + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = { setWindowThumbnailPosition: actions.setWindowThumbnailPosition }; + +/** + * mapStateToProps - to hook up connect + * @memberof WindowViewer + * @private + */ +const mapStateToProps = (state, props) => ( + { + thumbnailNavigationPosition: state.windows[props.windowId].thumbnailNavigationPosition, + } +); + +const enhance = compose( + connect(mapStateToProps, mapDispatchToProps), + // further HOC go here +); + +export default enhance(WindowThumbnailSettings); diff --git a/src/containers/WindowTopMenu.js b/src/containers/WindowTopMenu.js new file mode 100644 index 0000000000000000000000000000000000000000..cd163f2f530e6929e8cb3b268f4b2e7bc56cb4ae --- /dev/null +++ b/src/containers/WindowTopMenu.js @@ -0,0 +1,4 @@ +import miradorWithPlugins from '../lib/miradorWithPlugins'; +import WindowTopMenu from '../components/WindowTopMenu'; + +export default miradorWithPlugins(WindowTopMenu); diff --git a/src/lib/MiradorViewer.js b/src/lib/MiradorViewer.js index f0083b78a47318672241e980617f5a6e6337a82f..325b7b5cb9f97acb96b0b61595dbf24cc9320c1d 100644 --- a/src/lib/MiradorViewer.js +++ b/src/lib/MiradorViewer.js @@ -43,16 +43,16 @@ class MiradorViewer { store.dispatch(action); mergedConfig.windows.forEach((miradorWindow) => { - let thumbnailNavigationDisplayed; - if (miradorWindow.thumbnailNavigationDisplayed !== undefined) { - ({ thumbnailNavigationDisplayed } = miradorWindow); + let thumbnailNavigationPosition; + if (miradorWindow.thumbnailNavigationPosition !== undefined) { + ({ thumbnailNavigationPosition } = miradorWindow); } else { - thumbnailNavigationDisplayed = mergedConfig.thumbnailNavigation.displayedByDefault; + thumbnailNavigationPosition = mergedConfig.thumbnailNavigation.defaultPosition; } store.dispatch(actions.fetchManifest(miradorWindow.loadedManifest)); store.dispatch(actions.addWindow({ manifestId: miradorWindow.loadedManifest, - thumbnailNavigationDisplayed, + thumbnailNavigationPosition, })); }); } diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index fad609fe8f6b288f69da4087255b806884deb776..c5e503f91bb105f1f5742c53af2f8eaa7f0f8da8 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -12,6 +12,7 @@ const ActionTypes = { RECEIVE_MANIFEST: 'RECEIVE_MANIFEST', RECEIVE_MANIFEST_FAILURE: 'RECEIVE_MANIFEST_FAILURE', SET_CONFIG: 'SET_CONFIG', + SET_WINDOW_THUMBNAIL_POSITION: 'SET_WINDOW_THUMBNAIL_POSITION', TOGGLE_WINDOW_SIDE_BAR: 'TOGGLE_WINDOW_SIDE_BAR', UPDATE_CONFIG: 'UPDATE_CONFIG', REMOVE_MANIFEST: 'REMOVE_MANIFEST', diff --git a/src/state/actions/window.js b/src/state/actions/window.js index c68a6288a229a9504dfa8fa27540de665237c677..605b690b8b38bc86b52f8fbc33a3a1c8216c2bee 100644 --- a/src/state/actions/window.js +++ b/src/state/actions/window.js @@ -24,7 +24,7 @@ export function addWindow(options) { collectionIndex: 0, manifestId: null, rangeId: null, - thumbnailNavigationDisplayed: true, // True by default in settings.js + thumbnailNavigationPosition: 'bottom', // bottom by default in settings.js xywh: [0, 0, 400, 400], rotation: null, }; @@ -50,3 +50,13 @@ export function removeWindow(windowId) { export function toggleWindowSideBar(windowId) { return { type: ActionTypes.TOGGLE_WINDOW_SIDE_BAR, windowId }; } + +/** + * toggleWindowSideBar - action creator + * + * @param {String} windowId + * @memberof ActionCreators + */ +export function setWindowThumbnailPosition(windowId, position) { + return { type: ActionTypes.SET_WINDOW_THUMBNAIL_POSITION, windowId, position }; +} diff --git a/src/state/reducers/windows.js b/src/state/reducers/windows.js index d61f61b5b86f924cadd334ad73b052bd68c4de6e..8c851f8249148b26e4906cdc5ecd06c86d2a0a99 100644 --- a/src/state/reducers/windows.js +++ b/src/state/reducers/windows.js @@ -22,6 +22,14 @@ const windowsReducer = (state = {}, action) => { sideBarOpen: !state[action.windowId].sideBarOpen, }, }; + case ActionTypes.SET_WINDOW_THUMBNAIL_POSITION: + return { + ...state, + [action.windowId]: { + ...state[action.windowId], + thumbnailNavigationPosition: action.position, + }, + }; case ActionTypes.NEXT_CANVAS: return setCanvasIndex(state, action.windowId, currentIndex => currentIndex + 1); case ActionTypes.PREVIOUS_CANVAS: