Work on form errors & validation

This commit is contained in:
SachaG 2018-03-25 10:54:45 +09:00
parent 8d686ae9e1
commit 1d7cef5556
11 changed files with 383 additions and 135 deletions

View file

@ -84,6 +84,7 @@ Datatable.propTypes = {
showSearch: PropTypes.bool,
newFormOptions: PropTypes.object,
editFormOptions: PropTypes.object,
emptyState: PropTypes.object,
}
Datatable.defaultProps = {

View file

@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { registerComponent } from 'meteor/vulcan:core';
import { FormattedMessage } from 'meteor/vulcan:i18n';
const FieldErrors = ({ errors }) => (
<ul className="form-input-errors">
{errors.map((error, index) => (
<li key={index}>
{error.message || (
<FormattedMessage
id={`errors.${error.data.type}`}
values={{ ...error.data }}
defaultMessage={JSON.stringify(error)}
/>
)}
</li>
))}
</ul>
);
registerComponent('FieldErrors', FieldErrors);

View file

@ -164,24 +164,48 @@ class Form extends Component {
Get a field's value in a document // TODO: maybe not needed?
*/
getValue = (fieldName, document) => {
getValue = (fieldName, fieldSchema, document) => {
if (typeof document[fieldName] !== 'undefined' && document[fieldName] !== null) {
return document[fieldName];
let value = document[fieldName];
// convert value type if needed
if (fieldSchema.type.definitions[0].type === Number) value = Number(value);
// if value is an array of objects ({_id: '123'}, {_id: 'abc'}), flatten it into an array of strings (['123', 'abc'])
// fallback to item itself if item._id is not defined (ex: item is not an object or item is just {slug: 'xxx'})
// if (Array.isArray(field.value)) {
// field.value = field.value.map(item => item._id || item);
// }
// TODO: not needed anymore?
return value;
}
return null;
};
/*
Given an array field, get its nested schema
*/
getNestedSchema = (fieldName) => {
const arrayItemSchema = this.getSchema()[`${fieldName}.$`];
return arrayItemSchema && arrayItemSchema.type.definitions[0].type._schema;
}
/*
Given a field's name, its schema, document, and parent, create the
complete field object to be passed to the component
*/
createField = (fieldName, fieldSchema, document, parentFieldName) => {
// console.log('// createField', fieldName)
createField = (fieldName, fieldSchema, document, parentFieldName, parentPath) => {
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
// console.log('// createField', fieldPath)
// console.log(fieldSchema)
// console.log(document)
// console.log('-> nested: ', fieldSchema.type.singleType === Array)
// console.log('-> nested: ', !!this.getNestedSchema(fieldName))
// store fieldSchema object in this.fieldSchemas
this.fieldSchemas[fieldName] = fieldSchema;
@ -191,6 +215,7 @@ class Form extends Component {
// intialize properties
let field = {
name: fieldName,
path: fieldPath,
datatype: fieldSchema.type,
control: fieldSchema.control,
layout: this.props.layout,
@ -205,21 +230,10 @@ class Form extends Component {
field.label = this.getLabel(fieldName);
// note: for nested fields, value will be null here and set by FormNested later
const fieldValue = this.getValue(fieldName, document);
const fieldValue = this.getValue(fieldName, fieldSchema, document);
// add value
if (fieldValue) {
field.value = fieldValue;
// convert value type if needed
if (fieldSchema.type.definitions[0].type === Number) field.value = Number(field.value);
// if value is an array of objects ({_id: '123'}, {_id: 'abc'}), flatten it into an array of strings (['123', 'abc'])
// fallback to item itself if item._id is not defined (ex: item is not an object or item is just {slug: 'xxx'})
// if (Array.isArray(field.value)) {
// field.value = field.value.map(item => item._id || item);
// }
// TODO: not needed anymore?
}
// backward compatibility from 'autoform' to 'form'
@ -301,25 +315,23 @@ class Form extends Component {
// add document
field.document = this.getDocument();
// add error state
const validationError = _.findWhere(this.state.errors, { name: 'app.validation_error' });
if (validationError) {
const fieldErrors = _.filter(validationError.data.errors, error => error.data.fieldName === fieldName);
if (fieldErrors) {
field.errors = fieldErrors.map(error => ({ ...error, message: this.getErrorMessage(error) }));
}
}
// add any relevant errors
// const fieldErrors = _.filter(this.state.errors, error => error.data.name === fieldName);
// if (fieldErrors) {
// field.errors = fieldErrors.map(error => ({ ...error, message: this.getErrorMessage(error) }));
// }
// nested fields: set control to "nested"
if (fieldSchema.type.singleType === Array) {
const nestedSchema = this.getNestedSchema(fieldName);
if (nestedSchema) {
field.nestedSchema = nestedSchema;
field.control = 'nested';
// get nested schema
field.nestedSchema = this.getSchema()[`${fieldName}.$`].type.definitions[0].type._schema; // TODO: do this better
// for each nested field, get field object by calling createField recursively
field.nestedFields = this.getFieldNames(field.nestedSchema).map(subFieldName => {
const subFieldSchema = field.nestedSchema[subFieldName];
return this.createField(subFieldName, subFieldSchema, document, fieldName);
return this.createField(subFieldName, subFieldSchema, document, fieldName, fieldPath);
});
}
@ -435,41 +447,7 @@ class Form extends Component {
}
};
/*
Render errors
*/
renderErrors = () => {
return (
<div className="form-errors">
{this.state.errors.map((error, index) => {
let message;
if (error.data && error.data.errors) {
// this error is a "multi-error" with multiple sub-errors
message = error.data.errors.map(error => {
return {
content: this.getErrorMessage(error),
data: error.data,
};
});
} else {
// this is a regular error
message = {
content:
error.message ||
this.context.intl.formatMessage({ id: error.id, defaultMessage: error.id }, error.data),
};
}
return <Components.FormFlash key={index} message={message} type="error" />;
})}
</div>
);
};
// --------------------------------------------------------------------- //
// ------------------------------- Context ----------------------------- //
@ -486,7 +464,7 @@ class Form extends Component {
// add error to state
this.setState(prevState => ({
errors: [...prevState.errors, graphQLError],
errors: [...prevState.errors, ...graphQLError.data.errors],
}));
};
@ -560,6 +538,7 @@ class Form extends Component {
addToSubmitForm: this.addToSubmitForm,
addToSuccessForm: this.addToSuccessForm,
addToFailureForm: this.addToFailureForm,
errors: this.state.errors,
};
};
@ -584,7 +563,6 @@ class Form extends Component {
this.addToDeletedValues(path);
} else {
set(prevState.currentValues, path, value);
// dot.str(path, value, prevState.currentValues);
}
});
return prevState;
@ -783,7 +761,7 @@ class Form extends Component {
return (
<div className={'document-' + this.getFormType()}>
<Formsy.Form onSubmit={this.submitForm} onKeyDown={this.formKeyDown} disabled={this.state.disabled} ref="form">
{this.renderErrors()}
<Components.FormErrors errors={this.state.errors}/>
{fieldGroups.map(group => (
<Components.FormGroup key={group.name} {...group} updateCurrentValues={this.updateCurrentValues} />
@ -870,6 +848,7 @@ Form.childContextTypes = {
clearForm: PropTypes.func,
getDocument: PropTypes.func,
submitForm: PropTypes.func,
errors: PropTypes.array,
};
module.exports = Form;

View file

@ -52,7 +52,7 @@ class FormComponent extends PureComponent {
renderComponent() {
// see https://facebook.github.io/react/warnings/unknown-prop.html
const { control, group, updateCurrentValues, document, beforeComponent, afterComponent, limit, errors, nestedSchema, nestedFields, datatype, parentFieldName, itemIndex, ...rest } = this.props; // eslint-disable-line
const { control, group, updateCurrentValues, document, beforeComponent, afterComponent, limit, errors, nestedSchema, nestedFields, datatype, parentFieldName, itemIndex, path, ...rest } = this.props; // eslint-disable-line
const properties = {
value: '', // default value, will be overridden by `rest` if real value has been passed down through props
@ -72,7 +72,7 @@ class FormComponent extends PureComponent {
switch (this.props.control) {
case 'nested':
return <Components.FormNested updateCurrentValues={updateCurrentValues} nestedSchema={nestedSchema} nestedFields={nestedFields} {...properties}/>;
return <Components.FormNested path={path} updateCurrentValues={updateCurrentValues} nestedSchema={nestedSchema} nestedFields={nestedFields} datatype={datatype} {...properties}/>;
case 'number':
return <Components.FormComponentNumber {...properties}/>;
@ -145,12 +145,9 @@ class FormComponent extends PureComponent {
}
}
renderErrors = () => {
return (
<ul className='form-input-errors'>
{this.props.errors.map((error, index) => <li key={index}>{error.message}</li>)}
</ul>
)
getErrors = () => {
const fieldErrors = this.context.errors.filter(error => error.data.name === this.props.path);
return fieldErrors;
}
showClear = () => {
@ -175,14 +172,14 @@ class FormComponent extends PureComponent {
render() {
const hasErrors = this.props.errors && this.props.errors.length;
const hasErrors = this.getErrors() && this.getErrors().length;
const inputClass = classNames('form-input', `input-${this.props.name}`, `form-component-${this.props.control || 'default'}`,{'input-error': hasErrors});
return (
<div className={inputClass}>
{this.props.beforeComponent ? this.props.beforeComponent : null}
{this.renderComponent()}
{hasErrors ? this.renderErrors() : null}
{hasErrors ? <Components.FieldErrors errors={this.getErrors()}/> : null}
{this.showClear() ? this.renderClear() : null}
{this.props.limit ? <div className={classNames('form-control-limit', {danger: this.state.limit < 10})}>{this.state.limit}</div> : null}
{this.props.afterComponent ? this.props.afterComponent : null}
@ -209,6 +206,7 @@ FormComponent.propTypes = {
FormComponent.contextTypes = {
intl: intlShape,
addToDeletedValues: PropTypes.func,
errors: PropTypes.array,
};
registerComponent('FormComponent', FormComponent);

View file

@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { registerComponent } from 'meteor/vulcan:core';
import { FormattedMessage } from 'meteor/vulcan:i18n';
import Alert from 'react-bootstrap/lib/Alert';
const FormErrors = ({ errors }) => (
<div className="form-errors">
{!!errors.length && (
<Alert className="flash-message" bsStyle="danger">
<ul>
{errors.map((error, index) => (
<li key={index}>
{error.message || (
<FormattedMessage
id={`errors.${error.data.type}`}
values={{ ...error.data }}
defaultMessage={JSON.stringify(error)}
/>
)}
</li>
))}
</ul>
</Alert>
)}
</div>
);
registerComponent('FormErrors', FormErrors);
// /*
// Render errors
// */
// renderErrors = () => {
// return (
// <div className="form-errors">
// {this.state.errors.map((error, index) => {
// let message;
// if (error.data && error.data.errors) {
// // this error is a "multi-error" with multiple sub-errors
// message = error.data.errors.map(error => {
// return {
// content: this.getErrorMessage(error),
// data: error.data,
// };
// });
// } else {
// // this is a regular error
// message = {
// content:
// error.message ||
// this.context.intl.formatMessage({ id: error.id, defaultMessage: error.id }, error.data),
// };
// }
// return <Components.FormFlash key={index} message={message} type="error" />;
// })}
// </div>
// );
// };

View file

@ -3,14 +3,27 @@ import PropTypes from 'prop-types';
import { Components, registerComponent } from 'meteor/vulcan:core';
import Button from 'react-bootstrap/lib/Button';
const FormNestedItem = ({ isDeleted, nestedFields, name, subDocument, removeItem, ...props }) => {
const FormNestedItem = (
{ isDeleted, nestedFields, name, path, subDocument, removeItem, itemIndex, ...props },
{ errors }
) => {
return (
<div className={`form-nested-item ${isDeleted ? 'form-nested-item-deleted' : ''}`}>
<div className="form-nested-item-inner">
{nestedFields.map((field, i) => {
// note: default value to '' to avoid uncontrolled component error
const value = subDocument && subDocument[field.name] || '';
return <Components.FormComponent key={i} {...props} {...field} value={value} />;
let value = (subDocument && subDocument[field.name]) || '';
if (props.control === 'number') value = Number(value);
return (
<Components.FormComponent
key={i}
{...props}
{...field}
path={`${path}.${field.name}`}
value={value}
itemIndex={itemIndex}
/>
);
})}
</div>
<div className="form-nested-item-remove">
@ -23,21 +36,24 @@ const FormNestedItem = ({ isDeleted, nestedFields, name, subDocument, removeItem
</Button>
</div>
<div className="form-nested-item-deleted-overlay"/>
<div className="form-nested-item-deleted-overlay" />
</div>
);
};
FormNestedItem.contextTypes = {
errors: PropTypes.array,
};
registerComponent('FormNestedItem', FormNestedItem);
class FormNested extends PureComponent {
addItem = () => {
this.props.updateCurrentValues({[`${this.props.name}.${this.props.value.length}`] : {}});
this.props.updateCurrentValues({ [`${this.props.path}.${this.props.value.length}`]: {} });
};
removeItem = index => {
this.props.updateCurrentValues({[`${this.props.name}.${index}`] : null});
this.props.updateCurrentValues({ [`${this.props.path}.${index}`]: null });
};
/*
@ -47,7 +63,7 @@ class FormNested extends PureComponent {
look for the presence of 'addresses.1')
*/
isDeleted = index => {
return this.context.deletedValues.includes(`${this.props.name}.${index}`);
return this.context.deletedValues.includes(`${this.props.path}.${index}`);
};
render() {
@ -56,13 +72,22 @@ class FormNested extends PureComponent {
<label className="control-label col-sm-3">{this.props.label}</label>
<div className="col-sm-9">
{this.props.value &&
this.props.value.map((subDocument, i) => (
!this.isDeleted(i) && <FormNestedItem key={i} itemIndex={i} {...this.props} subDocument={subDocument} removeItem={() => {this.removeItem(i)}} />
))}
<Button
bsStyle="success"
onClick={this.addItem}
>
this.props.value.map(
(subDocument, i) =>
!this.isDeleted(i) && (
<FormNestedItem
{...this.props}
key={i}
itemIndex={i}
subDocument={subDocument}
path={`${this.props.path}.${i}`}
removeItem={() => {
this.removeItem(i);
}}
/>
)
)}
<Button bsStyle="success" onClick={this.addItem}>
</Button>
</div>
@ -72,7 +97,7 @@ class FormNested extends PureComponent {
}
FormNested.contextTypes = {
deletedValues: PropTypes.array
}
deletedValues: PropTypes.array,
};
registerComponent('FormNested', FormNested);

View file

@ -12,6 +12,8 @@ import '../components/bootstrap/Url.jsx';
import '../components/bootstrap/Date.jsx';
import '../components/Flash.jsx';
import '../components/FieldErrors.jsx';
import '../components/FormErrors.jsx';
import '../components/FormComponent.jsx';
import '../components/FormNested.jsx';
import '../components/FormGroup.jsx';

View file

@ -134,4 +134,7 @@ addStrings('en', {
"admin": "Admin",
"notifications": "Notifications",
"errors.expectedType": `Expected a field of type {dataType}, got “{value}” instead.`,
"errors.required": `Field “{name}” is required.`,
});

View file

@ -6,10 +6,10 @@ String.prototype.replaceAll = function(search, replacement) {
return target.replace(new RegExp(search, 'g'), replacement);
};
const FormattedMessage = ({ id, values, defaultMessage, html = false }) => {
const FormattedMessage = ({ id, values, defaultMessage = '', html = false }) => {
const messages = Strings[getSetting('locale', 'en')] || {};
let message = messages[id] || defaultMessage;
if (values) {
if (message && values) {
_.forEach(values, (value, key) => {
message = message.replaceAll(`{${key}}`, value);
});

View file

@ -10,7 +10,6 @@
*/
export const validateDocument = (document, collection, context) => {
const { Users, currentUser } = context;
const schema = collection.simpleSchema()._schema;
@ -18,14 +17,13 @@ export const validateDocument = (document, collection, context) => {
// Check validity of inserted document
_.forEach(document, (value, fieldName) => {
const fieldSchema = schema[fieldName];
// 1. check that the current user has permission to insert each field
if (!fieldSchema || !Users.canInsertField (currentUser, fieldSchema)) {
if (!fieldSchema || !Users.canInsertField(currentUser, fieldSchema)) {
validationErrors.push({
id: 'app.disallowed_property_detected',
fieldName
fieldName,
});
}
@ -33,27 +31,24 @@ export const validateDocument = (document, collection, context) => {
if (fieldSchema.limit && value.length > fieldSchema.limit) {
validationErrors.push({
id: 'app.field_is_too_long',
data: {fieldName, limit: fieldSchema.limit}
data: { fieldName, limit: fieldSchema.limit },
});
}
// 3. check that fields have the proper type
// TODO
});
// 4. check that required fields have a value
_.keys(schema).forEach(fieldName => {
const fieldSchema = schema[fieldName];
if ((fieldSchema.required || !fieldSchema.optional) && typeof document[fieldName] === 'undefined') {
validationErrors.push({
id: 'app.required_field_missing',
data: {fieldName}
data: { fieldName },
});
}
});
// 5. still run SS validation for now for backwards compatibility
@ -64,13 +59,12 @@ export const validateDocument = (document, collection, context) => {
console.log(error);
validationErrors.push({
id: 'app.schema_validation_error',
data: {message: error.message}
data: { message: error.message },
});
}
return validationErrors;
}
};
/*
@ -84,7 +78,6 @@ export const validateDocument = (document, collection, context) => {
*/
export const validateModifier = (modifier, document, collection, context) => {
const { Users, currentUser } = context;
const schema = collection.simpleSchema()._schema;
const set = modifier.$set;
@ -94,61 +87,223 @@ export const validateModifier = (modifier, document, collection, context) => {
// 1. check that the current user has permission to edit each field
const modifiedProperties = _.keys(set).concat(_.keys(unset));
modifiedProperties.forEach(function (fieldName) {
modifiedProperties.forEach(function(fieldName) {
var field = schema[fieldName];
if (!field || !Users.canEditField(currentUser, field, document)) {
validationErrors.push({
id: 'app.disallowed_property_detected',
fieldName
data: {name: fieldName},
});
}
});
// Check validity of set modifier
_.forEach(set, (value, fieldName) => {
const fieldSchema = schema[fieldName];
// 2. check field lengths
if (fieldSchema.limit && value.length > fieldSchema.limit) {
validationErrors.push({
id: 'app.field_is_too_long',
data: {fieldName, limit: fieldSchema.limit}
data: { name: fieldName, limit: fieldSchema.limit },
});
}
// 3. check that fields have the proper type
// TODO
});
// // 4. check that required fields have a value
// // when editing, we only want to require fields that are actually part of the form
// // so we make sure required keys are present in the $unset object
// _.keys(schema).forEach(fieldName => {
// const fieldSchema = schema[fieldName];
// if (unset[fieldName] && (fieldSchema.required || !fieldSchema.optional) && typeof set[fieldName] === 'undefined') {
// validationErrors.push({
// id: 'app.required_field_missing',
// data: { name: fieldName },
// });
// }
// });
// 5. still run SS validation for now for backwards compatibility
const validationContext = collection.simpleSchema().newContext();
validationContext.validate({ $set: set, $unset: unset }, { modifier: true });
if (!validationContext.isValid()) {
const errors = validationContext.validationErrors();
console.log('// validationContext');
console.log(validationContext.isValid());
console.log(errors);
errors.forEach(error => {
// eslint-disable-next-line no-console
// console.log(error);
validationErrors.push({
id: 'app.schema_validation_error',
data: error,
});
});
}
return validationErrors;
};
/*
The following versions were written to be more SimpleSchema-agnostic, but
are not currently used
*/
/*
If document is not trusted, run validation steps:
1. Check that the current user has permission to edit each field
2. Check field lengths
3. Check field types
4. Check for missing fields
5. Run SimpleSchema validation step (for now)
*/
export const validateDocumentNotUsed = (document, collection, context) => {
const { Users, currentUser } = context;
const schema = collection.simpleSchema()._schema;
let validationErrors = [];
// Check validity of inserted document
_.forEach(document, (value, fieldName) => {
const fieldSchema = schema[fieldName];
// 1. check that the current user has permission to insert each field
if (!fieldSchema || !Users.canInsertField(currentUser, fieldSchema)) {
validationErrors.push({
id: 'app.disallowed_property_detected',
fieldName,
});
}
// 2. check field lengths
if (fieldSchema.limit && value.length > fieldSchema.limit) {
validationErrors.push({
id: 'app.field_is_too_long',
data: { fieldName, limit: fieldSchema.limit },
});
}
// 3. check that fields have the proper type
// TODO
});
// 4. check that required fields have a value
_.keys(schema).forEach(fieldName => {
const fieldSchema = schema[fieldName];
if ((fieldSchema.required || !fieldSchema.optional) && typeof document[fieldName] === 'undefined') {
validationErrors.push({
id: 'app.required_field_missing',
data: { fieldName },
});
}
});
// 5. still run SS validation for now for backwards compatibility
try {
collection.simpleSchema().validate(document);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
validationErrors.push({
id: 'app.schema_validation_error',
data: { message: error.message },
});
}
return validationErrors;
};
/*
If document is not trusted, run validation steps:
1. Check that the current user has permission to insert each field
2. Check field lengths
3. Check field types
4. Check for missing fields
5. Run SimpleSchema validation step (for now)
*/
export const validateModifierNotUsed = (modifier, document, collection, context) => {
const { Users, currentUser } = context;
const schema = collection.simpleSchema()._schema;
const set = modifier.$set;
const unset = modifier.$unset;
let validationErrors = [];
// 1. check that the current user has permission to edit each field
const modifiedProperties = _.keys(set).concat(_.keys(unset));
modifiedProperties.forEach(function(fieldName) {
var field = schema[fieldName];
if (!field || !Users.canEditField(currentUser, field, document)) {
validationErrors.push({
id: 'app.disallowed_property_detected',
data: {name: fieldName},
});
}
});
// Check validity of set modifier
_.forEach(set, (value, fieldName) => {
const fieldSchema = schema[fieldName];
// 2. check field lengths
if (fieldSchema.limit && value.length > fieldSchema.limit) {
validationErrors.push({
id: 'app.field_is_too_long',
data: { name: fieldName, limit: fieldSchema.limit },
});
}
// 3. check that fields have the proper type
// TODO
});
// 4. check that required fields have a value
// when editing, we only want to require fields that are actually part of the form
// so we make sure required keys are present in the $unset object
_.keys(schema).forEach(fieldName => {
const fieldSchema = schema[fieldName];
if (unset[fieldName] && (fieldSchema.required || !fieldSchema.optional) && typeof set[fieldName] === 'undefined') {
validationErrors.push({
id: 'app.required_field_missing',
data: {fieldName}
data: { name: fieldName },
});
}
});
// 5. still run SS validation for now for backwards compatibility
try {
collection.simpleSchema().validate({$set: set, $unset: unset}, { modifier: true });
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
validationErrors.push({
id: 'app.schema_validation_error',
data: {message: error.message}
const validationContext = collection.simpleSchema().newContext();
validationContext.validate({ $set: set, $unset: unset }, { modifier: true });
if (!validationContext.isValid()) {
const errors = validationContext.validationErrors();
console.log('// validationContext');
console.log(validationContext.isValid());
console.log(errors);
errors.forEach(error => {
// eslint-disable-next-line no-console
// console.log(error);
validationErrors.push({
id: 'app.schema_validation_error',
data: error,
});
});
}
return validationErrors;
}
};

View file

@ -130,9 +130,9 @@ export const editMutation = async ({ collection, documentId, set = {}, unset = {
debug('// editMutation');
debug('// collectionName: ', collection._name);
debug('// documentId: ', documentId);
// debug('// set: ', set);
// debug('// unset: ', unset);
// debug('// document: ', document);
debug('// set: ', set);
debug('// unset: ', unset);
debug('// document: ', document);
if (validate) {