diff --git a/.changeset/31062-notification-topic-channel-pickers.md b/.changeset/31062-notification-topic-channel-pickers.md new file mode 100644 index 00000000000..43e9f3de51e --- /dev/null +++ b/.changeset/31062-notification-topic-channel-pickers.md @@ -0,0 +1,19 @@ +--- +'@object-ui/fields': patch +'@object-ui/i18n': patch +--- + +feat(fields): Setup's notification topic and channel fields offer the served list and still take any typed value + +Clause-②: yes (widening) — five new `fields.preferenceOption.*` keys in every published `@object-ui/i18n` pack, and `field:notification-topic` / `field:notification-channel` join `@object-ui/fields`' registered widget keys (`RegisteredFieldWidgetType`); nothing is narrowed. +Metadata-protocol: none + +A notification preference's `topic` and `channel`, and a notification template's `topic`, already declare `widget: 'notification-topic'` / `'notification-channel'`. The record form now resolves both: `@object-ui/fields` registers `field:notification-topic` and `field:notification-channel`, a fillable arm of the registry-name picker (`RegistryRefField`). + +- **The list.** Read from `GET /api/v1/messaging/preference-options` through the host's authenticated fetch (`SchemaRendererContext.apiFetch`), once per page for every field that draws from it, and listed in the order the server sends: `*` first, shown as "All topics (*)" / "All channels (*)". +- **A typed value.** Typing in the picker's search box offers a row that commits the text exactly as typed. A stored value the list does not hold stays on screen as its own option, marked "not in the list". Nothing is refused or rewritten; the server stores what it is given, as before. +- **A channel that is not configured** is listed, labelled "(not configured)", and can be picked. +- **No list.** When the request fails, or answers an empty list, the field is the plain text box it was before, with no notice. +- **Strings.** Five new `fields.preferenceOption.*` keys in every built-in language pack (`zh-Hant` regenerated from `zh-Hans`). + +The template's `channel` field declares no widget and stays a text box. diff --git a/ui/packages/app-shell/src/utils/paramValueShape.ts b/ui/packages/app-shell/src/utils/paramValueShape.ts index c8c958d5ef8..4eb46cbba1e 100644 --- a/ui/packages/app-shell/src/utils/paramValueShape.ts +++ b/ui/packages/app-shell/src/utils/paramValueShape.ts @@ -179,6 +179,8 @@ export const PARAM_VALUE_SHAPES: Readonly> = 'recipient-picker': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Recipient id string. Widget-hint only — not a declarable param type.' }, 'position-ref': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Position name string. Widget-hint only — not a declarable param type.' }, 'permission-ref': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Permission-set name string. Widget-hint only — not a declarable param type.' }, + 'notification-topic': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Notification topic string. Widget-hint only — not a declarable param type.' }, + 'notification-channel': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Notification channel string. Widget-hint only — not a declarable param type.' }, }); /** Concrete `base[]` tag for a base shape. */ diff --git a/ui/packages/cli/src/utils/known-schema-types.ts b/ui/packages/cli/src/utils/known-schema-types.ts index 2c7c0a3c3bf..3c59cab8577 100644 --- a/ui/packages/cli/src/utils/known-schema-types.ts +++ b/ui/packages/cli/src/utils/known-schema-types.ts @@ -146,6 +146,8 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'field:markdown', 'field:master_detail', 'field:multiselect', + 'field:notification-channel', + 'field:notification-topic', 'field:number', 'field:object', 'field:object-ref', diff --git a/ui/packages/fields/src/FieldEditWidget.tsx b/ui/packages/fields/src/FieldEditWidget.tsx index 37ffe9696a4..f9b1c1fc773 100644 --- a/ui/packages/fields/src/FieldEditWidget.tsx +++ b/ui/packages/fields/src/FieldEditWidget.tsx @@ -190,6 +190,7 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set([ // sibling fields / a loaded object catalog), not inline in a grid cell. 'object-ref', 'filter-condition', 'recipient-picker', 'position-ref', 'permission-ref', + 'notification-topic', 'notification-channel', ]); /** Field types whose value is chosen in one discrete gesture (no free typing). */ diff --git a/ui/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx b/ui/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx index c25c87d3b49..e08560d96d7 100644 --- a/ui/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +++ b/ui/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx @@ -134,7 +134,12 @@ import { QRCodeField } from '../widgets/QRCodeField'; import { ObjectRefField } from '../widgets/ObjectRefField'; import { FilterConditionField } from '../widgets/FilterConditionField'; import { RecipientPickerField } from '../widgets/RecipientPickerField'; -import { PositionRefField, PermissionRefField } from '../widgets/RegistryRefField'; +import { + PositionRefField, + PermissionRefField, + NotificationTopicField, + NotificationChannelField, +} from '../widgets/RegistryRefField'; /** Same widget map as the #3291 sweep, so the parity check covers the registry. */ const WIDGETS: Record> = { @@ -188,6 +193,8 @@ const WIDGETS: Record> = { 'recipient-picker': RecipientPickerField, 'position-ref': PositionRefField, 'permission-ref': PermissionRefField, + 'notification-topic': NotificationTopicField, + 'notification-channel': NotificationChannelField, }; /** diff --git a/ui/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx b/ui/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx index ec595bb7a17..49b9fbf29c5 100644 --- a/ui/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx +++ b/ui/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx @@ -144,7 +144,12 @@ import { QRCodeField } from '../widgets/QRCodeField'; import { ObjectRefField } from '../widgets/ObjectRefField'; import { FilterConditionField } from '../widgets/FilterConditionField'; import { RecipientPickerField } from '../widgets/RecipientPickerField'; -import { PositionRefField, PermissionRefField } from '../widgets/RegistryRefField'; +import { + PositionRefField, + PermissionRefField, + NotificationTopicField, + NotificationChannelField, +} from '../widgets/RegistryRefField'; /* ════════════════════════════════════════════════════════════════════════════ * Every registered field widget, both hosts @@ -207,6 +212,8 @@ const WIDGETS: Record> = { 'recipient-picker': RecipientPickerField, 'position-ref': PositionRefField, 'permission-ref': PermissionRefField, + 'notification-topic': NotificationTopicField, + 'notification-channel': NotificationChannelField, }; /** Option widgets render an "unfillable" placeholder unless offered a list. */ diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 6e2ee802ceb..a1890de0c13 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -4890,6 +4890,14 @@ const fieldWidgetMap = { // permission-ref → a permission set (sys_user_permission_set.permission_set) 'position-ref': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.PositionRefField })), 'permission-ref': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.PermissionRefField })), + // The fillable arm of the same widget (#30208 row 6, ruling E on #30527): a + // notification preference's `topic` / `channel` and a template's `topic`, + // offered from `GET /api/v1/messaging/preference-options` and still taking + // any typed value, which the server stores as written. + // notification-topic → a notification topic, `*` for all + // notification-channel → a notification channel, `*` for all + 'notification-topic': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.NotificationTopicField })), + 'notification-channel': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.NotificationChannelField })), } satisfies Record; /** The registered field widget keys, as a literal union (objectui#4857). */ @@ -5072,6 +5080,8 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([ 'recipient-picker', 'position-ref', 'permission-ref', + 'notification-topic', + 'notification-channel', // ── Retired bare node keys (objectui#10859 batch 8) ─────────────────────── // Each was registered bare only as this loop's fallback: no other package // owns the name, no form path reads it, and no document authors it as a @@ -5220,6 +5230,8 @@ export const FIELD_WIDGET_LABELLING: Record< 'recipient-picker': 'control', 'position-ref': 'control', 'permission-ref': 'control', + 'notification-topic': 'control', + 'notification-channel': 'control', }; export function registerField(fieldType: string): void { diff --git a/ui/packages/fields/src/widgets/RegistryRefField.tsx b/ui/packages/fields/src/widgets/RegistryRefField.tsx index cffff5a203d..666e92f43f9 100644 --- a/ui/packages/fields/src/widgets/RegistryRefField.tsx +++ b/ui/packages/fields/src/widgets/RegistryRefField.tsx @@ -7,13 +7,29 @@ */ import React from 'react'; -import { AlertTriangle } from 'lucide-react'; -import { Combobox, EmptyValue, Input, cn } from '@object-ui/components'; -import { SchemaRendererContext } from '@object-ui/react'; +import { AlertTriangle, Check, ChevronsUpDown } from 'lucide-react'; +import { + Button, + Combobox, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + EmptyValue, + Input, + Popover, + PopoverContent, + PopoverTrigger, + cn, +} from '@object-ui/components'; +import { SchemaRendererContext, type ApiFetch } from '@object-ui/react'; import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec'; import { MCP_AGENT_PERMISSION_SETS } from '@objectstack/spec/ai'; import type { FieldWidgetComponentProps } from './types.js'; -import { registryLister, type RegistryItemHeader } from './registryCatalog.js'; +import { registryLister, type RegistryItemHeader, type RegistryLister } from './registryCatalog.js'; +import { loadPreferenceOptions, type PreferenceList, type PreferenceOptionItem } from './preferenceOptions.js'; import { toDomProps } from './toDomProps.js'; import { useFieldTranslation } from './useFieldTranslation.js'; @@ -67,6 +83,24 @@ import { useFieldTranslation } from './useFieldTranslation.js'; * * ⛔ No arm writes anything but a name the person picked or typed: no label, * no id, no first-item default. + * + * ## The fillable arm — `notification-topic` / `notification-channel` + * + * A notification preference's `topic` and `channel`, and a template's `topic`, + * are free text the server stores as written (#30208 row 6, ruling E on + * #30527): the list is a suggestion, never a closed set. So the same four-arm + * machinery reads a different catalog — the preference options door + * (`./preferenceOptions.ts`), through the host's authenticated fetch — and + * differs from the registry arm in exactly what an open list needs: + * + * - FAILED, IDLE (no host fetch on `SchemaRendererContext`) or an empty + * list: the plain text box, with no notice; + * - LOADED: a picker that lists the served values in the door's order (`*` + * first) and ALSO commits whatever the person types, verbatim; + * - a stored or typed value the list does not hold is kept, marked "not in + * the list" — never refused, never rewritten; + * - a channel the door marks not configured is listed, labelled so, and + * selectable. */ export type { RegistryItemHeader }; @@ -123,47 +157,70 @@ function nonGrantableNames(type: string): ReadonlySet { return NO_EXCLUSIONS; } +/** + * Where a catalog is read from: whether an origin can answer at all, and the + * read. One module-level reader per arm, so the read's identity never moves + * between renders. + */ +interface CatalogReader { + readerOf(origin: unknown): Origin | null; + read(origin: Origin, list: string): Promise; +} + +/** The registry arm: `DataSource.listMetadataItems(type)`. */ +const REGISTRY_READER: CatalogReader = { + readerOf: registryLister, + read: (source, type) => source.listMetadataItems(type), +}; + +/** The fillable arm: the preference options door, through the host's fetch. */ +const PREFERENCE_READER: CatalogReader = { + readerOf: (origin) => (typeof origin === 'function' ? (origin as ApiFetch) : null), + read: (doFetch, list) => loadPreferenceOptions(doFetch).then((options) => options[list as PreferenceList]), +}; + /** A settled answer, with the inputs it answers for. */ interface SettledCatalog { - dataSource: unknown; - type: string; + origin: unknown; + list: string; state: RegistryCatalogState; } /** - * Load one registry type's catalog. Keyed on the data source and the type it - * reads — the inputs the request is made from — and nothing memoised. + * Load one catalog. Keyed on the origin it is read from (the data source, or + * the host's fetch) and the list it reads — the inputs the request is made + * from — and nothing memoised. * * The arm is DERIVED from those inputs on every render rather than written - * into state ahead of the request: no lister is IDLE, and a lister whose - * answer for exactly these inputs has not settled is LOADING. So an answer to - * an older data source or type can never be shown as the current one, and the - * effect only ever writes a settled answer. + * into state ahead of the request: an origin that cannot answer is IDLE, and + * one whose answer for exactly these inputs has not settled is LOADING. So an + * answer to an older origin or list can never be shown as the current one, and + * the effect only ever writes a settled answer. */ -function useRegistryCatalog(dataSource: unknown, type: string): RegistryCatalogState { +function useCatalog(reader: CatalogReader, origin: unknown, list: string): RegistryCatalogState { const [settled, setSettled] = React.useState(null); React.useEffect(() => { - const source = registryLister(dataSource); + const source = reader.readerOf(origin); if (!source) return; let cancelled = false; - source.listMetadataItems(type).then( + reader.read(source, list).then( (items) => { - if (!cancelled) setSettled({ dataSource, type, state: { status: 'loaded', items } }); + if (!cancelled) setSettled({ origin, list, state: { status: 'loaded', items } }); }, (err: unknown) => { if (!cancelled) { - setSettled({ dataSource, type, state: { status: 'error', message: failureMessage(err) } }); + setSettled({ origin, list, state: { status: 'error', message: failureMessage(err) } }); } }, ); return () => { cancelled = true; }; - }, [dataSource, type]); + }, [reader, origin, list]); - if (!registryLister(dataSource)) return IDLE; - return settled && settled.dataSource === dataSource && settled.type === type ? settled.state : LOADING; + if (!reader.readerOf(origin)) return IDLE; + return settled && settled.origin === origin && settled.list === list ? settled.state : LOADING; } /** What one item reads as: its label beside its name, or the name alone. */ @@ -188,7 +245,7 @@ export function RegistryRefField({ const ctx = React.useContext(SchemaRendererContext); const { t } = useFieldTranslation(); const dataSource = props.dataSource ?? ctx?.dataSource ?? null; - const catalog = useRegistryCatalog(dataSource, registryType); + const catalog = useCatalog(REGISTRY_READER, dataSource, registryType); const excluded = nonGrantableNames(registryType); const current = typeof value === 'string' ? value : ''; const testId = `registry-ref-${registryType}`; @@ -304,4 +361,210 @@ export function PermissionRefField(props: FieldWidgetComponentProps) { return ; } +/** What one listed preference value reads as. */ +function preferenceText( + item: PreferenceOptionItem, + list: PreferenceList, + t: (key: string, params?: Record) => string, +): string { + if (item.wildcard) { + return t(list === 'topics' ? 'fields.preferenceOption.allTopics' : 'fields.preferenceOption.allChannels', { + value: item.name, + }); + } + if (!item.configured) return t('fields.preferenceOption.notConfigured', { value: item.name }); + return item.name; +} + +interface FillableOption { + value: string; + label: string; + notConfigured: boolean; +} + +function matchesSearch(option: FillableOption, needle: string): boolean { + if (!needle) return true; + const q = needle.toLowerCase(); + return option.value.toLowerCase().includes(q) || option.label.toLowerCase().includes(q); +} + +export interface PreferenceOptionFieldProps extends FieldWidgetComponentProps { + /** Which of the preference options door's two lists this field offers. */ + preferenceList: PreferenceList; +} + +/** + * The fillable arm (see the file header): a value from the preference options + * list, or whatever the person types. + */ +export function PreferenceOptionField({ + preferenceList, + value, + onChange, + readonly, + className, + error, + ...props +}: PreferenceOptionFieldProps) { + const ctx = React.useContext(SchemaRendererContext); + const { t } = useFieldTranslation(); + // The host's authenticated fetch only: a surface with none (a preview, a + // test that drives the widget bare) has no list to offer and is IDLE. + const catalog = useCatalog(PREFERENCE_READER, ctx?.apiFetch, preferenceList); + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(''); + const current = typeof value === 'string' ? value : ''; + const testId = `preference-option-${preferenceList}`; + + const listed = catalog.status === 'loaded' ? (catalog.items as PreferenceOptionItem[]) : null; + const entry = listed?.find((item) => item.name === current); + const notInList = listed !== null && listed.length > 0 && current !== '' && !entry; + const notInListText = t('fields.preferenceOption.notInList', { value: current }); + + if (readonly) { + if (!current) return ; + return ( + + {notInList ? notInListText : entry ? preferenceText(entry, preferenceList, t) : current} + + ); + } + + const domProps = toDomProps(props); + const disabled = props.disabled; + + if (catalog.status === 'loading') { + return ( + + ); + } + + // A failed or empty answer is no list at all: the plain text box, with no + // notice, writing exactly what is typed. + if (!listed || listed.length === 0) { + return ( + onChange(e.target.value)} + disabled={disabled} + className={className} + aria-invalid={!!error} + /> + ); + } + + const options: FillableOption[] = listed.map((item) => ({ + value: item.name, + label: preferenceText(item, preferenceList, t), + notConfigured: !item.configured, + })); + // A stored value the list does not hold stays its own option, marked. + if (notInList) options.unshift({ value: current, label: notInListText, notConfigured: false }); + + const filtered = options.filter((option) => matchesSearch(option, search.trim())); + // The typed text is offered as written whenever it is not already a value. + const offerTyped = search.trim() !== '' && !options.some((option) => option.value === search); + const commit = (next: string) => { + onChange(next); + setOpen(false); + setSearch(''); + }; + + // DOM pass-through onto the trigger, the widget's focusable control + // (objectui#3318). `name` is withheld: the trigger is a button, not a + // submission control (the `ObjectRefField` reasoning). + const { name: _domName, ...triggerDomProps } = domProps; + return ( + { + setOpen(next); + if (!next) setSearch(''); + }} + > + + + + + + + + {!offerTyped && filtered.length === 0 ? {t('common.noResults')} : null} + {offerTyped ? ( + + commit(search)} + data-testid={`${testId}-use-typed`} + > + {t('fields.preferenceOption.useTyped', { value: search })} + + + ) : null} + {filtered.length > 0 ? ( + + {filtered.map((option) => ( + commit(option.value)} + data-not-configured={option.notConfigured || undefined} + > + + {option.label} + + ))} + + ) : null} + + + + + ); +} + +/** `notification-topic`: a notification topic, from the list or typed. */ +export function NotificationTopicField(props: FieldWidgetComponentProps) { + return ; +} + +/** `notification-channel`: a notification channel, from the list or typed. */ +export function NotificationChannelField(props: FieldWidgetComponentProps) { + return ; +} + export default RegistryRefField; diff --git a/ui/packages/fields/src/widgets/__tests__/RegistryRefField.preferenceOptions-31062.test.tsx b/ui/packages/fields/src/widgets/__tests__/RegistryRefField.preferenceOptions-31062.test.tsx new file mode 100644 index 00000000000..8f482efc50a --- /dev/null +++ b/ui/packages/fields/src/widgets/__tests__/RegistryRefField.preferenceOptions-31062.test.tsx @@ -0,0 +1,259 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `notification-topic` / `notification-channel` — the fillable arm of + * `RegistryRefField` (#30208 row 6, ruling E on #30527). + * + * A notification preference's topic and channel are free text the server + * stores as written; the preference options door only SUGGESTS values. These + * pins hold what the arm promises: + * + * - the list renders from the door, in its order, `*` first; + * - a failed or empty answer gives the plain text box, with no notice; + * - a value outside the list is committed as typed and shown marked + * "not in the list"; + * - a channel the door marks not configured is listed, labelled, selectable. + * + * The door is reached through the host's fetch on `SchemaRendererContext`, + * stubbed here with the door's own success envelope. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { NotificationTopicField, NotificationChannelField } from '../RegistryRefField'; +import { PREFERENCE_OPTIONS_URL } from '../preferenceOptions'; +// Module scope: the package entry registers every `field:` key the +// record form resolves a field's `widget:` hint through. +import { registerAllFields } from '../../index'; + +const ANSWER = { + success: true, + data: { + topics: [ + { value: '*', wildcard: true, sources: [] }, + { value: 'approval.requested', wildcard: false, sources: ['platform', 'stored'] }, + { value: 'invoice.paid', wildcard: false, sources: ['flow'] }, + ], + channels: [ + { value: '*', wildcard: true, configured: true }, + { value: 'inbox', wildcard: false, configured: true }, + { value: 'push', wildcard: false, configured: false }, + ], + }, +}; + +function door(answer: () => Promise) { + return vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => answer()); +} + +const ok = (body: unknown) => async () => new Response(JSON.stringify(body), { status: 200 }); + +function mount( + Widget: typeof NotificationTopicField, + { + apiFetch, + value = '', + onChange = vi.fn(), + readonly = false, + }: { apiFetch: ReturnType; value?: string; onChange?: any; readonly?: boolean }, +) { + render( + + + , + ); + return { onChange }; +} + +function optionTexts(): string[] { + return within(screen.getByRole('listbox')) + .getAllByRole('option') + .map((option) => option.textContent ?? ''); +} + +afterEach(() => cleanup()); + +describe('the record form reaches the arm through `field:` (#30208 row 6)', () => { + it.each([ + ['notification-topic', 'preference-option-topics', 'All topics (*)'], + ['notification-channel', 'preference-option-channels', 'All channels (*)'], + ])('`%s` is registered and lists the door', async (widget, testId, wildcard) => { + registerAllFields(); + const Widget = ComponentRegistry.get(`field:${widget}`); + expect(Widget).toBeDefined(); + + const apiFetch = door(ok(ANSWER)); + render( + + + {React.createElement(Widget!, { value: '', onChange: vi.fn(), field: { name: 'x', type: 'text' } })} + + , + ); + fireEvent.click(await screen.findByTestId(testId)); + expect(optionTexts()[0]).toBe(wildcard); + }); +}); + +describe('notification-topic — the list renders from the door (#30208 row 6)', () => { + it('reads the preference options door through the host fetch and lists its topics, `*` first', async () => { + const apiFetch = door(ok(ANSWER)); + mount(NotificationTopicField, { apiFetch }); + + fireEvent.click(await screen.findByTestId('preference-option-topics')); + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(String(apiFetch.mock.calls[0][0])).toBe(PREFERENCE_OPTIONS_URL); + // The door's order, kept: the wildcard leads, by its words and its value. + expect(optionTexts()).toEqual(['All topics (*)', 'approval.requested', 'invoice.paid']); + }); + + it('stores the picked value, the wildcard included', async () => { + const apiFetch = door(ok(ANSWER)); + const { onChange } = mount(NotificationTopicField, { apiFetch }); + + fireEvent.click(await screen.findByTestId('preference-option-topics')); + fireEvent.click(screen.getByText('All topics (*)')); + expect(onChange).toHaveBeenCalledWith('*'); + }); + + it('shows a stored listed value by its own face, unmarked', async () => { + const apiFetch = door(ok(ANSWER)); + mount(NotificationTopicField, { apiFetch, value: 'invoice.paid' }); + + const trigger = await screen.findByTestId('preference-option-topics'); + expect(trigger).toHaveTextContent('invoice.paid'); + expect(trigger).not.toHaveAttribute('data-not-in-list'); + }); + + it('a topic field and a channel field share one read of the door', async () => { + const apiFetch = door(ok(ANSWER)); + render( + + + + , + ); + expect(await screen.findByTestId('preference-option-topics')).toBeInTheDocument(); + expect(await screen.findByTestId('preference-option-channels')).toBeInTheDocument(); + expect(apiFetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('notification-topic — the plain text box when there is no list (#30208 row 6)', () => { + it('a failed read gives the text box, with no notice, writing exactly what is typed', async () => { + const apiFetch = door(async () => new Response('{}', { status: 500 })); + const { onChange } = mount(NotificationTopicField, { apiFetch, value: 'approval.requested' }); + + const box = await screen.findByRole('textbox'); + await vi.waitFor(() => expect(box).toBeEnabled()); + expect(box).toHaveValue('approval.requested'); + expect(screen.queryByRole('status')).toBeNull(); + expect(screen.queryByTestId('preference-option-topics')).toBeNull(); + + fireEvent.change(box, { target: { value: 'invoices.paid' } }); + expect(onChange).toHaveBeenCalledWith('invoices.paid'); + }); + + it('a rejected fetch gives the same text box', async () => { + const apiFetch = door(async () => { + throw new TypeError('Failed to fetch'); + }); + mount(NotificationTopicField, { apiFetch }); + + const box = await screen.findByRole('textbox'); + await vi.waitFor(() => expect(box).toBeEnabled()); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('no host fetch gives the same text box, and nothing is read', async () => { + const globalFetch = vi.spyOn(globalThis, 'fetch'); + const onChange = vi.fn(); + render( + + + , + ); + const box = screen.getByRole('textbox'); + expect(box).toBeEnabled(); + expect(box).toHaveValue('*'); + expect(globalFetch).not.toHaveBeenCalled(); + globalFetch.mockRestore(); + }); + + it('an empty list gives the same text box', async () => { + const apiFetch = door(ok({ success: true, data: { topics: [], channels: [] } })); + mount(NotificationTopicField, { apiFetch }); + + const box = await screen.findByRole('textbox'); + await vi.waitFor(() => expect(box).toBeEnabled()); + expect(screen.queryByTestId('preference-option-topics')).toBeNull(); + }); +}); + +describe('notification-topic — a value outside the list (#30208 row 6)', () => { + it('commits the typed text as written', async () => { + const apiFetch = door(ok(ANSWER)); + const { onChange } = mount(NotificationTopicField, { apiFetch }); + + fireEvent.click(await screen.findByTestId('preference-option-topics')); + fireEvent.change(screen.getByTestId('preference-option-topics-search'), { + target: { value: 'Invoices.Paid' }, + }); + fireEvent.click(screen.getByTestId('preference-option-topics-use-typed')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('Invoices.Paid'); + }); + + it('keeps a stored value the list lacks, marked "not in the list", and writes nothing', async () => { + const apiFetch = door(ok(ANSWER)); + const { onChange } = mount(NotificationTopicField, { apiFetch, value: 'invoices.paid' }); + + const trigger = await screen.findByTestId('preference-option-topics'); + expect(trigger).toHaveTextContent('invoices.paid (not in the list)'); + expect(trigger).toHaveAttribute('data-not-in-list', 'true'); + expect(onChange).not.toHaveBeenCalled(); + + // It stays an option of its own, ahead of the listed values. + fireEvent.click(trigger); + expect(optionTexts()[0]).toBe('invoices.paid (not in the list)'); + }); + + it('marks it on the read face too', async () => { + const apiFetch = door(ok(ANSWER)); + mount(NotificationTopicField, { apiFetch, value: 'invoices.paid', readonly: true }); + + expect(await screen.findByText('invoices.paid (not in the list)')).toHaveAttribute('data-not-in-list', 'true'); + }); +}); + +describe('notification-channel — a channel that is not configured (#30208 row 6)', () => { + it('is listed, labelled not configured, and selectable', async () => { + const apiFetch = door(ok(ANSWER)); + const { onChange } = mount(NotificationChannelField, { apiFetch }); + + fireEvent.click(await screen.findByTestId('preference-option-channels')); + expect(optionTexts()).toEqual(['All channels (*)', 'inbox', 'push (not configured)']); + + const push = screen.getByText('push (not configured)').closest('[role="option"]')!; + expect(push).toHaveAttribute('data-not-configured', 'true'); + expect(push).not.toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(push); + expect(onChange).toHaveBeenCalledWith('push'); + }); +}); diff --git a/ui/packages/fields/src/widgets/preferenceOptions.ts b/ui/packages/fields/src/widgets/preferenceOptions.ts new file mode 100644 index 00000000000..670e62edbc7 --- /dev/null +++ b/ui/packages/fields/src/widgets/preferenceOptions.ts @@ -0,0 +1,82 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The list the `notification-topic` / `notification-channel` faces offer: the + * messaging service's preference options door, + * `GET /api/v1/messaging/preference-options` (service-messaging + * `preference-options-routes.ts`). It answers `{ topics, channels }`, each a + * list of `{ value, wildcard, … }` with the `'*'` entry first; a channel also + * says whether the deployment can send on it (`configured`). + * + * The read rides the host's AUTHENTICATED fetch, `SchemaRendererContext.apiFetch`, + * and only that: a surface whose host supplies none offers no list, and the + * face is the plain text box. One request per fetch function for the page + * session, shared by every field that draws from it (a preference form holds a + * topic and a channel); a failed read is not kept, so the next field that + * mounts asks again. + * + * The list is a SUGGESTION, never a closed set: the server stores any value an + * administrator types. So an answer that cannot be read as the door's shape is + * a failure like any other, and the face falls back to the plain text box. + */ + +import type { ApiFetch } from '@object-ui/react'; +import type { RegistryItemHeader } from './registryCatalog.js'; + +/** The door the list is read from. */ +export const PREFERENCE_OPTIONS_URL = '/api/v1/messaging/preference-options'; + +/** Which of the door's two lists a field offers. */ +export type PreferenceList = 'topics' | 'channels'; + +/** One listed value: the value stored, and what the door says about it. */ +export interface PreferenceOptionItem extends RegistryItemHeader { + /** `true` on the `'*'` entry alone. */ + wildcard: boolean; + /** `false` on a channel the deployment cannot send on right now; `true` otherwise. */ + configured: boolean; +} + +export type PreferenceOptions = Record; + +function listOf(raw: unknown, list: PreferenceList): PreferenceOptionItem[] { + if (!Array.isArray(raw)) throw new Error(`GET ${PREFERENCE_OPTIONS_URL} answered no ${list} list.`); + const items: PreferenceOptionItem[] = []; + for (const entry of raw) { + const value = (entry as { value?: unknown } | null)?.value; + if (typeof value !== 'string' || value === '') continue; + const { wildcard, configured } = entry as { wildcard?: unknown; configured?: unknown }; + items.push({ name: value, wildcard: wildcard === true, configured: configured !== false }); + } + return items; +} + +async function readPreferenceOptions(doFetch: ApiFetch): Promise { + const res = await doFetch(PREFERENCE_OPTIONS_URL, { + headers: { accept: 'application/json' }, + credentials: 'include', + }); + if (!res.ok) throw new Error(`GET ${PREFERENCE_OPTIONS_URL} answered ${res.status}.`); + const body = (await res.json()) as { data?: { topics?: unknown; channels?: unknown } } | null; + return { topics: listOf(body?.data?.topics, 'topics'), channels: listOf(body?.data?.channels, 'channels') }; +} + +const answers = new WeakMap>(); + +/** Read the door's lists through `doFetch`, sharing the request with every other caller. */ +export function loadPreferenceOptions(doFetch: ApiFetch): Promise { + const existing = answers.get(doFetch); + if (existing) return existing; + const request = readPreferenceOptions(doFetch).catch((err: unknown) => { + if (answers.get(doFetch) === request) answers.delete(doFetch); + throw err; + }); + answers.set(doFetch, request); + return request; +} diff --git a/ui/packages/fields/src/widgets/useFieldTranslation.ts b/ui/packages/fields/src/widgets/useFieldTranslation.ts index 77dbef89ca3..6700fd67710 100644 --- a/ui/packages/fields/src/widgets/useFieldTranslation.ts +++ b/ui/packages/fields/src/widgets/useFieldTranslation.ts @@ -103,6 +103,15 @@ const FIELD_DEFAULTS: Record = { // the `en` pack. 'fields.registryRef.loadFailed': 'The list could not be loaded. You can still type the name.', 'fields.registryRef.notFound': '{{name}} (not found)', + // #30208 row 6 — the fillable arm (`notification-topic` / + // `notification-channel`): the `*` entry's two faces, a channel the + // deployment cannot send on, a value the list does not hold, and the row + // that commits the typed text as written. Same values as the `en` pack. + 'fields.preferenceOption.allTopics': 'All topics ({{value}})', + 'fields.preferenceOption.allChannels': 'All channels ({{value}})', + 'fields.preferenceOption.notConfigured': '{{value}} (not configured)', + 'fields.preferenceOption.notInList': '{{value}} (not in the list)', + 'fields.preferenceOption.useTyped': 'Use "{{value}}"', 'fields.recipient.selectTypeFirst': 'Select a recipient type first.', 'fields.recipient.loading': 'Loading…', 'fields.recipient.search': 'Search…', diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index fd7f449d6d9..fb85e90229f 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -401,7 +401,11 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 108 once objectstack-ai/objectstack#30894 added // `connectAgent.access.revokeTitle`, the confirm that names the API key // being revoked („{{name}}“) — one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(108); + // 109 once objectstack-ai/objectstack#31062 added + // `fields.preferenceOption.useTyped`, the picker row that commits the typed + // topic or channel („{{value}}“ verwenden) — one interpolated span, runtime + // data. + expect(okSpans, 'correctly paired spans').toBe(109); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -490,8 +494,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 107 / 107 / 0 after objectstack-ai/objectstack#30206 added // `console.firstRunChecklist.organizationName.todo` and `.timezone.todo`, // one matched pair each. 108 / 108 / 0 after objectstack-ai/objectstack#30894 - // added `connectAgent.access.revokeTitle`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 108, close: 108, rdq: 0 }); + // added `connectAgent.access.revokeTitle`, one more matched pair. 109 / 109 / 0 + // after objectstack-ai/objectstack#31062 added + // `fields.preferenceOption.useTyped`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 109, close: 109, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 4c58d3677ac..6e9ccb32f88 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -348,6 +348,13 @@ const ar = { loadFailed: "تعذّر تحميل القائمة. لا يزال بإمكانك كتابة الاسم.", notFound: "{{name}} (غير موجود)", }, + preferenceOption: { + allTopics: "كل الموضوعات ({{value}})", + allChannels: "كل القنوات ({{value}})", + notConfigured: "{{value}} (غير مُهيّأة)", + notInList: "{{value}} (ليست في القائمة)", + useTyped: "استخدام «{{value}}»", + }, recipient: { selectTypeFirst: "اختر نوع المستلم أولاً.", loading: "جارٍ التحميل…", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index f526718e84c..89432a18c22 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -330,6 +330,13 @@ const de = { loadFailed: "Die Liste konnte nicht geladen werden. Sie können den Namen weiterhin eingeben.", notFound: "{{name}} (nicht gefunden)", }, + preferenceOption: { + allTopics: "Alle Themen ({{value}})", + allChannels: "Alle Kanäle ({{value}})", + notConfigured: "{{value}} (nicht konfiguriert)", + notInList: "{{value}} (nicht in der Liste)", + useTyped: "„{{value}}“ verwenden", + }, recipient: { selectTypeFirst: "Wählen Sie zuerst einen Empfängertyp.", loading: "Wird geladen…", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 8fcb77d67e3..000460106fb 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -475,6 +475,13 @@ const en = { loadFailed: 'The list could not be loaded. You can still type the name.', notFound: '{{name}} (not found)', }, + preferenceOption: { + allTopics: 'All topics ({{value}})', + allChannels: 'All channels ({{value}})', + notConfigured: '{{value}} (not configured)', + notInList: '{{value}} (not in the list)', + useTyped: 'Use "{{value}}"', + }, recipient: { selectTypeFirst: 'Select a recipient type first.', loading: 'Loading…', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 523addeced4..af748c93ac0 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -338,6 +338,13 @@ const es = { loadFailed: "No se pudo cargar la lista. Todavía puede escribir el nombre.", notFound: "{{name}} (no encontrado)", }, + preferenceOption: { + allTopics: "Todos los temas ({{value}})", + allChannels: "Todos los canales ({{value}})", + notConfigured: "{{value}} (no configurado)", + notInList: "{{value}} (no está en la lista)", + useTyped: "Usar «{{value}}»", + }, recipient: { selectTypeFirst: "Selecciona primero un tipo de destinatario.", loading: "Cargando…", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 04ce6791f6a..9300facc9ce 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -333,6 +333,13 @@ const fr = { loadFailed: "La liste n'a pas pu être chargée. Vous pouvez toujours saisir le nom.", notFound: "{{name}} (introuvable)", }, + preferenceOption: { + allTopics: "Tous les sujets ({{value}})", + allChannels: "Tous les canaux ({{value}})", + notConfigured: "{{value}} (non configuré)", + notInList: "{{value}} (absent de la liste)", + useTyped: "Utiliser « {{value}} »", + }, recipient: { selectTypeFirst: "Sélectionnez d'abord un type de destinataire.", loading: "Chargement…", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index c3edb8f7ec2..5889d9d6d5a 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -330,6 +330,13 @@ const ja = { loadFailed: "一覧を読み込めませんでした。名前を直接入力できます。", notFound: "{{name}}(見つかりません)", }, + preferenceOption: { + allTopics: "すべてのトピック({{value}})", + allChannels: "すべてのチャネル({{value}})", + notConfigured: "{{value}}(未設定)", + notInList: "{{value}}(一覧にありません)", + useTyped: "「{{value}}」を使用", + }, recipient: { selectTypeFirst: "先に受信者タイプを選択してください。", loading: "読み込み中…", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 164ac5b79bc..336fc7c39aa 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -330,6 +330,13 @@ const ko = { loadFailed: "목록을 불러오지 못했습니다. 이름을 직접 입력할 수 있습니다.", notFound: "{{name}} (찾을 수 없음)", }, + preferenceOption: { + allTopics: "모든 주제 ({{value}})", + allChannels: "모든 채널 ({{value}})", + notConfigured: "{{value}} (구성되지 않음)", + notInList: "{{value}} (목록에 없음)", + useTyped: '"{{value}}" 사용', + }, recipient: { selectTypeFirst: "먼저 수신자 유형을 선택하세요.", loading: "로딩 중…", diff --git a/ui/packages/i18n/src/locales/pt-BR.ts b/ui/packages/i18n/src/locales/pt-BR.ts index 511fe2aa790..bd4de7d50c6 100644 --- a/ui/packages/i18n/src/locales/pt-BR.ts +++ b/ui/packages/i18n/src/locales/pt-BR.ts @@ -333,6 +333,13 @@ const pt = { loadFailed: "Não foi possível carregar a lista. Você ainda pode digitar o nome.", notFound: "{{name}} (não encontrado)", }, + preferenceOption: { + allTopics: "Todos os tópicos ({{value}})", + allChannels: "Todos os canais ({{value}})", + notConfigured: "{{value}} (não configurado)", + notInList: "{{value}} (fora da lista)", + useTyped: 'Usar "{{value}}"', + }, recipient: { selectTypeFirst: "Selecione primeiro um tipo de destinatário.", loading: "Carregando…", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 0ea6b5b1a43..7845e7893f0 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -339,6 +339,13 @@ const ru = { loadFailed: "Не удалось загрузить список. Имя можно ввести вручную.", notFound: "{{name}} (не найдено)", }, + preferenceOption: { + allTopics: "Все темы ({{value}})", + allChannels: "Все каналы ({{value}})", + notConfigured: "{{value}} (не настроен)", + notInList: "{{value}} (нет в списке)", + useTyped: "Использовать «{{value}}»", + }, recipient: { selectTypeFirst: "Сначала выберите тип получателя.", loading: "Загрузка…", diff --git a/ui/packages/i18n/src/locales/zh-Hans.ts b/ui/packages/i18n/src/locales/zh-Hans.ts index 46c98df1f31..ca12f89588f 100644 --- a/ui/packages/i18n/src/locales/zh-Hans.ts +++ b/ui/packages/i18n/src/locales/zh-Hans.ts @@ -340,6 +340,13 @@ const zh = { loadFailed: '列表加载失败。仍可直接输入名称。', notFound: '{{name}}(未找到)', }, + preferenceOption: { + allTopics: '全部主题({{value}})', + allChannels: '全部渠道({{value}})', + notConfigured: '{{value}}(未配置)', + notInList: '{{value}}(不在列表中)', + useTyped: '使用“{{value}}”', + }, recipient: { selectTypeFirst: '请先选择接收方类型。', loading: '加载中…', diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index 31402dba157..9b68bca0015 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -340,6 +340,13 @@ const zhHant = { loadFailed: '列表載入失敗。仍可直接輸入名稱。', notFound: '{{name}}(未找到)', }, + preferenceOption: { + allTopics: '全部主題({{value}})', + allChannels: '全部渠道({{value}})', + notConfigured: '{{value}}(未配置)', + notInList: '{{value}}(不在列表中)', + useTyped: '使用“{{value}}”', + }, recipient: { selectTypeFirst: '請先選擇接收方型別。', loading: '載入中…', diff --git a/ui/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx b/ui/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx index 5bb53212feb..a7a7644fb63 100644 --- a/ui/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx @@ -231,9 +231,11 @@ describe('inline-edit type coverage — every type has exactly one decision (#42 expect(byBucket).toEqual({ excluded: [ 'auto_number', 'autonumber', 'composite', 'filter-condition', 'formula', - 'grid', 'html', 'object', 'object-ref', 'password', + 'grid', 'html', 'notification-channel', 'notification-topic', + 'object', 'object-ref', 'password', // objectui#7611: the registry-name pickers, widget hints like - // `object-ref`. + // `object-ref` — and their fillable arm's two keys above + // (objectstack#31062). 'permission-ref', 'position-ref', 'recipient-picker', 'record', 'repeater', 'richtext', 'secret', 'summary', 'vector',