mirror of
https://github.com/vale981/Vulcan
synced 2025-03-10 20:46:40 -04:00
1147 lines
33 KiB
JavaScript
1147 lines
33 KiB
JavaScript
/*
|
|
|
|
Main form component.
|
|
|
|
This component expects:
|
|
|
|
### All Forms:
|
|
|
|
- collection
|
|
- currentUser
|
|
- client (Apollo client)
|
|
|
|
### New Form:
|
|
|
|
- newMutation
|
|
|
|
### Edit Form:
|
|
|
|
- editMutation
|
|
- removeMutation
|
|
- document
|
|
|
|
*/
|
|
|
|
import {
|
|
registerComponent,
|
|
Components,
|
|
runCallbacks,
|
|
getErrors,
|
|
getSetting,
|
|
Utils,
|
|
isIntlField,
|
|
mergeWithComponents
|
|
} from 'meteor/vulcan:core';
|
|
import React, { Component } from 'react';
|
|
import SimpleSchema from 'simpl-schema';
|
|
import PropTypes from 'prop-types';
|
|
import { intlShape } from 'meteor/vulcan:i18n';
|
|
import cloneDeep from 'lodash/cloneDeep';
|
|
import get from 'lodash/get';
|
|
import set from 'lodash/set';
|
|
import unset from 'lodash/unset';
|
|
import compact from 'lodash/compact';
|
|
import update from 'lodash/update';
|
|
import merge from 'lodash/merge';
|
|
import find from 'lodash/find';
|
|
import pick from 'lodash/pick';
|
|
import isEqual from 'lodash/isEqual';
|
|
import isEqualWith from 'lodash/isEqualWith';
|
|
import uniq from 'lodash/uniq';
|
|
import uniqBy from 'lodash/uniqBy';
|
|
import isObject from 'lodash/isObject';
|
|
import mapValues from 'lodash/mapValues';
|
|
import pickBy from 'lodash/pickBy';
|
|
|
|
import { convertSchema, formProperties } from '../modules/schema_utils';
|
|
import { isEmptyValue } from '../modules/utils';
|
|
import { getParentPath } from '../modules/path_utils';
|
|
import {
|
|
getEditableFields,
|
|
getInsertableFields
|
|
} from '../modules/schema_utils.js';
|
|
import withCollectionProps from './withCollectionProps';
|
|
import { callbackProps } from './propTypes';
|
|
|
|
|
|
// props that should trigger a form reset
|
|
const RESET_PROPS = [
|
|
'collection', 'collectionName', 'typeName', 'document', 'schema', 'currentUser',
|
|
'fields', 'removeFields',
|
|
'prefilledProps' // TODO: prefilledProps should be merged instead?
|
|
];
|
|
|
|
const compactParent = (object, path) => {
|
|
const parentPath = getParentPath(path);
|
|
|
|
// note: we only want to compact arrays, not objects
|
|
const compactIfArray = x => (Array.isArray(x) ? compact(x) : x);
|
|
|
|
update(object, parentPath, compactIfArray);
|
|
};
|
|
|
|
const getDefaultValues = convertedSchema => {
|
|
// TODO: make this work with nested schemas, too
|
|
return pickBy(
|
|
mapValues(convertedSchema, field => field.defaultValue),
|
|
value => value
|
|
);
|
|
};
|
|
|
|
const getInitialStateFromProps = nextProps => {
|
|
const collection = nextProps.collection;
|
|
const schema = nextProps.schema
|
|
? new SimpleSchema(nextProps.schema)
|
|
: collection.simpleSchema();
|
|
const convertedSchema = convertSchema(schema);
|
|
const formType = nextProps.document ? 'edit' : 'new';
|
|
// for new document forms, add default values to initial document
|
|
const defaultValues =
|
|
formType === 'new' ? getDefaultValues(convertedSchema) : {};
|
|
const initialDocument = merge(
|
|
{},
|
|
defaultValues,
|
|
nextProps.prefilledProps,
|
|
nextProps.document
|
|
);
|
|
|
|
//if minCount is specified, go ahead and create empty nested documents
|
|
Object.keys(convertedSchema).forEach(key => {
|
|
let minCount = convertedSchema[key].minCount;
|
|
if(minCount) {
|
|
initialDocument[key] = initialDocument[key] || [];
|
|
while(initialDocument[key].length < minCount)
|
|
initialDocument[key].push({});
|
|
}
|
|
});
|
|
|
|
// remove all instances of the `__typename` property from document
|
|
Utils.removeProperty(initialDocument, '__typename');
|
|
|
|
return {
|
|
disabled: false,
|
|
errors: [],
|
|
deletedValues: [],
|
|
currentValues: {},
|
|
// convert SimpleSchema schema into JSON object
|
|
schema: convertedSchema,
|
|
// Also store all field schemas (including nested schemas) in a flat structure
|
|
flatSchema: convertSchema(schema, true),
|
|
// the initial document passed as props
|
|
initialDocument,
|
|
// initialize the current document to be the same as the initial document
|
|
currentDocument: initialDocument
|
|
};
|
|
};
|
|
|
|
/*
|
|
|
|
1. Constructor
|
|
2. Helpers
|
|
3. Errors
|
|
4. Context
|
|
4. Method & Callback
|
|
5. Render
|
|
|
|
*/
|
|
|
|
class SmartForm extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
...getInitialStateFromProps(props)
|
|
};
|
|
}
|
|
|
|
defaultValues = {};
|
|
|
|
submitFormCallbacks = [];
|
|
successFormCallbacks = [];
|
|
failureFormCallbacks = [];
|
|
|
|
// --------------------------------------------------------------------- //
|
|
// ------------------------------- Helpers ----------------------------- //
|
|
// --------------------------------------------------------------------- //
|
|
|
|
/*
|
|
If a document is being passed, this is an edit form
|
|
*/
|
|
getFormType = () => {
|
|
return this.props.document ? 'edit' : 'new';
|
|
};
|
|
|
|
/*
|
|
Get a list of all insertable fields
|
|
*/
|
|
getInsertableFields = schema => {
|
|
return getInsertableFields(
|
|
schema || this.state.schema,
|
|
this.props.currentUser
|
|
);
|
|
};
|
|
|
|
/*
|
|
Get a list of all editable fields
|
|
*/
|
|
getEditableFields = schema => {
|
|
return getEditableFields(
|
|
schema || this.state.schema,
|
|
this.props.currentUser,
|
|
this.state.initialDocument
|
|
);
|
|
};
|
|
|
|
/*
|
|
|
|
Get a list of all mutable (insertable/editable depending on current form type) fields
|
|
|
|
*/
|
|
getMutableFields = schema => {
|
|
return this.getFormType() === 'edit'
|
|
? this.getEditableFields(schema)
|
|
: this.getInsertableFields(schema);
|
|
};
|
|
|
|
/*
|
|
|
|
Get the current document
|
|
|
|
*/
|
|
getDocument = () => {
|
|
return this.state.currentDocument;
|
|
};
|
|
|
|
/*
|
|
|
|
Like getDocument, but cross-reference with getFieldNames()
|
|
to only return fields that actually need to be submitted
|
|
|
|
Also remove any deleted values.
|
|
|
|
*/
|
|
getData = customArgs => {
|
|
const args = {
|
|
excludeHiddenFields: false,
|
|
replaceIntlFields: true,
|
|
addExtraFields: false,
|
|
...customArgs
|
|
};
|
|
|
|
// only keep relevant fields
|
|
// for intl fields, make sure we look in foo_intl and not foo
|
|
const fields = this.getFieldNames(args);
|
|
let data = pick(this.getDocument(), ...fields);
|
|
|
|
// compact deleted values
|
|
this.state.deletedValues.forEach(path => {
|
|
if (path.includes('.')) {
|
|
/*
|
|
|
|
If deleted field is a nested field, nested array, or nested array item, try to compact its parent array
|
|
|
|
- Nested field: 'address.city'
|
|
- Nested array: 'addresses.1'
|
|
- Nested array item: 'addresses.1.city'
|
|
|
|
*/
|
|
compactParent(data, path);
|
|
}
|
|
});
|
|
|
|
// run data object through submitForm callbacks
|
|
data = runCallbacks({ callbacks: this.submitFormCallbacks, iterator: data, properties: { form: this }});
|
|
|
|
return data;
|
|
};
|
|
|
|
/*
|
|
|
|
Get form components, in case any has been overwritten for this specific form
|
|
|
|
*/
|
|
// --------------------------------------------------------------------- //
|
|
// -------------------------------- Fields ----------------------------- //
|
|
// --------------------------------------------------------------------- //
|
|
|
|
/*
|
|
|
|
Get all field groups
|
|
|
|
*/
|
|
getFieldGroups = () => {
|
|
// build fields array by iterating over the list of field names
|
|
let fields = this.getFieldNames().map(fieldName => {
|
|
// get schema for the current field
|
|
return this.createField(fieldName, this.state.schema);
|
|
});
|
|
|
|
fields = _.sortBy(fields, 'order');
|
|
|
|
// get list of all unique groups (based on their name) used in current fields
|
|
let groups = _.compact(uniqBy(_.pluck(fields, 'group'), g => g && g.name));
|
|
|
|
// for each group, add relevant fields
|
|
groups = groups.map(group => {
|
|
group.label =
|
|
group.label || this.context.intl.formatMessage({ id: group.name });
|
|
group.fields = _.filter(fields, field => {
|
|
return field.group && field.group.name === group.name;
|
|
});
|
|
return group;
|
|
});
|
|
|
|
// add default group
|
|
groups = [
|
|
{
|
|
name: 'default',
|
|
label: 'default',
|
|
order: 0,
|
|
fields: _.filter(fields, field => {
|
|
return !field.group;
|
|
})
|
|
}
|
|
].concat(groups);
|
|
|
|
// sort by order
|
|
groups = _.sortBy(groups, 'order');
|
|
|
|
// console.log(groups);
|
|
|
|
return groups;
|
|
};
|
|
|
|
/*
|
|
|
|
Get a list of the fields to be included in the current form
|
|
|
|
Note: when submitting the form (getData()), do not include any extra fields.
|
|
|
|
*/
|
|
getFieldNames = (args) => {
|
|
// we do this to avoid having default values in arrow functions, which breaks MS Edge support. See https://github.com/meteor/meteor/issues/10171
|
|
let args0 = args || {};
|
|
const {
|
|
schema = this.state.schema,
|
|
excludeHiddenFields = true,
|
|
replaceIntlFields = false,
|
|
addExtraFields = true
|
|
} = args0;
|
|
|
|
const { fields, addFields } = this.props;
|
|
|
|
// get all editable/insertable fields (depending on current form type)
|
|
let relevantFields = this.getMutableFields(schema);
|
|
|
|
// if "fields" prop is specified, restrict list of fields to it
|
|
if (typeof fields !== 'undefined' && fields.length > 0) {
|
|
relevantFields = _.intersection(relevantFields, fields);
|
|
}
|
|
|
|
// if "hideFields" prop is specified, remove its fields
|
|
const removeFields = this.props.hideFields || this.props.removeFields;
|
|
if (typeof removeFields !== 'undefined' && removeFields.length > 0) {
|
|
relevantFields = _.difference(relevantFields, removeFields);
|
|
}
|
|
|
|
// if "addFields" prop is specified, add its fields
|
|
if (
|
|
addExtraFields &&
|
|
typeof addFields !== 'undefined' &&
|
|
addFields.length > 0
|
|
) {
|
|
relevantFields = relevantFields.concat(addFields);
|
|
}
|
|
|
|
// remove all hidden fields
|
|
if (excludeHiddenFields) {
|
|
const document = this.getDocument();
|
|
relevantFields = _.reject(relevantFields, fieldName => {
|
|
const hidden = schema[fieldName].hidden;
|
|
return typeof hidden === 'function'
|
|
? hidden({ ...this.props, document })
|
|
: hidden;
|
|
});
|
|
}
|
|
|
|
// replace intl fields
|
|
if (replaceIntlFields) {
|
|
relevantFields = relevantFields.map(
|
|
fieldName =>
|
|
isIntlField(schema[fieldName]) ? `${fieldName}_intl` : fieldName
|
|
);
|
|
}
|
|
|
|
// remove any duplicates
|
|
relevantFields = uniq(relevantFields);
|
|
|
|
return relevantFields;
|
|
};
|
|
|
|
initField = (fieldName, fieldSchema) => {
|
|
// intialize properties
|
|
let field = {
|
|
..._.pick(fieldSchema, formProperties),
|
|
document: this.state.initialDocument,
|
|
name: fieldName,
|
|
datatype: fieldSchema.type,
|
|
layout: this.props.layout,
|
|
input: fieldSchema.input || fieldSchema.control
|
|
};
|
|
field.label = this.getLabel(fieldName);
|
|
// // replace value by prefilled value if value is empty
|
|
// const prefill = fieldSchema.prefill || (fieldSchema.form && fieldSchema.form.prefill);
|
|
// if (prefill) {
|
|
// const prefilledValue = typeof prefill === 'function' ? prefill.call(fieldSchema) : prefill;
|
|
// if (!!prefilledValue && !field.value) {
|
|
// field.prefilledValue = prefilledValue;
|
|
// field.value = prefilledValue;
|
|
// }
|
|
// }
|
|
|
|
// if options are a function, call it
|
|
if (typeof field.options === 'function') {
|
|
field.options = field.options.call(fieldSchema, this.props);
|
|
}
|
|
|
|
// if this an intl'd field, use a special intlInput
|
|
if (isIntlField(fieldSchema)) {
|
|
field.intlInput = true;
|
|
}
|
|
|
|
// add any properties specified in fieldSchema.form as extra props passed on
|
|
// to the form component, calling them if they are functions
|
|
const inputProperties =
|
|
fieldSchema.form || fieldSchema.inputProperties || {};
|
|
for (const prop in inputProperties) {
|
|
const property = inputProperties[prop];
|
|
field[prop] =
|
|
typeof property === 'function'
|
|
? property.call(fieldSchema, this.props)
|
|
: property;
|
|
}
|
|
|
|
// add description as help prop
|
|
if (fieldSchema.description) {
|
|
field.help = fieldSchema.description;
|
|
}
|
|
return field;
|
|
};
|
|
handleFieldPath = (field, fieldName, parentPath) => {
|
|
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
field.path = fieldPath;
|
|
if (field.defaultValue) {
|
|
set(this.defaultValues, fieldPath, field.defaultValue);
|
|
}
|
|
return field;
|
|
};
|
|
handleFieldParent = (field, parentFieldName) => {
|
|
// if field has a parent field, pass it on
|
|
if (parentFieldName) {
|
|
field.parentFieldName = parentFieldName;
|
|
}
|
|
|
|
return field;
|
|
};
|
|
handlePermissions = (field, fieldName, schema) => {
|
|
// if field is not creatable/updatable, disable it
|
|
if (!this.getMutableFields(schema).includes(fieldName)) {
|
|
field.disabled = true;
|
|
}
|
|
return field;
|
|
};
|
|
handleFieldChildren = (field, fieldName, fieldSchema, schema) => {
|
|
// array field
|
|
if (fieldSchema.field) {
|
|
field.arrayFieldSchema = fieldSchema.field;
|
|
// create a field that can be exploited by the form
|
|
field.arrayField = this.createArraySubField(
|
|
fieldName,
|
|
field.arrayFieldSchema,
|
|
schema
|
|
);
|
|
|
|
//field.nestedInput = true
|
|
}
|
|
// nested fields: set input to "nested"
|
|
if (fieldSchema.schema) {
|
|
field.nestedSchema = fieldSchema.schema;
|
|
field.nestedInput = true;
|
|
|
|
// get nested schema
|
|
// for each nested field, get field object by calling createField recursively
|
|
field.nestedFields = this.getFieldNames({
|
|
schema: field.nestedSchema
|
|
}).map(subFieldName => {
|
|
return this.createField(
|
|
subFieldName,
|
|
field.nestedSchema,
|
|
fieldName,
|
|
field.path
|
|
);
|
|
});
|
|
}
|
|
return field;
|
|
};
|
|
|
|
/*
|
|
Given a field's name, the containing schema, and parent, create the
|
|
complete field object to be passed to the component
|
|
|
|
*/
|
|
createField = (fieldName, schema, parentFieldName, parentPath) => {
|
|
const fieldSchema = schema[fieldName];
|
|
let field = this.initField(fieldName, fieldSchema);
|
|
field = this.handleFieldPath(field, fieldName, parentPath);
|
|
field = this.handleFieldParent(field, parentFieldName);
|
|
field = this.handlePermissions(field, fieldName, schema);
|
|
field = this.handleFieldChildren(field, fieldName, fieldSchema, schema);
|
|
return field;
|
|
};
|
|
createArraySubField = (fieldName, subFieldSchema, schema) => {
|
|
const subFieldName = `${fieldName}.$`;
|
|
let subField = this.initField(subFieldName, subFieldSchema);
|
|
// array subfield has the same path and permissions as its parent
|
|
// so we use parent name (fieldName) and not subfieldName
|
|
subField = this.handleFieldPath(subField, fieldName);
|
|
subField = this.handlePermissions(subField, fieldName, schema);
|
|
// we do not allow nesting yet
|
|
//subField = this.handleFieldChildren(field, fieldSchema)
|
|
return subField;
|
|
};
|
|
|
|
/*
|
|
|
|
Get a field's label
|
|
|
|
*/
|
|
getLabel = (fieldName, fieldLocale) => {
|
|
const collectionName = this.props.collectionName.toLowerCase();
|
|
const defaultMessage = '|*|*|';
|
|
let id = `${collectionName}.${fieldName}`;
|
|
let intlLabel;
|
|
intlLabel = this.context.intl.formatMessage({ id, defaultMessage });
|
|
if (intlLabel === defaultMessage) {
|
|
id = `global.${fieldName}`;
|
|
intlLabel = this.context.intl.formatMessage({ id });
|
|
if (intlLabel === defaultMessage) {
|
|
id = fieldName;
|
|
intlLabel = this.context.intl.formatMessage({ id });
|
|
}
|
|
}
|
|
const schemaLabel =
|
|
this.state.flatSchema[fieldName] &&
|
|
this.state.flatSchema[fieldName].label;
|
|
const label = intlLabel || schemaLabel || fieldName;
|
|
if (fieldLocale) {
|
|
const intlFieldLocale = this.context.intl.formatMessage({
|
|
id: `locales.${fieldLocale}`,
|
|
defaultMessage: fieldLocale
|
|
});
|
|
return `${label} (${intlFieldLocale})`;
|
|
} else {
|
|
return label;
|
|
}
|
|
};
|
|
|
|
// --------------------------------------------------------------------- //
|
|
// ------------------------------- Errors ------------------------------ //
|
|
// --------------------------------------------------------------------- //
|
|
|
|
/*
|
|
|
|
Add error to form state
|
|
|
|
Errors can have the following properties:
|
|
- id: used as an internationalization key, for example `errors.required`
|
|
- path: for field-specific errors, the path of the field with the issue
|
|
- properties: additional data. Will be passed to vulcan-i18n as values
|
|
- message: if id cannot be used as i81n key, message will be used
|
|
|
|
*/
|
|
throwError = error => {
|
|
let formErrors = getErrors(error);
|
|
|
|
// eslint-disable-next-line no-console
|
|
console.log(formErrors);
|
|
|
|
// add error(s) to state
|
|
this.setState(prevState => ({
|
|
errors: [...prevState.errors, ...formErrors]
|
|
}));
|
|
};
|
|
|
|
/*
|
|
|
|
Clear errors for a field
|
|
|
|
*/
|
|
clearFieldErrors = path => {
|
|
const errors = this.state.errors.filter(error => error.path !== path);
|
|
this.setState({ errors });
|
|
};
|
|
|
|
// --------------------------------------------------------------------- //
|
|
// ------------------------------- Context ----------------------------- //
|
|
// --------------------------------------------------------------------- //
|
|
|
|
// add something to deleted values
|
|
addToDeletedValues = name => {
|
|
this.setState(prevState => ({
|
|
deletedValues: [...prevState.deletedValues, name]
|
|
}));
|
|
};
|
|
|
|
// add a callback to the form submission
|
|
addToSubmitForm = callback => {
|
|
this.submitFormCallbacks.push(callback);
|
|
};
|
|
|
|
// add a callback to form submission success
|
|
addToSuccessForm = callback => {
|
|
this.successFormCallbacks.push(callback);
|
|
};
|
|
|
|
// add a callback to form submission failure
|
|
addToFailureForm = callback => {
|
|
this.failureFormCallbacks.push(callback);
|
|
};
|
|
|
|
setFormState = fn => {
|
|
this.setState(fn);
|
|
};
|
|
|
|
submitFormContext = newValues => {
|
|
// keep the previous ones and extend (with possible replacement) with new ones
|
|
this.setState(
|
|
prevState => ({
|
|
currentValues: {
|
|
...prevState.currentValues,
|
|
...newValues
|
|
} // Submit form after setState update completed
|
|
}),
|
|
() => this.submitForm(this.form.getModel())
|
|
);
|
|
};
|
|
|
|
// pass on context to all child components
|
|
getChildContext = () => {
|
|
return {
|
|
throwError: this.throwError,
|
|
clearForm: this.clearForm,
|
|
refetchForm: this.refetchForm,
|
|
isChanged: this.isChanged,
|
|
submitForm: this.submitFormContext, //Change in name because we already have a function
|
|
// called submitForm, but no reason for the user to know
|
|
// about that
|
|
addToDeletedValues: this.addToDeletedValues,
|
|
updateCurrentValues: this.updateCurrentValues,
|
|
getDocument: this.getDocument,
|
|
getLabel: this.getLabel,
|
|
initialDocument: this.state.initialDocument,
|
|
setFormState: this.setFormState,
|
|
addToSubmitForm: this.addToSubmitForm,
|
|
addToSuccessForm: this.addToSuccessForm,
|
|
addToFailureForm: this.addToFailureForm,
|
|
errors: this.state.errors,
|
|
currentValues: this.state.currentValues,
|
|
deletedValues: this.state.deletedValues
|
|
};
|
|
};
|
|
|
|
// --------------------------------------------------------------------- //
|
|
// ------------------------------ Lifecycle ---------------------------- //
|
|
// --------------------------------------------------------------------- //
|
|
|
|
/*
|
|
|
|
When props change, reinitialize the form state
|
|
Triggered only for data related props (collection, document, currentUser etc.)
|
|
|
|
@see https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
|
|
|
|
*/
|
|
UNSAFE_componentWillReceiveProps(nextProps) {
|
|
const needReset = !!RESET_PROPS.find(prop => !isEqual(this.props[prop], nextProps[prop]));
|
|
if (needReset) {
|
|
this.setState(getInitialStateFromProps(nextProps));
|
|
}
|
|
}
|
|
|
|
/*
|
|
|
|
Manually update the current values of one or more fields(i.e. on change or blur).
|
|
|
|
*/
|
|
updateCurrentValues = (newValues, options = {}) => {
|
|
// default to overwriting old value with new
|
|
const { mode = 'overwrite' } = options;
|
|
const { changeCallback } = this.props;
|
|
|
|
// keep the previous ones and extend (with possible replacement) with new ones
|
|
this.setState(prevState => {
|
|
// keep only the relevant properties
|
|
const { currentValues, currentDocument, deletedValues } = cloneDeep(
|
|
prevState
|
|
);
|
|
const newState = {
|
|
currentValues,
|
|
currentDocument,
|
|
deletedValues,
|
|
foo: {}
|
|
};
|
|
|
|
Object.keys(newValues).forEach(key => {
|
|
const path = key;
|
|
const value = newValues[key];
|
|
if (isEmptyValue(value)) {
|
|
// delete value
|
|
unset(newState.currentValues, path);
|
|
set(newState.currentDocument, path, null);
|
|
newState.deletedValues = [...prevState.deletedValues, path];
|
|
} else {
|
|
// 1. update currentValues
|
|
set(newState.currentValues, path, value);
|
|
|
|
// 2. update currentDocument
|
|
// For arrays and objects, give option to merge instead of overwrite
|
|
if (mode === 'merge' && (Array.isArray(value) || isObject(value))) {
|
|
const oldValue = get(newState.currentDocument, path);
|
|
set(newState.currentDocument, path, merge(oldValue, value));
|
|
} else {
|
|
set(newState.currentDocument, path, value);
|
|
}
|
|
|
|
// 3. in case value had previously been deleted, "undelete" it
|
|
newState.deletedValues = _.without(prevState.deletedValues, path);
|
|
}
|
|
});
|
|
if (changeCallback) changeCallback(newState.currentDocument);
|
|
return newState;
|
|
});
|
|
};
|
|
|
|
/*
|
|
|
|
Warn the user if there are unsaved changes
|
|
|
|
*/
|
|
handleRouteLeave = () => {
|
|
if (this.isChanged()) {
|
|
const message = this.context.intl.formatMessage({
|
|
id: 'forms.confirm_discard',
|
|
defaultMessage: 'Are you sure you want to discard your changes?'
|
|
});
|
|
return message;
|
|
}
|
|
};
|
|
|
|
//see https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
|
|
//the message returned is actually ignored by most browsers and a default message 'Are you sure you want to leave this page? You might have unsaved changes' is displayed. See the Notes section on the mozilla docs above
|
|
handlePageLeave = event => {
|
|
if (this.isChanged()) {
|
|
const message = this.context.intl.formatMessage({
|
|
id: 'forms.confirm_discard',
|
|
defaultMessage: 'Are you sure you want to discard your changes?'
|
|
});
|
|
if (event) {
|
|
event.returnValue = message;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
};
|
|
|
|
/*
|
|
|
|
Install a route leave hook to warn the user if there are unsaved changes
|
|
|
|
*/
|
|
componentDidMount = () => {
|
|
let warnUnsavedChanges = getSetting('forms.warnUnsavedChanges');
|
|
if (typeof this.props.warnUnsavedChanges === 'boolean') {
|
|
warnUnsavedChanges = this.props.warnUnsavedChanges;
|
|
}
|
|
if (warnUnsavedChanges) {
|
|
const routes = this.props.router.routes;
|
|
const currentRoute = routes[routes.length - 1];
|
|
this.props.router.setRouteLeaveHook(currentRoute, this.handleRouteLeave);
|
|
|
|
//check for closing the browser with unsaved changes
|
|
window.onbeforeunload = this.handlePageLeave;
|
|
}
|
|
};
|
|
|
|
/*
|
|
Remove the closing browser check on component unmount
|
|
see https://gist.github.com/mknabe/bfcb6db12ef52323954a28655801792d
|
|
*/
|
|
componentWillUnmount = () => {
|
|
let warnUnsavedChanges = getSetting('forms.warnUnsavedChanges');
|
|
if (typeof this.props.warnUnsavedChanges === 'boolean') {
|
|
warnUnsavedChanges = this.props.warnUnsavedChanges;
|
|
}
|
|
if (warnUnsavedChanges) {
|
|
window.onbeforeunload = undefined; //undefined instead of null to support IE
|
|
}
|
|
};
|
|
|
|
/*
|
|
|
|
Returns true if there are any differences between the initial document and the current one
|
|
|
|
*/
|
|
isChanged = () => {
|
|
const initialDocument = this.state.initialDocument;
|
|
const changedDocument = this.getDocument();
|
|
|
|
const changedValue = find(changedDocument, (value, key, collection) => {
|
|
return !isEqualWith(value, initialDocument[key], (objValue, othValue) => {
|
|
if (!objValue && !othValue) return true;
|
|
});
|
|
});
|
|
|
|
return typeof changedValue !== 'undefined';
|
|
};
|
|
|
|
/*
|
|
|
|
Refetch the document from the database (in case it was updated by another process or to reset the form)
|
|
|
|
*/
|
|
refetchForm = () => {
|
|
if (this.props.data && this.props.data.refetch) {
|
|
this.props.data.refetch();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Clears form errors and values.
|
|
*
|
|
* @example Clear form
|
|
* // form will be fully emptied, with exception of prefilled values
|
|
* clearForm({ document: {} });
|
|
*
|
|
* @example Reset/revert form
|
|
* // form will be reverted to its initial state
|
|
* clearForm();
|
|
*
|
|
* @example Clear with new values
|
|
* // form will be cleared but initialized with the new document
|
|
* const document = {
|
|
* // ... some values
|
|
* };
|
|
* clearForm({ document });
|
|
*
|
|
* @param {Object=} options
|
|
* @param {Object=} options.document
|
|
* Document to use as new initial document when values are cleared instead of
|
|
* the existing one. Note that prefilled props will be merged
|
|
*/
|
|
clearForm = ({ document } = {}) => {
|
|
document = document ? merge({}, this.props.prefilledProps, document) : null;
|
|
this.setState(prevState => ({
|
|
errors: [],
|
|
currentValues: {},
|
|
deletedValues: [],
|
|
currentDocument: document || prevState.initialDocument,
|
|
initialDocument: document || prevState.initialDocument,
|
|
disabled: false
|
|
}));
|
|
};
|
|
|
|
/*
|
|
|
|
Key down handler
|
|
|
|
*/
|
|
formKeyDown = event => {
|
|
if ((event.ctrlKey || event.metaKey) && event.keyCode === 13) {
|
|
this.submitForm(this.form.getModel());
|
|
}
|
|
};
|
|
|
|
newMutationSuccessCallback = result => {
|
|
this.mutationSuccessCallback(result, 'new');
|
|
};
|
|
|
|
editMutationSuccessCallback = result => {
|
|
this.mutationSuccessCallback(result, 'edit');
|
|
};
|
|
|
|
mutationSuccessCallback = (result, mutationType) => {
|
|
this.setState(prevState => ({ disabled: false }));
|
|
let document = result.data[Object.keys(result.data)[0]].data; // document is always on first property
|
|
|
|
// for new mutation, run refetch function if it exists
|
|
if (mutationType === 'new' && this.props.refetch) this.props.refetch();
|
|
|
|
// call the clear form method (i.e. trigger setState) only if the form has not been unmounted
|
|
// (we are in an async callback, everything can happen!)
|
|
if (this.form) {
|
|
this.form.reset(this.getDocument());
|
|
this.clearForm({
|
|
document: mutationType === 'edit' ? document : undefined
|
|
});
|
|
}
|
|
|
|
// run document through mutation success callbacks
|
|
document = runCallbacks({ callbacks: this.successFormCallbacks, iterator: document, properties: { form: this }});
|
|
|
|
// run success callback if it exists
|
|
if (this.props.successCallback) this.props.successCallback(document, { form: this });
|
|
};
|
|
|
|
// catch graphql errors
|
|
mutationErrorCallback = (document, error) => {
|
|
this.setState(prevState => ({ disabled: false }));
|
|
|
|
// eslint-disable-next-line no-console
|
|
console.log('// graphQL Error');
|
|
// eslint-disable-next-line no-console
|
|
console.log(error);
|
|
|
|
// run mutation failure callbacks on error, we do not allow the callbacks to change the error
|
|
runCallbacks({ callbacks: this.failureFormCallbacks, iterator: error, properties: { error, form: this }});
|
|
|
|
if (!_.isEmpty(error)) {
|
|
// add error to state
|
|
this.throwError(error);
|
|
}
|
|
|
|
// run error callback if it exists
|
|
if (this.props.errorCallback) this.props.errorCallback(document, error, { form: this });
|
|
|
|
// scroll back up to show error messages
|
|
Utils.scrollIntoView('.flash-message');
|
|
};
|
|
|
|
/*
|
|
|
|
Submit form handler
|
|
|
|
*/
|
|
submitForm = event => {
|
|
|
|
event.preventDefault();
|
|
|
|
// if form is disabled (there is already a submit handler running) don't do anything
|
|
if (this.state.disabled) {
|
|
return;
|
|
}
|
|
|
|
// clear errors and disable form while it's submitting
|
|
this.setState(prevState => ({ errors: [], disabled: true }));
|
|
|
|
// complete the data with values from custom components
|
|
// note: it follows the same logic as SmartForm's getDocument method
|
|
let data = this.getData({ replaceIntlFields: true, addExtraFields: false });
|
|
|
|
// if there's a submit callback, run it
|
|
if (this.props.submitCallback) {
|
|
data = this.props.submitCallback(data) || data;
|
|
}
|
|
|
|
if (this.getFormType() === 'new') {
|
|
// create document form
|
|
this.props[`create${this.props.typeName}`]({ data })
|
|
.then(this.newMutationSuccessCallback)
|
|
.catch(error => this.mutationErrorCallback(document, error));
|
|
} else {
|
|
// update document form
|
|
const documentId = this.getDocument()._id;
|
|
this.props[`update${this.props.typeName}`]({
|
|
selector: { documentId },
|
|
data
|
|
})
|
|
.then(this.editMutationSuccessCallback)
|
|
.catch(error => this.mutationErrorCallback(document, error));
|
|
}
|
|
};
|
|
|
|
/*
|
|
|
|
Delete document handler
|
|
|
|
*/
|
|
deleteDocument = () => {
|
|
const document = this.getDocument();
|
|
const documentId = this.props.document._id;
|
|
const documentTitle = document.title || document.name || '';
|
|
|
|
const deleteDocumentConfirm = this.context.intl.formatMessage(
|
|
{ id: 'forms.delete_confirm' },
|
|
{ title: documentTitle }
|
|
);
|
|
|
|
if (window.confirm(deleteDocumentConfirm)) {
|
|
this.props
|
|
.removeMutation({ documentId })
|
|
.then(mutationResult => {
|
|
// the mutation result looks like {data:{collectionRemove: null}} if succeeded
|
|
if (this.props.removeSuccessCallback)
|
|
this.props.removeSuccessCallback({ documentId, documentTitle });
|
|
if (this.props.refetch) this.props.refetch();
|
|
})
|
|
.catch(error => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(error);
|
|
});
|
|
}
|
|
};
|
|
|
|
|
|
// --------------------------------------------------------------------- //
|
|
// ------------------------- Props to Pass ----------------------------- //
|
|
// --------------------------------------------------------------------- //
|
|
|
|
getWrapperProps = () => ({
|
|
className: 'document-' + this.getFormType(),
|
|
});
|
|
|
|
getFormProps = () => ({
|
|
id: this.props.id,
|
|
onSubmit: this.submitForm,
|
|
onKeyDown: this.formKeyDown,
|
|
ref: e => {
|
|
this.form = e;
|
|
},
|
|
});
|
|
|
|
getFormErrorsProps = () => ({
|
|
errors: this.state.errors
|
|
});
|
|
|
|
getFormGroupProps = group => ({
|
|
key: group.name,
|
|
...group,
|
|
errors: this.state.errors,
|
|
throwError: this.throwError,
|
|
currentValues: this.state.currentValues,
|
|
updateCurrentValues: this.updateCurrentValues,
|
|
deletedValues: this.state.deletedValues,
|
|
addToDeletedValues: this.addToDeletedValues,
|
|
clearFieldErrors: this.clearFieldErrors,
|
|
formType: this.getFormType(),
|
|
currentUser: this.props.currentUser,
|
|
disabled: this.state.disabled,
|
|
formComponents: mergeWithComponents(this.props.formComponents),
|
|
});
|
|
|
|
getFormSubmitProps = () => ({
|
|
submitLabel: this.props.submitLabel,
|
|
cancelLabel: this.props.cancelLabel,
|
|
revertLabel: this.props.revertLabel,
|
|
cancelCallback: this.props.cancelCallback,
|
|
revertCallback: this.props.revertCallback,
|
|
document: this.getDocument(),
|
|
deleteDocument:
|
|
(this.getFormType() === 'edit' &&
|
|
this.props.showRemove &&
|
|
this.deleteDocument) ||
|
|
null,
|
|
collectionName:this.props.collectionName,
|
|
currentValues:this.state.currentValues,
|
|
deletedValues:this.state.deletedValues,
|
|
errors:this.state.errors,
|
|
});
|
|
|
|
// --------------------------------------------------------------------- //
|
|
// ----------------------------- Render -------------------------------- //
|
|
// --------------------------------------------------------------------- //
|
|
|
|
render() {
|
|
const FormComponents = mergeWithComponents(this.props.formComponents);
|
|
|
|
return (
|
|
<div {...this.getWrapperProps()}>
|
|
<form {...this.getFormProps()}>
|
|
<FormComponents.FormErrors {...this.getFormErrorsProps()} />
|
|
|
|
{this.getFieldGroups().map(group => (
|
|
<FormComponents.FormGroup {...this.getFormGroupProps(group)} />
|
|
))}
|
|
|
|
{this.props.repeatErrors && this.renderErrors()}
|
|
|
|
<FormComponents.FormSubmit {...this.getFormSubmitProps()} />
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
SmartForm.propTypes = {
|
|
// main options
|
|
collection: PropTypes.object.isRequired,
|
|
collectionName: PropTypes.string.isRequired,
|
|
typeName: PropTypes.string.isRequired,
|
|
|
|
document: PropTypes.object, // if a document is passed, this will be an edit form
|
|
schema: PropTypes.object, // usually not needed
|
|
|
|
// graphQL
|
|
newMutation: PropTypes.func, // the new mutation
|
|
editMutation: PropTypes.func, // the edit mutation
|
|
removeMutation: PropTypes.func, // the remove mutation
|
|
|
|
// form
|
|
prefilledProps: PropTypes.object,
|
|
layout: PropTypes.string,
|
|
fields: PropTypes.arrayOf(PropTypes.string),
|
|
addFields: PropTypes.arrayOf(PropTypes.string),
|
|
removeFields: PropTypes.arrayOf(PropTypes.string),
|
|
hideFields: PropTypes.arrayOf(PropTypes.string), // OpenCRUD backwards compatibility
|
|
showRemove: PropTypes.bool,
|
|
submitLabel: PropTypes.node,
|
|
cancelLabel: PropTypes.node,
|
|
revertLabel: PropTypes.node,
|
|
repeatErrors: PropTypes.bool,
|
|
warnUnsavedChanges: PropTypes.bool,
|
|
formComponents: PropTypes.object,
|
|
|
|
// callbacks
|
|
...callbackProps,
|
|
|
|
currentUser: PropTypes.object,
|
|
client: PropTypes.object
|
|
};
|
|
|
|
SmartForm.defaultProps = {
|
|
layout: 'horizontal',
|
|
prefilledProps: {},
|
|
repeatErrors: false,
|
|
showRemove: true
|
|
};
|
|
|
|
SmartForm.contextTypes = {
|
|
intl: intlShape
|
|
};
|
|
|
|
SmartForm.childContextTypes = {
|
|
addToDeletedValues: PropTypes.func,
|
|
deletedValues: PropTypes.array,
|
|
addToSubmitForm: PropTypes.func,
|
|
addToFailureForm: PropTypes.func,
|
|
addToSuccessForm: PropTypes.func,
|
|
updateCurrentValues: PropTypes.func,
|
|
setFormState: PropTypes.func,
|
|
throwError: PropTypes.func,
|
|
clearForm: PropTypes.func,
|
|
refetchForm: PropTypes.func,
|
|
isChanged: PropTypes.func,
|
|
initialDocument: PropTypes.object,
|
|
getDocument: PropTypes.func,
|
|
getLabel: PropTypes.func,
|
|
submitForm: PropTypes.func,
|
|
errors: PropTypes.array,
|
|
currentValues: PropTypes.object
|
|
};
|
|
|
|
module.exports = SmartForm;
|
|
|
|
registerComponent({
|
|
name: 'Form',
|
|
component: SmartForm,
|
|
hocs: [withCollectionProps]
|
|
});
|