diff --git a/apps/aurora/news/+core-widgets.documentation b/apps/aurora/news/+core-widgets.documentation new file mode 100644 index 000000000..d17533f83 --- /dev/null +++ b/apps/aurora/news/+core-widgets.documentation @@ -0,0 +1 @@ +Documented the new core widgets, the choices widget in the widget lookup order, and the file and image fields in the upgrade guide. @sneridagh diff --git a/docs/conceptual-guides/form-fields-controls-and-widgets.md b/docs/conceptual-guides/form-fields-controls-and-widgets.md index cb096af9c..7aceb8392 100644 --- a/docs/conceptual-guides/form-fields-controls-and-widgets.md +++ b/docs/conceptual-guides/form-fields-controls-and-widgets.md @@ -311,10 +311,14 @@ The form looks the widget up from the field's schema hints, in this order: 1. The field's name, for example `recurrence`. 2. The widget named in the field's tagged values, `frontendOptions.widget`. 3. The field's `widget` hint, for example `textarea` or `datetime`. -4. The field's choices or vocabulary. -5. The field's factory, for example `Relation List`. -6. The field's type, for example `boolean`. -7. The default widget. +4. The field's vocabulary, for example `plone.app.vocabularies.Catalog`, when a widget is registered for it. +5. The choices widget, when the field has choices or a vocabulary. +6. The field's factory, for example `Relation List`. +7. The field's type, for example `boolean`. +8. The default widget. + +A widget registered for a vocabulary is more specific than the choices widget, which renders all the other fields with choices or a vocabulary as a select. +Register the choices widget with `config.registerWidget({ key: 'choices', definition: MySelectWidget })`. Whichever widget it finds, the widget receives the same contract. That is what lets the form generator stay generic. diff --git a/docs/reference/widgets.md b/docs/reference/widgets.md index cc47e6ba8..06208f17f 100644 --- a/docs/reference/widgets.md +++ b/docs/reference/widgets.md @@ -4,7 +4,7 @@ myst: "description": "The core widgets of Plone Aurora's forms: what they are registered for, the value they read and write, and their widget options" "property=og:description": "The core widgets of Plone Aurora's forms: what they are registered for, the value they read and write, and their widget options" "property=og:title": "Core widgets" - "keywords": "Plone Aurora, forms, widgets, widget options, extraFields, object browser, image widget, block schema" + "keywords": "Plone Aurora, forms, widgets, widget options, extraFields, object browser, image widget, select, vocabulary, tags, file upload, block schema" --- (core-widgets-label)= @@ -43,13 +43,20 @@ const blockSchema = { | --- | --- | --- | | `TextWidget` | the default widget | a string | | `TextareaWidget` | `widget: 'textarea'` | a string, which can span several lines | +| `EmailWidget` | `widget: 'email'` | an email address | +| `PasswordWidget` | `widget: 'password'` | a password | +| `UrlWidget` | `widget: 'url'` | an absolute URL | +| `NumberWidget` | `type: 'number'`, `type: 'integer'` | a number, or `null` | | `BooleanWidget` | `type: 'boolean'` | a boolean | +| `SelectWidget` | the choices widget: fields with `choices` or a `vocabulary`, `widget: 'select'` | the token of the chosen option, or `null` | +| `ArrayWidget` | `type: 'array'`, `widget: 'array'`, `widget: 'token'` | a list of tokens | | `DateWidget` | `widget: 'date'` | an ISO date, `YYYY-MM-DD`, or `null` | | `DateTimeWidget` | `widget: 'datetime'` | an ISO 8601 date and time in UTC, such as `2026-10-09T10:00:00Z`, or `null` | | `AlignWidget` | `widget: 'align'` | the chosen action, such as `left` | | `SizeWidget` | `widget: 'size'` | the chosen action, such as `m` | | `WidthWidget` | `widget: 'width'` | the chosen action, such as `full` | -| `ImageWidget` | `widget: 'image'`, `factory: 'Image'` | the image's URL, an app path for site content, or `null` | +| `FileWidget` | `factory: 'File'`, `factory: 'Image'`, `widget: 'file'` | the file, or `null` | +| `ImageWidget` | `widget: 'image'` | the image's URL, an app path for site content, or `null` | | `ObjectBrowserWidget` | `widget: 'object_browser'`, `factory: 'Relation List'`, `vocabulary: 'plone.app.vocabularies.Catalog'` | a list of the selected items | | `QuerystringWidget` | `widget: 'querystring'` | a query object | | `RecurrenceWidget` | the `recurrence` field | an RFC 5545 recurrence rule, or `null` | @@ -60,6 +67,63 @@ const blockSchema = { `TextareaWidget` renders multi-line text, such as the summary of a page. Neither has widget options. +## Input widgets: `EmailWidget`, `PasswordWidget`, and `UrlWidget` + +Text inputs for an email address, a password, and a URL. +On a phone, they show the keyboard for their kind of text. +The browser doesn't fill a password field in with the editor's own password. +None has widget options. + +The form checks that the value of a field with `widget: 'email'` or `widget: 'url'` is an email address or a URL, see {ref}`validate-form-fields-label`. + +## `NumberWidget` + +A number input, with buttons to step the value up and down. +It shows the number in the editor's locale. +The value is a number, or `null` when the editor empties the field. + +It reads the schema's `minimum` and `maximum`: the buttons stop there, and a number out of range is brought back within it. +An `integer` field takes whole numbers only. + +## `SelectWidget` + +A select, where the editor picks one option. +It is the choices widget: it renders the fields with `choices` or a `vocabulary` for which no more specific widget is registered. +The value is the token of the chosen option. + +The options are the schema's `choices`, or the terms of the field's vocabulary, which the widget loads from the site. +An optional field also has a "No value" option, which empties the field. + +The content API sends some values as a term, an object with its token and its title, such as `{ "token": "en", "title": "English" }`. +The widget reads a term, and writes its token. + +## `ArrayWidget` + +A list of tokens, such as the tags of a page. +The editor types a token and presses {kbd}`Enter` to add it, and removes a token from the list. +The value is the list of tokens. + +The schema's `items` decide which tokens are valid: + +- With `choices` or a `vocabulary` in `items`, the editor picks the tokens from those options only. +- Otherwise, the editor can add any token. + A vocabulary in the field's `widgetOptions`, such as the keywords of the `subjects` field, suggests tokens as the editor types. + +```ts +days: { + title: 'Days', + type: 'array', + items: { + choices: [ + ['MO', 'Monday'], + ['TU', 'Tuesday'], + ], + }, +}, +``` + +Like `SelectWidget`, it reads a list of terms, and writes a list of tokens. + ## `BooleanWidget` A checkbox. @@ -91,11 +155,36 @@ align: { }, ``` +## `FileWidget` + +Picks a file for a field that stores a file, such as the file of a File, the image of an Image, or the lead image of a page. +The editor chooses a file, or drops it on the widget. +The widget shows the file's name and size, and a preview of an image. + +The value is the file, as the content API sends and receives it. +A new file has its content encoded in base64: + +```json +{ + "data": "iVBORw0KGgo…", + "encoding": "base64", + "content-type": "image/png", + "filename": "photo.png" +} +``` + +A stored file comes with its `download` URL instead, and the widget leaves it as it is. +Removing the file sets the value to `null`. + +A field with the `Image` factory only takes images. + ## `ImageWidget` Picks an image: from the site with the object browser, by uploading a file, or by entering a URL. Its value is the image's URL. For an image of the site, it's an app path, such as `/news/photo.jpg`. +It is for the fields that store the URL of an image, such as the image block's `url`. +A field that stores the image itself uses `FileWidget`. The widget browses the site from where the form is, and uploads to the form's container. See {ref}`form-fields-controls-and-widgets-label` for how a widget learns where the form is. diff --git a/docs/upgrade-guide/plone-aurora.md b/docs/upgrade-guide/plone-aurora.md index 75ac10ff3..911d7c047 100644 --- a/docs/upgrade-guide/plone-aurora.md +++ b/docs/upgrade-guide/plone-aurora.md @@ -377,3 +377,24 @@ The old names still work, but are deprecated and will be removed in a future rel ``` The `align`, `size`, and `width` widgets that the forms use are now adapters in `@plone/cmsui`, around these pickers. + +#### File and image fields store files + +```{versionchanged} 1.0.0-alpha.20 +The fields with the `File` or `Image` factory use `FileWidget`, instead of `ImageWidget`. +``` + +```{versionadded} 1.0.0-alpha.20 +`SelectWidget`, `ArrayWidget`, `NumberWidget`, `FileWidget`, `EmailWidget`, `PasswordWidget`, and `UrlWidget` in `@plone/cmsui`, and `NumberField` in `@plone/quanta`. +``` + +A field with the `File` or `Image` factory stores the file itself, such as the image of an Image, or the lead image of a News Item. +`ImageWidget` stored the URL of an image in such a field, which the content API can't save. +These fields now use `FileWidget`, which uploads the file. +`ImageWidget` stays the widget of the fields with `widget: 'image'`, which store the URL of an image, such as the image block's `url`. + +If your add-on registered a widget for the `Image` factory, it still overrides `FileWidget`. + +Plone Aurora now registers widgets for the fields with choices or a vocabulary, lists, numbers, files, email addresses, passwords, and URLs. +These fields used to render as text inputs. +See {ref}`core-widgets-label`. diff --git a/packages/client/news/+content-file-fields.bugfix b/packages/client/news/+content-file-fields.bugfix new file mode 100644 index 000000000..0e70886a6 --- /dev/null +++ b/packages/client/news/+content-file-fields.bugfix @@ -0,0 +1 @@ +Accepted the choice tokens of `allow_discussion`, a stored `preview_image`, and `null` for the file fields, in the content data of `createContent` and `updateContent`. @sneridagh diff --git a/packages/client/news/+vocabulary-batch-size.feature b/packages/client/news/+vocabulary-batch-size.feature new file mode 100644 index 000000000..c3a6d9a9c --- /dev/null +++ b/packages/client/news/+vocabulary-batch-size.feature @@ -0,0 +1 @@ +Added the `b_size` argument to `getVocabulary`, to get a batch of a given size, or all the terms with `-1`. @sneridagh diff --git a/packages/client/src/restapi/vocabularies/get.ts b/packages/client/src/restapi/vocabularies/get.ts index bdf7995c3..597c9ba55 100644 --- a/packages/client/src/restapi/vocabularies/get.ts +++ b/packages/client/src/restapi/vocabularies/get.ts @@ -9,19 +9,21 @@ const getVocabularySchema = z.object({ title: z.string().optional(), token: z.string().optional(), tokens: z.array(z.string()).optional(), + b_size: z.number().optional(), }); export type VocabulariesArgs = z.infer; export async function getVocabulary( this: PloneClient, - { path, title, token, tokens }: VocabulariesArgs, + { path, title, token, tokens, b_size }: VocabulariesArgs, ): Promise> { const validatedArgs = getVocabularySchema.parse({ path, title, token, tokens, + b_size, }); const options: ApiRequestParams = { @@ -30,6 +32,9 @@ export async function getVocabulary( ...(validatedArgs.title && { title: validatedArgs.title }), ...(validatedArgs.token && { token: validatedArgs.token }), ...(validatedArgs.tokens && { tokens: validatedArgs.tokens }), + ...(validatedArgs.b_size !== undefined && { + b_size: validatedArgs.b_size, + }), }, }; const vocabulariesPath = `@vocabularies/${validatedArgs.path}`; diff --git a/packages/client/src/validation/content.ts b/packages/client/src/validation/content.ts index 0554e8138..7c397d4c2 100644 --- a/packages/client/src/validation/content.ts +++ b/packages/client/src/validation/content.ts @@ -65,7 +65,8 @@ export const createContentDataSchema = z '@id': z.string().optional(), '@static_behaviors': z.unknown().optional(), '@type': z.string(), - allow_discussion: z.boolean().optional(), + // A boolean, or the token of its choices: `True` or `False`. + allow_discussion: z.union([z.boolean(), z.string()]).nullable().optional(), blocks: z.unknown().optional(), blocks_layout: z.object({ items: z.array(z.string()) }).optional(), changeNote: z.string().optional(), @@ -82,6 +83,8 @@ export const createContentDataSchema = z encoding: z.string(), filename: z.string(), }) + .passthrough() + .nullable() .optional(), id: z.string().optional(), image: z @@ -91,6 +94,8 @@ export const createContentDataSchema = z encoding: z.string(), filename: z.string(), }) + .passthrough() + .nullable() .optional(), language: z.string().optional(), preview_caption: z.string().optional(), @@ -101,6 +106,8 @@ export const createContentDataSchema = z encoding: z.string(), filename: z.string(), }) + .passthrough() + .nullable() .optional(), relatedItems: z.array(RelatedItemPayloadSchema).optional(), rights: z.string().nullable().optional(), @@ -111,7 +118,8 @@ export const createContentDataSchema = z export const updateContentDataSchema = z .object({ - allow_discussion: z.boolean().optional(), + // A boolean, or the token of its choices: `True` or `False`. + allow_discussion: z.union([z.boolean(), z.string()]).nullable().optional(), blocks: z.unknown().optional(), blocks_layout: z.object({ items: z.array(z.string()) }).optional(), changeNote: z.string().optional(), @@ -130,13 +138,15 @@ export const updateContentDataSchema = z }) .optional(), preview_caption: z.string().nullable().optional(), + // A new file, or the stored one as the API sent it. preview_image: z .object({ 'content-type': z.string(), - data: z.string(), - encoding: z.string(), + data: z.string().optional(), + encoding: z.string().optional(), filename: z.string(), }) + .passthrough() .nullable() .optional(), relatedItems: z.array(RelatedItemPayloadSchema).optional(), diff --git a/packages/cmsui/acceptance/tests/core-widgets.test.ts b/packages/cmsui/acceptance/tests/core-widgets.test.ts new file mode 100644 index 000000000..9abdbf258 --- /dev/null +++ b/packages/cmsui/acceptance/tests/core-widgets.test.ts @@ -0,0 +1,236 @@ +import type { APIRequestContext } from '@playwright/test'; +import { expect, test } from '../../../tooling/playwright/test'; +import { login } from '../../../tooling/playwright/login'; +import { createContent } from '../../../tooling/playwright/content'; + +const API = 'http://localhost:55001/plone'; +const AUTH = `Basic ${Buffer.from('admin:secret').toString('base64')}`; + +const getJSON = async (request: APIRequestContext, path: string) => + ( + await request.get(`${API}${path}`, { + headers: { Accept: 'application/json', Authorization: AUTH }, + }) + ).json(); + +// A 1x1 PNG. +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', +); + +test('The tags of a page are added as tokens and saved as a list', async ({ + page, + request, +}) => { + await login(page); + await createContent(page, { + contentType: 'Document', + contentId: 'tagged-page', + contentTitle: 'Tagged page', + }); + + await page.goto('/@@edit/tagged-page', { waitUntil: 'networkidle' }); + await page.getByRole('tab', { name: 'Content' }).click(); + await page.locator('button', { hasText: /^Categorization$/ }).click(); + + const tags = page.getByRole('combobox', { name: 'Tags' }); + await tags.fill('aurora'); + await tags.press('Enter'); + await tags.fill('plone'); + await tags.press('Enter'); + await expect(page.getByRole('row', { name: 'aurora' })).toBeVisible(); + await expect(page.getByRole('row', { name: 'plone' })).toBeVisible(); + + const saved = page.waitForResponse( + (response) => response.request().method() === 'PATCH' && response.ok(), + ); + await page.getByRole('button', { name: 'Save' }).click(); + await saved; + + const content = await getJSON(request, '/tagged-page'); + expect(content.subjects).toEqual(['aurora', 'plone']); +}); + +test('A field with a vocabulary or choices is a select', async ({ + page, + request, +}) => { + await login(page); + await createContent(page, { + contentType: 'Document', + contentId: 'select-page', + contentTitle: 'Select page', + }); + + await page.goto('/@@edit/select-page', { waitUntil: 'networkidle' }); + await page.getByRole('tab', { name: 'Content' }).click(); + + // The language's options come from its vocabulary. + await page.locator('button', { hasText: /^Categorization$/ }).click(); + await page.getByRole('button', { name: /Language/ }).click(); + await page.getByRole('option', { name: 'English' }).click(); + + // Allow discussion has its choices in the schema. The content API sends + // its value as a boolean. + await page.locator('button', { hasText: /^Settings$/ }).click(); + const allowDiscussion = page.getByRole('button', { + name: /Allow discussion/, + }); + await expect(allowDiscussion).toContainText('No'); + await allowDiscussion.click(); + await page.getByRole('option', { name: 'Yes' }).click(); + + const saved = page.waitForResponse( + (response) => response.request().method() === 'PATCH' && response.ok(), + ); + await page.getByRole('button', { name: 'Save' }).click(); + await saved; + + const content = await getJSON(request, '/select-page'); + expect(content.language.token).toBe('en'); + expect(content.allow_discussion).toBe(true); +}); + +test('A control panel shows the terms of its lists, and saves them', async ({ + page, + request, +}) => { + await login(page); + await page.goto('/controlpanel/navigation', { waitUntil: 'networkidle' }); + + // The content API sends the terms of a vocabulary with their titles. + await expect(page.getByRole('row', { name: 'Page' })).toBeVisible(); + + const saved = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/controlpanel/navigation'), + ); + await page.getByLabel('Save').click(); + await saved; + + const settings = await getJSON(request, '/@controlpanels/navigation'); + expect( + settings.data.displayed_types.map((term: { token: string }) => term.token), + ).toContain('Document'); +}); + +test('A control panel saves its numbers as numbers, and hides passwords', async ({ + page, + request, +}) => { + await login(page); + await page.goto('/controlpanel/mail', { waitUntil: 'networkidle' }); + + const port = page.getByRole('textbox', { name: 'SMTP port' }); + await expect(port).toHaveValue('25'); + await port.fill('2525'); + await port.blur(); + const password = page.getByLabel('ESMTP password'); + await expect(password).toHaveAttribute('type', 'password'); + await password.fill('s3cret'); + // Required by the mail settings. + await page.getByLabel("Site 'From' name").fill('Aurora'); + await page.getByLabel("Site 'From' address").fill('aurora@example.com'); + + const saved = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/controlpanel/mail'), + ); + await page.getByLabel('Save').click(); + await saved; + + const settings = await getJSON(request, '/@controlpanels/mail'); + expect(settings.data.smtp_port).toBe(2525); + expect(settings.data.smtp_pass).toBe('s3cret'); + + // Leave the mail settings as they were. + await request.patch(`${API}/@controlpanels/mail`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: AUTH, + }, + data: { + smtp_port: 25, + smtp_pass: null, + email_from_name: null, + email_from_address: null, + }, + }); +}); + +test('Adding a file uploads the chosen file', async ({ page, request }) => { + await login(page); + await createContent(page, { + contentType: 'Document', + contentId: 'downloads', + contentTitle: 'Downloads', + }); + + await page.goto('/@@add/downloads?type=File', { waitUntil: 'networkidle' }); + await page.getByRole('tab', { name: 'Content' }).click(); + await page.getByLabel('Title').fill('Report'); + await page.getByLabel('File', { exact: true }).setInputFiles({ + name: 'report.txt', + mimeType: 'text/plain', + buffer: Buffer.from('The annual report'), + }); + await expect(page.getByText('report.txt')).toBeVisible(); + + const created = page.waitForResponse( + (response) => response.request().method() === 'POST' && response.ok(), + ); + await page.getByRole('button', { name: 'Save' }).click(); + await created; + + const listing = await getJSON(request, '/downloads/@search?portal_type=File'); + expect(listing.items).toHaveLength(1); + const file = await getJSON( + request, + new URL(listing.items[0]['@id']).pathname.replace(/^\/plone/, ''), + ); + expect(file.file.filename).toBe('report.txt'); + expect(file.file.size).toBe('The annual report'.length); +}); + +test('Adding an image uploads the chosen image, and shows a preview', async ({ + page, + request, +}) => { + await login(page); + await createContent(page, { + contentType: 'Document', + contentId: 'gallery', + contentTitle: 'Gallery', + }); + + // The add form used to crash here (#163), and then offered to pick an + // image URL, which the image field of an Image can't store. + await page.goto('/@@add/gallery?type=Image', { waitUntil: 'networkidle' }); + await page.getByRole('tab', { name: 'Content' }).click(); + await page.getByLabel('Title').fill('Pixel'); + await page.getByLabel('Image', { exact: true }).setInputFiles({ + name: 'pixel.png', + mimeType: 'image/png', + buffer: PNG, + }); + await expect(page.locator('img[src^="data:image/png"]')).toBeVisible(); + + const created = page.waitForResponse( + (response) => response.request().method() === 'POST' && response.ok(), + ); + await page.getByRole('button', { name: 'Save' }).click(); + await created; + + const listing = await getJSON(request, '/gallery/@search?portal_type=Image'); + expect(listing.items).toHaveLength(1); + const image = await getJSON( + request, + new URL(listing.items[0]['@id']).pathname.replace(/^\/plone/, ''), + ); + expect(image.image.filename).toBe('pixel.png'); + expect(image.image['content-type']).toBe('image/png'); +}); diff --git a/packages/cmsui/acceptance/tests/widget-resolution.test.ts b/packages/cmsui/acceptance/tests/widget-resolution.test.ts index 5191531f5..55193f4a3 100644 --- a/packages/cmsui/acceptance/tests/widget-resolution.test.ts +++ b/packages/cmsui/acceptance/tests/widget-resolution.test.ts @@ -1,6 +1,8 @@ import { expect, test } from '../../../tooling/playwright/test'; import { login } from '../../../tooling/playwright/login'; import { createContent } from '../../../tooling/playwright/content'; +import { waitForPlateEditorReady } from '../../../tooling/playwright/plate'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; test('The edit form resolves image and boolean fields to their widgets', async ({ page, @@ -15,10 +17,10 @@ test('The edit form resolves image and boolean fields to their widgets', async ( await page.goto('/@@edit/widget-news', { waitUntil: 'networkidle' }); await page.getByRole('tab', { name: 'Content' }).click(); - // The lead image is an `Image` factory field. + // The lead image is an `Image` factory field: it stores the image file. await expect( - page.getByText('Browse the site, drop an image, or use a URL'), - ).toBeVisible(); + page.locator('input[type="file"][name="image"][accept="image/*"]'), + ).toHaveCount(1); // Boolean fields resolve by their `boolean` type. await page.locator('button', { hasText: /^Settings$/ }).click(); @@ -47,37 +49,58 @@ test('A page summary is edited in a multi-line text area', async ({ page }) => { expect(await summary.evaluate((element) => element.tagName)).toBe('TEXTAREA'); }); -test('Adding an image shows its image field, browsing from the container', async ({ - page, -}) => { +test('The image widget browses from the edited page', async ({ page }) => { await login(page); await createContent(page, { // With plone.volto, pages are folderish. contentType: 'Document', - contentId: 'gallery', - contentTitle: 'Gallery', + contentId: 'album', + contentTitle: 'Album', }); await createContent(page, { contentType: 'Image', - contentId: 'sunset', - contentTitle: 'Sunset', - path: 'gallery', + contentId: 'sunrise', + contentTitle: 'Sunrise', + path: 'album', image: true, }); + await createContent(page, { + contentType: 'Document', + contentId: 'album-page', + contentTitle: 'Album page', + path: 'album', + bodyModifier: (body) => ({ + ...body, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { type: 'title', children: [{ text: 'Album page' }] }, + { + type: PLONE_BLOCK_TYPE, + '@type': 'image', + children: [{ text: '' }], + }, + ], + }, + }, + blocks_layout: { items: ['__somersault__'] }, + }), + }); - // The add form used to crash here (#163): the image widget guessed its - // location from the URL, and the object browser asked for `/@@add`. - await page.goto('/@@add/gallery?type=Image', { waitUntil: 'networkidle' }); - await page.getByRole('tab', { name: 'Content' }).click(); - await expect( - page.getByText('Browse the site, drop an image, or use a URL'), - ).toBeVisible(); + await page.goto('/@@edit/album/album-page', { waitUntil: 'networkidle' }); + await waitForPlateEditorReady(page); + await page + .locator('#toolbar') + .getByRole('button', { name: 'Settings' }) + .click(); - // The object browser starts in the container the image is added to, and - // shows where it is. + // The object browser starts at the edited page, and shows where it is + // (#163: it used to guess its location from the URL). await page.getByRole('button', { name: 'Pick an existing image' }).click(); const dialog = page.getByRole('dialog'); - await expect(dialog.getByText('Sunset')).toBeVisible(); - // The listing shows the folder's children; its name is in the breadcrumbs. - await expect(dialog.getByText('Gallery', { exact: true })).toBeVisible(); + await expect(dialog.getByRole('link', { name: 'Album page' })).toBeVisible(); + // From there, the editor goes up to the folder, and finds its image. + await dialog.getByRole('link', { name: 'Album', exact: true }).click(); + await expect(dialog.getByText('Sunrise')).toBeVisible(); }); diff --git a/packages/cmsui/components/ArrayWidget/ArrayWidget.stories.tsx b/packages/cmsui/components/ArrayWidget/ArrayWidget.stories.tsx new file mode 100644 index 000000000..d3121216b --- /dev/null +++ b/packages/cmsui/components/ArrayWidget/ArrayWidget.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ArrayWidget } from './ArrayWidget'; + +const meta = { + title: 'CMSUI/Widgets/ArrayWidget', + component: ArrayWidget, + args: { + name: 'subjects', + label: 'Tags', + description: 'Tags are commonly used for ad-hoc organization of content.', + value: ['news', 'events'], + onChange: () => {}, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function ArrayWidgetStory(args: React.ComponentProps) { + const [value, setValue] = useState(args.value ?? null); + return ; +} + +/** The editor can add any token. */ +export const Default: Story = { + render: (args) => , +}; + +/** The schema's `items` restrict the tokens to their choices. */ +export const WithChoices: Story = { + args: { + label: 'Days', + description: '', + value: ['MO'], + schema: { + type: 'array', + items: { + choices: [ + ['MO', 'Monday'], + ['TU', 'Tuesday'], + ['WE', 'Wednesday'], + ], + }, + }, + }, + render: (args) => , +}; + +export const Invalid: Story = { + args: { + value: [], + required: true, + invalid: true, + errorMessage: 'Required input is missing.', + }, + render: (args) => , +}; diff --git a/packages/cmsui/components/ArrayWidget/ArrayWidget.tsx b/packages/cmsui/components/ArrayWidget/ArrayWidget.tsx new file mode 100644 index 000000000..4e8f207b3 --- /dev/null +++ b/packages/cmsui/components/ArrayWidget/ArrayWidget.tsx @@ -0,0 +1,171 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { Key } from 'react-aria-components'; +import type { FieldSchema, FormWidgetProps } from '@plone/types'; +import { ComboBox, ComboBoxItem, Tag, TagGroup } from '@plone/quanta'; +import { useTranslation } from 'react-i18next'; +import { termOption, useChoices, type TermValue } from '../Form/useChoices'; + +/** + * The widget reads a list of tokens, or of terms as the content API sends + * them, and writes a list of tokens. + */ +export type ArrayWidgetProps = FormWidgetProps; + +type TokenItem = { id: string; name: string }; + +/** Waits until the editor stops typing before searching the vocabulary. */ +function useDebouncedValue(value: string, delay = 250) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timeout = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timeout); + }, [value, delay]); + return debounced; +} + +/** + * Adapts the widget contract to the Quanta `ComboBox` and `TagGroup` + * controls, for a list of strings, such as the tags of a page. The value is + * the list of tokens. + * + * The editor types to add a token, and removes one from the list of tags. + * The schema's `items` can restrict the tokens to their `choices` or + * `vocabulary`. A vocabulary in the widget options, like the keywords of + * `subjects`, only suggests tokens: the editor can still add new ones. + */ +export function ArrayWidget({ + name, + value, + onChange, + onBlur, + label, + description, + placeholder, + required, + disabled, + readOnly, + invalid, + errorMessage, + className, + schema, + widgetOptions, +}: ArrayWidgetProps) { + const { t } = useTranslation(); + const terms = useMemo( + () => + (Array.isArray(value) ? value : []) + .map(termOption) + .filter((term) => term !== null), + [value], + ); + const tokens = useMemo(() => terms.map((term) => term.value), [terms]); + const items = schema?.items as FieldSchema | undefined; + const choices = items?.choices; + const vocabulary = items?.vocabulary ?? widgetOptions?.vocabulary; + // The items' choices or vocabulary are the only valid tokens. + const creatable = !choices && !items?.vocabulary; + + const [input, setInput] = useState(''); + const title = useDebouncedValue(input.trim()); + const { options } = useChoices({ + choices, + vocabulary, + title: title || undefined, + }); + + // The labels of the tokens, kept while the options change with the search. + const labels = useRef(new Map()); + [...terms, ...options].forEach((option) => + labels.current.set(option.value, option.label), + ); + + const suggestions = useMemo( + () => + options + .filter((option) => !tokens.includes(option.value)) + .map((option) => ({ id: option.value, name: option.label })), + [options, tokens], + ); + const selected = useMemo( + () => + tokens.map((token) => ({ + id: token, + name: labels.current.get(token) ?? token, + })), + // The labels follow the terms and the options. + // eslint-disable-next-line react-hooks/exhaustive-deps + [tokens, terms, options], + ); + + const add = (token: string) => { + const next = token.trim(); + setInput(''); + if (!next || tokens.includes(next)) return; + onChange([...tokens, next]); + }; + + const remove = (keys: Set) => { + const next = tokens.filter((token) => !keys.has(token)); + onChange(next); + }; + + const editable = !disabled && !readOnly; + + return ( +
+ + label={label} + description={description} + errorMessage={errorMessage} + placeholder={placeholder ?? t('cmsui.widgets.array.placeholder')} + items={suggestions} + inputValue={input} + onInputChange={setInput} + // The combo box adds tokens: it never keeps a selection. + selectedKey={null} + onSelectionChange={(key) => { + if (key != null) add(String(key)); + }} + allowsCustomValue={creatable} + // No suggestions, no popover: it would hide the tags. + allowsEmptyCollection={false} + onKeyDown={(event) => { + // Enter adds the typed token, unless an option is focused: then + // the combo box selects that option. + if ( + event.key === 'Enter' && + creatable && + !(event.target as HTMLElement).getAttribute('aria-activedescendant') + ) { + event.preventDefault(); + add(input); + } + }} + onBlur={() => { + if (creatable && input.trim()) add(input); + onBlur?.(); + }} + isRequired={required} + isDisabled={disabled} + isReadOnly={readOnly} + isInvalid={invalid} + // Validation is the form's job, not the browser's. + validationBehavior="aria" + > + {(item) => {item.name}} + + {selected.length > 0 && ( + + aria-label={label ?? name} + items={selected} + onRemove={editable ? remove : undefined} + className="mt-2" + > + {(item) => {item.name}} + + )} +
+ ); +} + +ArrayWidget.displayName = 'ArrayWidget'; diff --git a/packages/cmsui/components/FileWidget/FileWidget.stories.tsx b/packages/cmsui/components/FileWidget/FileWidget.stories.tsx new file mode 100644 index 000000000..ce7a30477 --- /dev/null +++ b/packages/cmsui/components/FileWidget/FileWidget.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { FileWidget } from './FileWidget'; + +const meta = { + title: 'CMSUI/Widgets/FileWidget', + component: FileWidget, + args: { + name: 'file', + label: 'File', + description: 'The file to publish.', + value: null, + onChange: () => {}, + required: true, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function FileWidgetStory(args: React.ComponentProps) { + const [value, setValue] = useState(args.value ?? null); + return ; +} + +export const Default: Story = { + render: (args) => , +}; + +/** A stored file, as the content API sends it. */ +export const WithFile: Story = { + args: { + value: { + filename: 'annual-report.pdf', + 'content-type': 'application/pdf', + size: 254000, + download: '/annual-report.pdf/@@download/file', + }, + }, + render: (args) => , +}; + +/** An `Image` field only takes images, and shows a preview. */ +export const Image: Story = { + args: { + label: 'Image', + description: '', + schema: { factory: 'Image' }, + }, + render: (args) => , +}; + +export const Invalid: Story = { + args: { + invalid: true, + errorMessage: 'Required input is missing.', + }, + render: (args) => , +}; diff --git a/packages/cmsui/components/FileWidget/FileWidget.tsx b/packages/cmsui/components/FileWidget/FileWidget.tsx new file mode 100644 index 000000000..086c9f25d --- /dev/null +++ b/packages/cmsui/components/FileWidget/FileWidget.tsx @@ -0,0 +1,227 @@ +import { useId, useRef, useState } from 'react'; +import type { FormWidgetProps } from '@plone/types'; +import { Button, Description, DropZone, Label } from '@plone/quanta'; +import { AttachmentIcon, BinIcon } from '@plone/icons'; +import { useTranslation } from 'react-i18next'; +import { twMerge } from 'tailwind-merge'; + +/** + * The value of a file or image field. + * + * The content API sends a stored file with its `download` URL. A new file + * is sent to the API with its `data`, encoded in base64. + */ +export type FileValue = { + filename?: string; + 'content-type'?: string; + size?: number; + /** The URL of the stored file. */ + download?: string; + /** The content of a new file. */ + data?: string; + encoding?: 'base64'; + [key: string]: unknown; +}; + +export type FileWidgetProps = FormWidgetProps; + +function readFile(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = String(reader.result ?? ''); + resolve({ + data: dataUrl.slice(dataUrl.indexOf(',') + 1), + encoding: 'base64', + 'content-type': file.type || 'application/octet-stream', + filename: file.name, + size: file.size, + }); + }; + reader.onerror = () => + reject(reader.error ?? new Error('File read failed')); + reader.readAsDataURL(file); + }); +} + +const formatSize = (size?: number) => { + if (size == null) return ''; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +}; + +const previewSrc = (value: FileValue) => { + if (!value['content-type']?.startsWith('image/')) return undefined; + if (value.data) return `data:${value['content-type']};base64,${value.data}`; + return value.download; +}; + +/** + * Picks a file for a file or image field, such as the file of a File or the + * image of an Image. The editor chooses a file, or drops it on the widget. + * The value is the file, as the content API sends and receives it, or + * `null` when the field is emptied. + * + * An `Image` field only accepts images, and shows a preview. + */ +export function FileWidget({ + name, + value, + onChange, + onBlur, + label, + description, + required, + disabled, + readOnly, + invalid, + errorMessage, + className, + schema, +}: FileWidgetProps) { + const { t } = useTranslation(); + const id = useId(); + const inputRef = useRef(null); + const [readError, setReadError] = useState(''); + const imageOnly = schema?.factory === 'Image'; + const editable = !disabled && !readOnly; + const file = value && typeof value === 'object' ? value : null; + const preview = file ? previewSrc(file) : undefined; + const error = errorMessage || readError; + const showError = invalid || !!readError; + + const pick = async (picked?: File | null) => { + if (!picked) return; + if (imageOnly && !picked.type.startsWith('image/')) { + setReadError(t('cmsui.widgets.file.imageOnly')); + return; + } + try { + onChange(await readFile(picked)); + setReadError(''); + } catch { + setReadError(t('cmsui.widgets.file.readError')); + } + }; + + return ( +
+ {label && } + { + const item = event.items.find((item) => item.kind === 'file'); + if (item?.kind === 'file') await pick(await item.getFile()); + }} + className="flex flex-col items-stretch gap-3 p-4 text-left" + > + {file && ( +
+ {preview ? ( + + ) : ( + + )} +
+ {file.download && !file.data ? ( + + {file.filename} + + ) : ( + {file.filename} + )} + + {formatSize(file.size)} + +
+ {editable && ( + + )} +
+ )} +
+ {/* The file input is the field's control: it has the field's + label, and the keyboard opens the file chooser from it. */} + pick(event.target.files?.[0])} + onBlur={onBlur} + className="peer sr-only" + /> + {editable && ( + // For the pointer only: the input above has the focus. + inputRef.current?.click()} + className={` + cursor-pointer rounded-lg bg-quanta-snow px-3 py-2 text-sm text-quanta-space + peer-focus-visible:outline-2 peer-focus-visible:outline-quanta-cobalt + hover:bg-quanta-smoke + `} + > + {file + ? t('cmsui.widgets.file.replace') + : t('cmsui.widgets.file.choose')} + + )} + {!file && ( + + {t('cmsui.widgets.file.orDrop')} + + )} +
+
+ {description && ( + {description} + )} + {showError && error && ( +

+ {error} +

+ )} +
+ ); +} + +FileWidget.displayName = 'FileWidget'; diff --git a/packages/cmsui/components/Form/Field.test.tsx b/packages/cmsui/components/Form/Field.test.tsx index 56fab892e..4ea2ddd06 100644 --- a/packages/cmsui/components/Form/Field.test.tsx +++ b/packages/cmsui/components/Form/Field.test.tsx @@ -54,6 +54,7 @@ describe('Field widget resolution', () => { key: 'type', definition: { boolean: marker('type') }, }); + config.registerWidget({ key: 'choices', definition: marker('choices') }); }); it('resolves by field id first', () => { @@ -97,6 +98,41 @@ describe('Field widget resolution', () => { ).toBe('vocabulary'); }); + it('resolves by choices', () => { + expect( + renderedWidget({ + name: 'field', + type: 'string', + choices: [['a', 'A']], + }), + ).toBe('choices'); + }); + + it('resolves a field with a vocabulary by choices', () => { + expect( + renderedWidget({ + name: 'field', + type: 'string', + vocabulary: { + '@id': + 'http://localhost/@vocabularies/plone.app.vocabularies.SupportedContentLanguages', + }, + }), + ).toBe('choices'); + }); + + it('prefers the widget of a vocabulary over the choices widget', () => { + expect( + renderedWidget({ + name: 'field', + vocabulary: { + '@id': + 'http://localhost/@vocabularies/plone.app.vocabularies.Catalog', + }, + }), + ).toBe('vocabulary'); + }); + it('resolves by type', () => { expect(renderedWidget({ name: 'field', type: 'boolean' })).toBe('type'); }); diff --git a/packages/cmsui/components/Form/Field.tsx b/packages/cmsui/components/Form/Field.tsx index 9d65d950f..ca8d793a1 100644 --- a/packages/cmsui/components/Form/Field.tsx +++ b/packages/cmsui/components/Form/Field.tsx @@ -239,9 +239,11 @@ const SchemaField = (props: FieldProps) => { getWidgetByFieldId(name) || getWidgetFromTaggedValues(schema.widgetOptions) || getWidgetByName(schema.widget) || - getWidgetByChoices(resolvable) || + // A widget registered for the vocabulary is more specific than the + // widget of all the fields with choices. getWidgetByVocabulary(schema.vocabulary) || getWidgetByVocabularyFromHint(resolvable) || + getWidgetByChoices(resolvable) || getWidgetByFactory(schema.factory) || getWidgetByType(schema.type) || getWidgetDefault(); diff --git a/packages/cmsui/components/Form/useChoices.ts b/packages/cmsui/components/Form/useChoices.ts new file mode 100644 index 000000000..5b45e15c3 --- /dev/null +++ b/packages/cmsui/components/Form/useChoices.ts @@ -0,0 +1,94 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { WidgetChoice, WidgetVocabulary } from '@plone/types'; + +/** One option of a field with choices or a vocabulary. */ +export type ChoiceOption = { value: string; label: string }; + +/** + * A term as the content API sends it: its token, or an object with its token + * and its title, such as `{ "token": "Document", "title": "Page" }`. + */ +export type TermValue = string | { token: string; title?: string | null }; + +/** The option of a term the content API sent. */ +export const termOption = (term: unknown): ChoiceOption | null => { + if (term == null || term === '') return null; + if (typeof term === 'object' && 'token' in term) { + const { token, title } = term as { token: unknown; title?: unknown }; + return { value: String(token), label: String(title ?? token) }; + } + return { value: String(term), label: String(term) }; +}; + +// The same empty list on every render, while a vocabulary loads. +const NO_OPTIONS: ChoiceOption[] = []; + +/** + * The name of a vocabulary, from its `@id` + * (`http://site/@vocabularies/plone.app.vocabularies.Keywords`). + */ +export const vocabularyName = (vocabulary?: WidgetVocabulary) => { + const id = vocabulary?.['@id']; + if (!id || !id.includes('/@vocabularies/')) return undefined; + return id.slice(id.lastIndexOf('/@vocabularies/') + '/@vocabularies/'.length); +}; + +/** + * The options of a field: its `choices`, or the terms of its vocabulary. + * + * Fields with choices have their options in the schema. For a vocabulary, + * the terms are loaded from the `@vocabulary` resource route; with `title`, + * only the terms whose title matches it. + */ +export function useChoices({ + choices, + vocabulary, + title, +}: { + choices?: WidgetChoice[]; + vocabulary?: WidgetVocabulary; + title?: string; +}): { options: ChoiceOption[]; loading: boolean } { + const name = choices ? undefined : vocabularyName(vocabulary); + const query = name + ? new URLSearchParams({ name, ...(title ? { title } : {}) }).toString() + : ''; + const [loaded, setLoaded] = useState<{ + query: string; + options: ChoiceOption[]; + } | null>(null); + + useEffect(() => { + if (!query) return; + const controller = new AbortController(); + fetch(`/@vocabulary?${query}`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }) + .then((response) => (response.ok ? response.json() : { items: [] })) + .then((data: { items?: Array<{ token: string; title: string }> }) => + setLoaded({ + query, + options: (data.items ?? []).map(({ token, title }) => ({ + value: token, + label: title, + })), + }), + ) + .catch(() => { + // An aborted or failed request leaves the options as they are. + }); + return () => controller.abort(); + }, [query]); + + const fromChoices = useMemo( + () => (choices ?? []).map(([value, label]) => ({ value, label })), + [choices], + ); + + if (!query) return { options: fromChoices, loading: false }; + if (loaded?.query === query) + return { options: loaded.options, loading: false }; + return { options: loaded?.options ?? NO_OPTIONS, loading: true }; +} diff --git a/packages/cmsui/components/InputWidgets/InputWidgets.stories.tsx b/packages/cmsui/components/InputWidgets/InputWidgets.stories.tsx new file mode 100644 index 000000000..0a3a83e6e --- /dev/null +++ b/packages/cmsui/components/InputWidgets/InputWidgets.stories.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { EmailWidget, PasswordWidget, UrlWidget } from './InputWidgets'; + +const meta = { + title: 'CMSUI/Widgets/InputWidgets', + component: EmailWidget, + args: { + name: 'field', + label: 'Field', + value: '', + onChange: () => {}, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function InputWidgetStory({ + Widget, + ...args +}: React.ComponentProps & { + Widget: typeof EmailWidget; +}) { + const [value, setValue] = useState(args.value ?? ''); + return ; +} + +export const Email: Story = { + args: { label: 'Email', value: 'editor@example.com' }, + render: (args) => , +}; + +export const Password: Story = { + args: { label: 'Password', value: 'secret' }, + render: (args) => , +}; + +export const Url: Story = { + args: { label: 'URL', value: 'https://plone.org' }, + render: (args) => , +}; diff --git a/packages/cmsui/components/InputWidgets/InputWidgets.tsx b/packages/cmsui/components/InputWidgets/InputWidgets.tsx new file mode 100644 index 000000000..a0350f0d5 --- /dev/null +++ b/packages/cmsui/components/InputWidgets/InputWidgets.tsx @@ -0,0 +1,74 @@ +import type { FormWidgetProps } from '@plone/types'; +import { TextField, type TextFieldProps } from '@plone/quanta'; + +export type InputWidgetProps = FormWidgetProps; + +/** + * Makes a widget that adapts the widget contract to the Quanta `TextField` + * control with an input of the given type. The value is a string. + */ +function inputWidget( + displayName: string, + inputProps: Pick, +) { + function InputWidget({ + name, + value, + onChange, + onBlur, + label, + description, + placeholder, + required, + disabled, + readOnly, + invalid, + errorMessage, + className, + }: InputWidgetProps) { + return ( + + ); + } + InputWidget.displayName = displayName; + return InputWidget; +} + +/** An email address. */ +export const EmailWidget = inputWidget('EmailWidget', { + type: 'email', + inputMode: 'email', +}); + +/** + * A password. The browser doesn't fill it in with the editor's own + * password. + */ +export const PasswordWidget = inputWidget('PasswordWidget', { + type: 'password', + autoComplete: 'new-password', +}); + +/** An absolute URL, such as `https://plone.org`. */ +export const UrlWidget = inputWidget('UrlWidget', { + type: 'url', + inputMode: 'url', +}); diff --git a/packages/cmsui/components/NumberWidget/NumberWidget.stories.tsx b/packages/cmsui/components/NumberWidget/NumberWidget.stories.tsx new file mode 100644 index 000000000..089eed08e --- /dev/null +++ b/packages/cmsui/components/NumberWidget/NumberWidget.stories.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { NumberWidget } from './NumberWidget'; + +const meta = { + title: 'CMSUI/Widgets/NumberWidget', + component: NumberWidget, + args: { + name: 'limit', + label: 'Items per page', + description: 'How many items a listing shows.', + value: 10, + onChange: () => {}, + schema: { type: 'integer', minimum: 1, maximum: 100 }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function NumberWidgetStory(args: React.ComponentProps) { + const [value, setValue] = useState(args.value ?? null); + return ; +} + +/** An `integer` field, between its `minimum` and `maximum`. */ +export const Default: Story = { + render: (args) => , +}; + +/** A `number` field takes decimals. */ +export const Decimal: Story = { + args: { + label: 'Ratio', + description: '', + value: 1.5, + schema: { type: 'number' }, + }, + render: (args) => , +}; + +export const Invalid: Story = { + args: { + value: null, + required: true, + invalid: true, + errorMessage: 'Required input is missing.', + }, + render: (args) => , +}; diff --git a/packages/cmsui/components/NumberWidget/NumberWidget.tsx b/packages/cmsui/components/NumberWidget/NumberWidget.tsx new file mode 100644 index 000000000..f06256dc8 --- /dev/null +++ b/packages/cmsui/components/NumberWidget/NumberWidget.tsx @@ -0,0 +1,63 @@ +import type { FormWidgetProps } from '@plone/types'; +import { NumberField } from '@plone/quanta'; + +export type NumberWidgetProps = FormWidgetProps; + +const asNumber = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) ? value : undefined; + +/** + * Adapts the widget contract to the Quanta `NumberField` control, for + * `number` and `integer` fields. The value is a number, or `null` when the + * field is empty. + * + * It reads the schema's `minimum` and `maximum`, and only takes whole + * numbers for an `integer` field. + */ +export function NumberWidget({ + name, + value, + onChange, + onBlur, + label, + description, + placeholder, + required, + disabled, + readOnly, + invalid, + errorMessage, + className, + schema, +}: NumberWidgetProps) { + const integer = schema?.type === 'integer'; + + return ( + onChange(Number.isNaN(next) ? null : next)} + onBlur={onBlur} + minValue={asNumber(schema?.minimum)} + maxValue={asNumber(schema?.maximum)} + step={integer ? 1 : undefined} + formatOptions={ + integer ? { maximumFractionDigits: 0, useGrouping: false } : undefined + } + label={label} + description={description} + placeholder={placeholder} + isRequired={required} + isDisabled={disabled} + isReadOnly={readOnly} + isInvalid={invalid} + errorMessage={errorMessage} + // Validation is the form's job, not the browser's. + validationBehavior="aria" + className={className} + /> + ); +} + +NumberWidget.displayName = 'NumberWidget'; diff --git a/packages/cmsui/components/SelectWidget/SelectWidget.stories.tsx b/packages/cmsui/components/SelectWidget/SelectWidget.stories.tsx new file mode 100644 index 000000000..7cd1da41d --- /dev/null +++ b/packages/cmsui/components/SelectWidget/SelectWidget.stories.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SelectWidget } from './SelectWidget'; + +const meta = { + title: 'CMSUI/Widgets/SelectWidget', + component: SelectWidget, + args: { + name: 'language', + label: 'Language', + description: 'The language of the page.', + value: 'en', + onChange: () => {}, + choices: [ + ['en', 'English'], + ['de', 'Deutsch'], + ['it', 'Italiano'], + ], + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function SelectWidgetStory(args: React.ComponentProps) { + const [value, setValue] = useState(args.value ?? null); + return ; +} + +export const Default: Story = { + render: (args) => , +}; + +export const Required: Story = { + args: { required: true }, + render: (args) => , +}; + +export const Invalid: Story = { + args: { + value: null, + required: true, + invalid: true, + errorMessage: 'Required input is missing.', + }, + render: (args) => , +}; diff --git a/packages/cmsui/components/SelectWidget/SelectWidget.tsx b/packages/cmsui/components/SelectWidget/SelectWidget.tsx new file mode 100644 index 000000000..51979b393 --- /dev/null +++ b/packages/cmsui/components/SelectWidget/SelectWidget.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import type { FormWidgetProps } from '@plone/types'; +import { Select, type SelectItemObject } from '@plone/quanta'; +import { useTranslation } from 'react-i18next'; +import { termOption, useChoices, type TermValue } from '../Form/useChoices'; + +/** + * The widget reads a token, or a term as the content API sends it, and + * writes a token. + */ +export type SelectWidgetProps = FormWidgetProps; + +// The key of the "no value" option. +const NO_VALUE = '--NOVALUE--'; + +/** + * Adapts the widget contract to the Quanta `Select` control, for a field + * with `choices` or a vocabulary. The value is the token of the chosen + * option, or `null`. + */ +export function SelectWidget({ + name, + value, + onChange, + onBlur, + label, + description, + placeholder, + required, + disabled, + readOnly, + invalid, + errorMessage, + className, + choices, + vocabulary, + widgetOptions, +}: SelectWidgetProps) { + const { t } = useTranslation(); + const { options } = useChoices({ + choices, + vocabulary: vocabulary ?? widgetOptions?.vocabulary, + }); + + // The content API can send the value of a choice as a term object, or as + // another type than its token, such as `true` for the token `True`. + const term = termOption(value); + const stored = term?.value ?? null; + const selected = + stored === null + ? null + : (options.find((option) => option.value === stored)?.value ?? + options.find( + (option) => option.value.toLowerCase() === stored.toLowerCase(), + )?.value ?? + stored); + + const items = useMemo(() => { + const list: SelectItemObject[] = [...options]; + // The stored value stays selected while the vocabulary loads, or when + // it's no longer one of the options. + if (selected && !list.some((item) => item.value === selected)) { + list.unshift({ value: selected, label: term?.label ?? selected }); + } + // An optional field can be emptied. + if (!required) { + list.unshift({ + value: NO_VALUE, + label: t('cmsui.widgets.select.noValue'), + }); + } + return list; + }, [options, selected, term?.label, required, t]); + + return ( + +
+ + + + + + +
+ + )} + + {description && {description}} + {errorMessage} + + ); +} + +function StepperButton(props: ButtonProps) { + return ( +