Use redux-form instead of custom actions/reducers (fixes #5524)

- Removed custom form reducers in favour of redux-form (updateField(), addForm(), etc)
- Storing 'state' in schema reducer to avoid a separate forms reducer tree (no longer needed for other use cases). This means 'state' is only the "initial state", any form edits will be handled by redux-form internally in the 'reduxForm' reducer namespace
- Removed componentWillUnmount() from <Form> since there's no more reducer state to clean up (removed formReducer.js), and redux-form handles that internally
- Removed isFetching state from <FormBuilder> since there's now a props.submitting coming from redux-form
- Improved passing of "clicked button" (submittingAction), using component state rather than reducer and passing it into action handlers (no need for components to inspect it any other way)
- Hacky duplication of this.submittingAction and this.state.submittingAction to avoid re-render of <FormBuilder> *during* a submission (see https://github.com/erikras/redux-form/issues/1944)
- Inlined handleSubmit() XHR (rather than using a redux action). There's other ways for form consumers to listen to form evens (e.g. onSubmitSuccess passed through <FormBuilder> into reduxForm()).
- Adapting checkbox/radio fields to redux-forms
  Need to send onChange event with values rather than the original event,
  see http://redux-form.com/6.1.1/docs/api/Field.md/#-input-onchange-eventorvalue-function-
- Using reduxForm() within render() causes DOM to get thrown away,
  and has weird side effects like https://github.com/erikras/redux-form/issues/1944.
  See https://github.com/erikras/redux-form/issues/603#issuecomment-176397728
- Refactored <FormBuilderLoader> as a separate container component which connects to redux and redux-form. This keeps the <FormBuilder> component dependency free and easy to test. It'll also be an advantage if we switch to a GraphQL backed component, in which case the async loading routines will look very different from the current <FormBuilderLoader> implementation
- Refactoring out the redux-form dependency from FormBuilder to make it more testable (through baseFormComponent and baseFieldComponent)
- Passing through 'form' to allow custom identifiers (which is important because currently the schema "id" contains the record identifier, making the form identifier non-deterministic - which means you can't use it with the redux-form selector API)
This commit is contained in:
Ingo Schommer 2016-10-12 14:47:14 +13:00
parent 5f81122ad3
commit 5b31a40593
27 changed files with 635 additions and 892 deletions

View File

@ -1,16 +1,16 @@
import BootRoutes from './BootRoutes'; import BootRoutes from './BootRoutes';
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'; import { combineReducers, createStore, applyMiddleware, compose } from 'redux';
import thunkMiddleware from 'redux-thunk'; import thunkMiddleware from 'redux-thunk';
import { reducer as ReduxFormReducer } from 'redux-form';
import { routerReducer } from 'react-router-redux';
import Config from 'lib/Config'; import Config from 'lib/Config';
import reducerRegister from 'lib/ReducerRegister'; import reducerRegister from 'lib/ReducerRegister';
import * as configActions from 'state/config/ConfigActions'; import * as configActions from 'state/config/ConfigActions';
import ConfigReducer from 'state/config/ConfigReducer'; import ConfigReducer from 'state/config/ConfigReducer';
import FormReducer from 'state/form/FormReducer';
import SchemaReducer from 'state/schema/SchemaReducer'; import SchemaReducer from 'state/schema/SchemaReducer';
import RecordsReducer from 'state/records/RecordsReducer'; import RecordsReducer from 'state/records/RecordsReducer';
import CampaignReducer from 'state/campaign/CampaignReducer'; import CampaignReducer from 'state/campaign/CampaignReducer';
import BreadcrumbsReducer from 'state/breadcrumbs/BreadcrumbsReducer'; import BreadcrumbsReducer from 'state/breadcrumbs/BreadcrumbsReducer';
import { routerReducer } from 'react-router-redux';
import bootInjector from 'boot/BootInjector'; import bootInjector from 'boot/BootInjector';
// Sections // Sections
@ -19,7 +19,7 @@ import CampaignAdmin from 'containers/CampaignAdmin/controller';
function appBoot() { function appBoot() {
reducerRegister.add('config', ConfigReducer); reducerRegister.add('config', ConfigReducer);
reducerRegister.add('form', FormReducer); reducerRegister.add('form', ReduxFormReducer);
reducerRegister.add('schemas', SchemaReducer); reducerRegister.add('schemas', SchemaReducer);
reducerRegister.add('records', RecordsReducer); reducerRegister.add('records', RecordsReducer);
reducerRegister.add('campaign', CampaignReducer); reducerRegister.add('campaign', CampaignReducer);

View File

@ -5,6 +5,7 @@ require('expose?Form!components/Form/Form');
require('expose?FormConstants!components/Form/FormConstants'); require('expose?FormConstants!components/Form/FormConstants');
require('expose?FormAction!components/FormAction/FormAction'); require('expose?FormAction!components/FormAction/FormAction');
require('expose?FormBuilder!components/FormBuilder/FormBuilder'); require('expose?FormBuilder!components/FormBuilder/FormBuilder');
require('expose?FormBuilderLoader!containers/FormBuilderLoader/FormBuilderLoader');
require('expose?FormBuilderModal!components/FormBuilderModal/FormBuilderModal'); require('expose?FormBuilderModal!components/FormBuilderModal/FormBuilderModal');
require('expose?GridField!components/GridField/GridField'); require('expose?GridField!components/GridField/GridField');
require('expose?GridFieldCell!components/GridField/GridFieldCell'); require('expose?GridFieldCell!components/GridField/GridFieldCell');

View File

@ -17,6 +17,7 @@ require('expose?Tether!tether');
require('expose?ReactDom!react-dom'); require('expose?ReactDom!react-dom');
require('expose?Redux!redux'); require('expose?Redux!redux');
require('expose?ReactRedux!react-redux'); require('expose?ReactRedux!react-redux');
require('expose?ReduxForm!redux-form');
require('expose?ReduxThunk!redux-thunk'); require('expose?ReduxThunk!redux-thunk');
require('expose?ReactRouter!react-router'); require('expose?ReactRouter!react-router');
require('expose?ReactRouterRedux!react-router-redux'); require('expose?ReactRouterRedux!react-router-redux');

View File

@ -65,7 +65,7 @@ class CheckboxSetField extends SilverStripeComponent {
}) })
.map((item) => `${item.value}`); .map((item) => `${item.value}`);
this.props.onChange(event, { id: this.props.id, value: newValue }); this.props.onChange(newValue);
} }
} }

View File

@ -90,8 +90,7 @@ describe('CheckboxSetField', () => {
checkboxSetField.handleChange(event, { id: 'checkbox-two', value: 1 }); checkboxSetField.handleChange(event, { id: 'checkbox-two', value: 1 });
expect(checkboxSetField.props.onChange).toBeCalledWith( expect(checkboxSetField.props.onChange).toBeCalledWith(
event, ['one', 'two', 'four']
{ id: 'checkbox', value: ['one', 'two', 'four'] }
); );
}); });
@ -101,8 +100,7 @@ describe('CheckboxSetField', () => {
checkboxSetField.handleChange(event, { id: 'checkbox-one', value: 0 }); checkboxSetField.handleChange(event, { id: 'checkbox-one', value: 0 });
expect(checkboxSetField.props.onChange).toBeCalledWith( expect(checkboxSetField.props.onChange).toBeCalledWith(
event, ['four']
{ id: 'checkbox', value: ['four'] }
); );
}); });
}); });

View File

@ -8,21 +8,15 @@ class Form extends SilverStripeComponent {
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
componentWillUnmount() {
if (typeof this.props.componentWillUnmount === 'undefined') {
return;
}
this.props.componentWillUnmount(this.props.formId);
}
render() { render() {
const defaultFormProps = { const formProps = Object.assign(
{},
{
className: 'form', className: 'form',
onSubmit: this.handleSubmit, onSubmit: this.handleSubmit,
}; },
const formProps = Object.assign({}, defaultFormProps, this.props.attributes); this.props.attributes
);
const fields = this.props.mapFieldsToComponents(this.props.fields); const fields = this.props.mapFieldsToComponents(this.props.fields);
const actions = this.props.mapActionsToComponents(this.props.actions); const actions = this.props.mapActionsToComponents(this.props.actions);
@ -62,10 +56,7 @@ Form.propTypes = {
id: React.PropTypes.string, id: React.PropTypes.string,
method: React.PropTypes.string.isRequired, method: React.PropTypes.string.isRequired,
}), }),
componentWillUnmount: React.PropTypes.func,
data: React.PropTypes.array,
fields: React.PropTypes.array.isRequired, fields: React.PropTypes.array.isRequired,
formId: React.PropTypes.string.isRequired,
handleSubmit: React.PropTypes.func, handleSubmit: React.PropTypes.func,
mapActionsToComponents: React.PropTypes.func.isRequired, mapActionsToComponents: React.PropTypes.func.isRequired,
mapFieldsToComponents: React.PropTypes.func.isRequired, mapFieldsToComponents: React.PropTypes.func.isRequired,

View File

@ -1,8 +1,8 @@
# Form Component # Form Component
The FormComponent is used to render forms in SilverStripe. The only time you should need to use `FormComponent` directly is when you're composing custom layouts. Forms can be automatically generated from a schema using the `FormBuilder` component. The `Form` component is used to render forms in SilverStripe.
The only time you should need to use `FormComponent` directly is when you're composing custom layouts.
This component should be moved to Framework when dependency injection is implemented. Forms can be automatically generated from a schema using the `FormBuilder` component.
## Properties ## Properties
@ -19,7 +19,6 @@ attributes = {
} }
``` ```
* `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.
* `fields` (required): A list of field objects to display in the form. These objects should be transformed to Components using the `this.props.mapFieldsToComponents` method. * `fields` (required): A list of field objects to display in the form. These objects should be transformed to Components using the `this.props.mapFieldsToComponents` method.
* `mapFieldsToComponents` (required): A function that maps each schema field (`this.props.fields`) to the component responsibe for render it. * `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. * `handleSubmit` (func): Called then the form is submitted.

View File

@ -28,6 +28,7 @@ class FormAction extends SilverStripeComponent {
typeof this.props.attributes === 'undefined' ? {} : this.props.attributes, typeof this.props.attributes === 'undefined' ? {} : this.props.attributes,
{ {
id: this.props.id, id: this.props.id,
name: this.props.name,
className: this.getButtonClasses(), className: this.getButtonClasses(),
disabled: this.props.disabled, disabled: this.props.disabled,
onClick: this.handleClick, onClick: this.handleClick,

View File

@ -1,197 +1,45 @@
import React from 'react'; import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as formActions from 'state/form/FormActions';
import * as schemaActions from 'state/schema/SchemaActions';
import SilverStripeComponent from 'lib/SilverStripeComponent'; import SilverStripeComponent from 'lib/SilverStripeComponent';
import Form from 'components/Form/Form';
import fetch from 'isomorphic-fetch';
import backend from 'lib/Backend'; import backend from 'lib/Backend';
import injector from 'lib/Injector'; import injector from 'lib/Injector';
import merge from 'merge'; import merge from 'merge';
import es6promise from 'es6-promise'; class FormBuilder extends SilverStripeComponent {
es6promise.polyfill();
export class FormBuilderComponent extends SilverStripeComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.formSchemaPromise = null; const schemaStructure = props.schema.schema;
this.state = { isFetching: false }; this.state = { submittingAction: null };
this.submitApi = backend.createEndpointFetcher({
url: schemaStructure.attributes.action,
method: schemaStructure.attributes.method,
});
this.mapActionsToComponents = this.mapActionsToComponents.bind(this); this.mapActionsToComponents = this.mapActionsToComponents.bind(this);
this.mapFieldsToComponents = this.mapFieldsToComponents.bind(this); this.mapFieldsToComponents = this.mapFieldsToComponents.bind(this);
this.handleFieldUpdate = this.handleFieldUpdate.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.handleAction = this.handleAction.bind(this); this.handleAction = this.handleAction.bind(this);
this.removeForm = this.removeForm.bind(this);
this.getFormId = this.getFormId.bind(this);
this.getFormSchema = this.getFormSchema.bind(this);
this.findField = this.findField.bind(this); this.findField = this.findField.bind(this);
} this.buildComponent = this.buildComponent.bind(this);
/**
* Get the schema for this form
*
* @returns {array}
*/
getFormSchema() {
return this.props.schemas[this.props.schemaUrl];
}
/**
* Gets the ID for this form
*
* @returns {String}
*/
getFormId() {
const schema = this.getFormSchema();
if (schema) {
return schema.id;
}
return null;
}
componentDidMount() {
this.fetch();
}
componentDidUpdate(prevProps) {
if (this.props.schemaUrl !== prevProps.schemaUrl) {
this.fetch();
}
}
/**
* Fetches data used to generate a form. This can be form schema and or form state data.
* When the response comes back the data is saved to state.
*
* @param {Boolean} schema If form schema data should be returned in the response.
* @param {Boolean} state If form state data should be returned in the response.
*
* @return {Object} Promise from the AJAX request.
*/
fetch(schema = true, state = true) {
const headerValues = [];
if (this.state.isFetching === true) {
return this.formSchemaPromise;
}
if (schema === true) {
headerValues.push('schema');
}
if (state === true) {
headerValues.push('state');
}
this.formSchemaPromise = fetch(this.props.schemaUrl, {
headers: { 'X-FormSchema-Request': headerValues.join() },
credentials: 'same-origin',
})
.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 });
if (typeof formSchema.id !== 'undefined') {
const defaultData = {
SecurityID: this.props.config.SecurityID,
};
this.submitApi = (...args) => {
const endPoint = backend.createEndpointFetcher({
url: formSchema.schema.attributes.action,
method: formSchema.schema.attributes.method,
defaultData,
});
// Ensure that schema changes are handled prior to updating state
return endPoint(...args)
.then((response) => {
if (response.schema) {
const newSchema = Object.assign({}, { id: response.id, schema: response.schema });
this.props.schemaActions.setSchema(newSchema);
}
return response;
});
};
this.props.schemaActions.setSchema(formSchema);
}
if (typeof formState.id !== 'undefined') {
this.props.formActions.addForm(formState);
}
});
// TODO Enable once <CampaignAdmin> is initialised via page.js route callbacks
// At the moment, it's running an Entwine onadd() rule which ends up
// rendering the index view, and only then calling route.start() to
// match the detail view (admin/campaigns/set/:id/show).
// This causes the form builder to be unmounted during a fetch() call.
// this.setState({ isFetching: true });
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 === 'function') {
fn(this.getFormId(), this.props.formActions.updateField);
} else {
this.props.formActions.updateField(this.getFormId(), updates);
}
} }
/** /**
* When the action is clicked on, records which action was clicked on * When the action is clicked on, records which action was clicked on
* This can allow for preventing the submit action, such as a custom action for the button * This can allow for preventing the submit action, such as a custom action for the button
* *
* @param event * @param {Event} event
* @param submitAction
*/ */
handleAction(event, submitAction) { handleAction(event) {
this.props.formActions.setSubmitAction(this.getFormId(), submitAction); // Custom handlers
if (typeof this.props.handleAction === 'function') { if (typeof this.props.handleAction === 'function') {
this.props.handleAction(event, submitAction, this.getFieldValues()); this.props.handleAction(event, this.getFieldValues());
}
const name = event.currentTarget.name;
// Allow custom handlers to cancel event
if (!event.isPropagationStopped()) {
this.setState({ submittingAction: name });
} }
} }
@ -199,48 +47,45 @@ export class FormBuilderComponent extends SilverStripeComponent {
* Form submission handler passed to the Form Component as a prop. * Form submission handler passed to the Form Component as a prop.
* Provides a hook for controllers to access for state and provide custom functionality. * Provides a hook for controllers to access for state and provide custom functionality.
* *
* For example: * @param {Object} data Processed and validated data from redux-form
* * (originally retrieved through getFieldValues())
* 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
* @return {Promise|null} * @return {Promise|null}
*/ */
handleSubmit(event) { handleSubmit(data) {
const fieldValues = this.getFieldValues(); // Add form action data (or default to first action, same as browser behaviour)
const action = this.state.submittingAction
? this.state.submittingAction
: this.props.schema.schema.actions[0].name;
const submitFn = () => this.props.formActions.submitForm( const dataWithAction = Object.assign({}, data, {
this.submitApi, [action]: 1,
this.getFormId(), });
fieldValues const headers = {
); 'X-Formschema-Request': 'state,schema',
'X-Requested-With': 'XMLHttpRequest',
};
if (typeof this.props.handleSubmit !== 'undefined') { const resetSubmittingFn = () => {
return this.props.handleSubmit(event, fieldValues, submitFn); this.setState({ submittingAction: null });
};
const submitFn = (customData) =>
this.submitApi(customData || dataWithAction, headers)
.then(formSchema => {
resetSubmittingFn();
return formSchema;
})
.catch((reason) => {
// TODO Generic CMS error reporting
// TODO Handle validation errors
resetSubmittingFn();
return reason;
});
if (typeof this.props.handleSubmit === 'function') {
return this.props.handleSubmit(dataWithAction, action, submitFn);
} }
event.preventDefault();
return submitFn(); return submitFn();
} }
@ -250,35 +95,31 @@ export class FormBuilderComponent extends SilverStripeComponent {
* @returns {Object} * @returns {Object}
*/ */
getFieldValues() { getFieldValues() {
const schema = this.props.schemas[this.props.schemaUrl];
// using state is more efficient and has the same fields, fallback to nested schema // using state is more efficient and has the same fields, fallback to nested schema
const fields = (schema.state) const schema = this.props.schema.schema;
? schema.state.fields const state = this.props.schema.state;
: schema.schema.fields;
// Set action if (!state) {
const action = this.getSubmitAction(); return {};
const values = {};
if (action) {
values[action] = 1;
} }
// Reduce all other fields return state.fields
return this.props.form[this.getFormId()].fields
.reduce((prev, curr) => { .reduce((prev, curr) => {
const match = this.findField(fields, curr.id); const match = this.findField(schema.fields, curr.id);
if (!match) { if (!match) {
return prev; return prev;
} }
// Skip non-data fields
if (match.type === 'Structural' || match.readOnly === true) {
return prev;
}
return Object.assign({}, prev, { return Object.assign({}, prev, {
[match.name]: curr.value, [match.name]: curr.value,
}); });
}, values); }, {});
}
getSubmitAction() {
return this.props.form[this.getFormId()].submitAction;
} }
/** /**
@ -309,39 +150,42 @@ export class FormBuilderComponent extends SilverStripeComponent {
/** /**
* Common functionality for building a Field or Action from schema. * Common functionality for building a Field or Action from schema.
* *
* @param field * @param {Object} props Props which every form field receives. Leave it up to the
* @param extraProps * schema and component to determine which props are required.
* @returns {*} * @returns {*}
*/ */
buildComponent(field, extraProps = {}) { buildComponent(props) {
const Component = field.component !== null let componentProps = props;
? injector.getComponentByName(field.component) // 'component' key is renamed to 'schemaComponent' in normalize*() methods
: injector.getComponentByDataType(field.type); const SchemaComponent = componentProps.schemaComponent !== null
? injector.getComponentByName(componentProps.schemaComponent)
: injector.getComponentByDataType(componentProps.type);
if (Component === null) { if (SchemaComponent === null) {
return null; return null;
} else if (field.component !== null && Component === undefined) { } else if (componentProps.schemaComponent !== null && SchemaComponent === undefined) {
throw Error(`Component not found in injector: ${field.component}`); throw Error(`Component not found in injector: ${componentProps.schemaComponent}`);
} }
// Props which every form field receives.
// Leave it up to the schema and component to determine
// which props are required.
const props = Object.assign({}, field, extraProps);
// if no value, it is better to unset it // if no value, it is better to unset it
if (props.value === null) { if (componentProps.value === null) {
delete props.value; delete componentProps.value;
} }
// Inline `input` props into main field props
// (each component can pick and choose the props required for it's <input>
// See http://redux-form.com/6.0.5/docs/api/Field.md/#input-props
componentProps = Object.assign({}, componentProps, componentProps.input);
delete componentProps.input;
// Provides container components a place to hook in // Provides container components a place to hook in
// and apply customisations to scaffolded components. // and apply customisations to scaffolded components.
const createFn = this.props.createFn; const createFn = this.props.createFn;
if (typeof createFn === 'function') { if (typeof createFn === 'function') {
return createFn(Component, props); return createFn(SchemaComponent, componentProps);
} }
return <Component key={props.id} {...props} />; return <SchemaComponent key={componentProps.id} {...componentProps} />;
} }
/** /**
@ -353,16 +197,25 @@ export class FormBuilderComponent extends SilverStripeComponent {
* @return {Array} * @return {Array}
*/ */
mapFieldsToComponents(fields) { mapFieldsToComponents(fields) {
const FieldComponent = this.props.baseFieldComponent;
return fields.map((field) => { return fields.map((field) => {
// Events let props = field;
const extraProps = { onChange: this.handleFieldUpdate };
// Build child nodes
if (field.children) { if (field.children) {
extraProps.children = this.mapFieldsToComponents(field.children); props = Object.assign(
{},
field,
{ children: this.mapFieldsToComponents(field.children) }
);
} }
return this.buildComponent(field, extraProps); // Don't wrap structural or readonly fields, since they don't need connected fields.
// The redux-form connected fields also messed up react-bootstrap's tab handling.
if (field.type === 'Structural' || field.readOnly === true) {
return this.buildComponent(props);
}
return <FieldComponent key={props.id} {...props} component={this.buildComponent} />;
}); });
} }
@ -373,23 +226,21 @@ export class FormBuilderComponent extends SilverStripeComponent {
* @return {Array} * @return {Array}
*/ */
mapActionsToComponents(actions) { mapActionsToComponents(actions) {
const form = this.props.form[this.getFormId()];
return actions.map((action) => { return actions.map((action) => {
const loading = (form && form.submitting && form.submitAction === action.name); const props = Object.assign({}, action);
// Events
const extraProps = {
handleClick: this.handleAction,
loading,
disabled: loading || action.disabled,
};
// Build child nodes
if (action.children) { if (action.children) {
extraProps.children = this.mapActionsToComponents(action.children); props.children = this.mapActionsToComponents(action.children);
} else {
props.handleClick = this.handleAction;
// Reset through componentWillReceiveProps()
if (this.state.submittingAction === action.name) {
props.loading = true;
}
} }
return this.buildComponent(action, extraProps); return this.buildComponent(props);
}); });
} }
@ -416,106 +267,144 @@ export class FormBuilderComponent extends SilverStripeComponent {
}); });
} }
/** /**
* Cleans up Redux state used by the form when the Form component is unmonuted. * If there is structural and state data available merge those data for each field.
* Otherwise just use the structural data. Ensure that keys don't conflict
* with redux-form expectations.
* *
* @param {string} formId - ID of the form to clean up. * @param {array} fields
* @param {Object} state Optional
* @return {array}
*/ */
removeForm(formId) { normalizeFields(fields, state) {
this.props.formActions.removeForm(formId); return fields.map((field) => {
} const fieldState = (state && state.fields)
? state.fields.find((item) => item.id === field.id)
: {};
/** const data = merge.recursive(
* If there is structural and state data availabe merge those data for each field. true,
* Otherwise just use the structural data. this.mergeFieldData(field, fieldState),
*/ // Overlap with redux-form prop handling : createFieldProps filters out the 'component' key
getFieldData(formFields, formState) { { schemaComponent: field.component }
if (!formFields || !formState || !formState.fields) { );
return formFields;
}
return formFields.map((field) => {
const state = formState.fields.find((item) => item.id === field.id);
const data = this.mergeFieldData(field, state);
if (field.children) { if (field.children) {
return Object.assign({}, data, { data.children = this.normalizeFields(field.children, state);
children: this.getFieldData(field.children, formState), }
return data;
}); });
} }
/**
* Ensure that keys don't conflict with redux-form expectations.
*
* @param {array} actions
* @return {array}
*/
normalizeActions(actions) {
return actions.map((action) => {
const data = merge.recursive(
true,
action,
// Overlap with redux-form prop handling : createFieldProps filters out the 'component' key
{ schemaComponent: action.component }
);
if (action.children) {
data.children = this.normalizeActions(action.children);
}
return data; return data;
}); });
} }
render() { render() {
const formId = this.getFormId(); const schema = this.props.schema.schema;
if (!formId) { const state = this.props.schema.state;
return null; const BaseFormComponent = this.props.baseFormComponent;
}
const formSchema = this.getFormSchema();
const formState = this.props.form[formId];
// If the response from fetching the initial data
// hasn't come back yet, don't render anything.
if (!formSchema || !formSchema.schema) {
return null;
}
// Map form schema to React component attribute names, // Map form schema to React component attribute names,
// which requires renaming some of them (by unsetting the original keys) // which requires renaming some of them (by unsetting the original keys)
const attributes = Object.assign({}, formSchema.schema.attributes, { const attributes = Object.assign({}, schema.attributes, {
className: formSchema.schema.attributes.class, className: schema.attributes.class,
encType: formSchema.schema.attributes.enctype, encType: schema.attributes.enctype,
}); });
// these two still cause silent errors
delete attributes.class; delete attributes.class;
delete attributes.enctype; delete attributes.enctype;
const fieldData = this.getFieldData(formSchema.schema.fields, formState); const {
const actionData = this.getFieldData(formSchema.schema.actions, formState); asyncValidate,
onSubmitFail,
onSubmitSuccess,
shouldAsyncValidate,
touchOnBlur,
touchOnChange,
persistentSubmitErrors,
validate,
form,
} = this.props;
const formProps = { const props = {
actions: actionData, form, // required as redux-form identifier
fields: this.normalizeFields(schema.fields, state),
actions: this.normalizeActions(schema.actions),
attributes, attributes,
componentWillUnmount: this.removeForm, data: schema.data,
data: formSchema.schema.data, initialValues: this.getFieldValues(),
fields: fieldData, onSubmit: this.handleSubmit,
formId,
handleSubmit: this.handleSubmit,
mapActionsToComponents: this.mapActionsToComponents, mapActionsToComponents: this.mapActionsToComponents,
mapFieldsToComponents: this.mapFieldsToComponents, mapFieldsToComponents: this.mapFieldsToComponents,
asyncValidate,
onSubmitFail,
onSubmitSuccess,
shouldAsyncValidate,
touchOnBlur,
touchOnChange,
persistentSubmitErrors,
validate,
}; };
return <Form {...formProps} />; return <BaseFormComponent {...props} />;
} }
} }
FormBuilderComponent.propTypes = { const schemaPropType = PropTypes.shape({
config: React.PropTypes.object, id: PropTypes.string.isRequired,
createFn: React.PropTypes.func, schema: PropTypes.shape({
form: React.PropTypes.object.isRequired, attributes: PropTypes.shape({
formActions: React.PropTypes.object.isRequired, class: PropTypes.string,
handleSubmit: React.PropTypes.func, enctype: PropTypes.string,
handleAction: React.PropTypes.func, }),
schemas: React.PropTypes.object.isRequired, fields: PropTypes.array.isRequired,
schemaActions: React.PropTypes.object.isRequired, }).isRequired,
schemaUrl: React.PropTypes.string.isRequired, state: PropTypes.shape({
fields: PropTypes.array,
}),
});
const basePropTypes = {
createFn: PropTypes.func,
handleSubmit: PropTypes.func,
handleAction: PropTypes.func,
asyncValidate: PropTypes.func,
onSubmitFail: PropTypes.func,
onSubmitSuccess: PropTypes.func,
shouldAsyncValidate: PropTypes.func,
touchOnBlur: PropTypes.bool,
touchOnChange: PropTypes.bool,
persistentSubmitErrors: PropTypes.bool,
validate: PropTypes.func,
baseFormComponent: PropTypes.func.isRequired,
baseFieldComponent: PropTypes.func.isRequired,
}; };
function mapStateToProps(state) { FormBuilder.propTypes = Object.assign({}, basePropTypes, {
return { form: PropTypes.string.isRequired,
config: state.config, schema: schemaPropType.isRequired,
form: state.form, });
schemas: state.schemas,
};
}
function mapDispatchToProps(dispatch) { export { basePropTypes, schemaPropType };
return { export default FormBuilder;
formActions: bindActionCreators(formActions, dispatch),
schemaActions: bindActionCreators(schemaActions, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(FormBuilderComponent);

View File

@ -1,15 +1,90 @@
# FormBuilder Component # FormBuilder Component
Used to generate forms, made up of field components and actions, from FormFieldSchema data. Used to generate forms, made up of field components and actions, from form schema data.
This component will be moved to Framweork or CMS when dependency injection is implemented. Forms are usually rendered through [redux-form](http://redux-form.com/),
although this can be controlled through the `baseFormComponent`
and `baseFieldComponent` props.
If you want to load the schema from a server via XHR, use the
[FormBuilderLoader](../../containers/FormBuilderLoader/README.md] instead.
## Properties ## Properties
* `createFn` (function): Gives container components a chance to access a form component before it's constructed. Use this as an opportunity to pass a custom click handler to to a field for example. * `form` (string): Form identifier (useful to reference this form through redux selectors)
* `schemaUrl` (string): The schema URL where the form will be scaffolded from e.g. '/admin/pages/schema/1'. * `baseFormComponent` (function): A React component to render the form
* `handleSubmit` (function): Event handler passed to the Form Component as a prop. Parameters received are: * `baseFieldComponent` (function): A React component to render each field. Should be a HOC which receives
* `event` (Event): The submit event, it is strongly recommended to call `preventDefault()` the actual field builder through a `component` prop.
* `fieldValues` (object): An object containing the field values captured by the Submit handler * `schema` (object): A form schema (see "Schema Structure" below)
* `submitFn` (function): A callback for when the submission was successful, if submission fails, this function should not be called. (e.g. validation error) * `createFn` (function): Gives container components a chance to access a form component before it's constructed.
* `handleAction` (function): Event handler when a form action is clicked on, allows preventing submit and know which action was clicked on. Use this as an opportunity to pass a custom click handler to to a field for example.
* `handleSubmit` (function): Event handler passed to the Form Component as a prop.
Should return a promise (usually the result of the `submitFn` argument). Parameters received are:
* `data` (object): An object containing the field values captured by the submit handler
* `action` (string): The name of the button clicked to perform this action.
Defaults to first button when form is submitted by pressing the "enter" key.
* `submitFn` (function): A callback for when the submission was successful, if submission fails,
this function should not be called. (e.g. validation error). Pass in your modified `data`
to influence the data being sent through.
* `handleAction` (function): Event handler when a form action is clicked on, allows preventing submit and know which action was clicked on. Arguments:
* `event` (function) Allows cancellation of the submission through `event.stopPropagation()`.
The action can be identified via `event.currentTarget.name`.
* `data` (object): Validated and processed field values, ready for submission
* `asyncValidate` (function): See [redux-form](http://redux-form.com/6.0.5/docs/api/ReduxForm.md/)
* `onSubmitFail` (function): See [redux-form](http://redux-form.com/6.0.5/docs/api/ReduxForm.md/)
* `onSubmitSuccess` (function): See [redux-form](http://redux-form.com/6.0.5/docs/api/ReduxForm.md/)
* `shouldAsyncValidate` (function): See [redux-form](http://redux-form.com/6.0.5/docs/api/ReduxForm.md/)
* `touchOnBlur` (bool): See [redux-form](http://redux-form.com/6.0.5/docs/api/ReduxForm.md/)
* `touchOnChange` (bool): See [redux-form](http://redux-form.com/6.0.5/docs/api/ReduxForm.md/)
* `persistentSubmitErrors` (bool): See [redux-form](http://redux-form.com/6.0.5/docs/api/ReduxForm.md/)
* `validate` (function): See [redux-form](http://redux-form.com/6.0.5/docs/api/ReduxForm.md/)
## Schema Structure
The `schema` prop expects a particular structure, containing the form structure
in a `schema` key, and the form values in a `state` key.
See [RFC: FormField React Integration API](https://github.com/silverstripe/silverstripe-framework/issues/4938) for details.
## Example
```js
import { Field as ReduxFormField, reduxForm } from 'redux-form';
class MyComponent extends Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(data, action, submitFn) {
// You can implement custom submission handling and data processing here.
// Ensure to always return a promise if you want execution to continue.
if (!this.myCheck(data)) {
return;
}
return submitFn();
}
render() {
const props = {
form: 'MyForm',
schema: { /* ... */ },
baseFormComponent: reduxForm()(Form),
baseFieldComponent: ReduxFormField,
handleSubmit: this.handleSubmit
};
return <FormBuilder {...props} />
}
}
```
With the default implementation of [redux-form](http://http://redux-form.com)
(passed in through `baseFormComponent` and `baseFieldComponent`),
the submission process works as follows:
1. `<FormBuilder>` passes it's `handleSubmit` to `reduxForm()` as an `onSubmit` prop
1. `reduxForm()` passes it's own `handleSubmit` to `<Form>`
1. `<Form>` sets `<Form onSubmit={this.props.handleSubmit}>`
1. `<Form>` calls `reduxForm()` own `handleSubmit()`, which does normalisation and validation
1. `reduxForm()` calls its `onSubmit` prop, which is set to `<FormBuilder>` `handleSubmit()`
1. `<FormBuilder>` either submits the form, or calls it's own overloaded `handleSubmit()` prop
See [handleSubmit](http://redux-form.com/6.0.5/docs/api/Props.md#-handlesubmit-eventorsubmit-function-)
in the redux-form docs for more details.

View File

@ -3,23 +3,39 @@
jest.unmock('merge'); jest.unmock('merge');
jest.unmock('lib/SilverStripeComponent'); jest.unmock('lib/SilverStripeComponent');
jest.unmock('../FormBuilder'); jest.unmock('../FormBuilder');
jest.unmock('redux-form');
import { FormBuilderComponent } from '../FormBuilder'; const React = require('react');
import ReactTestUtils from 'react-addons-test-utils';
import FormBuilder from '../FormBuilder';
describe('FormBuilder', () => {
const baseProps = {
form: 'MyForm',
baseFormComponent: () => <form />,
baseFieldComponent: (props) => {
// eslint-disable-next-line react/prop-types
const Component = props.component;
return <Component {...props} />;
},
schema: {
id: 'MyForm',
schema: {
attributes: {},
fields: [],
actions: [],
},
state: {
fields: [],
},
},
};
describe('FormBuilderComponent', () => {
describe('mergeFieldData()', () => { describe('mergeFieldData()', () => {
let formBuilder = null; let formBuilder = null;
beforeEach(() => { beforeEach(() => {
const props = { formBuilder = new FormBuilder(baseProps);
form: {},
formActions: {},
schemas: {},
schemaActions: {},
schemaUrl: 'admin/assets/schema/1',
};
formBuilder = new FormBuilderComponent(props);
}); });
it('should deep merge properties on the originalobject', () => { it('should deep merge properties on the originalobject', () => {
@ -57,40 +73,22 @@ describe('FormBuilderComponent', () => {
describe('getFieldValues()', () => { describe('getFieldValues()', () => {
let formBuilder = null; let formBuilder = null;
let fieldValues = null; let fieldValues = null;
let props = null; const props = Object.assign({}, baseProps);
it('should retrieve field values based on schema', () => { it('should retrieve field values based on schema', () => {
props = { props.schema.schema.fields = [
form: { { id: 'fieldOne', name: 'fieldOne' },
MyForm: { { id: 'fieldTwo', name: 'fieldTwo' },
submitAction: 'action_save', ];
fields: [ props.schema.state.fields = [
{ id: 'fieldOne', value: 'valOne' }, { id: 'fieldOne', value: 'valOne' },
{ id: 'fieldTwo', value: null }, { id: 'fieldTwo', value: null },
{ id: 'notInSchema', value: 'invalid' }, { id: 'notInSchema', value: 'invalid' },
], ];
}, formBuilder = new FormBuilder(baseProps);
},
formActions: {},
schemas: {
'admin/assets/schema/1': {
id: 'MyForm',
schema: {
fields: [
{ id: 'fieldOne', name: 'fieldOne' },
{ id: 'fieldTwo', name: 'fieldTwo' },
],
},
},
},
schemaActions: {},
schemaUrl: 'admin/assets/schema/1',
};
formBuilder = new FormBuilderComponent(props);
fieldValues = formBuilder.getFieldValues(); fieldValues = formBuilder.getFieldValues();
expect(fieldValues).toEqual({ expect(fieldValues).toEqual({
action_save: 1,
fieldOne: 'valOne', fieldOne: 'valOne',
fieldTwo: null, fieldTwo: null,
}); });
@ -100,23 +98,9 @@ describe('FormBuilderComponent', () => {
describe('findField()', () => { describe('findField()', () => {
let formBuilder = null; let formBuilder = null;
let fields = null; let fields = null;
const props = {
form: {
myForm: {},
formActions: {},
schemas: {
'admin/assets/schema/1': {
id: 'myForm',
schema: {},
},
},
schemaActions: {},
schemaUrl: 'admin/assets/schema/1',
},
};
beforeEach(() => { beforeEach(() => {
formBuilder = new FormBuilderComponent(props); formBuilder = new FormBuilder(baseProps);
}); });
it('should retrieve the field in the shallow fields list', () => { it('should retrieve the field in the shallow fields list', () => {
@ -152,4 +136,61 @@ describe('FormBuilderComponent', () => {
expect(field.id).toBe('fieldTwoThree'); expect(field.id).toBe('fieldTwoThree');
}); });
}); });
describe('handleSubmit', () => {
let formBuilder = null;
const props = baseProps;
beforeEach(() => {
formBuilder = ReactTestUtils.renderIntoDocument(<FormBuilder {...props} />);
props.schema.schema.fields = [
{ id: 'fieldOne', name: 'fieldOne' },
{ id: 'fieldTwo', name: 'fieldTwo' },
];
props.schema.schema.actions = [
{ id: 'actionOne', name: 'actionOne' },
{ id: 'actionTwo', name: 'actionTwo' },
];
props.schema.state.fields = [
{ id: 'fieldOne', value: 'valOne' },
{ id: 'fieldTwo', value: null },
{ id: 'notInSchema', value: 'invalid' },
];
});
it('should include submitted action from schema', () => {
formBuilder.setState({ submittingAction: 'actionTwo' });
const submitApiMock = jest.genMockFunction();
submitApiMock.mockImplementation(() => Promise.resolve({}));
formBuilder.submitApi = submitApiMock;
formBuilder.handleSubmit(formBuilder.getFieldValues());
expect(formBuilder.submitApi.mock.calls[0][0]).toEqual(
{
fieldOne: 'valOne',
fieldTwo: null,
actionTwo: 1,
}
);
});
it('should default to first button when none is specified', () => {
const submitApiMock = jest.genMockFunction();
submitApiMock.mockImplementation(() => Promise.resolve({}));
formBuilder.submitApi = submitApiMock;
formBuilder.handleSubmit(formBuilder.getFieldValues());
expect(formBuilder.submitApi.mock.calls[0][0]).toEqual(
{
fieldOne: 'valOne',
fieldTwo: null,
actionOne: 1,
}
);
});
});
}); });

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Modal } from 'react-bootstrap-ss'; import { Modal } from 'react-bootstrap-ss';
import SilverStripeComponent from 'lib/SilverStripeComponent'; import SilverStripeComponent from 'lib/SilverStripeComponent';
import FormBuilder from 'components/FormBuilder/FormBuilder'; import FormBuilderLoader from 'containers/FormBuilderLoader/FormBuilderLoader';
class FormBuilderModal extends SilverStripeComponent { class FormBuilderModal extends SilverStripeComponent {
constructor(props) { constructor(props) {
@ -19,7 +19,7 @@ class FormBuilderModal extends SilverStripeComponent {
*/ */
getForm() { getForm() {
return ( return (
<FormBuilder <FormBuilderLoader
schemaUrl={this.props.schemaUrl} schemaUrl={this.props.schemaUrl}
handleSubmit={this.handleSubmit} handleSubmit={this.handleSubmit}
handleAction={this.props.handleAction} handleAction={this.props.handleAction}
@ -74,17 +74,16 @@ class FormBuilderModal extends SilverStripeComponent {
/** /**
* Handle submitting the form in the Modal * Handle submitting the form in the Modal
* *
* @param {Event} event * @param {Object} data
* @param {object} fieldValues * @param {String} action
* @param {function} submitFn * @param {Function} submitFn The original submit function
* @returns {Promise} * @returns {Promise}
*/ */
handleSubmit(event, fieldValues, submitFn) { handleSubmit(data, action, submitFn) {
let promise = null; let promise = null;
if (typeof this.props.handleSubmit === 'function') { if (typeof this.props.handleSubmit === 'function') {
promise = this.props.handleSubmit(event, fieldValues, submitFn); promise = this.props.handleSubmit(data, action, submitFn);
} else { } else {
event.preventDefault();
promise = submitFn(); promise = submitFn();
} }
@ -106,6 +105,7 @@ class FormBuilderModal extends SilverStripeComponent {
}); });
}); });
} }
return promise; return promise;
} }

View File

@ -35,7 +35,7 @@ class OptionsetField extends SilverStripeComponent {
const sourceItem = this.props.source const sourceItem = this.props.source
.find((item, index) => this.getItemKey(item, index) === field.id); .find((item, index) => this.getItemKey(item, index) === field.id);
this.props.onChange(event, { id: this.props.id, value: `${sourceItem.value}` }); this.props.onChange(sourceItem.value);
} }
} }
} }

View File

@ -47,8 +47,7 @@ describe('OptionsetField', () => {
setField.handleChange(event, { id: 'set-one', value: 1 }); setField.handleChange(event, { id: 'set-one', value: 1 });
expect(setField.props.onChange).toBeCalledWith( expect(setField.props.onChange).toBeCalledWith(
event, 'one'
{ id: 'set', value: 'one' }
); );
}); });
}); });

View File

@ -9,7 +9,7 @@ import SilverStripeComponent from 'lib/SilverStripeComponent';
import FormAction from 'components/FormAction/FormAction'; import FormAction from 'components/FormAction/FormAction';
import i18n from 'i18n'; import i18n from 'i18n';
import Toolbar from 'components/Toolbar/Toolbar'; import Toolbar from 'components/Toolbar/Toolbar';
import FormBuilder from 'components/FormBuilder/FormBuilder'; import FormBuilderLoader from 'containers/FormBuilderLoader/FormBuilderLoader';
import CampaignAdminList from './CampaignAdminList'; import CampaignAdminList from './CampaignAdminList';
class CampaignAdmin extends SilverStripeComponent { class CampaignAdmin extends SilverStripeComponent {
@ -56,7 +56,7 @@ class CampaignAdmin extends SilverStripeComponent {
// NOOP - Lazy loaded in CampaignAdminList.js // NOOP - Lazy loaded in CampaignAdminList.js
break; break;
case 'edit': case 'edit':
// @todo - Lazy load in FormBuilder / GridField // @todo - Lazy load in FormBuilderLoader / GridField
breadcrumbs.push({ breadcrumbs.push({
text: i18n._t('Campaigns.EDIT_CAMPAIGN', 'Editing Campaign'), text: i18n._t('Campaigns.EDIT_CAMPAIGN', 'Editing Campaign'),
href: this.getActionRoute(id, view), href: this.getActionRoute(id, view),
@ -137,7 +137,7 @@ class CampaignAdmin extends SilverStripeComponent {
</div> </div>
</div> </div>
<div className="campaign-admin"> <div className="campaign-admin">
<FormBuilder {...formBuilderProps} /> <FormBuilderLoader {...formBuilderProps} />
</div> </div>
</div> </div>
</div> </div>
@ -184,7 +184,7 @@ class CampaignAdmin extends SilverStripeComponent {
</Toolbar> </Toolbar>
<div className="panel panel--padded panel--scrollable flexbox-area-grow form--inline"> <div className="panel panel--padded panel--scrollable flexbox-area-grow form--inline">
<FormBuilder {...formBuilderProps} /> <FormBuilderLoader {...formBuilderProps} />
</div> </div>
</div> </div>
); );
@ -210,7 +210,7 @@ class CampaignAdmin extends SilverStripeComponent {
<BreadcrumbComponent multiline crumbs={this.props.breadcrumbs} /> <BreadcrumbComponent multiline crumbs={this.props.breadcrumbs} />
</Toolbar> </Toolbar>
<div className="panel panel--padded panel--scrollable flexbox-area-grow form--inline"> <div className="panel panel--padded panel--scrollable flexbox-area-grow form--inline">
<FormBuilder {...formBuilderProps} /> <FormBuilderLoader {...formBuilderProps} />
</div> </div>
</div> </div>
); );
@ -218,10 +218,10 @@ class CampaignAdmin extends SilverStripeComponent {
/** /**
* Hook to allow customisation of components being constructed * Hook to allow customisation of components being constructed
* by the Campaign DetailEdit FormBuilder. * by the Campaign DetailEdit FormBuilderLoader.
* *
* @param {Object} Component - Component constructor. * @param {Object} Component - Component constructor.
* @param {Object} props - Props passed from FormBuilder. * @param {Object} props - Props passed from FormBuilderLoader.
* *
* @return {Object} - Instanciated React component * @return {Object} - Instanciated React component
*/ */
@ -245,10 +245,10 @@ class CampaignAdmin extends SilverStripeComponent {
/** /**
* Hook to allow customisation of components being constructed * Hook to allow customisation of components being constructed
* by the Campaign creation FormBuilder. * by the Campaign creation FormBuilderLoader.
* *
* @param {Object} Component - Component constructor. * @param {Object} Component - Component constructor.
* @param {Object} props - Props passed from FormBuilder. * @param {Object} props - Props passed from FormBuilderLoader.
* *
* @return {Object} - Instanciated React component * @return {Object} - Instanciated React component
*/ */
@ -272,10 +272,10 @@ class CampaignAdmin extends SilverStripeComponent {
/** /**
* Hook to allow customisation of components being constructed * Hook to allow customisation of components being constructed
* by the Campaign list FormBuilder. * by the Campaign list FormBuilderLoader.
* *
* @param object Component - Component constructor. * @param object Component - Component constructor.
* @param object props - Props passed from FormBuilder. * @param object props - Props passed from FormBuilderLoader.
* *
* @return object - Instanciated React component * @return object - Instanciated React component
*/ */
@ -283,7 +283,7 @@ class CampaignAdmin extends SilverStripeComponent {
const sectionUrl = this.props.sectionConfig.url; const sectionUrl = this.props.sectionConfig.url;
const typeUrlParam = 'set'; const typeUrlParam = 'set';
if (props.component === 'GridField') { if (props.schemaComponent === 'GridField') {
const extendedProps = Object.assign({}, props, { const extendedProps = Object.assign({}, props, {
data: Object.assign({}, props.data, { data: Object.assign({}, props.data, {
handleDrillDown: (event, record) => { handleDrillDown: (event, record) => {

View File

@ -0,0 +1,106 @@
import React, { PropTypes, Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import fetch from 'isomorphic-fetch';
import { Field as ReduxFormField, reduxForm } from 'redux-form';
import * as schemaActions from 'state/schema/SchemaActions';
import Form from 'components/Form/Form';
import FormBuilder, { basePropTypes, schemaPropType } from 'components/FormBuilder/FormBuilder';
import es6promise from 'es6-promise';
es6promise.polyfill();
class FormBuilderLoader extends Component {
constructor(props) {
super(props);
this.handleSubmitSuccess = this.handleSubmitSuccess.bind(this);
}
componentDidMount() {
this.fetch();
}
componentDidUpdate(prevProps) {
if (this.props.schemaUrl !== prevProps.schemaUrl) {
this.fetch();
}
}
handleSubmitSuccess(result) {
this.props.schemaActions.setSchema(result);
if (this.props.onSubmitSuccess) {
this.props.onSubmitSuccess(result);
}
}
/**
* Fetches data used to generate a form. This can be form schema and or form state data.
* When the response comes back the data is saved to state.
*
* @param {Boolean} schema If form schema data should be returned in the response.
* @param {Boolean} state If form state data should be returned in the response.
* @return {Object} Promise from the AJAX request.
*/
fetch(schema = true, state = true) {
const headerValues = [];
if (schema === true) {
headerValues.push('schema');
}
if (state === true) {
headerValues.push('state');
}
return fetch(this.props.schemaUrl, {
headers: { 'X-FormSchema-Request': headerValues.join() },
credentials: 'same-origin',
})
.then(response => response.json())
.then(formSchema => {
if (typeof formSchema.id !== 'undefined') {
this.props.schemaActions.setSchema(formSchema);
}
});
}
render() {
// If the response from fetching the initial data
// hasn't come back yet, don't render anything.
if (!this.props.schema) {
return null;
}
const props = Object.assign({}, this.props, {
onSubmitSuccess: this.handleSubmitSuccess,
});
return <FormBuilder {...props} />;
}
}
FormBuilderLoader.propTypes = Object.assign({}, basePropTypes, {
schemaActions: PropTypes.object.isRequired,
schemaUrl: PropTypes.string.isRequired,
schema: schemaPropType,
form: PropTypes.string,
});
FormBuilderLoader.defaultProps = {
// Perform this *outside* of render() to avoid re-rendering of the whole DOM structure
// every time render() is triggered.
baseFormComponent: reduxForm()(Form),
baseFieldComponent: ReduxFormField,
};
export default connect(
(state, ownProps) => {
const schema = state.schemas[ownProps.schemaUrl];
const form = schema ? schema.id : null;
return { schema, form };
},
(dispatch) => ({
schemaActions: bindActionCreators(schemaActions, dispatch),
})
)(FormBuilderLoader);

View File

@ -0,0 +1,12 @@
# FormBuilderLoader Component
Used to retrieve a schema for FormBuilder to generate forms made up of field components and actions.
Wraps a [FormBuilder](../../components/FormBuilder/README.md] component with async loading logic,
and stores the loaded schemas in a Redux store.
## Properties
* `schemaUrl` (string): The schema URL where the form will be scaffolded from e.g. '/admin/pages/schema/1'.
* `schemaActions` (object): A `setSchema()` function to interact with the redux store.
See [FormBuilder](../../components/FormBuilder/README.md] for more properties.

View File

@ -1,6 +0,0 @@
import { reduxForm } from 'redux-form';
import Form from 'components/Form/Form';
export default reduxForm({
// configured at runtime
})(Form);

View File

@ -1,9 +0,0 @@
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',
SET_SUBMIT_ACTION: 'SET_SUBMIT_ACTION',
};

View File

@ -1,96 +0,0 @@
import { ACTION_TYPES } from './FormActionTypes';
/**
* 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) => {
const headers = {
'X-Formschema-Request': 'schema,state',
'X-Requested-With': 'XMLHttpRequest',
};
dispatch({
type: ACTION_TYPES.SUBMIT_FORM_REQUEST,
payload: { formId },
});
return submitApi(Object.assign({ ID: formId }, fieldValues), headers)
.then((response) => {
dispatch({
type: ACTION_TYPES.SUBMIT_FORM_SUCCESS,
payload: { response },
});
return response;
})
.catch((error) => {
throw error.response.text().then((errorText) => {
dispatch({
type: ACTION_TYPES.SUBMIT_FORM_FAILURE,
payload: { formId, error: errorText },
});
return errorText;
});
});
};
}
export function setSubmitAction(formId, submitAction) {
return (dispatch) => {
dispatch({
type: ACTION_TYPES.SET_SUBMIT_ACTION,
payload: { formId, submitAction },
});
};
}

View File

@ -1,78 +0,0 @@
import deepFreeze from 'deep-freeze-strict';
import { ACTION_TYPES } from './FormActionTypes';
const initialState = deepFreeze({});
function formReducer(state = initialState, action) {
const updateForm = (formId, data) => Object.assign({},
state, {
[formId]: Object.assign({},
state[formId],
data
),
});
switch (action.type) {
case ACTION_TYPES.SUBMIT_FORM_REQUEST:
return deepFreeze(updateForm(action.payload.formId, {
error: false,
submitting: true,
}));
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,
error: false,
submitting: false,
},
}));
case ACTION_TYPES.UPDATE_FIELD:
return deepFreeze(updateForm(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;
}),
}));
case ACTION_TYPES.SUBMIT_FORM_SUCCESS:
return deepFreeze(updateForm(action.payload.response.id, {
fields: action.payload.response.state.fields,
error: false,
messages: action.payload.response.state.messages,
submitting: false,
}));
case ACTION_TYPES.SUBMIT_FORM_FAILURE:
return deepFreeze(updateForm(action.payload.formId, {
error: true,
messages: action.payload.error,
submitting: false,
}));
case ACTION_TYPES.SET_SUBMIT_ACTION:
return deepFreeze(updateForm(action.payload.formId, {
submitAction: action.payload.submitAction,
}));
default:
return state;
}
}
export default formReducer;

View File

@ -1,24 +0,0 @@
# form
This state key holds form and form field data. Forms built using the `FormBuilder` component
have their state stored in child keys of `form` (keyed by form ID) automatically.
```js
{
form: {
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

@ -1,233 +0,0 @@
/* global jest, describe, expect, it, beforeEach */
jest.unmock('deep-freeze-strict');
jest.unmock('../FormReducer');
jest.unmock('../FormActionTypes');
import deepFreeze from 'deep-freeze-strict';
import { ACTION_TYPES } from '../FormActionTypes';
import formReducer from '../FormReducer';
describe('formReducer', () => {
describe('ADD_FORM', () => {
const initialState = deepFreeze({
DetailEditForm: {
fields: [
{
data: [],
id: 'Form_DetailEditForm_Name',
messages: [],
valid: true,
value: 'Test',
},
],
submitting: false,
},
});
it('should add a form', () => {
const payload = {
formState: {
fields: [
{
data: [],
id: 'Form_EditForm_Name',
messages: [],
valid: true,
value: 'Test',
},
],
id: 'EditForm',
messages: [],
},
};
const nextState = formReducer(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');
expect(nextState.EditForm.submitting).toBe(false);
});
});
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 = formReducer(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 = formReducer(initialState, {
type: ACTION_TYPES.UPDATE_FIELD,
payload: {
formId: 'DetailEditForm',
updates: {
id: 'Form_DetailEditForm_Name',
value: 'Updated',
},
},
});
expect(nextState.DetailEditForm.fields[0].value).toBe('Updated');
});
});
describe('SUBMIT_FORM_SUCCESS', () => {
const initialState = deepFreeze({
DetailEditForm: {
fields: [
{
data: [],
id: 'Form_DetailEditForm_Name',
messages: [],
valid: true,
value: 'Test',
},
],
submitting: true,
},
});
it('should add top level form messages', () => {
const nextState = formReducer(initialState, {
type: ACTION_TYPES.SUBMIT_FORM_SUCCESS,
payload: {
id: 'DetailEditForm',
response: {
id: 'DetailEditForm',
state: {
fields: [
{
data: [],
id: 'Form_DetailEditForm_Name',
messages: [],
valid: true,
value: 'Test',
},
],
messages: [
{
type: 'good',
value: 'Saved.',
},
],
},
},
},
});
expect(nextState.DetailEditForm.messages).toBeDefined();
expect(nextState.DetailEditForm.messages.length).toBe(1);
expect(nextState.DetailEditForm.messages[0].type).toBe('good');
expect(nextState.DetailEditForm.messages[0].value).toBe('Saved.');
expect(nextState.DetailEditForm.submitting).toBe(false);
});
});
describe('SUBMIT_FORM_REQUEST', () => {
it('should set submitting to true', () => {
const initialState = deepFreeze({
DetailEditForm: {
fields: [
{
data: [],
id: 'Form_DetailEditForm_Name',
messages: [],
valid: true,
value: 'Test',
},
],
submitting: false,
},
});
const nextState = formReducer(initialState, {
type: ACTION_TYPES.SUBMIT_FORM_REQUEST,
payload: { formId: 'DetailEditForm' },
});
expect(nextState.DetailEditForm.submitting).toBe(true);
});
});
describe('SUBMIT_FORM_FAILURE', () => {
it('should set submitting to false', () => {
const initialState = deepFreeze({
DetailEditForm: {
fields: [
{
data: [],
id: 'Form_DetailEditForm_Name',
messages: [],
valid: true,
value: 'Test',
},
],
submitting: true,
},
});
const nextState = formReducer(initialState, {
type: ACTION_TYPES.SUBMIT_FORM_FAILURE,
payload: { formId: 'DetailEditForm' },
});
expect(nextState.DetailEditForm.submitting).toBe(false);
});
});
});

View File

@ -2,6 +2,8 @@ import ACTION_TYPES from './SchemaActionTypes';
/** /**
* Sets the schema being used to generate the current form layout. * Sets the schema being used to generate the current form layout.
* Note that the `state` key just determines the initial form field values,
* and is overruled by redux-form behaviour (stored in separate reducer key)
* *
* @param string schema - JSON schema for the layout. * @param string schema - JSON schema for the layout.
*/ */

70
npm-shrinkwrap.json generated
View File

@ -8388,6 +8388,76 @@
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/redux/-/redux-3.0.5.tgz" "resolved": "https://registry.npmjs.org/redux/-/redux-3.0.5.tgz"
}, },
"redux-form": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/redux-form/-/redux-form-6.0.5.tgz",
"dependencies": {
"array-findindex-polyfill": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/array-findindex-polyfill/-/array-findindex-polyfill-0.1.0.tgz"
},
"es6-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-3.1.0.tgz"
},
"hoist-non-react-statics": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz"
},
"invariant": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.1.tgz",
"dependencies": {
"loose-envify": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.2.0.tgz",
"dependencies": {
"js-tokens": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-1.0.3.tgz"
}
}
}
}
},
"is-promise": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz"
},
"lodash": {
"version": "4.16.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.0.tgz"
},
"lodash-es": {
"version": "4.16.0",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.16.0.tgz"
},
"shallowequal": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-0.2.2.tgz",
"dependencies": {
"lodash.keys": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz",
"dependencies": {
"lodash._getnative": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz"
},
"lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz"
},
"lodash.isarray": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz"
}
}
}
}
}
}
},
"redux-logger": { "redux-logger": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-2.6.1.tgz" "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-2.6.1.tgz"

View File

@ -58,6 +58,7 @@
"react-router": "^2.4.1", "react-router": "^2.4.1",
"react-router-redux": "^4.0.5", "react-router-redux": "^4.0.5",
"redux": "https://registry.npmjs.org/redux/-/redux-3.0.5.tgz", "redux": "https://registry.npmjs.org/redux/-/redux-3.0.5.tgz",
"redux-form": "^6.0.2",
"redux-thunk": "^2.1.0", "redux-thunk": "^2.1.0",
"tether": "^1.3.2", "tether": "^1.3.2",
"url": "^0.11.0" "url": "^0.11.0"
@ -104,7 +105,8 @@
"mocksPattern": "mocks", "mocksPattern": "mocks",
"unmockedModulePathPatterns": [ "unmockedModulePathPatterns": [
"<rootDir>/node_modules/react", "<rootDir>/node_modules/react",
"<rootDir>/node_modules/qs" "<rootDir>/node_modules/qs",
"<rootDir>/node_modules/redux-form"
], ],
"bail": true, "bail": true,
"testRunner": "<rootDir>/node_modules/jest-cli/src/testRunners/jasmine/jasmine2.js" "testRunner": "<rootDir>/node_modules/jest-cli/src/testRunners/jasmine/jasmine2.js"

View File

@ -68,6 +68,7 @@ const config = [
'components/FormBuilderModal/FormBuilderModal': 'FormBuilderModal', 'components/FormBuilderModal/FormBuilderModal': 'FormBuilderModal',
'components/GridField/GridField': 'GridField', 'components/GridField/GridField': 'GridField',
'components/Toolbar/Toolbar': 'Toolbar', 'components/Toolbar/Toolbar': 'Toolbar',
'containers/FormBuilderLoader/FormBuilderLoader': 'FormBuilderLoader',
'deep-freeze-strict': 'DeepFreezeStrict', 'deep-freeze-strict': 'DeepFreezeStrict',
i18n: 'i18n', i18n: 'i18n',
jQuery: 'jQuery', jQuery: 'jQuery',
@ -85,6 +86,7 @@ const config = [
'react-router': 'ReactRouter', 'react-router': 'ReactRouter',
'react-addons-css-transition-group': 'ReactAddonsCssTransitionGroup', 'react-addons-css-transition-group': 'ReactAddonsCssTransitionGroup',
react: 'React', react: 'React',
'redux-form': 'ReduxForm',
'redux-thunk': 'ReduxThunk', 'redux-thunk': 'ReduxThunk',
redux: 'Redux', redux: 'Redux',
config: 'Config', config: 'Config',