Skip to content

Commit 3f61e4f

Browse files
feat(fields): Setup's notification topic and channel fields pick from the served list and still take a typed value (#31069)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoC9MDip31tiDLU5QajkcAgR)_ Fixes #31062 Part of #30208 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 ## 维护者速读 - 改了什么:Setup 的通知偏好(主题、渠道)和通知模板(主题)不再是纯文本框,改成下拉选择器,列表来自服务端 `preference-options` 接口,`*` 排第一;仍可直接输入列表外的值。 - 为什么改:管理员手敲主题/渠道容易打错(如 `invoices.paid`、`emial`),通知就会静默不发或发错渠道;按裁决 E(#30527)给出可选列表并保留自由输入。 - 风险与回滚:只影响声明了 `notification-topic` / `notification-channel` 的三个字段;接口失败或返回空列表时退回原来的文本框;回滚即 revert 本 PR。 - 先看:`ui/packages/fields/src/widgets/RegistryRefField.tsx`(填充分支 `PreferenceOptionField`)、`ui/packages/fields/src/widgets/preferenceOptions.ts`(读取与共享请求)、`ui/packages/fields/src/index.tsx`(注册)。 - 两分钟验证:打开 Setup → Notification Preferences → New,点 Topic,应看到 "All topics (*)" 排第一的列表;在搜索框输入 `invoices.paid` 点 "Use …",字段显示 "invoices.paid (not in the list)";Channel 列表里 push 显示 "(not configured)" 且可选。 - 需要你做的:无,席位落地。 - **席位意见**:ui 席 2 对照 head `43dd3c69` 的 diff 复核:24 个文件、+776 / −28,无受治理路径。设置里通知偏好与模板的「主题」「渠道」字段改为可填写的下拉:列表来自 `GET /api/v1/messaging/preference-options`(经宿主已认证的 fetch,整页共用一次请求),`*` 排在最前;手输的值原样保存,不在列表中的值保留并标注;未配置的渠道照列、标注、可选;请求失败或列表为空时回到原来的文本框。新增五条文案进所有语言包。Clause-② 改为 yes (widening),席位 TIER_DEFAULT 复核 PASS(6700168365)。CI 全绿,同意落地。 ## What changes `sys_notification_preference.topic` / `.channel` and `sys_notification_template.topic` declare `widget: 'notification-topic'` / `'notification-channel'` since #31004; nothing registered them, so they rendered as text boxes. This PR registers both as `field:WIDGET` keys in `@object-ui/fields`, so the record form picks them up with no per-page code. **Home: option (a), a fillable arm on `RegistryRefField`.** It registers through `fieldWidgetMap`, the field widget registry the record form already consults for `FieldSchema.widget`, beside `position-ref` / `permission-ref`; option (b) would live in `app-shell` beside `FlowReferenceField`, which only the flow inspector reads, and would need its own route into the record form. The arm reuses the registry picker's four-state catalog machinery (IDLE / LOADING / FAILED / LOADED, generalised from `useRegistryCatalog` to `useCatalog(reader, origin, list)` with one module-level reader per arm), and differs from the registry arm only where an open list must: - **The list** comes from `GET /api/v1/messaging/preference-options` through the host's authenticated fetch (`SchemaRendererContext.apiFetch`, the console's existing `createAuthenticatedFetch` path; no new fetch helper). One request per fetch function for the page, shared by the topic and the channel field; a failed read is not kept. Listed in the server's order, `*` first, shown as "All topics (*)" / "All channels (*)". - **No list** (no host fetch, a failed or malformed answer, or an empty list): the plain text box, with no notice. The registry arm's failure banner is not shown here. - **A typed value**: the picker's search box offers a "Use "TEXT"" row that commits the text exactly as typed. A stored value the list lacks stays its own option, marked "TEXT (not in the list)" (`data-not-in-list`), on the edit and the read face. Nothing is refused or rewritten. - **A channel the server marks not configured** is listed as "push (not configured)" (`data-not-configured`) and is selectable. - **Strings**: five `fields.preferenceOption.*` keys in every console pack; `zh-Hant` regenerated from `zh-Hans` with `gen:zh-hant`. - **Registration ripple, the `position-ref` (objectui#7611) ripple**: `FIELD_WIDGET_LABELLING`, the bare-name skip list, `INLINE_EXCLUDED_FIELD_TYPES` and the two fields widget sweeps; under the seat's claim amendments 6699576193 and 6699989026, two `widgetHintOnly` rows in `app-shell` `paramValueShape.ts`, the two names in `plugin-detail`'s `inlineEditTypeCoverage` excluded bucket, and `ui/packages/cli/src/utils/known-schema-types.ts` regenerated by `node ui/scripts/regenerate-known-schema-types.mjs` (two lines, no hand edit). The `de` quote census moves 108 → 109 for the new `„{{value}}“ verwenden` row. Out of scope, unchanged: the template's `channel` (`channel_renders_template`, no widget, still a text box), quiet hours, row 5 of #30208, any server change. ## Tests `ui/packages/fields/src/widgets/__tests__/RegistryRefField.preferenceOptions-31062.test.tsx`, 14 cases: - the list renders from the route (`*` first, the door's order, one shared read for two fields), and both keys resolve through `field:WIDGET` in the registry; - the fallback to the text box when the route answers 500, when the fetch rejects, when the list is empty, and when no host fetch exists (nothing read); - a value outside the list is committed as typed and shown marked, on the edit and the read face; - a "not configured" channel is shown labelled and is selectable. **Ablation** (`scripts/ablation-replace.mjs`, src import, no build in the path): deleting the `'notification-topic'` loader from `fieldWidgetMap` turns "`notification-topic` is registered and lists the door" red (`expected undefined to be defined`, 1 failed / 12 passed); restore verified blob == HEAD. ## Acceptance notes - Clause-② is copied from the claim (`no`). The PR adds five keys to every published `@object-ui/i18n` pack and two registered widget keys; some recent i18n-key PRs declared that `yes (widening)` and some `no`, so the seat may want to amend. - Open PR #31037 also edits all ten hand-written `ui/packages/i18n/src/locales/*.ts` packs, in a different block; whichever lands second takes a textual merge. - `pnpm check:eager-closure` needs a Console build and is CI's: the arm adds code to `@object-ui/fields`, which rides the eager `ui-components` chunk. ## Verification Readings name their tree. Gates and type-check at `da4d45a2`; the last commit `1fc7c752` adds only the regenerated `known-schema-types.ts`, and the ui `scripts/` suite was re-run on it. Re-deriving at `1fc7c752` gives the same 38 families. <details><summary>Live before, on main 23cf11a (showcase backend --fresh, Console dev server, admin sign-in)</summary> - Setup → Notification Preferences → New: Topic and Channel are `INPUT type=text`, prefilled `*`. Notification Templates → New: Topic and Channel are `INPUT type=text`. No request to the options route. - `GET /api/v1/messaging/preference-options` (signed in): `200`, 28 topics (`*` first, then `approval.*`, `auth.*`, `collab.mention`, the showcase flow topics such as `invoice.signoff`, `project.digest`, `task.assigned`, and `notify`), 5 channels: `*`, `inbox`, `email`, `sms`, and `push` with `configured: false`. Anonymous: `401`. </details> <details><summary>Live after, on this branch (same backend)</summary> - Notification Preferences → New: Topic trigger reads "All topics (*)", its list has 28 options with "All topics (*)" first; Channel lists `All channels (*)`, `inbox`, `email`, `sms`, `push (not configured)`. - Typed `invoices.paid` in the topic search and picked "Use …": the trigger reads "invoices.paid (not in the list)" with `data-not-in-list=true`. Picked `push (not configured)`. Save, then `GET /api/v1/data/sys_notification_preference` reads back `topic: invoices.paid`, `channel: push`. - Notification Templates → New: Topic is the picker; Channel stays `INPUT type=text`. - The options route answered `200` once per page load. </details> <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run packages/fields/src/widgets/__tests__/RegistryRefField.preferenceOptions-31062.test.tsx`: 14 passed. - `packages/fields/` whole, `packages/i18n/`, `packages/components/src/renderers/form/`, the plugin-form `position-ref` form test, the app-shell form-type guards and the cli ratchet: 8 reds before the ripple rows (all named above), then green on the rerun of every red file (361 + 367 + 48 tests). - `pnpm --filter @object-ui/fields --filter @object-ui/i18n --filter @object-ui/app-shell run type-check` after a dts build of their closure: exit 0. </details> <details><summary>Derived gates (dispatch-gates --commands, 38 families; every line exit 0)</summary> Reconciled with `dispatch-gates.mjs --ran`: 38 derived, 38 run, 0 NOT-MEASURED. Record: `/mnt/project-files/ui-seat-2/r2026-11-06/31062/gates-ran-1fc7c752.list`. Includes `node scripts/check-sdui-manifest.mjs` (exit 0), `pnpm check:nul-bytes`, `pnpm check:cross-package-test-inputs`, `pnpm check:type-check-coverage`, `pnpm check:type-check-debt`, `pnpm check:undeclared-dep-imports`, the changeset gates, and `pnpm --dir ui exec vitest run scripts/` (170 passed, 2 skipped). </details> <!-- signed: domain:ui#2 · os-tesla · session_01L9qibw7DekEe3wx5f3zphf --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a539ba8 commit 3f61e4f

24 files changed

Lines changed: 776 additions & 28 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(fields): Setup's notification topic and channel fields offer the served list and still take any typed value
7+
8+
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.
9+
Metadata-protocol: none
10+
11+
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`).
12+
13+
- **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 (*)".
14+
- **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.
15+
- **A channel that is not configured** is listed, labelled "(not configured)", and can be picked.
16+
- **No list.** When the request fails, or answers an empty list, the field is the plain text box it was before, with no notice.
17+
- **Strings.** Five new `fields.preferenceOption.*` keys in every built-in language pack (`zh-Hant` regenerated from `zh-Hans`).
18+
19+
The template's `channel` field declares no widget and stays a text box.

‎ui/packages/app-shell/src/utils/paramValueShape.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ export const PARAM_VALUE_SHAPES: Readonly<Record<string, ParamValueShapeSpec>> =
179179
'recipient-picker': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Recipient id string. Widget-hint only — not a declarable param type.' },
180180
'position-ref': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Position name string. Widget-hint only — not a declarable param type.' },
181181
'permission-ref': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Permission-set name string. Widget-hint only — not a declarable param type.' },
182+
'notification-topic': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Notification topic string. Widget-hint only — not a declarable param type.' },
183+
'notification-channel': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Notification channel string. Widget-hint only — not a declarable param type.' },
182184
});
183185

184186
/** Concrete `base[]` tag for a base shape. */

‎ui/packages/cli/src/utils/known-schema-types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
146146
'field:markdown',
147147
'field:master_detail',
148148
'field:multiselect',
149+
'field:notification-channel',
150+
'field:notification-topic',
149151
'field:number',
150152
'field:object',
151153
'field:object-ref',

‎ui/packages/fields/src/FieldEditWidget.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set<string>([
190190
// sibling fields / a loaded object catalog), not inline in a grid cell.
191191
'object-ref', 'filter-condition', 'recipient-picker',
192192
'position-ref', 'permission-ref',
193+
'notification-topic', 'notification-channel',
193194
]);
194195

195196
/** Field types whose value is chosen in one discrete gesture (no free typing). */

‎ui/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,12 @@ import { QRCodeField } from '../widgets/QRCodeField';
134134
import { ObjectRefField } from '../widgets/ObjectRefField';
135135
import { FilterConditionField } from '../widgets/FilterConditionField';
136136
import { RecipientPickerField } from '../widgets/RecipientPickerField';
137-
import { PositionRefField, PermissionRefField } from '../widgets/RegistryRefField';
137+
import {
138+
PositionRefField,
139+
PermissionRefField,
140+
NotificationTopicField,
141+
NotificationChannelField,
142+
} from '../widgets/RegistryRefField';
138143

139144
/** Same widget map as the #3291 sweep, so the parity check covers the registry. */
140145
const WIDGETS: Record<string, ComponentType<any>> = {
@@ -188,6 +193,8 @@ const WIDGETS: Record<string, ComponentType<any>> = {
188193
'recipient-picker': RecipientPickerField,
189194
'position-ref': PositionRefField,
190195
'permission-ref': PermissionRefField,
196+
'notification-topic': NotificationTopicField,
197+
'notification-channel': NotificationChannelField,
191198
};
192199

193200
/**

‎ui/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,12 @@ import { QRCodeField } from '../widgets/QRCodeField';
144144
import { ObjectRefField } from '../widgets/ObjectRefField';
145145
import { FilterConditionField } from '../widgets/FilterConditionField';
146146
import { RecipientPickerField } from '../widgets/RecipientPickerField';
147-
import { PositionRefField, PermissionRefField } from '../widgets/RegistryRefField';
147+
import {
148+
PositionRefField,
149+
PermissionRefField,
150+
NotificationTopicField,
151+
NotificationChannelField,
152+
} from '../widgets/RegistryRefField';
148153

149154
/* ════════════════════════════════════════════════════════════════════════════
150155
* Every registered field widget, both hosts
@@ -207,6 +212,8 @@ const WIDGETS: Record<string, ComponentType<any>> = {
207212
'recipient-picker': RecipientPickerField,
208213
'position-ref': PositionRefField,
209214
'permission-ref': PermissionRefField,
215+
'notification-topic': NotificationTopicField,
216+
'notification-channel': NotificationChannelField,
210217
};
211218

212219
/** Option widgets render an "unfillable" placeholder unless offered a list. */

‎ui/packages/fields/src/index.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4890,6 +4890,14 @@ const fieldWidgetMap = {
48904890
// permission-ref → a permission set (sys_user_permission_set.permission_set)
48914891
'position-ref': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.PositionRefField })),
48924892
'permission-ref': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.PermissionRefField })),
4893+
// The fillable arm of the same widget (#30208 row 6, ruling E on #30527): a
4894+
// notification preference's `topic` / `channel` and a template's `topic`,
4895+
// offered from `GET /api/v1/messaging/preference-options` and still taking
4896+
// any typed value, which the server stores as written.
4897+
// notification-topic → a notification topic, `*` for all
4898+
// notification-channel → a notification channel, `*` for all
4899+
'notification-topic': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.NotificationTopicField })),
4900+
'notification-channel': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.NotificationChannelField })),
48934901
} satisfies Record<string, FieldWidgetLoader>;
48944902

48954903
/** The registered field widget keys, as a literal union (objectui#4857). */
@@ -5072,6 +5080,8 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
50725080
'recipient-picker',
50735081
'position-ref',
50745082
'permission-ref',
5083+
'notification-topic',
5084+
'notification-channel',
50755085
// ── Retired bare node keys (objectui#10859 batch 8) ───────────────────────
50765086
// Each was registered bare only as this loop's fallback: no other package
50775087
// 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<
52205230
'recipient-picker': 'control',
52215231
'position-ref': 'control',
52225232
'permission-ref': 'control',
5233+
'notification-topic': 'control',
5234+
'notification-channel': 'control',
52235235
};
52245236

52255237
export function registerField(fieldType: string): void {

0 commit comments

Comments
 (0)