fix(netlify-cms-core): validate nested fields (#1873)

This commit is contained in:
Bartholomew 2019-02-05 23:27:34 +01:00 committed by Shawn Erquhart
parent ade5809dff
commit 627e600d29
10 changed files with 183 additions and 22 deletions

View File

@ -4,11 +4,14 @@ describe('Editorial Workflow', () => {
const entry1 = { title: 'first title', body: 'first body' }
const entry2 = { title: 'second title', body: 'second body' }
const entry3 = { title: 'third title', body: 'third body' }
const setting1 = { limit: 10, author: 'John Doe' }
const setting2 = { name: 'Andrew Wommack', description: 'A Gospel Teacher' }
const notifications = {
saved: 'Entry saved',
published: 'Entry published',
updated: 'Entry status updated',
deletedUnpublished: 'Unpublished changes deleted',
error: { missingField: 'Oops, you\'ve missed a required field. Please complete before saving.' }
}
describe('Test Backend', () => {
@ -27,6 +30,29 @@ describe('Editorial Workflow', () => {
assertNotification(notifications.saved)
}
function validateObjectFields({ limit, author }) {
cy.get('a[href^="#/collections/settings"]').click()
cy.get('a[href^="#/collections/settings/entries/general"]').click()
cy.get('input[type=number]').type(limit);
cy.contains('button', 'Save').click()
assertNotification(notifications.error.missingField)
cy.contains('label', 'Default Author').click()
cy.focused().type(author)
cy.contains('button', 'Save').click()
assertNotification(notifications.saved)
}
function validateListFields({ name, description }) {
cy.get('a[href^="#/collections/settings"]').click()
cy.get('a[href^="#/collections/settings/entries/authors"]').click()
cy.contains('button', 'Add').click()
cy.contains('button', 'Save').click()
assertNotification(notifications.error.missingField)
cy.get('input').eq(2).type(name)
cy.get('[data-slate-editor]').eq(2).type(description)
cy.contains('button', 'Save').click()
}
function exitEditor() {
cy.contains('a[href^="#/collections/"]', 'Writing in').click()
}
@ -70,6 +96,16 @@ describe('Editorial Workflow', () => {
exitEditor()
}
function validateObjectFieldsAndExit(setting) {
validateObjectFields(setting)
exitEditor()
}
function validateListFieldsAndExit(setting) {
validateListFields(setting)
exitEditor()
}
function goToWorkflow() {
cy.contains('a', 'Workflow').click()
}
@ -172,6 +208,16 @@ describe('Editorial Workflow', () => {
createPostAndExit(entry1)
})
it('can validate object fields', () => {
login()
validateObjectFieldsAndExit(setting1)
})
it('can validate list fields', () => {
login()
validateListFieldsAndExit(setting2)
})
it('can publish an editorial workflow entry', () => {
login()
createPostAndExit(entry1)

View File

@ -62,7 +62,7 @@ collections: # A list of collections the CMS should be able to edit
fields:
- { label: 'Number of posts on frontpage', name: front_limit, widget: number }
- { label: 'Default Author', name: author, widget: string }
- { label: 'Default Thumbnail', name: thumb, widget: image, class: 'thumb' }
- { label: 'Default Thumbnail', name: thumb, widget: image, class: 'thumb', required: false }
- name: 'authors'
label: 'Authors'

View File

@ -29,6 +29,7 @@ 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 DRAFT_CLEAR_ERRORS = 'DRAFT_CLEAR_ERRORS';
export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST';
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
@ -208,13 +209,17 @@ export function changeDraftField(field, value, metadata) {
};
}
export function changeDraftFieldValidation(field, errors) {
export function changeDraftFieldValidation(uniquefieldId, errors) {
return {
type: DRAFT_VALIDATION_ERRORS,
payload: { field, errors },
payload: { uniquefieldId, errors },
};
}
export function clearFieldErrors() {
return { type: DRAFT_CLEAR_ERRORS };
}
/*
* Exported Thunk Action Creators
*/

View File

@ -7,6 +7,7 @@ import { partial, uniqueId } from 'lodash';
import { connect } from 'react-redux';
import { colors, colorsRaw, transitions, lengths, borders } from 'netlify-cms-ui-default';
import { resolveWidget, getEditorComponents } from 'Lib/registry';
import { clearFieldErrors } from 'Actions/entries';
import { addAsset } from 'Actions/media';
import { query, clearSearch } from 'Actions/search';
import { loadEntry } from 'Actions/entries';
@ -140,10 +141,12 @@ class EditorControl extends React.Component {
removeInsertedMedia: PropTypes.func.isRequired,
onValidate: PropTypes.func,
processControlRef: PropTypes.func,
controlRef: PropTypes.func,
query: PropTypes.func.isRequired,
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
isFetching: PropTypes.bool,
clearSearch: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
loadEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
@ -170,10 +173,12 @@ class EditorControl extends React.Component {
removeInsertedMedia,
onValidate,
processControlRef,
controlRef,
query,
queryHits,
isFetching,
clearSearch,
clearFieldErrors,
loadEntry,
t,
} = this.props;
@ -182,8 +187,9 @@ class EditorControl extends React.Component {
const fieldName = field.get('name');
const fieldHint = field.get('hint');
const isFieldOptional = field.get('required') === false;
const onValidateObject = onValidate;
const metadata = fieldsMetaData && fieldsMetaData.get(fieldName);
const errors = fieldsErrors && fieldsErrors.get(fieldName);
const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId);
return (
<ControlContainer>
<ControlErrorsList>
@ -223,7 +229,7 @@ class EditorControl extends React.Component {
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
onValidate={onValidate && partial(onValidate, fieldName)}
onValidate={onValidate && partial(onValidate, this.uniqueFieldId)}
onOpenMediaLibrary={openMediaLibrary}
onClearMediaControl={clearMediaControl}
onRemoveMediaControl={removeMediaControl}
@ -235,13 +241,17 @@ class EditorControl extends React.Component {
setInactiveStyle={() => this.setState({ styleActive: false })}
resolveWidget={resolveWidget}
getEditorComponents={getEditorComponents}
ref={processControlRef && partial(processControlRef, fieldName)}
ref={processControlRef && partial(processControlRef, field)}
controlRef={controlRef}
editorControl={ConnectedEditorControl}
query={query}
loadEntry={loadEntry}
queryHits={queryHits}
clearSearch={clearSearch}
clearFieldErrors={clearFieldErrors}
isFetching={isFetching}
fieldsErrors={fieldsErrors}
onValidateObject={onValidateObject}
t={t}
/>
{fieldHint && (
@ -273,6 +283,7 @@ const mapDispatchToProps = {
return loadEntry(collection, slug)(dispatch, getState);
},
clearSearch,
clearFieldErrors,
};
const ConnectedEditorControl = connect(

View File

@ -17,10 +17,16 @@ const ControlPaneContainer = styled.div`
export default class ControlPane extends React.Component {
componentValidate = {};
processControlRef = (fieldName, wrappedControl) => {
controlRef(field, wrappedControl) {
if (!wrappedControl) return;
this.componentValidate[fieldName] = wrappedControl.validate;
};
const name = field.get('name');
const widget = field.get('widget');
if (widget === 'list' || widget === 'object') {
this.componentValidate[name] = wrappedControl.innerWrappedControl.validate;
} else {
this.componentValidate[name] = wrappedControl.validate;
}
}
validate = () => {
this.props.fields.forEach(field => {
@ -61,7 +67,8 @@ export default class ControlPane extends React.Component {
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
processControlRef={this.processControlRef}
processControlRef={this.controlRef.bind(this)}
controlRef={this.controlRef}
/>
),
)}

View File

@ -33,6 +33,7 @@ export default class Widget extends Component {
]),
mediaPaths: ImmutablePropTypes.map.isRequired,
metadata: ImmutablePropTypes.map,
fieldsErrors: ImmutablePropTypes.map,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func,
onOpenMediaLibrary: PropTypes.func.isRequired,
@ -44,8 +45,10 @@ export default class Widget extends Component {
resolveWidget: PropTypes.func.isRequired,
getEditorComponents: PropTypes.func.isRequired,
isFetching: PropTypes.bool,
controlRef: PropTypes.func,
query: PropTypes.func.isRequired,
clearSearch: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
editorControl: PropTypes.func.isRequired,
uniqueFieldId: PropTypes.string.isRequired,
@ -77,16 +80,16 @@ export default class Widget extends Component {
* `getWrappedInstance` method. Note that connected widgets must pass
* `withRef: true` to `connect` in the options object.
*/
const wrappedControl = ref.getWrappedInstance ? ref.getWrappedInstance() : ref;
this.innerWrappedControl = ref.getWrappedInstance ? ref.getWrappedInstance() : ref;
this.wrappedControlValid = wrappedControl.isValid || truthy;
this.wrappedControlValid = this.innerWrappedControl.isValid || truthy;
/**
* Get the `shouldComponentUpdate` method from the wrapped control, and
* provide the control instance is the `this` binding.
*/
const { shouldComponentUpdate: scu } = wrappedControl;
this.wrappedControlShouldComponentUpdate = scu && scu.bind(wrappedControl);
const { shouldComponentUpdate: scu } = this.innerWrappedControl;
this.wrappedControlShouldComponentUpdate = scu && scu.bind(this.innerWrappedControl);
};
validate = (skipWrapped = false) => {
@ -203,6 +206,7 @@ export default class Widget extends Component {
mediaPaths,
metadata,
onChange,
onValidateObject,
onOpenMediaLibrary,
onRemoveMediaControl,
onClearMediaControl,
@ -224,8 +228,11 @@ export default class Widget extends Component {
query,
queryHits,
clearSearch,
clearFieldErrors,
isFetching,
loadEntry,
fieldsErrors,
controlRef,
t,
} = this.props;
return React.createElement(controlComponent, {
@ -235,6 +242,7 @@ export default class Widget extends Component {
metadata,
onChange,
onChangeObject: this.onChangeObject,
onValidateObject,
onOpenMediaLibrary,
onClearMediaControl,
onRemoveMediaControl,
@ -257,8 +265,11 @@ export default class Widget extends Component {
query,
queryHits,
clearSearch,
clearFieldErrors,
isFetching,
loadEntry,
fieldsErrors,
controlRef,
t,
});
}

View File

@ -5,6 +5,7 @@ import {
DRAFT_DISCARD,
DRAFT_CHANGE_FIELD,
DRAFT_VALIDATION_ERRORS,
DRAFT_CLEAR_ERRORS,
ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS,
ENTRY_PERSIST_FAILURE,
@ -61,11 +62,15 @@ const entryDraftReducer = (state = Map(), action) => {
case DRAFT_VALIDATION_ERRORS:
if (action.payload.errors.length === 0) {
return state.deleteIn(['fieldsErrors', action.payload.field]);
return state.deleteIn(['fieldsErrors', action.payload.uniquefieldId]);
} else {
return state.setIn(['fieldsErrors', action.payload.field], action.payload.errors);
return state.setIn(['fieldsErrors', action.payload.uniquefieldId], action.payload.errors);
}
case DRAFT_CLEAR_ERRORS: {
return state.set('fieldsErrors', Map());
}
case ENTRY_PERSIST_REQUEST:
case UNPUBLISHED_ENTRY_PERSIST_REQUEST: {
return state.setIn(['entry', 'isPersisting'], true);

View File

@ -68,13 +68,17 @@ const valueTypes = {
};
export default class ListControl extends React.Component {
validations = [];
static propTypes = {
metadata: ImmutablePropTypes.map,
onChange: PropTypes.func.isRequired,
onChangeObject: PropTypes.func.isRequired,
onValidateObject: PropTypes.func.isRequired,
value: ImmutablePropTypes.list,
field: PropTypes.object,
forID: PropTypes.string,
controlRef: PropTypes.func,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
@ -85,6 +89,8 @@ export default class ListControl extends React.Component {
setInactiveStyle: PropTypes.func.isRequired,
editorControl: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
};
static defaultProps = {
@ -168,6 +174,17 @@ export default class ListControl extends React.Component {
onChange((value || List()).push(parsedValue));
};
processControlRef = ref => {
if (!ref) return;
this.validations.push(ref.validate);
};
validate = () => {
this.validations.forEach(validateListItem => {
validateListItem();
});
};
/**
* In case the `onChangeObject` function is frozen by a child widget implementation,
* e.g. when debounced, always get the latest object value instead of using
@ -196,16 +213,25 @@ export default class ListControl extends React.Component {
handleRemove = (index, event) => {
event.preventDefault();
const { itemsCollapsed } = this.state;
const { value, metadata, onChange, field } = this.props;
const { value, metadata, onChange, field, clearFieldErrors } = this.props;
const collectionName = field.get('name');
const isSingleField = this.getValueType() === valueTypes.SINGLE;
const metadataRemovePath = isSingleField ? value.get(index) : value.get(index).valueSeq();
const parsedMetadata = metadata && { [collectionName]: metadata.removeIn(metadataRemovePath) };
// Removed item object index is the last item in the list
const removedItemIndex = value.count() - 1;
this.setState({ itemsCollapsed: itemsCollapsed.delete(index) });
onChange(value.remove(index), parsedMetadata);
clearFieldErrors();
// Remove deleted item object validation
if (this.validations) {
this.validations.splice(removedItemIndex, 1);
}
};
handleItemCollapseToggle = (index, event) => {
@ -253,7 +279,16 @@ export default class ListControl extends React.Component {
};
renderItem = (item, index) => {
const { classNameWrapper, editorControl, resolveWidget } = this.props;
const {
classNameWrapper,
editorControl,
onValidateObject,
clearFieldErrors,
fieldsErrors,
controlRef,
resolveWidget,
} = this.props;
const { itemsCollapsed } = this.state;
const collapsed = itemsCollapsed.get(index);
let field = this.props.field;
@ -286,6 +321,11 @@ export default class ListControl extends React.Component {
editorControl={editorControl}
resolveWidget={resolveWidget}
forList
onValidateObject={onValidateObject}
clearFieldErrors={clearFieldErrors}
fieldsErrors={fieldsErrors}
ref={this.processControlRef}
controlRef={controlRef}
/>
</SortableListItem>
);

View File

@ -1,7 +1,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { css, cx } from 'react-emotion';
import { Map } from 'immutable';
import { Map, List } from 'immutable';
import { ObjectWidgetTopBar, components } from 'netlify-cms-ui-default';
const styles = {
@ -14,15 +15,21 @@ const styles = {
};
export default class ObjectControl extends Component {
componentValidate = {};
static propTypes = {
onChangeObject: PropTypes.func.isRequired,
onValidateObject: PropTypes.func.isRequired,
value: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.bool]),
field: PropTypes.object,
forID: PropTypes.string,
classNameWrapper: PropTypes.string.isRequired,
forList: PropTypes.bool,
controlRef: PropTypes.func,
editorControl: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
};
static defaultProps = {
@ -46,8 +53,26 @@ export default class ObjectControl extends Component {
return true;
}
validate = () => {
const { field } = this.props;
let fields = field.get('field') || field.get('fields');
fields = List.isList(fields) ? fields : List([fields]);
fields.forEach(field => {
if (field.get('widget') === 'hidden') return;
this.componentValidate[field.get('name')]();
});
};
controlFor(field, key) {
const { value, onChangeObject, editorControl: EditorControl } = this.props;
const {
value,
onChangeObject,
onValidateObject,
clearFieldErrors,
fieldsErrors,
editorControl: EditorControl,
controlRef,
} = this.props;
if (field.get('widget') === 'hidden') {
return null;
@ -55,7 +80,19 @@ export default class ObjectControl extends Component {
const fieldName = field.get('name');
const fieldValue = value && Map.isMap(value) ? value.get(fieldName) : value;
return <EditorControl key={key} field={field} value={fieldValue} onChange={onChangeObject} />;
return (
<EditorControl
key={key}
field={field}
value={fieldValue}
onChange={onChangeObject}
clearFieldErrors={clearFieldErrors}
fieldsErrors={fieldsErrors}
onValidate={onValidateObject}
processControlRef={controlRef && controlRef.bind(this)}
controlRef={controlRef}
/>
);
}
handleCollapseToggle = () => {

View File

@ -25,6 +25,5 @@ The following options are available on all fields:
widget: "string"
pattern: [".{12,}", "Must have at least 12 characters"]
```
- **Note:** Currently validation doesn't work on nested fields
## Default widgets