diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index 0f488489..1e01bbe6 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -52,6 +52,10 @@ const en = { range: '%{fieldLabel} must be between %{minValue} and %{maxValue}.', min: '%{fieldLabel} must be at least %{minValue}.', max: '%{fieldLabel} must be %{maxValue} or less.', + rangeCount: '%{fieldLabel} must have between %{minCount} and %{maxCount} item(s).', + rangeCountExact: '%{fieldLabel} must have exactly %{count} item(s).', + minCount: '%{fieldLabel} must be at least %{minCount} item(s).', + maxCount: '%{fieldLabel} must be %{maxCount} or less item(s).', }, }, editor: { diff --git a/packages/netlify-cms-widget-select/src/SelectControl.js b/packages/netlify-cms-widget-select/src/SelectControl.js index 9cd42eeb..69bcad05 100644 --- a/packages/netlify-cms-widget-select/src/SelectControl.js +++ b/packages/netlify-cms-widget-select/src/SelectControl.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { Map, List, fromJS } from 'immutable'; -import { find } from 'lodash'; +import { find, isNumber } from 'lodash'; import Select from 'react-select'; import { reactSelectStyles } from 'netlify-cms-ui-default'; @@ -55,6 +55,55 @@ export default class SelectControl extends React.Component { }), }; + isValid = () => { + const { field, value, t } = this.props; + const min = field.get('min'); + const max = field.get('max'); + if (!field.get('multiple')) { + return { error: false }; + } + if ([min, max].every(isNumber)) { + const isValid = value && value.size >= min && value.size <= max; + const messageKey = min === max ? 'rangeCountExact' : 'rangeCount'; + if (!isValid) { + return { + error: { + message: t(`editor.editorControlPane.widget.${messageKey}`, { + fieldLabel: field.get('label', field.get('name')), + minValue: min, + maxValue: max, + }), + }, + }; + } + } else if (isNumber(min)) { + const isValid = value && value.size >= min; + if (!isValid) { + return { + error: { + message: t('editor.editorControlPane.widget.rangeMin', { + fieldLabel: field.get('label', field.get('name')), + minValue: min, + }), + }, + }; + } + } else if (isNumber(max)) { + const isValid = !value || value.size <= max; + if (!isValid) { + return { + error: { + message: t('editor.editorControlPane.widget.rangeMax', { + fieldLabel: field.get('label', field.get('name')), + maxValue: max, + }), + }, + }; + } + } + return { error: false }; + }; + handleChange = selectedOption => { const { onChange, field } = this.props; const isMultiple = field.get('multiple', false); diff --git a/packages/netlify-cms-widget-select/src/__tests__/select.spec.js b/packages/netlify-cms-widget-select/src/__tests__/select.spec.js index d40d3e6c..0fabd204 100644 --- a/packages/netlify-cms-widget-select/src/__tests__/select.spec.js +++ b/packages/netlify-cms-widget-select/src/__tests__/select.spec.js @@ -34,7 +34,7 @@ class SelectController extends React.Component { } function setup({ field, defaultValue }) { - let renderArgs; + let renderArgs, ref; const stateChangeSpy = jest.fn(); const setActiveSpy = jest.fn(); const setInactiveSpy = jest.fn(); @@ -52,6 +52,8 @@ function setup({ field, defaultValue }) { classNameWrapper="" setActiveStyle={setActiveSpy} setInactiveStyle={setInactiveSpy} + ref={widgetRef => (ref = widgetRef)} + t={jest.fn(msg => msg)} /> ); }} @@ -66,6 +68,7 @@ function setup({ field, defaultValue }) { stateChangeSpy, setActiveSpy, setInactiveSpy, + ref, input, }; } @@ -207,4 +210,81 @@ describe('Select widget', () => { expect(getByText('baz')).toBeInTheDocument(); }); }); + describe.only('validation', () => { + function validate(setupOpts) { + const { ref } = setup(setupOpts); + const { error } = ref.isValid(); + return error?.message; + } + it('should fail with less items than min allows', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, min: 1 }), + }; + expect(validate(opts)).toMatchInlineSnapshot(`"editor.editorControlPane.widget.rangeMin"`); + }); + it('should fail with more items than max allows', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, max: 1 }), + defaultValue: fromJS([stringOptions[0], stringOptions[1]]), + }; + expect(validate(opts)).toMatchInlineSnapshot(`"editor.editorControlPane.widget.rangeMax"`); + }); + it('should enforce min when both min and max are set', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, min: 1, max: 2 }), + }; + expect(validate(opts)).toMatchInlineSnapshot(`"editor.editorControlPane.widget.rangeCount"`); + }); + it('should enforce max when both min and max are set', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, min: 1, max: 2 }), + defaultValue: fromJS([stringOptions[0], stringOptions[1], stringOptions[2]]), + }; + expect(validate(opts)).toMatchInlineSnapshot(`"editor.editorControlPane.widget.rangeCount"`); + }); + it('should enforce min and max when they are the same value', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, min: 2, max: 2 }), + defaultValue: fromJS([stringOptions[0], stringOptions[1], stringOptions[2]]), + }; + expect(validate(opts)).toMatchInlineSnapshot( + `"editor.editorControlPane.widget.rangeCountExact"`, + ); + }); + it('should pass when min is met', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, min: 1 }), + defaultValue: fromJS([stringOptions[0]]), + }; + expect(validate(opts)).toBeUndefined(); + }); + it('should pass when max is met', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, max: 1 }), + defaultValue: fromJS([stringOptions[0]]), + }; + expect(validate(opts)).toBeUndefined(); + }); + it('should pass when both min and max are met', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, min: 2, max: 3 }), + defaultValue: fromJS([stringOptions[0], stringOptions[1]]), + }; + expect(validate(opts)).toBeUndefined(); + }); + it('should pass when both min and max are met, and are the same value', () => { + const opts = { + field: fromJS({ options: stringOptions, multiple: true, min: 2, max: 2 }), + defaultValue: fromJS([stringOptions[0], stringOptions[1]]), + }; + expect(validate(opts)).toBeUndefined(); + }); + it('should not fail on min/max if multiple is not true', () => { + const opts = { + field: fromJS({ options: stringOptions, min: 2, max: 2 }), + defaultValue: fromJS([stringOptions[0]]), + }; + expect(validate(opts)).toBeUndefined(); + }); + }); }); diff --git a/website/content/docs/widgets/select.md b/website/content/docs/widgets/select.md index 5754222b..400464e9 100644 --- a/website/content/docs/widgets/select.md +++ b/website/content/docs/widgets/select.md @@ -16,6 +16,8 @@ The select widget allows you to pick a string value from a dropdown menu. - string values: the label displayed in the dropdown is the value saved in the file - object with `label` and `value` fields: the label displays in the dropdown; the value is saved in the file - `multiple`: accepts a boolean; defaults to `false` + - `min`: minimum number of items; ignored if **multiple** is not `true` + - `max`: maximum number of items; ignored if **multiple** is not `true` - **Example** (options as strings): ```yaml - label: "Align Content" @@ -42,4 +44,14 @@ The select widget allows you to pick a string value from a dropdown menu. options: ["Design", "UX", "Dev"] default: ["Design"] ``` - +- **Example** (min/max): + ```yaml + - label: "Tags" + name: "tags" + widget: "select" + multiple: true + min: 1 + max: 3 + options: ["Design", "UX", "Dev"] + default: ["Design"] + ```