Repository navigation
Commit f88ca70
feat(console): draw the map settings specifier as a table of keyed rows (#31451)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} -->
_Requested by **Steedos** · [project
thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbQL7fkNjP6TbzDGnUYWhZet)_
Fixes #31391
Clause-②: yes
Metadata-protocol: none
## 维护者速读
-
**改了什么**:设置页「数据生命周期」里的保留期覆盖、行数配额、按类默认配额三项,不再显示「不支持的设置项类型:map」,改成一张表:每行一个对象(或生命周期类别),后面是各列的值。加一行就是从服务器给出的可选键里挑一个;已用过的键不会再出现;存着但服务器不再提供的键留在原行,标「不在可选范围」。时长列是「数字
+ 单位(小时/天/周/年)」,存成 `90d` 这样的字面量;配额列存成数字。保存时发送对象,删光所有行发送
`null`。保存被拒时,错误指向的那一行(和那一格)会被标红,旁边是服务器的原句。环境变量锁定时整张表只读。
- **为什么改**:#30208 第 5 行裁决 A 把这三个键改成 `map` 类型(服务端已在 #31386
落地),控制台还没有对应的控件,管理员只能走 API 或 `OS_LIFECYCLE_*` 来设。
- **风险与回滚**:只动控制台设置页和 i18n 词条,不动 spec、不动服务端。其他未知类型照旧显示「不支持」。新增 11 个
`console.settingsView.map*` 词条,十一个语言包都有。回滚即 revert。
-
**先看**:`ui/apps/console/src/pages/settings/SettingsMapField.tsx`(行的本地草稿、`rowsToValue`
删光发 `null`、时长单元格);`settingsFieldRefusals.ts` 的 `mapRefusalPointers`(只读
`constraint.entry` / `constraint.column`);`SettingsField.tsx` 的 `case
'map'`。
- **两分钟验证**:`cd ui && pnpm exec vitest run
apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx`,预期
13 个用例全绿。
- **需要你做的**:无,席位落地。
## What changes
- **`SettingsMapField.tsx` (new).** The control for `type: 'map'`. It
renders a table: the `keyColumn` header, then the declared `columns` in
order. Each stored entry is one row, in stored order. The rows are a
local draft with stable ids, rebuilt from `value` only when the page
hands back a value these rows did not produce (a reset, a discard, the
re-read after a save).
- **Add by picking a key.** The add control is a picker over the served
`options` that no row uses yet, so a row is never keyless and Save never
drops a half-made row. Each row's key picker offers its own key plus the
unused ones. A stored key the served `options` no longer hold stays on
its row, with a "Not offered" badge and a hint saying Save will refuse
it. When no `options` are served at all, the save door admits every key,
so nothing is marked.
- **Cells.** A `duration` cell is a whole-number box plus a unit select
(`h`, `d`, `w`, `y`; a new cell defaults to days), stored as the
literal, so 90 days is `'90d'`. A `positive_integer` cell is a number
box whose value is sent as a number. Cells are sent as typed: a value
the server would refuse is still sent, so the refusal names it; nothing
is quietly repaired or dropped. An emptied cell is left out of a
several-column entry.
- **Save.** Every edit hands the page the object the rows store, and
that object goes out through the page's existing patch. Removing every
row hands `null`, a clear, never `{}`.
- **Refusals.** `errorAt` is the save door's `constraint.entry` /
`constraint.column`. The row named by `entry` is marked (`data-invalid`,
a tinted background). The cell named by `column` gets `aria-invalid` and
`aria-describedby` pointing at the error line. When the miss is the key
or the entry itself, the row's key picker gets them instead. The
sentence stays the row's error line, as the server sent it.
- **Locked.** A locked or saving row disables every cell, picker and
remove button.
- **`SettingsField.tsx`.** A `case 'map'` draws the control through the
shared `wrapper`, named as a group by the row label, and takes the new
`errorAt` prop. The `default` unsupported-type arm stays for every other
unknown type (item 8).
- **`settingsFieldRefusals.ts`.** `extractFieldErrors` keeps only
`field` and `message`, so `mapRefusalPointers` reads `constraint` from
the same `details.fields` array the code is read from. Only `entry` and
`column` are taken, and only when `constraint.type === 'map'`. `allowed`
(one comma-joined string on the wire) and `kind` stay in the sentence.
An `invalid_type` refusal carries no `entry`, so it points at no row.
- **`SettingsView.tsx`.** The per-field error state carries `{ message,
at }`, so the pointer reaches the row. A pre-save block still carries a
message only.
- **`types.ts`.** The console mirror gains `'map'`, `keyColumn` and
`columns` (with `kind: 'duration' | 'positive_integer'`), from
`packages/spec/src/system/settings-manifest.zod.ts`.
- **`@object-ui/i18n`.** 11 new `console.settingsView.map*` keys in
every locale pack: `mapAddRow`, `mapRemoveRow` (with `key`),
`mapKeyNotOffered`, `mapKeyNotOfferedHint`, `mapNoRows`, `mapSearchKey`,
`mapUnitLabel` (with `column`), and `mapUnitHours` / `Days` / `Weeks` /
`Years`. `zh-Hant.ts` is regenerated from `zh-Hans.ts` (`gen:zh-hant`).
The empty-search text reuses `common.noResults`.
## Tests
`ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx`
has 13 cases over the real `SettingsView` → `SettingsField` render path.
Its fixtures are the `lifecycleSettingsManifest` shapes, with options
and refusals as the door testkit measured them on `main`:
- A one-column map and a several-column map render their rows from the
stored value, in stored order, under the declared headers, with no
unsupported-type line.
- The key picker lists the served options by label and hides a key
another row uses. A stored key outside `options` stays, marked "Not
offered".
- Adding "Audit log" and typing 90 in "Keep rows for" PUTs `{
retention_overrides: { sys_audit_log: { maxAge: '90d' } } }`.
- Editing a quota PUTs the object with a number cell, never JSON text.
An untouched row goes back as stored, and an emptied cell is left out.
- Removing every row PUTs `{ quotas: null }`, asserted not to equal
`{}`.
- An `invalid_option` answer marks the row named by `entry` and its key
picker, and `aria-describedby` points at the server sentence. Its
fixture carries `allowed` as the door's comma-joined string. An
`invalid_shape` answer with `column` marks that cell and not the key.
Editing clears the mark.
- An env-locked map renders read-only: cells, key picker, add picker and
remove button are all disabled, and a click on remove changes nothing.
**Ablations** (committed fix first, mutated through
`scripts/ablation-replace.mjs`, restored blob == HEAD `b56fdaae6ae9`,
`git diff HEAD` empty):
1. The duration cell sends `Number(typed)` instead of the literal. The
`90d` case goes red: `- "maxAge": "90d"`, `+ "maxAge": 90`.
2. The used-key filter is dropped (`const unused = served`). The picker
case goes red: `expected [ 'Audit log', 'Job run' ] to deeply equal [
'Job run' ]`.
## Acceptance notes
- **Item 7 is out of scope here** (no `packages/spec` change on this
card). The headers render `keyColumn.label` and `columns[].label` as
served, through `pickLocalized` for an inline locale map. Today they are
English literals in `lifecycle-settings.ts` (`Object`, `Keep rows for`,
`Expire rows after`, `Max rows`, `Lifecycle class`), so a zh page shows
them in English beside translated labels. Translating them needs a
`columns` group in `packages/spec/src/system/translation.zod.ts`. The
seat files that follow-up.
- **The `quota_defaults` option labels are the class names** (`audit`,
`telemetry`, …), as `lifecycleClassKeySource` serves them. They go
through the same `labels.optionLabel` slot as every option, so a
namespace bundle can translate them; none does today.
- **A map value stored as JSON text** by a console older than
objectstack#31241 is not an object, so the control draws no rows for it.
The #31241 one-save heal (`storedJsonTextDraft`) seeds `json` keys only,
and these three keys are `map` now. This is a reading of the code, not a
reproduction: no deployment holding such a value was measured.
- **No live run.** The control was not driven against a running server.
The served `options` and the refusal shapes come from the door testkit
(`packages/services/service-settings/src/settings-map-shape-30208.test.ts`,
16/16 on `main` `cd6618e8`), and the fixtures copy them.
- **`Clause-②: yes`** is the claim's line, copied. Measured on the built
`ui/packages/i18n/dist/locales/en.d.ts`: every new key is a readonly
literal leaf (`readonly mapAddRow: "Add a row";`), so the published
`TranslationKeys` widens. That is the `(widening)` arm, which the
claim's bare `yes` leaves unstated.
## Verification
<details><summary>Derived gate union (dispatch-gates --commands): 38 of
38 exit 0</summary>
38 of 38 exit 0 at `bdc8319d` (`main` `3d73c1d6` merged; `dispatch-gates
--ran`: 38 derived, 38 run, 0 NOT-MEASURED). `pnpm --dir ui exec vitest
run scripts/` ran at `ba2bb379` (170 files passed, 2 skipped); the merge
after it touched no `ui/` path.
- `node scripts/check-adr-0087-registration.mjs --base origin/main` ·
exit 0
- `node scripts/check-adr-0087-registration.mjs --self-test` · exit 0
- `node scripts/check-changeset-no-major.mjs --base origin/main` · exit
0
- `node scripts/check-closing-keyword-parity.mjs` · exit 0
- `node scripts/check-closing-keyword-parity.mjs --self-test` · exit 0
- `node scripts/check-comment-mask-corpus.mjs` · exit 0
- `node scripts/check-empty-changeset.mjs --base origin/main` · exit 0
- `node scripts/check-empty-changeset.mjs --self-test` · exit 0
- `node scripts/check-sdui-manifest.mjs` · exit 0
- `node scripts/check-sdui-manifest.mjs --self-test` · exit 0
- `node scripts/ci/snapshot-next.mjs --self-test` · exit 0
- `node scripts/pm/release-rehearsal-clone.mjs --self-test` · exit 0
- `node scripts/release-pending-publish.mjs --self-test` · exit 0
- `pnpm --filter @objectstack/spec run check:duration-unit-keys` · exit
0
- `pnpm check:changeset-gate-self-tests` · exit 0
- `pnpm check:closed-ceremony-text` · exit 0
- `pnpm check:cross-package-test-inputs` · exit 0
- `pnpm check:dispatcher-error-vocabulary` · exit 0
- `pnpm check:driver-memory-census` · exit 0
- `pnpm check:engine-double-contract` · exit 0
- `pnpm check:gitlink-declared` · exit 0
- `pnpm check:merge-driver` · exit 0
- `pnpm check:nul-bytes` · exit 0
- `pnpm check:published-files` · exit 0
- `pnpm check:query-options-erasure` · exit 0
- `pnpm check:refd-timer-probe` · exit 0
- `pnpm check:registry-log-declared` · exit 0
- `pnpm check:rest-log-spy-declared` · exit 0
- `pnpm check:role-word` · exit 0
- `pnpm check:skill-identifier-liveness` · exit 0
- `pnpm check:test-source-alias` · exit 0
- `pnpm check:tier-file-adoption` · exit 0
- `pnpm check:type-check-coverage` · exit 0
- `pnpm check:undeclared-dep-imports` · exit 0
- `pnpm check:watch-hint-literal` · exit 0
- `pnpm check:where-matcher` · exit 0
- `pnpm check:type-check-debt` · exit 0
- `pnpm --dir ui exec vitest run scripts/` · exit 0
</details>
<details><summary>Suites and type-check</summary>
- `turbo run type-check --filter=@object-ui/console
--filter=@object-ui/i18n`: 74/74 tasks, console and i18n both cache
miss, executed.
- `vitest run apps/console/src/pages/settings/`: 40 files, 343 tests
passed.
- `vitest run packages/i18n/`: 99 files, 1804 passed, 13 skipped (after
`gen:zh-hant`; before it, `zh-hant-generated.test.ts` was the one red).
- `node scripts/check-i18n-en-drift.mjs` (ui): no en value changed in
range.
- `eslint` on the six touched console files: 0 errors, 6 pre-existing
warnings.
- Server door testkit on `main` `cd6618e8`:
`settings-map-shape-30208.test.ts` 16/16.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QsR2jUhVXEJm46XTxRs4GM)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent bb61872 commit f88ca70
18 files changed
Lines changed: 1039 additions & 12 deletions
File tree
- .changeset
- ui
- apps/console/src/pages/settings
- __tests__
- packages/i18n/src/locales
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| 45 | + | |
45 | 46 | | |
46 | 47 | | |
47 | 48 | | |
| |||
115 | 116 | | |
116 | 117 | | |
117 | 118 | | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
118 | 125 | | |
119 | 126 | | |
120 | 127 | | |
| |||
586 | 593 | | |
587 | 594 | | |
588 | 595 | | |
| 596 | + | |
589 | 597 | | |
590 | 598 | | |
591 | 599 | | |
| |||
1062 | 1070 | | |
1063 | 1071 | | |
1064 | 1072 | | |
| 1073 | + | |
| 1074 | + | |
| 1075 | + | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
| 1080 | + | |
| 1081 | + | |
| 1082 | + | |
| 1083 | + | |
| 1084 | + | |
| 1085 | + | |
| 1086 | + | |
| 1087 | + | |
1065 | 1088 | | |
1066 | 1089 | | |
1067 | 1090 | | |
| |||
0 commit comments