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..c76e95e42d --- /dev/null +++ b/docs/source/configuration/validation.md @@ -0,0 +1,340 @@ +--- +myst: + html_meta: + "description": "Client side form field validation" + "property=og:description": "Client side form field validation" + "property=og:title": "Client side form field validation" + "keywords": "Volto, Plone, frontend, React, configuration, form, fields, validation" +--- + +# Client side form field validation + +Volto provides an extensible way to validate form field values. +This extensibility is based on the Volto registry. +It applies to content types, custom programatically generated forms, and blocks schema settings. +The mechanism serializes all of them according to the [JSON schema standard](https://json-schema.org/draft/2020-12/json-schema-validation). +Finally Volto generates the form from the serialization. + + +(voltos-default-validators-label)= + +## Volto's default validators + +Volto provides a set of validators by default: + +### Strings +- minLength +- maxLength +- pattern + +### Password +- minLength +- maxLength +- pattern + +### Numbers +- isNumber +- minimum +- maximum + +### Integers +- isInteger +- minimum +- maximum + +### Arrays +- maxItems +- minItems +- uniqueItems + +### Per widget +- email +- url + +### Event content type +- event end date must be on or after its start date + +You can find them in the module {file}`packages/volto/src/config/validators.ts`. + + +## Register a validator + +You can register a validator using the `registerUtility` method in the registry API from your add-on configuration. + + +### Register and declare a simple validator + +This section describes how to validate a field with a specific validator, a common use case. + + +#### Volto custom forms and block schema forms + +When you define custom forms and block schema forms programatically, you can register a custom validator using the `format` property in your core using JSON schema. + +The following example shows how to create the schema for a block. + +```ts +let blockSchema = { + // ... fieldset definition in here + properties: { + ...schema.properties, + customField: { + title: 'My custom URL field', + description: '', + format: 'url' + }, + }, + required: [], +}; +``` + +You should register the `url` named validator as a Volto validator utility. +In the following example, the `urlValidator` method validator will be applied for the block field `customField` in the previous example. + +```ts +config.registerUtility({ + type: 'validator', + name: 'url', + method: urlValidator, +}) +``` + + +#### Content types + +You can also specify the `format` of content types using the schema hints in the backend using `frontendOptions`. + +```python +from plone.supermodel import model +from zope import schema + +class IMyContent(model.Schema): + directives.widget( + "customField", + frontendOptions={ + "format": "url", + }, + ) + customField = schema.TextLine( + title="Custom URL field", + required=False, + ) + # Rest of your content type definition +``` + +The response from `plone.restapi` will be something like the following. +It is slightly different from blocks JSON schema, but the validation engine will behave the same. +The `urlValidator` method validator will be applied for the content type field `customField` from the earlier example. + +```json +{ + "properties": { + "customField": { + "title": "Custom URL field", + "widgetOptions": { + "frontendOptions": { + "format": "url" + } + } + } + } +} +``` + + +### Advanced scenarios + +If, for some reason, you can't modify the existing implementation of the JSON schema definitions for existing content types, blocks, or forms, you can use the following advanced validator registrations. +These allow you to register validators according to whether it is a `field`, `widget`, `behaviorName` (for content types), or `blockType` (for blocks). + +#### Field `type` validators + +Field `type` validators are applied depending on the specified `type` of the field in the JSON schema from content types, forms, or blocks. + +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 shows how to define the JSON schema in a block. + +```ts +let blockSchema = { + // ... fieldset definition in here + properties: { + ...schema.properties, + customField: { + title: 'My custom field', + description: '', + type: 'integer', + maximum: 30 + }, + }, + required: [], +}; +``` + +```ts +config.registerUtility({ + type: 'validator', + name: 'maximum', + dependencies: { + fieldType: 'integer', + }, + method: maximumValidator, +}) +``` + + +#### Field `widget` validators + +Field `widget` validators are applied depending on the specified `widget` of the field. +You should specify the `widget` either in the JSON schema of the block or as additional data in the content type definition. + +The following example shows how to specify the `widget` in the JSON schema of the block. + +```ts +let blockSchema = { + // ... fieldset definition in here + properties: { + ...schema.properties, + customField: { + title: 'My custom field', + description: '', + widget: 'phoneNumber', + }, + }, + required: [], +}; +``` + +```ts +config.registerUtility({ + type: 'validator', + name: 'phoneNumber', + dependencies: { + widget: 'phoneNumber', + }, + method: phoneValidator, +}) +``` + +The following example shows how to specify the `widget` in the content type definition in the schema hints in the backend using `frontendOptions`. +The validation engine will behave the same as in the JSON schema of the block, applying the `urlValidator` method validator for the content type field `customField` in the previous example. + +```python +from plone.supermodel import model +from zope import schema + +class IMyContent(model.Schema): + directives.widget( + "customField", + frontendOptions={ + "widget": "url", + }, + ) + customField = schema.TextLine( + title="Custom URL field", + required=False, + ) + # Rest of your content type definition +``` + + +#### Behavior and field name validators + +Behavior and field name validators are applied depending on the behavior in combination with the name of the field. +These usually come from a content type definition. +This type of validator only applies to content type validators. +It takes the `behaviorName` and the `fieldName` as dependencies. + +```ts +config.registerUtility({ + type: 'validator', + name: 'dateRange', + dependencies: { + behaviorName: 'plone.eventbasic', + fieldName: 'start' + }, + method: startEventDateRangeValidator, +}) +``` + + +#### Block type and field name validators + +Block type and field name validators are applied depending on the block type in combination with the name of the field in the block settings JSON schema. +This type of validator only applies to blocks. +It takes the `blockType` and the `fieldName` as dependencies. + +```ts +config.registerUtility({ + type: 'validator', + name: 'url', + dependencies: { + blockType: 'slider', + fieldName: 'url' + }, + method: urlValidator, +}) +``` + + +### Override a validator + +You can override a validator in your add-on in the same way as any other component defined in the registry. +You can redefine them using the same `dependencies` and provide your own validator. + + +## 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; +}; +``` + + +## Invariants + +Using `formData`, you can perform validation checks using other field data as source. +This is useful when you want to validate two related fields, such as ensuring the end date of an event is after its start date. +You can create invariant validator types. +The following code snippet shows how to create a validator method that ensures the event content type's end date is after its start date. + +```ts +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, { + endDateValueOrEndFieldName: formData.end || 'end', + }) + : null; +}; +``` diff --git a/docs/source/upgrade-guide/index.md b/docs/source/upgrade-guide/index.md index ecb77f6195..8359867145 100644 --- a/docs/source/upgrade-guide/index.md +++ b/docs/source/upgrade-guide/index.md @@ -363,6 +363,7 @@ The only Volto component that makes use of it is `PersonalPreferences`. If you shadow it, then you should update this component. For the rest, it is unlikely that your code refers to this module, since it's used internally by Volto itself. + ### Renamed `test-setup-config` module `test-setup-config.js` has been renamed to `test-setup-config.jsx` since, in fact, it contains JSX. @@ -377,6 +378,57 @@ The `react-share` library and `SocialSharing` component has not been used in the If you still use it, you can add it to your main add-on dependency, and extract 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`. +None of those functions were exported in the first place, so no imports will be broken. +If you shadowed the module {file}`packages/volto/src/helpers/FormValidation/FormValidation.jsx`, you should review it and update it accordingly. + +```{seealso} +{doc}`../configuration/validation` +``` + +### Field validation for blocks + +`BlockDataForm` component now gets a new prop `errors`. +This prop must be assigned with the new prop passed down from the blocks engine `blocksErrors`. +If not passed down, the block can't display any field validation error. + +```tsx +// More component code above here + + const { + block, + blocksConfig, + contentType, + data, + navRoot, + onChangeBlock, + blocksErrors, + } = props; + +return ( + { + onChangeBlock(block, { + ...data, + [id]: value, + }); + }} + onChangeBlock={onChangeBlock} + formData={data} + blocksConfig={blocksConfig} + navRoot={navRoot} + contentType={contentType} + errors={blocksErrors} + /> +) +``` + ### `SchemaWidget` widget registration change Previously in the widget mapping, the `SchemaWidget` was registered in the `id` object and assigned to the `schema` key. diff --git a/packages/coresandbox/src/components/Blocks/FormBlock/Data.tsx b/packages/coresandbox/src/components/Blocks/FormBlock/Data.tsx new file mode 100644 index 0000000000..a0f7ce8d68 --- /dev/null +++ b/packages/coresandbox/src/components/Blocks/FormBlock/Data.tsx @@ -0,0 +1,31 @@ +import { useIntl } from 'react-intl'; +import { BlockDataForm } from '@plone/volto/components/manage/Form'; +import type { BlockEditProps } from '@plone/types'; + +const FormBlockData = (props: BlockEditProps) => { + const { block, blocksConfig, contentType, data, navRoot, onChangeBlock } = + props; + const intl = useIntl(); + const schema = blocksConfig[data['@type']].blockSchema({ intl, props }); + + return ( + { + onChangeBlock(block, { + ...data, + [id]: value, + }); + }} + onChangeBlock={onChangeBlock} + formData={data} + blocksConfig={blocksConfig} + navRoot={navRoot} + contentType={contentType} + /> + ); +}; + +export default FormBlockData; diff --git a/packages/coresandbox/src/components/Blocks/FormBlock/Edit.tsx b/packages/coresandbox/src/components/Blocks/FormBlock/Edit.tsx new file mode 100644 index 0000000000..225b91ae4f --- /dev/null +++ b/packages/coresandbox/src/components/Blocks/FormBlock/Edit.tsx @@ -0,0 +1,122 @@ +import { SidebarPortal } from '@plone/volto/components'; +import Data from './Data'; +import type { BlockEditProps } from '@plone/types'; +import { Helmet } from '@plone/volto/helpers'; + +import { defineMessages, useIntl } from 'react-intl'; +import { Container } from 'semantic-ui-react'; +import { Form } from '@plone/volto/components/manage/Form'; + +const messages = defineMessages({ + testForm: { + id: 'Test Form', + defaultMessage: 'Test Form', + }, + default: { + id: 'Default', + defaultMessage: 'Default', + }, + textlineTitle: { + id: 'Title', + defaultMessage: 'Title', + }, + emailTitle: { + id: 'Email', + defaultMessage: 'Email', + }, + + urlTitle: { + id: 'URL', + defaultMessage: 'Enter URL', + }, + + datetimeTitle: { + id: 'Date/Time', + defaultMessage: 'Enter Date/Time', + }, + + idTitle: { + id: 'Id', + defaultMessage: 'Enter ID', + }, + + richTextTitle: { + id: 'RichText', + defaultMessage: 'Enter RichText', + }, + + PasswordTitle: { + id: 'password', + defaultMessage: 'Password', + }, +}); +const FormBlockEdit = (props: BlockEditProps) => { + const intl = useIntl(); + const { selected } = props; + + return ( + <> +

+ Form Block +

+ + +
+ + + + + + ); +}; + +export default FormBlockEdit; diff --git a/packages/coresandbox/src/components/Blocks/FormBlock/View.tsx b/packages/coresandbox/src/components/Blocks/FormBlock/View.tsx new file mode 100644 index 0000000000..3d44816ed0 --- /dev/null +++ b/packages/coresandbox/src/components/Blocks/FormBlock/View.tsx @@ -0,0 +1,9 @@ +const FormBlockView = () => { + return ( +
+
Form Block View
+
+ ); +}; + +export default FormBlockView; diff --git a/packages/coresandbox/src/components/Blocks/FormBlock/schema.ts b/packages/coresandbox/src/components/Blocks/FormBlock/schema.ts new file mode 100644 index 0000000000..3903ac86c2 --- /dev/null +++ b/packages/coresandbox/src/components/Blocks/FormBlock/schema.ts @@ -0,0 +1,19 @@ +import type { BlockConfigBase } from '@plone/types'; + +export const formBlockSchema: BlockConfigBase['blockSchema'] = ({ intl }) => ({ + title: 'form Block', + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: ['title'], + }, + ], + properties: { + title: { + widget: 'textLine', + title: 'Title', + }, + }, + required: [], +}); diff --git a/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx b/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx index 104ea631ee..0485429876 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, + blocksErrors, + } = 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={blocksErrors} /> ); }; 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/coresandbox/src/components/TestForm.jsx b/packages/coresandbox/src/components/TestForm.jsx new file mode 100644 index 0000000000..23fa15787e --- /dev/null +++ b/packages/coresandbox/src/components/TestForm.jsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { Helmet } from '@plone/volto/helpers'; + +import { defineMessages, useIntl } from 'react-intl'; +import { Container } from 'semantic-ui-react'; +import { Form } from '@plone/volto/components/manage/Form'; + +const messages = defineMessages({ + testForm: { + id: 'Test Form', + defaultMessage: 'Test Form', + }, + default: { + id: 'Default', + defaultMessage: 'Default', + }, + textlineTitle: { + id: 'Title', + defaultMessage: 'Title', + }, + emailTitle: { + id: 'Email', + defaultMessage: 'Email', + }, + + urlTitle: { + id: 'URL', + defaultMessage: 'Enter URL', + }, + + datetimeTitle: { + id: 'Date/Time', + defaultMessage: 'Enter Date/Time', + }, + + idTitle: { + id: 'Id', + defaultMessage: 'Enter ID', + }, + + linkTitle: { + id: 'Link', + defaultMessage: 'Link to Document/Event/News', + }, + + linkDescription: { + id: 'Enter Link', + defaultMessage: 'Enter Link', + }, + + richTextTitle: { + id: 'RichText', + defaultMessage: 'Enter RichText', + }, + + PasswordTitle: { + id: 'password', + defaultMessage: 'Password', + }, +}); + +const TestForm = (props) => { + const intl = useIntl(); + /** + * Cancel handler + * @method onCancel + * @returns {undefined} + */ + const onCancel = () => { + props.history.goBack(); + }; + + return ( + + + + + ); +}; + +export default TestForm; diff --git a/packages/coresandbox/src/index.ts b/packages/coresandbox/src/index.ts index 264be43d9b..bad373a9b0 100644 --- a/packages/coresandbox/src/index.ts +++ b/packages/coresandbox/src/index.ts @@ -15,7 +15,10 @@ import type { ConfigType } from '@plone/registry'; import SlotComponentTest from './components/Slots/SlotTest'; import { ContentTypeCondition } from '@plone/volto/helpers'; import { RouteCondition } from '@plone/volto/helpers/Slots'; - +import TestForm from './components/TestForm'; +import FormBlockView from './components/Blocks/FormBlock/View'; +import FormBlockEdit from './components/Blocks/FormBlock/Edit'; +import { formBlockSchema } from './components/Blocks/FormBlock/schema'; const testBlock: BlockConfigBase = { id: 'testBlock', title: 'testBlock', @@ -133,6 +136,20 @@ const testBlockDefaultView: BlockConfigBase = { ], extensions: {}, }; +const testformBlock: BlockConfigBase = { + id: 'testformBlock', + title: 'Form Block', + icon: codeSVG, + group: 'common', + view: FormBlockView, + edit: FormBlockEdit, + blockSchema: formBlockSchema, + restricted: false, + mostUsed: true, + sidebarTab: 1, + + extensions: {}, +}; const listing = (config: ConfigType) => { return { @@ -177,10 +194,19 @@ declare module '@plone/types' { testBlockMultipleFieldsets: BlockConfigBase; testBlockDefaultEdit: BlockConfigBase; testBlockDefaultView: BlockConfigBase; + testformBlock: BlockConfigBase; } } const applyConfig = (config: ConfigType) => { + config.addonRoutes = [ + ...config.addonRoutes, + { + path: '/form', + component: TestForm, + exact: false, + }, + ]; config.blocks.blocksConfig.testBlock = testBlock; config.blocks.blocksConfig.inputBlock = inputBlock; config.blocks.blocksConfig.testBlockConditional = testBlockConditional; @@ -190,6 +216,7 @@ const applyConfig = (config: ConfigType) => { testBlockMultipleFieldsets; config.blocks.blocksConfig.testBlockDefaultEdit = testBlockDefaultEdit; config.blocks.blocksConfig.testBlockDefaultView = testBlockDefaultView; + config.blocks.blocksConfig.testformBlock = testformBlock; config.blocks.blocksConfig.listing = listing(config); config.views.contentTypesViews.Folder = NewsAndEvents; diff --git a/packages/registry/news/6161.feature b/packages/registry/news/6161.feature new file mode 100644 index 0000000000..005ffce826 --- /dev/null +++ b/packages/registry/news/6161.feature @@ -0,0 +1 @@ +Added `Utilities` registry for `registerUtility`, `getUtility`, and `getUtilities`. @sneridagh diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index 932f84bc01..830202f797 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -10,6 +10,7 @@ import type { SlotComponent, SlotPredicate, SlotsConfig, + UtilitiesConfig, ViewsConfig, WidgetsConfig, } from '@plone/types'; @@ -23,6 +24,7 @@ export type ConfigData = { addonRoutes: AddonRoutesConfig; slots: SlotsConfig; components: ComponentsConfig; + utilities: UtilitiesConfig; experimental: ExperimentalConfig; }; @@ -30,6 +32,10 @@ type GetComponentResult = { component: React.ComponentType; }; +type GetUtilityResult = { + method: (...args: any[]) => any; +}; + export type ConfigType = InstanceType; class Config { @@ -128,6 +134,14 @@ class Config { this._data.components = components; } + get utilities() { + return this._data.utilities; + } + + set utilities(utilities) { + this._data.utilities = utilities; + } + getComponent( options: { name: string; dependencies?: string[] | string } | string, ): GetComponentResult { @@ -406,6 +420,69 @@ class Config { const result = currentSlotComponents.slice(); currentSlot.data[name] = result.splice(position, 1); } + + registerUtility(options: { + name: string; + type: string; + dependencies?: Record; + method: (args: any) => any; + }) { + const { name, type, method, dependencies = {} } = options; + let depsString: string = ''; + if (!method) { + throw new Error('No method provided'); + } else { + depsString = Object.keys(dependencies) + .sort() + .map((key) => `${key}:${dependencies[key]}`) + .join('+'); + } + const utilityName = `${depsString ? `|${depsString}` : ''}${name}`; + + let utilityType = this._data.utilities[type]; + if (!utilityType) { + this._data.utilities[type] = {}; + utilityType = this._data.utilities[type]; + } + utilityType[utilityName] = { method }; + } + + getUtility(options: { + name: string; + type: string; + dependencies?: Record; + }): GetUtilityResult { + const { name, type, dependencies = {} } = options; + let depsString: string = ''; + depsString = Object.keys(dependencies) + .map((key) => `${key}:${dependencies[key]}`) + .join('+'); + + const utilityName = `${depsString ? `|${depsString}` : ''}${name}`; + + return this._data.utilities[type][utilityName] || {}; + } + + getUtilities(options: { + type: string; + dependencies?: Record; + }): Array { + const { type, dependencies = {} } = options; + let depsString: string = ''; + depsString = Object.keys(dependencies) + .map((key) => `${key}:${dependencies[key]}`) + .join('+'); + + const utilityName = `${depsString ? `|${depsString}` : ''}`; + const utilitiesKeys = Object.keys(this._data.utilities[type]).filter( + (key) => key.startsWith(utilityName), + ); + const utilities = utilitiesKeys.map( + (key) => this._data.utilities[type][key], + ); + + return utilities; + } } const instance = new Config(); diff --git a/packages/registry/src/registry.test.tsx b/packages/registry/src/registry.test.tsx index 4763b93946..ab189a9368 100644 --- a/packages/registry/src/registry.test.tsx +++ b/packages/registry/src/registry.test.tsx @@ -1,21 +1,23 @@ 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('utilities', {}); }); -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, @@ -919,3 +921,146 @@ describe('Slots registry', () => { }); }); }); + +describe('Utilities registry', () => { + afterEach(() => { + config.set('utilities', {}); + }); + + it('registers a simple utility', () => { + config.registerUtility({ + name: 'url', + type: 'validator', + method: () => 'this is a simple validator utility', + }); + + expect( + config.getUtility({ name: 'url', type: 'validator' }).method(), + ).toEqual('this is a simple validator utility'); + }); + + it('registers a utility with dependencies', () => { + config.registerUtility({ + name: 'email', + type: 'validator', + dependencies: { fieldType: 'email' }, + method: () => 'this is a validator utility with dependencies', + }); + + expect( + config + .getUtility({ + name: 'email', + dependencies: { fieldType: 'email' }, + type: 'validator', + }) + .method(), + ).toEqual('this is a validator utility with dependencies'); + }); + + it('registers utilities, one with and one without dependencies', () => { + config.registerUtility({ + name: 'email', + type: 'validator', + method: () => 'this is a simple validator utility', + }); + + config.registerUtility({ + name: 'email', + type: 'validator', + dependencies: { fieldType: 'email' }, + method: () => 'this is a validator utility with dependencies', + }); + + expect( + config.getUtility({ name: 'email', type: 'validator' }).method(), + ).toEqual('this is a simple validator utility'); + + expect( + config + .getUtility({ + name: 'email', + dependencies: { fieldType: 'email' }, + type: 'validator', + }) + .method(), + ).toEqual('this is a validator utility with dependencies'); + }); + + it('registers utilities with the same name, but different dependencies', () => { + config.registerUtility({ + name: 'email', + type: 'validator', + dependencies: { fieldType: 'email' }, + method: () => 'this is a validator utility with dependencies for email', + }); + + config.registerUtility({ + name: 'email', + type: 'validator', + dependencies: { fieldType: 'string' }, + method: () => 'this is a validator utility with dependencies for string', + }); + + expect( + config + .getUtility({ + name: 'email', + dependencies: { fieldType: 'string' }, + type: 'validator', + }) + .method(), + ).toEqual('this is a validator utility with dependencies for string'); + + expect( + config + .getUtility({ + name: 'email', + dependencies: { fieldType: 'email' }, + type: 'validator', + }) + .method(), + ).toEqual('this is a validator utility with dependencies for email'); + }); + + it('getUtilities - registers two utilities with the same dependencies and different names', () => { + config.registerUtility({ + name: 'minLength', + type: 'validator', + dependencies: { fieldType: 'string' }, + method: () => 'this is a validator for minLength', + }); + + config.registerUtility({ + name: 'maxLength', + type: 'validator', + dependencies: { fieldType: 'string' }, + method: () => 'this is a validator for maxLength', + }); + + expect( + config.getUtilities({ + dependencies: { fieldType: 'string' }, + type: 'validator', + }).length, + ).toEqual(2); + + expect( + config + .getUtilities({ + dependencies: { fieldType: 'string' }, + type: 'validator', + })[0] + .method(), + ).toEqual('this is a validator for minLength'); + + expect( + config + .getUtilities({ + dependencies: { fieldType: 'string' }, + type: 'validator', + })[1] + .method(), + ).toEqual('this is a validator for maxLength'); + }); +}); diff --git a/packages/types/news/6161.feature b/packages/types/news/6161.feature new file mode 100644 index 0000000000..c78c615f1d --- /dev/null +++ b/packages/types/news/6161.feature @@ -0,0 +1,2 @@ +Added `errors` shape to the `BlockEditProps`. +Added typings for `Utilities` registry. @sneridagh diff --git a/packages/types/src/blocks/index.d.ts b/packages/types/src/blocks/index.d.ts index 28043b0918..2c0a2e65de 100644 --- a/packages/types/src/blocks/index.d.ts +++ b/packages/types/src/blocks/index.d.ts @@ -116,4 +116,6 @@ export interface BlockEditProps { history: History; location: Location; token: string; + errors: Record>; + blocksErrors: Record>>; } diff --git a/packages/types/src/config/Utilities.d.ts b/packages/types/src/config/Utilities.d.ts new file mode 100644 index 0000000000..14bf92d8dc --- /dev/null +++ b/packages/types/src/config/Utilities.d.ts @@ -0,0 +1,4 @@ +// export type Utility = Record any }>; +export type Utility = Record any }>; + +export type UtilitiesConfig = Record; diff --git a/packages/types/src/config/index.d.ts b/packages/types/src/config/index.d.ts index 670b9636f9..58be498702 100644 --- a/packages/types/src/config/index.d.ts +++ b/packages/types/src/config/index.d.ts @@ -3,6 +3,7 @@ import type { BlocksConfig } from './Blocks'; import type { ViewsConfig } from './Views'; import type { WidgetsConfig } from './Widgets'; import type { SlotsConfig } from './Slots'; +import type { UtilitiesConfig } from './Utilities'; export type AddonReducersConfig = Record; @@ -33,12 +34,20 @@ export type ConfigData = { addonRoutes: AddonRoutesConfig; slots: SlotsConfig; components: ComponentsConfig; + utilities: UtilitiesConfig; experimental: ExperimentalConfig; }; -export { SettingsConfig, BlocksConfig, ViewsConfig, WidgetsConfig }; +export { + BlocksConfig, + SettingsConfig, + UtilitiesConfig, + ViewsConfig, + WidgetsConfig, +}; export * from './Blocks'; export * from './Settings'; export * from './Slots'; +export * from './Utilities'; export * from './Views'; export * from './Widgets'; diff --git a/packages/volto/Makefile b/packages/volto/Makefile index 225a878497..39948f2749 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/cypress/tests/coresandbox/fieldTypeValidation.js b/packages/volto/cypress/tests/coresandbox/fieldTypeValidation.js new file mode 100644 index 0000000000..e97c7bd132 --- /dev/null +++ b/packages/volto/cypress/tests/coresandbox/fieldTypeValidation.js @@ -0,0 +1,160 @@ +context('Test field types in example content', () => { + describe('Test', () => { + beforeEach(() => { + cy.intercept('GET', `/**/*?expand*`).as('content'); + cy.intercept('GET', '/**/@types/example').as('schema'); + cy.intercept('POST', '/**/').as('create'); + + // given a logged in editor and a page in edit mode + cy.autologin(); + cy.visit('/'); + cy.wait('@content'); + + // We always add a new example content type, fill the required + cy.navigate('/add?type=example'); + cy.wait('@schema'); + + cy.get('#field-title').type('An Example'); + }); + + it('Test Email field by entering email address without a domain', function () { + cy.get('#field-email_field').type('plone'); + cy.findAllByText('Email field').click(); + + cy.get('.form-error-label') + .contains('Input must be valid email (something@domain.com)') + .should('be.visible'); + cy.get('#toolbar-save').click(); + cy.wait(100); + cy.get('.toast-inner-content') + .contains('Input must be valid email (something@domain.com)') + .should('be.visible'); + }); + + it('Test Text Field', function () { + cy.get('#field-description').type('Volto Coresandbox fixture'); + cy.findAllByText('Description (Textline)').click(); + cy.get('.form-error-label').should('not.exist'); + cy.get('#toolbar-save').click(); + cy.wait(100); + cy.get('.toast-inner-content').should('not.exist'); + }); + + it('Test Integer & Float Field', function () { + cy.findByText('Number fields').click(); + cy.wait(500); + cy.get('#field-int_field').type('121'); + cy.get('#field-float_field').type('121.121'); + cy.findAllByText('Integer Field (e.g. 12)').click(); + cy.get('.form-error-label').should('not.exist'); + cy.get('#toolbar-save').click(); + cy.wait(100); + cy.get('.toast-inner-content').should('not.exist'); + }); + it('Test Date & Time Field', function () { + const date = new Date(); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const currentTime = date.toLocaleTimeString([], { + hour: 'numeric', + minute: 'numeric', + hour12: true, + }); + + cy.findByText('Date and time fields').click(); + cy.wait(100); + //Date + cy.get('#datetime_field-date').type(`${month}/${day}/${year}`); + cy.get('#datetime_field-date').should( + 'have.value', + `${month}/${day}/${year}`, + ); + + //Time + cy.get('#datetime_field-time').type(`${currentTime} `); + cy.get('#datetime_field-time').should('have.value', `${currentTime}`); + + cy.get('.form-error-label').should('not.exist'); + cy.get('#toolbar-save').click(); + cy.wait(100); + cy.get('.toast-inner-content').should('not.exist'); + }); + + it('Test List Field', function () { + cy.findAllByText('Choice and Multiple Choice fields').click(); + cy.wait(500); // We allow the Select component to lazy load + + cy.get('.react-select__placeholder') + .should('be.visible') + .contains('Select'); + + // We select the choice 'Beginner' of the field and remove it + cy.get('#field-list_field').click(); + cy.findAllByText('Beginner').click(); + cy.get( + '#field-list_field > .react-select__control > .react-select__value-container > .react-select__multi-value', + ) + .first('Beginner') + .get('.react-select__multi-value__remove') + .click(); + + // We select the choice 'Advanced' of the field + cy.get('#field-list_field').click(); + cy.findAllByText('Advanced').click(); + cy.get( + '#field-list_field > .react-select__control > .react-select__value-container > .react-select__multi-value', + ).should('have.text', 'Advanced'); + + cy.get('.form-error-label').should('not.exist'); + cy.get('#toolbar-save').click(); + cy.wait(100); + cy.get('.toast-inner-content').should('not.exist'); + }); + + it('Test Relationship Field', function () { + cy.findAllByText('Relation fields').click(); + cy.wait(500); // We allow the Select component to lazy load + cy.get('.react-select__placeholder') + .should('be.visible') + .contains('Select'); + + // We select the choice 'Beginner' of the field and remove it + + cy.get('#field-relationchoice_field > .react-select__control ') + .click() + .get('.react-select__menu-list > #react-select-6-option-4') + .click(); + + cy.wait(100); + cy.get('.form-error-label').should('not.exist'); + cy.get('#toolbar-save').click(); + cy.wait(100); + cy.get('.toast-inner-content').should('not.exist'); + cy.wait('@create'); + cy.wait('@content'); + cy.get('.relation').should('have.attr', 'href'); + }); + //relation widget Folder private + + it('Test URI Field by entering invalid URI', function () { + cy.findAllByText('Other fields').click(); + cy.get('#field-uri_field').type('plone'); + cy.findAllByText('URI field').click(); + + cy.get('.form-error-label') + .contains( + 'Input must be valid url (www.something.com or http(s)://www.something.com)', + ) + .should('be.visible'); + cy.get('#toolbar-save').click(); + cy.wait(100); + cy.get('.toast-inner-content') + .contains( + 'Input must be valid url (www.something.com or http(s)://www.something.com)', + ) + .should('be.visible'); + }); + }); +}); diff --git a/packages/volto/cypress/tests/coresandbox/formBlockValidation.js b/packages/volto/cypress/tests/coresandbox/formBlockValidation.js new file mode 100644 index 0000000000..1de78bc7e8 --- /dev/null +++ b/packages/volto/cypress/tests/coresandbox/formBlockValidation.js @@ -0,0 +1,109 @@ +context('Test Field Type in form block', () => { + describe('Test', () => { + beforeEach(() => { + cy.intercept('GET', `/**/*?expand*`).as('content'); + cy.intercept('GET', '/**/Document').as('schema'); + // given a logged in editor and a page in edit mode + cy.autologin(); + cy.createContent({ + contentType: 'Document', + contentId: 'document', + contentTitle: 'Test document', + }); + cy.visit('/'); + cy.wait('@content'); + cy.navigate('/document'); + cy.wait('@content'); + cy.navigate('/document/edit'); + cy.wait('@schema'); + cy.getSlateTitle(); + + cy.getSlate().click(); + cy.get('.button .block-add-button').click({ force: true }); + + cy.get('.blocks-chooser .mostUsed .button.testformBlock').click(); + }); + + it('Test Email field by entering email address without a domain', function () { + cy.get('#field-email').click({ force: true }).type('plone'); + cy.findAllByText('Email').click(); + cy.get('.form-error-label') + .contains('Input must be valid email (something@domain.com)') + .should('be.visible'); + }); + + it('Test Text Field', function () { + cy.get('#field-textline') + .click({ force: true }) + .type('Volto Coresandbox fixture'); + cy.get('.form-error-label').should('not.exist'); + }); + + it('Test Date & Time Field', function () { + const date = new Date(); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const currentTime = date.toLocaleTimeString([], { + hour: 'numeric', + minute: 'numeric', + hour12: true, + }); + + cy.findByText('Enter Date/Time').click(); + cy.wait(100); + //Date + cy.get('#datetime-date').type(`${month}/${day}/${year}`); + cy.get('#datetime-date').should('have.value', `${month}/${day}/${year}`); + + //Time + cy.get('#datetime-time').type(`${currentTime} `); + cy.get('#datetime-time').should('have.value', `${currentTime}`); + + cy.get('.form-error-label').should('not.exist'); + cy.findByText('Enter Date/Time').click(); + }); + + it('Test URI Field by entering invalid URI', function () { + cy.get('#field-url').click({ force: true }).type('plone'); + cy.findAllByText('Enter URL').click(); + cy.get('.form-error-label') + .contains( + 'Input must be valid url (www.something.com or http(s)://www.something.com)', + ) + .should('be.visible'); + }); + + it('Test ID Field Type', function () { + cy.get('#field-id').click({ force: true }).type('Plone'); + cy.findAllByText('Enter ID').click(); + cy.get('.form-error-label') + .contains( + 'Only 7-bit bytes characters are allowed. Cannot contain uppercase letters, special characters: <, >, &, #, /, ?, or others that are illegal in URLs. Cannot start with: _, aq_, @@, ++. Cannot end with __. Cannot be: request,contributors, ., .., "". Cannot contain new lines.', + ) + .should('be.visible'); + }); + + it('Test RichText Field Type', function () { + cy.get('p[data-slate-node="element"]') + .click({ force: true }) + .type('Plone{selectall}'); + cy.get('a[title="Bold"]').click(); + cy.get('a[title="Italic"]').click(); + cy.get('.slate_wysiwyg_box').click(); + }); + it('Missing required field error', function () { + cy.get('#field-textline').type('Volto Coresandbox fixture'); + cy.get('#field-email').type('plone@org.com'); + cy.get('#field-password').click(); + cy.get('#field-email').click(); + cy.get('.form-error-label') + .contains('Required input is missing.') + .should('be.visible'); + cy.get('.ui.icon.negative.attached.message > .content ').should( + 'be.visible', + ); + }); + }); +}); diff --git a/packages/volto/cypress/tests/coresandbox/formFieldTypeValidation.js b/packages/volto/cypress/tests/coresandbox/formFieldTypeValidation.js new file mode 100644 index 0000000000..09d9397229 --- /dev/null +++ b/packages/volto/cypress/tests/coresandbox/formFieldTypeValidation.js @@ -0,0 +1,103 @@ +context('Test Field Type in form ', () => { + describe('Test', () => { + beforeEach(() => { + cy.intercept('GET', `/**/*?expand*`).as('content'); + cy.intercept('GET', '/**/@types/example').as('schema'); + cy.intercept('POST', '/**/').as('create'); + + // given a logged in editor and a page in edit mode + cy.autologin(); + cy.visit('/'); + cy.wait('@content'); + cy.navigate('/form'); + }); + + it('Test Email field by entering email address without a domain', function () { + cy.get('#field-email').type('plone'); + cy.findAllByText('Email').click(); + + cy.get('.form-error-label') + .contains('Input must be valid email (something@domain.com)') + .should('be.visible'); + }); + + it('Test Text Field', function () { + cy.get('#field-textline').type('Volto Coresandbox fixture'); + cy.findAllByText('Title').click(); + cy.get('.form-error-label').should('not.exist'); + }); + + it('Test Date & Time Field', function () { + const date = new Date(); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const currentTime = date.toLocaleTimeString([], { + hour: 'numeric', + minute: 'numeric', + hour12: true, + }); + + cy.findByText('Enter Date/Time').click(); + cy.wait(100); + //Date + cy.get('#datetime-date').type(`${month}/${day}/${year}`); + cy.get('#datetime-date').should('have.value', `${month}/${day}/${year}`); + + //Time + cy.get('#datetime-time').type(`${currentTime} `); + cy.get('#datetime-time').should('have.value', `${currentTime}`); + + cy.get('.form-error-label').should('not.exist'); + cy.findByText('Enter Date/Time').click(); + }); + + it('Test URI Field by entering invalid URI', function () { + cy.get('#field-url').type('plone'); + cy.findAllByText('Enter URL').click(); + cy.get('.form-error-label') + .contains( + 'Input must be valid url (www.something.com or http(s)://www.something.com)', + ) + .should('be.visible'); + }); + + it('Test ID Field Type', function () { + cy.get('#field-id').type('Plone'); + cy.findAllByText('Enter ID').click(); + cy.get('.form-error-label') + .contains( + 'Only 7-bit bytes characters are allowed. Cannot contain uppercase letters, special characters: <, >, &, #, /, ?, or others that are illegal in URLs. Cannot start with: _, aq_, @@, ++. Cannot end with __. Cannot be: request,contributors, ., .., "". Cannot contain new lines.', + ) + .should('be.visible'); + }); + it('Test Link Document/News/Event Field Type', function () { + cy.get('.objectbrowser-field > .selected-values').click(); + cy.get('svg.icon.home-icon').click(); + cy.get('li').last().click(); + cy.findAllByText('Link to Document/Event/News').click(); + cy.get('.objectbrowser-field > .selected-values > div.ui.label').should( + 'be.visible', + ); + }); + it('Test RichText Field Type', function () { + cy.get('.slate_wysiwyg_box').type('Plone{selectall}'); + cy.get('a[title="Bold"]').click(); + cy.get('a[title="Italic"]').click(); + cy.get('.slate_wysiwyg_box').click(); + }); + it('Missing required field error', function () { + cy.get('#field-textline').type('Volto Coresandbox fixture'); + cy.get('#field-email').type('plone@org.com'); + cy.get('#field-password').click(); + cy.get('#field-email').click(); + cy.get('.form-error-label') + .contains('Required input is missing.') + .should('be.visible'); + cy.get('.ui.icon.negative.attached.message > .content ').should( + 'be.visible', + ); + }); + }); +}); diff --git a/packages/volto/locales/ca/LC_MESSAGES/volto.po b/packages/volto/locales/ca/LC_MESSAGES/volto.po index c208633667..97299c5c52 100644 --- a/packages/volto/locales/ca/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ca/LC_MESSAGES/volto.po @@ -1355,11 +1355,21 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "Llista d'esdeveniments" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3665,6 +3675,16 @@ msgstr "No s'ha pogut suprimir l'element." msgid "The link address is:" msgstr "L'adreça de l'enllaç és:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3681,6 +3701,11 @@ msgstr "El procés de registre ha estat satisfactori. Si us plau, comproveu la v msgid "The site configuration is outdated and needs to be upgraded." msgstr "" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4395,6 +4420,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..3ae526f295 100644 --- a/packages/volto/locales/de/LC_MESSAGES/volto.po +++ b/packages/volto/locales/de/LC_MESSAGES/volto.po @@ -1354,11 +1354,21 @@ msgstr "Fehler" msgid "Event" msgstr "Ereignis" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "Termine" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3664,6 +3674,16 @@ msgstr "Das Objekt kann nicht gelöscht werden." msgid "The link address is:" msgstr "Die Linkadresse lautet:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3680,6 +3700,11 @@ msgstr "Bitte prüfen Sie Ihr E-Mail Postfach. Sie sollten eine E-Mail erhalten msgid "The site configuration is outdated and needs to be upgraded." msgstr "Die Seitenkonfiguration ist veraltet und muss aktualisiert werden." +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4394,6 +4419,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..309d263b01 100644 --- a/packages/volto/locales/en/LC_MESSAGES/volto.po +++ b/packages/volto/locales/en/LC_MESSAGES/volto.po @@ -1349,11 +1349,21 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3659,6 +3669,16 @@ msgstr "" msgid "The link address is:" msgstr "" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3675,6 +3695,11 @@ msgstr "" msgid "The site configuration is outdated and needs to be upgraded." msgstr "" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4389,6 +4414,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..bcd3892d27 100644 --- a/packages/volto/locales/es/LC_MESSAGES/volto.po +++ b/packages/volto/locales/es/LC_MESSAGES/volto.po @@ -1356,11 +1356,21 @@ msgstr "Error" msgid "Event" msgstr "Evento" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "Listado de eventos" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3666,6 +3676,16 @@ msgstr "El elemento no se ha podido eliminar." msgid "The link address is:" msgstr "La dirección del enlace es:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3682,6 +3702,11 @@ msgstr "El registro fue exitoso. Por favor, verifique su bandeja de entrada para msgid "The site configuration is outdated and needs to be upgraded." msgstr "La configuración del sitio está anticuada y debe ser actualizada." +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4396,6 +4421,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..db6c019cb4 100644 --- a/packages/volto/locales/eu/LC_MESSAGES/volto.po +++ b/packages/volto/locales/eu/LC_MESSAGES/volto.po @@ -1356,11 +1356,21 @@ msgstr "Errorea" msgid "Event" msgstr "Hitzordua" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "Hitzorduen zerrenda" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3666,6 +3676,16 @@ msgstr "Elementua ezin izan da ezabatu." msgid "The link address is:" msgstr "Loturaren helbidea hauxe da:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3682,6 +3702,11 @@ msgstr "Izen-emate prozesua ondo egin duzu. Begiratu zure eposta, kontua aktibat msgid "The site configuration is outdated and needs to be upgraded." msgstr "Atariaren konfigurazioa zaharkituta dago eta eguneratu egin behar da." +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4396,6 +4421,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..a894243780 100644 --- a/packages/volto/locales/fi/LC_MESSAGES/volto.po +++ b/packages/volto/locales/fi/LC_MESSAGES/volto.po @@ -1354,11 +1354,21 @@ msgstr "" msgid "Event" msgstr "Tapahtuma" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "Tapahtumalista" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3664,6 +3674,16 @@ msgstr "Tätä kohdetta ei voitu poistaa." msgid "The link address is:" msgstr "Linkin osoite on:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3680,6 +3700,11 @@ msgstr "Rekisteröinti onnistui. Tarkista, saitko sähköpostiisi ohjeet käytt msgid "The site configuration is outdated and needs to be upgraded." msgstr "Sivuston konfiguraatio on vanhentunut ja se pitää päivittää." +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4394,6 +4419,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..92c6836cb5 100644 --- a/packages/volto/locales/fr/LC_MESSAGES/volto.po +++ b/packages/volto/locales/fr/LC_MESSAGES/volto.po @@ -1356,11 +1356,21 @@ msgstr "Erreur" msgid "Event" msgstr "Événement" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "Liste des événements" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3666,6 +3676,16 @@ msgstr "L'élement n'a pas pu être supprimé." msgid "The link address is:" msgstr "L'adresse du lien est :" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3682,6 +3702,11 @@ msgstr "Le processus d'inscription a réussi. Veuillez vérifier votre boîte e- msgid "The site configuration is outdated and needs to be upgraded." msgstr "La configuration du site nécessite une mise à niveau." +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4396,6 +4421,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..7acbc22305 100644 --- a/packages/volto/locales/hi/LC_MESSAGES/volto.po +++ b/packages/volto/locales/hi/LC_MESSAGES/volto.po @@ -1349,11 +1349,21 @@ msgstr "त्रुटि" msgid "Event" msgstr "आयोजन" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "आयोजन सूची" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3659,6 +3669,16 @@ msgstr "आइटम को हटाया नहीं जा सका।" msgid "The link address is:" msgstr "लिंक पता है:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3675,6 +3695,11 @@ msgstr "रजिस्ट्रेशन प्रक्रिया सफल msgid "The site configuration is outdated and needs to be upgraded." msgstr "साइट कॉन्फ़िगरेशन पुरानी है और अपग्रेड की आवश्यकता है।" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4389,6 +4414,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..11f9e88370 100644 --- a/packages/volto/locales/it/LC_MESSAGES/volto.po +++ b/packages/volto/locales/it/LC_MESSAGES/volto.po @@ -1349,11 +1349,21 @@ msgstr "Errore" msgid "Event" msgstr "Evento" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "Elenco eventi" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3659,6 +3669,16 @@ msgstr "L'elemento non può essere eliminato." msgid "The link address is:" msgstr "L'indirizzo del collegamento è:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3675,6 +3695,11 @@ msgstr "La registrazione è avvenuta correttamente. Per favore controlla la tua msgid "The site configuration is outdated and needs to be upgraded." msgstr "La configurazione del sito è obsoleta e deve essere aggiornata." +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4389,6 +4414,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..b9b6ceb98f 100644 --- a/packages/volto/locales/ja/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ja/LC_MESSAGES/volto.po @@ -1354,11 +1354,21 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3664,6 +3674,16 @@ msgstr "" msgid "The link address is:" msgstr "" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3680,6 +3700,11 @@ msgstr "The registration process has been successful. Please check your e-mail i msgid "The site configuration is outdated and needs to be upgraded." msgstr "" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4394,6 +4419,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..efdef3cd6f 100644 --- a/packages/volto/locales/nl/LC_MESSAGES/volto.po +++ b/packages/volto/locales/nl/LC_MESSAGES/volto.po @@ -1353,11 +1353,21 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3663,6 +3673,16 @@ msgstr "" msgid "The link address is:" msgstr "" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3679,6 +3699,11 @@ msgstr "" msgid "The site configuration is outdated and needs to be upgraded." msgstr "" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4393,6 +4418,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..d245b26dfb 100644 --- a/packages/volto/locales/pt/LC_MESSAGES/volto.po +++ b/packages/volto/locales/pt/LC_MESSAGES/volto.po @@ -1354,11 +1354,21 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3664,6 +3674,16 @@ msgstr "" msgid "The link address is:" msgstr "" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3680,6 +3700,11 @@ msgstr "O processo de registo foi bem sucedido. Por favor verifique no seu e-mai msgid "The site configuration is outdated and needs to be upgraded." msgstr "" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4394,6 +4419,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..364922d608 100644 --- a/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po +++ b/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po @@ -1355,11 +1355,21 @@ msgstr "Erro" msgid "Event" msgstr "Evento" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "Listagem de Evento" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3665,6 +3675,16 @@ msgstr "O item não pôde ser excluído." msgid "The link address is:" msgstr "O endereço do link é:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3681,6 +3701,11 @@ msgstr "O processo de registro foi bem sucedido. Verifique sua caixa de entrada msgid "The site configuration is outdated and needs to be upgraded." msgstr "A configuração do site está desatualizada e precisa ser atualizada." +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4395,6 +4420,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..7c203620ef 100644 --- a/packages/volto/locales/ro/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ro/LC_MESSAGES/volto.po @@ -1349,11 +1349,21 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3659,6 +3669,16 @@ msgstr "Elementul nu a putut fi șters." msgid "The link address is:" msgstr "Adresa linkului este:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3675,6 +3695,11 @@ msgstr "Procesul de înregistrare a avut succes. Vă rugăm să verificați căs msgid "The site configuration is outdated and needs to be upgraded." msgstr "" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4389,6 +4414,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..d38fea965b 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-24T09:39:31.887Z\n" "Last-Translator: Plone i18n \n" "Language-Team: Plone i18n \n" "Content-Type: text/plain; charset=utf-8\n" @@ -1351,11 +1351,21 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3661,6 +3671,16 @@ msgstr "" msgid "The link address is:" msgstr "" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3677,6 +3697,11 @@ msgstr "" msgid "The site configuration is outdated and needs to be upgraded." msgstr "" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4391,6 +4416,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..50d2927f3a 100644 --- a/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po +++ b/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po @@ -1355,11 +1355,21 @@ msgstr "" msgid "Event" msgstr "事件" +#. Default: "Event end date must be on or after {startDateValueOrStartFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event end date must be on or after {startDateValueOrStartFieldName}" +msgstr "" + #. Default: "Event listing" #: config/Views msgid "Event listing" msgstr "事件列表" +#. Default: "Event start date must be on or before {endDateValueOrEndFieldName}" +#: helpers/MessageLabels/MessageLabels +msgid "Event start date must be on or before {endDateValueOrEndFieldName}" +msgstr "" + #. Default: "Event view" #: config/Views msgid "Event view" @@ -3665,6 +3675,16 @@ msgstr "此条目无法被删除。" msgid "The link address is:" msgstr "链接地址为:" +#. Default: "The number of items must be greater than or equal to {minItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be greater than or equal to {minItems}" +msgstr "" + +#. Default: "The number of items must be less than or equal to {maxItems}" +#: helpers/MessageLabels/MessageLabels +msgid "The number of items must be less than or equal to {maxItems}" +msgstr "" + #. Default: "The provided alternative url already exists!" #: components/manage/Aliases/Aliases msgid "The provided alternative url already exists!" @@ -3681,6 +3701,11 @@ msgstr "注册过程成功完成。请在您的电子邮箱中查看有关如何 msgid "The site configuration is outdated and needs to be upgraded." msgstr "网站配置已过时,需要升级。" +#. Default: "The value does not match the pattern {pattern}" +#: helpers/MessageLabels/MessageLabels +msgid "The value does not match the pattern {pattern}" +msgstr "" + #. Default: "The working copy was discarded" #: components/manage/Toolbar/More msgid "The working copy was discarded" @@ -4395,6 +4420,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..6c3a087353 --- /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 shadowed this component in your project or add-on, you should review it and update it accordingly. diff --git a/packages/volto/news/6217.internal b/packages/volto/news/6217.internal new file mode 100644 index 0000000000..75258c4a17 --- /dev/null +++ b/packages/volto/news/6217.internal @@ -0,0 +1 @@ +Added Cypress test for field types in example content - @Tishasoumya-02 \ No newline at end of file diff --git a/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx b/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx index a5fb57e217..65d3f6fb0d 100644 --- a/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx +++ b/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx @@ -58,6 +58,8 @@ const BlocksForm = (props) => { history, location, token, + errors, + blocksErrors, } = props; const [isClient, setIsClient] = useState(false); @@ -281,6 +283,7 @@ const BlocksForm = (props) => { onDeleteBlock={onDeleteBlock} onSelectBlock={onSelectBlock} removable + errors={blocksErrors} /> , document.getElementById('sidebar-order'), @@ -354,6 +357,8 @@ const BlocksForm = (props) => { history, location, token, + errors, + blocksErrors, }; 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/Blocks/Container/Data.jsx b/packages/volto/src/components/manage/Blocks/Container/Data.jsx index d9b24bf6e0..83e75b469c 100644 --- a/packages/volto/src/components/manage/Blocks/Container/Data.jsx +++ b/packages/volto/src/components/manage/Blocks/Container/Data.jsx @@ -2,8 +2,15 @@ import { useIntl } from 'react-intl'; import { BlockDataForm } from '@plone/volto/components/manage/Form'; const ContainerData = (props) => { - const { block, blocksConfig, data, onChangeBlock, navRoot, contentType } = - props; + const { + block, + blocksConfig, + blocksErrors, + data, + onChangeBlock, + navRoot, + contentType, + } = props; const intl = useIntl(); const schema = blocksConfig[data['@type']].blockSchema({ intl }); @@ -28,6 +35,7 @@ const ContainerData = (props) => { blocksConfig={blocksConfig} navRoot={navRoot} contentType={contentType} + errors={blocksErrors} /> ); }; diff --git a/packages/volto/src/components/manage/Blocks/Image/ImageSidebar.jsx b/packages/volto/src/components/manage/Blocks/Image/ImageSidebar.jsx index 94a43e1d3c..dd35733475 100644 --- a/packages/volto/src/components/manage/Blocks/Image/ImageSidebar.jsx +++ b/packages/volto/src/components/manage/Blocks/Image/ImageSidebar.jsx @@ -10,8 +10,15 @@ import imageSVG from '@plone/volto/icons/image.svg'; import trashSVG from '@plone/volto/icons/delete.svg'; const ImageSidebar = (props) => { - const { blocksConfig, data, block, onChangeBlock, navRoot, contentType } = - props; + const { + blocksConfig, + blocksErrors, + data, + block, + onChangeBlock, + navRoot, + contentType, + } = props; const intl = useIntl(); const schema = ImageSchema({ formData: data, intl }); return ( @@ -98,6 +105,7 @@ const ImageSidebar = (props) => { blocksConfig={blocksConfig} navRoot={navRoot} contentType={contentType} + errors={blocksErrors} /> ); diff --git a/packages/volto/src/components/manage/Blocks/Listing/ListingData.jsx b/packages/volto/src/components/manage/Blocks/Listing/ListingData.jsx index a531009b3f..f19ba3ff3e 100644 --- a/packages/volto/src/components/manage/Blocks/Listing/ListingData.jsx +++ b/packages/volto/src/components/manage/Blocks/Listing/ListingData.jsx @@ -4,8 +4,15 @@ import { useIntl } from 'react-intl'; import { BlockDataForm } from '@plone/volto/components/manage/Form'; const ListingData = (props) => { - const { data, block, blocksConfig, onChangeBlock, navRoot, contentType } = - props; + const { + data, + block, + blocksConfig, + blocksErrors, + onChangeBlock, + navRoot, + contentType, + } = props; const intl = useIntl(); const schema = blocksConfig.listing.blockSchema({ ...props, @@ -28,6 +35,7 @@ const ListingData = (props) => { block={block} navRoot={navRoot} contentType={contentType} + errors={blocksErrors} /> ); }; diff --git a/packages/volto/src/components/manage/Blocks/Maps/MapsSidebar.jsx b/packages/volto/src/components/manage/Blocks/Maps/MapsSidebar.jsx index 1bc2179f0b..977b7dc870 100644 --- a/packages/volto/src/components/manage/Blocks/Maps/MapsSidebar.jsx +++ b/packages/volto/src/components/manage/Blocks/Maps/MapsSidebar.jsx @@ -18,7 +18,8 @@ const messages = defineMessages({ }); const MapsSidebar = (props) => { - const { data, block, onChangeBlock, navRoot, contentType } = props; + const { data, block, blocksErrors, onChangeBlock, navRoot, contentType } = + props; const intl = useIntl(); const schema = MapsSchema({ ...props, intl }); @@ -44,6 +45,7 @@ const MapsSidebar = (props) => { block={block} navRoot={navRoot} contentType={contentType} + errors={blocksErrors} /> )} diff --git a/packages/volto/src/components/manage/Blocks/Search/SearchBlockEdit.jsx b/packages/volto/src/components/manage/Blocks/Search/SearchBlockEdit.jsx index 184ee3579b..56ac5769e4 100644 --- a/packages/volto/src/components/manage/Blocks/Search/SearchBlockEdit.jsx +++ b/packages/volto/src/components/manage/Blocks/Search/SearchBlockEdit.jsx @@ -23,6 +23,7 @@ const messages = defineMessages({ const SearchBlockEdit = (props) => { const { block, + blocksErrors, onChangeBlock, data, selected, @@ -94,6 +95,7 @@ const SearchBlockEdit = (props) => { formData={data} navRoot={navRoot} contentType={contentType} + errors={blocksErrors} /> diff --git a/packages/volto/src/components/manage/Blocks/Teaser/Data.jsx b/packages/volto/src/components/manage/Blocks/Teaser/Data.jsx index f57bf762a5..69bb3db42a 100644 --- a/packages/volto/src/components/manage/Blocks/Teaser/Data.jsx +++ b/packages/volto/src/components/manage/Blocks/Teaser/Data.jsx @@ -31,8 +31,15 @@ const messages = defineMessages({ }); const TeaserData = (props) => { - const { block, blocksConfig, data, onChangeBlock, navRoot, contentType } = - props; + const { + block, + blocksConfig, + blocksErrors, + data, + onChangeBlock, + navRoot, + contentType, + } = props; const dispatch = useDispatch(); const intl = useIntl(); @@ -161,6 +168,7 @@ const TeaserData = (props) => { actionButton={data.overwrite && ActionButton} navRoot={navRoot} contentType={contentType} + errors={blocksErrors} /> ); }; diff --git a/packages/volto/src/components/manage/Blocks/ToC/Edit.jsx b/packages/volto/src/components/manage/Blocks/ToC/Edit.jsx index d9c68c6ca4..dc369719a6 100644 --- a/packages/volto/src/components/manage/Blocks/ToC/Edit.jsx +++ b/packages/volto/src/components/manage/Blocks/ToC/Edit.jsx @@ -29,6 +29,7 @@ class Edit extends Component { block={this.props.block} navRoot={this.props.navRoot} contentType={this.props.contentType} + errors={this.props.blocksErrors} /> diff --git a/packages/volto/src/components/manage/Blocks/Video/VideoSidebar.jsx b/packages/volto/src/components/manage/Blocks/Video/VideoSidebar.jsx index 1a5b110f44..bec7228200 100644 --- a/packages/volto/src/components/manage/Blocks/Video/VideoSidebar.jsx +++ b/packages/volto/src/components/manage/Blocks/Video/VideoSidebar.jsx @@ -18,7 +18,8 @@ const messages = defineMessages({ }); const VideoSidebar = (props) => { - const { data, block, onChangeBlock, navRoot, contentType } = props; + const { data, block, blocksErrors, onChangeBlock, navRoot, contentType } = + props; const intl = useIntl(); const schema = VideoBlockSchema({ ...props, intl }); @@ -44,6 +45,7 @@ const VideoSidebar = (props) => { block={block} navRoot={navRoot} contentType={contentType} + errors={blocksErrors} /> )} diff --git a/packages/volto/src/components/manage/Form/Form.jsx b/packages/volto/src/components/manage/Form/Form.jsx index 1ebdca28f7..8b108d7dde 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,8 @@ class Form extends Component { history={this.props.history} location={this.props.location} token={this.props.token} + errors={this.state.errors} + blocksErrors={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..ddce0abd9f 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'; @@ -209,8 +210,9 @@ let config = { }, addonRoutes: [], addonReducers: {}, - slots: {}, components, + slots: {}, + utilities: {}, }; // The apiExpanders depends on a config of the object, so it's done here @@ -238,5 +240,8 @@ ConfigRegistry.addonRoutes = config.addonRoutes; ConfigRegistry.addonReducers = config.addonReducers; ConfigRegistry.components = config.components; ConfigRegistry.slots = config.slots; +ConfigRegistry.utilities = config.utilities; + +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..5a92e004e9 --- /dev/null +++ b/packages/volto/src/config/validation.ts @@ -0,0 +1,155 @@ +import { ConfigType } from '@plone/registry'; + +import { + minLengthValidator, + maxLengthValidator, + urlValidator, + emailValidator, + isNumberValidator, + maximumValidator, + minimumValidator, + isIntegerValidator, + maxItemsValidator, + minItemsValidator, + hasUniqueItemsValidator, + startEventDateRangeValidator, + endEventDateRangeValidator, + patternValidator, +} from '@plone/volto/helpers/FormValidation/validators'; + +const registerValidators = (config: ConfigType) => { + config.registerUtility({ + name: 'minLength', + type: 'validator', + dependencies: { fieldType: 'string' }, + method: minLengthValidator, + }); + + config.registerUtility({ + name: 'maxLength', + type: 'validator', + dependencies: { fieldType: 'string' }, + method: maxLengthValidator, + }); + + config.registerUtility({ + name: 'pattern', + type: 'validator', + dependencies: { fieldType: 'string' }, + method: patternValidator, + }); + + config.registerUtility({ + name: 'minLength', + type: 'validator', + dependencies: { fieldType: 'password' }, + method: minLengthValidator, + }); + + config.registerUtility({ + name: 'maxLength', + type: 'validator', + dependencies: { fieldType: 'password' }, + method: maxLengthValidator, + }); + + config.registerUtility({ + name: 'pattern', + type: 'validator', + dependencies: { fieldType: 'password' }, + method: patternValidator, + }); + + config.registerUtility({ + name: 'email', + type: 'validator', + dependencies: { widget: 'email' }, + method: emailValidator, + }); + + config.registerUtility({ + name: 'url', + type: 'validator', + dependencies: { widget: 'url' }, + method: urlValidator, + }); + + config.registerUtility({ + name: 'number', + type: 'validator', + dependencies: { fieldType: 'number' }, + method: isNumberValidator, + }); + + config.registerUtility({ + name: 'minimum', + type: 'validator', + dependencies: { fieldType: 'number' }, + method: minimumValidator, + }); + + config.registerUtility({ + name: 'maximum', + type: 'validator', + dependencies: { fieldType: 'number' }, + method: maximumValidator, + }); + + config.registerUtility({ + name: 'integer', + type: 'validator', + dependencies: { fieldType: 'integer' }, + method: isIntegerValidator, + }); + + config.registerUtility({ + name: 'minimum', + type: 'validator', + dependencies: { fieldType: 'integer' }, + method: minimumValidator, + }); + + config.registerUtility({ + name: 'maximum', + type: 'validator', + dependencies: { fieldType: 'integer' }, + method: maximumValidator, + }); + + config.registerUtility({ + name: 'maxItems', + type: 'validator', + dependencies: { fieldType: 'array' }, + method: maxItemsValidator, + }); + + config.registerUtility({ + name: 'minItems', + type: 'validator', + dependencies: { fieldType: 'array' }, + method: minItemsValidator, + }); + + config.registerUtility({ + name: 'uniqueItems', + type: 'validator', + dependencies: { fieldType: 'array' }, + method: hasUniqueItemsValidator, + }); + + config.registerUtility({ + name: 'dateRangeValidator', + type: 'validator', + dependencies: { behaviorName: 'plone.eventbasic', fieldName: 'start' }, + method: startEventDateRangeValidator, + }); + + config.registerUtility({ + name: 'dateRangeValidator', + type: 'validator', + dependencies: { behaviorName: 'plone.eventbasic', fieldName: 'end' }, + method: endEventDateRangeValidator, + }); +}; + +export { registerValidators }; diff --git a/packages/volto/src/helpers/FormValidation/FormValidation.jsx b/packages/volto/src/helpers/FormValidation/FormValidation.jsx index 95c470fa77..f423f49d5a 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,122 @@ 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.method({ + value: fieldData, field, + formData, formatMessage, - ); + }); return errorMessage; }) .filter((item) => !!item); + } + + Object.entries(schema.properties).forEach(([fieldId, field]) => { + let fieldData = formData[fieldId]; + + // Validation per specific validator set (format property) + const hasSpecificValidator = + field.widgetOptions?.frontendOptions?.format || field.format; + let specificFieldErrors = []; + if (hasSpecificValidator) { + const specificValidationCriteria = config.getUtilities({ + type: 'validator', + dependencies: { format: hasSpecificValidator }, + }); + + specificFieldErrors = checkFieldErrors( + specificValidationCriteria, + field, + fieldData, + ); + } + + // Validation per field type + const fieldType = field.type || 'string'; // defaults to string + const fieldTypeValidationCriteria = config.getUtilities({ + type: 'validator', + dependencies: { fieldType }, + }); + + const fieldErrors = checkFieldErrors( + fieldTypeValidationCriteria, + field, + fieldData, + ); + + // Validation per field widget + const widgetName = + field.widgetOptions?.frontendOptions?.widget || field.widget || ''; + + let widgetErrors = []; + if (widgetName) { + const widgetNameValidationCriteria = config.getUtilities({ + type: 'validator', + dependencies: { widget: widgetName }, + }); + + widgetErrors = checkFieldErrors( + widgetNameValidationCriteria, + field, + fieldData, + ); + } + + // Validation per specific behavior and field name (for content types) + const behaviorName = field.behavior; + let perBehaviorFieldErrors = []; + if (behaviorName) { + const specificPerBehaviorFieldValidationCriteria = config.getUtilities({ + type: 'validator', + dependencies: { behaviorName, fieldName: fieldId }, + }); + + perBehaviorFieldErrors = checkFieldErrors( + specificPerBehaviorFieldValidationCriteria, + field, + fieldData, + ); + } + + // Validation per block type validator (for blocks) + const blockType = formData['@type']; + let blockTypeFieldErrors = []; + if (blockType) { + const blockTypeFieldValidationCriteria = config.getUtilities({ + type: 'validator', + dependencies: { blockType, fieldName: fieldId }, + }); + + blockTypeFieldErrors = checkFieldErrors( + blockTypeFieldValidationCriteria, + field, + fieldData, + ); + } - const uniqueErrors = hasUniqueItems(field, fieldData, formatMessage); - const mergedErrors = [...fieldErrors, ...uniqueErrors]; + const mergedErrors = [ + ...specificFieldErrors, + ...fieldErrors, + ...widgetErrors, + ...perBehaviorFieldErrors, + ...blockTypeFieldErrors, + ]; if (mergedErrors.length > 0) { errors[fieldId] = [ ...(errors[fieldId] || []), + ...specificFieldErrors, ...fieldErrors, - ...uniqueErrors, + ...widgetErrors, + ...perBehaviorFieldErrors, + ...blockTypeFieldErrors, ]; } }); diff --git a/packages/volto/src/helpers/FormValidation/FormValidation.test.js b/packages/volto/src/helpers/FormValidation/FormValidation.test.js index 9f97c6849a..b5c40d4dd2 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,827 @@ describe('FormValidation', () => { }), ).toEqual({}); }); + + it('widget - validator from block - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + widget: 'url', + }, + }, + required: [], + }; + + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('type + widget - validator from block - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + type: 'customfieldtype', + widget: 'url', + }, + }, + required: [], + }; + config.registerUtility({ + type: 'validator', + name: 'alwaysFail', + dependencies: { fieldType: 'customfieldtype' }, + method: () => 'Fails', + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: ['Fails', messages.isValidURL.defaultMessage], + }); + }); + + it('widget - validator from content type set - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + widgetOptions: { + frontendOptions: { + widget: 'url', + }, + }, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('string - 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('string - 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('string - pattern - Fail', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'password', + description: '', + pattern: '^[a-zA-Z0-9]*$', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'as#', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.pattern.defaultMessage], + }); + }); + + it('string - pattern - Succeeds', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'password', + description: '', + pattern: '^[a-zA-Z0-9]*$', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asasd', + }, + formatMessage, + }), + ).toEqual({}); + }); + + it('number - isNumber - fails (not string|number as number)', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Number field', + type: 'number', + description: '', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + //since 'number' can accept digits in string & number format hence testing it with an alphabet + customField: 'n', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isNumber.defaultMessage], + }); + }); + + it('number - isNumber - as string', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Number field', + type: 'number', + description: '', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + //since 'number' can accept digits in string & number format hence testing it with an alphabet + customField: '1', + }, + formatMessage, + }), + ).toEqual({}); + }); + + it('number - isNumber - as number', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Number field', + type: 'number', + description: '', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + //since 'number' can accept digits in string & number format hence testing it with an alphabet + customField: 1, + }, + formatMessage, + }), + ).toEqual({}); + }); + + 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: 'integer', + 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 - maxItems', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Array field', + type: 'array', + description: '', + maxItems: 1, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: [1, 2], + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.maxItems.defaultMessage], + }); + }); + + it('array - minItems', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Array field', + type: 'array', + description: '', + minItems: 3, + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: [1], + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.minItems.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('format - specific validator set - Errors', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + format: 'url', + }, + }, + required: [], + }; + config.registerUtility({ + type: 'validator', + name: 'url', + dependencies: { format: 'url' }, + method: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'foo', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('format - specific validator set - Succeeds', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + format: 'url', + }, + }, + required: [], + }; + config.registerUtility({ + type: 'validator', + name: 'url', + dependencies: { format: 'url' }, + method: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'https://plone.org/', + }, + formatMessage, + }), + ).toEqual({}); + }); + + it('format - specific validator from content type set - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + widgetOptions: { + frontendOptions: { + format: 'url', + }, + }, + }, + }, + required: [], + }; + config.registerUtility({ + type: 'validator', + name: 'url', + dependencies: { format: 'url' }, + method: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asdasd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('format - specific validator from content type set - Succeeds', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + widgetOptions: { + frontendOptions: { + format: 'url', + }, + }, + }, + }, + required: [], + }; + config.registerUtility({ + type: 'validator', + name: 'url', + dependencies: { format: 'url' }, + method: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'https://plone.org/', + }, + formatMessage, + }), + ).toEqual({}); + }); + + it('behavior + fieldName - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + behavior: 'plone.eventbasic', + title: 'Default field', + description: '', + }, + }, + required: [], + }; + config.registerUtility({ + type: 'validator', + name: 'url', + dependencies: { + behaviorName: 'plone.eventbasic', + fieldName: 'customField', + format: 'url', + }, + method: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); + + it('behavior + fieldName - start date in Event - Fails', () => { + let contentTypeSchema = { + properties: { + ...schema.properties, + start: { + behavior: 'plone.eventbasic', + type: 'string', + title: 'Start date', + description: '', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: contentTypeSchema, + formData: { + start: '2024-08-01T11:00:00+00:00', + end: '2024-04-01T11:00:00+00:00', + }, + formatMessage, + }), + ).toEqual({ + start: [messages.startEventRange.defaultMessage], + }); + }); + + it('behavior + fieldName - end date in Event - Fails', () => { + let contentTypeSchema = { + properties: { + ...schema.properties, + end: { + behavior: 'plone.eventbasic', + type: 'string', + title: 'End date', + description: '', + }, + }, + required: [], + }; + expect( + FormValidation.validateFieldsPerFieldset({ + schema: contentTypeSchema, + formData: { + start: '2024-08-01T11:00:00+00:00', + end: '2024-04-01T11:00:00+00:00', + }, + formatMessage, + }), + ).toEqual({ + end: [messages.endEventRange.defaultMessage], + }); + }); + + it('block - per block type and fieldID specific - Fails', () => { + let newSchema = { + properties: { + ...schema.properties, + customField: { + title: 'Default field', + description: '', + }, + }, + required: [], + }; + config.registerUtility({ + type: 'validator', + dependencies: { blockType: 'slider', fieldName: 'customField' }, + method: urlValidator, + }); + expect( + FormValidation.validateFieldsPerFieldset({ + schema: newSchema, + formData: { + '@type': 'slider', + username: 'test username', + customField: 'asd', + }, + formatMessage, + }), + ).toEqual({ + customField: [messages.isValidURL.defaultMessage], + }); + }); }); }); diff --git a/packages/volto/src/helpers/FormValidation/validators.ts b/packages/volto/src/helpers/FormValidation/validators.ts new file mode 100644 index 0000000000..82e927b927 --- /dev/null +++ b/packages/volto/src/helpers/FormValidation/validators.ts @@ -0,0 +1,203 @@ +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 isNumberValidator = ({ value, formatMessage }: Validator) => { + const isNumeric = (string: string | number) => Number.isFinite(+string); + const floatRegex = /^[+-]?\d+(\.\d+)?$/; + const isValid = isNumeric(value) && floatRegex.test(value); + // const isValid = + // typeof value === 'string' && !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 isIntegerValidator = ({ value, formatMessage }: Validator) => { + const isNumeric = (string: string | number) => Number.isFinite(+string); + const intRegex = /^-?[0-9]+$/; + const isValid = isNumeric(value) && intRegex.test(value); + return !isValid ? formatMessage(messages.isInteger) : null; +}; + +export const hasUniqueItemsValidator = ({ + 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, { + endDateValueOrEndFieldName: formData.end || 'end', + }) + : 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, { + startDateValueOrStartFieldName: formData.start || 'start', + }) + : null; +}; + +export const patternValidator = ({ + value, + field, + formatMessage, +}: Validator) => { + if (!field.pattern) { + return null; + } + const regex = new RegExp(field.pattern); + const isValid = regex.test(value); + return !isValid ? formatMessage(messages.pattern) : null; +}; + +export const maxItemsValidator = ({ + value, + field, + formatMessage, +}: Validator) => { + if (!field.maxItems) { + return null; + } + const isValid = Array.isArray(value) && value.length <= field.maxItems; + return !isValid + ? formatMessage(messages.maxItems, { maxItems: field.maxItems }) + : null; +}; + +export const minItemsValidator = ({ + value, + field, + formatMessage, +}: Validator) => { + if (!field.minItems) { + return null; + } + const isValid = Array.isArray(value) && value.length >= field.minItems; + return !isValid + ? formatMessage(messages.minItems, { minItems: field.minItems }) + : null; +}; diff --git a/packages/volto/src/helpers/MessageLabels/MessageLabels.js b/packages/volto/src/helpers/MessageLabels/MessageLabels.js index a6483fe4c9..7549e09043 100644 --- a/packages/volto/src/helpers/MessageLabels/MessageLabels.js +++ b/packages/volto/src/helpers/MessageLabels/MessageLabels.js @@ -375,4 +375,32 @@ 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: 'Event start date must be on or before {endDateValueOrEndFieldName}', + defaultMessage: + 'Event start date must be on or before {endDateValueOrEndFieldName}', + }, + endEventRange: { + id: 'Event end date must be on or after {startDateValueOrStartFieldName}', + defaultMessage: + 'Event end date must be on or after {startDateValueOrStartFieldName}', + }, + pattern: { + id: 'The value does not match the pattern {pattern}', + defaultMessage: 'The value does not match the pattern {pattern}', + }, + maxItems: { + id: 'The number of items must be less than or equal to {maxItems}', + defaultMessage: + 'The number of items must be less than or equal to {maxItems}', + }, + minItems: { + id: 'The number of items must be greater than or equal to {minItems}', + defaultMessage: + 'The number of items must be greater than or equal to {minItems}', + }, }); diff --git a/packages/volto/test-setup-config.jsx b/packages/volto/test-setup-config.jsx index 3f9d1dc8d8..b42bd47dc4 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,15 @@ config.set('components', { component: (props) => Image component mock, }, }); + +config.set('utilities', {}); + config.set('experimental', { addBlockButton: { enabled: false, }, }); + config.set('slots', {}); + +registerValidators(config); 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 {