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
No related branches found
No related tags found
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