Validation (#216)

* Field config options: 'required' and 'pattern'
* Widget controls can implement it's own isValid
* Validation errors store in redux & displayed
* Support for returned Promises in isValid
* Allow widget controls to return either a boolean, an error object or a promise from isValid
This commit is contained in:
Cássio Souza
2017-01-13 19:30:40 -02:00
committed by GitHub
parent b710e706da
commit 3306670459
12 changed files with 224 additions and 25 deletions

View File

@ -19,12 +19,26 @@
color: #7c8382;
}
}
.label {
display: block;
color: #AAB0AF;
font-size: 12px;
margin-bottom: 8px;
}
.labelWithError {
composes: label;
color: #FF706F;
}
.errors {
list-style-type: none;
font-size: 10px;
color: #FF706F;
margin-bottom: 18px;
}
.widget {
border-bottom: 1px solid #e8eae8;
position: relative;

View File

@ -1,6 +1,8 @@
import React, { Component, PropTypes } from 'react';
import { Map, fromJS } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { resolveWidget } from '../Widgets';
import ControlHOC from '../Widgets/ControlHOC';
import styles from './ControlPane.css';
function isHidden(field) {
@ -8,28 +10,47 @@ function isHidden(field) {
}
export default class ControlPane extends Component {
componentValidate = {};
processControlRef(fieldName, wrappedControl) {
if (!wrappedControl) return;
this.componentValidate[fieldName] = wrappedControl.validate;
}
validate = () => {
this.props.fields.forEach(field => this.componentValidate[field.get("name")]());
};
controlFor(field) {
const { entry, fieldsMetaData, getAsset, onChange, onAddAsset, onRemoveAsset } = this.props;
const { entry, fieldsMetaData, fieldsErrors, getAsset, onChange, onAddAsset, onRemoveAsset } = this.props;
const widget = resolveWidget(field.get('widget'));
const fieldName = field.get('name');
const value = entry.getIn(['data', fieldName]);
const metadata = fieldsMetaData.get(fieldName);
const errors = fieldsErrors.get(fieldName);
const labelClass = errors ? styles.labelWithError : styles.label;
if (entry.size === 0 || entry.get('partial') === true) return null;
return (
<div className={styles.control}>
<label className={styles.label} htmlFor={fieldName}>{field.get('label')}</label>
{
React.createElement(widget.control, {
field,
value,
metadata,
onChange: (newValue, newMetadata) => onChange(fieldName, newValue, newMetadata),
onAddAsset,
onRemoveAsset,
getAsset,
})
}
<label className={labelClass} htmlFor={fieldName}>{field.get('label')}</label>
<ul className={styles.errors}>
{
errors && errors.map(error => (
typeof error === 'string' && <li key={error.trim().replace(/[^a-z0-9]+/gi, '-')}>{error}</li>
))
}
</ul>
<ControlHOC
controlComponent={widget.control}
field={field}
value={value}
metadata={metadata}
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
onValidate={this.props.onValidate.bind(this, fieldName)}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
ref={this.processControlRef.bind(this, fieldName)}
/>
</div>
);
}
@ -60,8 +81,10 @@ ControlPane.propTypes = {
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
};

View File

@ -20,17 +20,23 @@ class EntryEditor extends Component {
this.setState({ showEventBlocker: false });
};
handleOnPersist = () => {
this.controlPaneRef.validate();
this.props.onPersist();
};
render() {
const {
collection,
entry,
fields,
fieldsMetaData,
fieldsErrors,
getAsset,
onChange,
onValidate,
onAddAsset,
onRemoveAsset,
onPersist,
onCancelEdit,
} = this.props;
@ -53,10 +59,13 @@ class EntryEditor extends Component {
entry={entry}
fields={fields}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
getAsset={getAsset}
onChange={onChange}
onValidate={onValidate}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
ref={c => this.controlPaneRef = c} // eslint-disable-line
/>
</div>
@ -76,7 +85,7 @@ class EntryEditor extends Component {
<div className={styles.footer}>
<Toolbar
isPersisting={entry.get('isPersisting')}
onPersist={onPersist}
onPersist={this.handleOnPersist}
onCancelEdit={onCancelEdit}
/>
</div>
@ -91,9 +100,11 @@ EntryEditor.propTypes = {
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onPersist: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
onCancelEdit: PropTypes.func.isRequired,

View File

@ -0,0 +1,92 @@
import React, { Component, PropTypes } from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
const truthy = () => ({ error: false });
class ControlHOC extends Component {
static propTypes = {
controlComponent: PropTypes.func.isRequired,
field: ImmutablePropTypes.map.isRequired,
value: PropTypes.node,
metadata: ImmutablePropTypes.map,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
};
processInnerControlRef = (wrappedControl) => {
if (!wrappedControl) return;
this.wrappedControlValid = wrappedControl.isValid || truthy;
};
validate = (skipWrapped = false) => {
const { field, value } = this.props;
const errors = [];
const validations = [this.validatePresence, this.validatePattern];
validations.forEach((func) => {
const response = func(field, value);
if (response.error) errors.push(response.error);
});
if (skipWrapped) {
if (skipWrapped.error) errors.push(skipWrapped.error);
} else {
const wrappedError = this.validateWrappedControl(field);
if (wrappedError.error) errors.push(wrappedError.error);
}
this.props.onValidate(errors);
};
validatePresence(field, value) {
const isRequired = field.get('required', true);
if (isRequired && (value === null || value.length === 0)) {
return { error: true };
}
return { error: false };
}
validatePattern(field, value) {
const pattern = field.get('pattern', false);
if (pattern && !RegExp(pattern.first()).test(value)) {
return { error: `${ field.get('label', field.get('name')) } didn't match the pattern: ${ pattern.last() }` };
}
return { error: false };
}
validateWrappedControl = (field) => {
const response = this.wrappedControlValid();
if (typeof response === "boolean") {
const isValid = response;
return { error: (!isValid) };
} else if (response.hasOwnProperty('error')) {
return response;
} else if (response instanceof Promise) {
response.then(
() => { this.validate({ error: false }); },
(error) => {
this.validate({ error: `${ field.get('label', field.get('name')) } - ${ error }.` });
}
);
return { error: `${ field.get('label', field.get('name')) } is processing.` };
}
return { error: false };
};
render() {
const { controlComponent, field, value, metadata, onChange, onAddAsset, onRemoveAsset, getAsset } = this.props;
return React.createElement(controlComponent, {
field,
value,
metadata,
onChange,
onAddAsset,
onRemoveAsset,
getAsset,
ref: this.processInnerControlRef,
});
}
}
export default ControlHOC;

View File

@ -10,6 +10,16 @@ export default class FileControl extends React.Component {
processing: false,
};
promise = null;
isValid = () => {
if (this.promise) {
return this.promise;
}
return { error: false };
};
handleFileInputRef = (el) => {
this._fileInput = el;
};
@ -42,7 +52,7 @@ export default class FileControl extends React.Component {
this.props.onRemoveAsset(this.props.value);
if (file) {
this.setState({ processing: true });
createAssetProxy(file.name, file, false, this.props.field.get('private', false))
this.promise = createAssetProxy(file.name, file, false, this.props.field.get('private', false))
.then((assetProxy) => {
this.setState({ processing: false });
this.props.onAddAsset(assetProxy);

View File

@ -10,6 +10,16 @@ export default class ImageControl extends React.Component {
processing: false,
};
promise = null;
isValid = () => {
if (this.promise) {
return this.promise;
}
return { error: false };
};
handleFileInputRef = (el) => {
this._fileInput = el;
};
@ -46,7 +56,7 @@ export default class ImageControl extends React.Component {
this.props.onRemoveAsset(this.props.value);
if (file) {
this.setState({ processing: true });
createAssetProxy(file.name, file)
this.promise = createAssetProxy(file.name, file)
.then((assetProxy) => {
this.setState({ processing: false });
this.props.onAddAsset(assetProxy);