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.
This commit is contained in:
David Craig 2016-04-12 16:47:24 +12:00 committed by Ingo Schommer
parent e01846d418
commit 7fcdf35438
22 changed files with 688 additions and 90 deletions

View File

@ -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);

View File

@ -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

View File

@ -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 <CampaignAdmin> ..." 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 <CampaignAdmin> 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 <Component {...extendedProps} />;
* }
* ...
*
* @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 <FormBuilder handleSubmit={this.handleSubmit} />
* }
* ```
*
* @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 <FormActionComponent key={i} {...props} />;
});
}
/**
* 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),
};
}

View File

@ -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.

View File

@ -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,
}),
};

View File

@ -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 `<input>` element.
Used for the field's `name` attribute.
### onChange
### handleFieldUpdate
Handler function called when the field's value changes.

View File

@ -10,11 +10,15 @@ class TextField extends SilverStripeComponent {
}
render() {
const labelText = this.props.leftTitle !== null
? this.props.leftTitle
: this.props.title;
return (
<div className="field text">
{this.props.label &&
{labelText &&
<label className="left" htmlFor={`gallery_${this.props.name}`}>
{this.props.label}
{labelText}
</label>
}
<div className="middleColumn">
@ -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,
};

View File

@ -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();
});
});
});

View File

@ -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) =>
<FormAction {...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 (
<form {...attr}>
<form {...props}>
{fields &&
<fieldset className="form-group">
{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,
};

View File

@ -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.

View File

@ -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 (
<div className="cms-content__inner no-preview">
@ -93,17 +104,13 @@ class CampaignAdmin extends SilverStripeComponent {
<div className="panel-scrollable--single-toolbar">
<div className="toolbar--content">
<div className="btn-toolbar">
<FormAction
label={i18n._t('Campaigns.ADDCAMPAIGN')}
icon={'plus'}
handleClick={this.addCampaign}
/>
<FormAction {...formActionProps} />
</div>
</div>
<FormBuilder schemaUrl={schemaUrl} createFn={this.createFn} />
<FormBuilder {...formBuilderProps} />
</div>
</div>
</div>
</div>
);
}
@ -126,22 +133,38 @@ class CampaignAdmin extends SilverStripeComponent {
}
/**
* @todo
* Renders the Detail Edit Form for a Campaign.
*/
renderDetailEditView() {
return <p>Edit</p>;
const baseSchemaUrl = this.props.sectionConfig.forms.DetailEditForm.schemaUrl;
const formBuilderProps = {
createFn: this.campaignEditCreateFn,
formId: 'DetailEditForm',
schemaUrl: `${baseSchemaUrl}/ChangeSet/${this.props.campaignId}`,
};
return (
<div className="cms-middle no-preview">
<div className="cms-campaigns collapse in" aria-expanded="true">
<NorthHeader />
<FormBuilder {...formBuilderProps} />
</div>
</div>
);
}
/**
* 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 <Component key={props.name} {...extendedProps} />;
}
return <Component key={props.name} {...props} />;
}
/**
* 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) {

View File

@ -305,7 +305,7 @@ class Backend {
? [url, headers]
: [url, encodedData, headers];
return this[refinedSpec.method](...args)
return this[refinedSpec.method.toLowerCase()](...args)
.then(parseResponse);
};
}

View File

@ -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) {

View File

@ -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.

View File

@ -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',
};

View File

@ -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 },
});
});
};
}

View File

@ -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;

View File

@ -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');
});
});
});

View File

@ -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.

View File

@ -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')
)

View File

@ -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)));

View File

@ -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',