From 5fdfe40dd29e9e22c9ae7d6219bc057f7ea7280b Mon Sep 17 00:00:00 2001 From: KoljaTM Date: Thu, 15 Oct 2020 19:27:23 +0200 Subject: [PATCH] feat(widget-list): add min max configuration (#4394) --- packages/netlify-cms-lib-widgets/src/index.ts | 4 +- .../src/validations.ts | 28 ++++ .../src/ListControl.js | 24 ++- .../src/__tests__/ListControl.spec.js | 157 +++++++++++++++++- .../netlify-cms-widget-list/src/schema.js | 2 + .../netlify-cms-widget-select/package.json | 1 + .../src/SelectControl.js | 31 ++-- website/content/docs/widgets/list.md | 12 ++ 8 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 packages/netlify-cms-lib-widgets/src/validations.ts diff --git a/packages/netlify-cms-lib-widgets/src/index.ts b/packages/netlify-cms-lib-widgets/src/index.ts index 23526989..ea8194fc 100644 --- a/packages/netlify-cms-lib-widgets/src/index.ts +++ b/packages/netlify-cms-lib-widgets/src/index.ts @@ -1,6 +1,8 @@ import * as stringTemplate from './stringTemplate'; +import * as validations from './validations'; export const NetlifyCmsLibWidgets = { stringTemplate, + validations, }; -export { stringTemplate }; +export { stringTemplate, validations }; diff --git a/packages/netlify-cms-lib-widgets/src/validations.ts b/packages/netlify-cms-lib-widgets/src/validations.ts new file mode 100644 index 00000000..f194d55a --- /dev/null +++ b/packages/netlify-cms-lib-widgets/src/validations.ts @@ -0,0 +1,28 @@ +import { isNumber } from 'lodash'; +import { List } from 'immutable'; + +export const validateMinMax = ( + t: (key: string, options: unknown) => string, + fieldLabel: string, + value?: List, + min?: number, + max?: number, +) => { + const minMaxError = (messageKey: string) => ({ + type: 'RANGE', + message: t(`editor.editorControlPane.widget.${messageKey}`, { + fieldLabel, + minCount: min, + maxCount: max, + count: min, + }), + }); + + if ([min, max, value?.size].every(isNumber) && (value!.size < min! || value!.size > max!)) { + return minMaxError(min === max ? 'rangeCountExact' : 'rangeCount'); + } else if (isNumber(min) && min > 0 && value?.size && value.size < min) { + return minMaxError('rangeMin'); + } else if (isNumber(max) && value?.size && value.size > max) { + return minMaxError('rangeMax'); + } +}; diff --git a/packages/netlify-cms-widget-list/src/ListControl.js b/packages/netlify-cms-widget-list/src/ListControl.js index 1fe814be..fffbfbe8 100644 --- a/packages/netlify-cms-widget-list/src/ListControl.js +++ b/packages/netlify-cms-widget-list/src/ListControl.js @@ -15,7 +15,7 @@ import { getErrorMessageForTypedFieldAndValue, } from './typedListHelpers'; import { ListItemTopBar, ObjectWidgetTopBar, colors, lengths } from 'netlify-cms-ui-default'; -import { stringTemplate } from 'netlify-cms-lib-widgets'; +import { stringTemplate, validations } from 'netlify-cms-lib-widgets'; function valueToString(value) { return value ? value.join(',').replace(/,([^\s]|$)/g, ', $1') : ''; @@ -261,6 +261,28 @@ export default class ListControl extends React.Component { } else { this.props.validate(); } + this.props.onValidateObject(this.props.forID, this.validateSize()); + }; + + validateSize = () => { + const { field, value, t } = this.props; + const min = field.get('min'); + const max = field.get('max'); + const required = field.get('required', true); + + if (!required && !value?.size) { + return []; + } + + const error = validations.validateMinMax( + t, + field.get('label', field.get('name')), + value, + min, + max, + ); + + return error ? [error] : []; }; /** 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 081274e2..acd54baa 100644 --- a/packages/netlify-cms-widget-list/src/__tests__/ListControl.spec.js +++ b/packages/netlify-cms-widget-list/src/__tests__/ListControl.spec.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import { fromJS } from 'immutable'; import ListControl from '../ListControl'; @@ -53,6 +53,7 @@ describe('ListControl', () => { path: 'posts/index.md', }), forID: 'forID', + t: key => key, }; beforeEach(() => { @@ -629,4 +630,158 @@ describe('ListControl', () => { mock.mockRestore(); } }); + + it('should give validation error if below min elements', () => { + const field = fromJS({ + name: 'list', + label: 'List', + collapsed: false, + minimize_collapsed: true, + required: true, + min: 2, + max: 3, + fields: [{ label: 'String', name: 'string', widget: 'string' }], + }); + const listControl = new ListControl({ + ...props, + field, + value: fromJS([{ string: 'item 1' }]), + }); + + listControl.validate(); + expect(props.onValidateObject).toHaveBeenCalledWith('forID', [ + { + message: 'editor.editorControlPane.widget.rangeCount', + type: 'RANGE', + }, + ]); + }); + + it('should give min validation error if below min elements', () => { + const field = fromJS({ + name: 'list', + label: 'List', + collapsed: false, + minimize_collapsed: true, + required: true, + min: 2, + fields: [{ label: 'String', name: 'string', widget: 'string' }], + }); + const listControl = new ListControl({ + ...props, + field, + value: fromJS([{ string: 'item 1' }]), + }); + + listControl.validate(); + expect(props.onValidateObject).toHaveBeenCalledWith('forID', [ + { + message: 'editor.editorControlPane.widget.rangeMin', + type: 'RANGE', + }, + ]); + }); + + it('should give validation error if above max elements', () => { + const field = fromJS({ + name: 'list', + label: 'List', + collapsed: false, + minimize_collapsed: true, + required: true, + min: 2, + max: 3, + fields: [{ label: 'String', name: 'string', widget: 'string' }], + }); + const listControl = new ListControl({ + ...props, + field, + value: fromJS([ + { string: 'item 1' }, + { string: 'item 2' }, + { string: 'item 3' }, + { string: 'item 4' }, + ]), + }); + + listControl.validate(); + expect(props.onValidateObject).toHaveBeenCalledWith('forID', [ + { + message: 'editor.editorControlPane.widget.rangeCount', + type: 'RANGE', + }, + ]); + }); + + it('should give max validation error if above max elements', () => { + const field = fromJS({ + name: 'list', + label: 'List', + collapsed: false, + minimize_collapsed: true, + required: true, + max: 3, + fields: [{ label: 'String', name: 'string', widget: 'string' }], + }); + const listControl = new ListControl({ + ...props, + field, + value: fromJS([ + { string: 'item 1' }, + { string: 'item 2' }, + { string: 'item 3' }, + { string: 'item 4' }, + ]), + }); + + listControl.validate(); + expect(props.onValidateObject).toHaveBeenCalledWith('forID', [ + { + message: 'editor.editorControlPane.widget.rangeMax', + type: 'RANGE', + }, + ]); + }); + + it('should give no validation error if between min and max elements', () => { + const field = fromJS({ + name: 'list', + label: 'List', + collapsed: false, + minimize_collapsed: true, + required: true, + min: 2, + max: 3, + fields: [{ label: 'String', name: 'string', widget: 'string' }], + }); + const listControl = new ListControl({ + ...props, + field, + value: fromJS([{ string: 'item 1' }, { string: 'item 2' }, { string: 'item 3' }]), + }); + + listControl.validate(); + expect(props.onValidateObject).toHaveBeenCalledWith('forID', []); + }); + + it('should give no validation error if no elements and optional', () => { + const field = fromJS({ + name: 'list', + label: 'List', + collapsed: false, + minimize_collapsed: true, + required: false, + min: 2, + max: 3, + fields: [{ label: 'String', name: 'string', widget: 'string' }], + }); + const listControl = new ListControl({ + ...props, + field, + value: fromJS([]), + }); + + listControl.validate(); + expect(props.onValidateObject).toHaveBeenCalledWith('forID', []); + }); }); diff --git a/packages/netlify-cms-widget-list/src/schema.js b/packages/netlify-cms-widget-list/src/schema.js index f868b0f6..9e0db2fa 100644 --- a/packages/netlify-cms-widget-list/src/schema.js +++ b/packages/netlify-cms-widget-list/src/schema.js @@ -6,5 +6,7 @@ export default { minimize_collapsed: { type: 'boolean' }, label_singular: { type: 'string' }, i18n: { type: 'boolean' }, + min: { type: 'number' }, + max: { type: 'number' }, }, }; diff --git a/packages/netlify-cms-widget-select/package.json b/packages/netlify-cms-widget-select/package.json index fdec70b9..9d9a65d0 100644 --- a/packages/netlify-cms-widget-select/package.json +++ b/packages/netlify-cms-widget-select/package.json @@ -24,6 +24,7 @@ }, "peerDependencies": { "immutable": "^3.7.6", + "netlify-cms-lib-widgets": "^1.0.0", "netlify-cms-ui-default": "^2.6.0", "prop-types": "^15.7.2", "react": "^16.8.4", diff --git a/packages/netlify-cms-widget-select/src/SelectControl.js b/packages/netlify-cms-widget-select/src/SelectControl.js index 1839aaad..1aef472f 100644 --- a/packages/netlify-cms-widget-select/src/SelectControl.js +++ b/packages/netlify-cms-widget-select/src/SelectControl.js @@ -2,9 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { Map, List, fromJS } from 'immutable'; -import { find, isNumber } from 'lodash'; +import { find } from 'lodash'; import Select from 'react-select'; import { reactSelectStyles } from 'netlify-cms-ui-default'; +import { validations } from 'netlify-cms-lib-widgets'; function optionToString(option) { return option && option.value ? option.value : null; @@ -59,28 +60,20 @@ export default class SelectControl extends React.Component { const { field, value, t } = this.props; const min = field.get('min'); const max = field.get('max'); - const minMaxError = messageKey => ({ - error: { - message: t(`editor.editorControlPane.widget.${messageKey}`, { - fieldLabel: field.get('label', field.get('name')), - minCount: min, - maxCount: max, - count: min, - }), - }, - }); if (!field.get('multiple')) { return { error: false }; } - if ([min, max].every(isNumber) && value?.size && (value.size < min || value.size > max)) { - return minMaxError(min === max ? 'rangeCountExact' : 'rangeCount'); - } else if (isNumber(min) && min > 0 && value?.size && value.size < min) { - return minMaxError('rangeMin'); - } else if (isNumber(max) && value?.size && value.size > max) { - return minMaxError('rangeMax'); - } - return { error: false }; + + const error = validations.validateMinMax( + t, + field.get('label', field.get('name')), + value, + min, + max, + ); + + return error ? { error } : { error: false }; }; handleChange = selectedOption => { diff --git a/website/content/docs/widgets/list.md b/website/content/docs/widgets/list.md index d49d0429..224f2657 100644 --- a/website/content/docs/widgets/list.md +++ b/website/content/docs/widgets/list.md @@ -17,6 +17,8 @@ The list widget allows you to create a repeatable item in the UI which saves as * `label_singular`: the text to show on the add button * `field`: a single widget field to be repeated * `fields`: a nested list of multiple widget fields to be included in each repeatable iteration + * `max`: maximum number of items in the list + * `min`: minimum number of items in the list * **Example** (`field`/`fields` not specified): ```yaml @@ -80,4 +82,14 @@ The list widget allows you to create a repeatable item in the UI which saves as fields: - {label: Quote, name: quote, widget: string, default: "Everything is awesome!"} - {label: Author, name: author, widget: string } + ``` + +* **Example** (with `max` & `min`): + ```yaml + - label: "Tags" + name: "tags" + widget: "list" + max: 3 + min: 1 + default: ["news"] ``` \ No newline at end of file