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:
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
|
92
src/components/Widgets/ControlHOC.js
Normal file
92
src/components/Widgets/ControlHOC.js
Normal 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;
|
@ -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);
|
||||
|
@ -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);
|
||||
|
Reference in New Issue
Block a user