diff --git a/admin/code/CampaignAdmin.php b/admin/code/CampaignAdmin.php index fd12131b9..02e1b5807 100644 --- a/admin/code/CampaignAdmin.php +++ b/admin/code/CampaignAdmin.php @@ -9,12 +9,15 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider { private static $allowed_actions = [ + 'item', + 'items', + 'schema', + 'DetailEditForm', + 'readCampaigns', 'createCampaign', 'readCampaign', 'updateCampaign', 'deleteCampaign', - 'schema', - 'DetailEditForm' ]; private static $menu_priority = 11; @@ -96,27 +99,33 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider { "customValidationMessage": "", "attributes": [], "data": { - "collectionReadUrl": { + "recordType": "ChangeSet", + "collectionReadEndpoint": { "url": "admin\/campaigns\/items", "method": "GET" }, - "itemReadUrl": { + "itemReadEndpoint": { "url": "admin\/campaigns\/item\/:id", "method": "GET" }, - "itemUpdateUrl": { + "itemUpdateEndpoint": { "url": "admin\/campaigns\/item\/:id", "method": "PUT" }, - "itemCreateUrl": { + "itemCreateEndpoint": { "url": "admin\/campaigns\/item\/:id", "method": "POST" }, - "itemDeleteUrl": { + "itemDeleteEndpoint": { "url": "admin\/campaigns\/item\/:id", "method": "DELETE" }, - "editFormSchemaUrl": "admin\/campaigns\/schema\/DetailEditForm" + "editFormSchemaEndpoint": "admin\/campaigns\/schema\/DetailEditForm", + "columns": [ + {"name": "Title", "field": "Name"}, + {"name": "Changes", "field": "_embedded.ChangeSetItems.length"}, + {"name": "Description", "field": "Description"} + ] } }, { "name": "SecurityID", @@ -177,7 +186,182 @@ JSON; public function readCampaigns(SS_HTTPRequest $request) { $response = new SS_HTTPResponse(); $response->addHeader('Content-Type', 'application/json'); - $response->setBody(Convert::raw2json(['campaigns' => 'read'])); + $json = <<setBody($json); return $response; } @@ -192,7 +376,7 @@ JSON; public function readCampaign(SS_HTTPRequest $request) { $response = new SS_HTTPResponse(); $response->addHeader('Content-Type', 'application/json'); - $response->setBody(Convert::raw2json(['campaign' => 'read'])); + $response->setBody(''); return $response; } diff --git a/admin/javascript/src/boot/index.js b/admin/javascript/src/boot/index.js index 38066e280..3a450d278 100644 --- a/admin/javascript/src/boot/index.js +++ b/admin/javascript/src/boot/index.js @@ -7,6 +7,7 @@ import reducerRegister from 'reducer-register'; import * as configActions from 'state/config/actions'; import ConfigReducer from 'state/config/reducer'; import SchemaReducer from 'state/schema/reducer'; +import RecordsReducer from 'state/records/reducer'; // Sections import CampaignAdmin from 'sections/campaign-admin'; @@ -14,6 +15,7 @@ import CampaignAdmin from 'sections/campaign-admin'; function appBoot() { reducerRegister.add('config', ConfigReducer); reducerRegister.add('schemas', SchemaReducer); + reducerRegister.add('records', RecordsReducer); const initialState = {}; const rootReducer = combineReducers(reducerRegister.getAll()); diff --git a/admin/javascript/src/components/form-builder/index.js b/admin/javascript/src/components/form-builder/index.js index c4ae70928..9ada30800 100644 --- a/admin/javascript/src/components/form-builder/index.js +++ b/admin/javascript/src/components/form-builder/index.js @@ -8,6 +8,10 @@ import FormComponent from 'components/form'; import TextField from 'components/text-field'; import HiddenField from 'components/hidden-field'; import GridField from 'components/grid-field'; +import fetch from 'isomorphic-fetch'; + +import es6promise from 'es6-promise'; +es6promise.polyfill(); // Using this to map field types to components until we implement dependency injection. var fakeInjector = { @@ -120,14 +124,17 @@ export class FormBuilderComponent extends SilverStripeComponent { headerValues.push('state'); } - this.formSchemaPromise = $.ajax({ - method: 'GET', + this.formSchemaPromise = fetch(this.props.schemaUrl, { headers: { 'X-FormSchema-Request': headerValues.join() }, - url: this.props.schemaUrl - }).done((data, status, xhr) => { - this.isFetching = false; - this.props.actions.setSchema(data); - }); + credentials: 'same-origin' + }) + .then(response => { + return response.json(); + }) + .then(json => { + this.isFetching = false; + this.props.actions.setSchema(json); + }); this.isFetching = true; diff --git a/admin/javascript/src/components/grid-field/index.js b/admin/javascript/src/components/grid-field/index.js index 5a09e67c8..1b23c4c8c 100644 --- a/admin/javascript/src/components/grid-field/index.js +++ b/admin/javascript/src/components/grid-field/index.js @@ -1,4 +1,6 @@ import React from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; import SilverStripeComponent from 'silverstripe-component.js'; import GridFieldTable from './table'; import GridFieldHeader from './header'; @@ -6,55 +8,43 @@ import GridFieldHeaderCell from './header-cell'; import GridFieldRow from './row'; import GridFieldCell from './cell'; import GridFieldAction from './action'; +import * as actions from 'state/records/actions'; +/** + * The component acts as a container for a grid field, + * with smarts around data retrieval from external sources. + * + * @todo Convert to higher order component which hooks up form schema data to an API backend as a grid data source + * @todo Replace "dumb" inner components with third party library (e.g. https://griddlegriddle.github.io) + */ class GridField extends SilverStripeComponent { constructor(props) { super(props); - this.deleteCampaign = this.deleteCampaign.bind(this); - this.editCampaign = this.editCampaign.bind(this); + this.deleteRecord = this.deleteRecord.bind(this); + this.editRecord = this.editRecord.bind(this); + } - // TODO: This will be an AJAX call and it's response stored in state. - this.mockData = { - campaigns: [ - { - title: 'SilverStripe 4.0 release', - description: 'All the stuff related to the 4.0 announcement', - changes: 20 - }, - { - title: 'March release', - description: 'march release stuff', - changes: 2 - }, - { - title: 'About us', - description: 'The team', - changes: 1345 - } - ] - }; + componentDidMount() { + super.componentDidMount(); + + let data = this.props.data; + + this.props.actions.fetchRecords(data.recordType, data.collectionReadEndpoint.method, data.collectionReadEndpoint.url); } render() { - const columns = [ - { - name: 'title' - }, - { - name: 'changes', - width: 2 - }, - { - name: 'description', - width: 6 - } - ]; + const records = this.props.records; + if(!records) { + return
; + } + + const columns = this.props.data.columns; const actions = [ - , - + , + ]; // Placeholder to align the headers correctly with the content @@ -63,9 +53,11 @@ class GridField extends SilverStripeComponent { const headerCells = columns.map((column, i) => {column.name}); const header = {headerCells.concat(actionPlaceholder)}; - const rows = this.mockData.campaigns.map((campaign, i) => { + const rows = records.map((record, i) => { var cells = columns.map((column, i) => { - return {campaign[column.name]} + // Get value by dot notation + var val = column.field.split('.').reduce((a, b) => a[b], record) + return {val} }); var rowActions = actions.map((action, j) => { @@ -82,14 +74,35 @@ class GridField extends SilverStripeComponent { ); } - deleteCampaign(event) { - // delete campaign + deleteRecord(event) { + // delete record } - editCampaign(event) { - // edit campaign + editRecord(event) { + // edit record } } -export default GridField; +GridField.propTypes = { + data: React.PropTypes.shape({ + recordType: React.PropTypes.string.isRequired, + headerColumns: React.PropTypes.array, + collectionReadEndpoint: React.PropTypes.object + }) +}; + +function mapStateToProps(state, ownProps) { + let recordType = ownProps.data ? ownProps.data.recordType : null; + return { + records: (state.records && recordType) ? state.records[recordType] : [] + } +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch) + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(GridField); diff --git a/admin/javascript/src/sections/campaign-admin/index.js b/admin/javascript/src/sections/campaign-admin/index.js index 001c09e6e..69c15f30d 100644 --- a/admin/javascript/src/sections/campaign-admin/index.js +++ b/admin/javascript/src/sections/campaign-admin/index.js @@ -4,10 +4,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import CampaignAdmin from './controller'; -import campaignsReducer from 'state/campaigns/reducer'; - -// TODO: Move this to the controller. -reducerRegister.add('campaigns', campaignsReducer); $.entwine('ss', function ($) { diff --git a/admin/javascript/src/silverstripe-backend.js b/admin/javascript/src/silverstripe-backend.js index e896765f6..685bd84af 100644 --- a/admin/javascript/src/silverstripe-backend.js +++ b/admin/javascript/src/silverstripe-backend.js @@ -1,16 +1,36 @@ -import $ from 'jQuery'; +import fetch from 'isomorphic-fetch'; +import es6promise from 'es6-promise'; +es6promise.polyfill(); + +/** + * @see https://github.com/github/fetch#handling-http-error-statuses + */ +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response + } else { + var error = new Error(response.statusText) + error.response = response + throw error + } +} class SilverStripeBackend { + constructor() { + // Allow mocking + this.fetch = fetch; + } + /** * Makes a network request using the GET HTTP verb. * * @param string url - Endpoint URL. - * - * @return object - jqXHR. See http://api.jquery.com/Types/#jqXHR + * @return object - Promise */ get(url) { - return $.ajax({ type: 'GET', url }); + return this.fetch(url, { method: 'get', credentials: 'same-origin' }) + .then(checkStatus); } /** @@ -18,11 +38,11 @@ class SilverStripeBackend { * * @param string url - Endpoint URL. * @param object data - Data to send with the request. - * - * @return object - jqXHR. See http://api.jquery.com/Types/#jqXHR + * @return object - Promise */ post(url, data) { - return $.ajax({ type: 'POST', url, data }); + return this.fetch(url, { method: 'post', credentials: 'same-origin', body: data }) + .then(checkStatus); } /** @@ -30,11 +50,11 @@ class SilverStripeBackend { * * @param string url - Endpoint URL. * @param object data - Data to send with the request. - * - * @return object - jqXHR. See http://api.jquery.com/Types/#jqXHR + * @return object - Promise */ put(url, data) { - return $.ajax({ type: 'PUT', url, data }); + return this.fetch(url, { method: 'put', credentials: 'same-origin', body: data }) + .then(checkStatus); } /** @@ -42,11 +62,11 @@ class SilverStripeBackend { * * @param string url - Endpoint URL. * @param object data - Data to send with the request. - * - * @return object - jqXHR. See http://api.jquery.com/Types/#jqXHR + * @return object - Promise */ delete(url, data) { - return $.ajax({ type: 'DELETE', url, data }); + return this.fetch(url, { method: 'delete', credentials: 'same-origin', body: data }) + .then(checkStatus); } } diff --git a/admin/javascript/src/state/campaigns/action-types.js b/admin/javascript/src/state/campaigns/action-types.js deleted file mode 100644 index d52b75776..000000000 --- a/admin/javascript/src/state/campaigns/action-types.js +++ /dev/null @@ -1,8 +0,0 @@ -export default { - CREATE_CAMPAIGN: 'CREATE_CAMPAIGN', - UPDATE_CAMPAIGN: 'UPDATE_CAMPAIGN', - DELETE_CAMPAIGN: 'DELETE_CAMPAIGN', - FETCH_CAMPAIGN_REQUEST: 'FETCH_CAMPAIGN_REQUEST', - FETCH_CAMPAIGN_FAILURE: 'FETCH_CAMPAIGN_FAILURE', - FETCH_CAMPAIGN_SUCCESS: 'FETCH_CAMPAIGN_SUCCESS' -}; diff --git a/admin/javascript/src/state/campaigns/actions.js b/admin/javascript/src/state/campaigns/actions.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/admin/javascript/src/state/campaigns/reducer.js b/admin/javascript/src/state/campaigns/reducer.js deleted file mode 100644 index f9e8d271d..000000000 --- a/admin/javascript/src/state/campaigns/reducer.js +++ /dev/null @@ -1,49 +0,0 @@ -import deepFreeze from 'deep-freeze'; -import ACTION_TYPES from './action-types'; - -const initialState = { - isFetching: false, - items: [] -}; - -function campaignsReducer(state = initialState, action) { - - switch (action.type) { - - case ACTION_TYPES.CREATE_CAMPAIGN: - return deepFreeze(Object.assign({}, state, { - - })); - - case ACTION_TYPES.UPDATE_CAMPAIGN: - return deepFreeze(Object.assign({}, state, { - - })); - - case ACTION_TYPES.DELETE_CAMPAIGN: - return deepFreeze(Object.assign({}, state, { - - })); - - case ACTION_TYPES.FETCH_CAMPAIGN_REQUEST: - return deepFreeze(Object.assign({}, state, { - isFetching: true - })); - - case ACTION_TYPES.FETCH_CAMPAIGN_FAILURE: - return deepFreeze(Object.assign({}, state, { - isFetching: false - })); - - case ACTION_TYPES.FETCH_CAMPAIGN_SUCCESS: - return deepFreeze(Object.assign({}, state, { - isFetching: false - })); - - default: - return state; - } - -} - -export default campaignsReducer; diff --git a/admin/javascript/src/state/campaigns/tests/campaigns-reducer-test.js b/admin/javascript/src/state/campaigns/tests/campaigns-reducer-test.js deleted file mode 100644 index b9d28fa28..000000000 --- a/admin/javascript/src/state/campaigns/tests/campaigns-reducer-test.js +++ /dev/null @@ -1,70 +0,0 @@ -jest.dontMock('deep-freeze'); -jest.dontMock('../reducer'); -jest.dontMock('../action-types'); - -var campaignsReducer = require('../reducer').default, - ACTION_TYPES = require('../action-types').default; - -describe('campaignsReducer', () => { - - describe('CREATE_CAMPAIGN', () => { - - }); - - describe('UPDATE_CAMPAIGN', () => { - - }); - - describe('DELETE_CAMPAIGN', () => { - - }); - - describe('FETCH_CAMPAIGN_REQUEST', () => { - - it('should set the "isFetching" flag', () => { - const initialState = { - isFetching: false - }; - - const action = { type: ACTION_TYPES.FETCH_CAMPAIGN_REQUEST }; - - const nextState = campaignsReducer(initialState, action); - - expect(nextState.isFetching).toBe(true); - }); - - }); - - describe('FETCH_CAMPAIGN_FAILURE', () => { - - it('should unset the "isFetching" flag', () => { - const initialState = { - isFetching: true - }; - - const action = { type: ACTION_TYPES.FETCH_CAMPAIGN_FAILURE }; - - const nextState = campaignsReducer(initialState, action); - - expect(nextState.isFetching).toBe(false); - }); - - }); - - describe('FETCH_CAMPAIGN_SUCCESS', () => { - - it('should unset the "isFetching" flag', () => { - const initialState = { - isFetching: true - }; - - const action = { type: ACTION_TYPES.FETCH_CAMPAIGN_FAILURE }; - - const nextState = campaignsReducer(initialState, action); - - expect(nextState.isFetching).toBe(false); - }); - - }); - -}); diff --git a/admin/javascript/src/state/records/action-types.js b/admin/javascript/src/state/records/action-types.js new file mode 100644 index 000000000..6982bf82f --- /dev/null +++ b/admin/javascript/src/state/records/action-types.js @@ -0,0 +1,8 @@ +export default { + CREATE_RECORD: 'CREATE_RECORD', + UPDATE_RECORD: 'UPDATE_RECORD', + DELETE_RECORD: 'DELETE_RECORD', + FETCH_RECORDS_REQUEST: 'FETCH_RECORDS_REQUEST', + FETCH_RECORDS_FAILURE: 'FETCH_RECORDS_FAILURE', + FETCH_RECORDS_SUCCESS: 'FETCH_RECORDS_SUCCESS' +}; diff --git a/admin/javascript/src/state/records/actions.js b/admin/javascript/src/state/records/actions.js new file mode 100644 index 000000000..3b7f6750d --- /dev/null +++ b/admin/javascript/src/state/records/actions.js @@ -0,0 +1,22 @@ +import ACTION_TYPES from './action-types'; +import fetch from 'isomorphic-fetch'; +import backend from 'silverstripe-backend.js'; + +/** + * Retrieves all records + * + * @param string recordType Type of record (the "class name") + * @param string method HTTP methods + * @param string url API endpoint + */ +export function fetchRecords(recordType, method, url) { + return (dispatch, getState) => { + dispatch ({type: ACTION_TYPES.FETCH_RECORDS_REQUEST, payload: {recordType: recordType}}); + return backend[method.toLowerCase()](url) + .then(response => response.json()) + .then(json => dispatch({type: ACTION_TYPES.FETCH_RECORDS_SUCCESS, payload: {recordType: recordType, data: json}})) + .catch((err) => { + dispatch({type: ACTION_TYPES.FETCH_RECORDS_FAILURE, payload: {error: err, recordType: recordType}}) + }); + } +} diff --git a/admin/javascript/src/state/records/reducer.js b/admin/javascript/src/state/records/reducer.js new file mode 100644 index 000000000..dbc516aaf --- /dev/null +++ b/admin/javascript/src/state/records/reducer.js @@ -0,0 +1,45 @@ +import deepFreeze from 'deep-freeze'; +import ACTION_TYPES from './action-types'; + +const initialState = { +}; + +function recordsReducer(state = initialState, action) { + switch (action.type) { + + case ACTION_TYPES.CREATE_RECORD: + return deepFreeze(Object.assign({}, state, { + + })); + + case ACTION_TYPES.UPDATE_RECORD: + return deepFreeze(Object.assign({}, state, { + + })); + + case ACTION_TYPES.DELETE_RECORD: + return deepFreeze(Object.assign({}, state, { + + })); + + case ACTION_TYPES.FETCH_RECORDS_REQUEST: + return state; + + case ACTION_TYPES.FETCH_RECORDS_FAILURE: + return state; + + case ACTION_TYPES.FETCH_RECORDS_SUCCESS: + let recordType = action.payload.recordType; + // TODO Automatic pluralisation from recordType + let records = action.payload.data._embedded[recordType + 's']; + return deepFreeze(Object.assign({}, state, { + [recordType]: records + })); + + default: + return state; + } + +} + +export default recordsReducer; diff --git a/admin/javascript/src/state/records/tests/reducer-test.js b/admin/javascript/src/state/records/tests/reducer-test.js new file mode 100644 index 000000000..870d5f122 --- /dev/null +++ b/admin/javascript/src/state/records/tests/reducer-test.js @@ -0,0 +1,36 @@ +jest.dontMock('deep-freeze'); +jest.dontMock('../reducer'); +jest.dontMock('../action-types'); + +var recordsReducer = require('../reducer').default, + ACTION_TYPES = require('../action-types').default; + +describe('recordsReducer', () => { + + describe('DELETE_RECORD_SUCCESS', () => { + const initialState = { + TypeA: [ + {ID: 1}, + {ID: 2} + ], + TypeB: [ + {ID: 1}, + {ID: 2} + ] + }; + + it('removes records from the declared type', () => { + const nextState = recordsReducer(initialState, { + type: ACTION_TYPES.DELETE_RECORD_SUCCESS, + payload: { recordType: 'TypeA', id: 2 } + }); + + expect(nextState.TypeA.length).toBe(1); + expect(nextState.TypeA[0].ID).toBe(1); + expect(nextState.TypeB.length).toBe(2); + expect(nextState.TypeB[0].ID).toBe(1); + expect(nextState.TypeB[1].ID).toBe(2); + }) + }); + +}); diff --git a/admin/javascript/src/tests/silverstripe-backend-test.js b/admin/javascript/src/tests/silverstripe-backend-test.js index cded489e9..c3edb3b05 100644 --- a/admin/javascript/src/tests/silverstripe-backend-test.js +++ b/admin/javascript/src/tests/silverstripe-backend-test.js @@ -1,42 +1,48 @@ -jest.mock('jQuery'); +jest.mock('isomorphic-fetch'); jest.unmock('../silverstripe-backend'); -import $ from 'jQuery'; +import fetch from 'isomorphic-fetch'; import backend from '../silverstripe-backend'; +var getFetchMock = function(data) { + let mock = jest.genMockFunction(); + let promise = new Promise((resolve, reject) => { + process.nextTick(() => resolve(data)); + }); + mock.mockReturnValue(promise); + + return mock; +}; + describe('SilverStripeBackend', () => { + beforeAll(() => { + let fetchMock = getFetchMock(); + backend.fetch = fetchMock; + }); + describe('get()', () => { - it('should return a jqXHR', () => { - var jqxhr = backend.get('http://example.com'); - - expect(typeof jqxhr).toBe('object'); - expect(typeof jqxhr.done).toBe('function'); - expect(typeof jqxhr.fail).toBe('function'); - expect(typeof jqxhr.always).toBe('function'); + it('should return a promise', () => { + var promise = backend.get('http://example.com'); + expect(typeof promise).toBe('object'); }); it('should send a GET request to an endpoint', () => { backend.get('http://example.com'); - - expect($.ajax).toBeCalledWith({ - type: 'GET', - url: 'http://example.com' - }); + expect(backend.fetch).toBeCalledWith( + 'http://example.com', + {method: 'get', credentials: 'same-origin'} + ); }); }); describe('post()', () => { - it('should return a jqXHR', () => { - var jqxhr = backend.get('http://example.com/item'); - - expect(typeof jqxhr).toBe('object'); - expect(typeof jqxhr.done).toBe('function'); - expect(typeof jqxhr.fail).toBe('function'); - expect(typeof jqxhr.always).toBe('function'); + it('should return a promise', () => { + var promise = backend.get('http://example.com/item'); + expect(typeof promise).toBe('object'); }); it('should send a POST request to an endpoint', () => { @@ -44,24 +50,19 @@ describe('SilverStripeBackend', () => { backend.post('http://example.com', postData); - expect($.ajax).toBeCalledWith({ - type: 'POST', - url: 'http://example.com', - data: postData - }); + expect(backend.fetch).toBeCalledWith( + 'http://example.com', + {method: 'post', body: postData, credentials: 'same-origin'} + ); }); }); describe('put()', () => { - it('should return a jqXHR', () => { - var jqxhr = backend.get('http://example.com/item'); - - expect(typeof jqxhr).toBe('object'); - expect(typeof jqxhr.done).toBe('function'); - expect(typeof jqxhr.fail).toBe('function'); - expect(typeof jqxhr.always).toBe('function'); + it('should return a promise', () => { + var promise = backend.get('http://example.com/item'); + expect(typeof promise).toBe('object'); }); it('should send a PUT request to an endpoint', () => { @@ -69,24 +70,20 @@ describe('SilverStripeBackend', () => { backend.put('http://example.com', putData); - expect($.ajax).toBeCalledWith({ - type: 'PUT', - url: 'http://example.com', - data: putData - }); + expect(backend.fetch).toBeCalledWith( + 'http://example.com', + {method: 'put', body: putData, credentials: 'same-origin'} + ); }); }); describe('delete()', () => { - it('should return a jqXHR', () => { - var jqxhr = backend.get('http://example.com/item'); + it('should return a promise', () => { + var promise = backend.get('http://example.com/item'); - expect(typeof jqxhr).toBe('object'); - expect(typeof jqxhr.done).toBe('function'); - expect(typeof jqxhr.fail).toBe('function'); - expect(typeof jqxhr.always).toBe('function'); + expect(typeof promise).toBe('object'); }); it('should send a DELETE request to an endpoint', () => { @@ -94,11 +91,10 @@ describe('SilverStripeBackend', () => { backend.delete('http://example.com', deleteData); - expect($.ajax).toBeCalledWith({ - type: 'DELETE', - url: 'http://example.com', - data: deleteData - }); + expect(backend.fetch).toBeCalledWith( + 'http://example.com', + {method: 'delete', body: deleteData, credentials: 'same-origin'} + ); }); }); diff --git a/package.json b/package.json index c05f64681..cbf73993c 100644 --- a/package.json +++ b/package.json @@ -39,15 +39,15 @@ "blueimp-tmpl": "^1.0.2", "bootstrap": "^4.0.0-alpha.2", "deep-freeze": "0.0.1", + "es6-promise": "^3.1.2", + "isomorphic-fetch": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", "jquery-sizes": "^0.33.0", - "npm-shrinkwrap": "^5.4.1", "page.js": "^4.13.3", "react": "^0.14.7", "react-addons-css-transition-group": "^0.14.7", - "react-addons-test-utils": "^0.14.7", "react-dom": "^0.14.7", "react-redux": "^4.0.6", - "redux": "^3.3.1", + "redux": "https://registry.npmjs.org/redux/-/redux-3.0.5.tgz", "redux-thunk": "^1.0.3", "tinymce": "^4.3.3" },