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

@ -219,9 +219,14 @@ export function loadUnpublishedEntries() {
};
}
export function persistUnpublishedEntry(collection, entryDraft, existingUnpublishedEntry) {
export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
return (dispatch, getState) => {
const state = getState();
const entryDraft = state.entryDraft;
// Early return if draft contains validation errors
if (!entryDraft.get('fieldsErrors').isEmpty()) return;
const backend = currentBackend(state.config);
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry');

View File

@ -24,6 +24,7 @@ export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
export const DRAFT_CHANGE = 'DRAFT_CHANGE';
export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD';
export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST';
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
@ -141,6 +142,7 @@ export function createDraftFromEntry(entry) {
};
}
export function discardDraft() {
return {
type: DRAFT_DISCARD,
@ -161,6 +163,14 @@ export function changeDraftField(field, value, metadata) {
};
}
export function changeDraftFieldValidation(field, errors) {
return {
type: DRAFT_VALIDATION_ERRORS,
payload: { field, errors },
};
}
/*
* Exported Thunk Action Creators
*/
@ -213,9 +223,14 @@ export function createEmptyDraft(collection) {
};
}
export function persistEntry(collection, entryDraft) {
export function persistEntry(collection) {
return (dispatch, getState) => {
const state = getState();
const entryDraft = state.entryDraft;
// Early return if draft contains validation errors
if (!entryDraft.get('fieldsErrors').isEmpty()) return;
const backend = currentBackend(state.config);
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry');

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);

View File

@ -7,6 +7,7 @@ import {
createEmptyDraft,
discardDraft,
changeDraftField,
changeDraftFieldValidation,
persistEntry,
} from '../actions/entries';
import { closeEntry } from '../actions/editor';
@ -23,6 +24,7 @@ class EntryPage extends React.Component {
addAsset: PropTypes.func.isRequired,
boundGetAsset: PropTypes.func.isRequired,
changeDraftField: PropTypes.func.isRequired,
changeDraftFieldValidation: PropTypes.func.isRequired,
collection: ImmutablePropTypes.map.isRequired,
createDraftFromEntry: PropTypes.func.isRequired,
createEmptyDraft: PropTypes.func.isRequired,
@ -72,8 +74,8 @@ class EntryPage extends React.Component {
};
handlePersistEntry = () => {
const { persistEntry, collection, entryDraft } = this.props;
persistEntry(collection, entryDraft);
const { persistEntry, collection } = this.props;
persistEntry(collection);
};
render() {
@ -84,6 +86,7 @@ class EntryPage extends React.Component {
boundGetAsset,
collection,
changeDraftField,
changeDraftFieldValidation,
addAsset,
removeAsset,
closeEntry,
@ -104,7 +107,9 @@ class EntryPage extends React.Component {
collection={collection}
fields={fields}
fieldsMetaData={entryDraft.get('fieldsMetaData')}
fieldsErrors={entryDraft.get('fieldsErrors')}
onChange={changeDraftField}
onValidate={changeDraftFieldValidation}
onAddAsset={addAsset}
onRemoveAsset={removeAsset}
onPersist={this.handlePersistEntry}
@ -138,6 +143,7 @@ export default connect(
mapStateToProps,
{
changeDraftField,
changeDraftFieldValidation,
addAsset,
removeAsset,
loadEntry,

View File

@ -40,8 +40,8 @@ export default function EntryPageHOC(EntryPage) {
};
// Overwrite persistEntry to persistUnpublishedEntry
returnObj.persistEntry = (collection, entryDraft) => {
dispatch(persistUnpublishedEntry(collection, entryDraft, unpublishedEntry));
returnObj.persistEntry = (collection) => {
dispatch(persistUnpublishedEntry(collection, unpublishedEntry));
};
}

View File

@ -2,7 +2,7 @@ import { Map, List, fromJS } from 'immutable';
import * as actions from '../../actions/entries';
import reducer from '../entryDraft';
let initialState = Map({ entry: Map(), mediaFiles: List(), fieldsMetaData: Map() });
let initialState = Map({ entry: Map(), mediaFiles: List(), fieldsMetaData: Map(), fieldsErrors: Map() });
const entry = {
collection: 'posts',
@ -30,6 +30,7 @@ describe('entryDraft reducer', () => {
},
mediaFiles: [],
fieldsMetaData: Map(),
fieldsErrors: Map(),
})
);
});
@ -50,6 +51,7 @@ describe('entryDraft reducer', () => {
},
mediaFiles: [],
fieldsMetaData: Map(),
fieldsErrors: Map(),
})
);
});

View File

@ -4,6 +4,7 @@ import {
DRAFT_CREATE_EMPTY,
DRAFT_DISCARD,
DRAFT_CHANGE_FIELD,
DRAFT_VALIDATION_ERRORS,
ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS,
ENTRY_PERSIST_FAILURE,
@ -18,7 +19,7 @@ import {
REMOVE_ASSET,
} from '../actions/media';
const initialState = Map({ entry: Map(), mediaFiles: List(), fieldsMetaData: Map() });
const initialState = Map({ entry: Map(), mediaFiles: List(), fieldsMetaData: Map(), fieldsErrors: Map() });
const entryDraftReducer = (state = Map(), action) => {
switch (action.type) {
@ -29,6 +30,7 @@ const entryDraftReducer = (state = Map(), action) => {
state.setIn(['entry', 'newRecord'], false);
state.set('mediaFiles', List());
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
});
case DRAFT_CREATE_EMPTY:
// New Entry
@ -37,6 +39,7 @@ const entryDraftReducer = (state = Map(), action) => {
state.setIn(['entry', 'newRecord'], true);
state.set('mediaFiles', List());
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
});
case DRAFT_DISCARD:
return initialState;
@ -45,6 +48,14 @@ const entryDraftReducer = (state = Map(), action) => {
state.setIn(['entry', 'data', action.payload.field], action.payload.value);
state.mergeIn(['fieldsMetaData'], fromJS(action.payload.metadata));
});
case DRAFT_VALIDATION_ERRORS:
if (action.payload.errors.length === 0) {
return state.deleteIn(['fieldsErrors', action.payload.field]);
} else {
return state.setIn(['fieldsErrors', action.payload.field], action.payload.errors);
}
case ENTRY_PERSIST_REQUEST:
case UNPUBLISHED_ENTRY_PERSIST_REQUEST: {
return state.setIn(['entry', 'isPersisting'], true);