silverstripe-framework/admin/client/src/components/FormBuilder/FormBuilder.js

403 lines
12 KiB
JavaScript

import React, { PropTypes } from 'react';
import merge from 'merge';
import schemaFieldValues, { findField } from 'lib/schemaFieldValues';
import SilverStripeComponent from 'lib/SilverStripeComponent';
import Validator from 'lib/Validator';
import backend from 'lib/Backend';
import injector from 'lib/Injector';
class FormBuilder extends SilverStripeComponent {
constructor(props) {
super(props);
const schemaStructure = props.schema.schema;
this.state = { submittingAction: null };
this.submitApi = backend.createEndpointFetcher({
url: schemaStructure.attributes.action,
method: schemaStructure.attributes.method,
});
this.mapActionsToComponents = this.mapActionsToComponents.bind(this);
this.mapFieldsToComponents = this.mapFieldsToComponents.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleAction = this.handleAction.bind(this);
this.buildComponent = this.buildComponent.bind(this);
this.validateForm = this.validateForm.bind(this);
}
/**
* Run validation for every field on the form and return an object which list issues while
* validating
*
* @param values
* @returns {*}
*/
validateForm(values) {
if (typeof this.props.validate === 'function') {
return this.props.validate(values);
}
const schema = this.props.schema && this.props.schema.schema;
if (!schema) {
return {};
}
const validator = new Validator(values);
return Object.entries(values).reduce((prev, curr) => {
const [key] = curr;
const field = findField(this.props.schema.schema.fields, key);
const { valid, errors } = validator.validateFieldSchema(field);
if (valid) {
return prev;
}
// so if there are multiple errors, it will be listed in html spans
const errorHtml = errors.map((message, index) => (
<span key={index} className="form__validation-message">{message}</span>
));
return Object.assign({}, prev, {
[key]: {
type: 'error',
value: { react: errorHtml },
},
});
}, {});
}
/**
* 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
*
* @param {Event} event
*/
handleAction(event) {
// Custom handlers
if (typeof this.props.handleAction === 'function') {
this.props.handleAction(event, this.props.values);
}
// Allow custom handlers to cancel event
if (!event.isPropagationStopped()) {
this.setState({ submittingAction: event.currentTarget.name });
}
}
/**
* Form submission handler passed to the Form Component as a prop.
* Provides a hook for controllers to access for state and provide custom functionality.
*
* @param {Object} data Processed and validated data from redux-form
* (originally retrieved through schemaFieldValues())
* @return {Promise|null}
*/
handleSubmit(data) {
// 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 dataWithAction = Object.assign({}, data, {
[action]: 1,
});
const requestedSchema = this.props.responseRequestedSchema.join();
const headers = {
'X-Formschema-Request': requestedSchema,
'X-Requested-With': 'XMLHttpRequest',
};
const submitFn = (customData) =>
this.submitApi(customData || dataWithAction, headers)
.then(formSchema => {
this.setState({ submittingAction: null });
return formSchema;
})
.catch((reason) => {
// TODO Generic CMS error reporting
this.setState({ submittingAction: null });
throw reason;
});
if (typeof this.props.handleSubmit === 'function') {
return this.props.handleSubmit(dataWithAction, action, submitFn);
}
return submitFn();
}
/**
* Common functionality for building a Field or Action from schema.
*
* @param {Object} props Props which every form field receives. Leave it up to the
* schema and component to determine which props are required.
* @returns {*}
*/
buildComponent(props) {
let componentProps = props;
// 'component' key is renamed to 'schemaComponent' in normalize*() methods
const SchemaComponent = componentProps.schemaComponent !== null
? injector.getComponentByName(componentProps.schemaComponent)
: injector.getComponentByDataType(componentProps.type);
if (SchemaComponent === null) {
return null;
} else if (componentProps.schemaComponent !== null && SchemaComponent === undefined) {
throw Error(`Component not found in injector: ${componentProps.schemaComponent}`);
}
// 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
// and apply customisations to scaffolded components.
const createFn = this.props.createFn;
if (typeof createFn === 'function') {
return createFn(SchemaComponent, componentProps);
}
return <SchemaComponent key={componentProps.id} {...componentProps} />;
}
/**
* Maps a list of schema fields to their React Component.
* Only top level form fields are handled here, composite fields (TabSets etc),
* are responsible for mapping and rendering their children.
*
* @param {Array} fields
* @return {Array}
*/
mapFieldsToComponents(fields) {
const FieldComponent = this.props.baseFieldComponent;
return fields.map((field) => {
let props = field;
if (field.children) {
props = Object.assign(
{},
field,
{ children: this.mapFieldsToComponents(field.children) }
);
}
// 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} />;
});
}
/**
* Maps a list of form actions to their React Component.
*
* @param {Array} actions
* @return {Array}
*/
mapActionsToComponents(actions) {
return actions.map((action) => {
const props = Object.assign({}, action);
if (action.children) {
props.children = this.mapActionsToComponents(action.children);
} else {
props.handleClick = this.handleAction;
// Reset through componentWillReceiveProps()
if (this.props.submitting && this.state.submittingAction === action.name) {
props.loading = true;
}
}
return this.buildComponent(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) {
// could be a dataless field
if (typeof state === 'undefined') {
return structure;
}
return merge.recursive(true, structure, {
data: state.data,
source: state.source,
message: state.message,
valid: state.valid,
value: state.value,
});
}
/**
* 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 {array} fields
* @param {Object} state Optional
* @return {array}
*/
normalizeFields(fields, state) {
return fields.map((field) => {
const fieldState = (state && state.fields)
? state.fields.find((item) => item.id === field.id)
: {};
const data = merge.recursive(
true,
this.mergeFieldData(field, fieldState),
// Overlap with redux-form prop handling : createFieldProps filters out the 'component' key
{ schemaComponent: field.component }
);
if (field.children) {
data.children = this.normalizeFields(field.children, state);
}
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;
});
}
render() {
const schema = this.props.schema.schema;
const state = this.props.schema.state;
const BaseFormComponent = this.props.baseFormComponent;
// Map form schema to React component attribute names,
// which requires renaming some of them (by unsetting the original keys)
const attributes = Object.assign({}, schema.attributes, {
className: schema.attributes.class,
encType: schema.attributes.enctype,
});
delete attributes.class;
delete attributes.enctype;
const {
asyncValidate,
onSubmitFail,
onSubmitSuccess,
shouldAsyncValidate,
touchOnBlur,
touchOnChange,
persistentSubmitErrors,
form,
afterMessages,
} = this.props;
const props = {
form, // required as redux-form identifier
afterMessages,
fields: this.normalizeFields(schema.fields, state),
actions: this.normalizeActions(schema.actions),
attributes,
data: schema.data,
initialValues: schemaFieldValues(schema, state),
onSubmit: this.handleSubmit,
valid: state && state.valid,
messages: (state && Array.isArray(state.messages)) ? state.messages : [],
mapActionsToComponents: this.mapActionsToComponents,
mapFieldsToComponents: this.mapFieldsToComponents,
asyncValidate,
onSubmitFail,
onSubmitSuccess,
shouldAsyncValidate,
touchOnBlur,
touchOnChange,
persistentSubmitErrors,
validate: this.validateForm,
};
return <BaseFormComponent {...props} />;
}
}
const schemaPropType = PropTypes.shape({
id: PropTypes.string,
schema: PropTypes.shape({
attributes: PropTypes.shape({
class: PropTypes.string,
enctype: PropTypes.string,
}),
fields: PropTypes.array.isRequired,
}),
state: PropTypes.shape({
fields: PropTypes.array,
}),
loading: PropTypes.boolean,
stateOverride: 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,
values: PropTypes.object,
submitting: PropTypes.bool,
baseFormComponent: PropTypes.func.isRequired,
baseFieldComponent: PropTypes.func.isRequired,
responseRequestedSchema: PropTypes.arrayOf(PropTypes.oneOf([
'schema', 'state', 'errors', 'auto',
])),
};
FormBuilder.propTypes = Object.assign({}, basePropTypes, {
form: PropTypes.string.isRequired,
schema: schemaPropType.isRequired,
});
FormBuilder.defaultProps = {
responseRequestedSchema: ['auto'],
};
export { basePropTypes, schemaPropType };
export default FormBuilder;