diff --git a/cypress/integration/editorial_workflow_spec.js b/cypress/integration/editorial_workflow_spec.js index e55151f9..6dcfd5eb 100644 --- a/cypress/integration/editorial_workflow_spec.js +++ b/cypress/integration/editorial_workflow_spec.js @@ -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) diff --git a/dev-test/config.yml b/dev-test/config.yml index d91d8f1a..3746ffd9 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -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' diff --git a/packages/netlify-cms-core/src/actions/entries.js b/packages/netlify-cms-core/src/actions/entries.js index 2bf48ec6..86d12ea1 100644 --- a/packages/netlify-cms-core/src/actions/entries.js +++ b/packages/netlify-cms-core/src/actions/entries.js @@ -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 */ diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index 96df64c9..2cd855e7 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -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 ( @@ -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( diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index 3156903a..648e408d 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -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} /> ), )} diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js index 7cab6b35..7b348ebf 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js @@ -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, }); } diff --git a/packages/netlify-cms-core/src/reducers/entryDraft.js b/packages/netlify-cms-core/src/reducers/entryDraft.js index bbb83e0f..7e72b819 100644 --- a/packages/netlify-cms-core/src/reducers/entryDraft.js +++ b/packages/netlify-cms-core/src/reducers/entryDraft.js @@ -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); diff --git a/packages/netlify-cms-widget-list/src/ListControl.js b/packages/netlify-cms-widget-list/src/ListControl.js index cb1cc4c8..43cd2d07 100644 --- a/packages/netlify-cms-widget-list/src/ListControl.js +++ b/packages/netlify-cms-widget-list/src/ListControl.js @@ -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} /> ); diff --git a/packages/netlify-cms-widget-object/src/ObjectControl.js b/packages/netlify-cms-widget-object/src/ObjectControl.js index 626272ad..f421217a 100644 --- a/packages/netlify-cms-widget-object/src/ObjectControl.js +++ b/packages/netlify-cms-widget-object/src/ObjectControl.js @@ -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 ; + return ( + + ); } handleCollapseToggle = () => { diff --git a/website/content/docs/widgets.md b/website/content/docs/widgets.md index 242accd2..62b1df02 100644 --- a/website/content/docs/widgets.md +++ b/website/content/docs/widgets.md @@ -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