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

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