Repository navigation
Commit f67f3b3
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
File tree
- .changeset
- ui/packages
- app-shell/src/utils
- cli/src/utils
- fields/src
- __tests__
- widgets
- __tests__
- i18n/src/locales
- plugin-detail/src/__tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
182 | 182 | | |
183 | 183 | | |
184 | 184 | | |
| 185 | + | |
185 | 186 | | |
186 | 187 | | |
187 | 188 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
| 161 | + | |
161 | 162 | | |
162 | 163 | | |
163 | 164 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
190 | 190 | | |
191 | 191 | | |
192 | 192 | | |
193 | | - | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
| 74 | + | |
| 75 | + | |
74 | 76 | | |
75 | 77 | | |
76 | 78 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
| 144 | + | |
144 | 145 | | |
145 | 146 | | |
146 | 147 | | |
| |||
197 | 198 | | |
198 | 199 | | |
199 | 200 | | |
| 201 | + | |
200 | 202 | | |
201 | 203 | | |
202 | 204 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
151 | 151 | | |
152 | 152 | | |
153 | 153 | | |
| 154 | + | |
154 | 155 | | |
155 | 156 | | |
156 | 157 | | |
| |||
216 | 217 | | |
217 | 218 | | |
218 | 219 | | |
| 220 | + | |
219 | 221 | | |
220 | 222 | | |
221 | 223 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4728 | 4728 | | |
4729 | 4729 | | |
4730 | 4730 | | |
| 4731 | + | |
| 4732 | + | |
| 4733 | + | |
| 4734 | + | |
4731 | 4735 | | |
4732 | 4736 | | |
4733 | 4737 | | |
| |||
4913 | 4917 | | |
4914 | 4918 | | |
4915 | 4919 | | |
| 4920 | + | |
4916 | 4921 | | |
4917 | 4922 | | |
4918 | 4923 | | |
| |||
5064 | 5069 | | |
5065 | 5070 | | |
5066 | 5071 | | |
| 5072 | + | |
5067 | 5073 | | |
5068 | 5074 | | |
5069 | 5075 | | |
| |||
0 commit comments