From d80412f4f50cb358880891134e8d386c0f29e852 Mon Sep 17 00:00:00 2001
From: Jack Reed <phillipjreed@gmail.com>
Date: Fri, 1 Feb 2019 05:26:27 -0700
Subject: [PATCH] Implement basic React/Redux windowing in Mosaic mode

Using react-mosaic, implements a basic Redux backed windowing
system.
---
 __tests__/src/actions/workspace.test.js    | 11 ++++
 __tests__/src/components/Window.test.js    |  2 -
 __tests__/src/components/Workspace.test.js | 64 ++++++++++++++++--
 __tests__/src/reducers/workspace.test.js   |  8 +++
 package.json                               |  1 +
 src/components/Window.js                   | 10 +--
 src/components/Workspace.js                | 76 +++++++++++++++++++---
 src/containers/Window.js                   |  3 +-
 src/containers/Workspace.js                | 12 +++-
 src/state/actions/action-types.js          |  1 +
 src/state/actions/workspace.js             | 10 +++
 src/state/reducers/workspace.js            |  2 +
 src/styles/index.scss                      |  1 +
 13 files changed, 174 insertions(+), 27 deletions(-)

diff --git a/__tests__/src/actions/workspace.test.js b/__tests__/src/actions/workspace.test.js
index 9f9b3ff08..b8e74464f 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 f0f7ffdbb..031446264 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 18774065f..fde08305b 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 756a29902..7f80dab51 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 ef8a0f9f3..d147f8927 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 91e83bfc6..2d1fec560 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 9f3244339..de8694c11 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 600c03d27..1532051f8 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 ba8a411fb..a74e9ae4b 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 c5e503f91..302d40515 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 84ea9a0dc..331ff96d3 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 874ea83f7..32d2b33de 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 1c827c81a..53bd8bd43 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 {
-- 
GitLab