From 571bb6aec5a17270657d208c3939a9fa87554230 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 07:21:04 +0000 Subject: [PATCH 1/4] feat(console): draw the map settings specifier as a table of keyed rows Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUhVXEJm46XTxRs4GM --- .../src/pages/settings/SettingsField.tsx | 23 + .../src/pages/settings/SettingsMapField.tsx | 422 ++++++++++++++++++ .../src/pages/settings/SettingsView.tsx | 14 +- .../SettingsView.mapRows-31391.test.tsx | 368 +++++++++++++++ .../pages/settings/settingsFieldRefusals.ts | 42 +- ui/apps/console/src/pages/settings/types.ts | 36 ++ ui/packages/i18n/src/locales/ar.ts | 11 + ui/packages/i18n/src/locales/de.ts | 11 + ui/packages/i18n/src/locales/en.ts | 17 + ui/packages/i18n/src/locales/es.ts | 11 + ui/packages/i18n/src/locales/fr.ts | 11 + ui/packages/i18n/src/locales/ja.ts | 11 + ui/packages/i18n/src/locales/ko.ts | 11 + ui/packages/i18n/src/locales/pt-BR.ts | 11 + ui/packages/i18n/src/locales/ru.ts | 11 + ui/packages/i18n/src/locales/zh-Hans.ts | 11 + ui/packages/i18n/src/locales/zh-Hant.ts | 11 + 17 files changed, 1020 insertions(+), 12 deletions(-) create mode 100644 ui/apps/console/src/pages/settings/SettingsMapField.tsx create mode 100644 ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx diff --git a/ui/apps/console/src/pages/settings/SettingsField.tsx b/ui/apps/console/src/pages/settings/SettingsField.tsx index dd5cca2959a..50789eb10d2 100644 --- a/ui/apps/console/src/pages/settings/SettingsField.tsx +++ b/ui/apps/console/src/pages/settings/SettingsField.tsx @@ -42,6 +42,7 @@ import { import { pickLocalized, useObjectTranslation } from '@object-ui/i18n'; import { AlertTriangle, ChevronRight, Info, RotateCcw } from 'lucide-react'; import { EnvLockBadge } from './EnvLockBadge'; +import { SettingsMapField, type MapRefusalAt } from './SettingsMapField'; import { resolveLabel, type Specifier, @@ -115,6 +116,12 @@ export interface SettingsFieldProps { * sitting under a red error reads as if the field has two states at once. */ error?: string; + /** + * Where the last refusal points inside a `map` value (objectstack#31391): + * the save door's `constraint.entry` and `constraint.column`. The row and + * cell named are marked; `error` stays the sentence. Read by `map` only. + */ + errorAt?: MapRefusalAt; /** * A fact the PAGE knows about this row that the manifest cannot carry * (objectstack#30313: the address the server judges beside Allowed IP @@ -586,6 +593,7 @@ export function SettingsField(props: SettingsFieldProps) { saving, labels, error, + errorAt, note, writeScope, onClearSaved, @@ -1062,6 +1070,21 @@ export function SettingsField(props: SettingsFieldProps) { onChange={(e) => onChange(e.target.value)} />, ); + case 'map': + // objectstack#31391 — keyed rows over the served `options`, one cell per + // declared column. A group named by the row's label, so no `htmlFor`. + return wrapper( + , + undefined, + ); default: return wrapper(
diff --git a/ui/apps/console/src/pages/settings/SettingsMapField.tsx b/ui/apps/console/src/pages/settings/SettingsMapField.tsx new file mode 100644 index 00000000000..a61951b66c0 --- /dev/null +++ b/ui/apps/console/src/pages/settings/SettingsMapField.tsx @@ -0,0 +1,422 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * — the control a `map` specifier gets (objectstack#31391): + * a table of keyed rows, one key column and the declared value columns. + * + * The contract it draws is `SpecifierSchema`'s `map` member (objectstack#30208, + * ADR-0007 as amended): `keyColumn` is the key column's header, `columns` the + * value columns in display order, each of kind `duration` or + * `positive_integer`. The keys a map admits are NOT declared: the namespace read + * serves them as the specifier's `options`, from a runtime option source (the + * objects that declare a lifecycle class other than `record`; for + * `lifecycle.quota_defaults`, those classes). + * + * Stored value: with one value column `Record` + * (`{ "sys_audit_log": 1000000 }`); with several `Record` (`{ "sys_job_run": { "maxAge": "1y" } }`), an empty cell left out + * of its entry. + * + * Design notes: + * - Rows live in a LOCAL draft with stable ids, as `FlowKeyValueField` keeps + * them, so a key change never remounts its row. Every edit is handed to the + * page at once as the object the rows store; the draft is rebuilt from + * `value` only when the page hands back something else (a reset, a discard, + * the re-read after a save). + * - A row is added by PICKING ITS KEY: the add control is a picker over the + * served keys no row uses yet. So no row is ever keyless, and Save never + * drops a half-made row without saying so. + * - A key one row uses is not offered to another. A stored key the served + * `options` no longer hold stays on its row, marked "not offered", so the + * save door's `invalid_option` refusal is explained before Save. With no + * `options` served at all the door admits every key (the option source was + * unreachable), so nothing is marked then. + * - Cells are kept as stored and sent as typed: a `duration` is the literal + * `` (`'90d'`), a `positive_integer` a number, never + * text. A cell the server would refuse is still sent, so the refusal names + * it; the control does not quietly repair or drop it. + * - Removing the last row sends `null`, a clear: the key falls back to its + * default, never to a stored `{}`. + * - A refusal's `constraint.entry` / `constraint.column` (the save door's + * `FieldError`) arrive as `errorAt`: the row named by `entry` is marked, and + * so is the cell named by `column`, or the row's key picker when the miss is + * the key or the entry itself. The sentence stays the row's error line. + * + * @module + */ + +import { useState } from 'react'; +import { X } from 'lucide-react'; +import { + Badge, + Button, + Combobox, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + cn, +} from '@object-ui/components'; +import { pickLocalized, useObjectTranslation } from '@object-ui/i18n'; +import type { Specifier, SpecifierMapColumn } from './types'; + +/** Where a map refusal points: the entry, and the column when it is a cell. */ +export interface MapRefusalAt { + entry: string; + column?: string; +} + +/** The duration units the server's `LIFECYCLE_DURATION_REGEX` admits, in order. */ +const DURATION_UNITS = ['h', 'd', 'w', 'y'] as const; +type DurationUnit = (typeof DURATION_UNITS)[number]; + +/** The unit a new duration cell starts with. */ +const DEFAULT_DURATION_UNIT: DurationUnit = 'd'; + +const UNIT_LABEL_KEYS: Record = { + h: 'console.settingsView.mapUnitHours', + d: 'console.settingsView.mapUnitDays', + w: 'console.settingsView.mapUnitWeeks', + y: 'console.settingsView.mapUnitYears', +}; + +/** One row of the local draft: a stable id, its key, and its entry as stored. */ +interface MapRow { + id: string; + key: string; + /** + * The entry exactly as stored or as last edited: the cell itself with one + * column, an object of cells with several. Kept as is, so a row nobody + * touches is sent back byte for byte, unknown properties included, and the + * server judges it as it judged it before. + */ + entry: unknown; +} + +function isPlainRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +let rowSeq = 0; +function nextRowId(): string { + rowSeq += 1; + return `map-row-${rowSeq}`; +} + +/** The rows a stored value holds, in its own order. Anything but an object holds none. */ +function readRows(value: unknown): MapRow[] { + if (!isPlainRecord(value)) return []; + return Object.entries(value).map(([key, entry]) => ({ id: nextRowId(), key, entry })); +} + +/** + * The value the rows store: the object of entries in row order, or `null` when + * there is no row. A single-column row with an empty cell stores `null` for + * its key, which the save door refuses on that row, rather than vanishing. + */ +function rowsToValue(rows: readonly MapRow[]): Record | null { + if (rows.length === 0) return null; + const out: Record = {}; + for (const row of rows) out[row.key] = row.entry === undefined ? null : row.entry; + return out; +} + +/** The identity the resync compares: insertion order is the row order, so it counts. */ +function valueIdentity(value: unknown): string { + return JSON.stringify(value ?? null); +} + +/** A cell of a row's entry, for the column given. */ +function cellOf(entry: unknown, column: SpecifierMapColumn, single: boolean): unknown { + if (single) return entry; + return isPlainRecord(entry) ? entry[column.name] : undefined; +} + +/** The entry with one cell replaced; `undefined` leaves the cell out. */ +function withCell(entry: unknown, column: SpecifierMapColumn, single: boolean, cell: unknown): unknown { + if (single) return cell; + const next: Record = isPlainRecord(entry) ? { ...entry } : {}; + if (cell === undefined) delete next[column.name]; + else next[column.name] = cell; + return next; +} + +/** + * A stored duration cell, split for the two inputs. A literal the server's + * grammar reads splits into its whole number and its unit; any other text is + * shown as it is in the number box with no unit chosen, so a malformed stored + * value is visible rather than blanked. + */ +function splitDuration(cell: unknown): { amount: string; unit: DurationUnit | undefined } { + if (cell === null || cell === undefined) return { amount: '', unit: undefined }; + const text = String(cell); + const unit = text.slice(-1) as DurationUnit; + if (DURATION_UNITS.includes(unit)) return { amount: text.slice(0, -1), unit }; + return { amount: text, unit: undefined }; +} + +interface CellProps { + column: SpecifierMapColumn; + columnLabel: string; + cell: unknown; + disabled?: boolean; + invalid: boolean; + describedBy?: string; + onCell: (cell: unknown) => void; +} + +/** A `duration` cell: a whole number and a unit, stored as the literal (`'90d'`). */ +function DurationCell({ columnLabel, cell, disabled, invalid, describedBy, onCell }: CellProps) { + const { t } = useObjectTranslation(); + const { amount, unit } = splitDuration(cell); + const shownUnit = unit ?? DEFAULT_DURATION_UNIT; + return ( +
+ { + const typed = e.target.value; + onCell(typed === '' ? undefined : `${typed}${shownUnit}`); + }} + /> + +
+ ); +} + +/** A `positive_integer` cell: a whole number above 0, sent as a number. */ +function PositiveIntegerCell({ columnLabel, cell, disabled, invalid, describedBy, onCell }: CellProps) { + return ( + { + const typed = e.target.value; + onCell(typed === '' ? undefined : Number(typed)); + }} + /> + ); +} + +export interface SettingsMapFieldProps { + spec: Specifier; + value: unknown; + onChange: (next: unknown) => void; + disabled?: boolean; + /** The id of the row's label, which names this group. */ + labelId: string; + /** An option's shown name, translated through the namespace's option slot. */ + optionLabel: (option: NonNullable[number]) => string; + /** The save door's pointer into the value, from the last refused save. */ + errorAt?: MapRefusalAt; + /** Set by the row's wrapper: the error line and note this control is described by. */ + 'aria-describedby'?: string; + 'aria-invalid'?: boolean; +} + +export function SettingsMapField({ + spec, + value, + onChange, + disabled, + labelId, + optionLabel, + errorAt, + 'aria-describedby': describedBy, +}: SettingsMapFieldProps) { + const { t, language } = useObjectTranslation(); + const columns = spec.columns ?? []; + const single = columns.length === 1; + const served = (spec.options ?? []).map((opt) => ({ value: String(opt.value), label: optionLabel(opt) })); + const servedKeys = new Set(served.map((o) => o.value)); + + // The local draft, rebuilt from `value` when the page hands back a value + // these rows did not produce (React's adjust-state-during-render pattern). + const incoming = valueIdentity(value); + const [draft, setDraft] = useState(() => ({ seen: incoming, rows: readRows(value) })); + let rows = draft.rows; + if (draft.seen !== incoming) { + rows = readRows(value); + setDraft({ seen: incoming, rows }); + } + + const commit = (next: MapRow[]) => { + const out = rowsToValue(next); + setDraft({ seen: valueIdentity(out), rows: next }); + onChange(out); + }; + + const usedKeys = new Set(rows.map((r) => r.key)); + const unused = served.filter((o) => !usedKeys.has(o.value)); + const keyHeader = pickLocalized(spec.keyColumn?.label, language); + const columnLabels = columns.map((c) => pickLocalized(c.label, language)); + const searchText = t('console.settingsView.mapSearchKey'); + const emptyText = t('common.noResults'); + + return ( +
+ + + + {keyHeader} + {columns.map((c, i) => ( + + {columnLabels[i]} + + ))} + + + + + {rows.length === 0 ? ( + + + {t('console.settingsView.mapNoRows')} + + + ) : null} + {rows.map((row) => { + const marked = errorAt?.entry === row.key; + const markedColumn = marked && errorAt?.column && columns.some((c) => c.name === errorAt.column) + ? errorAt.column + : undefined; + const keyMarked = marked && markedColumn === undefined; + const notOffered = served.length > 0 && !servedKeys.has(row.key); + const ownOption = served.find((o) => o.value === row.key) ?? { value: row.key, label: row.key }; + const keyOptions = [ownOption, ...unused]; + const rowName = ownOption.label; + return ( + + +
+ { + // Picking the row's own key again deselects it; a row + // keeps its key until another is picked. + if (!next || next === row.key) return; + commit(rows.map((r) => (r.id === row.id ? { ...r, key: next } : r))); + }} + /> + {notOffered ? ( + + {t('console.settingsView.mapKeyNotOffered')} + + ) : null} +
+
+ {columns.map((column, i) => { + const cellProps: CellProps = { + column, + columnLabel: columnLabels[i], + cell: cellOf(row.entry, column, single), + disabled, + invalid: markedColumn === column.name, + describedBy, + onCell: (cell) => + commit( + rows.map((r) => (r.id === row.id ? { ...r, entry: withCell(r.entry, column, single, cell) } : r)), + ), + }; + return ( + + {column.kind === 'duration' ? : } + + ); + })} + + + +
+ ); + })} +
+
+ { + if (!key || usedKeys.has(key)) return; + commit([...rows, { id: nextRowId(), key, entry: single ? undefined : {} }]); + }} + /> +
+ ); +} diff --git a/ui/apps/console/src/pages/settings/SettingsView.tsx b/ui/apps/console/src/pages/settings/SettingsView.tsx index dbc5a12e95c..95089b5e639 100644 --- a/ui/apps/console/src/pages/settings/SettingsView.tsx +++ b/ui/apps/console/src/pages/settings/SettingsView.tsx @@ -17,6 +17,7 @@ import { Button, Card, CardContent, Skeleton, Badge, getLazyIcon } from '@object import { ExpressionEvaluator } from '@object-ui/core'; import { useObjectTranslation } from '@object-ui/i18n'; import { SettingsField } from './SettingsField'; +import type { MapRefusalAt } from './SettingsMapField'; import { fieldWording, settingsFieldRefusals } from './settingsFieldRefusals'; import { getJudgedClientAddress, @@ -381,8 +382,12 @@ export function SettingsView() { * `error.code`, which `extractFieldErrors` could not read — so the whole * batch collapsed into one toast that named no field. It now arrives as the * declared `FieldError[]` under `error.details.fields`. + * + * Each carries the sentence the row shows and, for a `map` key, where the + * refusal points inside the value (objectstack#31391), so the row and cell + * it names are marked. A pre-save block points nowhere. */ - const [fieldErrors, setFieldErrors] = useState>({}); + const [fieldErrors, setFieldErrors] = useState>({}); /** * The last save's fail-closed crypto refusal, or null (objectui#4570). * @@ -586,7 +591,7 @@ export function SettingsView() { if (type === 'phone') return t('console.settingsView.invalidPhone'); return t('console.settingsView.invalidColor'); }; - setFieldErrors(Object.fromEntries(blocked.map((b) => [b.key, messageFor(b)]))); + setFieldErrors(Object.fromEntries(blocked.map((b) => [b.key, { message: messageFor(b) }]))); setFocusKey({ key: blocked[0].key }); const named = refusedSettingLabels( blocked.map(({ key }) => ({ field: key })), @@ -669,7 +674,7 @@ export function SettingsView() { // keeps the server's sentence (`settingsFieldRefusals`). const perField = settingsFieldRefusals(apiError, manifest.specifiers, fieldWording(labels), t); if (perField?.length) { - setFieldErrors(Object.fromEntries(perField.map((f) => [f.field, f.message]))); + setFieldErrors(Object.fromEntries(perField.map((f) => [f.field, { message: f.message, at: f.at }]))); const named = refusedSettingLabels(perField, manifest.specifiers, labels); toast.error( named.length > 0 @@ -841,7 +846,8 @@ export function SettingsView() { locked={resolved?.locked} saving={saving} labels={labels} - error={key ? fieldErrors[key] : undefined} + error={key ? fieldErrors[key]?.message : undefined} + errorAt={key ? fieldErrors[key]?.at : undefined} note={ judgedAddress && clientAddressRouteOf(namespace) !== undefined && diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx new file mode 100644 index 00000000000..d32148fa4c7 --- /dev/null +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx @@ -0,0 +1,368 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * SettingsView — a `map` setting is drawn as a table of keyed rows + * (objectstack#31391), over the real `SettingsField` render path. + * + * The server half (objectstack#30208) moved `lifecycle.retention_overrides`, + * `lifecycle.quotas` and `lifecycle.quota_defaults` to the `map` specifier: a + * `keyColumn`, typed value `columns`, and the keys the map admits served as + * the specifier's `options` by the namespace read. Before this card the page + * drew those rows as "Unsupported specifier type: map". + * + * The fixtures are the shapes `lifecycleSettingsManifest` declares and the + * door testkit measured on main (`settings-map-shape-30208.test.ts`): the + * object keys served with the object's label, the refusal answered as one + * `FieldError` with `constraint: { type: 'map', entry, column?, kind?, + * allowed? }`, where `allowed` is the admitted keys joined by `, `. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const getSettingsNamespace = vi.fn(); +const saveSettingsNamespace = vi.fn(); +const runSettingsAction = vi.fn(); + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api'); + return { + ...actual, + getSettingsNamespace: (...a: unknown[]) => getSettingsNamespace(...a), + saveSettingsNamespace: (...a: unknown[]) => saveSettingsNamespace(...a), + runSettingsAction: (...a: unknown[]) => runSettingsAction(...a), + }; +}); + +const toastError = vi.fn(); +const toastSuccess = vi.fn(); +vi.mock('sonner', () => ({ + toast: { + error: (...a: unknown[]) => toastError(...a), + success: (...a: unknown[]) => toastSuccess(...a), + }, +})); + +import { SettingsView } from '../SettingsView'; + +/** The keys the object option source serves on this deployment. */ +const OBJECT_OPTIONS = [ + { value: 'sys_audit_log', label: 'Audit log' }, + { value: 'sys_job_run', label: 'Job run' }, +]; + +type Resolved = { value: unknown; source: string; locked: boolean; envVar?: string }; + +/** The Data Lifecycle page's three maps, as `lifecycleSettingsManifest` declares them. */ +function lifecyclePayload(values: Record = {}, overrides: Record> = {}) { + const resolved: Record = {}; + const all = { retention_overrides: {}, quotas: {}, ...values }; + for (const [k, v] of Object.entries(all)) { + resolved[k] = { value: v, source: 'default', locked: false, ...overrides[k] }; + } + return { + manifest: { + namespace: 'lifecycle', + label: 'Data Lifecycle', + specifiers: [ + { + type: 'map', + key: 'retention_overrides', + label: 'Retention overrides', + default: {}, + keyColumn: { label: 'Object' }, + columns: [ + { name: 'maxAge', label: 'Keep rows for', kind: 'duration' }, + { name: 'expireAfter', label: 'Expire rows after', kind: 'duration' }, + ], + options: OBJECT_OPTIONS, + }, + { + type: 'map', + key: 'quotas', + label: 'Row quotas', + default: {}, + keyColumn: { label: 'Object' }, + columns: [{ name: 'max_rows', label: 'Max rows', kind: 'positive_integer' }], + options: OBJECT_OPTIONS, + }, + ], + }, + values: resolved, + }; +} + +function renderView() { + return render( + + + } /> + + , + ); +} + +const rowFor = (key: string) => document.querySelector(`[data-settings-key="${key}"]`)!; +const entryRow = (key: string, entry: string) => + rowFor(key).querySelector(`[data-map-entry="${entry}"]`); +const entryKeys = (key: string) => + Array.from(rowFor(key).querySelectorAll('[data-map-entry]')).map((r) => r.dataset.mapEntry); +const cell = (key: string, entry: string, columnLabel: string) => + within(entryRow(key, entry)!).getByRole('spinbutton', { name: columnLabel }) as HTMLInputElement; +/** Provider-less, `t` answers the key itself: the add picker's trigger reads its key. */ +const addPicker = (key: string) => within(rowFor(key)).getByRole('combobox', { name: /mapAddRow/ }); + +async function pickToAdd(key: string, optionLabel: string) { + fireEvent.click(addPicker(key)); + fireEvent.click(await screen.findByRole('option', { name: optionLabel })); +} + +async function save() { + fireEvent.click(await screen.findByRole('button', { name: /save/i })); + await waitFor(() => expect(saveSettingsNamespace).toHaveBeenCalledTimes(1)); + return saveSettingsNamespace.mock.calls[0] as [string, Record]; +} + +/** A `SETTINGS_VALIDATION` answer carrying one map `FieldError`, as the save door sends it. */ +function mapRefusal(fieldError: Record) { + const err = new Error('Settings for lifecycle are incomplete') as Error & { status?: number; payload?: unknown }; + err.status = 400; + err.payload = { + success: false, + error: { + code: 'SETTINGS_VALIDATION', + message: 'Settings for lifecycle are incomplete', + details: { namespace: 'lifecycle', fields: [fieldError] }, + }, + }; + return err; +} + +beforeEach(() => { + vi.clearAllMocks(); + saveSettingsNamespace.mockResolvedValue({ values: {} }); +}); +afterEach(cleanup); + +describe('SettingsView — a `map` setting draws its stored rows (objectstack#31391)', () => { + it('one value column: a row per stored entry, in stored order, the cell holding the stored number', async () => { + getSettingsNamespace.mockResolvedValue( + lifecyclePayload({ quotas: { sys_job_run: 500000, sys_audit_log: 1000000 } }), + ); + renderView(); + await screen.findByText('Row quotas'); + + expect(entryKeys('quotas')).toEqual(['sys_job_run', 'sys_audit_log']); + expect(cell('quotas', 'sys_audit_log', 'Max rows').value).toBe('1000000'); + // The key cell shows the served label, the header the declared key column. + expect(within(entryRow('quotas', 'sys_audit_log')!).getByRole('combobox').textContent).toContain('Audit log'); + expect(within(rowFor('quotas')).getByRole('columnheader', { name: 'Object' })).toBeTruthy(); + // The row is drawn as keyed rows, never as the unsupported-type fallback. + expect(rowFor('quotas').textContent).not.toMatch(/unsupportedSpecifierType/); + }); + + it('several value columns: each declared column is a cell, a duration split into its number', async () => { + getSettingsNamespace.mockResolvedValue( + lifecyclePayload({ retention_overrides: { sys_job_run: { maxAge: '1y', expireAfter: '30d' } } }), + ); + renderView(); + await screen.findByText('Retention overrides'); + + expect(entryKeys('retention_overrides')).toEqual(['sys_job_run']); + expect(cell('retention_overrides', 'sys_job_run', 'Keep rows for').value).toBe('1'); + expect(cell('retention_overrides', 'sys_job_run', 'Expire rows after').value).toBe('30'); + const headers = within(rowFor('retention_overrides')).getAllByRole('columnheader').map((h) => h.textContent); + expect(headers.slice(0, 3)).toEqual(['Object', 'Keep rows for', 'Expire rows after']); + }); +}); + +describe('SettingsView — the key picker (objectstack#31391)', () => { + it('lists the served options and hides a key another row already uses', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quotas: { sys_audit_log: 1000000 } })); + renderView(); + await screen.findByText('Row quotas'); + + fireEvent.click(addPicker('quotas')); + const offered = (await screen.findAllByRole('option')).map((o) => o.textContent); + expect(offered).toEqual(['Job run']); + }); + + it('with every key unused, lists every served option by its label', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload()); + renderView(); + await screen.findByText('Row quotas'); + + fireEvent.click(addPicker('quotas')); + const offered = (await screen.findAllByRole('option')).map((o) => o.textContent); + expect(offered).toEqual(['Audit log', 'Job run']); + }); + + it('a stored key the server no longer offers stays on its row, marked as not offered', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quotas: { sys_audit_logs: 10, sys_job_run: 5 } })); + renderView(); + await screen.findByText('Row quotas'); + + expect(entryKeys('quotas')).toEqual(['sys_audit_logs', 'sys_job_run']); + expect(within(entryRow('quotas', 'sys_audit_logs')!).getByTestId('settings-map-not-offered')).toBeTruthy(); + expect(within(entryRow('quotas', 'sys_job_run')!).queryByTestId('settings-map-not-offered')).toBeNull(); + }); +}); + +describe('SettingsView — a `map` setting is saved as the object its rows store (objectstack#31391)', () => { + it('a duration typed as a number with the default unit is stored as the literal `90d`', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload()); + renderView(); + await screen.findByText('Retention overrides'); + + await pickToAdd('retention_overrides', 'Audit log'); + fireEvent.change(cell('retention_overrides', 'sys_audit_log', 'Keep rows for'), { target: { value: '90' } }); + const [namespace, patch] = await save(); + + expect(namespace).toBe('lifecycle'); + expect(patch).toEqual({ retention_overrides: { sys_audit_log: { maxAge: '90d' } } }); + }); + + it('a positive integer is sent as a number, the map as an object, never as JSON text', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quotas: { sys_audit_log: 1000000 } })); + renderView(); + await screen.findByText('Row quotas'); + + fireEvent.change(cell('quotas', 'sys_audit_log', 'Max rows'), { target: { value: '2000' } }); + const [, patch] = await save(); + + expect(patch).toEqual({ quotas: { sys_audit_log: 2000 } }); + expect(typeof patch.quotas).toBe('object'); + expect(typeof (patch.quotas as Record).sys_audit_log).toBe('number'); + }); + + it('an untouched row is sent back as stored beside an edited one, cells left empty left out', async () => { + getSettingsNamespace.mockResolvedValue( + lifecyclePayload({ retention_overrides: { sys_job_run: { maxAge: '1y', expireAfter: '30d' } } }), + ); + renderView(); + await screen.findByText('Retention overrides'); + + fireEvent.change(cell('retention_overrides', 'sys_job_run', 'Expire rows after'), { target: { value: '' } }); + const [, patch] = await save(); + + expect(patch).toEqual({ retention_overrides: { sys_job_run: { maxAge: '1y' } } }); + }); + + it('removing every row sends null (a clear), never an empty object', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quotas: { sys_audit_log: 1000000, sys_job_run: 5 } })); + renderView(); + await screen.findByText('Row quotas'); + + for (const button of within(rowFor('quotas')).getAllByRole('button', { name: /mapRemoveRow/ })) { + fireEvent.click(button); + } + expect(entryKeys('quotas')).toEqual([]); + const [, patch] = await save(); + + expect(patch).toEqual({ quotas: null }); + expect(patch.quotas).not.toEqual({}); + }); +}); + +describe('SettingsView — a refused map save marks the row it names (objectstack#31391)', () => { + it('invalid_option: the row named by `entry` and its key picker are marked, with the server sentence', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quotas: { sys_audit_logs: 10, sys_job_run: 5 } })); + const sentence = + "Row quotas: 'sys_audit_logs' is not a key this setting admits. Allowed: sys_audit_log, sys_job_run."; + saveSettingsNamespace.mockRejectedValue( + mapRefusal({ + field: 'quotas', + code: 'invalid_option', + message: sentence, + label: 'Row quotas', + // The save door's shape: `allowed` is ONE comma-joined string, not an array. + constraint: { type: 'map', entry: 'sys_audit_logs', allowed: 'sys_audit_log, sys_job_run' }, + }), + ); + renderView(); + await screen.findByText('Row quotas'); + + fireEvent.change(cell('quotas', 'sys_job_run', 'Max rows'), { target: { value: '6' } }); + await save(); + + await waitFor(() => expect(entryRow('quotas', 'sys_audit_logs')!.dataset.invalid).toBe('true')); + expect(entryRow('quotas', 'sys_job_run')!.dataset.invalid).toBeUndefined(); + const keyPicker = within(entryRow('quotas', 'sys_audit_logs')!).getByRole('combobox'); + expect(keyPicker.getAttribute('aria-invalid')).toBe('true'); + const alert = rowFor('quotas').querySelector('[role="alert"]')!; + expect(alert.textContent).toBe(sentence); + expect(keyPicker.getAttribute('aria-describedby')).toBe(alert.id); + }); + + it('invalid_shape on a cell: the cell named by `column` is marked, not the key', async () => { + getSettingsNamespace.mockResolvedValue( + lifecyclePayload({ retention_overrides: { sys_job_run: { maxAge: '1y', expireAfter: '30d' } } }), + ); + saveSettingsNamespace.mockRejectedValue( + mapRefusal({ + field: 'retention_overrides', + code: 'invalid_shape', + message: + "Retention overrides: 'sys_job_run'.expireAfter must be a duration such as 7y (a whole number followed by h, d, w or y), not a string of 4 characters.", + constraint: { type: 'map', entry: 'sys_job_run', column: 'expireAfter', kind: 'duration' }, + }), + ); + renderView(); + await screen.findByText('Retention overrides'); + + fireEvent.change(cell('retention_overrides', 'sys_job_run', 'Expire rows after'), { target: { value: '1.5' } }); + await save(); + + await waitFor(() => expect(entryRow('retention_overrides', 'sys_job_run')!.dataset.invalid).toBe('true')); + expect(cell('retention_overrides', 'sys_job_run', 'Expire rows after').getAttribute('aria-invalid')).toBe('true'); + expect(cell('retention_overrides', 'sys_job_run', 'Keep rows for').getAttribute('aria-invalid')).toBeNull(); + expect( + within(entryRow('retention_overrides', 'sys_job_run')!).getByRole('combobox', { name: /Job run/ }).getAttribute('aria-invalid'), + ).toBeNull(); + }); + + it('editing the map clears the mark with the sentence', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quotas: { sys_audit_logs: 10 } })); + saveSettingsNamespace.mockRejectedValue( + mapRefusal({ + field: 'quotas', + code: 'invalid_option', + message: "Row quotas: 'sys_audit_logs' is not a key this setting admits.", + constraint: { type: 'map', entry: 'sys_audit_logs', allowed: 'sys_audit_log, sys_job_run' }, + }), + ); + renderView(); + await screen.findByText('Row quotas'); + fireEvent.change(cell('quotas', 'sys_audit_logs', 'Max rows'), { target: { value: '11' } }); + await save(); + await waitFor(() => expect(entryRow('quotas', 'sys_audit_logs')!.dataset.invalid).toBe('true')); + + fireEvent.change(cell('quotas', 'sys_audit_logs', 'Max rows'), { target: { value: '12' } }); + + expect(entryRow('quotas', 'sys_audit_logs')!.dataset.invalid).toBeUndefined(); + expect(rowFor('quotas').querySelector('[role="alert"]')).toBeNull(); + }); +}); + +describe('SettingsView — a locked map (objectstack#31391)', () => { + it('an env-pinned map renders read-only: no cell, key, add or remove control can be used', async () => { + getSettingsNamespace.mockResolvedValue( + lifecyclePayload( + { quotas: { sys_audit_log: 1000000 } }, + { quotas: { source: 'env', locked: true, envVar: 'OS_LIFECYCLE_QUOTAS' } }, + ), + ); + renderView(); + await screen.findByText('Row quotas'); + + expect(cell('quotas', 'sys_audit_log', 'Max rows').disabled).toBe(true); + expect((within(entryRow('quotas', 'sys_audit_log')!).getByRole('combobox') as HTMLButtonElement).disabled).toBe(true); + expect((addPicker('quotas') as HTMLButtonElement).disabled).toBe(true); + const remove = within(rowFor('quotas')).getByRole('button', { name: /mapRemoveRow/ }) as HTMLButtonElement; + expect(remove.disabled).toBe(true); + fireEvent.click(remove); + expect(entryKeys('quotas')).toEqual(['sys_audit_log']); + expect(screen.queryByTestId('settings-unsaved-count')).toBeNull(); + }); +}); diff --git a/ui/apps/console/src/pages/settings/settingsFieldRefusals.ts b/ui/apps/console/src/pages/settings/settingsFieldRefusals.ts index b25730cc11c..9c153851e54 100644 --- a/ui/apps/console/src/pages/settings/settingsFieldRefusals.ts +++ b/ui/apps/console/src/pages/settings/settingsFieldRefusals.ts @@ -29,26 +29,50 @@ import { extractFieldErrors } from '@object-ui/react'; import { resolveLabel, type Specifier } from './types'; import type { SettingsLabelHelpers } from './useSettingsLabel'; +import type { MapRefusalAt } from './SettingsMapField'; /** The machine code of a refusal for a value that misses its declared `pattern`. */ export const INVALID_FORMAT_CODE = 'invalid_format'; type Translate = (key: string, options?: Record) => string; +/** The settings door's `details.fields` entries, as declared (`FieldError[]`), or none. */ +function declaredFieldErrors(apiError: unknown): Array> { + const details = (apiError as { details?: { fields?: unknown } } | null | undefined)?.details; + const fields = details && typeof details === 'object' ? details.fields : undefined; + if (!Array.isArray(fields)) return []; + return fields.filter((entry): entry is Record => !!entry && typeof entry === 'object'); +} + /** The `code` of each refused key, from the settings door's `details.fields`. */ function refusalCodes(apiError: unknown): Map { const codes = new Map(); - const details = (apiError as { details?: { fields?: unknown } } | null | undefined)?.details; - const fields = details && typeof details === 'object' ? details.fields : undefined; - if (!Array.isArray(fields)) return codes; - for (const entry of fields) { - if (!entry || typeof entry !== 'object') continue; - const { field, code } = entry as { field?: unknown; code?: unknown }; + for (const { field, code } of declaredFieldErrors(apiError)) { if (typeof field === 'string' && typeof code === 'string') codes.set(field, code); } return codes; } +/** + * Where each refused `map` key's refusal points (objectstack#31391): the save + * door's `constraint: { type: 'map', entry, column? }` (ADR-0114 discrete + * values), so the row can mark the entry and the cell without parsing the + * sentence. Only `entry` and `column` are read; `allowed` (a comma-joined + * list the sentence already names) and `kind` are left to the sentence. A map + * refusal with no `entry` (`invalid_type`: the value is not keyed rows at all) + * points at no row. + */ +function mapRefusalPointers(apiError: unknown): Map { + const pointers = new Map(); + for (const { field, constraint } of declaredFieldErrors(apiError)) { + if (typeof field !== 'string' || !constraint || typeof constraint !== 'object') continue; + const { type, entry, column } = constraint as { type?: unknown; entry?: unknown; column?: unknown }; + if (type !== 'map' || typeof entry !== 'string') continue; + pointers.set(field, typeof column === 'string' ? { entry, column } : { entry }); + } + return pointers; +} + /** How the page names a field: its label, and the help line it draws under it. */ export interface FieldWording { label: string; @@ -80,12 +104,14 @@ export function settingsFieldRefusals( specifiers: ReadonlyArray, wordingOf: (key: string, spec: Specifier | undefined) => FieldWording, t: Translate, -): Array<{ field: string; message: string }> | null { +): Array<{ field: string; message: string; at?: MapRefusalAt }> | null { const perField = extractFieldErrors(apiError); if (!perField?.length) return null; const codes = refusalCodes(apiError); + const pointers = mapRefusalPointers(apiError); return perField.map(({ field, message }) => { - if (codes.get(field) !== INVALID_FORMAT_CODE) return { field, message }; + const at = pointers.get(field); + if (codes.get(field) !== INVALID_FORMAT_CODE) return at ? { field, message, at } : { field, message }; const spec = specifiers.find((s) => s.key === field); return { field, message: invalidFormatMessage(spec, wordingOf(field, spec), t) }; }); diff --git a/ui/apps/console/src/pages/settings/types.ts b/ui/apps/console/src/pages/settings/types.ts index e2b9a9d7f5b..ba8e329251a 100644 --- a/ui/apps/console/src/pages/settings/types.ts +++ b/ui/apps/console/src/pages/settings/types.ts @@ -34,6 +34,7 @@ export type SpecifierType = | 'slider' | 'color' | 'json' + | 'map' | 'action_button'; export type SpecifierScope = 'global' | 'tenant' | 'user'; @@ -63,6 +64,30 @@ export type SpecifierHandler = | { kind: 'action'; name: string; params?: Record; confirmText?: string } | { kind: 'navigate'; url: string; target?: '_self' | '_blank' }; +/** + * The value kinds a `map` column declares (objectstack#30208). Mirrors + * `SpecifierMapColumnKindSchema`, which is closed: a new kind is an ADR-0007 + * amendment. + */ +export type SpecifierMapColumnKind = 'duration' | 'positive_integer'; + +/** One value column of a `map` specifier. Mirrors `SpecifierMapColumnSchema`. */ +export interface SpecifierMapColumn { + /** The property the cell is stored under when the map has several value columns. */ + name: string; + /** An `I18nLabel`: the default-language string, or an inline locale map. */ + label: string | Record; + description?: string; + kind: SpecifierMapColumnKind; +} + +/** The key column of a `map` specifier: its header only, never a list of keys. */ +export interface SpecifierMapKeyColumn { + /** An `I18nLabel`: the default-language string, or an inline locale map. */ + label: string | Record; + description?: string; +} + export interface Specifier { type: SpecifierType; id?: string; @@ -104,6 +129,17 @@ export interface Specifier { childNamespace?: string; bannerText?: string; bannerSeverity?: 'info' | 'success' | 'warning' | 'error'; + /** + * `map` only (objectstack#30208): the key column's header. The keys a map + * admits are served at read time as this specifier's `options`, from a + * runtime option source; the manifest declares no list. + */ + keyColumn?: SpecifierMapKeyColumn; + /** + * `map` only: the value columns, in display order. One column stores + * `Record`; several store `Record`. + */ + columns?: SpecifierMapColumn[]; } export interface SettingsManifest { diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 4778820b518..4a82dce8008 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2449,6 +2449,17 @@ const ar = { deprecatedBadge: "مهمل", deprecatedBadgeReplacedBy: "مهمل ← {{replacedBy}}", unsupportedSpecifierType: "نوع إعداد غير مدعوم: {{type}}", + mapAddRow: "إضافة صف", + mapRemoveRow: "إزالة {{key}}", + mapKeyNotOffered: "غير متاح", + mapKeyNotOfferedHint: "هذا الإعداد لا يقبل هذا المفتاح حاليًا. سيُرفض الحفظ إلى أن يُزال الصف أو يُعيَّن له مفتاح آخر.", + mapNoRows: "لا توجد صفوف", + mapSearchKey: "بحث…", + mapUnitLabel: "وحدة {{column}}", + mapUnitHours: "ساعات", + mapUnitDays: "أيام", + mapUnitWeeks: "أسابيع", + mapUnitYears: "سنوات", invalidPhone: "أدخل رقم هاتف يتكون من الأرقام والمسافات و + ( ) - . فقط", invalidColor: "أدخل لونًا سداسيًا عشريًا، مثل #1a2b3c", jsonStoredAsText: "هذه القيمة محفوظة كنص وليس ككائن JSON. احفظ لتخزينها ككائن؛ وقد علّمتها الصفحة كتغيير غير محفوظ.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 88f6c78fc28..a223a13090a 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2210,6 +2210,17 @@ const de = { deprecatedBadge: "Veraltet", deprecatedBadgeReplacedBy: "Veraltet → {{replacedBy}}", unsupportedSpecifierType: "Nicht unterstützter Einstellungstyp: {{type}}", + mapAddRow: "Zeile hinzufügen", + mapRemoveRow: "{{key}} entfernen", + mapKeyNotOffered: "Nicht angeboten", + mapKeyNotOfferedHint: "Diesen Schlüssel lässt die Einstellung derzeit nicht zu. Das Speichern wird abgelehnt, bis die Zeile entfernt oder ihr ein anderer Schlüssel zugewiesen wird.", + mapNoRows: "Keine Zeilen", + mapSearchKey: "Suchen…", + mapUnitLabel: "Einheit für {{column}}", + mapUnitHours: "Stunden", + mapUnitDays: "Tage", + mapUnitWeeks: "Wochen", + mapUnitYears: "Jahre", invalidPhone: "Geben Sie eine Telefonnummer nur mit Ziffern, Leerzeichen und + ( ) - . ein", invalidColor: "Geben Sie eine Hex-Farbe ein, z. B. #1a2b3c", jsonStoredAsText: "Dieser Wert ist als Text gespeichert, nicht als JSON-Objekt. Speichern Sie, um ihn als Objekt zu speichern; die Seite hat ihn als ungespeicherte Änderung markiert.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index b3763321161..239a13d497b 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2367,6 +2367,23 @@ const en = { deprecatedBadge: 'Deprecated', deprecatedBadgeReplacedBy: 'Deprecated → {{replacedBy}}', unsupportedSpecifierType: 'Unsupported specifier type: {{type}}', + // The `map` control (objectstack#31391): keyed rows over the keys the server + // serves. `mapAddRow` is the picker that adds a row by its key, `{{key}}` in + // `mapRemoveRow` the row's key as shown; `mapKeyNotOffered` marks a stored key + // the server no longer offers, with `mapKeyNotOfferedHint` as its title; + // `mapUnitLabel` names a duration cell's unit select, `{{column}}` its header, + // and `mapUnitHours`..`mapUnitYears` are the h, d, w and y units it offers. + mapAddRow: 'Add a row', + mapRemoveRow: 'Remove {{key}}', + mapKeyNotOffered: 'Not offered', + mapKeyNotOfferedHint: 'This key is not one the setting admits now. Saving refuses it until the row is removed or given another key.', + mapNoRows: 'No rows', + mapSearchKey: 'Search…', + mapUnitLabel: '{{column}} unit', + mapUnitHours: 'hours', + mapUnitDays: 'days', + mapUnitWeeks: 'weeks', + mapUnitYears: 'years', // A settings value that misses its type's declared format blocks Save // (objectui#13287). `email` and `url` reuse `validation.email` and // `validation.url`; these two name what a phone number and a colour take. diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index be43e2ae712..cfaf376f5f7 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2272,6 +2272,17 @@ const es = { deprecatedBadge: "Obsoleto", deprecatedBadgeReplacedBy: "Obsoleto → {{replacedBy}}", unsupportedSpecifierType: "Tipo de ajuste no admitido: {{type}}", + mapAddRow: "Añadir una fila", + mapRemoveRow: "Quitar {{key}}", + mapKeyNotOffered: "No disponible", + mapKeyNotOfferedHint: "Este ajuste ya no admite esta clave. Al guardar se rechazará hasta que se quite la fila o se le asigne otra clave.", + mapNoRows: "No hay filas", + mapSearchKey: "Buscar…", + mapUnitLabel: "Unidad de {{column}}", + mapUnitHours: "horas", + mapUnitDays: "días", + mapUnitWeeks: "semanas", + mapUnitYears: "años", invalidPhone: "Introduzca un número de teléfono solo con dígitos, espacios y + ( ) - .", invalidColor: "Introduzca un color hexadecimal, como #1a2b3c", jsonStoredAsText: "Este valor está guardado como texto, no como objeto JSON. Guarde para almacenarlo como objeto; la página lo ha marcado como cambio sin guardar.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 6c89b1cb72a..70a8c28dcad 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2268,6 +2268,17 @@ const fr = { deprecatedBadge: "Obsolète", deprecatedBadgeReplacedBy: "Obsolète → {{replacedBy}}", unsupportedSpecifierType: "Type de paramètre non pris en charge : {{type}}", + mapAddRow: "Ajouter une ligne", + mapRemoveRow: "Retirer {{key}}", + mapKeyNotOffered: "Non proposée", + mapKeyNotOfferedHint: "Ce paramètre n'admet plus cette clé. L'enregistrement est refusé tant que la ligne n'est pas retirée ou qu'une autre clé ne lui est pas attribuée.", + mapNoRows: "Aucune ligne", + mapSearchKey: "Rechercher…", + mapUnitLabel: "Unité de {{column}}", + mapUnitHours: "heures", + mapUnitDays: "jours", + mapUnitWeeks: "semaines", + mapUnitYears: "années", invalidPhone: "Saisissez un numéro de téléphone composé uniquement de chiffres, d’espaces et de + ( ) - .", invalidColor: "Saisissez une couleur hexadécimale, par exemple #1a2b3c", jsonStoredAsText: "Cette valeur est enregistrée sous forme de texte, et non comme objet JSON. Enregistrez pour la stocker comme objet ; la page l'a marquée comme modification non enregistrée.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 3941394cddf..3a447b8e938 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2212,6 +2212,17 @@ const ja = { deprecatedBadge: "非推奨", deprecatedBadgeReplacedBy: "非推奨 → {{replacedBy}}", unsupportedSpecifierType: "サポートされていない設定項目の種類: {{type}}", + mapAddRow: "行を追加", + mapRemoveRow: "{{key}} を削除", + mapKeyNotOffered: "選択肢にありません", + mapKeyNotOfferedHint: "このキーは現在この設定で使用できません。行を削除するか別のキーを選ぶまで、保存は拒否されます。", + mapNoRows: "行がありません", + mapSearchKey: "検索…", + mapUnitLabel: "{{column}} の単位", + mapUnitHours: "時間", + mapUnitDays: "日", + mapUnitWeeks: "週", + mapUnitYears: "年", invalidPhone: "数字、スペース、+ ( ) - . のみで電話番号を入力してください", invalidColor: "#1a2b3c のような16進カラーを入力してください", jsonStoredAsText: "この値は JSON オブジェクトではなくテキストとして保存されています。保存するとオブジェクトとして保存されます。ページでは未保存の変更として表示しています。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index cc53e35d426..c372e1351d5 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2210,6 +2210,17 @@ const ko = { deprecatedBadge: "지원 중단됨", deprecatedBadgeReplacedBy: "지원 중단됨 → {{replacedBy}}", unsupportedSpecifierType: "지원되지 않는 설정 항목 유형: {{type}}", + mapAddRow: "행 추가", + mapRemoveRow: "{{key}} 삭제", + mapKeyNotOffered: "선택할 수 없음", + mapKeyNotOfferedHint: "이 키는 현재 이 설정에서 허용되지 않습니다. 행을 삭제하거나 다른 키를 지정할 때까지 저장이 거부됩니다.", + mapNoRows: "행이 없습니다", + mapSearchKey: "검색…", + mapUnitLabel: "{{column}} 단위", + mapUnitHours: "시간", + mapUnitDays: "일", + mapUnitWeeks: "주", + mapUnitYears: "년", invalidPhone: "숫자, 공백, + ( ) - . 만 사용하여 전화번호를 입력해주세요", invalidColor: "#1a2b3c 와 같은 16진수 색상을 입력해주세요", jsonStoredAsText: "이 값은 JSON 객체가 아닌 텍스트로 저장되어 있습니다. 저장하면 객체로 저장됩니다. 페이지에서 저장하지 않은 변경 사항으로 표시했습니다.", diff --git a/ui/packages/i18n/src/locales/pt-BR.ts b/ui/packages/i18n/src/locales/pt-BR.ts index 776b7a10808..7bf5a95587a 100644 --- a/ui/packages/i18n/src/locales/pt-BR.ts +++ b/ui/packages/i18n/src/locales/pt-BR.ts @@ -2267,6 +2267,17 @@ const pt = { deprecatedBadge: "Obsoleto", deprecatedBadgeReplacedBy: "Obsoleto → {{replacedBy}}", unsupportedSpecifierType: "Tipo de configuração não suportado: {{type}}", + mapAddRow: "Adicionar uma linha", + mapRemoveRow: "Remover {{key}}", + mapKeyNotOffered: "Não disponível", + mapKeyNotOfferedHint: "Esta configuração não aceita mais esta chave. Salvar será recusado até que a linha seja removida ou receba outra chave.", + mapNoRows: "Nenhuma linha", + mapSearchKey: "Pesquisar…", + mapUnitLabel: "Unidade de {{column}}", + mapUnitHours: "horas", + mapUnitDays: "dias", + mapUnitWeeks: "semanas", + mapUnitYears: "anos", invalidPhone: "Insira um número de telefone usando apenas dígitos, espaços e + ( ) - .", invalidColor: "Insira uma cor hexadecimal, como #1a2b3c", jsonStoredAsText: "Este valor está salvo como texto, não como objeto JSON. Salve para armazená-lo como objeto; a página o marcou como alteração não salva.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 100e555304a..7d6a9871392 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2336,6 +2336,17 @@ const ru = { deprecatedBadge: "Устарело", deprecatedBadgeReplacedBy: "Устарело → {{replacedBy}}", unsupportedSpecifierType: "Неподдерживаемый тип настройки: {{type}}", + mapAddRow: "Добавить строку", + mapRemoveRow: "Удалить {{key}}", + mapKeyNotOffered: "Недоступно", + mapKeyNotOfferedHint: "Эта настройка сейчас не принимает этот ключ. Сохранение будет отклонено, пока строка не удалена или ей не назначен другой ключ.", + mapNoRows: "Строк нет", + mapSearchKey: "Поиск…", + mapUnitLabel: "Единица: {{column}}", + mapUnitHours: "ч.", + mapUnitDays: "дн.", + mapUnitWeeks: "нед.", + mapUnitYears: "г.", invalidPhone: "Введите номер телефона, используя только цифры, пробелы и + ( ) - .", invalidColor: "Введите цвет в шестнадцатеричном формате, например #1a2b3c", jsonStoredAsText: "Это значение сохранено как текст, а не как JSON-объект. Сохраните, чтобы записать его как объект; страница отметила его как несохранённое изменение.", diff --git a/ui/packages/i18n/src/locales/zh-Hans.ts b/ui/packages/i18n/src/locales/zh-Hans.ts index 166cf85d4f1..08379f70065 100644 --- a/ui/packages/i18n/src/locales/zh-Hans.ts +++ b/ui/packages/i18n/src/locales/zh-Hans.ts @@ -1984,6 +1984,17 @@ const zh = { deprecatedBadge: '已弃用', deprecatedBadgeReplacedBy: '已弃用 → {{replacedBy}}', unsupportedSpecifierType: '不支持的设置项类型:{{type}}', + mapAddRow: '添加一行', + mapRemoveRow: '移除 {{key}}', + mapKeyNotOffered: '不在可选范围', + mapKeyNotOfferedHint: '此设置当前不接受这个键。删除这一行或改选其他键之前,保存会被拒绝。', + mapNoRows: '暂无行', + mapSearchKey: '搜索…', + mapUnitLabel: '{{column}}的单位', + mapUnitHours: '小时', + mapUnitDays: '天', + mapUnitWeeks: '周', + mapUnitYears: '年', invalidPhone: '请输入仅由数字、空格和 + ( ) - . 组成的电话号码', invalidColor: '请输入十六进制颜色,例如 #1a2b3c', jsonStoredAsText: '该值以文本形式保存,而不是 JSON 对象。保存后将改存为对象;页面已将其标记为未保存的更改。', diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index 73bfd02a4fd..e79f2f302a2 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -1984,6 +1984,17 @@ const zhHant = { deprecatedBadge: '已棄用', deprecatedBadgeReplacedBy: '已棄用 → {{replacedBy}}', unsupportedSpecifierType: '不支援的設定項型別:{{type}}', + mapAddRow: '新增一列', + mapRemoveRow: '移除 {{key}}', + mapKeyNotOffered: '不在可選範圍', + mapKeyNotOfferedHint: '此設定目前不接受這個鍵。刪除這一列或改選其他鍵之前,儲存會被拒絕。', + mapNoRows: '暫無資料列', + mapSearchKey: '搜尋…', + mapUnitLabel: '{{column}}的單位', + mapUnitHours: '小時', + mapUnitDays: '天', + mapUnitWeeks: '週', + mapUnitYears: '年', invalidPhone: '請輸入僅由數字、空格和 + ( ) - . 組成的電話號碼', invalidColor: '請輸入十六進位制顏色,例如 #1a2b3c', jsonStoredAsText: '該值以文本形式儲存,而不是 JSON 物件。儲存後將改存為物件;頁面已將其標記為未儲存的更改。', From b2de2ef657fc68331b89c2cdc18b2a984bdce389 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 07:33:21 +0000 Subject: [PATCH 2/4] fix(console): name the map key pickers by the key column header Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUhVXEJm46XTxRs4GM --- .../console/src/pages/settings/SettingsMapField.tsx | 7 ++++++- .../__tests__/SettingsView.mapRows-31391.test.tsx | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ui/apps/console/src/pages/settings/SettingsMapField.tsx b/ui/apps/console/src/pages/settings/SettingsMapField.tsx index a61951b66c0..b56fdaae6ae 100644 --- a/ui/apps/console/src/pages/settings/SettingsMapField.tsx +++ b/ui/apps/console/src/pages/settings/SettingsMapField.tsx @@ -296,6 +296,9 @@ export function SettingsMapField({ const unused = served.filter((o) => !usedKeys.has(o.value)); const keyHeader = pickLocalized(spec.keyColumn?.label, language); const columnLabels = columns.map((c) => pickLocalized(c.label, language)); + // Each row's key picker is named by the key column's header: a combobox + // takes its name from its label, never from the option it shows. + const keyHeaderId = `${labelId}-key`; const searchText = t('console.settingsView.mapSearchKey'); const emptyText = t('common.noResults'); @@ -304,7 +307,7 @@ export function SettingsMapField({ - {keyHeader} + {keyHeader} {columns.map((c, i) => ( {columnLabels[i]} @@ -346,6 +349,7 @@ export function SettingsMapField({ disabled={disabled} searchPlaceholder={searchText} emptyText={emptyText} + aria-labelledby={keyHeaderId} aria-invalid={keyMarked || undefined} aria-describedby={keyMarked ? describedBy : undefined} className={cn('w-56', keyMarked && 'border-destructive')} @@ -409,6 +413,7 @@ export function SettingsMapField({ value="" disabled={disabled || unused.length === 0} placeholder={t('console.settingsView.mapAddRow')} + aria-label={t('console.settingsView.mapAddRow')} searchPlaceholder={searchText} emptyText={emptyText} className="w-56" diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx index d32148fa4c7..376a903c377 100644 --- a/ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx @@ -156,7 +156,8 @@ describe('SettingsView — a `map` setting draws its stored rows (objectstack#31 expect(entryKeys('quotas')).toEqual(['sys_job_run', 'sys_audit_log']); expect(cell('quotas', 'sys_audit_log', 'Max rows').value).toBe('1000000'); // The key cell shows the served label, the header the declared key column. - expect(within(entryRow('quotas', 'sys_audit_log')!).getByRole('combobox').textContent).toContain('Audit log'); + const keyPicker = within(entryRow('quotas', 'sys_audit_log')!).getByRole('combobox', { name: 'Object' }); + expect(keyPicker.textContent).toContain('Audit log'); expect(within(rowFor('quotas')).getByRole('columnheader', { name: 'Object' })).toBeTruthy(); // The row is drawn as keyed rows, never as the unsupported-type fallback. expect(rowFor('quotas').textContent).not.toMatch(/unsupportedSpecifierType/); @@ -288,7 +289,7 @@ describe('SettingsView — a refused map save marks the row it names (objectstac await waitFor(() => expect(entryRow('quotas', 'sys_audit_logs')!.dataset.invalid).toBe('true')); expect(entryRow('quotas', 'sys_job_run')!.dataset.invalid).toBeUndefined(); - const keyPicker = within(entryRow('quotas', 'sys_audit_logs')!).getByRole('combobox'); + const keyPicker = within(entryRow('quotas', 'sys_audit_logs')!).getByRole('combobox', { name: 'Object' }); expect(keyPicker.getAttribute('aria-invalid')).toBe('true'); const alert = rowFor('quotas').querySelector('[role="alert"]')!; expect(alert.textContent).toBe(sentence); @@ -318,7 +319,7 @@ describe('SettingsView — a refused map save marks the row it names (objectstac expect(cell('retention_overrides', 'sys_job_run', 'Expire rows after').getAttribute('aria-invalid')).toBe('true'); expect(cell('retention_overrides', 'sys_job_run', 'Keep rows for').getAttribute('aria-invalid')).toBeNull(); expect( - within(entryRow('retention_overrides', 'sys_job_run')!).getByRole('combobox', { name: /Job run/ }).getAttribute('aria-invalid'), + within(entryRow('retention_overrides', 'sys_job_run')!).getByRole('combobox', { name: 'Object' }).getAttribute('aria-invalid'), ).toBeNull(); }); @@ -357,7 +358,8 @@ describe('SettingsView — a locked map (objectstack#31391)', () => { await screen.findByText('Row quotas'); expect(cell('quotas', 'sys_audit_log', 'Max rows').disabled).toBe(true); - expect((within(entryRow('quotas', 'sys_audit_log')!).getByRole('combobox') as HTMLButtonElement).disabled).toBe(true); + const keyPicker = within(entryRow('quotas', 'sys_audit_log')!).getByRole('combobox', { name: 'Object' }); + expect((keyPicker as HTMLButtonElement).disabled).toBe(true); expect((addPicker('quotas') as HTMLButtonElement).disabled).toBe(true); const remove = within(rowFor('quotas')).getByRole('button', { name: /mapRemoveRow/ }) as HTMLButtonElement; expect(remove.disabled).toBe(true); From c0143293f964a1ec0d41b0291d236b2018f0661e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 07:34:38 +0000 Subject: [PATCH 3/4] chore: changeset for the map settings rows Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUhVXEJm46XTxRs4GM --- .changeset/31391-settings-map-keyed-rows.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/31391-settings-map-keyed-rows.md diff --git a/.changeset/31391-settings-map-keyed-rows.md b/.changeset/31391-settings-map-keyed-rows.md new file mode 100644 index 00000000000..e83657da5b3 --- /dev/null +++ b/.changeset/31391-settings-map-keyed-rows.md @@ -0,0 +1,12 @@ +--- +'@object-ui/console': patch +'@object-ui/i18n': patch +--- + +feat(console): Setup draws a `map` setting as a table of keyed rows instead of "Unsupported specifier type: map" + +Clause-②: yes +Metadata-protocol: none + +- **`@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. +- **`@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. From ba2bb3793d9ad5c3df3bf20bbeb697fae49048a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 07:59:29 +0000 Subject: [PATCH 4/4] chore(i18n): regenerate zh-Hant from zh-Hans for the map keys Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUhVXEJm46XTxRs4GM --- ui/packages/i18n/src/locales/zh-Hant.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index e79f2f302a2..c75369cf408 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -1984,16 +1984,16 @@ const zhHant = { deprecatedBadge: '已棄用', deprecatedBadgeReplacedBy: '已棄用 → {{replacedBy}}', unsupportedSpecifierType: '不支援的設定項型別:{{type}}', - mapAddRow: '新增一列', + mapAddRow: '新增一行', mapRemoveRow: '移除 {{key}}', mapKeyNotOffered: '不在可選範圍', - mapKeyNotOfferedHint: '此設定目前不接受這個鍵。刪除這一列或改選其他鍵之前,儲存會被拒絕。', - mapNoRows: '暫無資料列', + mapKeyNotOfferedHint: '此設定當前不接受這個鍵。刪除這一行或改選其他鍵之前,儲存會被拒絕。', + mapNoRows: '暫無行', mapSearchKey: '搜尋…', mapUnitLabel: '{{column}}的單位', mapUnitHours: '小時', mapUnitDays: '天', - mapUnitWeeks: '週', + mapUnitWeeks: '周', mapUnitYears: '年', invalidPhone: '請輸入僅由數字、空格和 + ( ) - . 組成的電話號碼', invalidColor: '請輸入十六進位制顏色,例如 #1a2b3c',