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.
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..b56fdaae6ae
--- /dev/null
+++ b/ui/apps/console/src/pages/settings/SettingsMapField.tsx
@@ -0,0 +1,427 @@
+// 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 unit chosen before a number is typed waits for the number.
+ if (amount !== '') onCell(`${amount}${next}`);
+ }}
+ >
+
+
+
+
+ {DURATION_UNITS.map((u) => (
+
+ {t(UNIT_LABEL_KEYS[u])}
+
+ ))}
+
+
+
+ );
+}
+
+/** 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));
+ // 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');
+
+ return (
+
+
+
+
+
+ {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' ? : }
+
+ );
+ })}
+
+ commit(rows.filter((r) => r.id !== row.id))}
+ >
+
+
+
+
+ );
+ })}
+
+
+
{
+ 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..376a903c377
--- /dev/null
+++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.mapRows-31391.test.tsx
@@ -0,0 +1,370 @@
+// 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.
+ 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/);
+ });
+
+ 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', { name: 'Object' });
+ 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: 'Object' }).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);
+ 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);
+ 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..c75369cf408 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 物件。儲存後將改存為物件;頁面已將其標記為未儲存的更改。',