From 397857855b2c8514c2f7ce83756af6b6698abc3d Mon Sep 17 00:00:00 2001 From: Kevin Young Date: Mon, 25 May 2020 01:42:54 -0500 Subject: [PATCH] fix: Error UI improvements for nested lists/objects (#3726) --- .../editorial_workflow_spec_test_backend.js | 20 +- cypress/integration/field_validations_spec.js | 109 ++++++++++ cypress/utils/constants.js | 9 +- cypress/utils/steps.js | 201 +++++++++++++++++- .../netlify-cms-core/src/actions/entries.ts | 2 +- .../Editor/EditorControlPane/EditorControl.js | 27 ++- .../Editor/EditorControlPane/Widget.js | 11 +- .../src/ListControl.js | 19 +- .../src/__tests__/ListControl.spec.js | 1 + .../__snapshots__/ListControl.spec.js.snap | 25 +++ .../src/ObjectControl.js | 12 +- 11 files changed, 402 insertions(+), 34 deletions(-) create mode 100644 cypress/integration/field_validations_spec.js diff --git a/cypress/integration/editorial_workflow_spec_test_backend.js b/cypress/integration/editorial_workflow_spec_test_backend.js index fe620021..d4ecc1a1 100644 --- a/cypress/integration/editorial_workflow_spec_test_backend.js +++ b/cypress/integration/editorial_workflow_spec_test_backend.js @@ -15,9 +15,6 @@ import { assertEntryDeleted, assertWorkflowStatus, updateWorkflowStatusInEditor, - validateObjectFieldsAndExit, - validateNestedObjectFieldsAndExit, - validateListFieldsAndExit, unpublishEntry, publishEntryInEditor, duplicateEntry, @@ -26,7 +23,7 @@ import { publishAndCreateNewEntryInEditor, publishAndDuplicateEntryInEditor, } from '../utils/steps'; -import { setting1, setting2, workflowStatus, editorStatus, publishTypes } from '../utils/constants'; +import { workflowStatus, editorStatus, publishTypes } from '../utils/constants'; const entry1 = { title: 'first title', @@ -74,21 +71,6 @@ describe('Test Backend Editorial Workflow', () => { exitEditor(); }); - it('can validate object fields', () => { - login(); - validateObjectFieldsAndExit(setting1); - }); - - it('can validate fields nested in an object field', () => { - login(); - validateNestedObjectFieldsAndExit(setting1); - }); - - it('can validate list fields', () => { - login(); - validateListFieldsAndExit(setting2); - }); - it('can publish an editorial workflow entry', () => { login(); createPostAndExit(entry1); diff --git a/cypress/integration/field_validations_spec.js b/cypress/integration/field_validations_spec.js new file mode 100644 index 00000000..fae5970c --- /dev/null +++ b/cypress/integration/field_validations_spec.js @@ -0,0 +1,109 @@ +import '../utils/dismiss-local-backup'; +import { + login, + validateObjectFieldsAndExit, + validateNestedObjectFieldsAndExit, + validateListFieldsAndExit, + validateNestedListFieldsAndExit, +} from '../utils/steps'; +import { setting1, setting2 } from '../utils/constants'; + +const nestedListConfig = { + collections: [ + {}, + {}, + { + name: 'settings', + label: 'Settings', + editor: { preview: false }, + files: [ + {}, + {}, + { + name: 'hotel_locations', + label: 'Hotel Locations', + file: '_data/hotel_locations.yml', + fields: [ + { + label: 'Country', + name: 'country', + widget: 'string', + }, + { + label: 'Hotel Locations', + name: 'hotel_locations', + widget: 'list', + fields: [ + { + label: 'Cities', + name: 'cities', + widget: 'list', + fields: [ + { + label: 'City', + name: 'city', + widget: 'string', + }, + { + label: 'Number of Hotels in City', + name: 'number_of_hotels_in_city', + widget: 'number', + }, + { + label: 'City Locations', + name: 'city_locations', + widget: 'list', + fields: [ + { + label: 'Hotel Name', + name: 'hotel_name', + widget: 'string', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +}; + +describe('Test Backend Editorial Workflow', () => { + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + }); + + beforeEach(() => { + cy.task('setupBackend', { backend: 'test' }); + }); + + it('can validate object fields', () => { + login(); + validateObjectFieldsAndExit(setting1); + }); + + it('can validate fields nested in an object field', () => { + login(); + validateNestedObjectFieldsAndExit(setting1); + }); + + it('can validate list fields', () => { + login(); + validateListFieldsAndExit(setting2); + }); + + it('can validate deeply nested list fields', () => { + cy.task('updateConfig', nestedListConfig); + + login(); + validateNestedListFieldsAndExit(setting2); + }); +}); diff --git a/cypress/utils/constants.js b/cypress/utils/constants.js index a04ebef0..5119e952 100644 --- a/cypress/utils/constants.js +++ b/cypress/utils/constants.js @@ -3,6 +3,10 @@ const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' }; const setting1 = { limit: 10, author: 'John Doe' }; const setting2 = { name: 'Jane Doe', description: 'description' }; const publishTypes = { publishNow: 'Publish now', publishAndCreateNew: 'Publish and create new', publishAndDuplicate: 'Publish and duplicate' }; +const colorError = 'rgb(255, 0, 59)'; +const colorNormal = 'rgb(223, 223, 227)'; +const textColorNormal = 'rgb(68, 74, 87)'; + const notifications = { saved: 'Entry saved', published: 'Entry published', @@ -40,5 +44,8 @@ module.exports = { setting2, notifications, publishTypes, - HOT_KEY_MAP + HOT_KEY_MAP, + colorError, + colorNormal, + textColorNormal, }; diff --git a/cypress/utils/steps.js b/cypress/utils/steps.js index 6d823e8b..3eec5a5b 100644 --- a/cypress/utils/steps.js +++ b/cypress/utils/steps.js @@ -1,4 +1,12 @@ -const { notifications, workflowStatus, editorStatus, publishTypes } = require('./constants'); +const { + notifications, + workflowStatus, + editorStatus, + publishTypes, + colorError, + colorNormal, + textColorNormal, +} = require('./constants'); function login(user) { cy.viewport(1200, 1200); @@ -38,6 +46,35 @@ function assertNotification(message) { }); } +function assertColorOn(cssProperty, color, opts) { + if (opts.type && opts.type === 'label') { + (opts.scope ? opts.scope : cy).contains('label', opts.label).should($el => { + expect($el).to.have.css(cssProperty, color); + }); + } else if (opts.type && opts.type === 'field') { + const assertion = $el => expect($el).to.have.css(cssProperty, color); + if (opts.isMarkdown) { + (opts.scope ? opts.scope : cy) + .contains('label', opts.label) + .next() + .children() + .eq(0) + .children() + .eq(1) + .should(assertion); + } else { + (opts.scope ? opts.scope : cy) + .contains('label', opts.label) + .next() + .should(assertion); + } + } else if (opts.el) { + opts.el.should($el => { + expect($el).to.have.css(cssProperty, color); + }); + } +} + function exitEditor() { cy.contains('a[href^="#/collections/"]', 'Writing in').click(); } @@ -204,9 +241,6 @@ function flushClockAndSave() { cy.wait(500); } - cy.get('input') - .first() - .click(); cy.contains('button', 'Save').click(); assertNotification(notifications.saved); }); @@ -381,10 +415,12 @@ function validateObjectFields({ limit, author }) { cy.get('input[type=number]').type(limit); cy.contains('button', 'Save').click(); assertNotification(notifications.error.missingField); + assertFieldErrorStatus('Default Author', colorError); cy.contains('label', 'Default Author').click(); cy.focused().type(author); cy.contains('button', 'Save').click(); assertNotification(notifications.saved); + assertFieldErrorStatus('Default Author', colorNormal); } function validateNestedObjectFields({ limit, author }) { @@ -415,13 +451,126 @@ function validateListFields({ name, description }) { cy.contains('button', 'Add').click(); cy.contains('button', 'Save').click(); assertNotification(notifications.error.missingField); + assertFieldErrorStatus('Authors', colorError); + cy.get('div[class*=ListControl]') + .eq(2) + .as('listControl'); + assertFieldErrorStatus('Name', colorError, { scope: cy.get('@listControl') }); + assertColorOn('background-color', colorError, { + type: 'label', + label: 'Description', + scope: cy.get('@listControl'), + isMarkdown: true, + }); + assertListControlErrorStatus([colorError, colorError], '@listControl'); cy.get('input') .eq(2) .type(name); cy.getMarkdownEditor() .eq(2) .type(description); + flushClockAndSave(); + assertNotification(notifications.saved); + assertFieldErrorStatus('Authors', colorNormal); +} + +function validateNestedListFields() { + cy.get('a[href^="#/collections/settings"]').click(); + cy.get('a[href^="#/collections/settings/entries/hotel_locations"]').click(); + + // add first city list item + cy.contains('button', 'hotel locations').click(); + cy.contains('button', 'cities').click(); + cy.contains('label', 'City') + .next() + .type('Washington DC'); + cy.contains('label', 'Number of Hotels in City') + .next() + .type('5'); + cy.contains('button', 'city locations').click(); + + // add second city list item + cy.contains('button', 'cities').click(); + cy.contains('label', 'Cities') + .next() + .find('div[class*=ListControl]') + .eq(2) + .as('secondCitiesListControl'); + cy.get('@secondCitiesListControl') + .contains('label', 'City') + .next() + .type('Boston'); + cy.get('@secondCitiesListControl') + .contains('button', 'city locations') + .click(); + cy.contains('button', 'Save').click(); + assertNotification(notifications.error.missingField); + + // assert on fields + assertFieldErrorStatus('Hotel Locations', colorError); + assertFieldErrorStatus('Cities', colorError); + assertFieldErrorStatus('City', colorNormal); + assertFieldErrorStatus('City', colorNormal, { scope: cy.get('@secondCitiesListControl') }); + assertFieldErrorStatus('Number of Hotels in City', colorNormal); + assertFieldErrorStatus('Number of Hotels in City', colorError, { + scope: cy.get('@secondCitiesListControl'), + }); + assertFieldErrorStatus('City Locations', colorError); + assertFieldErrorStatus('City Locations', colorError, { + scope: cy.get('@secondCitiesListControl'), + }); + assertFieldErrorStatus('Hotel Name', colorError); + assertFieldErrorStatus('Hotel Name', colorError, { scope: cy.get('@secondCitiesListControl') }); + + // list control aliases + cy.contains('label', 'Hotel Locations') + .next() + .find('div[class*=ListControl]') + .first() + .as('hotelLocationsListControl'); + cy.contains('label', 'Cities') + .next() + .find('div[class*=ListControl]') + .eq(0) + .as('firstCitiesListControl'); + cy.contains('label', 'City Locations') + .next() + .find('div[class*=ListControl]') + .eq(0) + .as('firstCityLocationsListControl'); + cy.contains('label', 'Cities') + .next() + .find('div[class*=ListControl]') + .eq(3) + .as('secondCityLocationsListControl'); + + // assert on list controls + assertListControlErrorStatus([colorError, colorError], '@hotelLocationsListControl'); + assertListControlErrorStatus([colorError, colorError], '@firstCitiesListControl'); + assertListControlErrorStatus([colorError, colorError], '@secondCitiesListControl'); + assertListControlErrorStatus([colorError, colorError], '@firstCityLocationsListControl'); + assertListControlErrorStatus([colorError, colorError], '@secondCityLocationsListControl'); + + cy.contains('label', 'Hotel Name') + .next() + .type('The Ritz Carlton'); + cy.contains('button', 'Save').click(); + assertNotification(notifications.error.missingField); + assertListControlErrorStatus([colorNormal, textColorNormal], '@firstCitiesListControl'); + + // fill out rest of form and save + cy.get('@secondCitiesListControl') + .contains('label', 'Number of Hotels in City') + .type(3); + cy.get('@secondCitiesListControl') + .contains('label', 'Hotel Name') + .type('Grand Hyatt'); + cy.contains('label', 'Country') + .next() + .type('United States'); + flushClockAndSave(); + assertNotification(notifications.saved); } function validateObjectFieldsAndExit(setting) { @@ -439,10 +588,53 @@ function validateListFieldsAndExit(setting) { exitEditor(); } +function validateNestedListFieldsAndExit(setting) { + validateNestedListFields(setting); + exitEditor(); +} + function assertFieldValidationError({ message, fieldLabel }) { cy.contains('label', fieldLabel) .siblings('ul[class*=ControlErrorsList]') .contains(message); + assertFieldErrorStatus(fieldLabel, colorError); +} + +function assertFieldErrorStatus(label, color, opts = { isMarkdown: false }) { + assertColorOn('background-color', color, { + type: 'label', + label, + scope: opts.scope, + isMarkdown: opts.isMarkdown, + }); + assertColorOn('border-color', color, { + type: 'field', + label, + scope: opts.scope, + isMarkdown: opts.isMarkdown, + }); +} + +function assertListControlErrorStatus(colors = ['', ''], alias) { + cy.get(alias).within(() => { + // assert list item border has correct color + assertColorOn('border-right-color', colors[0], { + el: cy + .root() + .children() + .eq(2), + }); + // collapse list item + cy.get('button[class*=TopBarButton-button]') + .first() + .click(); + // assert list item label text has correct color + assertColorOn('color', colors[1], { el: cy.get('div[class*=NestedObjectLabel]').first() }); + // uncollapse list item + cy.get('button[class*=TopBarButton-button]') + .first() + .click(); + }); } module.exports = { @@ -468,6 +660,7 @@ module.exports = { validateObjectFieldsAndExit, validateNestedObjectFieldsAndExit, validateListFieldsAndExit, + validateNestedListFieldsAndExit, unpublishEntry, publishEntryInEditor, duplicateEntry, diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index 55a178c7..9147a719 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -349,7 +349,7 @@ export function changeDraftField( export function changeDraftFieldValidation( uniquefieldId: string, - errors: { type: string; message: string }[], + errors: { type: string; parentIds: string[]; message: string }[], ) { return { type: DRAFT_VALIDATION_ERRORS, 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 09b808f4..6f2516a6 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -115,6 +115,11 @@ class EditorControl extends React.Component { t: PropTypes.func.isRequired, isEditorComponent: PropTypes.bool, isNewEditorComponent: PropTypes.bool, + parentIds: PropTypes.arrayOf(PropTypes.string), + }; + + static defaultProps = { + parentIds: [], }; state = { @@ -123,6 +128,17 @@ class EditorControl extends React.Component { uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`); + isAncestorOfFieldError = () => { + const { fieldsErrors } = this.props; + + if (fieldsErrors && fieldsErrors.size > 0) { + return Object.values(fieldsErrors.toJS()).some(arr => + arr.some(err => err.parentIds && err.parentIds.includes(this.uniqueFieldId)), + ); + } + return false; + }; + render() { const { value, @@ -153,6 +169,7 @@ class EditorControl extends React.Component { isSelected, isEditorComponent, isNewEditorComponent, + parentIds, t, } = this.props; @@ -164,6 +181,9 @@ class EditorControl extends React.Component { const onValidateObject = onValidate; const metadata = fieldsMetaData && fieldsMetaData.get(fieldName); const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId); + const childErrors = this.isAncestorOfFieldError(); + const hasErrors = !!errors || childErrors; + return ( {({ css, cx }) => ( @@ -184,7 +204,7 @@ class EditorControl extends React.Component { )} {`${field.get('label', field.get('name'))}${ @@ -204,7 +224,7 @@ class EditorControl extends React.Component { { [css` ${styleStrings.widgetError}; - `]: !!errors, + `]: hasErrors, }, )} classNameWidget={css` @@ -255,10 +275,11 @@ class EditorControl extends React.Component { onValidateObject={onValidateObject} isEditorComponent={isEditorComponent} isNewEditorComponent={isNewEditorComponent} + parentIds={parentIds} t={t} /> {fieldHint && ( - + {fieldHint} )} 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 a7e7862e..85ed4776 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js @@ -118,11 +118,12 @@ export default class Widget extends Component { }; validatePresence = (field, value) => { - const t = this.props.t; + const { t, parentIds } = this.props; const isRequired = field.get('required', true); if (isRequired && isEmpty(value)) { const error = { type: ValidationErrorTypes.PRESENCE, + parentIds, message: t('editor.editorControlPane.widget.required', { fieldLabel: field.get('label', field.get('name')), }), @@ -134,7 +135,7 @@ export default class Widget extends Component { }; validatePattern = (field, value) => { - const t = this.props.t; + const { t, parentIds } = this.props; const pattern = field.get('pattern', false); if (isEmpty(value)) { @@ -144,6 +145,7 @@ export default class Widget extends Component { if (pattern && !RegExp(pattern.first()).test(value)) { const error = { type: ValidationErrorTypes.PATTERN, + parentIds, message: t('editor.editorControlPane.widget.regexPattern', { fieldLabel: field.get('label', field.get('name')), pattern: pattern.last(), @@ -157,7 +159,7 @@ export default class Widget extends Component { }; validateWrappedControl = field => { - const t = this.props.t; + const { t, parentIds } = this.props; if (typeof this.wrappedControlValid !== 'function') { throw new Error(oneLine` this.wrappedControlValid is not a function. Are you sure widget @@ -188,6 +190,7 @@ export default class Widget extends Component { const error = { type: ValidationErrorTypes.CUSTOM, + parentIds, message: t('editor.editorControlPane.widget.processing', { fieldLabel: field.get('label', field.get('name')), }), @@ -257,6 +260,7 @@ export default class Widget extends Component { controlRef, isEditorComponent, isNewEditorComponent, + parentIds, t, } = this.props; return React.createElement(controlComponent, { @@ -301,6 +305,7 @@ export default class Widget extends Component { isNewEditorComponent, fieldsErrors, controlRef, + parentIds, t, }); } diff --git a/packages/netlify-cms-widget-list/src/ListControl.js b/packages/netlify-cms-widget-list/src/ListControl.js index c743dc9c..e8956b20 100644 --- a/packages/netlify-cms-widget-list/src/ListControl.js +++ b/packages/netlify-cms-widget-list/src/ListControl.js @@ -110,6 +110,7 @@ export default class ListControl extends React.Component { static defaultProps = { value: List(), + parentIds: [], }; constructor(props) { @@ -410,6 +411,15 @@ export default class ListControl extends React.Component { this.validations = this.validations.filter(item => updatedKeys.includes(item.key)); }; + hasError = index => { + const { fieldsErrors } = this.props; + if (fieldsErrors && fieldsErrors.size > 0) { + return Object.values(fieldsErrors.toJS()).some(arr => + arr.some(err => err.parentIds && err.parentIds.includes(this.state.keys[index])), + ); + } + }; + // eslint-disable-next-line react/display-name renderItem = (item, index) => { const { @@ -421,12 +431,15 @@ export default class ListControl extends React.Component { fieldsErrors, controlRef, resolveWidget, + parentIds, + forID, } = this.props; const { itemsCollapsed, keys } = this.state; const collapsed = itemsCollapsed[index]; const key = keys[index]; let field = this.props.field; + const hasError = this.hasError(index); if (this.getValueType() === valueTypes.MIXED) { field = getTypedFieldForValue(field, item); @@ -448,7 +461,9 @@ export default class ListControl extends React.Component { dragHandleHOC={SortableHandle} data-testid={`styled-list-item-top-bar-${key}`} /> - {this.objectLabel(item)} + + {this.objectLabel(item)} + {({ css, cx }) => ( )} diff --git a/packages/netlify-cms-widget-list/src/__tests__/ListControl.spec.js b/packages/netlify-cms-widget-list/src/__tests__/ListControl.spec.js index 9da67098..081274e2 100644 --- a/packages/netlify-cms-widget-list/src/__tests__/ListControl.spec.js +++ b/packages/netlify-cms-widget-list/src/__tests__/ListControl.spec.js @@ -52,6 +52,7 @@ describe('ListControl', () => { entry: fromJS({ path: 'posts/index.md', }), + forID: 'forID', }; beforeEach(() => { diff --git a/packages/netlify-cms-widget-list/src/__tests__/__snapshots__/ListControl.spec.js.snap b/packages/netlify-cms-widget-list/src/__tests__/__snapshots__/ListControl.spec.js.snap index 4179fb63..3bbfd45f 100644 --- a/packages/netlify-cms-widget-list/src/__tests__/__snapshots__/ListControl.spec.js.snap +++ b/packages/netlify-cms-widget-list/src/__tests__/__snapshots__/ListControl.spec.js.snap @@ -148,6 +148,7 @@ exports[`ListControl should add to list when add button is clicked 1`] = `
@@ -375,6 +377,7 @@ exports[`ListControl should remove from list when remove button is clicked 1`] =
@@ -473,6 +477,7 @@ exports[`ListControl should remove from list when remove button is clicked 1`] = field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="true" + parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" /> @@ -631,6 +636,7 @@ exports[`ListControl should remove from list when remove button is clicked 2`] =
@@ -714,6 +721,7 @@ exports[`ListControl should render empty list 1`] = ` @@ -868,6 +876,7 @@ exports[`ListControl should render list with fields with collapse = "false" and
@@ -966,6 +976,7 @@ exports[`ListControl should render list with fields with collapse = "false" and field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="true" + parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" /> @@ -1123,6 +1134,7 @@ exports[`ListControl should render list with fields with collapse = "false" and
@@ -1221,6 +1234,7 @@ exports[`ListControl should render list with fields with collapse = "false" and field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="true" + parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" /> @@ -1379,6 +1393,7 @@ exports[`ListControl should render list with fields with default collapse ("true
@@ -1477,6 +1493,7 @@ exports[`ListControl should render list with fields with default collapse ("true field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="true" + parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" /> @@ -1617,6 +1634,7 @@ exports[`ListControl should render list with fields with default collapse ("true
@@ -1913,6 +1933,7 @@ exports[`ListControl should render list with nested object 1`] = ` field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"field\\": Map { \\"name\\": \\"object\\", \\"widget\\": \\"object\\", \\"label\\": \\"Object\\", \\"fields\\": List [ Map { \\"name\\": \\"title\\", \\"widget\\": \\"string\\", \\"label\\": \\"Title\\" } ] } }" fieldserrors="Map {}" forlist="true" + parentids="forID,1" validationkey="1" value="Map { \\"object\\": Map { \\"title\\": \\"item 2\\" } }" /> @@ -2070,6 +2091,7 @@ exports[`ListControl should render list with nested object with collapse = false
@@ -2168,6 +2191,7 @@ exports[`ListControl should render list with nested object with collapse = false field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"field\\": Map { \\"name\\": \\"object\\", \\"widget\\": \\"object\\", \\"label\\": \\"Object\\", \\"fields\\": List [ Map { \\"name\\": \\"title\\", \\"widget\\": \\"string\\", \\"label\\": \\"Title\\" } ] } }" fieldserrors="Map {}" forlist="true" + parentids="forID,1" validationkey="1" value="Map { \\"object\\": Map { \\"title\\": \\"item 2\\" } }" /> @@ -2181,6 +2205,7 @@ exports[`ListControl should render list with string array 1`] = ` diff --git a/packages/netlify-cms-widget-object/src/ObjectControl.js b/packages/netlify-cms-widget-object/src/ObjectControl.js index 33ed2bf3..3b1eb596 100644 --- a/packages/netlify-cms-widget-object/src/ObjectControl.js +++ b/packages/netlify-cms-widget-object/src/ObjectControl.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { ClassNames } from '@emotion/core'; import { Map, List } from 'immutable'; -import { ObjectWidgetTopBar, lengths } from 'netlify-cms-ui-default'; +import { ObjectWidgetTopBar, lengths, colors } from 'netlify-cms-ui-default'; const styleStrings = { nestedObjectControl: ` @@ -36,6 +36,7 @@ export default class ObjectControl extends React.Component { resolveWidget: PropTypes.func.isRequired, clearFieldErrors: PropTypes.func.isRequired, fieldsErrors: ImmutablePropTypes.map.isRequired, + hasError: PropTypes.bool, }; static defaultProps = { @@ -79,6 +80,7 @@ export default class ObjectControl extends React.Component { fieldsErrors, editorControl: EditorControl, controlRef, + parentIds, } = this.props; if (field.get('widget') === 'hidden') { @@ -99,6 +101,7 @@ export default class ObjectControl extends React.Component { onValidate={onValidateObject} processControlRef={controlRef && controlRef.bind(this)} controlRef={controlRef} + parentIds={parentIds} /> ); } @@ -115,7 +118,7 @@ export default class ObjectControl extends React.Component { }; render() { - const { field, forID, classNameWrapper, forList } = this.props; + const { field, forID, classNameWrapper, forList, hasError } = this.props; const collapsed = forList ? this.props.collapsed : this.state.collapsed; const multiFields = field.get('fields'); const singleField = field.get('field'); @@ -136,6 +139,11 @@ export default class ObjectControl extends React.Component { ${styleStrings.nestedObjectControl} `]: forList, }, + { + [css` + border-color: ${colors.textFieldBorder}; + `]: forList ? !hasError : false, + }, )} > {forList ? null : (