From 7fcdf354382772e4b526bd0b155c0917e885cc79 Mon Sep 17 00:00:00 2001 From: David Craig Date: Tue, 12 Apr 2016 16:47:24 +1200 Subject: [PATCH] Add DetailEditForm to Campaign admin - Add edit form to campaigns section - Handle form submissions with FormBuilder - Handle form state via Redux - Garbage collect form state - Removes $itemID as a required param for schema requests. Developers should be able to scaffold forms without populating values from an existing record. For example when building a form for creating new records. --- admin/client/src/boot/index.js | 2 + .../src/components/FormAction/README.md | 2 +- .../src/components/FormBuilder/FormBuilder.js | 215 +++++++++++++++++- .../src/components/FormBuilder/README.md | 4 + .../src/components/GridField/GridField.js | 19 +- .../client/src/components/TextField/README.md | 4 +- .../src/components/TextField/TextField.js | 25 +- .../TextField/tests/TextField-test.js | 8 +- admin/client/src/components/form/Form.js | 41 ++-- admin/client/src/components/form/README.md | 10 + .../containers/CampaignAdmin/CampaignAdmin.js | 92 ++++++-- admin/client/src/lib/backend.js | 2 +- .../src/state/campaign/CampaignReducer.js | 4 +- admin/client/src/state/forms/README.md | 24 ++ admin/client/src/state/forms/action-types.js | 8 + admin/client/src/state/forms/actions.js | 79 +++++++ admin/client/src/state/forms/reducer.js | 42 ++++ .../src/state/forms/tests/reducer-test.js | 126 ++++++++++ admin/client/src/state/schema/README.md | 5 +- admin/code/CampaignAdmin.php | 32 ++- admin/code/LeftAndMain.php | 22 +- tests/forms/FormSchemaTest.php | 12 +- 22 files changed, 688 insertions(+), 90 deletions(-) create mode 100644 admin/client/src/state/forms/README.md create mode 100644 admin/client/src/state/forms/action-types.js create mode 100644 admin/client/src/state/forms/actions.js create mode 100644 admin/client/src/state/forms/reducer.js create mode 100644 admin/client/src/state/forms/tests/reducer-test.js diff --git a/admin/client/src/boot/index.js b/admin/client/src/boot/index.js index cda2b6086..a2e5c84b6 100644 --- a/admin/client/src/boot/index.js +++ b/admin/client/src/boot/index.js @@ -6,6 +6,7 @@ import reducerRegister from 'lib/ReducerRegister'; import * as configActions from 'state/config/ConfigActions'; import ConfigReducer from 'state/config/ConfigReducer'; +import FormsReducer from 'state/forms/FormsReducer'; import SchemaReducer from 'state/schema/SchemaReducer'; import RecordsReducer from 'state/records/RecordsReducer'; import CampaignReducer from 'state/campaign/CampaignReducer'; @@ -16,6 +17,7 @@ import CampaignAdmin from 'containers/CampaignAdmin/index'; function appBoot() { reducerRegister.add('config', ConfigReducer); + reducerRegister.add('forms', FormsReducer); reducerRegister.add('schemas', SchemaReducer); reducerRegister.add('records', RecordsReducer); reducerRegister.add('campaign', CampaignReducer); diff --git a/admin/client/src/components/FormAction/README.md b/admin/client/src/components/FormAction/README.md index ffd1887ce..3872ac1c8 100644 --- a/admin/client/src/components/FormAction/README.md +++ b/admin/client/src/components/FormAction/README.md @@ -4,7 +4,7 @@ Used for form actions. For example a submit button. ## Props -### handleClick (function - required) +### handleClick (function) The handler for when a button is clicked diff --git a/admin/client/src/components/FormBuilder/FormBuilder.js b/admin/client/src/components/FormBuilder/FormBuilder.js index 23a37fd12..d72fd39a8 100644 --- a/admin/client/src/components/FormBuilder/FormBuilder.js +++ b/admin/client/src/components/FormBuilder/FormBuilder.js @@ -1,14 +1,17 @@ import React from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; +import * as formsActions from 'state/forms/FormsActions'; import * as schemaActions from 'state/schema/SchemaActions'; import SilverStripeComponent from 'lib/SilverStripeComponent'; import FormComponent from 'components/Form/Form'; +import FormActionComponent from 'components/FormAction/FormAction'; import TextField from 'components/TextField/TextField'; import HiddenField from 'components/HiddenField/HiddenField'; import GridField from 'components/GridField/GridField'; import fetch from 'isomorphic-fetch'; import deepFreeze from 'deep-freeze'; +import backend from 'lib/Backend'; import es6promise from 'es6-promise'; es6promise.polyfill(); @@ -47,7 +50,7 @@ const fakeInjector = { */ getComponentByDataType(dataType) { switch (dataType) { - case 'String': + case 'Text': return this.components.TextField; case 'Hidden': return this.components.HiddenField; @@ -66,7 +69,12 @@ export class FormBuilderComponent extends SilverStripeComponent { this.formSchemaPromise = null; this.state = { isFetching: false }; + + this.mapActionsToComponents = this.mapActionsToComponents.bind(this); this.mapFieldsToComponents = this.mapFieldsToComponents.bind(this); + this.handleFieldUpdate = this.handleFieldUpdate.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.removeForm = this.removeForm.bind(this); } componentDidMount() { @@ -82,7 +90,7 @@ export class FormBuilderComponent extends SilverStripeComponent { * * @return object - Promise from the AJAX request. */ - fetch(schema = true, state = false) { + fetch(schema = true, state = true) { const headerValues = []; if (this.state.isFetching === true) { @@ -103,9 +111,34 @@ export class FormBuilderComponent extends SilverStripeComponent { }) .then(response => response.json()) .then(json => { + const formSchema = Object.assign({}, { id: json.id, schema: json.schema }); + const formState = Object.assign({}, json.state); + // TODO See "Enable once ..." below - this.setState({ isFetching: false }); - this.props.actions.setSchema(json); + // this.setState({ isFetching: false }); + + if (typeof formSchema.id !== 'undefined') { + const defaultData = { + ID: formSchema.schema.id, + SecurityID: this.props.config.SecurityID, + }; + + if (formSchema.schema.actions.length > 0) { + defaultData[formSchema.schema.actions[0].name] = 1; + } + + this.submitApi = backend.createEndpointFetcher({ + url: formSchema.schema.attributes.action, + method: formSchema.schema.attributes.method, + defaultData, + }); + + this.props.schemaActions.setSchema(formSchema); + } + + if (typeof formState.id !== 'undefined') { + this.props.formsActions.addForm(formState); + } }); // TODO Enable once is initialised via page.js route callbacks @@ -118,6 +151,101 @@ export class FormBuilderComponent extends SilverStripeComponent { return this.formSchemaPromise; } + /** + * Update handler passed down to each form field as a prop. + * Form fields call this method when their state changes. + * + * You can pass an optional callback as the third param. This can be used to + * implement custom behaviour. For example you can use `createFn` hook from + * your controller context like this. + * + * controller.js + * ... + * detailEditFormCreateFn(Component, props) { + * const extendedProps = Object.assign({}, props, { + * handleFieldUpdate: (event, updates) => { + * props.handleFieldUpdate(event, updates, (formId, updateFieldAction) => { + * const customUpdates = Object.assign({}, updates, { + * value: someCustomParsing(updates.value), + * }; + * + * updateFieldAction(formId, customUpdates); + * }); + * }, + * }); + * + * return ; + * } + * ... + * + * @param {object} event - Change event from the form field component. + * @param {object} updates - Values to set in state. + * @param {string} updates.id - Field ID. Required to identify the field in the store. + * @param {function} [fn] - Optional function for custom behaviour. See example in description. + */ + handleFieldUpdate(event, updates, fn) { + if (typeof fn !== 'undefined') { + fn(this.props.formId, this.props.formsActions.updateField); + } else { + this.props.formsActions.updateField(this.props.formId, updates); + } + } + + /** + * Form submission handler passed to the Form Component as a prop. + * Provides a hook for controllers to access for state and provide custom functionality. + * + * For example: + * + * controller.js + * ``` + * constructor(props) { + * super(props); + * this.handleSubmit = this.handleSubmit.bind(this); + * } + * + * handleSubmit(event, fieldValues, submitFn) { + * event.preventDefault(); + * + * // Apply custom validation. + * if (!this.validate(fieldValues)) { + * return; + * } + * + * submitFn(); + * } + * + * render() { + * return + * } + * ``` + * + * @param {Object} event + */ + handleSubmit(event) { + const schemaFields = this.props.schemas[this.props.schemaUrl].schema.fields; + const fieldValues = this.props.forms[this.props.formId].fields + .reduce((prev, curr) => Object.assign({}, prev, { + [schemaFields.find(schemaField => schemaField.id === curr.id).name]: curr.value, + }), {}); + + const submitFn = () => { + this.props.formsActions.submitForm( + this.submitApi, + this.props.formId, + fieldValues + ); + }; + + if (typeof this.props.handleSubmit !== 'undefined') { + this.props.handleSubmit(event, fieldValues, submitFn); + return; + } + + event.preventDefault(); + submitFn(); + } + /** * Maps a list of schema fields to their React Component. * Only top level form fields are handled here, composite fields (TabSets etc), @@ -129,6 +257,7 @@ export class FormBuilderComponent extends SilverStripeComponent { */ mapFieldsToComponents(fields) { const createFn = this.props.createFn; + const handleFieldUpdate = this.handleFieldUpdate; return fields.map((field, i) => { const Component = field.component !== null @@ -142,7 +271,7 @@ export class FormBuilderComponent extends SilverStripeComponent { // Props which every form field receives. // Leave it up to the schema and component to determine // which props are required. - const props = deepFreeze(field); + const props = deepFreeze(Object.assign({}, field, { handleFieldUpdate })); // Provides container components a place to hook in // and apply customisations to scaffolded components. @@ -154,8 +283,58 @@ export class FormBuilderComponent extends SilverStripeComponent { }); } + /** + * Maps a list of form actions to their React Component. + * + * @param array actions + * + * @return array + */ + mapActionsToComponents(actions) { + const createFn = this.props.createFn; + + return actions.map((action, i) => { + const props = deepFreeze(action); + + if (typeof createFn === 'function') { + return createFn(FormActionComponent, props); + } + + return ; + }); + } + + /** + * Merges the structural and state data of a form field. + * The structure of the objects being merged should match the structures + * generated by the SilverStripe FormSchema. + * + * @param object structure - Structural data for a single field. + * @param object state - State data for a single field. + * + * @return object + */ + mergeFieldData(structure, state) { + return Object.assign({}, structure, { + data: Object.assign({}, structure.data, state.data), + messages: state.messages, + valid: state.valid, + value: state.value, + }); + } + + /** + * Cleans up Redux state used by the form when the Form component is unmonuted. + * + * @param {string} formId - ID of the form to clean up. + */ + removeForm(formId) { + this.props.formsActions.removeForm(formId); + } + render() { const formSchema = this.props.schemas[this.props.schemaUrl]; + const formState = this.props.forms[this.props.formId]; // If the response from fetching the initial data // hasn't come back yet, don't render anything. @@ -172,11 +351,21 @@ export class FormBuilderComponent extends SilverStripeComponent { encType: formSchema.schema.attributes.enctype, }); + // If there is structural and state data availabe merge those data for each field. + // Otherwise just use the structural data. + const fieldData = formSchema.schema && formState + ? formSchema.schema.fields.map((f, i) => this.mergeFieldData(f, formState.fields[i])) + : formSchema.schema.fields; + const formProps = { actions: formSchema.schema.actions, attributes, + componentWillUnmount: this.removeForm, data: formSchema.schema.data, - fields: formSchema.schema.fields, + fields: fieldData, + formId: formSchema.id, + handleSubmit: this.handleSubmit, + mapActionsToComponents: this.mapActionsToComponents, mapFieldsToComponents: this.mapFieldsToComponents, }; @@ -185,21 +374,29 @@ export class FormBuilderComponent extends SilverStripeComponent { } FormBuilderComponent.propTypes = { - actions: React.PropTypes.object.isRequired, + config: React.PropTypes.object, createFn: React.PropTypes.func, - schemaUrl: React.PropTypes.string.isRequired, + forms: React.PropTypes.object.isRequired, + formsActions: React.PropTypes.object.isRequired, + formId: React.PropTypes.string.isRequired, + handleSubmit: React.PropTypes.func, schemas: React.PropTypes.object.isRequired, + schemaActions: React.PropTypes.object.isRequired, + schemaUrl: React.PropTypes.string.isRequired, }; function mapStateToProps(state) { return { + config: state.config, + forms: state.forms, schemas: state.schemas, }; } function mapDispatchToProps(dispatch) { return { - actions: bindActionCreators(schemaActions, dispatch), + formsActions: bindActionCreators(formsActions, dispatch), + schemaActions: bindActionCreators(schemaActions, dispatch), }; } diff --git a/admin/client/src/components/FormBuilder/README.md b/admin/client/src/components/FormBuilder/README.md index 624ed76fd..0f25fc4b8 100644 --- a/admin/client/src/components/FormBuilder/README.md +++ b/admin/client/src/components/FormBuilder/README.md @@ -25,3 +25,7 @@ The schema URL where the form will be scaffolded from e.g. '/admin/pages/schema/ ### schema JSON schema representing the form. Used as the blueprint for generating the form. + +### onSubmit (func) + +Event handler passed to the Form Component as a prop. diff --git a/admin/client/src/components/GridField/GridField.js b/admin/client/src/components/GridField/GridField.js index cd24d3461..6ae8fa5e5 100644 --- a/admin/client/src/components/GridField/GridField.js +++ b/admin/client/src/components/GridField/GridField.js @@ -110,8 +110,8 @@ class GridField extends SilverStripeComponent { } /** - * @param number int - * @param event + * @param object event + * @param number id */ deleteRecord(event, id) { event.preventDefault(); @@ -123,9 +123,19 @@ class GridField extends SilverStripeComponent { ); } - editRecord(event) { + /** + * @param object event + * @param number id + */ + editRecord(event, id) { event.preventDefault(); - // TODO + + if (typeof this.props.data === 'undefined' || + typeof this.props.data.handleEditRecord === 'undefined') { + return; + } + + this.props.data.handleEditRecord(event, id); } } @@ -136,6 +146,7 @@ GridField.propTypes = { headerColumns: React.PropTypes.array, collectionReadEndpoint: React.PropTypes.object, handleDrillDown: React.PropTypes.func, + handleEditRecord: React.PropTypes.func, }), }; diff --git a/admin/client/src/components/TextField/README.md b/admin/client/src/components/TextField/README.md index 616661298..232cd7dfc 100644 --- a/admin/client/src/components/TextField/README.md +++ b/admin/client/src/components/TextField/README.md @@ -4,7 +4,7 @@ Generates an editable text field. ## Props -### label +### leftTitle The label text to display with the field. @@ -16,7 +16,7 @@ Addition CSS classes to apply to the `` element. Used for the field's `name` attribute. -### onChange +### handleFieldUpdate Handler function called when the field's value changes. diff --git a/admin/client/src/components/TextField/TextField.js b/admin/client/src/components/TextField/TextField.js index 472763e87..73e387e43 100644 --- a/admin/client/src/components/TextField/TextField.js +++ b/admin/client/src/components/TextField/TextField.js @@ -10,11 +10,15 @@ class TextField extends SilverStripeComponent { } render() { + const labelText = this.props.leftTitle !== null + ? this.props.leftTitle + : this.props.title; + return (
- {this.props.label && + {labelText && }
@@ -29,26 +33,31 @@ class TextField extends SilverStripeComponent { className: ['text', this.props.extraClass].join(' '), id: `gallery_${this.props.name}`, name: this.props.name, - onChange: this.props.onChange, + onChange: this.handleChange, type: 'text', value: this.props.value, }; } - handleChange() { - if (typeof this.props.onChange === 'undefined') { + /** + * Handles changes to the text field's value. + * + * @param object event + */ + handleChange(event) { + if (typeof this.props.handleFieldUpdate === 'undefined') { return; } - this.props.onChange(); + this.props.handleFieldUpdate(event, { id: this.props.id, value: event.target.value }); } } TextField.propTypes = { - label: React.PropTypes.string, + leftTitle: React.PropTypes.string, extraClass: React.PropTypes.string, name: React.PropTypes.string.isRequired, - onChange: React.PropTypes.func, + handleFieldUpdate: React.PropTypes.func, value: React.PropTypes.string, }; diff --git a/admin/client/src/components/TextField/tests/TextField-test.js b/admin/client/src/components/TextField/tests/TextField-test.js index 0c0f89037..dd7cf13e2 100644 --- a/admin/client/src/components/TextField/tests/TextField-test.js +++ b/admin/client/src/components/TextField/tests/TextField-test.js @@ -16,7 +16,7 @@ describe('TextField', () => { label: '', name: '', value: '', - onChange: jest.genMockFunction(), + handleFieldUpdate: jest.genMockFunction(), }; }); @@ -29,10 +29,10 @@ describe('TextField', () => { ); }); - it('should call the onChange function on props', () => { - textField.handleChange(); + it('should call the handleFieldUpdate function on props', () => { + textField.handleChange({ target: { value: '' } }); - expect(textField.props.onChange.mock.calls.length).toBe(1); + expect(textField.props.handleFieldUpdate).toBeCalled(); }); }); }); diff --git a/admin/client/src/components/form/Form.js b/admin/client/src/components/form/Form.js index 49b1b7172..854e27ade 100644 --- a/admin/client/src/components/form/Form.js +++ b/admin/client/src/components/form/Form.js @@ -1,28 +1,29 @@ import React from 'react'; import SilverStripeComponent from 'lib/SilverStripeComponent'; -import FormAction from 'components/FormAction/FormAction'; class Form extends SilverStripeComponent { - /** - * Gets the components responsible for perfoming actions on the form. - * For example form submission. - * - * @return array|null - */ - getFormAction() { - return this.props.actions.map((action) => - - ); + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + } + + componentWillUnmount() { + if (typeof this.props.componentWillUnmount === 'undefined') { + return; + } + + this.props.componentWillUnmount(this.props.formId); } render() { - const attr = this.props.attributes; + const props = Object.assign({ onSubmit: this.handleSubmit }, this.props.attributes); const fields = this.props.mapFieldsToComponents(this.props.fields); - const actions = this.getFormAction(); + const actions = this.props.mapActionsToComponents(this.props.actions); return ( -
+ {fields &&
{fields} @@ -40,6 +41,14 @@ class Form extends SilverStripeComponent { ); } + handleSubmit(event) { + if (typeof this.props.handleSubmit === 'undefined') { + return; + } + + this.props.handleSubmit(event); + } + } Form.propTypes = { @@ -51,8 +60,12 @@ Form.propTypes = { id: React.PropTypes.string, method: React.PropTypes.string.isRequired, }), + componentWillUnmount: React.PropTypes.func, data: React.PropTypes.array, fields: React.PropTypes.array.isRequired, + formId: React.PropTypes.string.isRequired, + handleSubmit: React.PropTypes.func, + mapActionsToComponents: React.PropTypes.func.isRequired, mapFieldsToComponents: React.PropTypes.func.isRequired, }; diff --git a/admin/client/src/components/form/README.md b/admin/client/src/components/form/README.md index 51fa85322..405612718 100644 --- a/admin/client/src/components/form/README.md +++ b/admin/client/src/components/form/README.md @@ -24,6 +24,12 @@ An object of HTML attributes for the form. For example: } ``` +### componentWillUnmount (func) + +Optional function which will be called in the component's 'native' `componentWillUnmount` method. + +This can be used to tidy up Redux state that's no longer required. + ### data Ad hoc data passed to the front-end from the server. @@ -35,3 +41,7 @@ A list of field objects to display in the form. These objects should be transfor ### mapFieldsToComponents (required) A function that maps each schema field (`this.props.fields`) to the component responsibe for render it. + +### handleSubmit (func) + +Called then the form is submitted. diff --git a/admin/client/src/containers/CampaignAdmin/CampaignAdmin.js b/admin/client/src/containers/CampaignAdmin/CampaignAdmin.js index ba6952061..4a8b75e6d 100644 --- a/admin/client/src/containers/CampaignAdmin/CampaignAdmin.js +++ b/admin/client/src/containers/CampaignAdmin/CampaignAdmin.js @@ -16,7 +16,6 @@ class CampaignAdmin extends SilverStripeComponent { super(props); this.addCampaign = this.addCampaign.bind(this); - this.createFn = this.createFn.bind(this); this.publishApi = backend.createEndpointFetcher({ url: this.props.sectionConfig.publishEndpoint.url, method: this.props.sectionConfig.publishEndpoint.method, @@ -25,6 +24,8 @@ class CampaignAdmin extends SilverStripeComponent { id: { urlReplacement: ':id', remove: true }, }, }); + this.campaignListCreateFn = this.campaignListCreateFn.bind(this); + this.campaignEditCreateFn = this.campaignEditCreateFn.bind(this); } componentDidMount() { @@ -38,7 +39,7 @@ class CampaignAdmin extends SilverStripeComponent { if (captureRoute) { // If this component is mounted, then handle all page changes via // state / redux - this.props.actions.showCampaignView(ctx.params.id, ctx.params.view); + this.props.actions.showCampaignView(ctx.params.id, ctx.params.view); } else { // If component is not mounted, we need to allow root routes to load // this section in via ajax @@ -80,7 +81,17 @@ class CampaignAdmin extends SilverStripeComponent { * @return object */ renderIndexView() { - const schemaUrl = this.props.sectionConfig.forms.editForm.schemaUrl; + const schemaUrl = this.props.sectionConfig.forms.EditForm.schemaUrl; + const formActionProps = { + label: i18n._t('Campaigns.ADDCAMPAIGN'), + icon: 'plus', + handleClick: this.addCampaign, + }; + const formBuilderProps = { + createFn: this.campaignListCreateFn, + formId: 'EditForm', + schemaUrl, + }; return (
@@ -93,17 +104,13 @@ class CampaignAdmin extends SilverStripeComponent {
- +
- + +
-
); } @@ -126,22 +133,38 @@ class CampaignAdmin extends SilverStripeComponent { } /** - * @todo + * Renders the Detail Edit Form for a Campaign. */ renderDetailEditView() { - return

Edit

; + const baseSchemaUrl = this.props.sectionConfig.forms.DetailEditForm.schemaUrl; + const formBuilderProps = { + createFn: this.campaignEditCreateFn, + formId: 'DetailEditForm', + schemaUrl: `${baseSchemaUrl}/ChangeSet/${this.props.campaignId}`, + }; + + return ( +
+
+ + +
+
+ ); } /** - * Hook to allow customisation of components being constructed by FormBuilder. + * Hook to allow customisation of components being constructed + * by the Campaign list FormBuilder. * * @param object Component - Component constructor. * @param object props - Props passed from FormBuilder. * * @return object - Instanciated React component */ - createFn(Component, props) { + campaignListCreateFn(Component, props) { const campaignViewRoute = this.props.sectionConfig.campaignViewRoute; + const typeUrlParam = 'set'; if (props.component === 'GridField') { const extendedProps = Object.assign({}, props, { @@ -149,12 +172,20 @@ class CampaignAdmin extends SilverStripeComponent { handleDrillDown: (event, record) => { // Set url and set list const path = campaignViewRoute - .replace(/:type\?/, 'set') + .replace(/:type\?/, typeUrlParam) .replace(/:id\?/, record.ID) .replace(/:view\?/, 'show'); window.ss.router.show(path); }, + handleEditRecord: (event, id) => { + const path = campaignViewRoute + .replace(/:type\?/, typeUrlParam) + .replace(/:id\?/, id) + .replace(/:view\?/, 'edit'); + + window.ss.router.show(path); + }, }), }); @@ -180,6 +211,29 @@ class CampaignAdmin extends SilverStripeComponent { ]; } + /* + * Hook to allow customisation of components being constructed + * by the Campaign detail edit FormBuilder. + * + * @param object Component - Component constructor. + * @param object props - Props passed from FormBuilder. + * + * @return object - Instanciated React component + */ + campaignEditCreateFn(Component, props) { + if (props.name === 'action_save') { + const extendedProps = Object.assign({}, props, { + type: 'submit', + label: props.title, + icon: 'save', + }); + + return ; + } + + return ; + } + /** * Gets preview URL for itemid * @param int id @@ -201,17 +255,19 @@ class CampaignAdmin extends SilverStripeComponent { } CampaignAdmin.propTypes = { - sectionConfig: React.PropTypes.shape({ + actions: React.PropTypes.object.isRequired, + campaignId: React.PropTypes.string, + config: React.PropTypes.shape({ forms: React.PropTypes.shape({ editForm: React.PropTypes.shape({ schemaUrl: React.PropTypes.string, }), }), - }), - config: React.PropTypes.shape({ SecurityID: React.PropTypes.string, }), + sectionConfig: React.PropTypes.object.isRequired, sectionConfigKey: React.PropTypes.string.isRequired, + view: React.PropTypes.string, }; function mapStateToProps(state, ownProps) { diff --git a/admin/client/src/lib/backend.js b/admin/client/src/lib/backend.js index fed85fca4..c8446ee72 100644 --- a/admin/client/src/lib/backend.js +++ b/admin/client/src/lib/backend.js @@ -305,7 +305,7 @@ class Backend { ? [url, headers] : [url, encodedData, headers]; - return this[refinedSpec.method](...args) + return this[refinedSpec.method.toLowerCase()](...args) .then(parseResponse); }; } diff --git a/admin/client/src/state/campaign/CampaignReducer.js b/admin/client/src/state/campaign/CampaignReducer.js index ba8d40147..e3656417d 100644 --- a/admin/client/src/state/campaign/CampaignReducer.js +++ b/admin/client/src/state/campaign/CampaignReducer.js @@ -1,11 +1,11 @@ import deepFreeze from 'deep-freeze'; import ACTION_TYPES from './CampaignActionTypes'; -const initialState = { +const initialState = deepFreeze({ campaignId: null, isPublishing: false, view: null, -}; +}); function reducer(state = initialState, action) { switch (action.type) { diff --git a/admin/client/src/state/forms/README.md b/admin/client/src/state/forms/README.md new file mode 100644 index 000000000..1b542e846 --- /dev/null +++ b/admin/client/src/state/forms/README.md @@ -0,0 +1,24 @@ +# forms + +This state key holds form and form field data. Forms built using the `FormBuilder` component +have their state stored in child keys of `forms` (keyed by form ID) automatically. + +```js +{ + forms: { + DetailEditForm: { + fields: [ + { + data: [], + id: "Form_DetailEditForm_Name", + messages: [], + valid: true, + value: "My Campaign" + } + ] + } + } +} +``` + +Forms built using `FormBuilder` will tidy up their state when unmounted. diff --git a/admin/client/src/state/forms/action-types.js b/admin/client/src/state/forms/action-types.js new file mode 100644 index 000000000..581c48a1b --- /dev/null +++ b/admin/client/src/state/forms/action-types.js @@ -0,0 +1,8 @@ +export const ACTION_TYPES = { + ADD_FORM: 'ADD_FORM', + REMOVE_FORM: 'REMOVE_FORM', + SUBMIT_FORM_FAILURE: 'SUBMIT_FORM_FAILURE', + SUBMIT_FORM_REQUEST: 'SUBMIT_FORM_REQUEST', + SUBMIT_FORM_SUCCESS: 'SUBMIT_FORM_SUCCESS', + UPDATE_FIELD: 'UPDATE_FIELD', +}; diff --git a/admin/client/src/state/forms/actions.js b/admin/client/src/state/forms/actions.js new file mode 100644 index 000000000..5c64c36e4 --- /dev/null +++ b/admin/client/src/state/forms/actions.js @@ -0,0 +1,79 @@ +import { ACTION_TYPES } from './action-types'; + +/** + * Removes a form from state. + * This action should be called when a Redux managed Form component unmounts. + * + * @param {string} formId - ID of the form you want to remove. + * @return {function} + */ +export function removeForm(formId) { + return (dispatch) => { + dispatch({ + type: ACTION_TYPES.REMOVE_FORM, + payload: { formId }, + }); + }; +} + +/** + * Sets one or more values on an existing form field. + * + * @param {string} formId - Id of the form where the field lives. + * @param {object} updates - The values to update on the field. + * @param {string} updates.id - Field ID. + * @return {function} + */ +export function updateField(formId, updates) { + return (dispatch) => { + dispatch({ + type: ACTION_TYPES.UPDATE_FIELD, + payload: { formId, updates }, + }); + }; +} + +/** + * Adds a form to the store. + * + * @param {object} formState + * @param {string} formState.id - The ID the form will be keyed as in state. + * @return {function} + */ +export function addForm(formState) { + return (dispatch) => { + dispatch({ + type: ACTION_TYPES.ADD_FORM, + payload: { formState }, + }); + }; +} + +/** + * Submits a form and handles the response. + * + * @param {Function} submitApi + * @param {String} formId + */ +export function submitForm(submitApi, formId, fieldValues) { + return (dispatch) => { + dispatch({ + type: ACTION_TYPES.SUBMIT_FORM_REQUEST, + payload: {}, + }); + + submitApi(Object.assign({ ID: formId }, fieldValues)) + .then(() => { + dispatch({ + type: ACTION_TYPES.SUBMIT_FORM_SUCCESS, + payload: {}, + }); + }) + .catch((error) => { + dispatch({ + type: ACTION_TYPES.SUBMIT_FORM_FAILURE, + payload: { error }, + }); + }); + }; +} diff --git a/admin/client/src/state/forms/reducer.js b/admin/client/src/state/forms/reducer.js new file mode 100644 index 000000000..1b10e2395 --- /dev/null +++ b/admin/client/src/state/forms/reducer.js @@ -0,0 +1,42 @@ +import deepFreeze from 'deep-freeze'; +import { ACTION_TYPES } from './action-types'; + +const initialState = deepFreeze({}); + +function formsReducer(state = initialState, action) { + switch (action.type) { + + case ACTION_TYPES.REMOVE_FORM: + return deepFreeze(Object.keys(state).reduce((previous, current) => { + if (current === action.payload.formId) { + return previous; + } + return Object.assign({}, previous, { + [current]: state[current], + }); + }, {})); + + case ACTION_TYPES.ADD_FORM: + return deepFreeze(Object.assign({}, state, { + [action.payload.formState.id]: { fields: action.payload.formState.fields }, + })); + + case ACTION_TYPES.UPDATE_FIELD: + return deepFreeze(Object.assign({}, state, { + [action.payload.formId]: Object.assign({}, state[action.payload.formId], { + fields: state[action.payload.formId].fields.map((field) => { + if (field.id === action.payload.updates.id) { + return Object.assign({}, field, action.payload.updates); + } + return field; + }), + }), + })); + + default: + return state; + + } +} + +export default formsReducer; diff --git a/admin/client/src/state/forms/tests/reducer-test.js b/admin/client/src/state/forms/tests/reducer-test.js new file mode 100644 index 000000000..c00812268 --- /dev/null +++ b/admin/client/src/state/forms/tests/reducer-test.js @@ -0,0 +1,126 @@ +jest.unmock('deep-freeze'); +jest.unmock('../reducer'); +jest.unmock('../action-types'); + +import deepFreeze from 'deep-freeze'; +import { ACTION_TYPES } from '../action-types'; +import formsReducer from '../reducer'; + +describe('formsReducer', () => { + + describe('ADD_FORM', () => { + const initialState = deepFreeze({ + DetailEditForm: { + fields: [ + { + data: [], + id: 'Form_DetailEditForm_Name', + messages: [], + valid: true, + value: 'Test', + }, + ], + }, + }); + + it('should add a form', () => { + const payload = { + formState: { + fields: [ + { + data: [], + id: 'Form_EditForm_Name', + messages: [], + valid: true, + value: 'Test', + }, + ], + id: 'EditForm', + messages: [], + }, + }; + + const nextState = formsReducer(initialState, { + type: ACTION_TYPES.ADD_FORM, + payload, + }); + + expect(nextState.DetailEditForm).toBeDefined(); + expect(nextState.EditForm).toBeDefined(); + expect(nextState.EditForm.fields).toBeDefined(); + expect(nextState.EditForm.fields[0].data).toBeDefined(); + expect(nextState.EditForm.fields[0].id).toBe('Form_EditForm_Name'); + expect(nextState.EditForm.fields[0].messages).toBeDefined(); + expect(nextState.EditForm.fields[0].valid).toBe(true); + expect(nextState.EditForm.fields[0].value).toBe('Test'); + }); + }); + + describe('REMOVE_FORM', () => { + const initialState = deepFreeze({ + DetailEditForm: { + fields: [ + { + data: [], + id: 'Form_DetailEditForm_Name', + messages: [], + valid: true, + value: 'Test', + }, + ], + }, + EditForm: { + fields: [ + { + data: [], + id: 'Form_EditForm_Name', + messages: [], + valid: true, + value: 'Test', + }, + ], + }, + }); + + it('should remove the form', () => { + const nextState = formsReducer(initialState, { + type: ACTION_TYPES.REMOVE_FORM, + payload: { formId: 'DetailEditForm' }, + }); + + expect(nextState.DetailEditForm).toBeUndefined(); + expect(nextState.EditForm).toBeDefined(); + }); + }); + + describe('UPDATE_FIELD', () => { + const initialState = deepFreeze({ + DetailEditForm: { + fields: [ + { + data: [], + id: 'Form_DetailEditForm_Name', + messages: [], + valid: true, + value: 'Test', + }, + ], + }, + }); + + it('should update properties of a form field', () => { + const nextState = formsReducer(initialState, { + type: ACTION_TYPES.UPDATE_FIELD, + payload: { + formId: 'DetailEditForm', + updates: { + id: 'Form_DetailEditForm_Name', + value: 'Updated', + }, + }, + }); + + expect(nextState.DetailEditForm.fields[0].value).toBe('Updated'); + }); + }); +}); diff --git a/admin/client/src/state/schema/README.md b/admin/client/src/state/schema/README.md index 6c545284c..ca9a360b0 100644 --- a/admin/client/src/state/schema/README.md +++ b/admin/client/src/state/schema/README.md @@ -2,4 +2,7 @@ Manages state associated with the FormFieldSchema. -When dependency injection is implemented, this will be moved into either Framework or CMS. We can't moveit there sooner because there is no way of extending state. +When dependency injection is implemented, this will be moved into either Framework or CMS. +We can't move it sooner because there's no way of extending state. + +Note form state is stored under the `forms` _not_ the `schema` key. diff --git a/admin/code/CampaignAdmin.php b/admin/code/CampaignAdmin.php index 24eefb65c..d3866f830 100644 --- a/admin/code/CampaignAdmin.php +++ b/admin/code/CampaignAdmin.php @@ -30,7 +30,7 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider { 'POST set/$ID/publish' => 'publishCampaign', 'POST set/$ID' => 'createCampaign', 'GET set/$ID/$Name' => 'readCampaign', - 'PUT set/$ID' => 'updateCampaign', + 'POST $ID' => 'updateCampaign', 'DELETE set/$ID' => 'deleteCampaign', ]; @@ -56,8 +56,11 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider { return array_merge(parent::getClientConfig(), [ 'forms' => [ // TODO Use schemaUrl instead - 'editForm' => [ + 'EditForm' => [ 'schemaUrl' => $this->Link('schema/EditForm') + ], + 'DetailEditForm' => [ + 'schemaUrl' => $this->Link('schema/DetailEditForm') ] ], 'campaignViewRoute' => $this->Link() . ':type?/:id?/:view?', @@ -325,8 +328,6 @@ JSON; return $hal; } - - /** * Gets viewable list of campaigns * @@ -353,21 +354,14 @@ JSON; if ($request->getHeader('Accept') == 'text/json') { $response->addHeader('Content-Type', 'application/json'); - $changeSet = ChangeSet::get()->byId($request->param('ID')); - - switch ($request->param('Name')) { - case "edit": - $response->setBody('{"message":"show the edit view"}'); - break; - case "show": - $response->setBody(Convert::raw2json($this->getChangeSetResource($changeSet))); - break; - default: - $response->setBody('{"message":"404"}'); + if ($request->param('Name')) { + $changeSet = ChangeSet::get()->byId($request->param('ID')); + $response->setBody(Convert::raw2json($this->getChangeSetResource($changeSet))); + } else { + $response->setBody('{"message":"Resource not found"}'); } return $response; - } else { return $this->index($request); } @@ -381,6 +375,8 @@ JSON; * @return SS_HTTPResponse */ public function updateCampaign(SS_HTTPRequest $request) { + $id = $request->param('ID'); + $response = new SS_HTTPResponse(); $response->addHeader('Content-Type', 'application/json'); $response->setBody(Convert::raw2json(['campaign' => 'update'])); @@ -469,11 +465,11 @@ JSON; * * @return Form */ - public function getDetailEditForm() { + public function getDetailEditForm($id) { return Form::create( $this, 'DetailEditForm', - ChangeSet::singleton()->getCMSFields(), + ChangeSet::get()->byId($id)->getCMSFields(), FieldList::create( FormAction::create('save', 'Save') ) diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index 57d313c41..e5a4b0c36 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -105,6 +105,10 @@ class LeftAndMain extends Controller implements PermissionProvider { 'schema', ]; + private static $url_handlers = [ + 'GET schema/$FormName/$RecordType/$ItemID' => 'schema' + ]; + private static $dependencies = [ 'schema' => '%$FormSchema' ]; @@ -226,7 +230,16 @@ class LeftAndMain extends Controller implements PermissionProvider { */ public function schema($request) { $response = $this->getResponse(); - $formName = $request->param('ID'); + $formName = $request->param('FormName'); + $recordType = $request->param('RecordType'); + $itemID = $request->param('ItemID'); + + if (!$formName || !$recordType) { + throw new SS_HTTPResponse_Exception( + 'Missing request params', + 400 + ); + } if(!$this->hasMethod("get{$formName}")) { throw new SS_HTTPResponse_Exception( @@ -242,7 +255,12 @@ class LeftAndMain extends Controller implements PermissionProvider { ); } - $form = $this->{"get{$formName}"}(); + $form = $this->{"get{$formName}"}($itemID); + + if ($itemID) { + $form->loadDataFrom($recordType::get()->byId($itemID)); + } + $response->addHeader('Content-Type', 'application/json'); $response->setBody(Convert::raw2json($this->getSchemaForForm($form))); diff --git a/tests/forms/FormSchemaTest.php b/tests/forms/FormSchemaTest.php index 8b586efe6..0927d01d3 100644 --- a/tests/forms/FormSchemaTest.php +++ b/tests/forms/FormSchemaTest.php @@ -9,9 +9,9 @@ class FormSchemaTest extends SapphireTest { $formSchema = new FormSchema(); $expected = [ 'name' => 'TestForm', - 'id' => null, - 'action' => null, - 'method' => '', + 'id' => 'Form_TestForm', + 'action' => 'Controller/TestForm', + 'method' => 'POST', 'schema_url' => '', 'attributes' => [ 'id' => 'Form_TestForm', @@ -54,7 +54,7 @@ class FormSchemaTest extends SapphireTest { $form = new Form(new Controller(), 'TestForm', new FieldList(), new FieldList()); $formSchema = new FormSchema(); $expected = [ - 'id' => 'TestForm', + 'id' => 'Form_TestForm', 'fields' => [ [ 'id' => 'Form_TestForm_SecurityID', @@ -79,7 +79,7 @@ class FormSchemaTest extends SapphireTest { $form->sessionMessage('All saved', 'good'); $formSchema = new FormSchema(); $expected = [ - 'id' => 'TestForm', + 'id' => 'Form_TestForm', 'fields' => [ [ 'id' => 'Form_TestForm_SecurityID', @@ -113,7 +113,7 @@ class FormSchemaTest extends SapphireTest { $validator->validationError('Title', 'Title is invalid', 'error'); $formSchema = new FormSchema(); $expected = [ - 'id' => 'TestForm', + 'id' => 'Form_TestForm', 'fields' => [ [ 'id' => 'Form_TestForm_Title',