|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #16458 — the platform catalogs name the `dashboard.header` composite's |
| 4 | +// children and the `header.actions[]` ROW properties, in every locale. |
| 5 | +// |
| 6 | +// Before this pin the four generated catalogs carried `header` alone: the |
| 7 | +// extractor walks a form field's DECLARED `fields`, `dashboard.form.ts` |
| 8 | +// declared none under `header`, so no `header.<child>` key was ever emitted |
| 9 | +// and the only localisation of those three children was a private overlay in |
| 10 | +// objectui. The row properties had no channel at all — a repeater's column |
| 11 | +// headers are read from the JSON Schema `title`, which the bundle overlays |
| 12 | +// through the `<repeater>.<property>` path pinned here. |
| 13 | +// |
| 14 | +// The English source of a row property's name lives in TWO places by |
| 15 | +// construction — the zod `.meta({ title })` the panel reads and the form's |
| 16 | +// declared `label` the extractor emits — and `packages/spec`'s |
| 17 | +// `dashboard.test.ts` pins those two equal. This file pins the catalog side: |
| 18 | +// the `en` leaf equals the form's declared label, and each translated locale |
| 19 | +// carries its own text rather than a copy of the source. |
| 20 | + |
| 21 | +import { describe, it, expect } from 'vitest'; |
| 22 | +import { dashboardForm } from '@objectstack/spec/ui'; |
| 23 | +import { enMetadataForms } from './en.metadata-forms.generated.js'; |
| 24 | +import { zhCNMetadataForms } from './zh-CN.metadata-forms.generated.js'; |
| 25 | +import { jaJPMetadataForms } from './ja-JP.metadata-forms.generated.js'; |
| 26 | +import { esESMetadataForms } from './es-ES.metadata-forms.generated.js'; |
| 27 | + |
| 28 | +const LOCALES = [ |
| 29 | + { name: 'en', forms: enMetadataForms as Record<string, any> }, |
| 30 | + { name: 'zh-CN', forms: zhCNMetadataForms as Record<string, any> }, |
| 31 | + { name: 'ja-JP', forms: jaJPMetadataForms as Record<string, any> }, |
| 32 | + { name: 'es-ES', forms: esESMetadataForms as Record<string, any> }, |
| 33 | +]; |
| 34 | + |
| 35 | +const HEADER_CHILDREN = ['header.showTitle', 'header.showDescription', 'header.actions']; |
| 36 | +const ROW_PROPERTIES = ['label', 'actionUrl', 'actionType', 'icon']; |
| 37 | +const ROW_KEYS = ROW_PROPERTIES.map((p) => `header.actions.${p}`); |
| 38 | + |
| 39 | +/** The `actions` repeater as `dashboard.form.ts` declares it, children included. */ |
| 40 | +function declaredActionsRepeater(): any { |
| 41 | + for (const section of (dashboardForm as any).sections ?? []) { |
| 42 | + for (const field of section.fields ?? []) { |
| 43 | + if (field?.field === 'header') { |
| 44 | + return (field.fields ?? []).find((f: any) => f?.field === 'actions'); |
| 45 | + } |
| 46 | + } |
| 47 | + } |
| 48 | + return undefined; |
| 49 | +} |
| 50 | + |
| 51 | +describe('#16458 — dashboard header children and row properties in every catalog', () => { |
| 52 | + for (const { name, forms } of LOCALES) { |
| 53 | + it(`${name}: carries the three header children and the four row-property keys`, () => { |
| 54 | + const fields = forms.dashboard?.fields ?? {}; |
| 55 | + for (const key of [...HEADER_CHILDREN, ...ROW_KEYS]) { |
| 56 | + expect(typeof fields[key]?.label, `${name} dashboard.fields['${key}'].label`).toBe('string'); |
| 57 | + expect(fields[key].label.length, `${name} dashboard.fields['${key}'].label is empty`).toBeGreaterThan(0); |
| 58 | + } |
| 59 | + // The three composite children carry a hint too — the overlay objectui |
| 60 | + // shipped for them had one, and this is what makes it redundant. |
| 61 | + for (const key of HEADER_CHILDREN) { |
| 62 | + expect(typeof fields[key]?.helpText, `${name} dashboard.fields['${key}'].helpText`).toBe('string'); |
| 63 | + } |
| 64 | + // Control — a neighbouring key known to exist, so an empty `fields` map |
| 65 | + // cannot pass by vacuity. |
| 66 | + expect(typeof fields.header?.label).toBe('string'); |
| 67 | + }); |
| 68 | + } |
| 69 | + |
| 70 | + it('en: each row-property leaf is the form\'s declared label, the English name the panel reads', () => { |
| 71 | + const repeater = declaredActionsRepeater(); |
| 72 | + expect(repeater, 'dashboard.form.ts declares header.actions with children').toBeDefined(); |
| 73 | + const declared = new Map<string, string>( |
| 74 | + (repeater.fields as any[]).map((f) => [String(f.field), String(f.label)]), |
| 75 | + ); |
| 76 | + expect([...declared.keys()]).toEqual(ROW_PROPERTIES); |
| 77 | + for (const prop of ROW_PROPERTIES) { |
| 78 | + expect(enMetadataForms.dashboard?.fields?.[`header.actions.${prop}`]?.label).toBe(declared.get(prop)); |
| 79 | + } |
| 80 | + }); |
| 81 | + |
| 82 | + it('translated locales carry their own text for every new leaf, not a copy of the source', () => { |
| 83 | + for (const { name, forms } of LOCALES) { |
| 84 | + if (name === 'en') continue; |
| 85 | + for (const key of [...HEADER_CHILDREN, ...ROW_KEYS]) { |
| 86 | + const en = (enMetadataForms as any).dashboard.fields[key].label; |
| 87 | + expect(forms.dashboard.fields[key].label, `${name} dashboard.fields['${key}'].label still reads the en source`).not.toBe(en); |
| 88 | + } |
| 89 | + } |
| 90 | + }); |
| 91 | +}); |
| 92 | + |
| 93 | +// --------------------------------------------------------------------------- |
| 94 | +// #16458 item ③, first half — the catalogs were ALREADY correct, and this pin |
| 95 | +// exists so the next reader cannot "repair" them backwards. |
| 96 | +// |
| 97 | +// The card and its triage both prescribe the opposite of the truth: "every |
| 98 | +// generated catalog names `refreshInterval`, not `refreshIntervalSeconds`". |
| 99 | +// That direction is inverted. `refreshInterval` was RENAMED to |
| 100 | +// `refreshIntervalSeconds` in @objectstack/spec 17 (#15680, ruling B on |
| 101 | +// #14478) and is now a `retiredKey` tombstone — authoring it is a parse error |
| 102 | +// (`packages/spec/src/ui/dashboard.test.ts` pins the refusal). The live |
| 103 | +// authorable key is `refreshIntervalSeconds`, which is what these catalogs and |
| 104 | +// `dashboard.form.ts` already name. |
| 105 | +// |
| 106 | +// The card's reading came from a substring: `refreshInterval` "occurs" in |
| 107 | +// `dashboard.zod.ts` only inside `refreshIntervalSeconds`, in the rename |
| 108 | +// comment and in the tombstone's own prose. Under `grep -P '\brefreshInterval\b'` |
| 109 | +// there is no live field by that name at all. |
| 110 | +// |
| 111 | +// So carrying out that acceptance literally would have written the tombstoned |
| 112 | +// key into all four catalogs and created exactly the never-matching entry the |
| 113 | +// card set out to remove. |
| 114 | +describe('#16458 item ③ — the catalogs name the LIVE refresh key, not the tombstone', () => { |
| 115 | + for (const { name, forms } of LOCALES) { |
| 116 | + it(`${name}: names \`refreshIntervalSeconds\` and never the retired \`refreshInterval\``, () => { |
| 117 | + const fields = forms.dashboard?.fields ?? {}; |
| 118 | + expect(typeof fields.refreshIntervalSeconds?.label, `${name} names the live key`).toBe('string'); |
| 119 | + expect( |
| 120 | + Object.keys(fields), |
| 121 | + `${name} carries the tombstoned \`refreshInterval\` — it is a parse error in the spec, so the entry could never match`, |
| 122 | + ).not.toContain('refreshInterval'); |
| 123 | + }); |
| 124 | + } |
| 125 | + |
| 126 | + it('the key the catalogs name is the key the form declares — one source, not two', () => { |
| 127 | + const declared = new Set<string>(); |
| 128 | + for (const section of (dashboardForm as any).sections ?? []) { |
| 129 | + for (const field of section.fields ?? []) if (field?.field) declared.add(String(field.field)); |
| 130 | + } |
| 131 | + // Control — the form really was walked, so an empty set cannot pass by vacuity. |
| 132 | + expect(declared.has('columns'), 'dashboardForm declares the neighbouring `columns`').toBe(true); |
| 133 | + expect(declared.has('refreshIntervalSeconds')).toBe(true); |
| 134 | + expect(declared.has('refreshInterval')).toBe(false); |
| 135 | + expect(Object.keys(enMetadataForms.dashboard?.fields ?? {})).toContain('refreshIntervalSeconds'); |
| 136 | + }); |
| 137 | +}); |
0 commit comments