diff --git a/__tests__/src/actions/index.test.js b/__tests__/src/actions/index.test.js index 17dfa2caebf95e7eebe76a752ef6fce4e806c9a7..1dd4c73127380b22a29b4a904e46290115d45955 100644 --- a/__tests__/src/actions/index.test.js +++ b/__tests__/src/actions/index.test.js @@ -133,4 +133,77 @@ describe('actions', () => { expect(actions.removeManifest('foo')).toEqual(expectedAction); }); }); + describe('requestInfoResponse', () => { + it('requests an infoResponse from given a url', () => { + const id = 'abc123'; + const expectedAction = { + type: ActionTypes.REQUEST_INFO_RESPONSE, + infoId: id, + }; + expect(actions.requestInfoResponse(id)).toEqual(expectedAction); + }); + }); + describe('receiveInfoResponse', () => { + it('recieves an infoResponse', () => { + const id = 'abc123'; + const json = { + id, + content: 'image information request', + }; + const expectedAction = { + type: ActionTypes.RECEIVE_INFO_RESPONSE, + infoId: id, + infoJson: json, + }; + expect(actions.receiveInfoResponse(id, json)).toEqual(expectedAction); + }); + }); + describe('fetchInfoResponse', () => { + let store = null; + beforeEach(() => { + store = mockStore({}); + }); + describe('success response', () => { + beforeEach(() => { + fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); // eslint-disable-line no-undef + }); + it('dispatches the REQUEST_MANIFEST action', () => { + store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json')); + expect(store.getActions()).toEqual([ + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' }, + ]); + }); + it('dispatches the REQUEST_MANIFEST and then RECEIVE_MANIFEST', () => { + store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json')) + .then(() => { + const expectedActions = store.getActions(); + expect(expectedActions).toEqual([ + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' }, + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', infoJson: { data: '12345' }, type: 'RECEIVE_INFO_RESPONSE' }, + ]); + }); + }); + }); + describe('error response', () => { + it('dispatches the REQUEST_INFO_RESPONSE and then RECEIVE_INFO_RESPONSE', () => { + store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json')) + .then(() => { + const expectedActions = store.getActions(); + expect(expectedActions).toEqual([ + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' }, + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', error: new Error('invalid json response body at undefined reason: Unexpected end of JSON input'), type: 'RECEIVE_INFO_RESPONSE_FAILURE' }, + ]); + }); + }); + }); + }); + describe('removeInfoResponse', () => { + it('removes an existing infoResponse', () => { + const expectedAction = { + type: ActionTypes.REMOVE_INFO_RESPONSE, + infoId: 'foo', + }; + expect(actions.removeInfoResponse('foo')).toEqual(expectedAction); + }); + }); }); diff --git a/__tests__/src/reducers/infoResponse.test.js b/__tests__/src/reducers/infoResponse.test.js new file mode 100644 index 0000000000000000000000000000000000000000..7841eb8b5e882383bbc88822d720f92afc38558c --- /dev/null +++ b/__tests__/src/reducers/infoResponse.test.js @@ -0,0 +1,70 @@ +import reducer from '../../../src/reducers/infoResponses'; +import ActionTypes from '../../../src/action-types'; + +describe('manifests reducer', () => { + it('should handle REQUEST_INFO_RESPONSE', () => { + expect(reducer({}, { + type: ActionTypes.REQUEST_INFO_RESPONSE, + infoId: 'abc123', + })).toEqual({ + abc123: { + isFetching: true, + }, + }); + }); + it('should handle RECEIVE_INFO_RESPONSE', () => { + expect(reducer( + { + abc123: { + isFetching: true, + }, + }, + { + type: ActionTypes.RECEIVE_INFO_RESPONSE, + infoId: 'abc123', + infoJson: { + id: 'abc123', + '@type': 'sc:Manifest', + content: 'lots of canvases and metadata and such', + }, + }, + )).toMatchObject({ + abc123: { + isFetching: false, + json: {}, + }, + }); + }); + it('should handle RECEIVE_INFO_RESPONSE_FAILURE', () => { + expect(reducer( + { + abc123: { + isFetching: true, + }, + }, + { + type: ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE, + infoId: 'abc123', + error: "This institution didn't enable CORS.", + }, + )).toEqual({ + abc123: { + isFetching: false, + error: "This institution didn't enable CORS.", + }, + }); + }); + it('should handle REMOVE_INFO_RESPONSE', () => { + expect(reducer( + { + abc123: { + stuff: 'foo', + }, + }, + { + type: ActionTypes.REMOVE_INFO_RESPONSE, + infoId: 'abc123', + }, + )).toEqual({}); + }); +}); diff --git a/src/action-types.js b/src/action-types.js index 70d859bca9806ece145f2f584eec180b109f1daa..9783cb967f276c8c5974510e9448cb22646dc649 100644 --- a/src/action-types.js +++ b/src/action-types.js @@ -12,6 +12,10 @@ const ActionTypes = { SET_CONFIG: 'SET_CONFIG', UPDATE_CONFIG: 'UPDATE_CONFIG', REMOVE_MANIFEST: 'REMOVE_MANIFEST', + REQUEST_INFO_RESPONSE: 'REQUEST_INFO_RESPONSE', + RECEIVE_INFO_RESPONSE: 'RECEIVE_INFO_RESPONSE', + RECEIVE_INFO_RESPONSE_FAILURE: 'RECEIVE_INFO_RESPONSE_FAILURE', + REMOVE_INFO_RESPONSE: 'REMOVE_INFO_RESPONSE', }; export default ActionTypes; diff --git a/src/actions/index.js b/src/actions/index.js index 5ecbb27a30b7cf62b700fcb37fd356a5fb8923fb..ec56ae64a9757709b08108efe0f7ad38a45055b9 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -156,3 +156,72 @@ export function fetchManifest(manifestId) { export function removeManifest(manifestId) { return { type: ActionTypes.REMOVE_MANIFEST, manifestId }; } + +/** + * requestInfoResponse - action creator + * + * @param {String} infoId + * @memberof ActionCreators + */ +export function requestInfoResponse(infoId) { + return { + type: ActionTypes.REQUEST_INFO_RESPONSE, + infoId, + }; +} + +/** + * receiveInfoResponse - action creator + * + * @param {String} infoId + * @param {Object} manifestJson + * @memberof ActionCreators + */ +export function receiveInfoResponse(infoId, infoJson) { + return { + type: ActionTypes.RECEIVE_INFO_RESPONSE, + infoId, + infoJson, + }; +} + +/** + * receiveInfoResponseFailure - action creator + * + * @param {String} infoId + * @param {String} error + * @memberof ActionCreators + */ +export function receiveInfoResponseFailure(infoId, error) { + return { + type: ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE, + infoId, + error, + }; +} + +/** + * fetchInfoResponse - action creator + * + * @param {String} infoId + * @memberof ActionCreators + */ +export function fetchInfoResponse(infoId) { + return ((dispatch) => { + dispatch(requestInfoResponse(infoId)); + return fetch(infoId) + .then(response => response.json()) + .then(json => dispatch(receiveInfoResponse(infoId, json))) + .catch(error => dispatch(receiveInfoResponseFailure(infoId, error))); + }); +} + +/** + * removeInfoResponse - action creator + * + * @param {String} infoId + * @memberof ActionCreators + */ +export function removeInfoResponse(infoId) { + return { type: ActionTypes.REMOVE_INFO_RESPONSE, infoId }; +} diff --git a/src/components/WindowViewer.js b/src/components/WindowViewer.js index f0624e5f25f26e4333c8d8dabe98984909eb93d7..77f77b7fd45e794a444861f6f1f7816f42e2f4b8 100644 --- a/src/components/WindowViewer.js +++ b/src/components/WindowViewer.js @@ -1,6 +1,7 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; -import fetch from 'node-fetch'; +import { connect } from 'react-redux'; +import { actions } from '../store'; import miradorWithPlugins from '../lib/miradorWithPlugins'; import OpenSeadragonViewer from './OpenSeadragonViewer'; import ViewerNavigation from './ViewerNavigation'; @@ -18,9 +19,6 @@ class WindowViewer extends Component { const { manifest } = this.props; this.canvases = manifest.manifestation.getSequences()[0].getCanvases(); - this.state = { - tileSources: [], - }; } /** @@ -28,7 +26,8 @@ class WindowViewer extends Component { * Request the initial canvas on mount */ componentDidMount() { - this.requestAndUpdateTileSources(); + const { fetchInfoResponse } = this.props; + fetchInfoResponse(this.imageInformationUri()); } /** @@ -36,23 +35,43 @@ class WindowViewer extends Component { * Request a new canvas if it is needed */ componentDidUpdate(prevProps) { - const { window } = this.props; - if (prevProps.window.canvasIndex !== window.canvasIndex) { - this.requestAndUpdateTileSources(); + const { window, fetchInfoResponse } = this.props; + if (prevProps.window.canvasIndex !== window.canvasIndex && !this.infoResponseIsInStore()) { + fetchInfoResponse(this.imageInformationUri()); } } /** + * infoResponseIsInStore - checks whether or not an info response is already + * in the store. No need to request it again. + * @return [Boolean] + */ + infoResponseIsInStore() { + const { infoResponses } = this.props; + const currentInfoResponse = infoResponses[this.imageInformationUri()]; + return (currentInfoResponse !== undefined + && currentInfoResponse.isFetching === false + && currentInfoResponse.json !== undefined); + } + + /** + * Constructs an image information URI to request from a canvas */ - requestAndUpdateTileSources() { + imageInformationUri() { const { window } = this.props; - fetch(`${this.canvases[window.canvasIndex].getImages()[0].getResource().getServices()[0].id}/info.json`) - .then(response => response.json()) - .then((json) => { - this.setState({ - tileSources: [json], - }); - }); + return `${this.canvases[window.canvasIndex].getImages()[0].getResource().getServices()[0].id}/info.json`; + } + + /** + * Return an image information response from the store for the correct image + */ + tileInfoFetchedFromStore() { + const { infoResponses } = this.props; + return [infoResponses[this.imageInformationUri()]] + .filter(infoResponse => (infoResponse !== undefined + && infoResponse.isFetching === false + && infoResponse.error === undefined)) + .map(infoResponse => infoResponse.json); } /** @@ -60,19 +79,43 @@ class WindowViewer extends Component { */ render() { const { window } = this.props; - const { tileSources } = this.state; return ( <Fragment> - <OpenSeadragonViewer tileSources={tileSources} window={window} /> + <OpenSeadragonViewer + tileSources={this.tileInfoFetchedFromStore()} + window={window} + /> <ViewerNavigation window={window} canvases={this.canvases} /> </Fragment> ); } } +/** + * mapStateToProps - to hook up connect + * @memberof WindowViewer + * @private + */ +const mapStateToProps = state => ( + { + infoResponses: state.infoResponses, + } +); + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof WindowViewer + * @private + */ +const mapDispatchToProps = dispatch => ({ + fetchInfoResponse: infoId => dispatch(actions.fetchInfoResponse(infoId)), +}); + WindowViewer.propTypes = { + infoResponses: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + fetchInfoResponse: PropTypes.func.isRequired, manifest: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; -export default miradorWithPlugins(WindowViewer); +export default connect(mapStateToProps, mapDispatchToProps)(miradorWithPlugins(WindowViewer)); diff --git a/src/reducers/index.js b/src/reducers/index.js index e494f7e3834f334b24ade689c0c384231b610564..a52f1c82bd3259b2a501689a603aad622bbe5c2d 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -3,6 +3,7 @@ import workspaceReducer from './workspace'; import windowsReducer from './windows'; import manifestsReducer from './manifests'; import configReducer from './config'; +import infoResponsesReducer from './infoResponses'; /** * Action Creators for Mirador @@ -14,6 +15,7 @@ const rootReducer = combineReducers({ windows: windowsReducer, manifests: manifestsReducer, config: configReducer, + infoResponses: infoResponsesReducer, }); export default rootReducer; diff --git a/src/reducers/infoResponses.js b/src/reducers/infoResponses.js new file mode 100644 index 0000000000000000000000000000000000000000..9954dcb9b4c7ad1480fe09062a0c1ef98cd0591f --- /dev/null +++ b/src/reducers/infoResponses.js @@ -0,0 +1,39 @@ +import ActionTypes from '../action-types'; + +/** + * infoResponsesReducer + */ +const infoResponsesReducer = (state = {}, action) => { + switch (action.type) { + case ActionTypes.REQUEST_INFO_RESPONSE: + return Object.assign({}, state, { + [action.infoId]: { + isFetching: true, + }, + }); + case ActionTypes.RECEIVE_INFO_RESPONSE: + return Object.assign({}, state, { + [action.infoId]: { + json: action.infoJson, + isFetching: false, + }, + }); + case ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE: + return Object.assign({}, state, { + [action.infoId]: { + error: action.error, + isFetching: false, + }, + }); + case ActionTypes.REMOVE_INFO_RESPONSE: + return Object.keys(state).reduce((object, key) => { + if (key !== action.infoId) { + object[key] = state[key]; // eslint-disable-line no-param-reassign + } + return object; + }, {}); + default: return state; + } +}; + +export default infoResponsesReducer;