Skip to content
Snippets Groups Projects
Unverified Commit 5c5f36d2 authored by aeschylus's avatar aeschylus Committed by GitHub
Browse files

Merge pull request #1777 from ProjectMirador/react-mosaic

React mosaic proof of concept with redux state management
parents 72e3efb0 ed9ec05d
Branches
Tags
No related merge requests found
Showing
with 291 additions and 35 deletions
...@@ -3,13 +3,6 @@ ...@@ -3,13 +3,6 @@
describe('Thumbnail navigation', () => { describe('Thumbnail navigation', () => {
beforeAll(async () => { beforeAll(async () => {
await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/'); 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 () => { it('navigates a manifest using thumbnail navigation', async () => {
......
...@@ -13,4 +13,15 @@ describe('workspace actions', () => { ...@@ -13,4 +13,15 @@ describe('workspace actions', () => {
expect(actions.fullscreenWorkspace(options)).toEqual(expectedAction); 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);
});
});
}); });
...@@ -10,8 +10,6 @@ describe('Window', () => { ...@@ -10,8 +10,6 @@ describe('Window', () => {
it('should render outer element', () => { it('should render outer element', () => {
wrapper = shallow(<Window window={window} />); wrapper = shallow(<Window window={window} />);
expect(wrapper.find('.mirador-window')).toHaveLength(1); expect(wrapper.find('.mirador-window')).toHaveLength(1);
expect(wrapper.instance().styleAttributes())
.toEqual({ width: '400px', height: '500px' });
}); });
it('should render <WindowTopBar>', () => { it('should render <WindowTopBar>', () => {
wrapper = shallow(<Window window={window} />); wrapper = shallow(<Window window={window} />);
......
...@@ -5,8 +5,30 @@ import Window from '../../../src/containers/Window'; ...@@ -5,8 +5,30 @@ import Window from '../../../src/containers/Window';
describe('Workspace', () => { describe('Workspace', () => {
const windows = { 1: { id: 1 }, 2: { id: 2 } }; const windows = { 1: { id: 1 }, 2: { id: 2 } };
let wrapper;
beforeEach(() => {
wrapper = shallow(
<Workspace
windows={windows}
config={{ workspace: { type: 'mosaic' } }}
/>,
);
});
it('should render properly', () => { it('should render properly', () => {
const wrapper = shallow(<Workspace windows={windows} />); 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( expect(wrapper.matchesElement(
<div className="mirador-workspace"> <div className="mirador-workspace">
<Window window={{ id: 1 }} /> <Window window={{ id: 1 }} />
...@@ -15,3 +37,4 @@ describe('Workspace', () => { ...@@ -15,3 +37,4 @@ describe('Workspace', () => {
)).toBe(true); )).toBe(true);
}); });
}); });
});
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();
});
});
});
...@@ -18,4 +18,12 @@ describe('workspace reducer', () => { ...@@ -18,4 +18,12 @@ describe('workspace reducer', () => {
fullscreen: true, fullscreen: true,
}); });
}); });
it('should handle UPDATE_WORKSPACE_MOSAIC_LAYOUT', () => {
expect(reducer([], {
type: ActionTypes.UPDATE_WORKSPACE_MOSAIC_LAYOUT,
layout: { foo: 'bar' },
})).toEqual({
layout: { foo: 'bar' },
});
});
}); });
...@@ -10,21 +10,13 @@ import ThumbnailNavigation from '../containers/ThumbnailNavigation'; ...@@ -10,21 +10,13 @@ import ThumbnailNavigation from '../containers/ThumbnailNavigation';
* @param {object} window * @param {object} window
*/ */
class Window extends Component { 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 * Renders things
*/ */
render() { render() {
const { manifest, window } = this.props; const { manifest, window } = this.props;
return ( return (
<div className={ns('window')} style={this.styleAttributes()}> <div className={ns('window')}>
<WindowTopBar <WindowTopBar
windowId={window.id} windowId={window.id}
manifest={manifest} manifest={manifest}
......
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Window from '../containers/Window'; import Window from '../containers/Window';
import WorkspaceMosaic from '../containers/WorkspaceMosaic';
import ns from '../config/css-ns'; import ns from '../config/css-ns';
/** /**
...@@ -10,20 +11,38 @@ import ns from '../config/css-ns'; ...@@ -10,20 +11,38 @@ import ns from '../config/css-ns';
*/ */
class Workspace extends React.Component { class Workspace extends React.Component {
/** /**
* render
*/ */
render() { constructor(props) {
const { windows } = this.props; super(props);
return (
<div className={ns('workspace')}> this.workspaceByType = this.workspaceByType.bind(this);
{ }
Object.values(windows).map(window => (
/**
* 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 <Window
key={window.id} key={window.id}
window={window} window={window}
/> />
)) ));
} }
}
/**
* render
*/
render() {
return (
<div className={ns('workspace')}>
{this.workspaceByType()}
</div> </div>
); );
} }
...@@ -31,6 +50,7 @@ class Workspace extends React.Component { ...@@ -31,6 +50,7 @@ class Workspace extends React.Component {
Workspace.propTypes = { Workspace.propTypes = {
windows: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types 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; export default Workspace;
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;
...@@ -4,4 +4,7 @@ export default { ...@@ -4,4 +4,7 @@ export default {
defaultPosition: 'bottom', defaultPosition: 'bottom',
height: 150, height: 150,
}, },
workspace: {
type: 'mosaic',
}
}; };
...@@ -7,8 +7,9 @@ import Window from '../components/Window'; ...@@ -7,8 +7,9 @@ import Window from '../components/Window';
* @memberof Window * @memberof Window
* @private * @private
*/ */
const mapStateToProps = ({ manifests }, props) => ({ const mapStateToProps = ({ manifests, windows }, props) => ({
manifest: manifests[props.window.manifestId], manifest: manifests[props.window.manifestId],
window: windows[props.window.id],
}); });
const enhance = compose( const enhance = compose(
......
import { compose } from 'redux'; import { compose } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import * as actions from '../state/actions';
import Workspace from '../components/Workspace'; import Workspace from '../components/Workspace';
/** /**
...@@ -9,6 +10,7 @@ import Workspace from '../components/Workspace'; ...@@ -9,6 +10,7 @@ import Workspace from '../components/Workspace';
*/ */
const mapStateToProps = state => ( const mapStateToProps = state => (
{ {
config: state.config,
windows: state.windows, windows: state.windows,
} }
); );
......
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);
...@@ -20,6 +20,7 @@ const ActionTypes = { ...@@ -20,6 +20,7 @@ const ActionTypes = {
RECEIVE_INFO_RESPONSE: 'RECEIVE_INFO_RESPONSE', RECEIVE_INFO_RESPONSE: 'RECEIVE_INFO_RESPONSE',
RECEIVE_INFO_RESPONSE_FAILURE: 'RECEIVE_INFO_RESPONSE_FAILURE', RECEIVE_INFO_RESPONSE_FAILURE: 'RECEIVE_INFO_RESPONSE_FAILURE',
REMOVE_INFO_RESPONSE: 'REMOVE_INFO_RESPONSE', REMOVE_INFO_RESPONSE: 'REMOVE_INFO_RESPONSE',
UPDATE_WORKSPACE_MOSAIC_LAYOUT: 'UPDATE_WORKSPACE_MOSAIC_LAYOUT',
}; };
export default ActionTypes; export default ActionTypes;
...@@ -10,3 +10,13 @@ import ActionTypes from './action-types'; ...@@ -10,3 +10,13 @@ import ActionTypes from './action-types';
export function fullscreenWorkspace(fullscreen) { export function fullscreenWorkspace(fullscreen) {
return { type: ActionTypes.FULLSCREEN_WORKSPACE, 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 };
}
...@@ -9,6 +9,8 @@ const workspaceReducer = (state = {}, action) => { ...@@ -9,6 +9,8 @@ const workspaceReducer = (state = {}, action) => {
return { ...state, focusedWindowId: action.windowId }; return { ...state, focusedWindowId: action.windowId };
case ActionTypes.FULLSCREEN_WORKSPACE: case ActionTypes.FULLSCREEN_WORKSPACE:
return { ...state, fullscreen: action.fullscreen }; return { ...state, fullscreen: action.fullscreen };
case ActionTypes.UPDATE_WORKSPACE_MOSAIC_LAYOUT:
return { ...state, layout: action.layout };
default: default:
return state; return state;
} }
......
...@@ -27,6 +27,7 @@ body { ...@@ -27,6 +27,7 @@ body {
&-window { &-window {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
} }
&-osd-container { &-osd-container {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment