Skip to content

Commit f67f3b3

Browse files
feat(fields): draw a notification preference's quiet hours as a time-zone picker and two times (#31459)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbEW6x36o2FahkNd4b3mGXdE)_ Fixes #31421 Clause-②: yes Metadata-protocol: none ## 维护者速读 - **改了什么**:通知偏好的「免打扰」有了专用控件:时区选择 + 开始/结束时间,可跨午夜。写入的仍是 `{ tz?, start, end }`,存储格式不变;读不懂的旧值原样显示并附说明,不会被悄悄改写。 - **为什么改**:现在要手写 `{ "tz": "Europe/Berlin", "start": "22:00", "end": "07:00" }` 这样的 JSON,管理员和用户都容易写错。 - **风险与回滚**:只新增一个控件名 `quiet-hours` 和十个语言包里的十条文案;没有字段声明这个控件之前,界面完全不变。回滚即 revert 本 PR。 - **先看**:`ui/packages/fields/src/widgets/QuietHoursField.tsx` 的 `commit`(何时写、何时不写),`ui/packages/fields/src/widgets/quietHours.ts` 的 `readQuietHours`(什么算读得懂),`ui/packages/fields/src/index.tsx` 的三处登记。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/fields/src/widgets/__tests__/QuietHoursField-31421.test.tsx`,预期 35 个测试全过。 - **需要你做的**:无,席位落地。控件要等 #31426(给字段加 `widget: 'quiet-hours'`)合入后才会在 Setup 出现;两个 PR 先后顺序不限。 ## What changes A field that declares `widget: 'quiet-hours'` is drawn as a time-zone picker plus a start and an end time instead of the JSON box. The stored shape is the one the messaging service reads: `{ tz?, start: 'HH:MM', end: 'HH:MM' }`, an absent `tz` meaning UTC, a window whose start is later than its end running past midnight. **What turns it on in Setup:** #31426 (server lane) adds `widget: 'quiet-hours'` to `sys_notification_preference.quiet_hours`. The two land in either order: until a field declares the hint, the form resolves `json` to the JSON editor as before, and an unregistered hint degrades to that same editor. | File | Change | |---|---| | `ui/packages/fields/src/widgets/quietHours.ts` | New. `readQuietHours` (empty / window / unreadable), the `HH:MM` test the service applies, and the zone list: UTC first, then `Intl.supportedValuesOf('timeZone')` filtered by the spec's `iana_time_zone` membership test, the `currency-code-picker` precedent. | | `ui/packages/fields/src/widgets/QuietHoursField.tsx` | New. The control and its readonly face. | | `ui/packages/fields/src/index.tsx` | `quiet-hours` in `fieldWidgetMap`, `FIELD_TYPES_SKIP_FALLBACK` and `FIELD_WIDGET_LABELLING` (`'group'`: three controls under one label). | | `ui/packages/fields/src/FieldEditWidget.tsx` | `quiet-hours` in `INLINE_EXCLUDED_FIELD_TYPES`: edited in the record form, not in a grid cell. | | `ui/packages/fields/src/widgets/useFieldTranslation.ts` | The ten new strings in the provider-less fallback table, same values as `en`. | | `ui/packages/i18n/src/locales/*.ts` | Ten `fields.quietHours.*` keys in the ten hand-written packs; `zh-Hant` regenerated from `zh-Hans` (`gen:zh-hant`). | | `ui/packages/app-shell/src/utils/paramValueShape.ts` | One `widgetHintOnly` row (`base: 'object'`), which its test requires for every registered widget. | | `ui/packages/cli/src/utils/known-schema-types.ts` | Regenerated (`node scripts/regenerate-known-schema-types.mjs`): gains `field:quiet-hours`. | | `ui/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx` | The excluded-bucket pin gains `quiet-hours`. | | `.changeset/31421-quiet-hours-control.md` | `@object-ui/fields`, `@object-ui/i18n`, `@object-ui/cli` patch. | Behaviour: - **Writes** exactly `{ tz?, start, end }` once both times are set, `null` once both times of a stored window are cleared, and nothing for a zone chosen alone. One time without the other writes nothing and marks the missing time invalid through constraint validation (the `ObjectField` channel), so the form does not submit a half window. - **Reads** a stored window back with each part kept verbatim: a part nobody edits is written back as stored (a one-digit stored hour stays one digit). `tz` stays absent until a zone is chosen. - **Unlisted zone:** a stored `tz` the list does not hold stays shown, marked "(not in the list)", and is written back as stored. - **Unreadable value** (any other shape: an extra key, a missing time, a time the service would not read, a `tz` that is not a non-empty string, a string or an array): shown as stored with a note, never rewritten; a "Set a time range" button starts a new window, written only once both times are set. - **Notes:** "Runs past midnight and ends the next day." when start is later than end; "Start and end are the same, so nothing is held." when they are equal, because the service's `quietHoursDeferral` treats that as no window. ## Tests `ui/packages/fields/src/widgets/__tests__/QuietHoursField-31421.test.tsx` (35 tests). The card's three pins: - **An empty value writes nothing:** an empty control calls `onChange` for nothing; a zone alone writes nothing; a half window writes nothing and the missing time carries `aria-invalid` and the constraint message; clearing both times of a stored window writes `null`. - **A 22:00 to 07:00 window round-trips:** filling zone, start and end writes `{ tz: 'Europe/Berlin', start: '22:00', end: '07:00' }` (strict equality, key for key); the stored window reads back into the three controls and an edit that restores it writes the identical object. - **A stored value without tz reads as UTC:** the picker shows UTC, an edit keeps `tz` absent, the readonly face says `(UTC)`. Plus the read table (15 shapes), the zone list, the unlisted zone, the same-minute window, the unreadable value (shown, not written, replaced only on request), and the record form path: the `form` renderer draws a `json` field whose metadata declares `widget: 'quiet-hours'` with this control as a named `group`, and the same field without the hint stays the JSON box. <details><summary>Test runs (all under the shared verify lock, from ui/)</summary> | Run | Head | Result | |---|---|---| | `vitest run packages/fields/ packages/i18n/ packages/cli/` + the five named pins (plugin-detail inline-edit coverage, app-shell `paramValueShape` / `paramToField` / `degradeSetTwin-5880` / `widget-dom-leak-sweep`) + `scripts/__tests__/known-schema-types-derivation-5115.test.ts` + `scripts/__tests__/check-doc-component-types.test.ts` + `examples/schema-catalog/test/` | `3e7c3da7` | 483 files, 9687 passed, 3 failed: the two registry tables (`widget-aria-invalid-registry-e2e`, `widget-dom-leak-e2e`) and `group-labelling-declaration` did not yet list the widget. Fixed in `e18cf4c7`; the four files then 387/387. | | `vitest run packages/i18n/` + the new test + the three fixed pins + `paramValueShape.test.ts` + `inlineEditTypeCoverage.test.tsx` + `scripts/` | `1db93f4e` (after merging `main` at `9728ab15`, closure rebuilt) | 277 files, 7314 passed, 15 skipped, 0 failed | | `type-check` of `@object-ui/fields`, `i18n`, `cli`, `app-shell`, `plugin-detail` | `1db93f4e` working tree | all five Done | | `eslint --no-inline-config` on the touched source and test files | `1db93f4e` | 0 errors; one warning in the new widget, `react-hooks/set-state-in-effect` on the external-value adoption, the `ObjectField` pattern and its inline disable | </details> ## Acceptance notes - **Docs.** `ui/AGENTS.md` #2 asks for the package README and a `content/docs/` page. Neither documents the sibling `notification-topic` / `notification-channel` hints either, and both paths sit outside this card's claimed surface; the seat has the call on whether that page lands with #31426, when the control goes live. - **The grid cell** keeps the JSON face (`JsonCellRenderer`): `WIDGET_CELL_RENDERER_TYPES` promotes only over a textual base type, and the card scopes the record form. - **The zone list is ICU's, not the Localization manifest's 17.** That curated list is served by the settings manifest to the console only and is labelled a convenience list, not the domain (seat ruling, claim amendment 6718720700). - **`Clause-②: yes`** is copied from the claim. Measured: ten new `@object-ui/i18n` locale keys and a new `RegisteredFieldWidgetType` member widen the published surface and narrow nothing, so the arm would read `(widening)`; the claim's line is kept as written. ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 39 families for this diff; all 39 ran at `1db93f4e` with exit codes captured before any pipe, and `--ran` answers: 39 derived families accounted for, 39 run, 0 NOT-MEASURED (a derived zero). Model tier: no path-derived mandate. <details><summary>Derived gates at 1db93f4 (39/39 exit 0)</summary> | Command | Exit | |---|---| | `node scripts/check-adr-0087-registration.mjs --base origin/main` | 0 | | `node scripts/check-adr-0087-registration.mjs --self-test` | 0 | | `node scripts/check-changeset-no-major.mjs --base origin/main` | 0 | | `node scripts/check-closing-keyword-parity.mjs` | 0 | | `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | | `node scripts/check-comment-mask-corpus.mjs` | 0 | | `node scripts/check-empty-changeset.mjs --base origin/main` | 0 | | `node scripts/check-empty-changeset.mjs --self-test` | 0 | | `node scripts/check-sdui-manifest.mjs` | 0 | | `node scripts/check-sdui-manifest.mjs --self-test` | 0 | | `node scripts/ci/snapshot-next.mjs --self-test` | 0 | | `node scripts/pm/release-rehearsal-clone.mjs --self-test` | 0 | | `node scripts/release-pending-publish.mjs --self-test` | 0 | | `pnpm --filter @objectstack/spec run check:duration-unit-keys` | 0 | | `pnpm --filter @objectstack/spec run check:error-code-provenance` | 0 | | `pnpm check:changeset-gate-self-tests` | 0 | | `pnpm check:closed-ceremony-text` | 0 | | `pnpm check:cross-package-test-inputs` | 0 | | `pnpm check:dispatcher-error-vocabulary` | 0 | | `pnpm check:driver-memory-census` | 0 | | `pnpm check:engine-double-contract` | 0 | | `pnpm check:gitlink-declared` | 0 | | `pnpm check:merge-driver` | 0 | | `pnpm check:nul-bytes` | 0 | | `pnpm check:published-files` | 0 | | `pnpm check:query-options-erasure` | 0 | | `pnpm check:refd-timer-probe` | 0 | | `pnpm check:registry-log-declared` | 0 | | `pnpm check:rest-log-spy-declared` | 0 | | `pnpm check:role-word` | 0 | | `pnpm check:skill-identifier-liveness` | 0 | | `pnpm check:test-source-alias` | 0 | | `pnpm check:tier-file-adoption` | 0 | | `pnpm check:type-check-coverage` | 0 | | `pnpm check:undeclared-dep-imports` | 0 | | `pnpm check:watch-hint-literal` | 0 | | `pnpm check:where-matcher` | 0 | | `pnpm --dir ui exec vitest run scripts/` | 0 (inside the locked final run above) | | `pnpm check:type-check-debt` | 0 (under the verify lock) | </details> - `node scripts/check-sdui-manifest.mjs`: present and intact. The manifest is the console's public-tier registry and holds no `field:` key (0 today), so registering a field widget does not move it; CI's Build & E2E UI regenerates and compares the bytes. - `pnpm check:new-line-citations` (from `ui/`): 0 new citations. - CI families (Type Check UI, Test UI, Build & E2E UI and the rest) are read from this PR's check runs, not re-run here. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files: 0 errors; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jTaiww8wGfNrqAW3vs --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTaiww8wGfNrqAW3vs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d1ef56 commit f67f3b3

24 files changed

Lines changed: 861 additions & 1 deletion
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+
'@object-ui/cli': patch
5+
---
6+
7+
feat(fields): a notification preference's quiet hours are drawn as a time-zone picker and two times, not a JSON box
8+
9+
Clause-②: yes
10+
Metadata-protocol: none
11+
12+
`@object-ui/fields` registers `field:quiet-hours` for a field that declares `widget: 'quiet-hours'`: a daily window when notifications are held, stored as `{ tz?, start: 'HH:MM', end: 'HH:MM' }`. The stored shape does not change. The control appears in Setup once `sys_notification_preference.quiet_hours` declares the hint; until then the field keeps the JSON editor.
13+
14+
- **Writes.** Exactly `{ tz?, start, end }` once both times are set, and `null` once both are cleared. One time without the other writes nothing and marks the missing time invalid, so the form does not submit. Choosing a zone alone writes nothing.
15+
- **Reads.** A stored window is read back into the control with each part kept as stored, so a part nobody edits is written back unchanged. A window without `tz` reads as UTC and stays without `tz` until a zone is chosen. A window whose start is later than its end runs past midnight, and the control says so; a window whose start and end are the same minute holds nothing, and the control says that too.
16+
- **Time zones.** UTC first, then every zone `Intl.supportedValuesOf('timeZone')` lists that the `iana_time_zone` value domain accepts. A stored zone outside the list stays on screen, marked "not in the list", and is written back as stored.
17+
- **Unreadable values.** A stored value of any other shape is shown as stored with a note, and is replaced only when the person chooses to set a new range and fills both times.
18+
- **Strings.** Ten new `fields.quietHours.*` keys in every built-in language pack (`zh-Hant` regenerated from `zh-Hans`).
19+
- `@object-ui/cli`'s known component types gain `field:quiet-hours`.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export const PARAM_VALUE_SHAPES: Readonly<Record<string, ParamValueShapeSpec>> =
182182
'permission-ref': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Permission-set name string. Widget-hint only — not a declarable param type.' },
183183
'notification-topic': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Notification topic string. Widget-hint only — not a declarable param type.' },
184184
'notification-channel': { base: 'string', cardinality: 'scalar', widgetHintOnly: true, note: 'Notification channel string. Widget-hint only — not a declarable param type.' },
185+
'quiet-hours': { base: 'object', cardinality: 'scalar', widgetHintOnly: true, note: 'Quiet-hours window { tz?, start, end } object (null when empty). Widget-hint only — not a declarable param type.' },
185186
});
186187

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
158158
'field:position-ref',
159159
'field:progress',
160160
'field:qrcode',
161+
'field:quiet-hours',
161162
'field:radio',
162163
'field:rating',
163164
'field:recipient-picker',

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +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', 'shareable-object-ref', 'filter-condition', 'recipient-picker',
192192
'position-ref', 'permission-ref',
193-
'notification-topic', 'notification-channel',
193+
'notification-topic', 'notification-channel', 'quiet-hours',
194194
]);
195195

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

‎ui/packages/fields/src/__tests__/group-labelling-declaration.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ const GROUP_LABELLED = [
7171
'slider',
7272
'signature',
7373
'grid',
74+
// A time-zone picker and two time inputs under one label (objectstack#31421).
75+
'quiet-hours',
7476
] as const;
7577

7678
/**

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ import {
141141
NotificationTopicField,
142142
NotificationChannelField,
143143
} from '../widgets/RegistryRefField';
144+
import { QuietHoursField } from '../widgets/QuietHoursField';
144145

145146
/** Same widget map as the #3291 sweep, so the parity check covers the registry. */
146147
const WIDGETS: Record<string, ComponentType<any>> = {
@@ -197,6 +198,7 @@ const WIDGETS: Record<string, ComponentType<any>> = {
197198
'permission-ref': PermissionRefField,
198199
'notification-topic': NotificationTopicField,
199200
'notification-channel': NotificationChannelField,
201+
'quiet-hours': QuietHoursField,
200202
};
201203

202204
/**

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ import {
151151
NotificationTopicField,
152152
NotificationChannelField,
153153
} from '../widgets/RegistryRefField';
154+
import { QuietHoursField } from '../widgets/QuietHoursField';
154155

155156
/* ════════════════════════════════════════════════════════════════════════════
156157
* Every registered field widget, both hosts
@@ -216,6 +217,7 @@ const WIDGETS: Record<string, ComponentType<any>> = {
216217
'permission-ref': PermissionRefField,
217218
'notification-topic': NotificationTopicField,
218219
'notification-channel': NotificationChannelField,
220+
'quiet-hours': QuietHoursField,
219221
};
220222

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

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4728,6 +4728,10 @@ const fieldWidgetMap = {
47284728
// notification-channel → a notification channel, `*` for all
47294729
'notification-topic': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.NotificationTopicField })),
47304730
'notification-channel': () => import('./widgets/RegistryRefField.js').then(m => ({ default: m.NotificationChannelField })),
4731+
// A notification preference's quiet hours: a daily window stored as
4732+
// `{ tz?, start: 'HH:MM', end: 'HH:MM' }`, drawn as a time-zone picker and
4733+
// two times.
4734+
'quiet-hours': () => import('./widgets/QuietHoursField.js').then(m => ({ default: m.QuietHoursField })),
47314735
} satisfies Record<string, FieldWidgetLoader>;
47324736

47334737
/** The registered field widget keys, as a literal union (objectui#4857). */
@@ -4913,6 +4917,7 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
49134917
'permission-ref',
49144918
'notification-topic',
49154919
'notification-channel',
4920+
'quiet-hours',
49164921
// ── Retired bare node keys (objectui#10859 batch 8) ───────────────────────
49174922
// Each was registered bare only as this loop's fallback: no other package
49184923
// owns the name, no form path reads it, and no document authors it as a
@@ -5064,6 +5069,7 @@ export const FIELD_WIDGET_LABELLING: Record<
50645069
'permission-ref': 'control',
50655070
'notification-topic': 'control',
50665071
'notification-channel': 'control',
5072+
'quiet-hours': 'group',
50675073
};
50685074

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

0 commit comments

Comments
 (0)