diff --git a/cypress/integration/create.js b/cypress/integration/create.js index 7b40013055..0574a5b50e 100644 --- a/cypress/integration/create.js +++ b/cypress/integration/create.js @@ -63,7 +63,7 @@ describe('Create Page', () => { ); }); - it('should redirect to show page after create success', () => { + it('should redirect to edit page after create success', () => { const values = [ { type: 'input', @@ -79,6 +79,33 @@ describe('Create Page', () => { CreatePage.setValues(values); CreatePage.submit(); + EditPage.waitUntilVisible(); + cy.get(EditPage.elements.input('title')).should(el => + expect(el).to.have.value('Test title') + ); + cy.get(EditPage.elements.input('teaser')).should(el => + expect(el).to.have.value('Test teaser') + ); + + EditPage.delete(); + }); + + it('should redirect to show page after create success with "Save and show"', () => { + const values = [ + { + type: 'input', + name: 'title', + value: 'Test title', + }, + { + type: 'textarea', + name: 'teaser', + value: 'Test teaser', + }, + ]; + + CreatePage.setValues(values); + CreatePage.submitAndShow(); ShowPage.waitUntilVisible(); EditPage.navigate(); EditPage.delete(); diff --git a/cypress/support/CreatePage.js b/cypress/support/CreatePage.js index b0ca1384bc..7f01eed999 100644 --- a/cypress/support/CreatePage.js +++ b/cypress/support/CreatePage.js @@ -7,8 +7,10 @@ export default url => ({ inputs: `.ra-input`, snackbar: 'div[role="alertdialog"]', submitButton: ".create-page button[type='submit']", - submitAndAddButton: + submitAndShowButton: ".create-page form>div:last-child button[type='button']:nth-child(2)", + submitAndAddButton: + ".create-page form>div:last-child button[type='button']:nth-child(3)", submitCommentable: ".create-page form>div:last-child button[type='button']:last-child", descInput: '.ql-editor', @@ -55,6 +57,13 @@ export default url => ({ cy.wait(200); // let the notification disappear (could block further submits) }, + submitAndShow() { + cy.get(this.elements.submitAndShowButton).click(); + cy.get(this.elements.snackbar); + cy.get(this.elements.body).click(); // dismiss notification + cy.wait(200); // let the notification disappear (could block further submits) + }, + submitAndAdd() { cy.get(this.elements.submitAndAddButton).click(); cy.get(this.elements.snackbar); diff --git a/docs/CustomApp.md b/docs/CustomApp.md index 716061491c..38e1387cbe 100644 --- a/docs/CustomApp.md +++ b/docs/CustomApp.md @@ -66,8 +66,8 @@ export default ({ compose( applyMiddleware( sagaMiddleware, - routerMiddleware(history), formMiddleware, + routerMiddleware(history), // add your own middlewares here ), typeof window !== 'undefined' && window.devToolsExtension diff --git a/examples/simple/src/i18n/en.js b/examples/simple/src/i18n/en.js index 83c93044d5..cdb8228a57 100644 --- a/examples/simple/src/i18n/en.js +++ b/examples/simple/src/i18n/en.js @@ -66,6 +66,7 @@ export const messages = { title: 'Post "%{title}"', }, action: { + save_and_edit: 'Save and Edit', save_and_add: 'Save and Add', save_and_show: 'Save and Show', save_with_average_note: 'Save with Note', diff --git a/examples/simple/src/posts/PostCreate.js b/examples/simple/src/posts/PostCreate.js index cc3a0e46ea..fe5c7155f9 100644 --- a/examples/simple/src/posts/PostCreate.js +++ b/examples/simple/src/posts/PostCreate.js @@ -52,10 +52,16 @@ const SaveWithNoteButton = connect( const PostCreateToolbar = props => ( + ({ type: INITIALIZE_FORM, @@ -9,3 +10,9 @@ export const initializeForm = initialValues => ({ export const resetForm = () => ({ type: RESET_FORM, }); + +export const beforeLocationChange = ({ payload, meta }) => ({ + type: BEFORE_LOCATION_CHANGE, + payload, + meta, +}); diff --git a/packages/ra-core/src/createAdminStore.js b/packages/ra-core/src/createAdminStore.js index a04d7af80a..7cadb1c2e6 100644 --- a/packages/ra-core/src/createAdminStore.js +++ b/packages/ra-core/src/createAdminStore.js @@ -7,6 +7,7 @@ import { USER_LOGOUT } from './actions/authActions'; import createAppReducer from './reducer'; import { adminSaga } from './sideEffect'; import { defaultI18nProvider } from './i18n'; +import formMiddleware from './form/formMiddleware'; export default ({ authProvider, @@ -36,7 +37,11 @@ export default ({ resettableAppReducer, initialState, compose( - applyMiddleware(sagaMiddleware, routerMiddleware(history)), + applyMiddleware( + sagaMiddleware, + formMiddleware, + routerMiddleware(history) + ), typeof window !== 'undefined' && window.devToolsExtension ? window.devToolsExtension() : f => f diff --git a/packages/ra-core/src/form/formMiddleware.js b/packages/ra-core/src/form/formMiddleware.js new file mode 100644 index 0000000000..24cfaf862c --- /dev/null +++ b/packages/ra-core/src/form/formMiddleware.js @@ -0,0 +1,43 @@ +import { LOCATION_CHANGE } from 'react-router-redux'; +import { destroy } from 'redux-form'; +import isEqual from 'lodash/isEqual'; + +import { resetForm } from '../actions/formActions'; +import { REDUX_FORM_NAME } from '../form/constants'; + +/** + * This middleware ensure that whenever a location change happen, we get the + * chance to properly reset the redux-form record form, preventing data to be + * kept between different resources or form types (CREATE, EDIT). + * + * A middleware is needed instead of a saga because we need to control the actions + * order: we need to ensure we reset the redux form BEFORE the location actually + * changes. Otherwise, the new page which may contain a record redux-form might + * initialize before our reset and loose its data. + */ +const formMiddleware = () => { + let previousLocation; + return next => action => { + if ( + action.type !== LOCATION_CHANGE || + (action.payload.state && action.payload.state.skipFormReset) + ) { + return next(action); + } + + // history allows one to redirect to the same location which can happen + // when using a special menu for a create page for instance. In this case, + // we don't want to reset the form. + // See https://github.com/marmelab/react-admin/issues/2291 + if (isEqual(action.payload, previousLocation)) { + return next(action); + } + + previousLocation = action.payload; + next(resetForm()); + next(destroy(REDUX_FORM_NAME)); + return next(action); + }; +}; + +export default formMiddleware; diff --git a/packages/ra-core/src/form/formMiddleware.spec.js b/packages/ra-core/src/form/formMiddleware.spec.js new file mode 100644 index 0000000000..6f54cd441f --- /dev/null +++ b/packages/ra-core/src/form/formMiddleware.spec.js @@ -0,0 +1,62 @@ +import { LOCATION_CHANGE } from 'react-router-redux'; +import { destroy } from 'redux-form'; + +import formMiddleware from './formMiddleware'; +import { REDUX_FORM_NAME } from '../form/constants'; +import { resetForm } from '../actions/formActions'; + +describe('form middleware', () => { + it('does not prevent actions other than LOCATION_CHANGE to be handled', () => { + const next = jest.fn(); + const action = { type: '@@redux-form/INITIALIZE' }; + + formMiddleware()(next)(action); + + expect(next).toHaveBeenCalledWith(action); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('does not prevent LOCATION_CHANGE actions to be handled if their state contains a skipFormReset set to true', () => { + const next = jest.fn(); + const action = { + type: LOCATION_CHANGE, + payload: { state: { skipFormReset: true } }, + }; + formMiddleware()(next)(action); + + expect(next).toHaveBeenCalledWith(action); + }); + + it('resets the record state and destroy the redux form before letting the location change to be handled', () => { + const next = jest.fn(); + const action = { + type: LOCATION_CHANGE, + payload: { + pathname: '/posts/create', + }, + }; + formMiddleware()(next)(action); + + expect(next).toHaveBeenCalledWith(resetForm()); + expect(next).toHaveBeenCalledWith(destroy(REDUX_FORM_NAME)); + expect(next).toHaveBeenCalledWith(action); + expect(next).toHaveBeenCalledTimes(3); + }); + + it('does not resets the record and form if LOCATION_CHANGE targets the same location', () => { + const next = jest.fn(); + const action = { + type: LOCATION_CHANGE, + payload: { + pathname: '/posts/create', + }, + }; + const middleware = formMiddleware()(next); + middleware(action); + middleware(action); + expect(next).toHaveBeenCalledWith(resetForm()); + expect(next).toHaveBeenCalledWith(destroy(REDUX_FORM_NAME)); + expect(next).toHaveBeenCalledWith(action); + expect(next).toHaveBeenCalledTimes(4); + }); +}); diff --git a/packages/ra-core/src/sideEffect/admin.js b/packages/ra-core/src/sideEffect/admin.js index 61885b6740..3ed89b2763 100644 --- a/packages/ra-core/src/sideEffect/admin.js +++ b/packages/ra-core/src/sideEffect/admin.js @@ -9,7 +9,6 @@ import redirection from './redirection'; import accumulate from './accumulate'; import refresh from './refresh'; import undo from './undo'; -import recordForm from './recordForm'; /** * @param {Object} dataProvider A Data Provider function @@ -27,6 +26,5 @@ export default (dataProvider, authProvider, i18nProvider) => refresh(), notification(), callback(), - recordForm(), ]); }; diff --git a/packages/ra-core/src/sideEffect/index.js b/packages/ra-core/src/sideEffect/index.js index 500d847f55..d6b11df62c 100644 --- a/packages/ra-core/src/sideEffect/index.js +++ b/packages/ra-core/src/sideEffect/index.js @@ -9,4 +9,3 @@ export accumulateSaga from './accumulate'; export refreshSaga from './refresh'; export i18nSaga from './i18n'; export undoSaga from './undo'; -export recordForm from './recordForm'; diff --git a/packages/ra-core/src/sideEffect/recordForm.js b/packages/ra-core/src/sideEffect/recordForm.js deleted file mode 100644 index d3c2ba47a2..0000000000 --- a/packages/ra-core/src/sideEffect/recordForm.js +++ /dev/null @@ -1,28 +0,0 @@ -import { put, takeEvery } from 'redux-saga/effects'; -import { LOCATION_CHANGE } from 'react-router-redux'; -import { destroy } from 'redux-form'; -import isEqual from 'lodash/isEqual'; - -import { resetForm } from '../actions/formActions'; -import { REDUX_FORM_NAME } from '../form/constants'; - -let previousLocation; - -export function* handleLocationChange({ payload }) { - if (payload.state && payload.state.skipFormReset) { - return; - } - - if (isEqual(payload, previousLocation)) { - return; - } - - previousLocation = payload; - - yield put(resetForm()); - yield put(destroy(REDUX_FORM_NAME)); -} - -export default function* recordForm() { - yield takeEvery(LOCATION_CHANGE, handleLocationChange); -} diff --git a/packages/ra-core/src/sideEffect/recordForm.spec.js b/packages/ra-core/src/sideEffect/recordForm.spec.js deleted file mode 100644 index 9ba3b76838..0000000000 --- a/packages/ra-core/src/sideEffect/recordForm.spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { put } from 'redux-saga/effects'; -import { LOCATION_CHANGE } from 'react-router-redux'; -import { destroy } from 'redux-form'; -import { resetForm } from '../actions/formActions'; -import { handleLocationChange } from './recordForm'; -import { REDUX_FORM_NAME } from '../form/constants'; - -describe('recordForm saga', () => { - it('resets the form when the LOCATION_CHANGE action has no state', () => { - const saga = handleLocationChange({ - type: LOCATION_CHANGE, - payload: { pathname: '/comments/create' }, - }); - - expect(saga.next().value).toEqual(put(resetForm())); - expect(saga.next().value).toEqual(put(destroy(REDUX_FORM_NAME))); - }); - - it('does not reset the form when the LOCATION_CHANGE action state has skipFormReset set to true', () => { - const saga = handleLocationChange({ - type: LOCATION_CHANGE, - payload: { - pathname: '/comments/create/2', - state: { skipFormReset: true }, - }, - }); - - expect(saga.next().value).toBeUndefined(); - }); - - it('does not reset the form when navigating to same location', () => { - const saga = handleLocationChange({ - type: LOCATION_CHANGE, - payload: { - pathname: '/comments/create/2', - }, - }); - saga.next(); - saga.next(); - saga.next(); - - const saga2 = handleLocationChange({ - type: LOCATION_CHANGE, - payload: { - pathname: '/comments/create/2', - }, - }); - - expect(saga2.next().value).toBeUndefined(); - }); -});