diff --git a/__tests__/integration/mirador/thumbnail-navigation.test.js b/__tests__/integration/mirador/thumbnail-navigation.test.js index 4056d5453028fe039f962622b79dff42553300dc..de83c2d2d0a09bb727e7b9a8d3b51be6f029cf3a 100644 --- a/__tests__/integration/mirador/thumbnail-navigation.test.js +++ b/__tests__/integration/mirador/thumbnail-navigation.test.js @@ -3,13 +3,6 @@ describe('Thumbnail navigation', () => { beforeAll(async () => { await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/'); - await expect(page).toClick('#addBtn'); - await expect(page).toFill('#manifestURL', 'http://localhost:5000/api/019'); - await expect(page).toClick('#fetchBtn'); - // TODO: Refactor the app so we get rid of the wait - await page.waitFor(1000); - await expect(page).toClick('.mirador-manifest-list-item'); - await page.waitFor(1000); }); it('navigates a manifest using thumbnail navigation', async () => { diff --git a/__tests__/src/actions/workspace.test.js b/__tests__/src/actions/workspace.test.js index 9f9b3ff08a4278ba87188163d52b43814e75a297..b8e74464fc08637f0588007cd52282828d1cfb29 100644 --- a/__tests__/src/actions/workspace.test.js +++ b/__tests__/src/actions/workspace.test.js @@ -13,4 +13,15 @@ describe('workspace actions', () => { expect(actions.fullscreenWorkspace(options)).toEqual(expectedAction); }); }); + describe('updateWorkspaceMosaicLayout', () => { + it('should updates mosaic layout', () => { + const options = { foo: 'bar' }; + + const expectedAction = { + type: ActionTypes.UPDATE_WORKSPACE_MOSAIC_LAYOUT, + layout: { foo: 'bar' }, + }; + expect(actions.updateWorkspaceMosaicLayout(options)).toEqual(expectedAction); + }); + }); }); diff --git a/__tests__/src/components/Window.test.js b/__tests__/src/components/Window.test.js index f0f7ffdbbe750b08ee93beb4a5f350269fb33d13..0314462641216de887746ecfc17daed0d0d60ffb 100644 --- a/__tests__/src/components/Window.test.js +++ b/__tests__/src/components/Window.test.js @@ -10,8 +10,6 @@ describe('Window', () => { it('should render outer element', () => { wrapper = shallow(<Window window={window} />); expect(wrapper.find('.mirador-window')).toHaveLength(1); - expect(wrapper.instance().styleAttributes()) - .toEqual({ width: '400px', height: '500px' }); }); it('should render <WindowTopBar>', () => { wrapper = shallow(<Window window={window} />); diff --git a/__tests__/src/components/Workspace.test.js b/__tests__/src/components/Workspace.test.js index 18774065fab1ce64e7b0419cf02011a6eca60382..138523c2b169aa9f09efe8d0b523a58622f37368 100644 --- a/__tests__/src/components/Workspace.test.js +++ b/__tests__/src/components/Workspace.test.js @@ -5,13 +5,36 @@ import Window from '../../../src/containers/Window'; describe('Workspace', () => { const windows = { 1: { id: 1 }, 2: { id: 2 } }; + let wrapper; + beforeEach(() => { + wrapper = shallow( + <Workspace + windows={windows} + config={{ workspace: { type: 'mosaic' } }} + />, + ); + }); it('should render properly', () => { - const wrapper = shallow(<Workspace windows={windows} />); - expect(wrapper.matchesElement( - <div className="mirador-workspace"> - <Window window={{ id: 1 }} /> - <Window window={{ id: 2 }} /> - </div>, - )).toBe(true); + expect(wrapper.find('.mirador-workspace').length).toBe(1); + expect(wrapper.find('Connect(WorkspaceMosaic)').length).toBe(1); + }); + describe('workspaceByType', () => { + it('when mosaic', () => { + expect(wrapper.find('Connect(WorkspaceMosaic)').length).toBe(1); + }); + it('anything else', () => { + wrapper = shallow( + <Workspace + windows={windows} + config={{ workspace: { type: 'foo' } }} + />, + ); + expect(wrapper.matchesElement( + <div className="mirador-workspace"> + <Window window={{ id: 1 }} /> + <Window window={{ id: 2 }} /> + </div>, + )).toBe(true); + }); }); }); diff --git a/__tests__/src/components/WorkspaceMosaic.test.js b/__tests__/src/components/WorkspaceMosaic.test.js new file mode 100644 index 0000000000000000000000000000000000000000..64b2a013163298979de207e54edeeae2540525ea --- /dev/null +++ b/__tests__/src/components/WorkspaceMosaic.test.js @@ -0,0 +1,69 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Mosaic } from 'react-mosaic-component'; +import WorkspaceMosaic from '../../../src/components/WorkspaceMosaic'; + +describe('WorkspaceMosaic', () => { + const windows = { 1: { id: 1 }, 2: { id: 2 } }; + let wrapper; + beforeEach(() => { + wrapper = shallow( + <WorkspaceMosaic + windows={windows} + workspace={{}} + updateWorkspaceMosaicLayout={() => {}} + />, + ); + }); + it('should render properly with an initialValue', () => { + expect(wrapper.matchesElement( + <Mosaic initialValue={{ direction: 'row', first: '1', second: '2' }} />, + )).toBe(true); + }); + describe('determineWorkspaceLayout', () => { + it('when window ids do not match workspace layout', () => { + wrapper = shallow( + <WorkspaceMosaic + windows={windows} + workspace={{ layout: 'foo' }} + updateWorkspaceMosaicLayout={() => {}} + />, + ); + expect(wrapper.instance().determineWorkspaceLayout()).toMatchObject({ + direction: 'row', first: '1', second: '2', + }); + }); + it('when window ids match workspace layout', () => { + wrapper = shallow( + <WorkspaceMosaic + windows={{ foo: { id: 'foo' } }} + workspace={{ layout: 'foo' }} + updateWorkspaceMosaicLayout={() => {}} + />, + ); + expect(wrapper.instance().determineWorkspaceLayout()).toBeNull(); + }); + }); + describe('tileRenderer', () => { + it('when window is available', () => { + expect(wrapper.instance().tileRenderer('1')).not.toBeNull(); + }); + it('when window is not available', () => { + expect(wrapper.instance().tileRenderer('bar')).toBeNull(); + }); + }); + describe('mosaicChange', () => { + it('calls the provided prop to update layout', () => { + const mock = jest.fn(); + wrapper = shallow( + <WorkspaceMosaic + windows={{ foo: { id: 'foo' } }} + workspace={{ layout: 'foo' }} + updateWorkspaceMosaicLayout={mock} + />, + ); + wrapper.instance().mosaicChange(); + expect(mock).toBeCalled(); + }); + }); +}); diff --git a/__tests__/src/reducers/workspace.test.js b/__tests__/src/reducers/workspace.test.js index 756a2990268ec9ee0822086128367cd9e9c113e2..7f80dab51600d2665081e21bb1c895e5c79e7dd4 100644 --- a/__tests__/src/reducers/workspace.test.js +++ b/__tests__/src/reducers/workspace.test.js @@ -18,4 +18,12 @@ describe('workspace reducer', () => { fullscreen: true, }); }); + it('should handle UPDATE_WORKSPACE_MOSAIC_LAYOUT', () => { + expect(reducer([], { + type: ActionTypes.UPDATE_WORKSPACE_MOSAIC_LAYOUT, + layout: { foo: 'bar' }, + })).toEqual({ + layout: { foo: 'bar' }, + }); + }); }); diff --git a/package.json b/package.json index ef8a0f9f3227206619e24555677993f4813c92f5..d147f892767a6bc609542207aa5e4a093cc8f04b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "react": "^16.7.0", "react-dom": "^16.4.0", "react-fullscreen-crossbrowser": "^1.0.9", + "react-mosaic-component": "^2.0.2", "react-redux": "^6.0.0", "react-virtualized": "^9.21.0", "redux": "4.0.1", diff --git a/src/components/Window.js b/src/components/Window.js index 91e83bfc6444f9a2e47d456d0957974d596bb917..2d1fec5604b0aa076fd570c83bd5777eb63d7f24 100644 --- a/src/components/Window.js +++ b/src/components/Window.js @@ -10,21 +10,13 @@ import ThumbnailNavigation from '../containers/ThumbnailNavigation'; * @param {object} window */ class Window extends Component { - /** - * Return style attributes - */ - styleAttributes() { - const { window } = this.props; - return { width: `${window.xywh[2]}px`, height: `${window.xywh[3]}px` }; - } - /** * Renders things */ render() { const { manifest, window } = this.props; return ( - <div className={ns('window')} style={this.styleAttributes()}> + <div className={ns('window')}> <WindowTopBar windowId={window.id} manifest={manifest} diff --git a/src/components/Workspace.js b/src/components/Workspace.js index 9f3244339b0ca9b98f94b480169159b09ae673ac..35cef899bf2e6092c1b19064a4a243cc30fa9f72 100644 --- a/src/components/Workspace.js +++ b/src/components/Workspace.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Window from '../containers/Window'; +import WorkspaceMosaic from '../containers/WorkspaceMosaic'; import ns from '../config/css-ns'; /** @@ -9,21 +10,39 @@ import ns from '../config/css-ns'; * @private */ class Workspace extends React.Component { + /** + */ + constructor(props) { + super(props); + + this.workspaceByType = this.workspaceByType.bind(this); + } + + /** + * Determine which workspace to render by configured type + */ + workspaceByType() { + const { config, windows } = this.props; + switch (config.workspace.type) { + case 'mosaic': + return <WorkspaceMosaic windows={windows} />; + default: + return Object.values(windows).map(window => ( + <Window + key={window.id} + window={window} + /> + )); + } + } + /** * render */ render() { - const { windows } = this.props; return ( <div className={ns('workspace')}> - { - Object.values(windows).map(window => ( - <Window - key={window.id} - window={window} - /> - )) - } + {this.workspaceByType()} </div> ); } @@ -31,6 +50,7 @@ class Workspace extends React.Component { Workspace.propTypes = { windows: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + config: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; export default Workspace; diff --git a/src/components/WorkspaceMosaic.js b/src/components/WorkspaceMosaic.js new file mode 100644 index 0000000000000000000000000000000000000000..8a7b0b0eb6326d0a3c581bfbf5f10244a280fe9b --- /dev/null +++ b/src/components/WorkspaceMosaic.js @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Mosaic, getLeaves, createBalancedTreeFromLeaves, +} from 'react-mosaic-component'; +import 'react-mosaic-component/react-mosaic-component.css'; +import Window from '../containers/Window'; + +/** + * Represents a work area that contains any number of windows + * @memberof Workspace + * @private + */ +class WorkspaceMosaic extends React.Component { + /** + */ + constructor(props) { + super(props); + + this.tileRenderer = this.tileRenderer.bind(this); + this.mosaicChange = this.mosaicChange.bind(this); + this.determineWorkspaceLayout = this.determineWorkspaceLayout.bind(this); + } + + /** + * Render a tile (Window) in the Mosaic. + */ + tileRenderer(id, path) { + const { windows } = this.props; + const window = windows[id]; + if (!window) return null; + return ( + <Window + key={window.id} + window={window} + /> + ); + } + + /** + * Update the redux store when the Mosaic is changed. + */ + mosaicChange(newLayout) { + const { updateWorkspaceMosaicLayout } = this.props; + updateWorkspaceMosaicLayout(newLayout); + } + + /** + * Used to determine whether or not a "new" layout should be autogenerated. + * If a Window is added or removed, generate that new layout and use that for + * this render. When the Mosaic changes, that will trigger a new store update. + */ + determineWorkspaceLayout() { + const { windows, workspace } = this.props; + const windowKeys = Object.keys(windows); + const leaveKeys = getLeaves(workspace.layout); + // Check every window is in the layout, and all layout windows are present + // in store + if (!windowKeys.every(e => leaveKeys.includes(e)) + || !leaveKeys.every(e => windowKeys.includes(e))) { + const newLayout = createBalancedTreeFromLeaves(windowKeys); + return newLayout; + } + return null; + } + + /** + */ + render() { + const { workspace } = this.props; + const newLayout = this.determineWorkspaceLayout(); + return ( + <Mosaic + renderTile={this.tileRenderer} + initialValue={newLayout || workspace.layout} + onChange={this.mosaicChange} + className="mirador-mosaic" + zeroStateView={<div />} + /> + ); + } +} + + +WorkspaceMosaic.propTypes = { + updateWorkspaceMosaicLayout: PropTypes.func.isRequired, + windows: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + workspace: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +export default WorkspaceMosaic; diff --git a/src/config/settings.js b/src/config/settings.js index 258339b2c14090a3b0fb18690ae23cc00f5821cb..fe3312aa23bd651b817acb62eb2f9f24c60954a1 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -4,4 +4,7 @@ export default { defaultPosition: 'bottom', height: 150, }, + workspace: { + type: 'mosaic', + } }; diff --git a/src/containers/Window.js b/src/containers/Window.js index 600c03d270346415751f805389a3f8b7fef64135..1532051f8aaec48f74001fe920697c10852aca77 100644 --- a/src/containers/Window.js +++ b/src/containers/Window.js @@ -7,8 +7,9 @@ import Window from '../components/Window'; * @memberof Window * @private */ -const mapStateToProps = ({ manifests }, props) => ({ +const mapStateToProps = ({ manifests, windows }, props) => ({ manifest: manifests[props.window.manifestId], + window: windows[props.window.id], }); const enhance = compose( diff --git a/src/containers/Workspace.js b/src/containers/Workspace.js index ba8a411fb18a6774c3886d3b6bc21850210faacf..0bac3285eb2f9364e1f08423dd0dcad13d6013a9 100644 --- a/src/containers/Workspace.js +++ b/src/containers/Workspace.js @@ -1,5 +1,6 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; +import * as actions from '../state/actions'; import Workspace from '../components/Workspace'; /** @@ -9,6 +10,7 @@ import Workspace from '../components/Workspace'; */ const mapStateToProps = state => ( { + config: state.config, windows: state.windows, } ); diff --git a/src/containers/WorkspaceMosaic.js b/src/containers/WorkspaceMosaic.js new file mode 100644 index 0000000000000000000000000000000000000000..d6b4a388caf5c5551936a146aabc5d6f0a95b2da --- /dev/null +++ b/src/containers/WorkspaceMosaic.js @@ -0,0 +1,30 @@ +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import * as actions from '../state/actions'; +import WorkspaceMosaic from '../components/WorkspaceMosaic'; + +/** + * mapStateToProps - to hook up connect + * @memberof Workspace + * @private + */ +const mapStateToProps = state => ( + { + workspace: state.workspace, + } +); + + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof Workspace + * @private + */ +const mapDispatchToProps = { updateWorkspaceMosaicLayout: actions.updateWorkspaceMosaicLayout }; + +const enhance = compose( + connect(mapStateToProps, mapDispatchToProps), + // further HOC go here +); + +export default enhance(WorkspaceMosaic); diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index c5e503f91bb105f1f5742c53af2f8eaa7f0f8da8..302d40515fb883623162514d066515d585fe7a89 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -20,6 +20,7 @@ const ActionTypes = { RECEIVE_INFO_RESPONSE: 'RECEIVE_INFO_RESPONSE', RECEIVE_INFO_RESPONSE_FAILURE: 'RECEIVE_INFO_RESPONSE_FAILURE', REMOVE_INFO_RESPONSE: 'REMOVE_INFO_RESPONSE', + UPDATE_WORKSPACE_MOSAIC_LAYOUT: 'UPDATE_WORKSPACE_MOSAIC_LAYOUT', }; export default ActionTypes; diff --git a/src/state/actions/workspace.js b/src/state/actions/workspace.js index 84ea9a0dc0f9db2afb248b73ef187f7987ee98bb..331ff96d3bccad0c6089875f90f2c2ba3c4aec1f 100644 --- a/src/state/actions/workspace.js +++ b/src/state/actions/workspace.js @@ -10,3 +10,13 @@ import ActionTypes from './action-types'; export function fullscreenWorkspace(fullscreen) { return { type: ActionTypes.FULLSCREEN_WORKSPACE, fullscreen }; } + +/** + * updateWorkspaceMosaicLayout - action creator + * + * @param {Object} layout + * @memberof ActionCreators + */ +export function updateWorkspaceMosaicLayout(layout) { + return { type: ActionTypes.UPDATE_WORKSPACE_MOSAIC_LAYOUT, layout }; +} diff --git a/src/state/reducers/workspace.js b/src/state/reducers/workspace.js index 874ea83f7edbfad96525567df91e912bd59f0fde..32d2b33de1335fcba4b68922bd8aa3b4b6ccd8ec 100644 --- a/src/state/reducers/workspace.js +++ b/src/state/reducers/workspace.js @@ -9,6 +9,8 @@ const workspaceReducer = (state = {}, action) => { return { ...state, focusedWindowId: action.windowId }; case ActionTypes.FULLSCREEN_WORKSPACE: return { ...state, fullscreen: action.fullscreen }; + case ActionTypes.UPDATE_WORKSPACE_MOSAIC_LAYOUT: + return { ...state, layout: action.layout }; default: return state; } diff --git a/src/styles/index.scss b/src/styles/index.scss index 1c827c81a26a32a9e4259cadb2d3108f9c567f6d..53bd8bd43d86efe90949417827222e39568d5758 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -27,6 +27,7 @@ body { &-window { display: flex; flex-direction: column; + height: 100%; } &-osd-container {