diff --git a/.changeset/31453-settings-map-column-translations.md b/.changeset/31453-settings-map-column-translations.md new file mode 100644 index 00000000000..e10101a407a --- /dev/null +++ b/.changeset/31453-settings-map-column-translations.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +'@objectstack/service-settings': patch +'@object-ui/console': patch +--- + +feat: a settings `map` specifier's column headers can be translated, and Data Lifecycle's three map tables ship translated headers + +Clause-②: yes (widening) +Metadata-protocol: none + +- `PlatformTranslationDataSchema` (`@objectstack/spec/system`): the platform bundle's `settings..keys.` entry gains two optional groups, spelled as the `map` specifier spells its headers. `keyColumn` translates the key column and `columns.` each value column, keyed by the column's `name`. Each carries `label` (the header) and `help` (the column's `description`, shown as the header's hover text), and is strict like its siblings: an undeclared leaf is refused, with `description` → `help` and `title` → `label` named, and `headers` / `columnLabels` → `columns`, `keyHeader` → `keyColumn` named on the setting entry. A bundle written before this change parses exactly as it did. `settings` stays platform-only, so no application bundle can write these groups. +- `@objectstack/service-settings`: every shipped non-English bundle (zh-CN, zh-Hant, ja-JP, ko, es-ES, pt-BR, fr, de, ru, ar) translates the five headers of `lifecycle.retention_overrides`, `lifecycle.quotas` and `lifecycle.quota_defaults` (`Object`, `Keep rows for`, `Expire rows after`, `Max rows`, `Lifecycle class`). The settings translation coverage test now holds each `map` header to the same per-locale coverage as the setting's label, and refuses header copy for a setting that is not a map or a column the map does not declare. +- `@object-ui/console`: the Settings page's map table reads the two groups. A header with no translation keeps the manifest's text, as every other settings string does. The key column now shows its declared `description` as hover text, as the value columns already did. +- Stored values, the save door and the manifest shape do not change. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 476b4d1dd44..01d92399a46 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 399 | +| `system/` | 400 | diff --git a/packages/services/service-settings/src/translations/ar.ts b/packages/services/service-settings/src/translations/ar.ts index 4fb63d286a6..517c41f2d58 100644 --- a/packages/services/service-settings/src/translations/ar.ts +++ b/packages/services/service-settings/src/translations/ar.ts @@ -700,14 +700,20 @@ export const ar: PlatformTranslationData = { retention_overrides: { label: 'تجاوزات الاحتفاظ', help: 'تجاوزات النافذة لكل كائن. تستخدم المدد h/d/w/y. تُضبط لكل مؤسسة، فتحتفظ المؤسسة الخاضعة للتنظيم بسنوات بينما تحتفظ مؤسسة التطوير بأيام. يُتجاهَل التجاوز الأقصر من الحد الأدنى الذي تحتاجه ميزة في المنصة (مثل نافذة كشف التكرار في قائمة انتظار المهام) ويُسجَّل خطأً، وتظل النافذة الافتراضية سارية.', + keyColumn: { label: 'الكائن' }, + columns: { maxAge: { label: 'الاحتفاظ بالصفوف لمدة' }, expireAfter: { label: 'انتهاء صلاحية الصفوف بعد' } }, }, quotas: { label: 'حصص الصفوف', help: 'الحد الأقصى لعدد الصفوف لكل كائن. يؤدي التجاوز إلى تنبيه حوكمة — ولا تحذف الحصص أبدًا ما يتجاوز السياسة المعلنة.', + keyColumn: { label: 'الكائن' }, + columns: { max_rows: { label: 'الحد الأقصى للصفوف' } }, }, quota_defaults: { label: 'الحصص الافتراضية حسب الفئة', help: 'حصص احتياطية حسب فئة دورة الحياة (telemetry, transient, event, audit).', + keyColumn: { label: 'فئة دورة الحياة' }, + columns: { max_rows: { label: 'الحد الأقصى للصفوف' } }, }, growth_alert_rows: { label: 'حد تنبيه النمو (صفوف لكل جولة)', diff --git a/packages/services/service-settings/src/translations/de.ts b/packages/services/service-settings/src/translations/de.ts index 093bbf7ed9f..03c439cd719 100644 --- a/packages/services/service-settings/src/translations/de.ts +++ b/packages/services/service-settings/src/translations/de.ts @@ -745,14 +745,20 @@ export const de: PlatformTranslationData = { retention_overrides: { label: 'Aufbewahrungs-Überschreibungen', help: 'Fensterüberschreibungen pro Objekt. Dauern verwenden h/d/w/y. Pro Organisation gesetzt, sodass eine regulierte Organisation Jahre aufbewahren kann, eine Entwicklungsorganisation nur Tage. Eine Überschreibung, die kürzer ist als das Minimum, das eine Plattformfunktion braucht (etwa das Duplikaterkennungsfenster der Job-Warteschlange), wird ignoriert und als Fehler protokolliert; das Standardfenster gilt weiter.', + keyColumn: { label: 'Objekt' }, + columns: { maxAge: { label: 'Zeilen aufbewahren für' }, expireAfter: { label: 'Zeilen ablaufen lassen nach' } }, }, quotas: { label: 'Zeilenkontingente', help: 'Maximale Zeilenanzahl pro Objekt. Eine Überschreitung löst eine Governance-Warnung aus — Kontingente löschen nie über die deklarierte Richtlinie hinaus.', + keyColumn: { label: 'Objekt' }, + columns: { max_rows: { label: 'Max. Zeilen' } }, }, quota_defaults: { label: 'Standardkontingente pro Klasse', help: 'Ersatzkontingente nach Lebenszyklusklasse (telemetry, transient, event, audit).', + keyColumn: { label: 'Lebenszyklusklasse' }, + columns: { max_rows: { label: 'Max. Zeilen' } }, }, growth_alert_rows: { label: 'Wachstums-Warnschwelle (Zeilen pro Durchlauf)', diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index f5e6ec23b2d..a58afd1435d 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -597,14 +597,20 @@ export const esES: PlatformTranslationData = { + 'Se define por organización, de modo que una organización regulada puede conservar años y una de desarrollo, días. ' + 'Una sustitución más corta que el mínimo que necesita una función de la plataforma (por ejemplo, la ventana de detección de duplicados de la cola de trabajos) ' + 'se ignora y se registra como error, y se sigue aplicando la ventana predeterminada.', + keyColumn: { label: 'Objeto' }, + columns: { maxAge: { label: 'Conservar filas durante' }, expireAfter: { label: 'Caducar filas tras' } }, }, quotas: { label: 'Cuotas de filas', help: 'Número máximo de filas por objeto. Superarlo genera una alerta de gobernanza: las cuotas nunca eliminan más allá de la política declarada.', + keyColumn: { label: 'Objeto' }, + columns: { max_rows: { label: 'Máx. de filas' } }, }, quota_defaults: { label: 'Cuotas predeterminadas por clase', help: 'Cuotas de respaldo por clase de ciclo de vida (telemetry, transient, event, audit).', + keyColumn: { label: 'Clase de ciclo de vida' }, + columns: { max_rows: { label: 'Máx. de filas' } }, }, growth_alert_rows: { label: 'Umbral de alerta de crecimiento (filas por barrido)', diff --git a/packages/services/service-settings/src/translations/fr.ts b/packages/services/service-settings/src/translations/fr.ts index c99d71fbbb1..b29076891db 100644 --- a/packages/services/service-settings/src/translations/fr.ts +++ b/packages/services/service-settings/src/translations/fr.ts @@ -754,14 +754,20 @@ export const fr: PlatformTranslationData = { retention_overrides: { label: 'Surcharges de rétention', help: 'Surcharges de fenêtre par objet. Les durées utilisent h/d/w/y. Définies par organisation : une organisation réglementée peut conserver des années, une organisation de développement quelques jours. Une surcharge plus courte que le minimum requis par une fonction de la plateforme (par exemple la fenêtre de détection des doublons de la file de tâches) est ignorée et journalisée comme erreur, et la fenêtre par défaut continue de s’appliquer.', + keyColumn: { label: 'Objet' }, + columns: { maxAge: { label: 'Conserver les lignes pendant' }, expireAfter: { label: 'Faire expirer les lignes après' } }, }, quotas: { label: 'Quotas de lignes', help: 'Nombre maximal de lignes par objet. Un dépassement déclenche une alerte de gouvernance — les quotas ne suppriment jamais au-delà de la politique déclarée.', + keyColumn: { label: 'Objet' }, + columns: { max_rows: { label: 'Lignes max.' } }, }, quota_defaults: { label: 'Quotas par défaut par classe', help: 'Quotas de repli par classe de cycle de vie (telemetry, transient, event, audit).', + keyColumn: { label: 'Classe de cycle de vie' }, + columns: { max_rows: { label: 'Lignes max.' } }, }, growth_alert_rows: { label: 'Seuil d’alerte de croissance (lignes par passage)', diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index 84656112775..cb7d99861c9 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -594,14 +594,20 @@ export const jaJP: PlatformTranslationData = { help: 'オブジェクトごとの保持ウィンドウの上書き。期間は h/d/w/y で指定します。' + '組織ごとに設定するため、規制対象の組織は数年、開発用の組織は数日だけ保持できます。' + 'プラットフォーム機能が必要とする最小期間(例:ジョブキューの重複検出ウィンドウ)より短い上書きは無視されてエラーとして記録され、既定のウィンドウが引き続き適用されます。', + keyColumn: { label: 'オブジェクト' }, + columns: { maxAge: { label: '行の保持期間' }, expireAfter: { label: '行の失効までの期間' } }, }, quotas: { label: '行数クォータ', help: 'オブジェクトごとの最大行数。超過するとガバナンスアラートが発生します。クォータが宣言済みのポリシーを超えて削除することはありません。', + keyColumn: { label: 'オブジェクト' }, + columns: { max_rows: { label: '最大行数' } }, }, quota_defaults: { label: 'クラス別の既定クォータ', help: 'ライフサイクルクラスごとのフォールバッククォータ(telemetry、transient、event、audit)。', + keyColumn: { label: 'ライフサイクルクラス' }, + columns: { max_rows: { label: '最大行数' } }, }, growth_alert_rows: { label: '増加アラートのしきい値(クリーンアップ 1 回あたりの行数)', diff --git a/packages/services/service-settings/src/translations/ko.ts b/packages/services/service-settings/src/translations/ko.ts index dcc11d97119..21a84160867 100644 --- a/packages/services/service-settings/src/translations/ko.ts +++ b/packages/services/service-settings/src/translations/ko.ts @@ -560,14 +560,20 @@ export const ko: PlatformTranslationData = { retention_overrides: { label: '보존 기간 재정의', help: '객체별 보존 기간 재정의입니다. 기간 단위는 h/d/w/y입니다. 조직별로 설정하므로 규제 대상 조직은 수년, 개발용 조직은 며칠만 보관할 수 있습니다. 플랫폼 기능에 필요한 최소 기간(예: 작업 대기열의 중복 감지 기간)보다 짧은 재정의는 무시되고 오류로 기록되며, 기본 기간이 계속 적용됩니다.', + keyColumn: { label: '객체' }, + columns: { maxAge: { label: '행 보존 기간' }, expireAfter: { label: '행 만료 기간' } }, }, quotas: { label: '행 할당량', help: '객체별 최대 행 수입니다. 초과하면 거버넌스 알림이 발생하며, 할당량은 선언된 정책을 넘어 데이터를 삭제하지 않습니다.', + keyColumn: { label: '객체' }, + columns: { max_rows: { label: '최대 행 수' } }, }, quota_defaults: { label: '클래스별 기본 할당량', help: '수명 주기 클래스별 대체 할당량입니다 (telemetry, transient, event, audit).', + keyColumn: { label: '수명 주기 클래스' }, + columns: { max_rows: { label: '최대 행 수' } }, }, growth_alert_rows: { label: '증가 알림 임계값(정리당 행 수)', diff --git a/packages/services/service-settings/src/translations/pt-BR.ts b/packages/services/service-settings/src/translations/pt-BR.ts index 1b1b76a3dce..b047fa67185 100644 --- a/packages/services/service-settings/src/translations/pt-BR.ts +++ b/packages/services/service-settings/src/translations/pt-BR.ts @@ -738,14 +738,20 @@ export const ptBR: PlatformTranslationData = { retention_overrides: { label: 'Substituições de retenção', help: 'Substituições de janela por objeto. As durações usam h/d/w/y. Definidas por organização, para que uma organização regulada guarde anos e uma de desenvolvimento guarde dias. Uma substituição mais curta que o mínimo exigido por um recurso da plataforma (por exemplo, a janela de detecção de duplicatas da fila de tarefas) é ignorada e registrada como erro, e a janela padrão continua valendo.', + keyColumn: { label: 'Objeto' }, + columns: { maxAge: { label: 'Manter linhas por' }, expireAfter: { label: 'Expirar linhas após' } }, }, quotas: { label: 'Cotas de linhas', help: 'Quantidade máxima de linhas por objeto. Ultrapassar gera um alerta de governança — as cotas nunca excluem além da política declarada.', + keyColumn: { label: 'Objeto' }, + columns: { max_rows: { label: 'Máx. de linhas' } }, }, quota_defaults: { label: 'Cotas padrão por classe', help: 'Cotas de reserva por classe de ciclo de vida (telemetry, transient, event, audit).', + keyColumn: { label: 'Classe de ciclo de vida' }, + columns: { max_rows: { label: 'Máx. de linhas' } }, }, growth_alert_rows: { label: 'Limite de alerta de crescimento (linhas por varredura)', diff --git a/packages/services/service-settings/src/translations/ru.ts b/packages/services/service-settings/src/translations/ru.ts index 4a03f8825ee..8eafce07d81 100644 --- a/packages/services/service-settings/src/translations/ru.ts +++ b/packages/services/service-settings/src/translations/ru.ts @@ -748,14 +748,20 @@ export const ru: PlatformTranslationData = { retention_overrides: { label: 'Переопределения хранения', help: 'Переопределения срока для объектов. Длительности задаются в h/d/w/y. Задаются для каждой организации, так что регулируемая организация может хранить годы, а организация для разработки — дни. Переопределение короче минимума, нужного функции платформы (например, окна обнаружения дубликатов очереди заданий), игнорируется и записывается как ошибка, а срок по умолчанию продолжает действовать.', + keyColumn: { label: 'Объект' }, + columns: { maxAge: { label: 'Хранить строки в течение' }, expireAfter: { label: 'Срок истечения строк' } }, }, quotas: { label: 'Квоты строк', help: 'Максимум строк для объекта. Превышение вызывает оповещение управления — квоты никогда не удаляют сверх объявленной политики.', + keyColumn: { label: 'Объект' }, + columns: { max_rows: { label: 'Макс. строк' } }, }, quota_defaults: { label: 'Квоты по умолчанию для классов', help: 'Резервные квоты по классу жизненного цикла (telemetry, transient, event, audit).', + keyColumn: { label: 'Класс жизненного цикла' }, + columns: { max_rows: { label: 'Макс. строк' } }, }, growth_alert_rows: { label: 'Порог оповещения о росте (строк за проход)', diff --git a/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts b/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts index f4aaef16c30..8e3d82547ba 100644 --- a/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts +++ b/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts @@ -12,10 +12,11 @@ * This test pins each shipped non-English locale to full coverage of every * manifest's structural strings — namespace title/description, each group * title, each field label, each `info_banner`'s title and text (keyed by - * the banner's `id` under `settings..banners`), and each + * the banner's `id` under `settings..banners`), each * `action_button`'s label (keyed by the button's `id` under - * `settings..actions`). Dropdown *option* labels are intentionally - * excluded: several must not be "translated" (the `timezone` option "UTC" is a + * `settings..actions`), and each `map` setting's table headers + * (`keys..keyColumn.label`, `keys..columns..label`). + * Dropdown *option* labels are intentionally excluded: several must not be "translated" (the `timezone` option "UTC" is a * code; each `locale` option names its language in that language), and * option-level parity is filled per-locale on a best-effort basis. */ @@ -94,6 +95,14 @@ function missingFor(data: { settings?: Record }, m: SettingsManifes if (s.confirmOffText !== undefined && tr?.keys?.[s.key]?.confirmOffText == null) { missing.push(`key:${s.key}.confirmOffText`); } + // A map's table headers are the map's own copy, held to the label's + // coverage: an untranslated one heads the table in English (#31453). + if (s.type === 'map') { + if (s.keyColumn && tr?.keys?.[s.key]?.keyColumn?.label == null) missing.push(`key:${s.key}.keyColumn`); + for (const c of s.columns ?? []) { + if (tr?.keys?.[s.key]?.columns?.[c.name]?.label == null) missing.push(`key:${s.key}.columns.${c.name}`); + } + } } } return missing; @@ -116,6 +125,7 @@ function undeclaredIn(data: { settings?: Record }): string[] { const groups = new Set(); const keys = new Set(); const confirmsOff = new Set(); + const mapColumns = new Map>(); const banners = new Set(); const actions = new Set(); for (const s of m.specifiers) { @@ -128,6 +138,7 @@ function undeclaredIn(data: { settings?: Record }): string[] { } else if (s.key) { keys.add(s.key); if (s.confirmOffText !== undefined) confirmsOff.add(s.key); + if (s.type === 'map') mapColumns.set(s.key, new Set((s.columns ?? []).map((c) => c.name))); } } for (const g of Object.keys(tr?.groups ?? {})) if (!groups.has(g)) extra.push(`${ns}.group:${g}`); @@ -136,6 +147,15 @@ function undeclaredIn(data: { settings?: Record }): string[] { if (keys.has(k) && (v as { confirmOffText?: unknown })?.confirmOffText != null && !confirmsOff.has(k)) { extra.push(`${ns}.key:${k}.confirmOffText`); } + // Headers for a setting that is not a map, or for a column the map does + // not declare, head no table. + const entry = v as { keyColumn?: unknown; columns?: Record }; + if (!keys.has(k)) continue; + const declared = mapColumns.get(k); + if (entry?.keyColumn != null && !declared) extra.push(`${ns}.key:${k}.keyColumn`); + for (const c of Object.keys(entry?.columns ?? {})) { + if (!declared?.has(c)) extra.push(`${ns}.key:${k}.columns.${c}`); + } } for (const b of Object.keys(tr?.banners ?? {})) if (!banners.has(b)) extra.push(`${ns}.banner:${b}`); for (const a of Object.keys(tr?.actions ?? {})) if (!actions.has(a)) extra.push(`${ns}.action:${a}`); diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 20fe0174ea3..8f1eb61c145 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -612,14 +612,20 @@ export const zhCN: PlatformTranslationData = { help: '按对象覆盖保留窗口。时长单位为 h/d/w/y。' + '按组织设置,因此受监管的组织可以保留数年,开发用的组织只保留数天。' + '短于某项平台功能所需最短窗口(例如作业队列的重复检测窗口)的覆盖会被忽略并记为错误,继续沿用默认窗口。', + keyColumn: { label: '对象' }, + columns: { maxAge: { label: '行保留时长' }, expireAfter: { label: '行过期时长' } }, }, quotas: { label: '行数配额', help: '按对象的最大行数。超出时触发治理告警——配额绝不会删除已声明策略之外的数据。', + keyColumn: { label: '对象' }, + columns: { max_rows: { label: '最大行数' } }, }, quota_defaults: { label: '按类别的默认配额', help: '按生命周期类别的兜底配额(telemetry、transient、event、audit)。', + keyColumn: { label: '生命周期类别' }, + columns: { max_rows: { label: '最大行数' } }, }, growth_alert_rows: { label: '增长告警阈值(每次清理的行数)', diff --git a/packages/services/service-settings/src/translations/zh-Hant.ts b/packages/services/service-settings/src/translations/zh-Hant.ts index e052211e730..e5bb130465d 100644 --- a/packages/services/service-settings/src/translations/zh-Hant.ts +++ b/packages/services/service-settings/src/translations/zh-Hant.ts @@ -490,11 +490,20 @@ export const zhHant: PlatformTranslationData = { retention_overrides: { label: '保留期覆寫', help: '依物件覆寫保留期。時長使用 h/d/w/y。依組織設定,因此受監管的組織可保留數年,開發用組織只保留數天。短於某項平台功能所需最短期限(例如工作佇列的重複偵測期限)的覆寫值會被忽略並記錄為錯誤,繼續套用預設期限。', + keyColumn: { label: '物件' }, + columns: { maxAge: { label: '列保留期限' }, expireAfter: { label: '列到期期限' } }, + }, + quotas: { + label: '列數配額', + help: '依物件的最大列數。超出時會發出治理警示——配額絕不會刪除超出已宣告原則的資料。', + keyColumn: { label: '物件' }, + columns: { max_rows: { label: '最大列數' } }, }, - quotas: { label: '列數配額', help: '依物件的最大列數。超出時會發出治理警示——配額絕不會刪除超出已宣告原則的資料。' }, quota_defaults: { label: '依類別的預設配額', help: '依生命週期類別的備援配額(telemetry、transient、event、audit)。', + keyColumn: { label: '生命週期類別' }, + columns: { max_rows: { label: '最大列數' } }, }, growth_alert_rows: { label: '成長警示門檻(每次清理的列數)', help: '宣告了生命週期的資料表在兩次清理之間增加超過這麼多列時發出警示。0 表示停用成長警示。' }, }, diff --git a/packages/spec/src/system/settings-manifest.zod.ts b/packages/spec/src/system/settings-manifest.zod.ts index 1764a6c7e81..55dfe9ff664 100644 --- a/packages/spec/src/system/settings-manifest.zod.ts +++ b/packages/spec/src/system/settings-manifest.zod.ts @@ -604,10 +604,17 @@ export const SpecifierSchema = lazySchema(() => z.object({ * key against it, and every manifest read serves them as this specifier's * `options`. Without a source, a declared `options` table is the fallback, * as for a `select`; without either, any key is admitted. + * + * Translated under `settings..keys..keyColumn` (`label`, and + * `description` as `help`), beside the map's own `label`. */ keyColumn: SpecifierMapKeyColumnSchema.optional().describe('Key column (map)'), - /** `map`: the value columns, in display order. See {@link keyColumn} for the stored shape. */ + /** + * `map`: the value columns, in display order. See {@link keyColumn} for the + * stored shape. Translated under `settings..keys..columns.` + * (`label`, and `description` as `help`), keyed by each column's `name`. + */ columns: z.array(SpecifierMapColumnSchema).optional().describe('Value columns (map): one stores a scalar per key, several an object'), /** diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 7bbb04e7517..a1eeb26e307 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -13,6 +13,7 @@ import { TranslationBundleSchema, PlatformTranslationDataSchema, PlatformTranslationBundleSchema, + ServedTranslationDataSchema, defineTranslationBundle, LocaleSchema, FieldTranslationSchema, @@ -1845,6 +1846,70 @@ describe('translation unknown-key strictness', () => { expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); expect(result.data?.settings?.auth?.keys?.email_password_enabled?.confirmOffText).toBe('Sure?'); }); + + describe("a map setting's column headers (#31453)", () => { + // Spelled as the `map` specifier spells them: `keyColumn` is the key + // column, `columns` the value columns keyed by column `name`; each + // carries the column's `label` and its `description` as `help`. + const keys = (entry: Record) => ({ lifecycle: { keys: { retention_overrides: entry } } }); + const issuesOf = (entry: Record) => + PlatformTranslationDataSchema.safeParse({ settings: keys(entry) }).error?.issues ?? []; + + it('carries the key column and each value column beside the setting label', () => { + const entry = { + label: '保留期覆盖', + keyColumn: { label: '对象', help: '要覆盖的对象' }, + columns: { maxAge: { label: '保留时长' }, expireAfter: { label: '过期时长', help: '行过期前的时长' } }, + }; + const result = PlatformTranslationDataSchema.safeParse({ settings: keys(entry) }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + const parsed = result.data?.settings?.lifecycle?.keys?.retention_overrides; + expect(parsed?.keyColumn).toEqual({ label: '对象', help: '要覆盖的对象' }); + expect(parsed?.columns?.expireAfter).toEqual({ label: '过期时长', help: '行过期前的时长' }); + // The served document and the platform bundle read the same shape. + expect(ServedTranslationDataSchema.safeParse({ settings: keys(entry) }).success).toBe(true); + expect(PlatformTranslationBundleSchema.safeParse({ 'zh-CN': { settings: keys(entry) } }).success).toBe(true); + }); + + it.each([ + ['headers', 'columns'], + ['columnLabels', 'columns'], + ['keyHeader', 'keyColumn'], + ])('refuses `%s` on the setting entry, naming `%s`', (wrong, right) => { + const issue = issuesOf({ [wrong]: {} }).find((i) => i.code === 'unrecognized_keys'); + expect(issue?.message).toContain(`\`${wrong}\` → \`${right}\``); + }); + + it('is strict on a column, as the setting entry is: an undeclared leaf is refused', () => { + const inColumn = issuesOf({ columns: { maxAge: { label: 'Keep', description: 'x' } } }) + .find((i) => i.code === 'unrecognized_keys'); + expect(inColumn?.message).toContain('`description` → `help`'); + expect(inColumn?.path).toEqual(['settings', 'lifecycle', 'keys', 'retention_overrides', 'columns', 'maxAge']); + const inKey = issuesOf({ keyColumn: { title: 'Object' } }).find((i) => i.code === 'unrecognized_keys'); + expect(inKey?.message).toContain('`title` → `label`'); + expect(inKey?.path).toEqual(['settings', 'lifecycle', 'keys', 'retention_overrides', 'keyColumn']); + }); + + it('refuses a bare string where a column entry is an object', () => { + // A column header is two strings, not one: a plain string would leave + // no place for its `help`, so the shape refuses it rather than coerce. + const at = ['settings', 'lifecycle', 'keys', 'retention_overrides']; + expect(issuesOf({ keyColumn: 'Object' }).map((i) => [i.code, i.path])) + .toEqual([['invalid_type', [...at, 'keyColumn']]]); + expect(issuesOf({ columns: { maxAge: 'Keep rows for' } }).map((i) => [i.code, i.path])) + .toEqual([['invalid_type', [...at, 'columns', 'maxAge']]]); + }); + + it("refuses a specifier column pasted in whole, saying where its `name` goes instead of renaming it", () => { + // `name` is the record key, never the header: a rename to `label` + // would tell the author to overwrite the header with the identifier. + const issue = issuesOf({ columns: { maxAge: { name: 'maxAge', label: '保留时长', kind: 'duration' } } }) + .find((i) => i.code === 'unrecognized_keys'); + expect(issue?.message).toContain('`columns.`'); + expect(issue?.message).toContain('the `map` specifier declares it'); + expect(issue?.message).not.toContain('`name` → `label`'); + }); + }); }); // ────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 3d74d42d43e..0dc7ca98d51 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -2237,6 +2237,29 @@ const appTranslationDataShape = () => ({ }).optional().describe('Cross-namespace Settings UI strings'), }); +/** + * One column header of a settings `map` specifier: the key column + * (`keys..keyColumn`) or one value column (`keys..columns.`). + * Its leaves are the two strings `SpecifierMapColumnSchema` and + * `SpecifierMapKeyColumnSchema` declare: `label`, and `description` translated + * as `help`, the spelling the setting's own entry uses for its `description`. + */ +const settingsMapColumnTranslation = (surface: string) => strictObject({ + surface, + history: TRANSLATION_HISTORY, + aliases: { title: 'label', header: 'label', heading: 'label', helpText: 'help', hint: 'help', description: 'help' }, + // A specifier column pasted in whole carries these two. Neither is copy: + // the column's `name` is the record key this entry sits under, and its + // `kind` is declared by the specifier, so neither is renamed to a leaf here. + guidance: { + name: "a column's `name` is not translated: it is the key this entry sits under (`columns.`), so write the entry there and drop `name`", + kind: "a column's `kind` is not translated: the `map` specifier declares it, so drop it from the translation", + }, +}, { + label: z.string().optional().describe('Translated column header'), + help: z.string().optional().describe('Translated column help text'), +}); + /** * The one group that is PLATFORM-ONLY, as a shape rather than a schema. * @@ -2281,6 +2304,10 @@ const platformSettingsShape = () => ({ * settings..keys..placeholder * settings..keys..options. * settings..keys..confirmOffText + * settings..keys..keyColumn.label + * settings..keys..keyColumn.help + * settings..keys..columns..label + * settings..keys..columns..help * settings..actions..label * settings..actions..confirmText * settings..actions..successMessage @@ -2293,6 +2320,12 @@ const platformSettingsShape = () => ({ * `groups` already uses for the same field), and its `bannerText` as * `text`. A banner without an `id` has no address here and renders its * manifest literals. + * + * `keyColumn` and `columns` are a `map` specifier's table headers, spelled + * as the specifier spells them: `keyColumn` is the key column, `columns` the + * value columns keyed by each column's `name`. Both carry the two strings a + * map column declares, its `label` and its `description`, the latter as + * `help` as on the setting itself. */ settings: z.record(z.string(), strictObject({ surface: 'this settings manifest translation', @@ -2312,7 +2345,10 @@ const platformSettingsShape = () => ({ keys: z.record(z.string(), strictObject({ surface: 'this setting translation', history: TRANSLATION_HISTORY, - aliases: { title: 'label', name: 'label', helpText: 'help', hint: 'help', description: 'help', choices: 'options', values: 'options' }, + aliases: { + title: 'label', name: 'label', helpText: 'help', hint: 'help', description: 'help', choices: 'options', values: 'options', + keyHeader: 'keyColumn', headers: 'columns', columnLabels: 'columns', + }, }, { label: z.string().optional().describe('Translated setting label'), help: z.string().optional().describe('Translated setting help text'), @@ -2321,6 +2357,12 @@ const platformSettingsShape = () => ({ .describe('Enum option value → translated label'), confirmOffText: z.string().optional() .describe("Translated confirm text shown before a toggle's value changes to off"), + keyColumn: settingsMapColumnTranslation('this settings map key-column translation') + .optional() + .describe("A map setting's key column header: `label` translates the specifier's `keyColumn.label`, `help` its `keyColumn.description`"), + columns: z.record(z.string(), settingsMapColumnTranslation('this settings map column translation')) + .optional() + .describe("A map setting's value column headers keyed by column `name`: `label` translates the column's `label`, `help` its `description`"), })).optional().describe('Per-setting field translations keyed by setting key'), actions: z.record(z.string(), strictObject({ surface: 'this settings action translation', diff --git a/ui/apps/console/src/pages/settings/SettingsField.tsx b/ui/apps/console/src/pages/settings/SettingsField.tsx index 50789eb10d2..264177c1a11 100644 --- a/ui/apps/console/src/pages/settings/SettingsField.tsx +++ b/ui/apps/console/src/pages/settings/SettingsField.tsx @@ -815,6 +815,24 @@ export function SettingsField(props: SettingsFieldProps) { return labels.optionLabel(spec.key, String(opt.value), literal); }; + // A `map` header's shown text: the setting's `keyColumn` slot for the key + // column, its `columns.` slot for a value column (objectstack#31453). + const renderMapHeader = ( + column: string | undefined, + leaf: 'label' | 'help', + literal: string | undefined, + ): string | undefined => { + if (!spec.key || !labels) return literal; + if (column === undefined) { + return leaf === 'label' + ? labels.mapKeyColumnLabel(spec.key, literal ?? '') + : labels.mapKeyColumnHelp(spec.key, literal); + } + return leaf === 'label' + ? labels.mapColumnLabel(spec.key, column, literal ?? '') + : labels.mapColumnHelp(spec.key, column, literal); + }; + // The default a `Modified` row names. A secret's default is never shown, // and a value with no plain spelling (an object) is left unnamed. const describeDefault = (raw: unknown): string | null => { @@ -1081,6 +1099,7 @@ export function SettingsField(props: SettingsFieldProps) { disabled={disabled} labelId={labelId} optionLabel={renderOptionLabel} + headerText={renderMapHeader} errorAt={error ? errorAt : undefined} />, undefined, diff --git a/ui/apps/console/src/pages/settings/SettingsMapField.tsx b/ui/apps/console/src/pages/settings/SettingsMapField.tsx index b56fdaae6ae..7a6e061b3ef 100644 --- a/ui/apps/console/src/pages/settings/SettingsMapField.tsx +++ b/ui/apps/console/src/pages/settings/SettingsMapField.tsx @@ -253,6 +253,13 @@ export interface SettingsMapFieldProps { labelId: string; /** An option's shown name, translated through the namespace's option slot. */ optionLabel: (option: NonNullable[number]) => string; + /** + * A header's shown text, translated through the setting's `keyColumn` slot + * (`column` undefined) or its `columns.` slot (objectstack#31453); + * `literal` is the manifest's own text in the page language. Absent, the + * literal is shown. + */ + headerText?: (column: string | undefined, leaf: 'label' | 'help', literal: string | undefined) => string | undefined; /** 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. */ @@ -267,6 +274,7 @@ export function SettingsMapField({ disabled, labelId, optionLabel, + headerText, errorAt, 'aria-describedby': describedBy, }: SettingsMapFieldProps) { @@ -294,8 +302,12 @@ export function SettingsMapField({ 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 header = (column: string | undefined, leaf: 'label' | 'help', literal: string | undefined) => + (headerText ? headerText(column, leaf, literal) : literal); + const keyHeader = header(undefined, 'label', pickLocalized(spec.keyColumn?.label, language)) ?? ''; + const keyHelp = header(undefined, 'help', spec.keyColumn?.description); + const columnLabels = columns.map((c) => header(c.name, 'label', pickLocalized(c.label, language)) ?? ''); + const columnHelps = columns.map((c) => header(c.name, 'help', c.description)); // 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`; @@ -307,9 +319,9 @@ export function SettingsMapField({ - {keyHeader} + {keyHeader} {columns.map((c, i) => ( - + {columnLabels[i]} ))} 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 376a903c377..9bd63735832 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 @@ -45,6 +45,12 @@ vi.mock('sonner', () => ({ })); import { SettingsView } from '../SettingsView'; +import { + createI18n, + I18nProvider, + transformSpecTranslations, + type SpecTranslationData, +} from '@object-ui/i18n'; /** The keys the object option source serves on this deployment. */ const OBJECT_OPTIONS = [ @@ -368,3 +374,72 @@ describe('SettingsView — a locked map (objectstack#31391)', () => { expect(screen.queryByTestId('settings-unsaved-count')).toBeNull(); }); }); + +/** + * The headers translate through the setting's own slots (objectstack#31453): + * `settings.lifecycle.keys..keyColumn` for the key column and + * `keys..columns.` for each value column, `label` the header and + * `help` its hover text. The served document below is the zh-CN shape the + * shipped platform bundle carries, minus one column, which keeps its literal. + */ +describe('SettingsView — a map\'s headers translate through keyColumn / columns (objectstack#31453)', () => { + const ZH: SpecTranslationData = { + settings: { + lifecycle: { + keys: { + retention_overrides: { + label: '保留期覆盖', + keyColumn: { label: '对象', help: '要覆盖保留期的对象' }, + // `expireAfter` is left out on purpose: it keeps its literal. + columns: { maxAge: { label: '行保留时长', help: '行保留多久' } }, + }, + quotas: { label: '行数配额', keyColumn: { label: '对象' }, columns: { max_rows: { label: '最大行数' } } }, + }, + }, + }, + }; + + function renderInZh() { + const instance = createI18n({ + defaultLanguage: 'zh-CN', + detectBrowserLanguage: false, + resources: { 'zh-CN': transformSpecTranslations(ZH) }, + }); + return render( + + + + } /> + + + , + ); + } + + it('heads the key column and a translated value column in zh-CN; an untranslated column keeps its literal', async () => { + getSettingsNamespace.mockResolvedValue( + lifecyclePayload({ retention_overrides: { sys_job_run: { maxAge: '1y', expireAfter: '30d' } } }), + ); + renderInZh(); + await screen.findByText('保留期覆盖'); + + const headers = within(rowFor('retention_overrides')).getAllByRole('columnheader'); + expect(headers.slice(0, 3).map((h) => h.textContent)).toEqual(['对象', '行保留时长', 'Expire rows after']); + // `help` is the header's hover text; a column without one has none. + expect(headers.slice(0, 3).map((h) => h.getAttribute('title'))).toEqual(['要覆盖保留期的对象', '行保留多久', null]); + // The translated header is still what names the row's cells and key picker. + expect(cell('retention_overrides', 'sys_job_run', '行保留时长').value).toBe('1'); + expect(cell('retention_overrides', 'sys_job_run', 'Expire rows after').value).toBe('30'); + expect(within(entryRow('retention_overrides', 'sys_job_run')!).getByRole('combobox', { name: '对象' })).toBeTruthy(); + }); + + it('a one-column map reads its own key, not a sibling map\'s', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quotas: { sys_audit_log: 1000000 } })); + renderInZh(); + await screen.findByText('行数配额'); + + const headers = within(rowFor('quotas')).getAllByRole('columnheader').map((h) => h.textContent); + expect(headers.slice(0, 2)).toEqual(['对象', '最大行数']); + expect(cell('quotas', 'sys_audit_log', '最大行数').value).toBe('1000000'); + }); +}); diff --git a/ui/apps/console/src/pages/settings/useSettingsLabel.ts b/ui/apps/console/src/pages/settings/useSettingsLabel.ts index 01e01c86740..0ba9977d7b1 100644 --- a/ui/apps/console/src/pages/settings/useSettingsLabel.ts +++ b/ui/apps/console/src/pages/settings/useSettingsLabel.ts @@ -11,6 +11,8 @@ * settings..groups..title / .description * settings..keys..label / .help / .placeholder / .confirmOffText * settings..keys..options. + * settings..keys..keyColumn.label / .help + * settings..keys..columns..label / .help * settings..actions..label / .confirmText / .successMessage * settings..banners..title / .text * @@ -20,6 +22,10 @@ * `i18n-resolver.ts`). That slot is the ONE spelling: no other leaf or branch * name for a banner is read here (objectui#12671). * + * `keyColumn` and `columns.` are a `map` setting's table headers, + * spelled as the specifier spells them: the key column, and each value column + * by its `name`; `help` translates the column's `description` (objectstack#31453). + * * All lookups fall back to the literal authored in the manifest, so an * unconfigured locale gracefully degrades to English (or whatever the * plugin author shipped). @@ -48,6 +54,14 @@ export interface SettingsLabelHelpers { /** A `toggle`'s question before it goes off, keyed by the setting key; falls back to its `confirmOffText`. */ fieldConfirmOff: (key: string, fallback: string) => string; optionLabel: (key: string, optionValue: string, fallback: string) => string; + /** A `map` setting's key column header; falls back to its `keyColumn.label`. */ + mapKeyColumnLabel: (key: string, fallback: string) => string; + /** A `map` setting's key column help; falls back to its `keyColumn.description`. */ + mapKeyColumnHelp: (key: string, fallback: string | undefined) => string | undefined; + /** A `map` setting's value column header, by column `name`; falls back to the column's `label`. */ + mapColumnLabel: (key: string, column: string, fallback: string) => string; + /** A `map` setting's value column help, by column `name`; falls back to the column's `description`. */ + mapColumnHelp: (key: string, column: string, fallback: string | undefined) => string | undefined; actionLabel: (actionId: string, fallback: string) => string; actionConfirm: (actionId: string, fallback: string | undefined) => string | undefined; actionSuccess: (actionId: string, fallback: string | undefined) => string | undefined; @@ -157,6 +171,13 @@ export function useSettingsLabel(namespace: string): SettingsLabelHelpers { resolve(`keys.${key}.confirmOffText`, fallback) ?? fallback, optionLabel: (key, optionValue, fallback) => resolve(`keys.${key}.options.${optionValue}`, fallback) ?? fallback, + mapKeyColumnLabel: (key, fallback) => + resolve(`keys.${key}.keyColumn.label`, fallback) ?? fallback, + mapKeyColumnHelp: (key, fallback) => resolve(`keys.${key}.keyColumn.help`, fallback), + mapColumnLabel: (key, column, fallback) => + resolve(`keys.${key}.columns.${column}.label`, fallback) ?? fallback, + mapColumnHelp: (key, column, fallback) => + resolve(`keys.${key}.columns.${column}.help`, fallback), actionLabel: (actionId, fallback) => resolve(`actions.${actionId}.label`, fallback) ?? fallback, actionConfirm: (actionId, fallback) =>