fix: Error UI improvements for nested lists/objects (#3726)

This commit is contained in:
Kevin Young 2020-05-25 01:42:54 -05:00 committed by GitHub
parent 2ecafd3354
commit 397857855b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 402 additions and 34 deletions

View File

@ -15,9 +15,6 @@ import {
assertEntryDeleted, assertEntryDeleted,
assertWorkflowStatus, assertWorkflowStatus,
updateWorkflowStatusInEditor, updateWorkflowStatusInEditor,
validateObjectFieldsAndExit,
validateNestedObjectFieldsAndExit,
validateListFieldsAndExit,
unpublishEntry, unpublishEntry,
publishEntryInEditor, publishEntryInEditor,
duplicateEntry, duplicateEntry,
@ -26,7 +23,7 @@ import {
publishAndCreateNewEntryInEditor, publishAndCreateNewEntryInEditor,
publishAndDuplicateEntryInEditor, publishAndDuplicateEntryInEditor,
} from '../utils/steps'; } from '../utils/steps';
import { setting1, setting2, workflowStatus, editorStatus, publishTypes } from '../utils/constants'; import { workflowStatus, editorStatus, publishTypes } from '../utils/constants';
const entry1 = { const entry1 = {
title: 'first title', title: 'first title',
@ -74,21 +71,6 @@ describe('Test Backend Editorial Workflow', () => {
exitEditor(); 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', () => { it('can publish an editorial workflow entry', () => {
login(); login();
createPostAndExit(entry1); createPostAndExit(entry1);

View File

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

View File

@ -3,6 +3,10 @@ const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' };
const setting1 = { limit: 10, author: 'John Doe' }; const setting1 = { limit: 10, author: 'John Doe' };
const setting2 = { name: 'Jane Doe', description: 'description' }; const setting2 = { name: 'Jane Doe', description: 'description' };
const publishTypes = { publishNow: 'Publish now', publishAndCreateNew: 'Publish and create new', publishAndDuplicate: 'Publish and duplicate' }; 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 = { const notifications = {
saved: 'Entry saved', saved: 'Entry saved',
published: 'Entry published', published: 'Entry published',
@ -40,5 +44,8 @@ module.exports = {
setting2, setting2,
notifications, notifications,
publishTypes, publishTypes,
HOT_KEY_MAP HOT_KEY_MAP,
colorError,
colorNormal,
textColorNormal,
}; };

View File

@ -1,4 +1,12 @@
const { notifications, workflowStatus, editorStatus, publishTypes } = require('./constants'); const {
notifications,
workflowStatus,
editorStatus,
publishTypes,
colorError,
colorNormal,
textColorNormal,
} = require('./constants');
function login(user) { function login(user) {
cy.viewport(1200, 1200); 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() { function exitEditor() {
cy.contains('a[href^="#/collections/"]', 'Writing in').click(); cy.contains('a[href^="#/collections/"]', 'Writing in').click();
} }
@ -204,9 +241,6 @@ function flushClockAndSave() {
cy.wait(500); cy.wait(500);
} }
cy.get('input')
.first()
.click();
cy.contains('button', 'Save').click(); cy.contains('button', 'Save').click();
assertNotification(notifications.saved); assertNotification(notifications.saved);
}); });
@ -381,10 +415,12 @@ function validateObjectFields({ limit, author }) {
cy.get('input[type=number]').type(limit); cy.get('input[type=number]').type(limit);
cy.contains('button', 'Save').click(); cy.contains('button', 'Save').click();
assertNotification(notifications.error.missingField); assertNotification(notifications.error.missingField);
assertFieldErrorStatus('Default Author', colorError);
cy.contains('label', 'Default Author').click(); cy.contains('label', 'Default Author').click();
cy.focused().type(author); cy.focused().type(author);
cy.contains('button', 'Save').click(); cy.contains('button', 'Save').click();
assertNotification(notifications.saved); assertNotification(notifications.saved);
assertFieldErrorStatus('Default Author', colorNormal);
} }
function validateNestedObjectFields({ limit, author }) { function validateNestedObjectFields({ limit, author }) {
@ -415,13 +451,126 @@ function validateListFields({ name, description }) {
cy.contains('button', 'Add').click(); cy.contains('button', 'Add').click();
cy.contains('button', 'Save').click(); cy.contains('button', 'Save').click();
assertNotification(notifications.error.missingField); 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') cy.get('input')
.eq(2) .eq(2)
.type(name); .type(name);
cy.getMarkdownEditor() cy.getMarkdownEditor()
.eq(2) .eq(2)
.type(description); .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(); 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) { function validateObjectFieldsAndExit(setting) {
@ -439,10 +588,53 @@ function validateListFieldsAndExit(setting) {
exitEditor(); exitEditor();
} }
function validateNestedListFieldsAndExit(setting) {
validateNestedListFields(setting);
exitEditor();
}
function assertFieldValidationError({ message, fieldLabel }) { function assertFieldValidationError({ message, fieldLabel }) {
cy.contains('label', fieldLabel) cy.contains('label', fieldLabel)
.siblings('ul[class*=ControlErrorsList]') .siblings('ul[class*=ControlErrorsList]')
.contains(message); .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 = { module.exports = {
@ -468,6 +660,7 @@ module.exports = {
validateObjectFieldsAndExit, validateObjectFieldsAndExit,
validateNestedObjectFieldsAndExit, validateNestedObjectFieldsAndExit,
validateListFieldsAndExit, validateListFieldsAndExit,
validateNestedListFieldsAndExit,
unpublishEntry, unpublishEntry,
publishEntryInEditor, publishEntryInEditor,
duplicateEntry, duplicateEntry,

View File

@ -349,7 +349,7 @@ export function changeDraftField(
export function changeDraftFieldValidation( export function changeDraftFieldValidation(
uniquefieldId: string, uniquefieldId: string,
errors: { type: string; message: string }[], errors: { type: string; parentIds: string[]; message: string }[],
) { ) {
return { return {
type: DRAFT_VALIDATION_ERRORS, type: DRAFT_VALIDATION_ERRORS,

View File

@ -115,6 +115,11 @@ class EditorControl extends React.Component {
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
isEditorComponent: PropTypes.bool, isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool, isNewEditorComponent: PropTypes.bool,
parentIds: PropTypes.arrayOf(PropTypes.string),
};
static defaultProps = {
parentIds: [],
}; };
state = { state = {
@ -123,6 +128,17 @@ class EditorControl extends React.Component {
uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`); 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() { render() {
const { const {
value, value,
@ -153,6 +169,7 @@ class EditorControl extends React.Component {
isSelected, isSelected,
isEditorComponent, isEditorComponent,
isNewEditorComponent, isNewEditorComponent,
parentIds,
t, t,
} = this.props; } = this.props;
@ -164,6 +181,9 @@ class EditorControl extends React.Component {
const onValidateObject = onValidate; const onValidateObject = onValidate;
const metadata = fieldsMetaData && fieldsMetaData.get(fieldName); const metadata = fieldsMetaData && fieldsMetaData.get(fieldName);
const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId); const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId);
const childErrors = this.isAncestorOfFieldError();
const hasErrors = !!errors || childErrors;
return ( return (
<ClassNames> <ClassNames>
{({ css, cx }) => ( {({ css, cx }) => (
@ -184,7 +204,7 @@ class EditorControl extends React.Component {
)} )}
<FieldLabel <FieldLabel
isActive={isSelected || this.state.styleActive} isActive={isSelected || this.state.styleActive}
hasErrors={!!errors} hasErrors={hasErrors}
htmlFor={this.uniqueFieldId} htmlFor={this.uniqueFieldId}
> >
{`${field.get('label', field.get('name'))}${ {`${field.get('label', field.get('name'))}${
@ -204,7 +224,7 @@ class EditorControl extends React.Component {
{ {
[css` [css`
${styleStrings.widgetError}; ${styleStrings.widgetError};
`]: !!errors, `]: hasErrors,
}, },
)} )}
classNameWidget={css` classNameWidget={css`
@ -255,10 +275,11 @@ class EditorControl extends React.Component {
onValidateObject={onValidateObject} onValidateObject={onValidateObject}
isEditorComponent={isEditorComponent} isEditorComponent={isEditorComponent}
isNewEditorComponent={isNewEditorComponent} isNewEditorComponent={isNewEditorComponent}
parentIds={parentIds}
t={t} t={t}
/> />
{fieldHint && ( {fieldHint && (
<ControlHint active={isSelected || this.state.styleActive} error={!!errors}> <ControlHint active={isSelected || this.state.styleActive} error={hasErrors}>
{fieldHint} {fieldHint}
</ControlHint> </ControlHint>
)} )}

View File

@ -118,11 +118,12 @@ export default class Widget extends Component {
}; };
validatePresence = (field, value) => { validatePresence = (field, value) => {
const t = this.props.t; const { t, parentIds } = this.props;
const isRequired = field.get('required', true); const isRequired = field.get('required', true);
if (isRequired && isEmpty(value)) { if (isRequired && isEmpty(value)) {
const error = { const error = {
type: ValidationErrorTypes.PRESENCE, type: ValidationErrorTypes.PRESENCE,
parentIds,
message: t('editor.editorControlPane.widget.required', { message: t('editor.editorControlPane.widget.required', {
fieldLabel: field.get('label', field.get('name')), fieldLabel: field.get('label', field.get('name')),
}), }),
@ -134,7 +135,7 @@ export default class Widget extends Component {
}; };
validatePattern = (field, value) => { validatePattern = (field, value) => {
const t = this.props.t; const { t, parentIds } = this.props;
const pattern = field.get('pattern', false); const pattern = field.get('pattern', false);
if (isEmpty(value)) { if (isEmpty(value)) {
@ -144,6 +145,7 @@ export default class Widget extends Component {
if (pattern && !RegExp(pattern.first()).test(value)) { if (pattern && !RegExp(pattern.first()).test(value)) {
const error = { const error = {
type: ValidationErrorTypes.PATTERN, type: ValidationErrorTypes.PATTERN,
parentIds,
message: t('editor.editorControlPane.widget.regexPattern', { message: t('editor.editorControlPane.widget.regexPattern', {
fieldLabel: field.get('label', field.get('name')), fieldLabel: field.get('label', field.get('name')),
pattern: pattern.last(), pattern: pattern.last(),
@ -157,7 +159,7 @@ export default class Widget extends Component {
}; };
validateWrappedControl = field => { validateWrappedControl = field => {
const t = this.props.t; const { t, parentIds } = this.props;
if (typeof this.wrappedControlValid !== 'function') { if (typeof this.wrappedControlValid !== 'function') {
throw new Error(oneLine` throw new Error(oneLine`
this.wrappedControlValid is not a function. Are you sure widget this.wrappedControlValid is not a function. Are you sure widget
@ -188,6 +190,7 @@ export default class Widget extends Component {
const error = { const error = {
type: ValidationErrorTypes.CUSTOM, type: ValidationErrorTypes.CUSTOM,
parentIds,
message: t('editor.editorControlPane.widget.processing', { message: t('editor.editorControlPane.widget.processing', {
fieldLabel: field.get('label', field.get('name')), fieldLabel: field.get('label', field.get('name')),
}), }),
@ -257,6 +260,7 @@ export default class Widget extends Component {
controlRef, controlRef,
isEditorComponent, isEditorComponent,
isNewEditorComponent, isNewEditorComponent,
parentIds,
t, t,
} = this.props; } = this.props;
return React.createElement(controlComponent, { return React.createElement(controlComponent, {
@ -301,6 +305,7 @@ export default class Widget extends Component {
isNewEditorComponent, isNewEditorComponent,
fieldsErrors, fieldsErrors,
controlRef, controlRef,
parentIds,
t, t,
}); });
} }

View File

@ -110,6 +110,7 @@ export default class ListControl extends React.Component {
static defaultProps = { static defaultProps = {
value: List(), value: List(),
parentIds: [],
}; };
constructor(props) { constructor(props) {
@ -410,6 +411,15 @@ export default class ListControl extends React.Component {
this.validations = this.validations.filter(item => updatedKeys.includes(item.key)); 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 // eslint-disable-next-line react/display-name
renderItem = (item, index) => { renderItem = (item, index) => {
const { const {
@ -421,12 +431,15 @@ export default class ListControl extends React.Component {
fieldsErrors, fieldsErrors,
controlRef, controlRef,
resolveWidget, resolveWidget,
parentIds,
forID,
} = this.props; } = this.props;
const { itemsCollapsed, keys } = this.state; const { itemsCollapsed, keys } = this.state;
const collapsed = itemsCollapsed[index]; const collapsed = itemsCollapsed[index];
const key = keys[index]; const key = keys[index];
let field = this.props.field; let field = this.props.field;
const hasError = this.hasError(index);
if (this.getValueType() === valueTypes.MIXED) { if (this.getValueType() === valueTypes.MIXED) {
field = getTypedFieldForValue(field, item); field = getTypedFieldForValue(field, item);
@ -448,7 +461,9 @@ export default class ListControl extends React.Component {
dragHandleHOC={SortableHandle} dragHandleHOC={SortableHandle}
data-testid={`styled-list-item-top-bar-${key}`} data-testid={`styled-list-item-top-bar-${key}`}
/> />
<NestedObjectLabel collapsed={collapsed}>{this.objectLabel(item)}</NestedObjectLabel> <NestedObjectLabel collapsed={collapsed} error={hasError}>
{this.objectLabel(item)}
</NestedObjectLabel>
<ClassNames> <ClassNames>
{({ css, cx }) => ( {({ css, cx }) => (
<ObjectControl <ObjectControl
@ -472,6 +487,8 @@ export default class ListControl extends React.Component {
validationKey={key} validationKey={key}
collapsed={collapsed} collapsed={collapsed}
data-testid={`object-control-${key}`} data-testid={`object-control-${key}`}
hasError={hasError}
parentIds={[...parentIds, forID, key]}
/> />
)} )}
</ClassNames> </ClassNames>

View File

@ -52,6 +52,7 @@ describe('ListControl', () => {
entry: fromJS({ entry: fromJS({
path: 'posts/index.md', path: 'posts/index.md',
}), }),
forID: 'forID',
}; };
beforeEach(() => { beforeEach(() => {

View File

@ -148,6 +148,7 @@ exports[`ListControl should add to list when add button is clicked 1`] = `
<div <div
class="classNameWrapper emotion-18" class="classNameWrapper emotion-18"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -218,6 +219,7 @@ exports[`ListControl should add to list when add button is clicked 1`] = `
field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,0"
validationkey="0" validationkey="0"
value="Map {}" value="Map {}"
/> />
@ -375,6 +377,7 @@ exports[`ListControl should remove from list when remove button is clicked 1`] =
<div <div
class="classNameWrapper emotion-22" class="classNameWrapper emotion-22"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -445,6 +448,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\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,0"
validationkey="0" validationkey="0"
value="Map { \\"string\\": \\"item 1\\" }" value="Map { \\"string\\": \\"item 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\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,1"
validationkey="1" validationkey="1"
value="Map { \\"string\\": \\"item 2\\" }" value="Map { \\"string\\": \\"item 2\\" }"
/> />
@ -631,6 +636,7 @@ exports[`ListControl should remove from list when remove button is clicked 2`] =
<div <div
class="classNameWrapper emotion-18" class="classNameWrapper emotion-18"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -701,6 +707,7 @@ exports[`ListControl should remove from list when remove button is clicked 2`] =
field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,1"
validationkey="1" validationkey="1"
value="Map { \\"string\\": \\"item 2\\" }" value="Map { \\"string\\": \\"item 2\\" }"
/> />
@ -714,6 +721,7 @@ exports[`ListControl should render empty list 1`] = `
<DocumentFragment> <DocumentFragment>
<input <input
class="classNameWrapper" class="classNameWrapper"
id="forID"
type="text" type="text"
value="" value=""
/> />
@ -868,6 +876,7 @@ exports[`ListControl should render list with fields with collapse = "false" and
<div <div
class="classNameWrapper emotion-22" class="classNameWrapper emotion-22"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -938,6 +947,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\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,0"
validationkey="0" validationkey="0"
value="Map { \\"string\\": \\"item 1\\" }" value="Map { \\"string\\": \\"item 1\\" }"
/> />
@ -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\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,1"
validationkey="1" validationkey="1"
value="Map { \\"string\\": \\"item 2\\" }" value="Map { \\"string\\": \\"item 2\\" }"
/> />
@ -1123,6 +1134,7 @@ exports[`ListControl should render list with fields with collapse = "false" and
<div <div
class="classNameWrapper emotion-22" class="classNameWrapper emotion-22"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -1193,6 +1205,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\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,0"
validationkey="0" validationkey="0"
value="Map { \\"string\\": \\"item 1\\" }" value="Map { \\"string\\": \\"item 1\\" }"
/> />
@ -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\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,1"
validationkey="1" validationkey="1"
value="Map { \\"string\\": \\"item 2\\" }" value="Map { \\"string\\": \\"item 2\\" }"
/> />
@ -1379,6 +1393,7 @@ exports[`ListControl should render list with fields with default collapse ("true
<div <div
class="classNameWrapper emotion-22" class="classNameWrapper emotion-22"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -1449,6 +1464,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\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,0"
validationkey="0" validationkey="0"
value="Map { \\"string\\": \\"item 1\\" }" value="Map { \\"string\\": \\"item 1\\" }"
/> />
@ -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\\" } ] }" field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }"
fieldserrors="Map {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,1"
validationkey="1" validationkey="1"
value="Map { \\"string\\": \\"item 2\\" }" value="Map { \\"string\\": \\"item 2\\" }"
/> />
@ -1617,6 +1634,7 @@ exports[`ListControl should render list with fields with default collapse ("true
<div <div
class="classNameWrapper emotion-14" class="classNameWrapper emotion-14"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -1815,6 +1833,7 @@ exports[`ListControl should render list with nested object 1`] = `
<div <div
class="classNameWrapper emotion-22" class="classNameWrapper emotion-22"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -1885,6 +1904,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\\" } ] } }" 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 {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,0"
validationkey="0" validationkey="0"
value="Map { \\"object\\": Map { \\"title\\": \\"item 1\\" } }" value="Map { \\"object\\": Map { \\"title\\": \\"item 1\\" } }"
/> />
@ -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\\" } ] } }" 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 {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,1"
validationkey="1" validationkey="1"
value="Map { \\"object\\": Map { \\"title\\": \\"item 2\\" } }" value="Map { \\"object\\": Map { \\"title\\": \\"item 2\\" } }"
/> />
@ -2070,6 +2091,7 @@ exports[`ListControl should render list with nested object with collapse = false
<div <div
class="classNameWrapper emotion-22" class="classNameWrapper emotion-22"
id="forID"
> >
<div <div
class="emotion-12 emotion-13" class="emotion-12 emotion-13"
@ -2140,6 +2162,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\\" } ] } }" 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 {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,0"
validationkey="0" validationkey="0"
value="Map { \\"object\\": Map { \\"title\\": \\"item 1\\" } }" value="Map { \\"object\\": Map { \\"title\\": \\"item 1\\" } }"
/> />
@ -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\\" } ] } }" 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 {}" fieldserrors="Map {}"
forlist="true" forlist="true"
parentids="forID,1"
validationkey="1" validationkey="1"
value="Map { \\"object\\": Map { \\"title\\": \\"item 2\\" } }" value="Map { \\"object\\": Map { \\"title\\": \\"item 2\\" } }"
/> />
@ -2181,6 +2205,7 @@ exports[`ListControl should render list with string array 1`] = `
<DocumentFragment> <DocumentFragment>
<input <input
class="classNameWrapper" class="classNameWrapper"
id="forID"
type="text" type="text"
value="item 1, item 2" value="item 1, item 2"
/> />

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { ClassNames } from '@emotion/core'; import { ClassNames } from '@emotion/core';
import { Map, List } from 'immutable'; import { Map, List } from 'immutable';
import { ObjectWidgetTopBar, lengths } from 'netlify-cms-ui-default'; import { ObjectWidgetTopBar, lengths, colors } from 'netlify-cms-ui-default';
const styleStrings = { const styleStrings = {
nestedObjectControl: ` nestedObjectControl: `
@ -36,6 +36,7 @@ export default class ObjectControl extends React.Component {
resolveWidget: PropTypes.func.isRequired, resolveWidget: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired, clearFieldErrors: PropTypes.func.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired, fieldsErrors: ImmutablePropTypes.map.isRequired,
hasError: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -79,6 +80,7 @@ export default class ObjectControl extends React.Component {
fieldsErrors, fieldsErrors,
editorControl: EditorControl, editorControl: EditorControl,
controlRef, controlRef,
parentIds,
} = this.props; } = this.props;
if (field.get('widget') === 'hidden') { if (field.get('widget') === 'hidden') {
@ -99,6 +101,7 @@ export default class ObjectControl extends React.Component {
onValidate={onValidateObject} onValidate={onValidateObject}
processControlRef={controlRef && controlRef.bind(this)} processControlRef={controlRef && controlRef.bind(this)}
controlRef={controlRef} controlRef={controlRef}
parentIds={parentIds}
/> />
); );
} }
@ -115,7 +118,7 @@ export default class ObjectControl extends React.Component {
}; };
render() { 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 collapsed = forList ? this.props.collapsed : this.state.collapsed;
const multiFields = field.get('fields'); const multiFields = field.get('fields');
const singleField = field.get('field'); const singleField = field.get('field');
@ -136,6 +139,11 @@ export default class ObjectControl extends React.Component {
${styleStrings.nestedObjectControl} ${styleStrings.nestedObjectControl}
`]: forList, `]: forList,
}, },
{
[css`
border-color: ${colors.textFieldBorder};
`]: forList ? !hasError : false,
},
)} )}
> >
{forList ? null : ( {forList ? null : (