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..fde08305b75da6c5a37e8f2edd75ee3c2e043180 100644 --- a/__tests__/src/components/Workspace.test.js +++ b/__tests__/src/components/Workspace.test.js @@ -1,17 +1,71 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { Mosaic } from 'react-mosaic-component'; import Workspace from '../../../src/components/Workspace'; -import Window from '../../../src/containers/Window'; describe('Workspace', () => { const windows = { 1: { id: 1 }, 2: { id: 2 } }; - it('should render properly', () => { - const wrapper = shallow(<Workspace windows={windows} />); + let wrapper; + beforeEach(() => { + wrapper = shallow( + <Workspace + windows={windows} + workspace={{}} + updateWorkspaceMosaicLayout={() => {}} + />, + ); + }); + it('should render properly with an initialValue', () => { expect(wrapper.matchesElement( <div className="mirador-workspace"> - <Window window={{ id: 1 }} /> - <Window window={{ id: 2 }} /> + <Mosaic initialValue={{ direction: 'row', first: '1', second: '2' }} /> </div>, )).toBe(true); }); + describe('determineWorkspaceLayout', () => { + it('when window ids do not match workspace layout', () => { + wrapper = shallow( + <Workspace + 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( + <Workspace + 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( + <Workspace + 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..de8694c11835ec14874a7d8c254b3717be9dfcbe 100644 --- a/src/components/Workspace.js +++ b/src/components/Workspace.js @@ -1,5 +1,9 @@ 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'; import ns from '../config/css-ns'; @@ -9,28 +13,82 @@ import ns from '../config/css-ns'; * @private */ class Workspace 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 */ render() { - const { windows } = this.props; + const { workspace } = this.props; + const newLayout = this.determineWorkspaceLayout(); return ( <div className={ns('workspace')}> - { - Object.values(windows).map(window => ( - <Window - key={window.id} - window={window} - /> - )) - } + <Mosaic + renderTile={this.tileRenderer} + initialValue={newLayout || workspace.layout} + onChange={this.mosaicChange} + className="mirador-mosaic" + zeroStateView={<div />} + /> </div> ); } } Workspace.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 Workspace; 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..a74e9ae4b6e0220926ec69d550f2bbbf310a74cf 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'; /** @@ -10,11 +11,20 @@ import Workspace from '../components/Workspace'; const mapStateToProps = state => ( { windows: state.windows, + 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), + connect(mapStateToProps, mapDispatchToProps), // further HOC go here ); 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 {