Skip to content

Commit f88ca70

Browse files
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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): Setup draws a `map` setting as a table of keyed rows instead of "Unsupported specifier type: map"
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/console`:** a settings row whose specifier is `type: 'map'` (Data Lifecycle's retention overrides, row quotas and per-class quota defaults) shows one row per stored entry, in stored order, under the declared `keyColumn` and `columns` headers. A row is added by picking its key from the keys the server serves as the specifier's `options`; a key another row uses is not offered again, and a stored key the server no longer offers stays on its row marked "Not offered". A `duration` cell is a whole number plus a unit (hours, days, weeks, years) and is saved as the literal (`'90d'`); a `positive_integer` cell is saved as a number. Save sends the map as an object through the page's existing patch, and removing every row sends `null`. A refused save marks the row its `constraint.entry` names, and the cell its `constraint.column` names, beside the server's sentence. An env-pinned map is read-only. Any other unknown specifier type still draws the unsupported-type line.
12+
- **`@object-ui/i18n`:** eleven `console.settingsView.map*` keys in every locale pack: the add picker, the remove button, the "Not offered" marker and its hint, the empty-table line, the key search placeholder, the unit select's name, and the four duration units.

‎ui/apps/console/src/pages/settings/SettingsField.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
import { pickLocalized, useObjectTranslation } from '@object-ui/i18n';
4343
import { AlertTriangle, ChevronRight, Info, RotateCcw } from 'lucide-react';
4444
import { EnvLockBadge } from './EnvLockBadge';
45+
import { SettingsMapField, type MapRefusalAt } from './SettingsMapField';
4546
import {
4647
resolveLabel,
4748
type Specifier,
@@ -115,6 +116,12 @@ export interface SettingsFieldProps {
115116
* sitting under a red error reads as if the field has two states at once.
116117
*/
117118
error?: string;
119+
/**
120+
* Where the last refusal points inside a `map` value (objectstack#31391):
121+
* the save door's `constraint.entry` and `constraint.column`. The row and
122+
* cell named are marked; `error` stays the sentence. Read by `map` only.
123+
*/
124+
errorAt?: MapRefusalAt;
118125
/**
119126
* A fact the PAGE knows about this row that the manifest cannot carry
120127
* (objectstack#30313: the address the server judges beside Allowed IP
@@ -586,6 +593,7 @@ export function SettingsField(props: SettingsFieldProps) {
586593
saving,
587594
labels,
588595
error,
596+
errorAt,
589597
note,
590598
writeScope,
591599
onClearSaved,
@@ -1062,6 +1070,21 @@ export function SettingsField(props: SettingsFieldProps) {
10621070
onChange={(e) => onChange(e.target.value)}
10631071
/>,
10641072
);
1073+
case 'map':
1074+
// objectstack#31391 — keyed rows over the served `options`, one cell per
1075+
// declared column. A group named by the row's label, so no `htmlFor`.
1076+
return wrapper(
1077+
<SettingsMapField
1078+
spec={spec}
1079+
value={value}
1080+
onChange={onChange}
1081+
disabled={disabled}
1082+
labelId={labelId}
1083+
optionLabel={renderOptionLabel}
1084+
errorAt={error ? errorAt : undefined}
1085+
/>,
1086+
undefined,
1087+
);
10651088
default:
10661089
return wrapper(
10671090
<div className="text-sm text-muted-foreground">

0 commit comments

Comments
 (0)