2016-03-22 00:25:23 +01:00
|
|
|
import React from 'react';
|
|
|
|
import { connect } from 'react-redux';
|
|
|
|
import { bindActionCreators } from 'redux';
|
2016-04-12 06:47:24 +02:00
|
|
|
import * as formsActions from 'state/forms/FormsActions';
|
2016-04-21 11:59:44 +02:00
|
|
|
import * as schemaActions from 'state/schema/SchemaActions';
|
|
|
|
import SilverStripeComponent from 'lib/SilverStripeComponent';
|
|
|
|
import FormComponent from 'components/Form/Form';
|
2016-04-12 06:47:24 +02:00
|
|
|
import FormActionComponent from 'components/FormAction/FormAction';
|
2016-04-21 11:59:44 +02:00
|
|
|
import TextField from 'components/TextField/TextField';
|
|
|
|
import HiddenField from 'components/HiddenField/HiddenField';
|
|
|
|
import GridField from 'components/GridField/GridField';
|
2016-03-29 04:38:48 +02:00
|
|
|
import fetch from 'isomorphic-fetch';
|
2016-04-07 12:35:52 +02:00
|
|
|
import deepFreeze from 'deep-freeze';
|
2016-04-12 06:47:24 +02:00
|
|
|
import backend from 'lib/Backend';
|
2016-03-29 04:38:48 +02:00
|
|
|
|
|
|
|
import es6promise from 'es6-promise';
|
|
|
|
es6promise.polyfill();
|
2016-03-22 00:25:23 +01:00
|
|
|
|
|
|
|
// Using this to map field types to components until we implement dependency injection.
|
2016-03-30 23:45:54 +02:00
|
|
|
const fakeInjector = {
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Components registered with the fake DI container.
|
|
|
|
*/
|
|
|
|
components: {
|
|
|
|
TextField,
|
|
|
|
GridField,
|
|
|
|
HiddenField,
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets the component matching the passed component name.
|
|
|
|
* Used when a component type is provided bt the form schema.
|
|
|
|
*
|
|
|
|
* @param string componentName - The name of the component to get from the injector.
|
|
|
|
*
|
|
|
|
* @return object|null
|
|
|
|
*/
|
|
|
|
getComponentByName(componentName) {
|
|
|
|
return this.components[componentName];
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Default data type to component mappings.
|
|
|
|
* Used as a fallback when no component type is provided in the form schema.
|
|
|
|
*
|
|
|
|
* @param string dataType - The data type provided by the form schema.
|
|
|
|
*
|
|
|
|
* @return object|null
|
|
|
|
*/
|
|
|
|
getComponentByDataType(dataType) {
|
|
|
|
switch (dataType) {
|
2016-04-12 06:47:24 +02:00
|
|
|
case 'Text':
|
2016-03-30 23:45:54 +02:00
|
|
|
return this.components.TextField;
|
|
|
|
case 'Hidden':
|
|
|
|
return this.components.HiddenField;
|
|
|
|
case 'Custom':
|
|
|
|
return this.components.GridField;
|
|
|
|
default:
|
|
|
|
return null;
|
2016-03-22 00:25:23 +01:00
|
|
|
}
|
2016-03-30 23:45:54 +02:00
|
|
|
},
|
|
|
|
};
|
2016-03-22 00:25:23 +01:00
|
|
|
|
|
|
|
export class FormBuilderComponent extends SilverStripeComponent {
|
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
constructor(props) {
|
|
|
|
super(props);
|
|
|
|
|
|
|
|
this.formSchemaPromise = null;
|
2016-04-07 07:15:14 +02:00
|
|
|
this.state = { isFetching: false };
|
2016-04-12 06:47:24 +02:00
|
|
|
|
|
|
|
this.mapActionsToComponents = this.mapActionsToComponents.bind(this);
|
2016-04-12 00:24:16 +02:00
|
|
|
this.mapFieldsToComponents = this.mapFieldsToComponents.bind(this);
|
2016-04-12 06:47:24 +02:00
|
|
|
this.handleFieldUpdate = this.handleFieldUpdate.bind(this);
|
|
|
|
this.handleSubmit = this.handleSubmit.bind(this);
|
|
|
|
this.removeForm = this.removeForm.bind(this);
|
2016-04-07 07:15:14 +02:00
|
|
|
}
|
2016-03-30 23:45:54 +02:00
|
|
|
|
2016-04-07 07:15:14 +02:00
|
|
|
componentDidMount() {
|
2016-03-30 23:45:54 +02:00
|
|
|
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.
|
|
|
|
*/
|
2016-04-12 06:47:24 +02:00
|
|
|
fetch(schema = true, state = true) {
|
2016-03-30 23:45:54 +02:00
|
|
|
const headerValues = [];
|
|
|
|
|
2016-04-07 07:15:14 +02:00
|
|
|
if (this.state.isFetching === true) {
|
2016-03-30 23:45:54 +02:00
|
|
|
return this.formSchemaPromise;
|
2016-03-22 00:25:23 +01:00
|
|
|
}
|
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
if (schema === true) {
|
|
|
|
headerValues.push('schema');
|
2016-03-22 00:25:23 +01:00
|
|
|
}
|
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
if (state === true) {
|
|
|
|
headerValues.push('state');
|
2016-03-22 00:25:23 +01:00
|
|
|
}
|
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
this.formSchemaPromise = fetch(this.props.schemaUrl, {
|
|
|
|
headers: { 'X-FormSchema-Request': headerValues.join() },
|
|
|
|
credentials: 'same-origin',
|
|
|
|
})
|
|
|
|
.then(response => response.json())
|
|
|
|
.then(json => {
|
2016-04-12 06:47:24 +02:00
|
|
|
const formSchema = Object.assign({}, { id: json.id, schema: json.schema });
|
|
|
|
const formState = Object.assign({}, json.state);
|
|
|
|
|
2016-04-12 00:24:16 +02:00
|
|
|
// TODO See "Enable once <CampaignAdmin> ..." below
|
2016-04-12 06:47:24 +02:00
|
|
|
// this.setState({ isFetching: false });
|
|
|
|
|
|
|
|
if (typeof formSchema.id !== 'undefined') {
|
|
|
|
const defaultData = {
|
|
|
|
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);
|
|
|
|
}
|
2016-03-30 23:45:54 +02:00
|
|
|
});
|
|
|
|
|
2016-04-12 00:24:16 +02:00
|
|
|
// 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 });
|
2016-03-30 23:45:54 +02:00
|
|
|
|
|
|
|
return this.formSchemaPromise;
|
|
|
|
}
|
|
|
|
|
2016-04-12 06:47:24 +02:00
|
|
|
/**
|
|
|
|
* 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();
|
|
|
|
}
|
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
/**
|
|
|
|
* 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) {
|
2016-04-12 00:24:16 +02:00
|
|
|
const createFn = this.props.createFn;
|
2016-04-12 06:47:24 +02:00
|
|
|
const handleFieldUpdate = this.handleFieldUpdate;
|
2016-04-12 00:24:16 +02:00
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
return fields.map((field, i) => {
|
|
|
|
const Component = field.component !== null
|
|
|
|
? fakeInjector.getComponentByName(field.component)
|
|
|
|
: fakeInjector.getComponentByDataType(field.type);
|
|
|
|
|
|
|
|
if (Component === null) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Props which every form field receives.
|
2016-04-07 12:35:52 +02:00
|
|
|
// Leave it up to the schema and component to determine
|
|
|
|
// which props are required.
|
2016-04-12 06:47:24 +02:00
|
|
|
const props = deepFreeze(Object.assign({}, field, { handleFieldUpdate }));
|
2016-03-30 23:45:54 +02:00
|
|
|
|
2016-04-12 00:24:16 +02:00
|
|
|
// Provides container components a place to hook in
|
|
|
|
// and apply customisations to scaffolded components.
|
|
|
|
if (typeof createFn === 'function') {
|
|
|
|
return createFn(Component, props);
|
|
|
|
}
|
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
return <Component key={i} {...props} />;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2016-04-12 06:47:24 +02:00
|
|
|
/**
|
|
|
|
* 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) => {
|
2016-04-20 03:43:13 +02:00
|
|
|
let props = deepFreeze(action);
|
2016-04-12 06:47:24 +02:00
|
|
|
|
2016-04-20 06:51:00 +02:00
|
|
|
// Add sensible defaults for common actions.
|
|
|
|
switch (props.name) {
|
|
|
|
case 'action_save':
|
|
|
|
props = deepFreeze(Object.assign({}, props, {
|
|
|
|
type: 'submit',
|
|
|
|
label: props.title,
|
|
|
|
icon: 'save',
|
|
|
|
}));
|
|
|
|
break;
|
|
|
|
case 'action_cancel':
|
|
|
|
props = deepFreeze(Object.assign({}, props, {
|
|
|
|
type: 'button',
|
|
|
|
label: props.title,
|
|
|
|
icon: 'cancel',
|
|
|
|
}));
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
break;
|
2016-04-12 06:47:24 +02:00
|
|
|
}
|
|
|
|
|
2016-04-20 06:51:00 +02:00
|
|
|
if (typeof createFn === 'function') {
|
|
|
|
return createFn(FormActionComponent, props);
|
2016-04-20 03:43:13 +02:00
|
|
|
}
|
|
|
|
|
2016-04-12 06:47:24 +02:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
render() {
|
2016-04-07 11:29:52 +02:00
|
|
|
const formSchema = this.props.schemas[this.props.schemaUrl];
|
2016-04-12 06:47:24 +02:00
|
|
|
const formState = this.props.forms[this.props.formId];
|
2016-03-30 23:45:54 +02:00
|
|
|
|
|
|
|
// If the response from fetching the initial data
|
|
|
|
// hasn't come back yet, don't render anything.
|
2016-04-07 11:29:52 +02:00
|
|
|
if (!formSchema) {
|
2016-03-30 23:45:54 +02:00
|
|
|
return null;
|
|
|
|
}
|
2016-03-22 00:25:23 +01:00
|
|
|
|
2016-04-10 23:16:32 +02:00
|
|
|
// Map form schema to React component attribute names,
|
|
|
|
// which requires renaming some of them (by unsetting the original keys)
|
|
|
|
const attributes = Object.assign({}, formSchema.schema.attributes, {
|
|
|
|
class: null,
|
|
|
|
className: formSchema.schema.attributes.class,
|
|
|
|
enctype: null,
|
|
|
|
encType: formSchema.schema.attributes.enctype,
|
|
|
|
});
|
|
|
|
|
2016-04-12 06:47:24 +02:00
|
|
|
// 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;
|
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
const formProps = {
|
2016-04-07 11:29:52 +02:00
|
|
|
actions: formSchema.schema.actions,
|
2016-04-10 23:16:32 +02:00
|
|
|
attributes,
|
2016-04-12 06:47:24 +02:00
|
|
|
componentWillUnmount: this.removeForm,
|
2016-04-07 11:29:52 +02:00
|
|
|
data: formSchema.schema.data,
|
2016-04-12 06:47:24 +02:00
|
|
|
fields: fieldData,
|
|
|
|
formId: formSchema.id,
|
|
|
|
handleSubmit: this.handleSubmit,
|
|
|
|
mapActionsToComponents: this.mapActionsToComponents,
|
2016-03-30 23:45:54 +02:00
|
|
|
mapFieldsToComponents: this.mapFieldsToComponents,
|
|
|
|
};
|
2016-03-22 00:25:23 +01:00
|
|
|
|
2016-03-30 23:45:54 +02:00
|
|
|
return <FormComponent {...formProps} />;
|
|
|
|
}
|
2016-03-22 00:25:23 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
FormBuilderComponent.propTypes = {
|
2016-04-12 06:47:24 +02:00
|
|
|
config: React.PropTypes.object,
|
2016-04-12 00:24:16 +02:00
|
|
|
createFn: React.PropTypes.func,
|
2016-04-12 06:47:24 +02:00
|
|
|
forms: React.PropTypes.object.isRequired,
|
|
|
|
formsActions: React.PropTypes.object.isRequired,
|
|
|
|
formId: React.PropTypes.string.isRequired,
|
|
|
|
handleSubmit: React.PropTypes.func,
|
2016-03-30 23:45:54 +02:00
|
|
|
schemas: React.PropTypes.object.isRequired,
|
2016-04-12 06:47:24 +02:00
|
|
|
schemaActions: React.PropTypes.object.isRequired,
|
|
|
|
schemaUrl: React.PropTypes.string.isRequired,
|
2016-03-22 00:25:23 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
function mapStateToProps(state) {
|
2016-03-30 23:45:54 +02:00
|
|
|
return {
|
2016-04-12 06:47:24 +02:00
|
|
|
config: state.config,
|
|
|
|
forms: state.forms,
|
2016-03-30 23:45:54 +02:00
|
|
|
schemas: state.schemas,
|
|
|
|
};
|
2016-03-22 00:25:23 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
function mapDispatchToProps(dispatch) {
|
2016-03-30 23:45:54 +02:00
|
|
|
return {
|
2016-04-12 06:47:24 +02:00
|
|
|
formsActions: bindActionCreators(formsActions, dispatch),
|
|
|
|
schemaActions: bindActionCreators(schemaActions, dispatch),
|
2016-03-30 23:45:54 +02:00
|
|
|
};
|
2016-03-22 00:25:23 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(FormBuilderComponent);
|