fix(netlify-cms-core): validate nested fields (#1873)
This commit is contained in:
parent
ade5809dff
commit
627e600d29
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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(
|
||||
|
@ -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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 = () => {
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user