diff --git a/__tests__/fixtures/config/export.example.json b/__tests__/fixtures/config/export.example.json new file mode 100644 index 0000000000000000000000000000000000000000..15054c42ff63a47515a5e4fbf3c709df976c9da3 --- /dev/null +++ b/__tests__/fixtures/config/export.example.json @@ -0,0 +1,298 @@ +{ + "companionWindows": { + "cw-b86f0558-6f04-4b56-ac9b-34b7e0769db3": { + "content": "info", + "default": true, + "id": "cw-b86f0558-6f04-4b56-ac9b-34b7e0769db3", + "position": "left" + }, + "cw-b15df6ac-9069-4f16-bd19-e11c6aaa3911": { + "content": "thumbnail_navigation", + "default": true, + "id": "cw-b15df6ac-9069-4f16-bd19-e11c6aaa3911", + "position": "far-bottom" + }, + "cw-c4934b3c-2411-40b1-9088-5e066276620f": { + "content": "info", + "default": true, + "id": "cw-c4934b3c-2411-40b1-9088-5e066276620f", + "position": "left" + }, + "cw-76580e14-4d7a-4ea5-84a6-5de052941a3d": { + "content": "thumbnail_navigation", + "default": true, + "id": "cw-76580e14-4d7a-4ea5-84a6-5de052941a3d", + "position": "far-bottom" + } + }, + "config": { + "canvasNavigation": { + "height": 50, + "width": 50 + }, + "theme": { + "palette": { + "type": "light", + "primary": { + "main": "#f5f5f5", + "light": "#ffffff", + "dark": "#eeeeee", + "contrastText": "rgba(0, 0, 0, 0.87)" + }, + "secondary": { + "main": "#1967d2", + "light": "#64b5f6", + "dark": "#0d47a1", + "contrastText": "#fff" + }, + "error": { + "main": "#b00020", + "light": "rgb(191, 51, 76)", + "dark": "rgb(123, 0, 22)", + "contrastText": "#fff" + } + }, + "typography": { + "fontSize": 16, + "body1": { + "fontSize": "1rem", + "letterSpacing": "0em", + "lineHeight": "1.6em" + }, + "body2": { + "fontSize": "0.878rem", + "letterSpacing": "0.015em", + "lineHeight": "1.6em" + }, + "button": { + "fontSize": "0.878rem", + "letterSpacing": "0.09em", + "lineHeight": "2.25rem", + "textTransform": "uppercase" + }, + "caption": { + "fontSize": "0.772rem", + "letterSpacing": "0.033em", + "lineHeight": "1.6rem" + }, + "body1Next": { + "fontSize": "1rem", + "letterSpacing": "0em", + "lineHeight": "1.6em" + }, + "body2Next": { + "fontSize": "0.878rem", + "letterSpacing": "0.015em", + "lineHeight": "1.6em" + }, + "buttonNext": { + "fontSize": "0.878rem", + "letterSpacing": "0.09em", + "lineHeight": "2.25rem" + }, + "captionNext": { + "fontSize": "0.772rem", + "letterSpacing": "0.33em", + "lineHeight": "1.6rem" + }, + "overline": { + "fontSize": "0.678rem", + "fontWeight": 500, + "letterSpacing": "0.166em", + "lineHeight": "2em", + "textTransform": "uppercase" + }, + "h1": { + "fontSize": "2.822rem", + "letterSpacing": "-0.015em", + "lineHeight": "1.2em" + }, + "h2": { + "fontSize": "1.575rem", + "letterSpacing": "0em", + "lineHeight": "1.33em" + }, + "h3": { + "fontSize": "1.383rem", + "fontWeight": 300, + "letterSpacing": "0em", + "lineHeight": "1.33em" + }, + "h4": { + "fontSize": "1.215rem", + "letterSpacing": "0.007em", + "lineHeight": "1.45em" + }, + "h5": { + "fontSize": "1.138rem", + "letterSpacing": "0.005em", + "lineHeight": "1.55em" + }, + "h6": { + "fontSize": "1.067rem", + "fontWeight": 400, + "letterSpacing": "0.01em", + "lineHeight": "1.6em" + }, + "subtitle1": { + "fontSize": "0.937rem", + "letterSpacing": "0.015em", + "lineHeight": "1.6em", + "fontWeight": 300 + }, + "subtitle2": { + "fontSize": "0.878rem", + "fontWeight": 500, + "letterSpacing": "0.02em", + "lineHeight": "1.75em" + }, + "useNextVariants": true + }, + "props": { + "MuiButtonBase": { + "disableTouchRipple": true + } + } + }, + "language": "en", + "availableLanguages": { + "de": "Deutsch", + "en": "English" + }, + "displayAllAnnotations": false, + "translations": {}, + "window": { + "allowClose": true, + "allowMaximize": true, + "defaultView": "single" + }, + "windows": [ + { + "loadedManifest": "https://iiif.harvardartmuseums.org/manifests/object/299843", + "canvasIndex": 2 + }, + { + "loadedManifest": "https://iiif.bodleian.ox.ac.uk/iiif/manifest/e32a277e-91e2-4a6d-8ba6-cc4bad230410.json", + "thumbnailNavigationPosition": "off" + } + ], + "thumbnailNavigation": { + "defaultPosition": "far-bottom", + "height": 150, + "width": 100 + }, + "workspace": { + "type": "mosaic" + }, + "workspaceControlPanel": { + "enabled": true + }, + "id": "mirador", + "manifests": { + "https://media.nga.gov/public/manifests/nga_highlights.json": { + "provider": "National Gallery of Art" + }, + "https://data.ucd.ie/api/img/manifests/ucdlib:33064": { + "provider": "Irish Architectural Archive" + }, + "https://wellcomelibrary.org/iiif/b18035723/manifest": { + "provider": "Wellcome Library" + }, + "https://demos.biblissima.fr/iiif/metadata/florus-dispersus/manifest.json": { + "provider": "Biblissima" + }, + "https://www.e-codices.unifr.ch/metadata/iiif/gau-Fragment/manifest.json": { + "provider": "e-codices - Virtual Manuscript Library of Switzerland" + }, + "https://wellcomelibrary.org/iiif/collection/b18031511": { + "provider": "Wellcome Library" + }, + "https://gallica.bnf.fr/iiif/ark:/12148/btv1b10022508f/manifest.json": { + "provider": "Bibliothèque nationale de France" + }, + "https://manifests.britishart.yale.edu/Osbornfa1": { + "provider": "Beinecke Rare Book and Manuscript Library, Yale University" + }, + "https://iiif.biblissima.fr/chateauroux/B360446201_MS0005/manifest.json": { + "provider": "Biblissima" + }, + "https://iiif.durham.ac.uk/manifests/trifle/32150/t1/m4/q7/t1m4q77fr328/manifest": { + "provider": "Durham University Library" + } + } + }, + "viewers": { + "window-404c667d-e1e0-4ae7-aaf2-98e08bb627bd": { + "x": 1205.5, + "y": 1686, + "zoom": 0.00021117049395471312 + }, + "window-f48761a4-4388-4ddd-aff6-9a331410aad3": { + "x": 2217.999999999988, + "y": 3193.5865262629163, + "zoom": 0.00010301562691749936 + } + }, + "windows": { + "window-404c667d-e1e0-4ae7-aaf2-98e08bb627bd": { + "canvasIndex": 4, + "collectionIndex": 0, + "companionWindowIds": [ + "cw-b86f0558-6f04-4b56-ac9b-34b7e0769db3", + "cw-b15df6ac-9069-4f16-bd19-e11c6aaa3911" + ], + "displayAllAnnotations": false, + "height": 400, + "id": "window-404c667d-e1e0-4ae7-aaf2-98e08bb627bd", + "manifestId": "https://wellcomelibrary.org/iiif/b18035723/manifest", + "maximized": false, + "rangeId": null, + "rotation": null, + "selectedAnnotations": {}, + "sideBarPanel": "info", + "thumbnailNavigationId": "cw-b15df6ac-9069-4f16-bd19-e11c6aaa3911", + "view": "single", + "width": 400, + "x": 200, + "y": 200 + }, + "window-f48761a4-4388-4ddd-aff6-9a331410aad3": { + "canvasIndex": 1, + "collectionIndex": 0, + "companionWindowIds": [ + "cw-c4934b3c-2411-40b1-9088-5e066276620f", + "cw-76580e14-4d7a-4ea5-84a6-5de052941a3d" + ], + "displayAllAnnotations": false, + "height": 400, + "id": "window-f48761a4-4388-4ddd-aff6-9a331410aad3", + "manifestId": "https://demos.biblissima.fr/iiif/metadata/florus-dispersus/manifest.json", + "maximized": false, + "rangeId": null, + "rotation": null, + "selectedAnnotations": {}, + "sideBarPanel": "info", + "thumbnailNavigationId": "cw-76580e14-4d7a-4ea5-84a6-5de052941a3d", + "view": "single", + "width": 400, + "x": 230, + "y": 250 + } + }, + "workspace": { + "exposeModeOn": false, + "height": 5000, + "viewportPosition": { + "x": 0, + "y": 0 + }, + "width": 5000, + "layout": { + "direction": "row", + "first": "window-404c667d-e1e0-4ae7-aaf2-98e08bb627bd", + "second": "window-f48761a4-4388-4ddd-aff6-9a331410aad3" + }, + "focusedWindowId": "window-f48761a4-4388-4ddd-aff6-9a331410aad3", + "isWorkspaceAddVisible": false + } +} \ No newline at end of file diff --git a/__tests__/src/actions/config.test.js b/__tests__/src/actions/config.test.js index f75d604d7a2ba9a0a3b832eb3c0948ffc205c857..aa2f94c57363478321e0fef48c38e6c2ae876cbf 100644 --- a/__tests__/src/actions/config.test.js +++ b/__tests__/src/actions/config.test.js @@ -1,5 +1,6 @@ import * as actions from '../../../src/state/actions'; import ActionTypes from '../../../src/state/actions/action-types'; +import configFixture from '../../fixtures/config/export.example.json'; describe('config actions', () => { describe('setConfig', () => { @@ -22,4 +23,15 @@ describe('config actions', () => { expect(actions.updateConfig(config)).toEqual(expectedAction); }); }); + + describe('importConfig', () => { + it('imports the config', () => { + const config = configFixture; + const expectedAction = { + config, + type: ActionTypes.IMPORT_CONFIG, + }; + expect(actions.importConfig(config)).toEqual(expectedAction); + }); + }); }); diff --git a/__tests__/src/actions/erros.test.js b/__tests__/src/actions/erros.test.js new file mode 100644 index 0000000000000000000000000000000000000000..b38e5b47546791f0568156779c344a24fd795543 --- /dev/null +++ b/__tests__/src/actions/erros.test.js @@ -0,0 +1,27 @@ +import * as actions from '../../../src/state/actions'; +import ActionTypes from '../../../src/state/actions/action-types'; + +describe('errors actions', () => { + describe('addError', () => { + it('adds an error', () => { + const errorMessage = 'errorMessage'; + const createdAction = actions.addError(errorMessage); + + expect(createdAction).toHaveProperty('message', errorMessage); + expect(createdAction).toHaveProperty('id'); + expect(createdAction.id).toBeDefined(); + }); + }); + + describe('removeError', () => { + it('removes an existing error', () => { + const errorId = 'testId123'; + const expectedAction = { + id: errorId, + type: ActionTypes.REMOVE_ERROR, + }; + + expect(actions.removeError(errorId)).toEqual(expectedAction); + }); + }); +}); diff --git a/__tests__/src/components/ErrorDialog.test.js b/__tests__/src/components/ErrorDialog.test.js new file mode 100644 index 0000000000000000000000000000000000000000..109187269e2245289d63b30b19d512771e01e86d --- /dev/null +++ b/__tests__/src/components/ErrorDialog.test.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ErrorDialog } from '../../../src/components/ErrorDialog'; + +/** + * Helper function to create a shallow wrapper around ErrorDialog + */ +function createWrapper(props) { + return shallow( + <ErrorDialog + t={key => key} + {...props} + />, + ); +} + +describe('ErrorDialog', () => { + let wrapper; + + it('renders properly', () => { + const errors = { testid123: { id: 'testid123', message: '' } }; + + wrapper = createWrapper({ errors }); + expect(wrapper.find('WithStyles(Dialog)').length).toBe(1); + }); + + it('shows up error message correctly', () => { + const errorMessage = 'error testMessage 123'; + const errors = { testid123: { id: 'testid123', message: errorMessage } }; + + wrapper = createWrapper({ errors }); + expect(wrapper.find('WithStyles(Typography)[variant="body2"]').render().text()).toBe(errorMessage); + }); + + it('triggers the handleClick prop when clicking the ok button', () => { + const errors = { testid123: { id: 'testid123', message: '' } }; + const mockHandleClick = jest.fn(); + + wrapper = createWrapper({ errors, removeError: mockHandleClick }); + wrapper.find('WithStyles(Button)').simulate('click'); + expect(mockHandleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/src/components/WorkspaceArea.test.js b/__tests__/src/components/WorkspaceArea.test.js index be8d0fc0725b05c9ac2f90e1ba194753e7460649..e03f8fe6d2af2cec851c1f2c7d7fb81ff9fe7d68 100644 --- a/__tests__/src/components/WorkspaceArea.test.js +++ b/__tests__/src/components/WorkspaceArea.test.js @@ -3,6 +3,7 @@ import { shallow } from 'enzyme'; import WorkspaceControlPanel from '../../../src/containers/WorkspaceControlPanel'; import Workspace from '../../../src/containers/Workspace'; import WorkspaceAdd from '../../../src/containers/WorkspaceAdd'; +import ErrorDialog from '../../../src/containers/ErrorDialog'; import { WorkspaceArea } from '../../../src/components/WorkspaceArea'; /** */ @@ -29,6 +30,7 @@ describe('WorkspaceArea', () => { <main> <WorkspaceControlPanel /> <Workspace /> + <ErrorDialog /> </main>, )).toBeTruthy(); }); diff --git a/__tests__/src/reducers/config.test.js b/__tests__/src/reducers/config.test.js index 9c53c62eea778dfa844f1c393d0c4fd8533ed18a..e451169ffa8e582e23b8fd46608243e1c8f352e0 100644 --- a/__tests__/src/reducers/config.test.js +++ b/__tests__/src/reducers/config.test.js @@ -1,5 +1,6 @@ import { configReducer } from '../../../src/state/reducers/config'; import ActionTypes from '../../../src/state/actions/action-types'; +import configFixture from '../../fixtures/config/export.example.json'; describe('config reducer', () => { describe('SET_CONFIG', () => { @@ -42,4 +43,12 @@ describe('config reducer', () => { }); }); }); + describe('IMPORT_CONFIG', () => { + it('should handle IMPORT_CONFIG', () => { + expect(configReducer([], { + config: configFixture, + type: ActionTypes.IMPORT_CONFIG, + })).toEqual(configFixture); + }); + }); }); diff --git a/__tests__/src/reducers/errors.test.js b/__tests__/src/reducers/errors.test.js new file mode 100644 index 0000000000000000000000000000000000000000..3a3923eaa4d1f40d3cc6a46a236b254769e422cd --- /dev/null +++ b/__tests__/src/reducers/errors.test.js @@ -0,0 +1,42 @@ + +import { errorsReducer } from '../../../src/state/reducers/errors'; +import ActionTypes from '../../../src/state/actions/action-types'; + +describe('ADD_ERROR', () => { + const errorMessage = 'testErrorMessage'; + const errorId = 'errorId123'; + + it('should handle ADD_ERROR', () => { + const error = { + id: errorId, + message: errorMessage, + }; + const ret = errorsReducer(undefined, { + type: ActionTypes.ADD_ERROR, + ...error, + + }); + expect(ret.items).toEqual([error.id]); + expect(ret).toHaveProperty(error.id); + expect(ret[error.id]).toEqual(error); + }); + + it('should handle REMOVE_ERROR', () => { + const stateBefore = { + errorId: { + id: errorId, + message: errorMessage, + }, + items: [errorId], + }; + + /* + Only the id is removed from the 'items' array. The error itself remains part of the state, + so we are able to provide an error history or some kind of logs later on + */ + expect(errorsReducer(stateBefore, { + id: errorId, + type: ActionTypes.REMOVE_ERROR, + })).toHaveProperty('items', []); + }); +}); diff --git a/package.json b/package.json index f8874daaba988e15c01f7a3eacebf55c86aae10b..7e3d3f7141fc7d9e23035de012817cd4bec0b4d8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "repository": "https://github.com/ProjectMirador/mirador", "size-limit": [ { - "limit": "360 KB", + "limit": "390 KB", "path": "dist/mirador.min.js" } ], diff --git a/src/components/ErrorDialog.js b/src/components/ErrorDialog.js new file mode 100644 index 0000000000000000000000000000000000000000..d2d6eff2565bbeb4ef4bb3d6003dfa77b427af43 --- /dev/null +++ b/src/components/ErrorDialog.js @@ -0,0 +1,63 @@ +import React, { Component } from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import PropTypes from 'prop-types'; +import { Typography } from '@material-ui/core'; +import Button from '@material-ui/core/Button'; +import { + first, + isUndefined, + omit, + values, +} from 'lodash'; + +/** + */ +export class ErrorDialog extends Component { + /** + * render + * @return + */ + render() { + const { + errors, removeError, t, + } = this.props; + + /* extract 'items' value and get first key-value-pair (an error) */ + const error = first(values(omit(errors, 'items'))); + const hasError = !isUndefined(error); + + return ( + <div> + { hasError && ( + <Dialog onClose={() => removeError(error.id)} open={hasError}> + <DialogTitle>{t('errorDialogTitle')}</DialogTitle> + <DialogContent> + <Typography variant="body2" noWrap color="inherit"> + {error.message} + </Typography> + <div> + <Button onClick={() => removeError(error.id)}> + {t('errorDialogConfirm')} + </Button> + </div> + </DialogContent> + </Dialog> + )} + </div> + ); + } +} + +ErrorDialog.propTypes = { + errors: PropTypes.object, // eslint-disable-line react/forbid-prop-types + removeError: PropTypes.func, + t: PropTypes.func, +}; + +ErrorDialog.defaultProps = { + errors: null, + removeError: () => {}, + t: key => key, +}; diff --git a/src/components/WorkspaceArea.js b/src/components/WorkspaceArea.js index 045b5fe7b6b57e20068ac3ca0ed7e2ae4440cdf2..4bff978fa9c90aa56c2d51c880614db3df9fd53d 100644 --- a/src/components/WorkspaceArea.js +++ b/src/components/WorkspaceArea.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import ErrorDialog from '../containers/ErrorDialog'; import WorkspaceControlPanel from '../containers/WorkspaceControlPanel'; import Workspace from '../containers/Workspace'; import WorkspaceAdd from '../containers/WorkspaceAdd'; @@ -30,7 +31,8 @@ export class WorkspaceArea extends Component { isWorkspaceAddVisible ? <WorkspaceAdd /> : <Workspace /> - } + } + <ErrorDialog /> </main> ); } diff --git a/src/components/WorkspaceExport.js b/src/components/WorkspaceExport.js index 1bc968fe519b937640fd0085501f633bbb178768..c4143585eb569aecf54525798dfe6476260f25cd 100644 --- a/src/components/WorkspaceExport.js +++ b/src/components/WorkspaceExport.js @@ -13,11 +13,20 @@ export class WorkspaceExport extends Component { */ exportableState() { const { state } = this.props; - const { config, windows } = state; + const { + companionWindows, + config, + viewers, + windows, + workspace, + } = state; return JSON.stringify({ + companionWindows, config, + viewers, windows, + workspace, }, null, 2); } diff --git a/src/components/WorkspaceImport.js b/src/components/WorkspaceImport.js new file mode 100644 index 0000000000000000000000000000000000000000..3f6d6dd8fd3bd223c5887823dd20224bf9c1856a --- /dev/null +++ b/src/components/WorkspaceImport.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import PropTypes from 'prop-types'; +import { Input } from '@material-ui/core'; +import Button from '@material-ui/core/Button'; + +/** + */ +export class WorkspaceImport extends Component { + /** + * + * constructor + */ + constructor(props) { + super(props); + + this.state = { + configImportValue: '', + }; + + this.handleClick = this.handleClick.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + /** + * @private + */ + handleChange(event) { + event.preventDefault(); + this.setState({ + configImportValue: event.target.value, + }); + } + + /** + * @private + */ + handleClick(event) { + const { importConfig } = this.props; + const { configImportValue } = this.state; + event.preventDefault(); + try { + const configJSON = JSON.parse(configImportValue); + importConfig(configJSON); + } catch (ex) { + const { addError } = this.props; + addError(ex.toString()); + } + } + + /** + * render + * @return + */ + render() { + const { + handleClose, open, t, + } = this.props; + return ( + <Dialog id="workspace-import" open={open} onClose={handleClose}> + <DialogTitle id="workspace-import-title">{t('import')}</DialogTitle> + <DialogContent> + <Input id="workspace-import-input" rows="15" multiline variant="filled" onChange={this.handleChange} /> + <div> + <Button onClick={this.handleClick}> + {t('importWorkspace')} + </Button> + </div> + </DialogContent> + </Dialog> + ); + } +} + +WorkspaceImport.propTypes = { + addError: PropTypes.func.isRequired, + handleClose: PropTypes.func.isRequired, + importConfig: PropTypes.func.isRequired, // eslint-disable-line react/forbid-prop-types + open: PropTypes.bool, // eslint-disable-line react/forbid-prop-types + t: PropTypes.func, +}; + +WorkspaceImport.defaultProps = { + open: false, + t: key => key, +}; diff --git a/src/components/WorkspaceMenu.js b/src/components/WorkspaceMenu.js index 535cdf244899979eb00324d12d08f211153e25bf..daf3f9cde15ec6acbb7059117ad074f82be4193d 100644 --- a/src/components/WorkspaceMenu.js +++ b/src/components/WorkspaceMenu.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import Menu from '@material-ui/core/Menu'; +import ImportIcon from '@material-ui/icons/Input'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import MenuItem from '@material-ui/core/MenuItem'; import Typography from '@material-ui/core/Typography'; @@ -12,6 +13,7 @@ import WindowList from '../containers/WindowList'; import WorkspaceSettings from '../containers/WorkspaceSettings'; import WorkspaceSelectionDialog from '../containers/WorkspaceSelectionDialog'; import WorkspaceExport from '../containers/WorkspaceExport'; +import WorkspaceImport from '../containers/WorkspaceImport'; import ns from '../config/css-ns'; /** @@ -24,6 +26,7 @@ export class WorkspaceMenu extends Component { super(props); this.state = { exportWorkspace: {}, + importWorkspace: {}, settings: {}, toggleZoom: {}, windowList: {}, @@ -79,6 +82,7 @@ export class WorkspaceMenu extends Component { } = this.props; const { + importWorkspace, windowList, toggleZoom, settings, @@ -155,6 +159,17 @@ export class WorkspaceMenu extends Component { </ListItemIcon> <Typography variant="body1">{t('downloadExportWorkspace')}</Typography> </MenuItem> + <MenuItem + aria-haspopup="true" + id="workspace-menu-import" + onClick={(e) => { this.handleMenuItemClick('importWorkspace', e); handleClose(e); }} + aria-owns={exportWorkspace.AnchorEl ? 'workspace-import' : undefined} + > + <ListItemIcon> + <ImportIcon /> + </ListItemIcon> + <Typography variant="body1">{t('importWorkspace')}</Typography> + </MenuItem> </Menu> <WindowList anchorEl={windowList.anchorEl} @@ -180,6 +195,11 @@ export class WorkspaceMenu extends Component { container={container} handleClose={this.handleMenuItemClose('exportWorkspace')} /> + <WorkspaceImport + open={Boolean(importWorkspace.open)} + container={container} + handleClose={this.handleMenuItemClose('importWorkspace')} + /> </> ); } diff --git a/src/components/index.js b/src/components/index.js index f9a96ce3a5274885ef34cba969637b6563bdb1b7..884ea2a8c43f62e145c3fad4e9340574d90e1891 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,6 +1,7 @@ export * from './App'; export * from './CanvasThumbnail'; export * from './CompanionWindow'; +export * from './ErrorDialog'; export * from './LabelValueMetadata'; export * from './LanguageSettings'; export * from './ManifestForm'; @@ -33,6 +34,7 @@ export * from './WorkspaceControlPanel'; export * from './WorkspaceControlPanelButtons'; export * from './WorkspaceExport'; export * from './WorkspaceFullScreenButton'; +export * from './WorkspaceImport'; export * from './WorkspaceMenu'; export * from './WorkspaceMenuButton'; export * from './WorkspaceMosaic'; diff --git a/src/containers/CompanionArea.js b/src/containers/CompanionArea.js index 11d8a12cb5a1356e49f64281f3cea7998b21ff25..8b30b05f486be83770255eee007b03e2ec21bb7f 100644 --- a/src/containers/CompanionArea.js +++ b/src/containers/CompanionArea.js @@ -11,7 +11,7 @@ import { CompanionArea } from '../components/CompanionArea'; const mapStateToProps = (state, { windowId, position }) => ({ companionAreaOpen: getCompanionAreaVisibility(state, { position, windowId }), companionWindows: getCompanionWindowsOfWindow(state, { windowId }) - .filter(cw => cw.position === position), + .filter(cw => cw && cw.position === position), sideBarOpen: getWindow(state, { windowId }).sideBarOpen, }); diff --git a/src/containers/ErrorDialog.js b/src/containers/ErrorDialog.js new file mode 100644 index 0000000000000000000000000000000000000000..b2993b8f451c2747d799fe08acc279ccd5e6d125 --- /dev/null +++ b/src/containers/ErrorDialog.js @@ -0,0 +1,32 @@ +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { withTranslation } from 'react-i18next'; +import { withPlugins } from '../extend'; +import { ErrorDialog } from '../components/ErrorDialog'; +import * as actions from '../state/actions'; + +/** + * mapStateToProps - to hook up connect + * @memberof ErrorDialog + * @private + */ +const mapStateToProps = state => ({ + errors: state.errors, +}); + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof App + * @private + */ +const mapDispatchToProps = { + removeError: actions.removeError, +}; + +const enhance = compose( + withTranslation(), + connect(mapStateToProps, mapDispatchToProps), + withPlugins('ErrorDialog'), +); + +export default enhance(ErrorDialog); diff --git a/src/containers/ThumbnailNavigation.js b/src/containers/ThumbnailNavigation.js index c570e094d59741bc2aa02781e7618020c4229c40..d87b0a39fa7aa644e30d971f1bd5959c98fd78e2 100644 --- a/src/containers/ThumbnailNavigation.js +++ b/src/containers/ThumbnailNavigation.js @@ -6,7 +6,9 @@ import { withPlugins } from '../extend'; import CanvasGroupings from '../lib/CanvasGroupings'; import * as actions from '../state/actions'; import { ThumbnailNavigation } from '../components/ThumbnailNavigation'; -import { getWindow, getManifestCanvases } from '../state/selectors'; +import { getWindow } from '../state/selectors/windows'; +import { getManifestCanvases } from '../state/selectors/manifests'; + /** * mapStateToProps - used to hook up state to props * @memberof ThumbnailNavigation diff --git a/src/containers/WorkspaceImport.js b/src/containers/WorkspaceImport.js new file mode 100644 index 0000000000000000000000000000000000000000..8386f733233752fe1de5184c6909932345a37eea --- /dev/null +++ b/src/containers/WorkspaceImport.js @@ -0,0 +1,24 @@ +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { withTranslation } from 'react-i18next'; +import { withPlugins } from '../extend'; +import { WorkspaceImport } from '../components/WorkspaceImport'; +import * as actions from '../state/actions'; + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof App + * @private + */ +const mapDispatchToProps = { + addError: actions.addError, + importConfig: actions.importWorkspace, +}; + +const enhance = compose( + withTranslation(), + connect(null, mapDispatchToProps), + withPlugins('WorkspaceImport'), +); + +export default enhance(WorkspaceImport); diff --git a/src/containers/WorkspaceMenu.js b/src/containers/WorkspaceMenu.js index 6ca930ec3e43593e36b0614245282ce2c2954240..d3c1353c77ef475f3ad72fdda043806a9c9b3c00 100644 --- a/src/containers/WorkspaceMenu.js +++ b/src/containers/WorkspaceMenu.js @@ -8,7 +8,7 @@ import { WorkspaceMenu } from '../components/WorkspaceMenu'; /** * mapDispatchToProps - used to hook up connect to action creators - * @memberof ManifestListItem + * @memberof WorkspaceMenu * @private */ const mapDispatchToProps = { diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 5e54b271541ad590c8643e15a6c65eae9585a8aa..86804652308df1cd5cf2134bb442160474d9f1b9 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -21,11 +21,15 @@ "downloadExport": "Download/Export Arbeitsfläche", "downloadExportWorkspace": "Download/Export Arbeitsfläche", "elastic": "Elastisch", + "errorDialogTitle": "Es ist ein Fehler aufgetreten", + "errorDialogConfirm": "OK", "exitFullScreen": "Vollbildmodus verlassen", "expandSidePanel": "Seitenleiste aufklappen", "fetchManifest": "Hinzufügen", "gallery": "Galerie", "hideZoomControls": "Zoomsteuerung verbergen", + "import" : "Import", + "importWorkspace": "Import Arbeitsfläche", "item": "Objekt: {{label}}", "language": "Sprache", "light": "Hell", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 109791ee5fc09fe3fb407c8d00db64815cf7485b..1c5cb4e5feb71b3436ee69fc999455d54f3bd3a0 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -23,11 +23,16 @@ "downloadExport": "Download/Export workspace", "downloadExportWorkspace": "Download/Export workspace", "elastic": "Elastic", + "errorDialogConfirm": "OK", + "errorDialogTitle": "An error occurred", "exitFullScreen": "Exit full screen", "expandSidePanel": "Expand sidebar", "fetchManifest": "Add", "gallery": "Gallery", + "fullScreen": "Full Screen", "hideZoomControls": "Hide zoom controls", + "import" : "Import", + "importWorkspace": "Import workspace", "item": "Item: {{label}}", "language": "Language", "light": "Light theme", diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index 65170e606c8a8744480580726528edf31801791a..1d33453a6fccce38706d739ed3ded3dfcfca4b3e 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -18,6 +18,8 @@ const ActionTypes = { TOGGLE_WORKSPACE_EXPOSE_MODE: 'TOGGLE_WORKSPACE_EXPOSE_MODE', ADD_MANIFEST: 'ADD_MANIFEST', ADD_WINDOW: 'ADD_WINDOW', + ADD_ERROR: 'ADD_ERROR', + IMPORT_CONFIG: 'IMPORT_CONFIG', SET_CANVAS: 'SET_CANVAS', MAXIMIZE_WINDOW: 'MAXIMIZE_WINDOW', MINIMIZE_WINDOW: 'MINIMIZE_WINDOW', @@ -28,6 +30,7 @@ const ActionTypes = { REQUEST_MANIFEST: 'REQUEST_MANIFEST', RECEIVE_MANIFEST: 'RECEIVE_MANIFEST', RECEIVE_MANIFEST_FAILURE: 'RECEIVE_MANIFEST_FAILURE', + REMOVE_ERROR: 'REMOVE_ERROR', SET_CONFIG: 'SET_CONFIG', SET_WINDOW_THUMBNAIL_POSITION: 'SET_WINDOW_THUMBNAIL_POSITION', SET_WINDOW_VIEW_TYPE: 'SET_WINDOW_VIEW_TYPE', diff --git a/src/state/actions/config.js b/src/state/actions/config.js index 5bbbd335ac5c4ab8f016973d531b6772135c7642..827a48497649b72cb3b80f791573c2b51d1eea67 100644 --- a/src/state/actions/config.js +++ b/src/state/actions/config.js @@ -1,10 +1,20 @@ import ActionTypes from './action-types'; +/** + * importConfig - action creator + * + * @param {Object} config + * @memberof ActionCreators + */ +export function importConfig(config) { + return { config, type: ActionTypes.IMPORT_CONFIG }; +} + /** * setConfig - action creator * * @param {Object} config -* @memberof ActionCreators + * @memberof ActionCreators */ export function setConfig(config) { return { config, type: ActionTypes.SET_CONFIG }; @@ -14,7 +24,7 @@ export function setConfig(config) { * updateConfig - action creator * * @param {Object} config -* @memberof ActionCreators + * @memberof ActionCreators */ export function updateConfig(config) { return { config, type: ActionTypes.UPDATE_CONFIG }; diff --git a/src/state/actions/errors.js b/src/state/actions/errors.js new file mode 100644 index 0000000000000000000000000000000000000000..5c1ee22f238379b3163812e56e2c2bafef791397 --- /dev/null +++ b/src/state/actions/errors.js @@ -0,0 +1,22 @@ +import uuid from 'uuid/v4'; +import ActionTypes from './action-types'; + +/** + * addError - action creator + * @param {string} error + */ +export function addError(error) { + return { + id: `error-${uuid()}`, + message: error, + type: ActionTypes.ADD_ERROR, + }; +} + +/** + * removeError - action creator + * @param {string} id + */ +export function removeError(id) { + return { id, type: ActionTypes.REMOVE_ERROR }; +} diff --git a/src/state/actions/index.js b/src/state/actions/index.js index 9465bc715a3c952c12b4ac7a98b8f0daa232e9db..7e792ec59e36944e38050bfe81894f37258c515f 100644 --- a/src/state/actions/index.js +++ b/src/state/actions/index.js @@ -4,6 +4,7 @@ */ export * from './companionWindow'; export * from './config'; +export * from './errors'; export * from './window'; export * from './manifest'; export * from './infoResponse'; diff --git a/src/state/actions/window.js b/src/state/actions/window.js index e925af90750b16925f0752407f8576af1a37cbea..6acea0fe1767d589a96e0b28da6938636184296c 100644 --- a/src/state/actions/window.js +++ b/src/state/actions/window.js @@ -69,11 +69,13 @@ export function addWindow(options) { companionWindows: [ { content: 'info', + default: true, id: cwDefault, position: 'left', }, { content: 'thumbnail_navigation', + default: true, id: cwThumbs, position: options.thumbnailNavigationPosition || config.thumbnailNavigation.defaultPosition, diff --git a/src/state/actions/workspace.js b/src/state/actions/workspace.js index 783ab5bdb14f680e85b20fd7d75bcdb436e2dd3b..b7f0cc5a3b4683c375680be46110cc28e3027eea 100644 --- a/src/state/actions/workspace.js +++ b/src/state/actions/workspace.js @@ -1,4 +1,16 @@ +import { + difference, + keys, + omit, + slice, + values, +} from 'lodash'; import ActionTypes from './action-types'; +import { importConfig } from './config'; +import { addWindow, removeWindow, updateWindow } from './window'; +import { addCompanionWindow, removeCompanionWindow, updateCompanionWindow } from './companionWindow'; +import { updateViewport } from './canvas'; +import { fetchManifest } from './manifest'; /** * setWorkspaceFullscreen - action creator @@ -85,3 +97,95 @@ export function toggleWorkspaceExposeMode() { type: ActionTypes.TOGGLE_WORKSPACE_EXPOSE_MODE, }; } + +/** + * importWorkspace - action creator + */ +export function importWorkspace(stateExport) { + return (dispatch, getState) => { + const { viewers } = stateExport || {}; + const { companionWindows } = stateExport || {}; + const { + exposeModeOn, + height, + viewportPosition, + width, + } = stateExport.workspace; + + const imWins = values(stateExport.windows); + const exWins = values(getState().windows); + const exWinCnt = exWins.length > imWins.length ? imWins.length : exWins.length; + + /* do window independent stuff at first */ + dispatch(importConfig(stateExport.config)); + getState().workspace.exposeModeOn !== exposeModeOn && dispatch(toggleWorkspaceExposeMode()); + dispatch(setWorkspaceViewportDimensions({ height, width })); + dispatch(setWorkspaceViewportPosition(viewportPosition)); + + /* now import the windows */ + + /* + If the existing workspace already contains windows (exWins), + we can re-use them in order to optimize the performance. + As we only can only re-use the amount of windows to be imported maximally, + slice all additional windows before + */ + const exIds = slice(exWins, 0, exWinCnt).map((exWin) => { + const imWin = imWins.shift(); + const viewer = viewers[imWin.id]; + dispatch(fetchManifest(imWin.manifestId)); + /* remove exisiting companionWindows, except the ones marked as default */ + exWin.companionWindowIds + .filter(cwId1 => !getState().companionWindows[cwId1].default) + .map(cwId2 => dispatch(removeCompanionWindow(exWin.id, cwId2))); + + /* update window */ + dispatch(updateWindow(exWin.id, omit(imWin, 'id', 'companionWindowIds', 'thumbnailNavigationId'))); + + /* update default companionWindows */ + exWin.companionWindowIds + // eslint-disable-next-line max-len + .filter(cwId => getState().companionWindows[cwId] && getState().companionWindows[cwId].default) + .map((cwId) => { + const newCw = values(companionWindows) + .find(cw => cw.default && cw.content === getState().companionWindows[cwId].content); + return dispatch(updateCompanionWindow(exWin.id, cwId, omit(newCw, 'id'))); + }); + + /* create non-default companion windows */ + imWin.companionWindowIds + .filter(cwId => !companionWindows[cwId].default) + .map(cwId => dispatch(addCompanionWindow(exWin.id, omit(companionWindows[cwId], 'id')))); + dispatch(updateViewport(exWin.id, viewer)); + return exWin.id; + }); + + /* create new windows for additionally imported ones */ + const imIds = imWins.map((imWin) => { + const viewer = viewers[imWin.id]; + + dispatch(fetchManifest(imWin.manifestId)); + dispatch(addWindow(omit(imWin, ['companionWindowIds', 'thumbnailNavigationId']))); + dispatch(updateViewport(imWin.id, viewer)); + + /* create non-default companion windows */ + values(companionWindows) + .filter(cw => !cw.default) + .map(cw => dispatch(addCompanionWindow(imWin.id, { ...omit(cw, 'id') }, {}))); + + /* update default companion windows */ + values(companionWindows) + .filter(cw => cw.default) + .map((cwNew) => { + const cwOld = values(getState().companionWindows) + .find(el => el.content === cwNew.content); + return dispatch(updateCompanionWindow(imWin.id, cwOld.id, omit(cwNew, 'id'))); + }); + return imWin.id; + }); + + /* close surplus windows */ + difference(keys(getState().windows), exIds.concat(imIds)) + .map(winId => dispatch(removeWindow(winId))); + }; +} diff --git a/src/state/reducers/config.js b/src/state/reducers/config.js index 4663b187b4d8e5ad82ad431391d9db65cb7e7c36..f1eecea23dc3dd0039f6bdf6041e9971e7a42957 100644 --- a/src/state/reducers/config.js +++ b/src/state/reducers/config.js @@ -7,6 +7,7 @@ import ActionTypes from '../actions/action-types'; export const configReducer = (state = {}, action) => { switch (action.type) { case ActionTypes.UPDATE_CONFIG: + case ActionTypes.IMPORT_CONFIG: return deepmerge(state, action.config); case ActionTypes.SET_CONFIG: return action.config; diff --git a/src/state/reducers/errors.js b/src/state/reducers/errors.js new file mode 100644 index 0000000000000000000000000000000000000000..7cbbd50a4b3f37ef7bd519c50a864a9459088dd3 --- /dev/null +++ b/src/state/reducers/errors.js @@ -0,0 +1,26 @@ +import without from 'lodash/without'; +import ActionTypes from '../actions/action-types'; + +const defaultState = { items: [] }; + +/** + * errorsReducer + */ +export const errorsReducer = (state = defaultState, action) => { + let ret; + switch (action.type) { + case ActionTypes.ADD_ERROR: + return { ...state, [action.id]: { id: action.id, message: action.message }, items: [...state.items, action.id] }; // eslint-disable-line max-len + case ActionTypes.REMOVE_ERROR: + ret = Object.keys(state).reduce((object, key) => { + if (key !== action.id) { + object[key] = state[key]; // eslint-disable-line no-param-reassign + } + return object; + }, {}); + ret.items = without(ret.items, action.id); + return ret; + default: + return state; + } +}; diff --git a/src/state/reducers/index.js b/src/state/reducers/index.js index 481f808243359fe4fe7b9d1038e7e2c26e5eb3de..66a5ad6ea91152e0042320972d01e8ca139975a8 100644 --- a/src/state/reducers/index.js +++ b/src/state/reducers/index.js @@ -1,4 +1,5 @@ export * from './companionWindows'; +export * from './errors'; export * from './workspace'; export * from './windows'; export * from './manifests'; diff --git a/src/state/reducers/rootReducer.js b/src/state/reducers/rootReducer.js index 49c3ec0f3bd1314c34d54ef17e29b4ba65d18116..f458e1bc683aaa27b849c3b3bad72a2b0151336c 100644 --- a/src/state/reducers/rootReducer.js +++ b/src/state/reducers/rootReducer.js @@ -2,6 +2,7 @@ import { combineReducers } from 'redux'; import { companionWindowsReducer, configReducer, + errorsReducer, infoResponsesReducer, manifestsReducer, viewersReducer, @@ -20,6 +21,7 @@ export default function createRootReducer(pluginReducers) { annotations: annotationsReducer, companionWindows: companionWindowsReducer, config: configReducer, + errors: errorsReducer, infoResponses: infoResponsesReducer, manifests: manifestsReducer, viewers: viewersReducer, diff --git a/src/state/selectors/workspace.js b/src/state/selectors/workspace.js index c07011a8891290975e027d83f63d40d10eb768ee..a52fc9d6048af6305dc72af7f2d512f2a273df91 100644 --- a/src/state/selectors/workspace.js +++ b/src/state/selectors/workspace.js @@ -9,3 +9,10 @@ export const getFullScreenEnabled = createSelector( [getWorkspace], workspace => workspace.isFullscreenEnabled, ); + +/** Returns the latest error from the state + * @param {object} state + */ +export function getLatestError(state) { + return state.errors.items[0] && state.errors[state.errors.items[0]]; +}