diff --git a/.github/actions/node_env_setup/action.yml b/.github/actions/node_env_setup/action.yml index b058a41457..78e3dd5d1d 100644 --- a/.github/actions/node_env_setup/action.yml +++ b/.github/actions/node_env_setup/action.yml @@ -36,7 +36,7 @@ runs: - name: Install Volto dependencies shell: bash - run: pnpm i + run: make install - name: Install Cypress if not in cache if: steps.cache-cypress-binary.outputs.cache-hit != 'true' diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index af5967f394..0d53b79548 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -497,45 +497,11 @@ jobs: steps: - uses: actions/checkout@v4 - # node setup - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - name: Set up Node.js environment + uses: ./.github/actions/node_env_setup with: node-version: ${{ matrix.node-version }} - - name: Enable corepack - run: corepack enable - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Cache Cypress Binary - id: cache-cypress-binary - uses: actions/cache@v4 - with: - path: ~/.cache/Cypress - key: binary-${{ matrix.node-version }}-${{ hashFiles('pnpm-lock.yaml') }} - - - run: pnpm i - - - name: Build dependencies - run: pnpm build:deps - - - name: Install Cypress if not in cache - if: steps.cache-cypress-binary.outputs.cache-hit != 'true' - working-directory: packages/volto - run: make cypress-install - # Generator own tests - name: Generator tests run: pnpm test diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index cfccae4362..b479cc7fb1 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -16,30 +16,11 @@ jobs: steps: - uses: actions/checkout@v4 - # node setup - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - name: Set up Node.js environment + uses: ./.github/actions/node_env_setup with: node-version: ${{ matrix.node-version }} - - name: Enable corepack - run: corepack enable - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - run: pnpm i - # Locales in place are needed for the tests to pass - run: pnpm --filter @plone/volto i18n diff --git a/Makefile b/Makefile index 1164cab39c..568b297d80 100644 --- a/Makefile +++ b/Makefile @@ -69,10 +69,11 @@ clean: ## Clean development environment find ./packages -name node_modules -exec rm -rf {} \; .PHONY: install -install: build-deps ## Set up development environment +install: ## Set up development environment # Setup ESlint for VSCode - node packages/scripts/vscodesettings.js pnpm i + node packages/scripts/vscodesettings.js + make build-deps ##### Documentation @@ -137,10 +138,10 @@ docs-test: docs-clean docs-linkcheckbroken docs-vale ## Clean docs build, then cypress-install: ## Install Cypress for acceptance tests $(NODEBIN)/cypress install -packages/registry/dist: packages/registry/src +packages/registry/dist: $(shell find packages/registry/src -type f) pnpm build:registry -packages/components/dist: packages/components/src +packages/components/dist: $(shell find packages/components/src -type f) pnpm build:components .PHONY: build-deps diff --git a/docs/source/configuration/index.md b/docs/source/configuration/index.md index 3375c41f9c..5f06beb069 100644 --- a/docs/source/configuration/index.md +++ b/docs/source/configuration/index.md @@ -27,4 +27,5 @@ environmentvariables expanders locking slots +validation ``` diff --git a/docs/source/configuration/validation.md b/docs/source/configuration/validation.md new file mode 100644 index 0000000000..5e7a0e74b4 --- /dev/null +++ b/docs/source/configuration/validation.md @@ -0,0 +1,204 @@ +--- +myst: + html_meta: + "description": "Client side form field validation" + "property=og:description": "Client side form field validation" + "property=og:title": "Form fields validation" + "keywords": "Volto, Plone, frontend, React, configuration, form, fields, validation" +--- + +# Client side form field validation + +Volto provides a mechanism for delivering form field validation in an extensible way. +This extensibility is based on the Volto component registry. + +## Registering a validator + +You can register a validator using the component registry API from your add-on configuration. +All validators are registered under the name `fieldValidator`. +The validators are registered using the `dependencies` array of the `registerComponent` API to differentiate the kind of validator to be registered. + +### `default` validators + +These validators are registered and applied to all fields. + +```ts +config.registerComponent({ + name: 'fieldValidator', + dependencies: ['default', 'minLength'], + component: minLengthValidator, +}); +``` + +It takes two `dependencies` being the first element a fixed `default` identifier, and the second you can set it up to identify the validator itself. +In the case of the example, this other dependency is `minLength`. +It can be any string. + +### Per field `type` validators + +These validators are applied depending on the specified `type` of the field. + +```ts +config.registerComponent({ + name: 'fieldValidator', + dependencies: ['integer', 'maximum'], + component: maximumValidator, +}); +``` + +It takes two `dependencies` since we can potentially have several validators for the same `type`. +The first element should be the field `type`, and the second you should set it up to identify the validator itself. +You should specify the `type` in the JSON schema of the block (in a content type, it is included in the default serialization of the field). +If a field does not specify type, it assumes a `string` type as validator. +The next example is for the use case of JSON schema defined in a block: + +```ts +let blockSchema = { + // ... fieldset definition in here + properties: { + ...schema.properties, + customField: { + title: 'My custom field', + description: '', + type: 'integer', + }, + }, + required: [], +}; +``` + +### Per field `widget` validators + +These validators are applied depending on the specified `widget` of the field. + +```ts +config.registerComponent({ + name: 'fieldValidator', + dependencies: ['phoneNumber', 'isValidPhone'], + component: phoneValidator, +}); +``` + +It takes two `dependencies` since we can potentially have several validators for the same `widget`. +The first element should be the name of the `widget`, and the second you can set it up to identify the validator. +You should specify the `widget` in the JSON schema of the block (or as additional data in the content type definition). +The next example is for the use case of a block JSON schema: + +```ts +let blockSchema = { + // ... fieldset definition in here + properties: { + ...schema.properties, + phone: { + title: 'Phone number', + description: '', + widget: 'phoneNumber', + }, + }, + required: [], +}; +``` + +### Per behavior and field name validator + +These validators are applied depending on the behavior (usually coming from a content type definition) in combination with the name of the field. + +```ts +config.registerComponent({ + name: 'fieldValidator', + dependencies: ['plone.eventbasic', 'start', 'startValidator'], + component: startEventDateRangeValidator, +}); +``` + +The first dependency should be the name of the behavior, and the second the name (`id`) of the field. +It can get a third dependency in case you want to specify several validators for the same behavior - field id combination. +This type of validator only applies to content-type validators. + +### Per block type and field name validator + +These validators are applied depending on the block type in combination with the name of the field in the block settings JSON schema. + +```ts +config.registerComponent({ + name: 'fieldValidator', + dependencies: ['slider', 'url', 'isURL'], + component: urlValidator, +}); +``` + +The first dependency should be the `id` of the block, and the second the `id` of the field. +It can get a third dependency in case you want to specify several validators for the same block type - field id combination. +This type of validator only applies to blocks. + +### Specific validator using the `validator` key in the field + +A final type of validator is applied to the field if the `validator` key is present in the JSON schema definition of the form field. + +```ts +config.registerComponent({ + name: 'fieldValidator', + dependencies: ['isURL'], + component: urlValidator, +}); +``` + +The dependencies take one single name, in this case, the name of the validator. +You should specify the validator in the JSON schema of the block (or as additional data in the content type definition). + +```ts +let blockSchema = { + // ... fieldset definition in here + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + validator: 'isURL', + }, + }, + required: [], +}; +``` + +It does not need to be tied to any field `type` or `widget` definition. +It runs in addition to all the above, so it complements the other validators if any apply. + +## Volto's default validators + +Volto provides a set of validators by default, you can find them in this module: `packages/volto/src/config/validators.ts` + +### How to override them + +You can override them in your add-on as any other component defined in the registry, by redefining them using the same `dependencies`, and providing your own. + +## Signature of a validator + +A validator has the following signature: + +```ts +type Validator = { + // The field value + value: string; + // The field schema definition object + field: Record; + // The form data + formData?: any; + // The intl formatMessage function + formatMessage: Function; +}; +``` + +This is an example of an `isNumber` validator: + +```ts +export const isNumber = ({ value, formatMessage }: Validator) => { + const floatRegex = /^[+-]?\d+(\.\d+)?$/; + const isValid = + typeof value === 'number' && !isNaN(value) && floatRegex.test(value); + return !isValid ? formatMessage(messages.isNumber) : null; +}; +``` + +Using the `formData` you can perform validation checks using other field data as source. +This is interesting in the case that two fields are related, like `start` and `end` dates. diff --git a/docs/source/upgrade-guide/index.md b/docs/source/upgrade-guide/index.md index e71c1357b4..243f382161 100644 --- a/docs/source/upgrade-guide/index.md +++ b/docs/source/upgrade-guide/index.md @@ -376,6 +376,13 @@ It is unlikely that your code uses it, unless you heavily customized the Jest te This was not used by the core since some time ago, and nowadays is more suitable for being an add-on and not being included in core. If you still use it, bring it back as your main add-on dependency, bring back the `SocialSharing` component from Volto 17 as a custom component in your add-on code. +### Refactor of `FormValidation` module + +The `packages/volto/src/helpers/FormValidation/FormValidation.jsx` module has been heavily refactored. +Some helper functions have been moved to `packages/volto/src/helpers/FormValidation/validators.ts`, however, none of those functions were exported in the first place, so no imports will be broken. +In case that you've shadowed the `packages/volto/src/helpers/FormValidation/FormValidation.jsx` module, you should revisit it and update it with the latest refactor. +If you added more validators manually in that shadow, please refer to the documentation to add the validators in the new way: {doc}`../configuration/validation`. + (volto-upgrade-guide-17.x.x)= ## Upgrading to Volto 17.x.x diff --git a/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx b/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx index 104ea631ee..0a89035bdd 100644 --- a/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx +++ b/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx @@ -3,8 +3,15 @@ import { BlockDataForm } from '@plone/volto/components/manage/Form'; import type { BlockEditProps } from '@plone/types'; const TestBlockData = (props: BlockEditProps) => { - const { block, blocksConfig, contentType, data, navRoot, onChangeBlock } = - props; + const { + block, + blocksConfig, + contentType, + data, + navRoot, + onChangeBlock, + errors, + } = props; const intl = useIntl(); const schema = blocksConfig[data['@type']].blockSchema({ intl, props }); @@ -24,6 +31,7 @@ const TestBlockData = (props: BlockEditProps) => { blocksConfig={blocksConfig} navRoot={navRoot} contentType={contentType} + errors={errors} /> ); }; diff --git a/packages/coresandbox/src/components/Blocks/TestBlock/schema.ts b/packages/coresandbox/src/components/Blocks/TestBlock/schema.ts index 8846418c98..756f93966d 100644 --- a/packages/coresandbox/src/components/Blocks/TestBlock/schema.ts +++ b/packages/coresandbox/src/components/Blocks/TestBlock/schema.ts @@ -182,6 +182,11 @@ export const multipleFieldsetsSchema: BlockConfigBase['blockSchema'] = ({ title: 'fourth', fields: ['href', 'firstWithDefault', 'style'], }, + { + id: 'fifth', + title: 'fifth', + fields: ['fieldRequiredInFieldset'], + }, ], properties: { slides: { @@ -232,6 +237,9 @@ export const multipleFieldsetsSchema: BlockConfigBase['blockSchema'] = ({ title: 'HTML', widget: 'richtext', }, + fieldRequiredInFieldset: { + title: 'Field required in fieldset', + }, }, - required: [], + required: ['fieldRequiredInFieldset'], }); diff --git a/packages/registry/news/6161.feature b/packages/registry/news/6161.feature new file mode 100644 index 0000000000..75641a0fa7 --- /dev/null +++ b/packages/registry/news/6161.feature @@ -0,0 +1 @@ +Add `getComponents` that match a partial set of dependencies, given a name. @sneridagh diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index 932f84bc01..f94a8b24fe 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -149,10 +149,43 @@ class Config { } } + getComponents( + options: { name: string; dependencies?: string[] | string } | string, + ): Array { + if (typeof options === 'object') { + const { name, dependencies = '' } = options; + let depsString: string = ''; + if (dependencies && Array.isArray(dependencies)) { + depsString = dependencies.join('+'); + } else if (typeof dependencies === 'string') { + depsString = dependencies; + } + const componentName = `${name}${depsString ? `|${depsString}` : ''}`; + const componentsKeys = Object.keys(this._data.components).filter((key) => + key.startsWith(componentName), + ); + const components = componentsKeys.map( + (key) => this._data.components[key], + ); + + return components; + } else { + // Shortcut notation, accepting a lonely string as argument + const componentName = options; + const componentsKeys = Object.keys(this._data.components).filter((key) => + key.startsWith(componentName), + ); + const components = componentsKeys.map( + (key) => this._data.components[key], + ); + return components; + } + } + registerComponent(options: { name: string; dependencies?: string[] | string; - component: React.ComponentType; + component: (args: any) => any; }) { const { name, component, dependencies = '' } = options; let depsString: string = ''; diff --git a/packages/registry/src/registry.test.tsx b/packages/registry/src/registry.test.tsx index 4763b93946..20af23c27d 100644 --- a/packages/registry/src/registry.test.tsx +++ b/packages/registry/src/registry.test.tsx @@ -1,21 +1,22 @@ import config from './index'; -import { describe, expect, it, afterEach } from 'vitest'; +import { describe, expect, it, afterEach, beforeEach } from 'vitest'; -config.set('components', { - Toolbar: { component: 'this is the Toolbar component' }, - 'Toolbar.Types': { component: 'this is the Types component' }, - 'Teaser|News Item': { component: 'This is the News Item Teaser component' }, +beforeEach(() => { + config.set('components', { + Toolbar: { component: 'this is the Toolbar component' }, + 'Toolbar.Types': { component: 'this is the Types component' }, + 'Teaser|News Item': { component: 'This is the News Item Teaser component' }, + }); + config.set('slots', {}); }); -config.set('slots', {}); - describe('Component registry', () => { - it('get components', () => { + it('get a component', () => { expect(config.getComponent('Toolbar').component).toEqual( 'this is the Toolbar component', ); }); - it('get components with context', () => { + it('get a component with context', () => { expect( config.getComponent({ name: 'Teaser', dependencies: 'News Item' }) .component, @@ -111,6 +112,39 @@ describe('Component registry', () => { }).component, ).toEqual('this is a Bar component'); }); + it('getComponents - get a collection of component registration', () => { + expect(config.getComponents('Toolbar').length).toEqual(2); + expect(config.getComponents('Toolbar')[0].component).toEqual( + 'this is the Toolbar component', + ); + expect(config.getComponents('Toolbar')[1].component).toEqual( + 'this is the Types component', + ); + }); + it('getComponents - get a collection of component registration and deps', () => { + config.registerComponent({ + name: 'Toolbar', + component: 'this is a StringFieldWidget component', + dependencies: ['News Item', 'StringFieldWidget'], + }); + config.registerComponent({ + name: 'Toolbar', + component: 'this is a AnotherWidget component', + dependencies: ['News Item', 'AnotherWidget'], + }); + expect( + config.getComponents({ name: 'Toolbar', dependencies: ['News Item'] }) + .length, + ).toEqual(2); + expect( + config.getComponents({ name: 'Toolbar', dependencies: ['News Item'] })[0] + .component, + ).toEqual('this is a StringFieldWidget component'); + expect( + config.getComponents({ name: 'Toolbar', dependencies: ['News Item'] })[1] + .component, + ).toEqual('this is a AnotherWidget component'); + }); }); describe('Slots registry', () => { diff --git a/packages/types/news/6161.bugfix b/packages/types/news/6161.bugfix new file mode 100644 index 0000000000..3e8c99230d --- /dev/null +++ b/packages/types/news/6161.bugfix @@ -0,0 +1 @@ +Add `errors` shape to the `BlockEditProps` @sneridagh diff --git a/packages/types/src/blocks/index.d.ts b/packages/types/src/blocks/index.d.ts index 28043b0918..5c40dff0d6 100644 --- a/packages/types/src/blocks/index.d.ts +++ b/packages/types/src/blocks/index.d.ts @@ -116,4 +116,5 @@ export interface BlockEditProps { history: History; location: Location; token: string; + errors: Record; } diff --git a/packages/volto/Makefile b/packages/volto/Makefile index 51236a9395..21b3b8487a 100644 --- a/packages/volto/Makefile +++ b/packages/volto/Makefile @@ -57,9 +57,11 @@ clean: ## Clean development environment rm -rf node_modules .PHONY: install -install: build-deps ## Set up development environment +install: ## Set up development environment # Setup ESlint for VSCode node packages/scripts/vscodesettings.js + pnpm i + make build-deps ##### Build diff --git a/packages/volto/locales/ca/LC_MESSAGES/volto.po b/packages/volto/locales/ca/LC_MESSAGES/volto.po index c208633667..1ef04b6b5d 100644 --- a/packages/volto/locales/ca/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ca/LC_MESSAGES/volto.po @@ -1268,6 +1268,11 @@ msgstr "" msgid "End Date" msgstr "Data de finalització" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3437,6 +3442,11 @@ msgstr "Dividir" msgid "Start Date" msgstr "Data d'inici" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4395,6 +4405,11 @@ msgstr "" msgid "availableViews" msgstr "" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/de/LC_MESSAGES/volto.po b/packages/volto/locales/de/LC_MESSAGES/volto.po index 431873a8ce..6157a2e0c1 100644 --- a/packages/volto/locales/de/LC_MESSAGES/volto.po +++ b/packages/volto/locales/de/LC_MESSAGES/volto.po @@ -1267,6 +1267,11 @@ msgstr "Aktiviert?" msgid "End Date" msgstr "Enddatum" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3436,6 +3441,11 @@ msgstr "Aufsplitten" msgid "Start Date" msgstr "Anfangsdatum" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4394,6 +4404,11 @@ msgstr "Geben Sie einen Nutzernamen ein, beispielsweise etwas wie "m.muster". Be msgid "availableViews" msgstr "Verfügbare Ansichten" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/en/LC_MESSAGES/volto.po b/packages/volto/locales/en/LC_MESSAGES/volto.po index 6446ae0d63..447c2509be 100644 --- a/packages/volto/locales/en/LC_MESSAGES/volto.po +++ b/packages/volto/locales/en/LC_MESSAGES/volto.po @@ -1262,6 +1262,11 @@ msgstr "" msgid "End Date" msgstr "" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3431,6 +3436,11 @@ msgstr "" msgid "Start Date" msgstr "" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4389,6 +4399,11 @@ msgstr "" msgid "availableViews" msgstr "" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/es/LC_MESSAGES/volto.po b/packages/volto/locales/es/LC_MESSAGES/volto.po index cdeb3c44d1..1b28b346b9 100644 --- a/packages/volto/locales/es/LC_MESSAGES/volto.po +++ b/packages/volto/locales/es/LC_MESSAGES/volto.po @@ -1269,6 +1269,11 @@ msgstr "¿Activado?" msgid "End Date" msgstr "Fecha final" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3438,6 +3443,11 @@ msgstr "División" msgid "Start Date" msgstr "Fecha de inicio" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4396,6 +4406,11 @@ msgstr "Introduzca el nombre de usuario que desee utilizar. Generalmente algo co msgid "availableViews" msgstr "Vistas disponibles" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/eu/LC_MESSAGES/volto.po b/packages/volto/locales/eu/LC_MESSAGES/volto.po index bbac9957bb..e6d1d7cc65 100644 --- a/packages/volto/locales/eu/LC_MESSAGES/volto.po +++ b/packages/volto/locales/eu/LC_MESSAGES/volto.po @@ -1269,6 +1269,11 @@ msgstr "Aktibatuta?" msgid "End Date" msgstr "Bukaera data" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3438,6 +3443,11 @@ msgstr "Banatu" msgid "Start Date" msgstr "Hasiera-data" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4396,6 +4406,11 @@ msgstr "Idatzi zure erabiltzaile izena, "jgarmendia" moduko zerbait. Ez da onart msgid "availableViews" msgstr "Bistak" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/fi/LC_MESSAGES/volto.po b/packages/volto/locales/fi/LC_MESSAGES/volto.po index 659146eaff..b47a21d524 100644 --- a/packages/volto/locales/fi/LC_MESSAGES/volto.po +++ b/packages/volto/locales/fi/LC_MESSAGES/volto.po @@ -1267,6 +1267,11 @@ msgstr "Aktivoitu?" msgid "End Date" msgstr "Päättymispäivä" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3436,6 +3441,11 @@ msgstr "Halkaise" msgid "Start Date" msgstr "Aloituspäivä" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4394,6 +4404,11 @@ msgstr "Lisää käyttäjätunnus, esim. mameikal. Älä käytä erikoismerkkej msgid "availableViews" msgstr "Saatavilla olevat näkymät" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/fr/LC_MESSAGES/volto.po b/packages/volto/locales/fr/LC_MESSAGES/volto.po index 4b98a6ebd5..59686d2042 100644 --- a/packages/volto/locales/fr/LC_MESSAGES/volto.po +++ b/packages/volto/locales/fr/LC_MESSAGES/volto.po @@ -1269,6 +1269,11 @@ msgstr "Activé ?" msgid "End Date" msgstr "Date de fin" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3438,6 +3443,11 @@ msgstr "Divisé" msgid "Start Date" msgstr "Date de début" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4396,6 +4406,11 @@ msgstr "Saisissez un nom d'utilisateur, haituellement quelque chose comme "jdupo msgid "availableViews" msgstr "Vues disponibles" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/hi/LC_MESSAGES/volto.po b/packages/volto/locales/hi/LC_MESSAGES/volto.po index 7fd750d60e..bad6113aab 100644 --- a/packages/volto/locales/hi/LC_MESSAGES/volto.po +++ b/packages/volto/locales/hi/LC_MESSAGES/volto.po @@ -1262,6 +1262,11 @@ msgstr "सक्षम?" msgid "End Date" msgstr "अंतिम तिथि" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3431,6 +3436,11 @@ msgstr "विभाजित करें" msgid "Start Date" msgstr "प्रारंभ तिथि" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4389,6 +4399,11 @@ msgstr "एक उपयोगकर्ता नाम दर्ज करे msgid "availableViews" msgstr "उपलब्ध दृश्य" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/it/LC_MESSAGES/volto.po b/packages/volto/locales/it/LC_MESSAGES/volto.po index 55d33ae7ee..511d8a3797 100644 --- a/packages/volto/locales/it/LC_MESSAGES/volto.po +++ b/packages/volto/locales/it/LC_MESSAGES/volto.po @@ -1262,6 +1262,11 @@ msgstr "Abilitato?" msgid "End Date" msgstr "Data di fine" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3431,6 +3436,11 @@ msgstr "Dividi" msgid "Start Date" msgstr "Data di inizio" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4389,6 +4399,11 @@ msgstr "Inserisci uno username, ad esempio 'jsmith'. Non sono consentiti spazi o msgid "availableViews" msgstr "Viste disponibili" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/ja/LC_MESSAGES/volto.po b/packages/volto/locales/ja/LC_MESSAGES/volto.po index 812edef96d..3143057326 100644 --- a/packages/volto/locales/ja/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ja/LC_MESSAGES/volto.po @@ -1267,6 +1267,11 @@ msgstr "" msgid "End Date" msgstr "終了日付" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3436,6 +3441,11 @@ msgstr "分割" msgid "Start Date" msgstr "開始日付" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4394,6 +4404,11 @@ msgstr "" msgid "availableViews" msgstr "" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/nl/LC_MESSAGES/volto.po b/packages/volto/locales/nl/LC_MESSAGES/volto.po index ac3e3f7898..a69cb7f9b6 100644 --- a/packages/volto/locales/nl/LC_MESSAGES/volto.po +++ b/packages/volto/locales/nl/LC_MESSAGES/volto.po @@ -1266,6 +1266,11 @@ msgstr "" msgid "End Date" msgstr "" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3435,6 +3440,11 @@ msgstr "" msgid "Start Date" msgstr "" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4393,6 +4403,11 @@ msgstr "" msgid "availableViews" msgstr "" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/pt/LC_MESSAGES/volto.po b/packages/volto/locales/pt/LC_MESSAGES/volto.po index 610eb70a88..d559c41160 100644 --- a/packages/volto/locales/pt/LC_MESSAGES/volto.po +++ b/packages/volto/locales/pt/LC_MESSAGES/volto.po @@ -1267,6 +1267,11 @@ msgstr "" msgid "End Date" msgstr "" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3436,6 +3441,11 @@ msgstr "Dividir" msgid "Start Date" msgstr "" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4394,6 +4404,11 @@ msgstr "" msgid "availableViews" msgstr "" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po b/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po index 2ef7345fc9..7ddb7ec3a3 100644 --- a/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po +++ b/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po @@ -1268,6 +1268,11 @@ msgstr "Ativada?" msgid "End Date" msgstr "Data Final" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3437,6 +3442,11 @@ msgstr "Dividir" msgid "Start Date" msgstr "Data de Início" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4395,6 +4405,11 @@ msgstr "Informe o nome do usuário que você deseja, geralmente algo como 'jsilv msgid "availableViews" msgstr "Visões disponíveis" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/ro/LC_MESSAGES/volto.po b/packages/volto/locales/ro/LC_MESSAGES/volto.po index 153a1c3701..d73b6498d4 100644 --- a/packages/volto/locales/ro/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ro/LC_MESSAGES/volto.po @@ -1262,6 +1262,11 @@ msgstr "" msgid "End Date" msgstr "Data de încheiere" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3431,6 +3436,11 @@ msgstr "Împărțire" msgid "Start Date" msgstr "Data de început" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4389,6 +4399,11 @@ msgstr "" msgid "availableViews" msgstr "" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/volto.pot b/packages/volto/locales/volto.pot index 3dd390b780..f3d97a00f4 100644 --- a/packages/volto/locales/volto.pot +++ b/packages/volto/locales/volto.pot @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Plone\n" -"POT-Creation-Date: 2024-07-09T06:25:05.312Z\n" +"POT-Creation-Date: 2024-07-15T15:43:39.835Z\n" "Last-Translator: Plone i18n \n" "Language-Team: Plone i18n \n" "Content-Type: text/plain; charset=utf-8\n" @@ -1264,6 +1264,11 @@ msgstr "" msgid "End Date" msgstr "" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3433,6 +3438,11 @@ msgstr "" msgid "Start Date" msgstr "" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4391,6 +4401,11 @@ msgstr "" msgid "availableViews" msgstr "" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po b/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po index 58f05932bc..3ea60cec70 100644 --- a/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po +++ b/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po @@ -1268,6 +1268,11 @@ msgstr "启用?" msgid "End Date" msgstr "结束日期" +#. Default: "End event date happens before than the start event date" +#: helpers/MessageLabels/MessageLabels +msgid "End event date happens before than the start event date" +msgstr "" + #. Default: "Enter URL or select an item" #: components/manage/AnchorPlugin/components/LinkButton/AddLinkForm msgid "Enter URL or select an item" @@ -3437,6 +3442,11 @@ msgstr "" msgid "Start Date" msgstr "开始日期" +#. Default: "Start event date happens later than the end event date" +#: helpers/MessageLabels/MessageLabels +msgid "Start event date happens later than the end event date" +msgstr "" + #. Default: "Start of the recurrence" #: components/manage/Widgets/RecurrenceWidget/Occurences msgid "Start of the recurrence" @@ -4395,6 +4405,11 @@ msgstr "" msgid "availableViews" msgstr "" +#. Default: "Error in the block field {errorField}." +#: helpers/MessageLabels/MessageLabels +msgid "blocksFieldsErrorTitle" +msgstr "" + #. Default: "Forgot your password?" #: components/theme/Login/Login #: components/theme/PasswordReset/RequestPasswordReset diff --git a/packages/volto/news/6161.breaking b/packages/volto/news/6161.breaking new file mode 100644 index 0000000000..17a66bab4e --- /dev/null +++ b/packages/volto/news/6161.breaking @@ -0,0 +1,5 @@ +Add foundations for extensible validation in forms @sneridagh + +Breaking: +`packages/volto/src/helpers/FormValidation/FormValidation.jsx` has been heavily refactored. +If you have shadowed this component in your project/add-on, please review and update your shadow. diff --git a/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx b/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx index a5fb57e217..40e15faff8 100644 --- a/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx +++ b/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx @@ -58,6 +58,7 @@ const BlocksForm = (props) => { history, location, token, + errors, } = props; const [isClient, setIsClient] = useState(false); @@ -281,6 +282,7 @@ const BlocksForm = (props) => { onDeleteBlock={onDeleteBlock} onSelectBlock={onSelectBlock} removable + errors={errors} /> , document.getElementById('sidebar-order'), @@ -354,6 +356,7 @@ const BlocksForm = (props) => { history, location, token, + errors, }; return editBlockWrapper( dragProps, diff --git a/packages/volto/src/components/manage/Blocks/Block/Order/Item.jsx b/packages/volto/src/components/manage/Blocks/Block/Order/Item.jsx index db73f4fbf7..68e20edcc6 100644 --- a/packages/volto/src/components/manage/Blocks/Block/Order/Item.jsx +++ b/packages/volto/src/components/manage/Blocks/Block/Order/Item.jsx @@ -2,7 +2,7 @@ import React, { forwardRef } from 'react'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; import { includes } from 'lodash'; - +import cx from 'classnames'; import { Icon } from '@plone/volto/components'; import { setUIState } from '@plone/volto/actions'; import config from '@plone/volto/registry'; @@ -28,6 +28,7 @@ export const Item = forwardRef( style, value, wrapperRef, + errors, ...props }, ref, @@ -37,6 +38,7 @@ export const Item = forwardRef( const multiSelected = useSelector((state) => state.form.ui.multiSelected); const gridSelected = useSelector((state) => state.form.ui.gridSelected); const dispatch = useDispatch(); + return (
  • - + 0 })} + > {config.blocks.blocksConfig[data?.['@type']]?.icon && ( handleRemove(id) : undefined} onSelectBlock={onSelectBlock} + errors={errors?.[id] || {}} /> ))} {createPortal( diff --git a/packages/volto/src/components/manage/Form/Form.jsx b/packages/volto/src/components/manage/Form/Form.jsx index 1ebdca28f7..151a2b542b 100644 --- a/packages/volto/src/components/manage/Form/Form.jsx +++ b/packages/volto/src/components/manage/Form/Form.jsx @@ -12,6 +12,7 @@ import { FormValidation, getBlocksFieldname, getBlocksLayoutFieldname, + hasBlocksData, messages, } from '@plone/volto/helpers'; import aheadSVG from '@plone/volto/icons/ahead.svg'; @@ -527,30 +528,92 @@ class Form extends Component { }) : {}; - if (keys(errors).length > 0) { + let blocksErrors = {}; + + if (hasBlocksData(formData)) { + // Validate blocks + const blocks = this.state.formData[getBlocksFieldname(formData)]; + const blocksLayout = + this.state.formData[getBlocksLayoutFieldname(formData)]; + const defaultSchema = { + properties: {}, + fieldsets: [], + required: [], + }; + blocksLayout.items.forEach((block) => { + let blockSchema = + config.blocks.blocksConfig[blocks[block]['@type']].blockSchema || + defaultSchema; + if (typeof blockSchema === 'function') { + blockSchema = blockSchema({ + intl: this.props.intl, + formData: blocks[block], + }); + } + const blockErrors = FormValidation.validateFieldsPerFieldset({ + schema: blockSchema, + formData: blocks[block], + formatMessage: this.props.intl.formatMessage, + }); + if (keys(blockErrors).length > 0) { + blocksErrors = { + ...blocksErrors, + [block]: { ...blockErrors }, + }; + } + }); + } + + if (keys(errors).length > 0 || keys(blocksErrors).length > 0) { const activeIndex = FormValidation.showFirstTabWithErrors({ errors, schema: this.props.schema, }); - this.setState( - { - errors, - activeIndex, - }, - () => { - Object.keys(errors).forEach((err) => - toast.error( - , - ), - ); + + this.setState({ + errors: { + ...errors, + ...(!isEmpty(blocksErrors) && { blocks: blocksErrors }), }, - ); - // Changes the focus to the metadata tab in the sidebar if error - this.props.setSidebarTab(0); + activeIndex, + }); + + if (keys(errors).length > 0) { + // Changes the focus to the metadata tab in the sidebar if error + Object.keys(errors).forEach((err) => + toast.error( + , + ), + ); + this.props.setSidebarTab(0); + } else if (keys(blocksErrors).length > 0) { + const errorField = Object.entries( + Object.entries(blocksErrors)[0][1], + )[0][0]; + const errorMessage = Object.entries( + Object.entries(blocksErrors)[0][1], + )[0][1]; + toast.error( + , + ); + this.props.setSidebarTab(1); + this.props.setUIState({ + selected: Object.keys(blocksErrors)[0], + multiSelected: [], + hovered: null, + }); + } } else { // Get only the values that have been modified (Edit forms), send all in case that // it's an add form @@ -730,6 +793,7 @@ class Form extends Component { history={this.props.history} location={this.props.location} token={this.props.token} + errors={this.state.errors.blocks} /> {this.state.isClient && this.state.sidebarMetadataIsAvailable && diff --git a/packages/volto/src/components/manage/Form/InlineForm.jsx b/packages/volto/src/components/manage/Form/InlineForm.jsx index 64bfb21946..f3998f434e 100644 --- a/packages/volto/src/components/manage/Form/InlineForm.jsx +++ b/packages/volto/src/components/manage/Form/InlineForm.jsx @@ -142,7 +142,6 @@ const InlineForm = (props) => { content={error.message} /> )} -
    {map(defaultFieldset.fields, (field, index) => ( @@ -157,7 +156,7 @@ const InlineForm = (props) => { onChangeField(id, value, itemInfo); }} key={field} - error={errors[field]} + error={errors?.[block]?.[field] || {}} block={block} /> ))} @@ -166,7 +165,6 @@ const InlineForm = (props) => { )}
    - {other.map((fieldset, index) => (
    @@ -199,7 +197,7 @@ const InlineForm = (props) => { onChangeField(id, value); }} key={field} - error={errors[field]} + error={errors?.[block]?.[field] || {}} block={block} /> ))} diff --git a/packages/volto/src/config/index.js b/packages/volto/src/config/index.js index 9c7b625fcc..5a0cf59a13 100644 --- a/packages/volto/src/config/index.js +++ b/packages/volto/src/config/index.js @@ -32,6 +32,7 @@ import applyAddonConfiguration, { addonsInfo } from 'load-volto-addons'; import ConfigRegistry from '@plone/volto/registry'; import { getSiteAsyncPropExtender } from '@plone/volto/helpers'; +import { registerValidators } from './validation'; const host = process.env.HOST || 'localhost'; const port = process.env.PORT || '3000'; @@ -239,4 +240,6 @@ ConfigRegistry.addonReducers = config.addonReducers; ConfigRegistry.components = config.components; ConfigRegistry.slots = config.slots; +registerValidators(ConfigRegistry); + applyAddonConfiguration(ConfigRegistry); diff --git a/packages/volto/src/config/validation.ts b/packages/volto/src/config/validation.ts new file mode 100644 index 0000000000..0fc7d8aad7 --- /dev/null +++ b/packages/volto/src/config/validation.ts @@ -0,0 +1,97 @@ +import { ConfigType } from '@plone/registry'; + +import { + minLengthValidator, + maxLengthValidator, + urlValidator, + emailValidator, + isNumber, + maximumValidator, + minimumValidator, + isInteger, + hasUniqueItems, + startEventDateRangeValidator, + endEventDateRangeValidator, +} from '@plone/volto/helpers/FormValidation/validators'; + +const registerValidators = (config: ConfigType) => { + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['default', 'minLength'], + component: minLengthValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['default', 'maxLength'], + component: maxLengthValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['email', 'isValidEmail'], + component: emailValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['url', 'isValidURL'], + component: urlValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['number', 'isNumber'], + component: isNumber, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['number', 'minimum'], + component: minimumValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['number', 'maximum'], + component: maximumValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['integer', 'isNumber'], + component: isInteger, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['integer', 'minimum'], + component: minimumValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['integer', 'maximum'], + component: maximumValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['array', 'uniqueItems'], + component: hasUniqueItems, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['plone.eventbasic', 'start'], + component: startEventDateRangeValidator, + }); + + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['plone.eventbasic', 'end'], + component: endEventDateRangeValidator, + }); +}; + +export { registerValidators }; diff --git a/packages/volto/src/helpers/FormValidation/FormValidation.jsx b/packages/volto/src/helpers/FormValidation/FormValidation.jsx index 95c470fa77..3039f83114 100644 --- a/packages/volto/src/helpers/FormValidation/FormValidation.jsx +++ b/packages/volto/src/helpers/FormValidation/FormValidation.jsx @@ -1,4 +1,4 @@ -import { map, uniq, keys, intersection, isEmpty } from 'lodash'; +import { map, keys, intersection, isEmpty } from 'lodash'; import { messages } from '../MessageLabels/MessageLabels'; import config from '@plone/volto/registry'; import { toast } from 'react-toastify'; @@ -11,7 +11,12 @@ import Toast from '@plone/volto/components/manage/Toast/Toast'; * @param {string | number} valueToCompare can compare '47' < 50 * @param {Function} intlFunc */ -const validationMessage = (isValid, criterion, valueToCompare, intlFunc) => +export const validationMessage = ( + isValid, + criterion, + valueToCompare, + intlFunc, +) => !isValid ? intlFunc(messages[criterion], { len: valueToCompare, @@ -19,142 +24,7 @@ const validationMessage = (isValid, criterion, valueToCompare, intlFunc) => : null; /** - * Returns if based on the criterion the value is lower or equal - * @param {string | number} value can compare '47' < 50 - * @param {string | number} valueToCompare can compare '47' < 50 - * @param {string} maxCriterion - * @param {Function} intlFunc - */ -const isMaxPropertyValid = (value, valueToCompare, maxCriterion, intlFunc) => { - const isValid = valueToCompare !== undefined ? value <= valueToCompare : true; - return validationMessage(isValid, maxCriterion, valueToCompare, intlFunc); -}; - -/** - * Returns if based on the criterion the value is higher or equal - * @param {string | number} value can compare '47' < 50 - * @param {string | number} valueToCompare can compare '47' < 50 - * @param {string} minCriterion - * @param {Function} intlFunc - */ -const isMinPropertyValid = (value, valueToCompare, minCriterion, intlFunc) => { - const isValid = valueToCompare !== undefined ? value >= valueToCompare : true; - return validationMessage(isValid, minCriterion, valueToCompare, intlFunc); -}; - -const widgetValidation = { - email: { - isValidEmail: (emailValue, emailObj, intlFunc) => { - // Email Regex taken from from WHATWG living standard: - // https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type=email) - const emailRegex = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; - const isValid = emailRegex.test(emailValue); - return !isValid ? intlFunc(messages.isValidEmail) : null; - }, - minLength: (emailValue, emailObj, intlFunc) => - isMinPropertyValid( - emailValue.length, - emailObj.minLength, - 'minLength', - intlFunc, - ), - maxLength: (emailValue, emailObj, intlFunc) => - isMaxPropertyValid( - emailValue.length, - emailObj.maxLength, - 'maxLength', - intlFunc, - ), - }, - url: { - isValidURL: (urlValue, urlObj, intlFunc) => { - var urlRegex = new RegExp( - '^(https?:\\/\\/)?' + // validate protocol - '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // validate domain name - '((\\d{1,3}\\.){3}\\d{1,3}))|' + // validate OR ip (v4) address - '(localhost)' + // validate OR localhost address - '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // validate port and path - '(\\?[;&a-z\\d%_.~+=-]*)?' + // validate query string - '(\\#[-a-z\\d_]*)?$', // validate fragment locator - 'i', - ); - const isValid = urlRegex.test(urlValue); - return !isValid ? intlFunc(messages.isValidURL) : null; - }, - minLength: (urlValue, urlObj, intlFunc) => - isMinPropertyValid( - urlValue.length, - urlObj.minLength, - 'minLength', - intlFunc, - ), - maxLength: (urlValue, urlObj, intlFunc) => - isMaxPropertyValid( - urlValue.length, - urlObj.maxLength, - 'maxLength', - intlFunc, - ), - }, - password: { - minLength: (passwordValue, passwordObj, intlFunc) => - isMinPropertyValid( - passwordValue.length, - passwordObj.minLength, - 'minLength', - intlFunc, - ), - maxLength: (passwordValue, passwordObj, intlFunc) => - isMaxPropertyValid( - passwordValue.length, - passwordObj.maxLength, - 'maxLength', - intlFunc, - ), - }, - string: { - minLength: (value, itemObj, intlFunc) => - isMinPropertyValid( - value.length, - itemObj.minLength, - 'minLength', - intlFunc, - ), - maxLength: (value, itemObj, intlFunc) => - isMaxPropertyValid( - value.length, - itemObj.maxLength, - 'maxLength', - intlFunc, - ), - }, - number: { - isNumber: (value, itemObj, intlFunc) => { - const floatRegex = /^[+-]?\d+(\.\d+)?$/; - const isValid = !isNaN(value) && floatRegex.test(value); - return !isValid ? intlFunc(messages.isNumber) : null; - }, - minimum: (value, itemObj, intlFunc) => - isMinPropertyValid(value, itemObj.minimum, 'minimum', intlFunc), - maximum: (value, itemObj, intlFunc) => - isMaxPropertyValid(value, itemObj.maximum, 'maximum', intlFunc), - }, - integer: { - isInteger: (value, itemObj, intlFunc) => { - const intRegex = /^-?[0-9]+$/; - const isValid = !isNaN(value) && intRegex.test(value); - return !isValid ? intlFunc(messages.isInteger) : null; - }, - minimum: (value, itemObj, intlFunc) => - isMinPropertyValid(value, itemObj.minimum, 'minimum', intlFunc), - maximum: (value, itemObj, intlFunc) => - isMaxPropertyValid(value, itemObj.maximum, 'maximum', intlFunc), - }, -}; - -/** - * The string that comes my not be a valid JSON + * The string that comes might not be a valid JSON * @param {string} requestItem */ export const tryParseJSON = (requestItem) => { @@ -171,24 +41,6 @@ export const tryParseJSON = (requestItem) => { return resultObj; }; -/** - * Returns errors if obj has unique Items - * @param {Object} field - * @param {*} fieldData - * @returns {Object[string]} - list of errors - */ -const hasUniqueItems = (field, fieldData, formatMessage) => { - const errors = []; - if ( - field.uniqueItems && - fieldData && - uniq(fieldData).length !== fieldData.length - ) { - errors.push(formatMessage(messages.uniqueItems)); - } - return errors; -}; - /** * If required fields are undefined, return list of errors * @returns {Object[string]} - list of errors @@ -252,35 +104,140 @@ const validateFieldsPerFieldset = ( touchedField, ); - map(schema.properties, (field, fieldId) => { - const fieldWidgetType = field.widget || field.type; - const widgetValidationCriteria = widgetValidation[fieldWidgetType] - ? Object.keys(widgetValidation[fieldWidgetType]) - : []; - let fieldData = formData[fieldId]; - // test each criterion ex maximum, isEmail, isUrl, maxLength etc - const fieldErrors = widgetValidationCriteria + function checkFieldErrors(fieldValidationCriteria, field, fieldData) { + return fieldValidationCriteria .map((widgetCriterion) => { const errorMessage = fieldData === undefined || fieldData === null ? null - : widgetValidation[fieldWidgetType][widgetCriterion]( - fieldData, + : widgetCriterion.component({ + value: fieldData, field, + formData, formatMessage, - ); + }); return errorMessage; }) .filter((item) => !!item); + } + + Object.entries(schema.properties).forEach(([fieldId, field]) => { + let fieldData = formData[fieldId]; + + // Default validation for all fields (required, minLength, maxLength) + const defaultFieldValidationCriteria = config.getComponents({ + name: 'fieldValidator', + dependencies: ['default'], + }); + + const defaultFieldErrors = checkFieldErrors( + defaultFieldValidationCriteria, + field, + fieldData, + ); + + // Validation per field type + const fieldType = field.type || 'string'; + // test each criterion eg. maximum, isEmail, isUrl, etc + const fieldTypeValidationCriteria = config.getComponents({ + name: 'fieldValidator', + dependencies: [fieldType], + }); + + const fieldErrors = checkFieldErrors( + fieldTypeValidationCriteria, + field, + fieldData, + ); + + // Validation per field widget + const fieldWidget = + field.widgetOptions?.frontendOptions?.widget || field.widget || ''; + + let widgetErrors = []; + if (fieldWidget) { + const fieldWidgetValidationCriteria = config.getComponents({ + name: 'fieldValidator', + dependencies: [fieldWidget], + }); + + widgetErrors = checkFieldErrors( + fieldWidgetValidationCriteria, + field, + fieldData, + ); + } + + // Validation per specific behavior and fieldId + const perBehaviorSpecificValidator = field.behavior; + let perBehaviorFieldErrors = []; + // test each criterion eg. maximum, isEmail, isUrl, etc + if (perBehaviorSpecificValidator) { + const specificFieldValidationCriteria = config.getComponents({ + name: 'fieldValidator', + dependencies: [perBehaviorSpecificValidator, fieldId], + }); + + perBehaviorFieldErrors = checkFieldErrors( + specificFieldValidationCriteria, + field, + fieldData, + ); + } + + // Validation per specific validator + const hasSpecificValidator = + field.widgetOptions?.frontendOptions?.validator || field.validator; + let specificFieldErrors = []; + // test each criterion eg. maximum, isEmail, isUrl, etc + if (hasSpecificValidator) { + const specificFieldValidationCriteria = config.getComponents({ + name: 'fieldValidator', + dependencies: [hasSpecificValidator], + }); + + specificFieldErrors = checkFieldErrors( + specificFieldValidationCriteria, + field, + fieldData, + ); + } + + // Validation per block type validator + const hasBlockType = formData['@type']; + let blockTypeFieldErrors = []; + // test each criterion eg. maximum, isEmail, isUrl, etc + if (hasBlockType) { + const blockTypeFieldValidationCriteria = config.getComponents({ + name: 'fieldValidator', + dependencies: [hasBlockType, fieldId], + }); + + blockTypeFieldErrors = checkFieldErrors( + blockTypeFieldValidationCriteria, + field, + fieldData, + ); + } - const uniqueErrors = hasUniqueItems(field, fieldData, formatMessage); - const mergedErrors = [...fieldErrors, ...uniqueErrors]; + const mergedErrors = [ + ...defaultFieldErrors, + ...fieldErrors, + ...widgetErrors, + ...perBehaviorFieldErrors, + ...specificFieldErrors, + ...blockTypeFieldErrors, + ]; if (mergedErrors.length > 0) { errors[fieldId] = [ ...(errors[fieldId] || []), + ...defaultFieldErrors, ...fieldErrors, - ...uniqueErrors, + ...widgetErrors, + ...perBehaviorFieldErrors, + ...specificFieldErrors, + ...blockTypeFieldErrors, ]; } }); diff --git a/packages/volto/src/helpers/FormValidation/FormValidation.test.js b/packages/volto/src/helpers/FormValidation/FormValidation.test.js index 9f97c6849a..057d6e0399 100644 --- a/packages/volto/src/helpers/FormValidation/FormValidation.test.js +++ b/packages/volto/src/helpers/FormValidation/FormValidation.test.js @@ -1,5 +1,7 @@ import FormValidation from './FormValidation'; import { messages } from '../MessageLabels/MessageLabels'; +import config from '@plone/volto/registry'; +import { urlValidator } from './validators'; const schema = { properties: { @@ -54,7 +56,7 @@ describe('FormValidation', () => { expect(FormValidation.validateFieldsPerFieldset()).toEqual({}); }); - it('validates missing required', () => { + it('required - validates missing', () => { expect( FormValidation.validateFieldsPerFieldset({ schema, @@ -66,7 +68,7 @@ describe('FormValidation', () => { }); }); - it('do not treat 0 as missing required value', () => { + it('required - do not treat 0 as missing required value', () => { let newSchema = { ...schema, properties: { @@ -98,7 +100,7 @@ describe('FormValidation', () => { ).toEqual({}); }); - it('validates incorrect email', () => { + it('email - validates incorrect', () => { expect( FormValidation.validateFieldsPerFieldset({ schema, @@ -110,7 +112,7 @@ describe('FormValidation', () => { }); }); - it('validates correct email', () => { + it('email - validates', () => { formData.email = 'test@domain.name'; expect( FormValidation.validateFieldsPerFieldset({ @@ -120,7 +122,8 @@ describe('FormValidation', () => { }), ).toEqual({}); }); - it('validates incorrect url', () => { + + it('url - validates incorrect url', () => { formData.url = 'foo'; expect( FormValidation.validateFieldsPerFieldset({ @@ -130,7 +133,8 @@ describe('FormValidation', () => { }), ).toEqual({ url: [messages.isValidURL.defaultMessage] }); }); - it('validates url', () => { + + it('url - validates', () => { formData.url = 'https://plone.org/'; expect( FormValidation.validateFieldsPerFieldset({ @@ -140,7 +144,8 @@ describe('FormValidation', () => { }), ).toEqual({}); }); - it('validates url with ip', () => { + + it('url - validates url with ip', () => { formData.url = 'http://127.0.0.1:8080/Plone'; expect( FormValidation.validateFieldsPerFieldset({ @@ -150,7 +155,8 @@ describe('FormValidation', () => { }), ).toEqual({}); }); - it('validates url with localhost', () => { + + it('url - validates url with localhost', () => { formData.url = 'http://localhost:8080/Plone'; expect( FormValidation.validateFieldsPerFieldset({ @@ -160,5 +166,591 @@ describe('FormValidation', () => { }), ).toEqual({}); }); + + it('default - widget validator from block - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + widget: 'isURL', + }, + }, + required: [], + }; + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['isURL'], + component: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('default - type and widget validator from block - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + type: 'customfieldtype', + widget: 'isURL', + }, + }, + required: [], + }; + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['customfieldtype', 'willFail'], + component: () => 'Fails', + }); + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['isURL'], + component: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: ['Fails', messages.isValidURL.defaultMessage], + }); + }); + + it('default - widget validator from content type set - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + widgetOptions: { + frontendOptions: { + widget: 'isURL', + }, + }, + }, + }, + required: [], + }; + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['isURL'], + component: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('default - min lenght', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'password', + description: '', + minLength: '8', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.minLength.defaultMessage], + }); + }); + + it('default - max lenght', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'password', + description: '', + maxLength: '8', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asdasdasdasdasd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.maxLength.defaultMessage], + }); + }); + + it('number - isNumber', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Number field', + type: 'number', + description: '', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: '1', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isNumber.defaultMessage], + }); + }); + + it('number - minimum', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Number field', + type: 'number', + description: '', + minimum: 8, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 1, + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.minimum.defaultMessage], + }); + }); + + it('number - maximum', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Number field', + type: 'number', + description: '', + maximum: 8, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 10, + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.maximum.defaultMessage], + }); + }); + + it('integer - isInteger', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Integer field', + type: 'integer', + description: '', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 1.5, + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isInteger.defaultMessage], + }); + }); + + it('integer - minimum', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Integer field', + type: 'integer', + description: '', + minimum: 8, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 1, + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.minimum.defaultMessage], + }); + }); + + it('integer - maximum', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Integer field', + type: 'number', + description: '', + maximum: 8, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 10, + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.maximum.defaultMessage], + }); + }); + + it('password - min lenght', () => { + let newSchema = { + ...schema, + properties: { + ...schema.properties, + password: { + title: 'password', + type: 'password', + description: '', + minLength: '8', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { username: 'test username', password: 'asd' }, + formatMessage, + }), + ).toEqual({ + password: [messages.minLength.defaultMessage], + }); + }); + + it('password - max lenght', () => { + let newSchema = { + ...schema, + properties: { + ...schema.properties, + password: { + title: 'password', + type: 'password', + description: '', + maxLength: '8', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { username: 'test username', password: 'asdasdasdasdasd' }, + formatMessage, + }), + ).toEqual({ + password: [messages.maxLength.defaultMessage], + }); + }); + + it('array - uniqueItems', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Array field', + type: 'array', + description: '', + uniqueItems: true, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: [1, 1], + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.uniqueItems.defaultMessage], + }); + }); + + it('array - uniqueItems false', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Array field', + type: 'array', + description: '', + uniqueItems: false, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: [1, 1], + }, + formatMessage, + }), + ).toEqual({}); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: [1], + }, + formatMessage, + }), + ).toEqual({}); + }); + + it('default - specific validator set - Errors', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + validator: 'isURL', + }, + }, + required: [], + }; + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['isURL'], + component: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'foo', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('default - specific validator set - Succeeds', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + validator: 'isURL', + }, + }, + required: [], + }; + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['isURL'], + component: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'https://plone.org/', + }, + formatMessage, + }), + ).toEqual({}); + }); + + it('default - specific validator from content type set - Succeeds', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + widgetOptions: { + frontendOptions: { + validator: 'isURL', + }, + }, + }, + }, + required: [], + }; + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['isURL'], + component: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'https://plone.org/', + }, + formatMessage, + }), + ).toEqual({}); + }); + + it('default - per behavior specific - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + behavior: 'plone.event', + title: 'Default field', + description: '', + }, + }, + required: [], + }; + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['plone.event', 'customField'], + component: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('block - per block type and fieldID specific - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + }, + }, + required: [], + }; + config.registerComponent({ + name: 'fieldValidator', + dependencies: ['slider', 'customField'], + component: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + '@type': 'slider', + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); }); + + // describe('validateBlockDataFields', () => { + + // }); }); diff --git a/packages/volto/src/helpers/FormValidation/validators.ts b/packages/volto/src/helpers/FormValidation/validators.ts new file mode 100644 index 0000000000..f6e4ed3057 --- /dev/null +++ b/packages/volto/src/helpers/FormValidation/validators.ts @@ -0,0 +1,148 @@ +import { validationMessage } from './FormValidation'; +import { messages } from '@plone/volto/helpers/MessageLabels/MessageLabels'; + +type MinMaxValidator = { + value: string | number; + fieldSpec: string | number; + criterion: string; + formatMessage: Function; +}; + +type Validator = { + value: string; + field: Record; + formData?: any; + formatMessage: Function; +}; + +export const isMaxPropertyValid = ({ + value, + fieldSpec, + criterion, + formatMessage, +}: MinMaxValidator) => { + const isValid = fieldSpec !== undefined ? value <= fieldSpec : true; + return validationMessage(isValid, criterion, fieldSpec, formatMessage); +}; + +export const isMinPropertyValid = ({ + value, + fieldSpec, + criterion, + formatMessage, +}: MinMaxValidator) => { + const isValid = fieldSpec !== undefined ? value >= fieldSpec : true; + return validationMessage(isValid, criterion, fieldSpec, formatMessage); +}; + +export const minLengthValidator = ({ + value, + field, + formatMessage, +}: Validator) => + isMinPropertyValid({ + value: value.length, + fieldSpec: field.minLength, + criterion: 'minLength', + formatMessage, + }); + +export const maxLengthValidator = ({ + value, + field, + formatMessage, +}: Validator) => + isMaxPropertyValid({ + value: value.length, + fieldSpec: field.maxLength, + criterion: 'maxLength', + formatMessage, + }); + +export const urlValidator = ({ value, formatMessage }: Validator) => { + const urlRegex = new RegExp( + '^(https?:\\/\\/)?' + // validate protocol + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // validate domain name + '((\\d{1,3}\\.){3}\\d{1,3}))|' + // validate OR ip (v4) address + '(localhost)' + // validate OR localhost address + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // validate port and path + '(\\?[;&a-z\\d%_.~+=-]*)?' + // validate query string + '(\\#[-a-z\\d_]*)?$', // validate fragment locator + 'i', + ); + const isValid = urlRegex.test(value); + return !isValid ? formatMessage(messages.isValidURL) : null; +}; + +export const emailValidator = ({ value, formatMessage }: Validator): string => { + // Email Regex taken from from WHATWG living standard: + // https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type=email) + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + const isValid = emailRegex.test(value); + return !isValid ? formatMessage(messages.isValidEmail) : null; +}; + +export const isNumber = ({ value, formatMessage }: Validator) => { + const floatRegex = /^[+-]?\d+(\.\d+)?$/; + const isValid = + typeof value === 'number' && !isNaN(value) && floatRegex.test(value); + return !isValid ? formatMessage(messages.isNumber) : null; +}; + +export const minimumValidator = ({ value, field, formatMessage }: Validator) => + isMinPropertyValid({ + value, + fieldSpec: field.minimum, + criterion: 'minimum', + formatMessage, + }); + +export const maximumValidator = ({ value, field, formatMessage }: Validator) => + isMaxPropertyValid({ + value, + fieldSpec: field.maximum, + criterion: 'maximum', + formatMessage, + }); + +export const isInteger = ({ value, formatMessage }: Validator) => { + const intRegex = /^-?[0-9]+$/; + const isValid = + typeof value === 'number' && !isNaN(value) && intRegex.test(value); + return !isValid ? formatMessage(messages.isInteger) : null; +}; + +export const hasUniqueItems = ({ value, field, formatMessage }: Validator) => { + if (!field.uniqueItems) { + return null; + } + const isValid = + field.uniqueItems && + value && + // unique items + [...new Set(value)].length === value.length; + return !isValid ? formatMessage(messages.uniqueItems) : null; +}; + +export const startEventDateRangeValidator = ({ + value, + field, + formData, + formatMessage, +}: Validator) => { + const isValid = + value && formData.end && new Date(value) < new Date(formData.end); + return !isValid ? formatMessage(messages.startEventRange) : null; +}; + +export const endEventDateRangeValidator = ({ + value, + field, + formData, + formatMessage, +}: Validator) => { + const isValid = + value && formData.start && new Date(value) < new Date(formData.start); + return !isValid ? formatMessage(messages.endEventRange) : null; +}; diff --git a/packages/volto/src/helpers/MessageLabels/MessageLabels.js b/packages/volto/src/helpers/MessageLabels/MessageLabels.js index a6483fe4c9..043129be1c 100644 --- a/packages/volto/src/helpers/MessageLabels/MessageLabels.js +++ b/packages/volto/src/helpers/MessageLabels/MessageLabels.js @@ -375,4 +375,16 @@ export const messages = defineMessages({ id: 'fileTooLarge', defaultMessage: 'This website does not accept files larger than {limit}', }, + blocksFieldsErrorTitle: { + id: 'blocksFieldsErrorTitle', + defaultMessage: 'Error in the block field {errorField}.', + }, + startEventRange: { + id: 'Start event date happens later than the end event date', + defaultMessage: 'Event start date happens later than the event end date', + }, + endEventRange: { + id: 'End event date happens before than the start event date', + defaultMessage: 'Event end date happens before the event start date', + }, }); diff --git a/packages/volto/test-setup-config.jsx b/packages/volto/test-setup-config.jsx index 3f9d1dc8d8..33ef175b17 100644 --- a/packages/volto/test-setup-config.jsx +++ b/packages/volto/test-setup-config.jsx @@ -23,6 +23,7 @@ import { } from '@plone/volto/config/ControlPanels'; import ListingBlockSchema from '@plone/volto/components/manage/Blocks/Listing/schema'; +import { registerValidators } from '@plone/volto/config/validation'; config.set('settings', { apiPath: 'http://localhost:8080/Plone', @@ -153,9 +154,13 @@ config.set('components', { component: (props) => Image component mock, }, }); + +registerValidators(config); + config.set('experimental', { addBlockButton: { enabled: false, }, }); + config.set('slots', {}); diff --git a/packages/volto/theme/themes/pastanaga/extras/sidebar.less b/packages/volto/theme/themes/pastanaga/extras/sidebar.less index 633cc070b2..5931049d89 100644 --- a/packages/volto/theme/themes/pastanaga/extras/sidebar.less +++ b/packages/volto/theme/themes/pastanaga/extras/sidebar.less @@ -518,6 +518,10 @@ padding-left: 0.5rem; text-overflow: ellipsis; white-space: nowrap; + + &.errored { + color: @red; + } } &.disable-interaction {