diff --git a/.changeset/i18nlabel-render-sites-4163.md b/.changeset/i18nlabel-render-sites-4163.md new file mode 100644 index 0000000000..8a52790190 --- /dev/null +++ b/.changeset/i18nlabel-render-sites-4163.md @@ -0,0 +1,26 @@ +--- +'@object-ui/layout': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/plugin-designer': patch +'@object-ui/app-shell': patch +--- + +An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed + +rc.6 widened `I18nLabel` from `string` to `string | Record`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors. + +| package | what an author can now write and see | +| --- | --- | +| `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip | +| `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items | +| `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute | +| `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile | +| `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` | + +**Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s. + +Two consequences worth knowing: + +- **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design. +- **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site. diff --git a/.changeset/record-picker-sort-limit-empty-text-4167.md b/.changeset/record-picker-sort-limit-empty-text-4167.md new file mode 100644 index 0000000000..1916bbff0d --- /dev/null +++ b/.changeset/record-picker-sort-limit-empty-text-4167.md @@ -0,0 +1,46 @@ +--- +"@object-ui/components": minor +--- + +`element:record_picker` publishes `sort`, `limit` and `emptyText` as authoring +inputs (objectui#4167). + +All three were already READ by the renderer and declared by the contract — the +renderer has passed `sort` into `$orderby` and `limit` into `$top` since the +block existed, and `emptyText` decides the no-rows message — but none of them +appeared in `inputs`, so every layer that reads a manifest said they did not +exist. `packages/components/src/renderers/layout/page.tsx` builds the JSX-page +compiler's prop whitelist from `getKnownTypes()` plus these `inputs`, so writing +any of the three on a JSX page drew an `unknown-prop` warning from +`sdui-parser/src/validate.ts` on a key the renderer then went on to honour. + +That is objectui#3407's shape — honoured, undiscoverable — and this is the same +repair objectui#3808 made for `record:details.hideFields` and objectui#3830 made +for `element:record_picker.filter`. `@objectstack/spec` 17.0.0-rc.6 is what made +it actionable: objectstack#5775 declared the three upstream, and the reverse +direction of the console's registry parity gate went red demanding them the +moment the pin moved — a red the previous exemption had predicted in writing and +called "correct and wanted". + +Each description documents the renderer's real behaviour rather than restating +the schema, because that is the half an author cannot read off the contract: + +- **`sort`** and **`limit`** are both overridden OUTRIGHT by a node-level + `dataSource` binding (`dataSource.sort ?? sort`), not merged with it — so a + node that carries a `dataSource` silently ignores them. +- **`limit`** defaults to 50 in the renderer, not in the schema, and a record + outside the limit cannot be picked at all with nothing in the control to say + more exist. +- **`emptyText`** is published as `string` against a contract of + `string | Record< string, string >`: rc.6 widened it to `I18nLabel`, and this + renderer passes the value straight into a text node with no locale resolution, + so only the plain-string form renders today. The description says so rather + than advertising a shape the renderer drops — the narrowed-type treatment + objectui#3832 describes, with the render-site gap tracked in objectui#4163. + +The console's `registry-inputs-spec-parity` suite also drops all twelve of its +off-spec exemptions, which rc.6 obsoleted at once (objectstack#6776 declared +`page:header.recordChrome` / `showStar` / `showCopyId`, `page:accordion.variant` +and `page:tabs.tabStyle`; objectstack#5775 declared the `element:record_picker` +trio and `children` on the four page containers). The forward direction of that +gate now runs with no cover of any kind. diff --git a/.changeset/spec-symbol-collisions-rc6-4167.md b/.changeset/spec-symbol-collisions-rc6-4167.md new file mode 100644 index 0000000000..1a6755052f --- /dev/null +++ b/.changeset/spec-symbol-collisions-rc6-4167.md @@ -0,0 +1,92 @@ +--- +"@object-ui/types": minor +"@object-ui/core": minor +"@object-ui/react": minor +"@object-ui/app-shell": minor +"@object-ui/layout": minor +"@object-ui/fields": minor +"@object-ui/components": minor +"@object-ui/plugin-designer": patch +--- + +Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6` +(objectui#4167, objectstack#4115). + +The rc.6 bump published nine names this repo already declared locally, on top of +four that predate it — `check:spec-symbols` reported all thirteen at once, and a +fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was +triaged on its own rather than blanket-renamed, because the right answer differs +per symbol: five bind to the spec, three are renamed because the spec's +same-named export means something else, five arrive by derivation, and one is a +declared dialect with a written reason. + +**Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and +`@object-ui/types`** — three exported names changed, because the spec exports the +same name for a *different* thing: + +| package | was | now | what the spec's same-named export actually is | +|:--|:--|:--|:--| +| `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) | +| `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale | +| `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects | + +`resolveI18nLabel` is the one where the collision had already started costing +something. rc.6 widened `I18nLabel` from `string` to +`string | Record< string, string >`, so the same authored value now reaches +either resolver — and each answers wrongly, silently, for the other's input: the +keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no +`defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale +tags. The rc.6 bump PR met this and aliased the spec's import as +`resolveInlineI18nLabel` in five files, with hand-written comments at two of +them. That is a review convention, which is what objectstack#4115 exists to +replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the +name says which vocabulary it resolves at every call site. + +**Eleven keep their names and are now imported or derived from the spec** instead +of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`, +`BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`, +`GanttConfig`, plus the three renamed above at their new names. + +**Four of the copies were losing information, not just duplicating it.** + +- **`GanttConfig` declared six keys and called itself canonical; rc.6's + `GanttConfigSchema` declares seventeen.** The eleven it never mentioned — + `parentField`, `typeField`, `baselineStartField`, `baselineEndField`, + `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`, + `quickFilters`, `autoZoomToFilter` — are all read by + `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx` + intersection that existed only because this type did not carry them. It now + derives from the spec, with `timeSegments` (shift segmentation) as the one + genuinely local extension; the schema is `$loose` upstream, so that key is + legal metadata rather than a second dialect. +- **`GanttConfig.tooltipFields` carried the comment "not part of the upstream + GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec. +- **`AddressValue` declared five of the spec's seven parts** — `countryCode` and + `formatted` were missing, under a comment already claiming to be "the part + names of `AddressSchema`". The widget still renders five inputs; binding the + type stops it from asserting the platform cannot store the other two, and makes + the `{ ...address }` write-through say so. +- **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a + vocabulary the spec extracted in objectstack#4614 precisely to collapse — its + own doc comment names this module as one of the three. It is now the spec's + array by reference, and the local date-macro bounds table is pinned complete + against it with `satisfies`, so a preset the schema gains without bounds here + is a compile error rather than a filter that validates clean and then selects + nothing. + +`NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`); +it is bound directly, with a both-directions type pin that it stays the same type +as the config's own `mode`. `KanbanConfig` / `CalendarConfig` / +`BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict` +schemas and are now re-exports — "still exact" is the argument for binding them, +since a copy with nothing to protect can only drift. + +`GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition +dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's +new refinement forced `.extend()` to be respelled as a `.shape` spread — which +moved a derivation the guard could see into an object literal it deliberately +does not descend into. The dialect is unchanged and its three divergences are +pinned; which side moves on the refinement itself is objectui#4165. + +`@objectstack/spec` moves from `devDependencies` to `dependencies` in +`@object-ui/layout`: its public type surface now references the spec. diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index f8000bc89f..e9ce55a421 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -113,9 +113,9 @@ that test tells you to edit this block. The one exception is called out on its r - **Version:** 17.4.0 (the version every `@object-ui/*` manifest carries — they are one `fixed` group in `.changeset/config.json`, so a release moves all of them together) -- **Spec:** `@objectstack/spec` ^17.0.0-rc.5 (declared by the root `package.json` and by +- **Spec:** `@objectstack/spec` ^17.0.0-rc.6 (declared by the root `package.json` and by `apps/console/package.json`) -- **Client:** `@objectstack/client` ^17.0.0-rc.5 (declared by `apps/console/package.json` +- **Client:** `@objectstack/client` ^17.0.0-rc.6 (declared by `apps/console/package.json` and `packages/data-objectstack/package.json`) - **Node.js:** ≥ 22 (see root `engines.node`) - **pnpm:** ≥ 9 (the workspace pins `pnpm@10.31.0` via `packageManager`) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index d1b8c3d571..a1b355c6db 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -296,115 +296,38 @@ const EXPECTED_WITHOUT_INPUTS = [ * is an upstream issue plus an entry here, never a local widening. Every reason * therefore has to cite an issue, which `references a tracking issue` asserts. * - * All eight entries below were verified against renderer read sites, not - * assumed — see objectui#3797 and objectstack#6776 for the per-key evidence. + * ## EMPTY as of @objectstack/spec 17.0.0-rc.6 (objectui#4167) + * + * All twelve entries were deleted on the rc.6 bump, by the `carries no stale + * exemption` test below, which named every one of them at once. Emptying it is + * the discipline working end to end rather than an absence of divergence: each + * entry cited the upstream issue that owned it, and rc.6 is where both of those + * issues landed. Verified per key against the resolved + * `ComponentPropsMap[type].shape` at this pin, not from the issues' wording: + * + * - **objectstack#6776** — `page:header` now declares `recordChrome`, + * `showStar` and `showCopyId`; `page:accordion` declares `variant`; + * `page:tabs` declares `tabStyle`. The five keys the renderer had read all + * along are contract now. `page:tabs` is the interesting one: the spec + * declares BOTH `tabStyle` and `type`, so the carrier collision written up in + * the deleted entry was resolved upstream by declaring the alias rather than + * by renaming — which is why `page:tabs.type` stays in + * `UNPUBLISHED_EXEMPTIONS` below (spec-declared, unpublishable in a flat + * carrier) while `tabStyle` needs no cover at all. + * - **objectstack#5775 / PR objectstack#6281** — `element:record_picker` + * declares `labelField`, `valueField` and `label`; `page:card` declares + * `children` (replacing the retired `body`); and `page:section` / + * `page:footer` / `page:sidebar` carry the shared `PageContainerProps`, whose + * one key is `children`. Those seven were the objectui#4027 stale-pin set, + * predicted in their own reasons ("Delete this entry when the pin moves") and + * deleted exactly there. + * + * The map stays declared rather than removed: a future divergence needs + * somewhere to be registered, and `exemptedFor` below reads it. Empty is a + * state, not a deletion — every forward-direction assertion now runs with no + * cover of any kind, which is the strongest reading this gate has ever had. */ -const OFF_SPEC_EXEMPTIONS: Record = { - // ── page:header — three author-facing booleans the spec never declared ──── - // Read at `containers.tsx:979/980/981` and consumed at `:1453` (which of the - // two header layouts renders) and `:1531/:1532` (the RecordTitleChip star and - // copy-id affordances). Author-reachable, NOT host-injected: this block's - // host injections come through RecordContext (`headerSystemActions`, - // `isFavorite`, `onToggleFavorite`) and are deliberately undeclared. - // `recordChrome` already has in-repo authors — `preview-samples.ts:68` writes - // it under `properties`, the exact shape the upstream linter warns on, and - // `plugin-detail/src/synth/buildDefaultPageSchema.ts:413` emits it on every - // synthesized record page. Direction is therefore "the spec should declare - // it", filed as objectstack#6776; withdrawing them here would delete live, - // author-reachable configuration. - // - // (objectui#3797 suggested these might be renderer-only props deliberately - // kept by objectui#3226 / PR #3265. Checked: #3265 (`d2363e710`) narrowed the - // LEGACY `page-header` alias in `packages/layout`, not this canonical block — - // there is no such deliberate-retention record.) - 'page:header.recordChrome': - 'Renderer reads it (containers.tsx:979) to pick the bare-h1 layout; authored in-repo by preview-samples.ts:68 and emitted by buildDefaultPageSchema.ts:413. Awaiting the spec declaration in objectstack#6776.', - 'page:header.showStar': - 'Renderer reads it (containers.tsx:980) and passes it to RecordTitleChip (:1531) to hide the follow star. Awaiting the spec declaration in objectstack#6776.', - 'page:header.showCopyId': - 'Renderer reads it (containers.tsx:981) and passes it to RecordTitleChip (:1532) to hide the copy-id button. Awaiting the spec declaration in objectstack#6776.', - - // ── page:accordion.variant ─────────────────────────────────────────────── - // Read at `containers.tsx:734` and consumed at `:735` to pick each panel's - // border class, so `variant: 'card'` renders visibly differently. The spec - // declares no equivalent at all. - 'page:accordion.variant': - "Renderer reads it (containers.tsx:734) and it changes per-panel borders (:735); the renderer's own comment documents `variant: 'card'` as an author opt-in. Awaiting the spec declaration in objectstack#6776.", - - // ── page:tabs.tabStyle — a carrier collision, not ordinary drift ────────── - // The spec DOES declare this concept, spelled `type` (`PageTabsProps.type`), - // and `containers.tsx:381` reads both (`properties.type || tabStyle`). Two - // spellings for one semantic is exactly what #0.1 forbids, so the local - // instinct is to withdraw `tabStyle` — but both local moves are wrong: - // - withdrawing it deletes the only spelling the FLAT carrier can express. - // `SchemaRenderer.tsx:251-270` deliberately refuses to hoist - // `properties.type` onto the node (it would shadow the dispatch key, and - // its comment names this very case), and a flat node is - // `{ type: 'page:tabs', tabStyle: 'card' }` where `type` is the tag; - // - publishing `type` instead declares a key this repo's own parser cannot - // validate: `sdui-parser/src/validate.ts:20-30` lists `'type'` in - // `BASE_PROPS`, so it is skipped as a base prop on every node. - // The only remaining lever is upstream, where the shape (rename `type` to - // `tabStyle` per objectstack#5775's precedent, vs declare an alias) is a spec - // contract decision this repo must not guess. Both options and their costs are - // written out in objectstack#6776. - 'page:tabs.tabStyle': - "Renderer reads it (containers.tsx:381) and it is the only spelling the flat SDUI carrier can express — the spec's `type` is unhoistable (SchemaRenderer.tsx:251-270) and unvalidatable as an input (validate.ts BASE_PROPS). Convergence is an upstream contract decision: objectstack#6776.", - - // ── element:record_picker — ALREADY settled upstream, stale pin only ────── - // objectstack#5775 (ADR-0087 D2) declared `labelField` / `valueField` / - // `label` (plus `sort` / `limit` / `emptyText`) and turned `displayField` / - // `searchFields` / `multiple` into `retiredKey()` tombstones — converging on - // the `labelField` this renderer actually reads - // (`renderers/basic/record-picker.tsx:80-81`), which is the direction - // objectui#3797 guessed at. Verified on objectstack `origin/main` - // (`packages/spec/src/ui/component.zod.ts:715-786`). It is not in a published - // release yet: the newest `@objectstack/spec` on npm is `17.0.0-rc.5`, which - // predates #5775 and is what this repo pins. So these three flags are a - // stale-pin artifact with NOTHING to do in either repo — and the - // `no stale exemption` test below deletes them for us, loudly, the moment the - // pin moves. - 'element:record_picker.labelField': - 'Already declared upstream by objectstack#5775 (converging on the spelling this renderer reads); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 predates it. Delete this entry when the pin moves.', - 'element:record_picker.valueField': - 'Already declared upstream by objectstack#5775; flagged only because the pinned @objectstack/spec@17.0.0-rc.5 predates it. Delete this entry when the pin moves.', - 'element:record_picker.label': - 'Already declared upstream by objectstack#5775; flagged only because the pinned @objectstack/spec@17.0.0-rc.5 predates it. Delete this entry when the pin moves.', - - // ── page container `children` — ALREADY settled upstream, stale pin only ─── - // The other half of the same upstream issue as the record_picker trio above, - // and the same stale-pin shape (objectui#4027). objectstack#5775 - // (PR objectstack#6281, merged 2026-08-07) declared `PageCardProps.children` - // as the canonical composition slot — retiring `body`, its second spelling — - // and gave `page:section` / `page:footer` / `page:sidebar` the shared - // `PageContainerProps`, whose one key is `children`, replacing the - // `EmptyProps` that had declared "zero props" for three components whose only - // job is to render a child list. Verified in that PR's merged diff - // (`packages/spec/src/ui/component.zod.ts`, `PageContainerProps` + the - // `ComponentPropsMap` entries), not from the issue's wording. - // - // The pinned `@objectstack/spec@17.0.0-rc.5` predates all of it: its - // `PageCardProps` still lists `body` and no `children`, and the three thin - // containers are still `EmptyProps` — so this gate reads four correct, - // contract-following declarations as off-spec. Nothing to do in either repo; - // the `no stale exemption` test below deletes these four for us, loudly, the - // moment the pin moves. - // - // Nothing about `children` moves a validation verdict either way, which is - // why publishing it ahead of the pin is safe: `validate.ts` lists `children` - // in `BASE_PROPS` (never an `unknown-prop`), and `codegen.ts:emitInterface` - // filters `slot` inputs out of the generated `.d.ts`, where - // `SduiBaseProps.children` already types it. The designer panel is the only - // surface that changes. - 'page:card.children': - 'Already declared upstream by objectstack#5775 / PR objectstack#6281 as the canonical card content slot (replacing the retired `body`); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 predates it. objectui#4027. Delete this entry when the pin moves.', - 'page:section.children': - 'Already declared upstream by objectstack#5775 / PR objectstack#6281 via the shared `PageContainerProps` (this renderer has always rendered `schema.children`); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 still maps this block to `EmptyProps`. objectui#4027. Delete this entry when the pin moves.', - 'page:footer.children': - 'Already declared upstream by objectstack#5775 / PR objectstack#6281 via the shared `PageContainerProps` (this renderer has always rendered `schema.children`); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 still maps this block to `EmptyProps`. objectui#4027. Delete this entry when the pin moves.', - 'page:sidebar.children': - 'Already declared upstream by objectstack#5775 / PR objectstack#6281 via the shared `PageContainerProps` (this renderer has always rendered `schema.children`); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 still maps this block to `EmptyProps`. objectui#4027. Delete this entry when the pin moves.', -}; +const OFF_SPEC_EXEMPTIONS: Record = {}; /** * Spec-declared top-level keys deliberately NOT published, each with the reason. @@ -719,4 +642,66 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(Object.keys(UNPUBLISHED_EXEMPTIONS)).not.toContain(`${type}.${key}`); } }); + + it('the three keys the rc.6 bump added to element:record_picker are discoverable', () => { + // objectui#4167, and pinned by name for exactly the reason the five above + // are: the derived reverse-direction assertion would go green just as + // readily if these three were added to `UNPUBLISHED_EXEMPTIONS` instead of + // declared, and "exempt it" is the cheaper move under time pressure. The + // exemption that covered the retired `displayField` / `searchFields` / + // `multiple` trio predicted this red in writing and called it "correct and + // wanted"; this is what banking that prediction looks like. + // + // The first assertion is not redundant with the second. It is the + // non-vacuity half: if a later pin dropped these from + // `ElementRecordPickerProps`, `undiscoverableSpecKeys` would stop naming + // them and every derived assertion would pass while the inputs sat there + // publishing keys the contract no longer has. + for (const key of ['sort', 'limit', 'emptyText']) { + expect( + specTopLevelKeys('element:record_picker'), + `spec no longer declares element:record_picker.${key}`, + ).toContain(key); + expect( + declaredInputs('element:record_picker') ?? [], + `element:record_picker does not publish ${key}`, + ).toContain(key); + expect(Object.keys(UNPUBLISHED_EXEMPTIONS)).not.toContain(`element:record_picker.${key}`); + } + }); + + it('the twelve rc.6-obsoleted off-spec exemptions are gone, and their keys are contract now', () => { + // The tombstone for the emptied `OFF_SPEC_EXEMPTIONS` (see its comment). + // Without this, "the list is empty" and "the list was accidentally deleted + // along with the divergences it covered" look identical, and every forward + // assertion passes either way — the same misdiagnosis objectui#4027 records + // for the `SPEC_SHAPE_EMPTY_ON_THE_PIN` carve-out, which is why that one + // also left an assertion behind rather than just a comment. + // + // Asserted as "the spec declares it AND the block publishes it", not merely + // "no longer exempted": these twelve keys were author-reachable + // configuration the renderers had always honoured, so a pin that regressed + // any of them must fail here loudly rather than quietly re-open an + // exemption-shaped hole. + const settledUpstream: Array<[string, string]> = [ + ['page:header', 'recordChrome'], + ['page:header', 'showStar'], + ['page:header', 'showCopyId'], + ['page:accordion', 'variant'], + ['page:tabs', 'tabStyle'], + ['element:record_picker', 'labelField'], + ['element:record_picker', 'valueField'], + ['element:record_picker', 'label'], + ['page:card', 'children'], + ['page:section', 'children'], + ['page:footer', 'children'], + ['page:sidebar', 'children'], + ]; + for (const [type, key] of settledUpstream) { + expect(specTopLevelKeys(type), `${type} spec no longer declares ${key}`).toContain(key); + expect(declaredInputs(type) ?? [], `${type} stopped publishing ${key}`).toContain(key); + expect(Object.keys(OFF_SPEC_EXEMPTIONS)).not.toContain(`${type}.${key}`); + } + expect(Object.keys(OFF_SPEC_EXEMPTIONS)).toEqual([]); + }); }); diff --git a/apps/console/src/pages/system/AppManagementPage.tsx b/apps/console/src/pages/system/AppManagementPage.tsx index 92205e8f90..4fb19bfc33 100644 --- a/apps/console/src/pages/system/AppManagementPage.tsx +++ b/apps/console/src/pages/system/AppManagementPage.tsx @@ -29,7 +29,7 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { useMetadata } from '@object-ui/app-shell'; -import { resolveI18nLabel } from '../../utils'; +import { resolveKeyedI18nLabel } from '../../utils'; export function AppManagementPage() { const navigate = useNavigate(); @@ -211,14 +211,14 @@ export function AppManagementPage() {
- {resolveI18nLabel(app.label) || app.name} + {resolveKeyedI18nLabel(app.label) || app.name} {isDefault && Default} {isActive ? 'Active' : 'Inactive'}
{app.description && ( -

{resolveI18nLabel(app.description)}

+

{resolveKeyedI18nLabel(app.description)}

)}
diff --git a/apps/console/src/utils.ts b/apps/console/src/utils.ts index b3569c684a..499860276e 100644 --- a/apps/console/src/utils.ts +++ b/apps/console/src/utils.ts @@ -3,12 +3,16 @@ */ /** - * Resolves an I18nLabel to a plain string. - * I18nLabel can be either a string or an object { key, defaultValue?, params? }. - * When it's an object and a `t` function is provided, it resolves the key - * through the i18n translation system. Otherwise returns defaultValue or key. + * Resolves objectui's KEYED i18n label (`{ key, defaultValue?, params? }`) to a + * plain string. + * + * When a `t` function is provided the key is resolved through the i18n + * translation system; otherwise `defaultValue`, then the key itself. NOT the + * spec's `resolveI18nLabel`, which resolves the INLINE per-locale map form — + * see `packages/react/src/utils/i18n.ts` for why the names diverge + * (objectui#4167). */ -export function resolveI18nLabel( +export function resolveKeyedI18nLabel( label: string | { key: string; defaultValue?: string; params?: Record } | undefined, t?: (key: string, options?: any) => string, ): string | undefined { diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index 8249cf4be1..ac2ac9c1f1 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -32,7 +32,7 @@ import { useRecordSearch } from '@object-ui/react'; import { useTheme } from './ThemeProvider'; import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider'; import { useObjectTranslation } from '@object-ui/i18n'; -import { resolveI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils'; +import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils'; import { getIcon } from '../utils/getIcon'; import { useRecentItems } from '../context/RecentItemsProvider'; import { useCommandPalette } from '../context/CommandPaletteProvider'; @@ -144,7 +144,7 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour // Prefer the i18n-resolved object label; fall back to the hit's plain // label (already objectName when the def had no string label). const objDef = objectsByName.get(hit.objectName); - const label = resolveI18nLabel(objDef?.label, t) || hit.objectLabel; + const label = resolveKeyedI18nLabel(objDef?.label, t) || hit.objectLabel; group = { objectLabel: label, icon: hit.icon, hits: [] }; byObject.set(hit.objectName, group); order.push(hit.objectName); @@ -262,11 +262,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour return ( runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))} > - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ); })} @@ -281,11 +281,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour .map(item => ( runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))} > - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ))} @@ -299,11 +299,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour .map(item => ( runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))} > - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ))} @@ -317,11 +317,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour .map(item => ( runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))} > - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ))} @@ -339,11 +339,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour return ( runCommand(() => onAppChange(appRouteSegment(app) ?? app.name))} > - {resolveI18nLabel(app.label, t)} + {resolveKeyedI18nLabel(app.label, t)} {app.name === activeApp?.name && ( {t('console.commandPalette.current')} )} diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index a9c8a68b93..845206ebbf 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -22,7 +22,7 @@ import { Package as PackageIcon, Sparkles as SparklesIcon } from 'lucide-react'; import { useAdapter } from '../../providers/AdapterProvider'; import { useMetadata } from '../../providers/MetadataProvider'; import { formatPublishFailures, type PublishFailure } from '../../views/studio-design/metadataError'; -import { resolveI18nLabel } from '../../utils'; +import { resolveKeyedI18nLabel } from '../../utils'; import { ExcelImportBar } from './ExcelImportBar'; import { Select, @@ -260,7 +260,7 @@ export function isPlatformBuiltinApp(app: { _packageId?: unknown }): boolean { */ interface MetadataAppItem { name: string; - label?: Parameters[0]; + label?: Parameters[0]; _packageId?: string; } @@ -1749,7 +1749,7 @@ export function ChatPane({ const app = (metadataApps ?? []).find( (a) => a._packageId === boundPackageId, ); - return app ? appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }) : boundPackageId; + return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : boundPackageId; }, [boundPackageId, metadataApps, appLabel, t]); // ADR-0057 A1.b edit mode — the resolved name of the app being EDITED @@ -1761,7 +1761,7 @@ export function ChatPane({ const app = (metadataApps ?? []).find( (a) => a._packageId === editPackageId, ); - return app ? appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }) : undefined; + return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : undefined; }, [editPackageId, metadataApps, appLabel, t]); // Per-surface empty-state branding (Build = authoring, Ask = data Q&A). On the @@ -1810,7 +1810,7 @@ export function ChatPane({ if (!pkg || byPackage.has(pkg) || isPlatformBuiltinApp(app)) { continue; } - byPackage.set(pkg, appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) })); + byPackage.set(pkg, appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) })); } return Array.from(byPackage, ([id, label]) => ({ id, label })); }, [metadataApps, appLabel, t]); diff --git a/packages/app-shell/src/console/home/AppCard.tsx b/packages/app-shell/src/console/home/AppCard.tsx index 2fb03c2bfa..73886e2b0b 100644 --- a/packages/app-shell/src/console/home/AppCard.tsx +++ b/packages/app-shell/src/console/home/AppCard.tsx @@ -9,7 +9,7 @@ import { Star, StarOff, ArrowUpRight } from 'lucide-react'; import { Card, CardContent, Button, Badge } from '@object-ui/components'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; -import { resolveI18nLabel, appRouteSegment } from '../../utils'; +import { resolveKeyedI18nLabel, appRouteSegment } from '../../utils'; import { useFavorites } from '../../hooks/useFavorites'; import { getIcon } from '../../utils/getIcon'; import { cn } from '@object-ui/components'; @@ -44,8 +44,8 @@ export function AppCard({ app, onClick, isFavorite, index = 0 }: AppCardProps) { const { toggleFavorite } = useFavorites(); const Icon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }); - const description = appDescription({ name: app.name, description: resolveI18nLabel(app.description, t) }); + const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); + const description = appDescription({ name: app.name, description: resolveKeyedI18nLabel(app.description, t) }); const accent = ACCENTS[(hashStr(app.name) + index) % ACCENTS.length]; const handleToggleFavorite = (e: React.MouseEvent) => { diff --git a/packages/app-shell/src/console/home/HomeAppsStrip.tsx b/packages/app-shell/src/console/home/HomeAppsStrip.tsx index ed3b434f69..7b38c13377 100644 --- a/packages/app-shell/src/console/home/HomeAppsStrip.tsx +++ b/packages/app-shell/src/console/home/HomeAppsStrip.tsx @@ -16,7 +16,7 @@ import { useMemo, useState } from 'react'; import { LayoutGrid, Store, Star } from 'lucide-react'; import { Button, cn } from '@object-ui/components'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; -import { resolveI18nLabel } from '../../utils'; +import { resolveKeyedI18nLabel } from '../../utils'; import { getIcon } from '../../utils/getIcon'; import type { FavoriteItem } from '../../hooks/useFavorites'; @@ -104,7 +104,7 @@ export function HomeAppsStrip({
{visible.map((app) => { const Icon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }); + const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); const fav = favNames.has(app.name); const grad = ICON_GRADIENTS[hashStr(app.name) % ICON_GRADIENTS.length]; return ( diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 1a1441e58c..42172dc367 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -112,7 +112,8 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const { fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionDescription, actionResultDialog } = useObjectLabel(); // Entitlement 403s render as a dialog, not a toast — its copy is localized // here rather than taken from the server (objectui#2458 / cloud#959). - const { t } = useObjectTranslation(); + // `language` also resolves inline per-locale action-param labels below. + const { t, language } = useObjectTranslation(); const objectDef = useMemo( () => (objectName ? objects?.find((o: any) => o.name === objectName) : undefined), @@ -186,6 +187,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons fieldLabel, fieldOptionLabel, row, + // Resolves an inline per-locale `label` map (rc.6's widened + // `I18nLabel`) for the active language — objectui#4163. + locale: language, }); // Localize each param's label/placeholder/helpText via the // `_actions..params..` convention. diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index d4730bb434..72658d3b6a 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -36,7 +36,7 @@ export type { } from './types'; export type { - MetadataState, + MetadataCacheState, MetadataContextValue, MetadataTypeStatus, } from './providers/MetadataProvider'; diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 96e47e5f6d..d99bf23dcf 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -66,7 +66,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; import { useAuth, getUserInitials, useIsWorkspaceAdmin } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider'; -import { resolveI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils'; +import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils'; import { getIcon } from '../utils/getIcon'; import { bearerAuthHeaders } from '../utils/authToken'; import { useMobileViewSwitcher } from './MobileViewSwitcherContext'; @@ -1125,7 +1125,7 @@ export function AppHeader({ .filter((a: any) => a.active !== false && a.hidden === true && a.name !== 'account') .map((app: any) => { const AppIcon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }); + const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); return ( {logo ? ( - {resolveI18nLabel(activeApp.label, + {resolveKeyedI18nLabel(activeApp.label, ) : ( React.createElement(getIcon(activeApp.icon), { className: "size-4" }) )}
- {resolveI18nLabel(activeApp.label, t)} + {resolveKeyedI18nLabel(activeApp.label, t)} - {resolveI18nLabel(activeApp.description, t) || t('layout.appSwitcher.appsAvailable', { defaultValue: '{{count}} apps available', count: activeApps.length })} + {resolveKeyedI18nLabel(activeApp.description, t) || t('layout.appSwitcher.appsAvailable', { defaultValue: '{{count}} apps available', count: activeApps.length })}
@@ -420,7 +422,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
{app.icon ? React.createElement(getIcon(app.icon), { className: "size-3" }) : }
- {resolveI18nLabel(app.label, t)} + {resolveKeyedI18nLabel(app.label, t)} {activeApp.name === app.name && } ))} @@ -779,7 +781,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri return ( - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ); }); diff --git a/packages/app-shell/src/layout/AppSwitcher.tsx b/packages/app-shell/src/layout/AppSwitcher.tsx index 9d548af3f6..f307a996e2 100644 --- a/packages/app-shell/src/layout/AppSwitcher.tsx +++ b/packages/app-shell/src/layout/AppSwitcher.tsx @@ -17,7 +17,7 @@ import { } from '@object-ui/components'; import { ChevronDown, Check } from 'lucide-react'; import { useMetadata } from '../providers/MetadataProvider'; -import { resolveI18nLabel, matchAppBySegment, appRouteSegment } from '../utils'; +import { resolveKeyedI18nLabel, matchAppBySegment, appRouteSegment } from '../utils'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { getIcon } from '../utils/getIcon'; @@ -41,7 +41,7 @@ export function AppSwitcher({ activeAppName, onAppChange }: AppSwitcherProps) { if (!activeApp) return null; - const appLabelText = appLabel({ name: activeApp.name, label: resolveI18nLabel(activeApp.label, t) }); + const appLabelText = appLabel({ name: activeApp.name, label: resolveKeyedI18nLabel(activeApp.label, t) }); return ( @@ -58,7 +58,7 @@ export function AppSwitcher({ activeAppName, onAppChange }: AppSwitcherProps) { {activeApps.map((app: any) => { const AppIcon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }); + const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); const isActive = activeApp.name === app.name; return ( ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx index e1f42d2336..7db2ffd8af 100644 --- a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx @@ -71,7 +71,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/appSidebarSettingsTargets.test.tsx b/packages/app-shell/src/layout/__tests__/appSidebarSettingsTargets.test.tsx index 4584431fd5..478a3f0c39 100644 --- a/packages/app-shell/src/layout/__tests__/appSidebarSettingsTargets.test.tsx +++ b/packages/app-shell/src/layout/__tests__/appSidebarSettingsTargets.test.tsx @@ -123,7 +123,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/systemNavDatasourcesHop.test.tsx b/packages/app-shell/src/layout/__tests__/systemNavDatasourcesHop.test.tsx index cce754a47f..5199f343bf 100644 --- a/packages/app-shell/src/layout/__tests__/systemNavDatasourcesHop.test.tsx +++ b/packages/app-shell/src/layout/__tests__/systemNavDatasourcesHop.test.tsx @@ -96,7 +96,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx b/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx index 684d10afff..7f3d6e0368 100644 --- a/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx +++ b/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx @@ -112,7 +112,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx b/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx index 579694f46d..a6ac09939f 100644 --- a/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx +++ b/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx @@ -127,7 +127,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx index d8e8023b08..12cfafafa5 100644 --- a/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/packages/app-shell/src/providers/MetadataProvider.tsx @@ -10,12 +10,12 @@ import { expandViewContainer } from '@objectstack/spec/ui'; import { type ObjectStackAdapter } from '@object-ui/data-objectstack'; import { normalizeSchemaReferenceKeys } from '@object-ui/core'; import { resolveInlineMode } from '@object-ui/plugin-form'; -import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataState } from '@object-ui/react'; +import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataCacheState } from '@object-ui/react'; import { usePreviewDrafts } from '../preview/PreviewModeContext'; import { createConsoleMetadataClient } from '../views/metadata-admin/metadataClientFactory'; import { subscribeCanvasInvalidate, subscribeMetadataRefresh } from '../assistant/assistantBus'; -export type { MetadataState, MetadataContextValue }; +export type { MetadataCacheState, MetadataContextValue }; export { useMetadataItem } from '@object-ui/react'; export { useMetadata }; @@ -68,7 +68,7 @@ const EAGER_TYPES = ['app', 'view'] as const; */ const ERROR_RETRY_COOLDOWN_MS = 1000; -const TYPE_BY_STATE_KEY: Record, string> = { +const TYPE_BY_STATE_KEY: Record, string> = { apps: 'app', objects: 'object', dashboards: 'dashboard', diff --git a/packages/app-shell/src/utils/index.ts b/packages/app-shell/src/utils/index.ts index c6c2160692..a0a7e1b6b6 100644 --- a/packages/app-shell/src/utils/index.ts +++ b/packages/app-shell/src/utils/index.ts @@ -35,12 +35,23 @@ export { preferLocal } from './preferLocal'; export { appRouteSegment, matchAppBySegment, appStudioDesignPath, appStudioSurfacePath, appStudioRoutePath } from './appRoute'; /** - * Resolves an I18nLabel to a plain string. - * I18nLabel can be either a string or an object { key, defaultValue?, params? }. - * When it's an object and a `t` function is provided, it resolves the key - * through the i18n translation system. Otherwise returns defaultValue or key. + * Resolves objectui's KEYED i18n label to a plain string. + * + * The keyed form is `{ key, defaultValue?, params? }` — a reference INTO a + * translation bundle. With a `t` function it resolves the key through i18next; + * without one it falls back to `defaultValue`, then the key itself. + * + * Renamed from `resolveI18nLabel` in objectui#4167: `@objectstack/spec` + * 17.0.0-rc.6 publishes a function of that exact name over the INLINE LOCALE MAP + * vocabulary (`{ en: 'Owner', 'zh-CN': '负责人' }`, resolved against a BCP-47 + * locale), and rc.6's widening of `I18nLabel` to `string | Record< string, + * string >` means the same authored value can now reach either function — each + * answering wrongly, and silently, for the other's input. The full account is at + * the `@object-ui/react` twin (`packages/react/src/utils/i18n.ts`), which is the + * same vocabulary without a `t`. Import the spec's as `resolveInlineI18nLabel` + * when you need the map form; the two names now say which is which. */ -export function resolveI18nLabel( +export function resolveKeyedI18nLabel( label: string | { key: string; defaultValue?: string; params?: Record } | undefined, t?: (key: string, options?: any) => string, ): string | undefined { diff --git a/packages/app-shell/src/utils/resolveActionParams.i18nLabel.test.ts b/packages/app-shell/src/utils/resolveActionParams.i18nLabel.test.ts new file mode 100644 index 0000000000..d295a8d41c --- /dev/null +++ b/packages/app-shell/src/utils/resolveActionParams.i18nLabel.test.ts @@ -0,0 +1,170 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4163 part 1 — the `app-shell` half, which the `tsc` errors reported + * against `resolveActionParams.test.ts` only SURFACED. + * + * `@objectstack/spec` 17.0.0-rc.6 widened `I18nLabel` from `string` to + * `string | Record`, so the spec's `ActionParamSchema.label` + * (and each option's `label`) admits an inline per-locale map. `RawActionParam` + * is a local restatement of that authoring shape, and it still said `string` — + * so the public authoring type stopped being assignable to it. Widening the + * DECLARATION is the fix; this file pins the behaviour that has to come with it. + * + * The resolver emits `@object-ui/core`'s `ActionParamDef`, whose `label` is a + * plain `string` and whose options' labels are plain `string`s. So the map may + * not simply be carried through: `ActionParamDialog` renders both into text, + * and an unresolved map arrives as `[object Object]` with nothing red anywhere. + * Resolution therefore happens HERE, against the locale the caller threads in. + */ +import { describe, it, expect } from 'vitest'; +import { + resolveActionParams, + type ResolveActionParamsContext, + type RawActionParam, +} from './resolveActionParams'; + +const ctx = (over: Partial = {}): ResolveActionParamsContext => ({ + objectName: 'sys_user', + objects: [ + { name: 'sys_user', fields: { phone_number: { type: 'text', label: 'Phone' } } }, + ], + fieldLabel: (_o, _f, fallback) => fallback, + ...over, +}); + +const REASON = { en: 'Reason', 'zh-CN': '原因' }; + +describe('resolveActionParams — inline per-locale param labels (#4163)', () => { + it('resolves a map label for the threaded locale, on an inline param', () => { + const params: RawActionParam[] = [{ name: 'reason', label: REASON }]; + expect(resolveActionParams(params, ctx({ locale: 'zh-CN' }))[0].label).toBe('原因'); + }); + + it('resolves the same map differently for a different locale', () => { + // The half that proves the LOCALE is read, not just that a string came out. + const params: RawActionParam[] = [{ name: 'reason', label: REASON }]; + expect(resolveActionParams(params, ctx({ locale: 'en' }))[0].label).toBe('Reason'); + }); + + it('never emits a non-string label', () => { + const params: RawActionParam[] = [{ name: 'reason', label: REASON }]; + const label = resolveActionParams(params, ctx({ locale: 'zh-CN' }))[0].label; + expect(typeof label).toBe('string'); + expect(String(label)).not.toBe('[object Object]'); + }); + + it('falls back to `en` when no locale is threaded', () => { + // `locale` is optional and nullish-tolerant; the spec's resolver documents + // "no locale known" as resolving to `en`. + const params: RawActionParam[] = [{ name: 'reason', label: REASON }]; + expect(resolveActionParams(params, ctx())[0].label).toBe('Reason'); + }); + + it('leaves a plain-string label exactly as authored', () => { + // Non-vacuity for every assertion above. + const params: RawActionParam[] = [{ name: 'reason', label: 'Why?' }]; + expect(resolveActionParams(params, ctx({ locale: 'zh-CN' }))[0].label).toBe('Why?'); + }); + + it('still falls through to `name` when the label is absent', () => { + // The `?? param.name` chain has to keep working: the resolver answers + // `undefined` for an absent label, which is nullish, so the chain proceeds. + const params: RawActionParam[] = [{ name: 'reason' }]; + expect(resolveActionParams(params, ctx())[0].label).toBe('reason'); + }); + + it('still falls through to `fieldLabel` on a field-backed param', () => { + const params: RawActionParam[] = [{ field: 'phone_number' }]; + expect(resolveActionParams(params, ctx())[0].label).toBe('Phone'); + }); + + it('an authored EMPTY label still wins over the fallbacks', () => { + // `''` is not nullish and the resolver returns it verbatim — "an author who + // wrote an empty label wrote a label". Pinned because a resolver that + // answered `undefined` for `''` would silently change this branch. + const params: RawActionParam[] = [{ name: 'reason', label: '' }]; + expect(resolveActionParams(params, ctx())[0].label).toBe(''); + }); + + it('a map that matches no limb falls through to the fallback, not to `[object Object]`', () => { + const params: RawActionParam[] = [{ name: 'reason', label: {} }]; + expect(resolveActionParams(params, ctx())[0].label).toBe('reason'); + }); + + it('resolves a map label on the field-backed branch too', () => { + const params: RawActionParam[] = [{ field: 'phone_number', label: REASON }]; + expect(resolveActionParams(params, ctx({ locale: 'zh-CN' }))[0].label).toBe('原因'); + }); + + it('resolves a map label on the missing-field fallback branch too', () => { + // Three emission sites, three pins — a fix applied to one leaves the others + // shipping the map, with every type still green. + const params: RawActionParam[] = [{ field: 'does_not_exist', label: REASON }]; + expect(resolveActionParams(params, ctx({ locale: 'zh-CN' }))[0].label).toBe('原因'); + }); +}); + +describe('resolveActionParams — inline per-locale OPTION labels (#4163)', () => { + const options = [ + { value: 'high', label: { en: 'High', 'zh-CN': '高' }, color: 'red' }, + { value: 'low', label: 'Low' }, + ]; + + it('resolves each inline option label for the threaded locale', () => { + const params: RawActionParam[] = [{ name: 'priority', type: 'select', options }]; + const resolved = resolveActionParams(params, ctx({ locale: 'zh-CN' }))[0].options!; + expect(resolved.map((o) => o.label)).toEqual(['高', 'Low']); + }); + + it('never emits a non-string option label', () => { + // Inline options pass through VERBATIM (that asymmetry is objectui#3559's + // deliberate shape), so without a crossing point the map rode straight into + // the select widget — the silent `[object Object]` this card is about. + const params: RawActionParam[] = [{ name: 'priority', type: 'select', options }]; + const resolved = resolveActionParams(params, ctx({ locale: 'zh-CN' }))[0].options!; + for (const option of resolved) expect(typeof option.label).toBe('string'); + }); + + it('preserves every other key the option declared', () => { + // objectui#3559's rule: resolve the label, rebuild nothing. A fresh + // `{ label, value }` would drop `color` / `visibleWhen` / `icon` / + // `disabled` and no assertion about labels would notice. + const params: RawActionParam[] = [{ name: 'priority', type: 'select', options }]; + const resolved = resolveActionParams(params, ctx({ locale: 'zh-CN' }))[0].options!; + expect(resolved[0]).toMatchObject({ value: 'high', color: 'red' }); + }); + + it('falls back to `value` for an option whose label resolves to nothing', () => { + // Which is exactly what the bare-string option shorthand already means + // (`{ label: s, value: s }`). + const params: RawActionParam[] = [ + { name: 'priority', type: 'select', options: [{ value: 'high' }] }, + ]; + expect(resolveActionParams(params, ctx())[0].options![0].label).toBe('high'); + }); + + it('leaves a field-inherited option list alone', () => { + // Non-vacuity in the other direction: the field branch goes through + // `normaliseOptions`, not the new crossing point, and must be unchanged. + const params: RawActionParam[] = [{ field: 'status' }]; + const resolved = resolveActionParams( + params, + ctx({ + objects: [ + { + name: 'sys_user', + fields: { status: { type: 'select', options: ['active', 'closed'] } }, + }, + ], + }), + )[0].options!; + expect(resolved.map((o) => o.label)).toEqual(['active', 'closed']); + }); +}); diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/app-shell/src/utils/resolveActionParams.ts index 0fa30349f4..0fa487511d 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/app-shell/src/utils/resolveActionParams.ts @@ -30,6 +30,11 @@ * names it via {@link RESOLVED_ONLY_PARAM_KEYS} rather than reading it. */ import type { ActionParamDef, ActionParamOption } from '@object-ui/core'; +import type { I18nLabel } from '@objectstack/spec/ui'; +// Aliased per PR #4169's convention — app-shell has its OWN `resolveI18nLabel` +// (renamed `resolveKeyedI18nLabel` by objectui#4167) over the translation-KEY +// vocabulary, which does not accept the inline per-locale map this resolves. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; /** * Resolved params keep raw `FieldType` values (`text` / `email` / `select` / @@ -39,12 +44,58 @@ import type { ActionParamDef, ActionParamOption } from '@object-ui/core'; * adapter (ADR-0059). */ +/** + * One option as AUTHORED on an action param — {@link ActionParamOption} with + * the label on the authoring side of rc.6's `I18nLabel` widening. + * + * The distinction is the whole point of the authored/resolved split: an author + * may write `{ value: 'high', label: { en: 'High', 'zh-CN': '高' } }`, and + * `ActionParamDialog` reads `ActionParamOption.label` as a plain `string`. Left + * un-narrowed, the map would ride an INLINE option list straight through to the + * select widget (inline options pass through verbatim — see `resolvedOptions`), + * and render as `[object Object]` with no diagnostic anywhere. So the two types + * are kept apart and {@link resolveOptionLabels} is the one crossing point. + * + * Spelled out rather than `Omit & { … }`: `Omit` + * over a type carrying `[key: string]: unknown` resolves `Exclude` back to `string | number`, so it drops `value`'s type AND keeps + * `label` as `unknown` — the derivation silently erases exactly the two keys it + * is supposed to be about. + */ +export type RawActionParamOption = { + /** Authored label — a plain string, or rc.6's inline per-locale map. */ + label?: I18nLabel; + value: string; + /** + * Everything else an option declares (`visibleWhen` / `color` / `icon` / + * `disabled`), preserved verbatim — objectui#3559. + */ + [key: string]: unknown; +}; + /** Raw param as authored on a schema action (post-zod). */ export interface RawActionParam { name?: string; field?: string; objectOverride?: string; - label?: string; + /** + * Display label, as authored. `@objectstack/spec` 17.0.0-rc.6 widened + * `I18nLabel` from `string` to `string | Record`, so an + * author may inline a per-locale map here + * (`label: { en: 'Reason', 'zh-CN': '原因' }`) — the same widening the spec's + * `ActionParamSchema.label` carries. + * + * This restatement is the load-bearing half (objectui#3174): when it drifts + * from what `@object-ui/types`' `ActionParam` says an author may write, this + * resolver stops accepting the public authoring type. That drift is exactly + * what a widened `I18nLabel` produced, and it is why the fix belongs on this + * DECLARATION rather than on the test that surfaced it. + * + * `resolveActionParams` emits `ActionParamDef.label`, which is a plain + * `string` — so the map is resolved on the way out (see {@link + * ResolveActionParamsContext.locale}), never forwarded. + */ + label?: I18nLabel; type?: string; required?: boolean; /** @@ -52,8 +103,13 @@ export interface RawActionParam { * resolved side ({@link ActionParamOption}): the two keys this resolver reads * plus a catch-all for whatever else an option declares (`visibleWhen`, * `color`, `icon`, `disabled`) — objectui#3559. + * + * One key differs, and it is the same rc.6 widening as {@link + * RawActionParam.label} one level down: an option's `label` is authored as + * `I18nLabel` and RESOLVED to a `string` before it reaches + * `ActionParamOption`. See {@link RawActionParamOption}. */ - options?: ActionParamOption[]; + options?: RawActionParamOption[]; placeholder?: string; helpText?: string; defaultValue?: unknown; @@ -104,6 +160,49 @@ function paramName(param: RawActionParam): string | undefined { return param.name ?? param.field; } +/** + * The one crossing point from {@link RawActionParamOption} to + * {@link ActionParamOption}: resolve each authored `label` — a plain string or + * rc.6's inline per-locale map — to the single string the dialog renders. + * + * Everything else is preserved by spread, for the same reason + * {@link normaliseOptions} preserves it (objectui#3559): rebuilding a fresh + * `{ label, value }` silently drops `visibleWhen` / `color` / `icon` / + * `disabled`. A label that resolves to nothing falls back to `value`, which is + * what a bare-string option already means (`{ label: s, value: s }`). + */ +function resolveOptionLabels( + options: RawActionParamOption[] | undefined, + locale: string | undefined, +): ActionParamOption[] | undefined { + if (!options) return undefined; + // Identity-preserving fast path, and it is load-bearing rather than an + // optimisation: objectui#3559 pins that an INLINE option list reaches the + // dialog *verbatim* — `toBe`, not `toEqual` — because the bug it closed was a + // rebuild that dropped `visibleWhen`. A list with nothing to resolve is + // therefore returned untouched, so that pin stays true as written; only a + // list that actually carries a map is rebuilt, and then by spread. + if (everyLabelResolved(options)) return options; + return options.map((option) => + typeof option.label === 'string' + ? (option as ActionParamOption) + : { + ...option, + // `?? option.value` is what a bare-string option already means + // (`{ label: s, value: s }`), and it is forced rather than chosen: + // `ActionParamOption.label` is a required `string`. + label: resolveInlineI18nLabel(option.label, locale) ?? option.value, + }, + ); +} + +/** True when no option carries a label the dialog cannot render as-is. */ +function everyLabelResolved( + options: RawActionParamOption[], +): options is ActionParamOption[] { + return options.every((option) => typeof option.label === 'string'); +} + /** * The key a `defaultFromRow` param reads off the row record — `field` wins here, * because row data is keyed by OBJECT FIELD. The mirror image of @@ -234,6 +333,17 @@ export interface ResolveActionParamsContext { * the row's current values pre-filled. */ row?: Record; + /** + * Active UI language (BCP-47) used to resolve an inline per-locale + * {@link RawActionParam.label} down to the one string `ActionParamDef.label` + * can carry. Both callers thread `useObjectTranslation().language`. + * + * Optional and nullish-tolerant: omitted means "no locale known", which the + * spec's resolver documents as resolving to `en`. It is a context field + * rather than a hook read because this is a pure function — the caller is + * the component that already knows the language. + */ + locale?: string; } /** @@ -306,14 +416,26 @@ export function resolveActionParam( ? ctx.row[rowKey] : undefined; + /** + * The authored label, resolved to the one string `ActionParamDef` carries. + * + * `?? param.name` / `?? ctx.fieldLabel(…)` keeps working unchanged: the + * resolver answers `undefined` for an absent label AND for a map no limb + * matched, and returns `''` verbatim for an authored empty string — which is + * what `param.label ?? …` did before, since `''` is not nullish. + */ + const authoredLabel = resolveInlineI18nLabel(param.label, ctx.locale); + /** Inline options, with each authored label narrowed to the resolved side. */ + const authoredOptions = resolveOptionLabels(param.options, ctx.locale); + // Inline param — no field reference, just normalise. if (!param.field) { return { name: param.name ?? '', - label: param.label ?? param.name ?? '', + label: authoredLabel ?? param.name ?? '', type: param.type ?? 'text', required: param.required ?? false, - options: param.options, + options: authoredOptions, placeholder: param.placeholder, helpText: param.helpText, defaultValue: rowDefault ?? param.defaultValue, @@ -338,10 +460,10 @@ export function resolveActionParam( // partial (e.g. tests). return { name: paramName(param) ?? param.field, - label: param.label ?? ctx.fieldLabel(ownerName, param.field, param.field), + label: authoredLabel ?? ctx.fieldLabel(ownerName, param.field, param.field), type: param.type ?? 'text', required: param.required ?? false, - options: param.options, + options: authoredOptions, placeholder: param.placeholder, helpText: param.helpText, defaultValue: rowDefault ?? param.defaultValue, @@ -356,9 +478,9 @@ export function resolveActionParam( } const resolvedType = param.type ?? field.type ?? 'text'; - const resolvedOptions = param.options + const resolvedOptions = authoredOptions ?? normaliseOptions(field.options, ownerName, param.field, ctx.fieldOptionLabel); - const resolvedLabel = param.label + const resolvedLabel = authoredLabel ?? ctx.fieldLabel(ownerName, param.field, field.label ?? param.field); /** Lookup/reference params carry extra picker config that the dialog diff --git a/packages/app-shell/src/views/DashboardView.tsx b/packages/app-shell/src/views/DashboardView.tsx index ceb81821cf..1175e08008 100644 --- a/packages/app-shell/src/views/DashboardView.tsx +++ b/packages/app-shell/src/views/DashboardView.tsx @@ -34,7 +34,7 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector'; import { SkeletonDashboard } from '../skeletons'; import { useMetadata } from '../providers/MetadataProvider'; import { useExpressionContext } from '../providers/ExpressionProvider'; -import { resolveI18nLabel, preferLocal } from '../utils'; +import { resolveKeyedI18nLabel, preferLocal } from '../utils'; import { useAdapter } from '../providers/AdapterProvider'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; @@ -182,8 +182,8 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { // fall back to `label` (the metadata display name) and finally to // the raw `name`. const headerSrc = (previewSchema as any) || dashboard; - const resolvedTitle = resolveI18nLabel(headerSrc.title, t); - const resolvedLabel = resolveI18nLabel(dashboard.label, t); + const resolvedTitle = resolveKeyedI18nLabel(headerSrc.title, t); + const resolvedLabel = resolveKeyedI18nLabel(dashboard.label, t); const fallbackLabel = dashboardLabel({ name: dashboard.name, label: resolvedLabel }); const display = resolvedTitle || fallbackLabel || dashboard.name; return ( @@ -195,7 +195,7 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { const rawDesc = headerSrc.description ?? dashboard.description; const desc = dashboardDescription({ name: dashboard.name, - description: resolveI18nLabel(rawDesc, t), + description: resolveKeyedI18nLabel(rawDesc, t), }); return desc ? (

{desc}

diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index fa4a2ff530..63f5cf6629 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -485,6 +485,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri fieldLabel, fieldOptionLabel, row, + // Resolves an inline per-locale `label` map (rc.6's widened + // `I18nLabel`) for the active language — objectui#4163. + locale: language, }); // Localize param label/placeholder/helpText (see ObjectView for the // convention); falls back to the metadata literal. diff --git a/packages/app-shell/src/views/SearchResultsPage.tsx b/packages/app-shell/src/views/SearchResultsPage.tsx index a06b02ce51..a44a2e37d4 100644 --- a/packages/app-shell/src/views/SearchResultsPage.tsx +++ b/packages/app-shell/src/views/SearchResultsPage.tsx @@ -28,7 +28,7 @@ import { useRecordSearch } from '@object-ui/react'; import { useMetadata } from '../providers/MetadataProvider'; import { useAdapter } from '../providers/AdapterProvider'; import { matchAppBySegment } from '../utils/appRoute'; -import { resolveI18nLabel, getRecordDisplayName } from '../utils'; +import { resolveKeyedI18nLabel, getRecordDisplayName } from '../utils'; import { getIcon } from '../utils/getIcon'; import { resolveHref } from '@object-ui/layout'; import { useAuth } from '@object-ui/auth'; @@ -174,7 +174,7 @@ export function SearchResultsPage() { let group = byObject.get(hit.objectName); if (!group) { const objDef = objectsByName.get(hit.objectName); - const label = resolveI18nLabel(objDef?.label, t) || hit.objectLabel; + const label = resolveKeyedI18nLabel(objDef?.label, t) || hit.objectLabel; group = { label, icon: objDef?.icon ?? hit.icon, hits: [] }; byObject.set(hit.objectName, group); order.push(hit.objectName); diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index 90f66a01bb..bcff036120 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -265,6 +265,50 @@ ComponentRegistry.register('record_picker', ElementRecordPickerRenderer, { { name: 'valueField', type: 'string', label: 'Value Field' }, { name: 'placeholder', type: 'string', label: 'Placeholder' }, { name: 'label', type: 'string', label: 'Label' }, + // ── sort / limit / emptyText — declared on the rc.6 bump (objectui#4167) ── + // `@objectstack/spec` 17.0.0-rc.6 lands objectstack#5775's other half: these + // three arrive as newly DECLARED keys on `ElementRecordPickerProps`, and the + // reverse direction of the parity gate went red demanding them the moment + // the pin moved. That red was predicted, in writing, by the exemption that + // covered the retired trio ("`sort` / `limit` / `emptyText` … become + // brand-new A-class gaps, and this gate will go RED demanding them. That red + // is correct and wanted"). All three were already READ here before they were + // declared anywhere, which is the objectui#3407 shape — honoured, and + // undiscoverable to every layer that reads a manifest. + { + name: 'sort', + // `'array'` is the spec's shape, not a chosen arm: `sort` is + // `z.array(z.object({ field, order: 'asc'|'desc' })).optional()`. Verified + // against `ElementRecordPickerPropsSchema.safeParse` — the array of + // `{ field, order }` parses, and the terse string spelling `'name asc'` + // does NOT, which is worth saying in the description because it is the + // form an author is most likely to reach for. + type: 'array', + label: 'Sort', + description: + 'Row order, as an array of `{ field, order }` entries — `[{ field: "name", order: "asc" }]`. It becomes the `$orderby` of the picker\'s own query, so it decides the order records are offered in. `order` is `asc` or `desc`; the terse string form (`"name asc"`) is not accepted by the contract. PRECEDENCE: identical to `filter` above and for the same reason — the renderer reads `dataSource.sort ?? sort`, so a node-level `dataSource` binding (or the saved view its `view` names) REPLACES this key outright rather than merging with it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `sort` unset.', + }, + { + name: 'limit', + type: 'number', + label: 'Limit', + description: + 'Maximum number of records the picker offers, as a whole number. It becomes the `$top` of the picker\'s own query, so it bounds what the user can choose from rather than how the list is displayed — a record outside the limit cannot be picked at all, and the control gives no sign that more exist. DEFAULT: 50 when neither this nor `dataSource.limit` is set, applied by the renderer (`record-picker.tsx:107`), not by the schema. PRECEDENCE: `dataSource.limit ?? limit ?? 50` — a node-level binding wins outright.', + }, + { + name: 'emptyText', + // A NARROWED type, named here for the objectui#3832 reason: the contract + // is `string | Record< string, string >` — rc.6 widened it to the same + // `I18nLabel` union it widened everywhere else — and `ComponentInput.type` + // is one coarse control kind with no way to spell a union. Publishing + // `'string'` therefore describes the form this renderer actually resolves, + // and the description carries the half the type cannot, which is the same + // treatment `element:text_input.defaultValue`'s `string | number` gets. + type: 'string', + label: 'Empty Text', + description: + 'Text shown in place of the row list when the query returns no records (renderer default "No records", `record-picker.tsx:213`). Unlike `filter` / `sort` / `limit` this is display-only — it never reaches the query, and a node-level `dataSource` binding does not override it. KNOWN GAP: the contract also accepts an inline per-locale map (`{ en: "None", "zh-CN": "无记录" }`) — rc.6 widened this key to `I18nLabel` — and this renderer passes the value straight into a text node with no locale resolution, so only the plain-string form renders today. Tracked with the rest of the widened-`I18nLabel` render-site audit in objectui#4163; this input publishes the string form deliberately rather than advertising a shape the renderer drops.', + }, ], }); diff --git a/packages/components/src/renderers/data-display/breadcrumb.tsx b/packages/components/src/renderers/data-display/breadcrumb.tsx index 04c497bfe0..ae910a8121 100644 --- a/packages/components/src/renderers/data-display/breadcrumb.tsx +++ b/packages/components/src/renderers/data-display/breadcrumb.tsx @@ -10,7 +10,7 @@ import { ComponentRegistry } from '@object-ui/core'; import type { BreadcrumbSchema } from '@object-ui/types'; import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from '../../ui/breadcrumb'; import { renderChildren } from '../../lib/utils'; -import { resolveI18nLabel } from '@object-ui/react'; +import { resolveKeyedI18nLabel } from '@object-ui/react'; ComponentRegistry.register('breadcrumb', ({ schema, ...props }: { schema: BreadcrumbSchema; [key: string]: any }) => { @@ -32,9 +32,9 @@ ComponentRegistry.register('breadcrumb',
{idx === (schema.items?.length || 0) - 1 ? ( - {resolveI18nLabel(item.label) ?? ''} + {resolveKeyedI18nLabel(item.label) ?? ''} ) : ( - {resolveI18nLabel(item.label) ?? ''} + {resolveKeyedI18nLabel(item.label) ?? ''} )} {idx < (schema.items?.length || 0) - 1 && } diff --git a/packages/components/src/renderers/navigation/header-bar.tsx b/packages/components/src/renderers/navigation/header-bar.tsx index 6e35a228a5..f9592541b5 100644 --- a/packages/components/src/renderers/navigation/header-bar.tsx +++ b/packages/components/src/renderers/navigation/header-bar.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { HeaderBarSchema, BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; -import { resolveI18nLabel, SchemaRenderer } from '@object-ui/react'; +import { resolveKeyedI18nLabel, SchemaRenderer } from '@object-ui/react'; import { SidebarTrigger, Separator, @@ -28,7 +28,7 @@ import { import { ChevronDown, Search } from 'lucide-react'; function BreadcrumbLabel({ crumb, isLast }: { crumb: BreadcrumbItemType; isLast: boolean }) { - const label = resolveI18nLabel(crumb.label) ?? ''; + const label = resolveKeyedI18nLabel(crumb.label) ?? ''; if (crumb.siblings && crumb.siblings.length > 0) { return ( diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index ca5c20126a..fcff965d1d 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -13,6 +13,7 @@ import { buildFilterCondition, buildWidgetScopedFilter, DATE_RANGE_FILTER_NAME, + DATE_RANGE_PRESETS, type DashboardFilterDef, } from '../dashboard-filters'; import { mergeFilters } from '../merge-filters'; @@ -434,3 +435,50 @@ describe('mergeFilters', () => { expect(mergeFilters({}, undefined)).toBeUndefined(); }); }); + +/** + * `DATE_RANGE_PRESETS` stopped being `Object.keys(PRESET_RANGES)` and became a + * re-export of the spec's const in objectui#4167 (objectstack#4115). + * + * A COPY of a const passes every value comparison — that insight is why the + * guard's own header says reference identity is the only check that separates a + * re-export from a fork, and it is the one assertion a `toEqual` on the member + * list cannot make. So the first test asks `toBe`, deliberately. + * + * The second is the other half of the burn-down and the one with teeth at + * runtime: the spec owns which presets EXIST, this module owns what each one + * RESOLVES TO, and nothing but the `satisfies` in `dashboard-filters.ts` ties + * them together. That is a compile-time pin, so it is erased here — this test + * is what makes the same claim visible in a suite run, and what would catch a + * future edit that re-annotated the table as `Record` (a string index + * signature satisfies every literal key, so the `satisfies` would go vacuously + * green while the bounds table quietly went missing entries). + */ +describe('DATE_RANGE_PRESETS is the spec\'s list, not a copy of it', () => { + it('is the spec\'s own array by REFERENCE, not an equal one', async () => { + const spec = await import('@objectstack/spec/ui'); + expect(DATE_RANGE_PRESETS).toBe(spec.DATE_RANGE_PRESETS); + }); + + it('every offered preset resolves to date-macro bounds', () => { + // `buildFilterCondition` warns and drops a preset it has no bounds for, so a + // preset in this list with no entry in the bounds table is a filter that + // validates clean and then selects nothing — the exact failure the spec's + // own comment on DATE_RANGE_PRESETS describes. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + for (const preset of DATE_RANGE_PRESETS) { + const built = buildFilterCondition( + { name: 'd', field: 'created_at', type: 'dateRange' }, + { preset }, + ); + expect(built, `preset "${preset}" produced no condition`).toBeDefined(); + } + expect( + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('unknown date range preset')), + ).toEqual([]); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index 2e56fb34e5..77b5658c95 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -21,6 +21,7 @@ */ import type { DashboardComponentSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types'; +import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/ui'; import { resolveDateMacros } from './date-macros.js'; /** Reserved filter name for the dashboard's built-in date range. */ @@ -69,8 +70,20 @@ export interface DateRangeValue { * Date-range presets → date-macro token bounds. Tokens stay symbolic in the * generated condition; every widget renderer resolves them at query time via * `resolveDateMacros`, exactly like hand-authored widget filters. + * + * `satisfies Record` is the load-bearing half of + * objectui#4167, not decoration. `DATE_RANGE_PRESETS` below is now the spec's + * list rather than `Object.keys` of this table, so the two could otherwise + * drift in the one direction the spec's own comment on that const names as the + * failure mode: a preset the SCHEMA knows and this table has no bounds for + * "validates clean and then resolves to nothing" — it would reach the filter + * bar's dropdown, be selected, and produce no range. The `satisfies` makes that + * a compile error at the moment the spec adds a preset (missing key), and makes + * a local invention a compile error too (excess key). The annotation form + * `const PRESET_RANGES: Record` would NOT do this: a string + * index signature satisfies every literal key, so the check passes vacuously. */ -const PRESET_RANGES: Record = { +const PRESET_RANGES = { today: { from: '{today}', to: '{today}' }, yesterday: { from: '{yesterday}', to: '{yesterday}' }, this_week: { from: '{current_week_start}', to: '{current_week_end}' }, @@ -84,10 +97,42 @@ const PRESET_RANGES: Record = { last_7_days: { from: '{7_days_ago}', to: '{today}' }, last_30_days: { from: '{30_days_ago}', to: '{today}' }, last_90_days: { from: '{90_days_ago}', to: '{today}' }, -}; +} satisfies Record; + +/** + * Bounds for a preset NAME that may not be one. + * + * The runtime receives whatever a stored dashboard carries, so an unrecognised + * name must warn (see `buildFilterCondition`) rather than fail to compile. That + * read is confined here so the `satisfies` pin above stays the single place + * deciding which names exist — an unguarded `PRESET_RANGES[someString]` would + * have forced the table back to a `Record` and taken the pin with it. + */ +function presetBounds(preset: string): { from?: string; to?: string } | undefined { + return (PRESET_RANGES as Record)[preset]; +} -/** Preset keys the filter bar offers, in display order. */ -export const DATE_RANGE_PRESETS = Object.keys(PRESET_RANGES); +/** + * Preset keys the filter bar offers, in display order — the spec's list, + * RE-EXPORTED since objectui#4167 rather than derived from the local bounds + * table (objectstack#4115). + * + * `@objectstack/spec` 17.0.0-rc.6 publishes `DATE_RANGE_PRESETS`, and its own + * doc comment names this module as one of the three copies the extraction + * (objectstack#4614) existed to collapse: "it used to exist three times — + * inline in `dateRange.defaultRange`, as `PRESET_RANGES` in objectui's + * `dashboard-filters`, and as a hand-written table in + * `content/docs/ui/dashboards.mdx`". So the burn-down direction here is not a + * judgement call, it is upstream's stated intent, and the two lists were + * already identical in content AND display order — the copy had nothing to + * protect. + * + * What stays local is the BOUNDS table above, which is the other half of + * #4614's design ("the two vocabularies live one import apart and neither + * restates the other's grammar"): the spec owns which presets exist, this + * module owns what each one resolves to in date-macro tokens. + */ +export { DATE_RANGE_PRESETS }; /** * ISO calendar date, optionally carrying a time part — `2026-01-15`, @@ -285,7 +330,7 @@ export function buildFilterCondition( const v = value as DateRangeValue; if (typeof v === 'object') { const preset = typeof v.preset === 'string' && v.preset ? v.preset : undefined; - const range = preset ? PRESET_RANGES[preset] : undefined; + const range = preset ? presetBounds(preset) : undefined; const from = range?.from ?? v.from; const to = range?.to ?? v.to; if (preset && !range) { diff --git a/packages/data-objectstack/src/spec-symbol-batch6.test.ts b/packages/data-objectstack/src/spec-symbol-batch6.test.ts index ffb9a1ef14..00973a48f2 100644 --- a/packages/data-objectstack/src/spec-symbol-batch6.test.ts +++ b/packages/data-objectstack/src/spec-symbol-batch6.test.ts @@ -205,7 +205,27 @@ describe('DroppedFieldsEvent IS the spec type, not a mirror of it', () => { // reason had appeared. `Equal` is used rather than `extends` because // `'readonly' | 'readonly_when' extends string` is true in the drifted // direction too. - type _ReasonIsTheEnum = Assert>; + // + // THIS PIN FIRED, and its firing is the whole point (objectui#4167). + // `@objectstack/spec` 17.0.0-rc.6 added a third arm, `primary_key`, and this + // assertion is what reported it — a `tsc` error naming the exact widening, + // on a bump whose build and full vitest run were both green. Under the + // deleted hand copy's bare `string` the arm would have arrived silently, and + // `writeWarningToast` would have narrated it as "Read-only, so it did not + // take effect" with nobody the wiser. + // + // The member list is updated here; the CONSUMER gap is objectui#3935, which + // was filed BEFORE this arm shipped and named this exact moment as its + // activation condition ("the moment `.objectui-sha`'s spec pin moves past + // that change, a user who triggers the primary-key strip is told the field + // is read-only"). rc.6 is that moment, so #3935 stops being a prediction. + // Not fixed here: `emitWriteWarning`'s binary ternary needs replacing with + // an exhaustive map plus a third wording in both locale tables, which is a + // UX and i18n read of its own rather than bump adaptation. #3935 owns it and + // already prescribes the shape. + type _ReasonIsTheEnum = Assert< + Equal + >; type _ReasonIsNotString = Assert, false>>; expect(true).toBe(true); diff --git a/packages/fields/src/widgets/AddressField.tsx b/packages/fields/src/widgets/AddressField.tsx index b513272773..c535de1563 100644 --- a/packages/fields/src/widgets/AddressField.tsx +++ b/packages/fields/src/widgets/AddressField.tsx @@ -14,7 +14,12 @@ import { type LegacyAddressValue, } from './address-format'; -export type { AddressValue }; +// Re-exported through its declaring module rather than bare, so the spec-symbol +// guard resolves the name to where it is actually defined: `address-format` +// imports `AddressValue` from `@objectstack/spec/data` (objectui#4167), and a +// bare `export type { AddressValue }` here would read to that guard as a second, +// local declaration of a name the spec owns. +export type { AddressValue } from './address-format'; /** * Address field widget - provides a structured address input diff --git a/packages/fields/src/widgets/address-format.ts b/packages/fields/src/widgets/address-format.ts index 6c883b44b4..9ddf8c639e 100644 --- a/packages/fields/src/widgets/address-format.ts +++ b/packages/fields/src/widgets/address-format.ts @@ -26,9 +26,11 @@ * `AddressField` (and its inputs) out of the lazily-loaded widget chunk. */ +import type { AddressValue } from '@objectstack/spec/data'; + /** - * Address data structure — the part names of `@objectstack/spec`'s - * `AddressSchema`, which is what the platform stores and what + * Address data structure — `z.input` of `@objectstack/spec`'s + * `AddressValueSchema`, which is what the platform stores and what * `/api/v1/data/**` serves back. * * The postal code is spelled `postalCode` (objectstack#5143). `AddressField` @@ -41,14 +43,28 @@ * spread the whole stored object through; the issue's account of that half is * corrected in `AddressField.postalCode.test.tsx`.) One name, on both sides: * the contract's. + * + * IMPORTED rather than declared since objectui#4167. rc.6 publishes + * `AddressValue` under this exact name, and the local copy — whose own comment + * already claimed to be "the part names of `AddressSchema`" — declared five of + * the seven parts. `countryCode` and `formatted` were missing, which is + * objectstack#4115's failure class precisely: a canonical CLAIM over a narrower + * shape, believed by the next reader. The claim is now the code. + * + * `AddressField` still renders five inputs and {@link formatAddress} still + * composes five parts, and that is a deliberate split rather than an omission: + * `formatted` is a derived one-line rendering of the parts (what this module + * computes) and `countryCode` is the ISO pair for `country`, neither of which a + * human types into a part box. Binding the type does not publish them as + * inputs — it stops the type from asserting the platform cannot store them, and + * it means a write from the widget no longer looks type-clean while dropping a + * key the contract carries: the `{ ...address }` spread in `handleFieldChange` + * preserves both, and now says so. + * + * The declaration moved here (objectui#4037) so the display cell renderer and + * the widget share one definition; the SOURCE of that definition is the spec. */ -export interface AddressValue { - street?: string; - city?: string; - state?: string; - postalCode?: string; - country?: string; -} +export type { AddressValue }; /** * The shape written by builds up to and including 17.0.0-rc.1, whose postal diff --git a/packages/layout/package.json b/packages/layout/package.json index 2a5add518c..220133f1a0 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -30,6 +30,7 @@ "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", + "@objectstack/spec": "^17.0.0-rc.6", "clsx": "^2.1.1", "lucide-react": "^1.29.0", "react": "19.2.8", @@ -42,7 +43,6 @@ "react-router-dom": "^6.0.0 || ^7.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.6", "@vitejs/plugin-react": "^6.0.5", "react-router-dom": "^7.18.2", "vite": "^8.2.1", diff --git a/packages/layout/src/AppSchemaRenderer.tsx b/packages/layout/src/AppSchemaRenderer.tsx index 1ca0fa6058..73d5e716f4 100644 --- a/packages/layout/src/AppSchemaRenderer.tsx +++ b/packages/layout/src/AppSchemaRenderer.tsx @@ -38,6 +38,10 @@ import { } from '@object-ui/components'; import type { AppComponentSchema, NavigationItem, NavigationArea } from '@object-ui/types'; import { menuItemToNavigationItem } from '@object-ui/types'; +// Aliased on import, following PR #4169's convention: this repo has its OWN +// `resolveI18nLabel` over a DIFFERENT vocabulary, and neither accepts the +// other's shape. See `resolveAreaLabel` below for which is which. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { AppShell, type AppShellBranding } from './AppShell'; import { NavigationRenderer, @@ -141,6 +145,52 @@ export interface AppSchemaRendererProps { * strict schema to reject. An area with no items at all derives the same way * (no visible item → hidden). */ +/** + * Resolve a `NavigationArea.label` — the spec's `I18nLabel` — to display text. + * + * ## Why the spec's resolver and not this package's `resolveLabel` + * + * There are two label vocabularies in play and they are NOT interchangeable + * (objectui#4167 renamed objectui's own resolver to keep them apart): + * + * - `NavigationItem.label` is objectui's KEYED ref — a translation key plus a + * default (`{ key, defaultValue, params }`) — resolved by {@link resolveLabel} + * against an injected `t`; + * - `NavigationArea.label` is `@objectstack/spec`'s `I18nLabel`, which + * 17.0.0-rc.6 widened from `string` to `string | Record` — + * the INLINE per-locale map the author writes directly in the metadata. + * + * Feeding a map to the keyed resolver returns `undefined` (no `key`, no + * `defaultValue`); feeding it to `String()` renders `[object Object]`. So this + * uses the producer's own shared resolver, `resolveI18nLabel` from + * `@objectstack/spec/ui`, which is the single rule for that vocabulary on both + * ends of the platform (objectstack#6761). + * + * ## Why no locale is threaded — a deliberate choice, not an omission + * + * `@object-ui/layout` carries **no i18n dependency by design**: this package's + * whole i18n story is injection (`NavigationRenderer` takes `t` and the label + * resolvers as arguments — "enables convention-based i18n auto-resolution + * without coupling the layout package to i18n"), and `AppSchemaRendererProps` + * exposes no locale, no `t`, and no context that carries one. Reaching for + * `@object-ui/i18n` here to read the live UI language would add exactly the + * coupling that design forbids, so the resolver is called with `undefined`, + * which it documents as "no locale known" and resolves as `en` — the platform's + * source language. + * + * The observable consequence, stated rather than hidden: an area whose label is + * an inline map renders its `en` entry (then `default`, then any entry) instead + * of the viewer's language. That is strictly better than `[object Object]`, and + * it is a floor, not a ceiling — the day a consumer needs per-viewer area + * labels, the fix is to thread a locale down as a prop from the host that + * already knows it, and this call is the one place it lands. Deliberately not + * done pre-emptively: no consumer of `AppSchemaRenderer` in this repo has a + * locale to give it today. + */ +function resolveAreaLabel(label: NavigationArea['label']): string { + return resolveInlineI18nLabel(label, undefined) ?? ''; +} + function AreaSwitcher({ areas, activeAreaId, @@ -162,15 +212,19 @@ function AreaSwitcher({ {areas.map((area) => { const AreaIcon = resolveIcon(area.icon); + // `NavigationArea.label` is the spec's `I18nLabel`, which since + // `@objectstack/spec` 17.0.0-rc.6 is `string | Record` — + // an author may inline `{ en: 'Sales', 'zh-CN': '销售' }` here. + const areaLabel = resolveAreaLabel(area.label); return ( onAreaChange(area.id)} > - {resolveLabel(area.label)} + {areaLabel} ); diff --git a/packages/layout/src/ResponsiveGrid.tsx b/packages/layout/src/ResponsiveGrid.tsx index f4a2b3eda6..fe56f6b8e5 100644 --- a/packages/layout/src/ResponsiveGrid.tsx +++ b/packages/layout/src/ResponsiveGrid.tsx @@ -8,32 +8,28 @@ import React from 'react'; import { cn } from '@object-ui/components'; +import type { BreakpointColumnMap, BreakpointOrderMap } from '@objectstack/spec/ui'; /** - * Spec-aligned breakpoint column map (mirrors @objectstack/spec BreakpointColumnMapSchema). - * Maps breakpoint names to grid column counts (1-12). - */ -export interface BreakpointColumnMap { - xs?: number; - sm?: number; - md?: number; - lg?: number; - xl?: number; - '2xl'?: number; -} - -/** - * Spec-aligned breakpoint order map (mirrors @objectstack/spec BreakpointOrderMapSchema). - * Maps breakpoint names to display order numbers. + * Breakpoint column map (`BreakpointColumnMapSchema`) and breakpoint order map + * (`BreakpointOrderMapSchema`), both re-exported from `@objectstack/spec/ui`. + * + * These were hand copies until objectui#4167, each carrying the word "mirrors" + * — the doc-comment shape objectstack#4115 catalogues, where the claim is what + * the next agent reads as canonical and the code is what actually decides. rc.6 + * publishes both names, and both copies happened to still be exact: six + * optional numbers keyed `xs`…`2xl`, on a `$strict` schema. "Still exact" is + * the argument FOR binding them, not against — the copies had nothing to + * protect, so the only thing they could do from here was drift, and a + * breakpoint the spec adds now arrives instead of silently not existing. + * + * `BreakpointOrderMap` has no read point in this package (`ResponsiveGrid` + * resolves columns only); it is published because `ResponsiveConfigSchema` + * pairs the two and an author configuring order needs the type. Bound rather + * than deleted for that reason — deleting a published type is a separate + * decision from stopping it being a fork. */ -export interface BreakpointOrderMap { - xs?: number; - sm?: number; - md?: number; - lg?: number; - xl?: number; - '2xl'?: number; -} +export type { BreakpointColumnMap, BreakpointOrderMap }; export interface ResponsiveGridProps { /** Grid column map per breakpoint */ diff --git a/packages/layout/src/__tests__/AppSchemaRenderer.areaI18nLabel.test.tsx b/packages/layout/src/__tests__/AppSchemaRenderer.areaI18nLabel.test.tsx new file mode 100644 index 0000000000..7e2ad31eaf --- /dev/null +++ b/packages/layout/src/__tests__/AppSchemaRenderer.areaI18nLabel.test.tsx @@ -0,0 +1,112 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4163 part 1 — `NavigationArea.label` is the spec's `I18nLabel`, and + * `@objectstack/spec` 17.0.0-rc.6 widened that from `string` to + * `string | Record`. The area switcher reads it twice (the + * button's `tooltip` and its visible ``), so an inline per-locale map + * used to reach objectui's KEYED resolver — which answers `undefined` for a map + * that carries no `key` — and, via the `tooltip` string slot, `[object Object]`. + * + * The pins below are DOM assertions rather than type assertions on purpose: the + * `tsc` error is the pre-fix red for the compile half, and it says nothing about + * *which* string a reader ends up seeing. Both halves have to hold. + * + * `@object-ui/layout` carries no i18n dependency by design, so the resolution + * runs at the spec's documented "no locale known" default (`en`) — see + * `resolveAreaLabel` in `AppSchemaRenderer.tsx` for why that is a choice and + * what would change it. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { AppComponentSchema, NavigationArea } from '@object-ui/types'; +import { AppSchemaRenderer } from '../AppSchemaRenderer'; + +function renderApp(schema: AppComponentSchema) { + return render( + + +
Page Content
+
+
, + ); +} + +/** Two areas — the switcher only renders with more than one. */ +const mapLabelledArea: NavigationArea = { + id: 'area-sales', + label: { en: 'Sales', 'zh-CN': '销售' }, + icon: 'Briefcase', + navigation: [ + { id: 'n1', type: 'object', label: 'Opportunities', objectName: 'opportunity' }, + ], +}; + +const stringLabelledArea: NavigationArea = { + id: 'area-service', + label: 'Service', + icon: 'Headphones', + navigation: [{ id: 'n2', type: 'object', label: 'Cases', objectName: 'case' }], +}; + +const schema: AppComponentSchema = { + type: 'app', + name: 'crm', + title: 'Sales CRM', + areas: [mapLabelledArea, stringLabelledArea], +}; + +describe('AppSchemaRenderer — inline per-locale area labels (#4163)', () => { + it('renders the resolved locale string for a map-valued area label', () => { + renderApp(schema); + expect(screen.getByText('Sales')).toBeTruthy(); + }); + + it('never renders the stringified object', () => { + const { container } = renderApp(schema); + // The whole harm this card exists for. Asserted on the full subtree rather + // than one node, because the map reaches TWO slots (visible text and the + // button's `tooltip`/`title`) and either alone would look fixed. + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('keeps the tooltip slot a real string, not a stringified map', () => { + renderApp(schema); + const button = screen.getByText('Sales').closest('button'); + expect(button).toBeTruthy(); + // `tooltip` is forwarded to the sidebar button; whatever attribute carries + // it must never hold the object's source text. + expect(button!.outerHTML).not.toContain('[object Object]'); + }); + + it('leaves a plain-string area label exactly as authored', () => { + // The non-vacuity half: if `resolveAreaLabel` were returning '' for + // everything, the assertion above would still pass. This one would not. + renderApp(schema); + expect(screen.getByText('Service')).toBeTruthy(); + }); + + it('falls back to `en` when the map has no entry for the default locale', () => { + // Limb 5 of the shared resolution rule: no locale is known here, so `en` + // is what the spec's resolver picks — the documented behaviour this + // package deliberately settles for. + renderApp({ + ...schema, + areas: [ + { ...mapLabelledArea, label: { 'zh-CN': '销售', en: 'Sales EN' } }, + stringLabelledArea, + ], + }); + expect(screen.getByText('Sales EN')).toBeTruthy(); + expect(screen.queryByText('销售')).toBeNull(); + }); +}); diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 47cbf104cd..5742c10c1c 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -4,6 +4,7 @@ import 'react-grid-layout/css/styles.css'; import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components'; import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react'; import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react'; +import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; import { isObjectProvider } from './utils'; import { classifyWidgetType } from './widgetDispatch'; @@ -97,6 +98,10 @@ export const DashboardGridLayout: React.FC = ({ const [editMode, setEditMode] = React.useState(false); const [refreshing, setRefreshing] = React.useState(false); const hasDndProvider = useHasDndProvider(); + // Active UI language, for resolving inline per-locale widget titles below. + // `useObjectTranslation` is provider-safe (react-i18next falls back to its + // global instance and never throws), so a standalone grid still renders. + const { language } = useObjectTranslation(); const intervalRef = React.useRef | null>(null); const handleRefresh = React.useCallback(() => { @@ -375,6 +380,13 @@ export const DashboardGridLayout: React.FC = ({ const widgetId = widget.id || `widget-${index}`; const componentSchema = getComponentSchema(widget); const isSelfContained = widget.type === 'metric'; + // `DashboardWidget.title` is the spec's `I18nLabel`: since + // 17.0.0-rc.6 an author may inline a per-locale map + // (`{ en: 'Pipeline', 'zh-CN': '销售漏斗' }`) instead of a string. + // Resolve it for the active UI language before it reaches the + // `title` attribute (a `string` slot) and the card heading (a text + // node) — both of which stringify a map to `[object Object]`. + const widgetTitle = pickLocalized(widget.title, language); return (
@@ -393,10 +405,10 @@ export const DashboardGridLayout: React.FC = ({ "bg-card/50 backdrop-blur-sm", editMode && "ring-2 ring-primary/20" )}> - {widget.title && ( + {widgetTitle && ( - - {widget.title} + + {widgetTitle} {editMode && (
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.i18nTitle.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.i18nTitle.test.tsx new file mode 100644 index 0000000000..14d4f0f0d3 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.i18nTitle.test.tsx @@ -0,0 +1,92 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4163 part 1 — `DashboardWidget.title` is the spec's `I18nLabel`, + * widened by `@objectstack/spec` 17.0.0-rc.6 from `string` to + * `string | Record`. + * + * The grid reads it into TWO slots of different kinds, which is why both are + * pinned: the card heading (a text node, where a map renders `[object Object]`) + * and the heading's `title` attribute (a `string | undefined` prop, where the + * map was a `tsc` error and would have become the object's source text). + * + * A third fact is pinned here too, and it is the one no type could have caught: + * the header's own render GATE was `widget.title &&`, and an object is always + * truthy. So a title map that resolves to nothing used to draw a header + * containing `[object Object]`; it now draws no header at all. + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { DashboardComponentSchema } from '@object-ui/types'; +import { DashboardGridLayout } from '../DashboardGridLayout'; + +// The grid renders each widget through `SchemaRenderer`; this suite is about +// the CHROME around it (card header / title attribute), so the inner renderer +// is stubbed to keep the assertions about the header alone. +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SchemaRenderer: () =>
, + }; +}); + +function schemaWith(title: unknown): DashboardComponentSchema { + return { + type: 'dashboard', + name: 'sales', + widgets: [ + { + id: 'w1', + type: 'bar', + // Cast at the fixture boundary only: the point of the test is what a + // spec-valid authored map does at the RENDER site. + title: title as never, + layout: { x: 0, y: 0, w: 6, h: 4 }, + }, + ], + } as DashboardComponentSchema; +} + +describe('DashboardGridLayout — inline per-locale widget titles (#4163)', () => { + it('renders the resolved locale string as the card heading', () => { + render(); + expect(screen.getByText('Pipeline')).toBeTruthy(); + }); + + it('never renders the stringified object, in the heading or the title attribute', () => { + const { container } = render( + , + ); + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('puts the resolved string in the heading `title` attribute, not the map', () => { + render(); + const heading = screen.getByText('Pipeline'); + expect(heading.getAttribute('title')).toBe('Pipeline'); + }); + + it('leaves a plain-string title exactly as authored', () => { + // Non-vacuity for the three assertions above. + render(); + expect(screen.getByText('Revenue')).toBeTruthy(); + expect(screen.getByText('Revenue').getAttribute('title')).toBe('Revenue'); + }); + + it('draws NO header for a title map that resolves to nothing', () => { + // The truthiness half. `{}` is truthy, so the old gate drew a header and + // filled it with `[object Object]`; the gate now tests the RESOLVED string. + const { container } = render(); + expect(container.innerHTML).not.toContain('[object Object]'); + expect(container.querySelector('[data-testid="widget-body"]')).toBeTruthy(); + }); +}); diff --git a/packages/plugin-designer/src/AppCreationWizard.tsx b/packages/plugin-designer/src/AppCreationWizard.tsx index 449f242697..ba55e0c887 100644 --- a/packages/plugin-designer/src/AppCreationWizard.tsx +++ b/packages/plugin-designer/src/AppCreationWizard.tsx @@ -48,7 +48,7 @@ import { } from 'lucide-react'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import { resolveI18nLabel } from '@object-ui/react'; +import { resolveKeyedI18nLabel } from '@object-ui/react'; import { useDesignerTranslation } from './hooks/useDesignerTranslation'; import { useConfirmDialog } from './hooks/useConfirmDialog'; @@ -527,7 +527,7 @@ function NavigationBuilderStep({ {item.icon} )} - {item.type === 'separator' ? t('appDesigner.separatorLabel') : resolveI18nLabel(item.label)} + {item.type === 'separator' ? t('appDesigner.separatorLabel') : resolveKeyedI18nLabel(item.label)} `, so a widget title may be an inline + * per-locale map (`{ en: 'Pipeline', 'zh-CN': '销售漏斗' }`). Every read that + * lands in a text node has to resolve it or React stringifies the object to + * `[object Object]`. + * + * `pickLocalized` is objectui's render-side resolver for that vocabulary, + * paired with the active UI language; `@objectstack/spec`'s own + * `resolveI18nLabel` implements the same rule, and the two are held limb for + * limb by `@object-ui/plugin-list`'s + * `src/__tests__/i18nLabel-resolver-parity.test.ts` — so the designer's preview + * of a dashboard and the runtime dashboard itself cannot start disagreeing + * about which locale entry wins. + * + * ⛔ This is for DISPLAY only. The title INPUT must not resolve through here — + * see `isAuthorableTitle`. + */ +function resolveWidgetTitle( + title: DashboardWidgetSchema['title'], + language: string | undefined, +): string { + return pickLocalized(title, language); +} + +/** + * Is this widget title editable in a single-line text input? + * + * The conservative branch PR #4169 took on `DashboardWidgetInspector`, applied + * to the other authoring surface, and the reason is data loss rather than + * types: resolving a per-locale map into one `` and writing + * `e.target.value` straight back would collapse **every other locale** on the + * first keystroke. An author who opened a dashboard to move a widget and + * happened to focus the title field would silently destroy the translations. + * + * So a map-valued title is shown resolved and **read-only**, and the stored map + * passes through an edit-and-save round trip untouched. Nothing can reach this + * path from stored metadata yet — `I18nLabel` was plain `string` through + * rc.5, so no persisted widget title can be a map — which is why the branch is + * safe to take without a ruling on the authoring UX. + * + * The real answer (a per-locale editor, a "translate this label" affordance, or + * a deliberate decision that Studio only ever authors the string form) is + * objectui#4163 **part 2**, which is unclaimed and pending design. This is a + * placeholder that cannot lose data, not that answer. + */ +function isAuthorableTitle( + title: DashboardWidgetSchema['title'], +): title is string | undefined { + return title == null || typeof title === 'string'; +} + // ============================================================================ // Widget Card // ============================================================================ @@ -125,6 +184,9 @@ function WidgetCard({ }: WidgetCardProps) { const wType = widget.type || 'metric'; const meta = WIDGET_TYPES.find((t) => t.type === wType) || WIDGET_TYPES[0]; + const { language } = useDesignerTranslation(); + // DISPLAY read of `widget.title` — see `resolveWidgetTitle`. + const title = resolveWidgetTitle(widget.title, language); return (
- {widget.title || `Widget ${index + 1}`} + {title || `Widget ${index + 1}`}
@@ -203,7 +265,10 @@ function WidgetPropertyPanel({ onChange, onClose, }: WidgetPropertyPanelProps) { - const { t } = useDesignerTranslation(); + const { t, language } = useDesignerTranslation(); + // Shown in the title input when the stored title is a map the input cannot + // safely author — resolved for reading, never written back. + const titleDisplay = resolveWidgetTitle(widget.title, language); return (
- {/* Title */} + {/* Title — the ONE authoring (not display) read of `widget.title`, and the + only place where following rc.6's widening mechanically would destroy + data. A map-valued title is shown resolved and READ-ONLY so the other + locales survive; see `isAuthorableTitle` for why, and objectui#4163 + part 2 for the authoring design this is standing in for. */}
onChange({ title: e.target.value })} + value={isAuthorableTitle(widget.title) ? widget.title ?? '' : titleDisplay} + onChange={(e) => { + // Guarded, not cast: without this the keystroke would replace an + // inline locale map with one locale's string. + if (!isAuthorableTitle(widget.title)) return; + onChange({ title: e.target.value }); + }} + readOnly={!isAuthorableTitle(widget.title)} disabled={readOnly} - className="block w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50" + className="block w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50 read-only:bg-gray-50 read-only:text-gray-500" />
@@ -320,7 +395,7 @@ function WidgetPropertyPanel({ // ============================================================================ function DashboardPreview({ schema }: { schema: DashboardComponentSchema }) { - const { t } = useDesignerTranslation(); + const { t, language } = useDesignerTranslation(); const widgets = schema.widgets || []; return (
@@ -338,7 +413,8 @@ function DashboardPreview({ schema }: { schema: DashboardComponentSchema }) {
- {w.title || 'Untitled'} + {/* DISPLAY read — resolve the inline locale map form. */} + {resolveWidgetTitle(w.title, language) || 'Untitled'}
{meta.label}
diff --git a/packages/plugin-designer/src/NavigationDesigner.tsx b/packages/plugin-designer/src/NavigationDesigner.tsx index 2e7ff7ba83..d2d5c45519 100644 --- a/packages/plugin-designer/src/NavigationDesigner.tsx +++ b/packages/plugin-designer/src/NavigationDesigner.tsx @@ -41,7 +41,7 @@ import { } from 'lucide-react'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import { resolveI18nLabel } from '@object-ui/react'; +import { resolveKeyedI18nLabel } from '@object-ui/react'; import { useDesignerTranslation } from './hooks/useDesignerTranslation'; function cn(...inputs: (string | undefined | false)[]) { @@ -142,7 +142,7 @@ function NavItemRow({ t, }: NavItemRowProps) { const [editingLabel, setEditingLabel] = useState(false); - const [labelDraft, setLabelDraft] = useState(resolveI18nLabel(item.label) ?? ''); + const [labelDraft, setLabelDraft] = useState(resolveKeyedI18nLabel(item.label) ?? ''); const [editingIcon, setEditingIcon] = useState(false); const [iconDraft, setIconDraft] = useState(item.icon || ''); const meta = NAV_TYPE_META[item.type]; @@ -154,7 +154,7 @@ function NavItemRow({ if (labelDraft.trim()) { onUpdateLabel(item.id, labelDraft.trim()); } else { - setLabelDraft(resolveI18nLabel(item.label) ?? ''); + setLabelDraft(resolveKeyedI18nLabel(item.label) ?? ''); } setEditingLabel(false); }; @@ -246,7 +246,7 @@ function NavItemRow({ onKeyDown={(e) => { if (e.key === 'Enter') handleLabelCommit(); if (e.key === 'Escape') { - setLabelDraft(resolveI18nLabel(item.label) ?? ''); + setLabelDraft(resolveKeyedI18nLabel(item.label) ?? ''); setEditingLabel(false); } }} @@ -261,12 +261,12 @@ function NavItemRow({ )} onDoubleClick={() => { if (!readOnly && item.type !== 'separator') { - setLabelDraft(resolveI18nLabel(item.label) ?? ''); + setLabelDraft(resolveKeyedI18nLabel(item.label) ?? ''); setEditingLabel(true); } }} > - {resolveI18nLabel(item.label)} + {resolveKeyedI18nLabel(item.label)} )} @@ -413,7 +413,7 @@ function PreviewItem({ item, depth }: { item: NavigationItem; depth: number }) { style={{ marginLeft: depth * 12 }} > - {resolveI18nLabel(item.label)} + {resolveKeyedI18nLabel(item.label)} {item.type === 'group' && item.children?.map((child) => ( diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx new file mode 100644 index 0000000000..607884d136 --- /dev/null +++ b/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx @@ -0,0 +1,146 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4163 part 1 — the three `widget.title` reads in `DashboardEditor`, + * and they are NOT the same kind of read. + * + * Two are DISPLAY (the widget card in the list, and the preview pane's tile): + * a map has to resolve to the locale's string or React renders + * `[object Object]`. + * + * The third is an AUTHORING WRITE — the property panel's single-line title + * `` — and it is the one where following rc.6's widening mechanically + * would have destroyed data rather than merely looked wrong. Resolving a map + * into the input and writing `e.target.value` back collapses every other locale + * on the first keystroke. This file's round-trip pin is the acceptance test for + * the conservative branch PR #4169 established on `DashboardWidgetInspector` + * and objectui#4163's dispatch ruling extends here: an inline map survives an + * unrelated edit-and-save untouched. + * + * Part 2 of #4163 (what Studio SHOULD offer for authoring a per-locale label) + * is unclaimed and pending design; these pins describe the placeholder, and + * they are written so that the real answer replaces them deliberately. + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { DashboardComponentSchema } from '@object-ui/types'; +import { DashboardEditor } from '../DashboardEditor'; + +const MAP_TITLE = { en: 'Pipeline', 'zh-CN': '销售漏斗' }; + +function schemaWith(title: unknown): DashboardComponentSchema { + return { + type: 'dashboard', + name: 'sales', + title: 'Sales dashboard', + widgets: [ + // Cast at the fixture boundary only — the point is what a spec-valid + // authored map does at the read/write sites. + { id: 'w1', type: 'bar', title: title as never }, + { id: 'w2', type: 'metric', title: 'Revenue' }, + ], + } as DashboardComponentSchema; +} + +describe('DashboardEditor — display reads of a map-valued widget title (#4163)', () => { + it('renders the resolved locale string on the widget card', () => { + render( {}} />); + const card = screen.getByTestId('dashboard-widget-w1'); + expect(card.textContent).toContain('Pipeline'); + }); + + it('never renders the stringified object anywhere in the editor', () => { + const { container } = render( + {}} />, + ); + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('leaves a plain-string title exactly as authored', () => { + // Non-vacuity: a resolver returning '' for everything passes the assertion + // above and fails this one. + render( {}} />); + expect(screen.getByTestId('dashboard-widget-w2').textContent).toContain('Revenue'); + }); +}); + +/** + * The schema from the most recent `onChange` call. + * + * Index arithmetic rather than `calls.at(-1)`: the repo compiles at + * `target`/`lib` `ES2020` (root `tsconfig.json`), and `Array.prototype.at` is + * ES2022 — so `.at()` type-checks nowhere in this repo even though every + * runtime it ships on has it. It passes `vitest` (esbuild strips types without + * checking them) and fails `tsc -p tsconfig.test.json`, which is the half of + * `type-check` that compiles tests. Left as a named helper so the constraint is + * stated once instead of re-learned at the next call site. + */ +function lastSchema(onChange: ReturnType): DashboardComponentSchema { + const calls = onChange.mock.calls; + return calls[calls.length - 1][0] as DashboardComponentSchema; +} + +describe('DashboardEditor — the title INPUT is a write path, not a display (#4163)', () => { + /** Select the widget so the property panel mounts. */ + function openPanelFor(schema: DashboardComponentSchema, widgetTestId: string) { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId(widgetTestId)); + return onChange; + } + + it('shows a map-valued title resolved, and READ-ONLY', () => { + openPanelFor(schemaWith(MAP_TITLE), 'dashboard-widget-w1'); + const input = screen.getByTestId('widget-prop-title') as HTMLInputElement; + expect(input.value).toBe('Pipeline'); + expect(input.readOnly).toBe(true); + }); + + it('⛔ a keystroke on a map-valued title writes NOTHING — the other locales survive', () => { + // The data-loss pin. Without the guard this emits + // `{ title: 'Pipelinex' }`, and `zh-CN` is gone forever on the next save. + const onChange = openPanelFor(schemaWith(MAP_TITLE), 'dashboard-widget-w1'); + const input = screen.getByTestId('widget-prop-title'); + fireEvent.change(input, { target: { value: 'Pipelinex' } }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('an inline map survives an UNRELATED edit-and-save round trip untouched', () => { + // The ruling's acceptance criterion, end to end: the author changes + // something else entirely on the same widget, and the stored map comes back + // byte-identical rather than flattened to one locale. + const onChange = openPanelFor(schemaWith(MAP_TITLE), 'dashboard-widget-w1'); + fireEvent.change(screen.getByTestId('widget-prop-color'), { target: { value: 'blue' } }); + + expect(onChange).toHaveBeenCalled(); + const saved = lastSchema(onChange); + const widget = saved.widgets!.find((w) => w.id === 'w1')!; + expect(widget.title).toEqual(MAP_TITLE); + // `toEqual` alone would pass for a rebuilt-but-equal object; this says the + // OTHER locale is still there, which is the thing that gets lost. + expect((widget.title as Record)['zh-CN']).toBe('销售漏斗'); + expect((widget as { colorVariant?: string }).colorVariant).toBe('blue'); + }); + + it('a plain-string title stays fully editable — the guard is narrow', () => { + // Non-vacuity for the two pins above: a guard that simply disabled the + // input for everyone would satisfy them and fail here. + const onChange = openPanelFor(schemaWith(MAP_TITLE), 'dashboard-widget-w2'); + const input = screen.getByTestId('widget-prop-title') as HTMLInputElement; + expect(input.value).toBe('Revenue'); + expect(input.readOnly).toBe(false); + + fireEvent.change(input, { target: { value: 'Revenue (net)' } }); + expect(onChange).toHaveBeenCalled(); + const saved = lastSchema(onChange); + expect(saved.widgets!.find((w) => w.id === 'w2')!.title).toBe('Revenue (net)'); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts index 153f881810..a02ef2e7a0 100644 --- a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts @@ -29,8 +29,13 @@ * `validateComponentProps` is advisory, the spec is only parsed on paths that * parse, and `RecordDetailsRenderer` reads `s.name` / `s.label` / `s.fields` * off each entry — all `undefined` on a string, so the section renders nothing. - * Under `layout: 'custom'` sections are the ONLY source of the body, so the + * Once `sections` is authored at all it is the ONLY source of the body, so the * page comes up blank with no diagnostic anywhere pointing at `sections`. + * (That used to read "under `layout: 'custom'`". `layout` was removed in + * @objectstack/spec 17.0.0 — its `auto` | `custom` semantics were never + * implemented — and the body is now chosen by what you author: `sections` + * renders the explicit groups, omitting it falls back to the object's + * `highlightFields`. The blank-page failure this file guards is unchanged.) * * Every expectation below is DERIVED from the spec schema at runtime rather * than restating today's key list, so a spec change fails here instead of @@ -109,15 +114,26 @@ describe('record:details — registry inputs vs @objectstack/spec', () => { // A VALUE verdict, so the criterion is a full parse, not key recognition: // the retired spelling has to be rejected on its value, and the object form // has to survive intact. + // + // NEITHER fixture may carry `layout` (objectui#4167). Both did until + // @objectstack/spec 17.0.0-rc.6, which gave the key — removed in 17.0.0 under + // ADR-0087 D2 (objectstack#6946) — a named `never` rejection. That rejection + // is what the fixtures then hit: the object-form case failed on `layout` + // while its `sections` were perfectly valid, and, worse, the id-list case + // above kept PASSING on `layout`'s own `invalid_type` without ever reaching + // `sections` — green for a reason that has nothing to do with what it + // asserts. Dropping the key puts both verdicts back on `sections`, where the + // issues now resolve to path `sections.0` / `sections.1`. const idList = RecordDetailsProps.safeParse({ - layout: 'custom', sections: ['contact_info', 'address'], }); expect(idList.success).toBe(false); expect(idList.error?.issues.map((i) => i.code)).toContain('invalid_type'); + // Pin the PATH, not just the code — this is precisely the assertion that was + // satisfied by the wrong key, and a code alone cannot tell the two apart. + expect(idList.error?.issues.map((i) => i.path.join('.'))).toContain('sections.0'); const objectForm = RecordDetailsProps.safeParse({ - layout: 'custom', sections: [{ name: 'contact_info', label: 'Contact', columns: 2, fields: ['phone'] }], }); expect(objectForm.success).toBe(true); diff --git a/packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts b/packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts new file mode 100644 index 0000000000..e0990622ba --- /dev/null +++ b/packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts @@ -0,0 +1,135 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4163 part 1 — the anti-drift pin for the inline per-locale label + * rule, and the guard that makes objectui#4163's second dispatch ruling + * ("the layout sites use the spec's OWN label resolver — do not hand-roll a + * resolution that can drift from it") safe where it could not be followed + * literally. + * + * ## Why two resolvers exist at all + * + * `@objectstack/spec` 17.0.0-rc.6 widened `I18nLabel` from `string` to + * `string | Record` and shipped `resolveI18nLabel(label, + * locale)` as the producer's rule for reading one. #4163 resolves that + * vocabulary at thirteen sites, and they do NOT all reach for the same + * function: + * + * - `@object-ui/layout` and `@object-ui/app-shell` call the spec's + * `resolveI18nLabel` directly; + * - `@object-ui/plugin-list`, `@object-ui/plugin-dashboard` and + * `@object-ui/plugin-designer` call objectui's `pickLocalized`, because + * those components sit inside objectui's i18n tree and already hold the live + * UI language — and `pickLocalized` answers `''` on a miss, which is what a + * text node wants, where the spec's answers `undefined`. + * + * Two functions over ONE vocabulary is exactly the drift the ruling names. The + * harm is not a crash: it is the same authored map rendering one locale on the + * runtime dashboard and a different one in the designer's preview of that same + * dashboard, with every type and every other test still green. Nothing else in + * either repo compares them, so this file is the comparison. + * + * ## What is asserted + * + * The six limbs of the rule, in order — exact tag, base language, a regional + * key sharing the base, `default`, `en`, then any entry — plus the pass-through + * and empty-map edges, each asserted on BOTH functions from one table. A limb + * that changes on one side and not the other fails here rather than in a + * screenshot. + * + * The ONE deliberate difference is normalized explicitly rather than hidden: + * the spec's resolver reports a miss as `undefined` (so a caller's `?? name` + * fallback chain can proceed), `pickLocalized` reports it as `''` (so a text + * node renders nothing). `specForRender` states that conversion in one place; + * if the two ever disagree about anything else, the table row fails. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveI18nLabel } from '@objectstack/spec/ui'; +import { pickLocalized } from '@object-ui/i18n'; + +/** + * The spec resolver's answer, in the spelling a render site needs. + * + * This `?? ''` is the whole of the permitted difference — see the header. It is + * written here, once, so that every other disagreement is a failure. + */ +function specForRender(label: unknown, locale: string | undefined): string { + return resolveI18nLabel(label as never, locale) ?? ''; +} + +/** `[what the author wrote, the viewer's locale, the string both must produce]` */ +const TABLE: ReadonlyArray = [ + // Pass-through: a plain string is not a map and never becomes one. + ['Pipeline', 'zh-CN', 'Pipeline'], + ['Pipeline', undefined, 'Pipeline'], + // An authored empty string is a label the author wrote, not a miss. + ['', 'en', ''], + + // Limb 1 — the exact BCP-47 tag wins. + [{ en: 'Sales', 'zh-CN': '销售' }, 'zh-CN', '销售'], + [{ en: 'Sales', 'zh-CN': '销售' }, 'en', 'Sales'], + // …including when the tag arrives padded, which both sides trim. + [{ en: 'Sales', 'zh-CN': '销售' }, ' zh-CN ', '销售'], + + // Limb 2 — the base language, when the author wrote the bare code. + [{ en: 'Sales', zh: '销售' }, 'zh-CN', '销售'], + // …and it outranks limb 3, so a bare key beats a regional sibling. + [{ zh: '基础', 'zh-CN': '区域' }, 'zh', '基础'], + + // Limb 3 — a regional key sharing the base, when neither exact nor base hit. + [{ en: 'Sales', 'zh-CN': '销售' }, 'zh', '销售'], + [{ en: 'Sales', 'zh-CN': '销售' }, 'zh-TW', '销售'], + [{ 'pt-BR': 'Vendas' }, 'pt-PT', 'Vendas'], + + // Limb 4 — `default` outranks `en`. + [{ default: 'Default', en: 'English' }, 'fr', 'Default'], + + // Limb 5 — `en`, the platform's source language. + [{ en: 'Sales', ja: '営業' }, 'fr', 'Sales'], + // …which is also what "no locale known" resolves to. + [{ en: 'Sales', 'zh-CN': '销售' }, undefined, 'Sales'], + + // Limb 6 — any entry, rather than nothing, when the map names none of the above. + [{ ja: '営業' }, 'fr', '営業'], + + // A map with no usable entry is a miss on both sides (see `specForRender`). + [{}, 'en', ''], +]; + +describe('inline per-locale label resolution agrees across both resolvers (#4163)', () => { + it.each(TABLE)( + 'resolves %j at locale %j to %j identically', + (label, locale, expected) => { + expect(pickLocalized(label, locale)).toBe(expected); + expect(specForRender(label, locale)).toBe(expected); + }, + ); + + it('reports a miss in each side\'s own documented spelling', () => { + // The permitted difference, pinned in BOTH directions so that neither side + // "fixing" it to match the other passes silently: `app-shell`'s + // `?? param.name` fallback chain depends on the spec's `undefined`, and the + // plugin text nodes depend on `pickLocalized`'s `''`. + expect(resolveI18nLabel({} as never, 'en')).toBeUndefined(); + expect(pickLocalized({}, 'en')).toBe(''); + expect(resolveI18nLabel(undefined, 'en')).toBeUndefined(); + expect(pickLocalized(undefined, 'en')).toBe(''); + }); + + it('neither resolver ever hands a text node the stringified object', () => { + // The harm #4163 exists for, stated once over the whole table rather than + // per site — a resolver that started returning the map would satisfy no + // assertion above but would also fail none of them for an untabled input. + for (const [label, locale] of TABLE) { + expect(pickLocalized(label, locale)).not.toBe('[object Object]'); + expect(specForRender(label, locale)).not.toBe('[object Object]'); + } + }); +}); diff --git a/packages/plugin-list/src/__tests__/spec-symbol-batch6.test.tsx b/packages/plugin-list/src/__tests__/spec-symbol-batch6.test.tsx index 8fff31ab9e..7e704984e7 100644 --- a/packages/plugin-list/src/__tests__/spec-symbol-batch6.test.tsx +++ b/packages/plugin-list/src/__tests__/spec-symbol-batch6.test.tsx @@ -47,6 +47,7 @@ import type { ListView as SpecListView, UserFilters as SpecUserFilters, ViewTab as SpecViewTab, + ViewTabParsed as SpecViewTabParsed, ViewTabSchema as SpecViewTabSchema, } from '@objectstack/spec/ui'; @@ -154,20 +155,32 @@ describe('ViewTab derives from the spec schema, on the authoring side', () => { type _IsTheSpecInput = Assert>; // Input, not output, and here is the evidence: the PARSED type requires the - // three defaulted keys, so re-exporting `SpecViewTab` would have made a + // three defaulted keys, so re-exporting the parsed shape would have made a // stored `{ name: 'open', label: 'Open' }` unrepresentable — the // `_input`/`_output` trap the guard's header warns about, and the one that // already bit `ObjectFieldGroup` (objectui#3169) and `OfflineConfig` // (objectui#3199). - type _ParsedRequiresPinned = Assert, false>>; - type _ParsedRequiresVisible = Assert, false>>; + // + // Re-pointed BY SIDE at `@objectstack/spec` 17.0.0-rc.6, not by name: the + // `…Input`-alias retirement moved the bare `ViewTab` onto the INPUT side + // (`ViewTab = z.input`, `ViewTabParsed = z.infer`), so `SpecViewTab` is no + // longer the parsed shape these four lines are about. Following the NAME + // would have left each of them comparing the authoring side against itself + // — passing on nothing — which is the swap objectui#4189 avoided for + // `ThemeInput`/`Theme` in the same release. Same fix, different package; + // this one was invisible until objectui#4163 cleared the `I18nLabel` + // errors in `TabBar.tsx`, because `type-check` is + // `tsc --noEmit && tsc -p tsconfig.typetests.json` and the `&&` never + // reached this project. + type _ParsedRequiresPinned = Assert, false>>; + type _ParsedRequiresVisible = Assert, false>>; type _AuthoredMayOmitPinned = Assert>; type _AuthoredMayOmitVisible = Assert>; // Same key set as the parsed type — only optionality differs. A key the // spec adds appears here; a key it retires disappears. - type _NoLocalKeys = Assert, never>>; - type _NoMissingKeys = Assert, never>>; + type _NoLocalKeys = Assert, never>>; + type _NoMissingKeys = Assert, never>>; // The three drifts the hand copy carried, pinned as fixed: // 1. `label` was REQUIRED locally; the spec makes it optional (`name` is diff --git a/packages/plugin-list/src/components/TabBar.tsx b/packages/plugin-list/src/components/TabBar.tsx index f3046122e3..419878351c 100644 --- a/packages/plugin-list/src/components/TabBar.tsx +++ b/packages/plugin-list/src/components/TabBar.tsx @@ -16,6 +16,7 @@ import { DropdownMenuItem, } from '@object-ui/components'; import { icons, ChevronDown, type LucideIcon } from 'lucide-react'; +import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { ViewTabSchema } from '@objectstack/spec/ui'; /** @@ -78,6 +79,41 @@ function getVisibleTabs(tabs: ViewTab[]): ViewTab[] { .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } +/** + * Resolve a tab's display text for the active UI language. + * + * `ViewTab.label` is the spec's `I18nLabel`, which `@objectstack/spec` + * 17.0.0-rc.6 widened from `string` to `string | Record`: an + * author may now write `label: { en: 'All Active', 'zh-CN': '全部活跃' }` on a + * view tab. Rendered straight into a text node the map form reaches React as an + * object — `[object Object]` — so every read goes through `pickLocalized`, + * objectui's render-side resolver for that vocabulary, paired with the live UI + * language exactly as `@object-ui/components` and `@object-ui/plugin-detail` + * pair them. (`@objectstack/spec`'s own `resolveI18nLabel` implements the same + * six-limb rule; `pickLocalized` is the spelling for components that sit inside + * objectui's i18n tree and can read the language, and it answers `''` rather + * than `undefined` on a miss, which is what a text node wants. That the two + * agree limb for limb is not assumed — it is pinned by + * `src/__tests__/i18nLabel-resolver-parity.test.ts`, which is what keeps this + * package's choice from drifting away from the sites that call the spec's + * resolver directly.) + * + * A label-less tab still resolves to `''` — `pickLocalized`'s miss spelling — + * which renders exactly what `{tab.label}` rendered for `undefined` before. + * Deliberately NOT given a `|| tab.name` fallback here: that would be a + * behaviour change this card did not ask for. + */ +function useTabLabel(): (tab: ViewTab | undefined) => string { + // Provider-safe: react-i18next falls back to its global instance and never + // throws, so a standalone TabBar (tests, embeds) degrades to the runtime + // default language rather than crashing. + const { language } = useObjectTranslation(); + return React.useCallback( + (tab: ViewTab | undefined) => (tab ? pickLocalized(tab.label, language) : ''), + [language], + ); +} + /** * TabBar renders a row of view tabs above the ListView toolbar. * Supports icons (resolved via Lucide), pinned tabs, isDefault selection, @@ -90,6 +126,7 @@ export const TabBar: React.FC = ({ className, }) => { const visibleTabs = React.useMemo(() => getVisibleTabs(tabs), [tabs]); + const tabLabel = useTabLabel(); // Determine the default tab: first isDefault tab, or first tab const defaultTab = React.useMemo(() => { @@ -137,7 +174,7 @@ export const TabBar: React.FC = ({ onClick={() => handleTabClick(tab)} > {TabIcon && } - {tab.label} + {tabLabel(tab)} ); })} @@ -159,6 +196,7 @@ export const TabBarSelect: React.FC = ({ className, }) => { const visibleTabs = React.useMemo(() => getVisibleTabs(tabs), [tabs]); + const tabLabel = useTabLabel(); const defaultTab = React.useMemo(() => { const def = visibleTabs.find(t => t.isDefault); @@ -197,7 +235,7 @@ export const TabBarSelect: React.FC = ({ > {/* eslint-disable-next-line react-hooks/static-components -- resolveIcon returns a stable lucide icon component from a static registry, not a component created during render */} {ActiveIcon && } - {activeTab?.label ?? ''} + {tabLabel(activeTab)} @@ -213,7 +251,7 @@ export const TabBarSelect: React.FC = ({ data-testid={`view-tab-select-${tab.name}`} > {TabIcon && } - {tab.label} + {tabLabel(tab)} ); })} diff --git a/packages/plugin-list/src/components/__tests__/TabBar.i18nLabel.test.tsx b/packages/plugin-list/src/components/__tests__/TabBar.i18nLabel.test.tsx new file mode 100644 index 0000000000..6495edb0e4 --- /dev/null +++ b/packages/plugin-list/src/components/__tests__/TabBar.i18nLabel.test.tsx @@ -0,0 +1,87 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4163 part 1 — `ViewTab.label` is the spec's `I18nLabel`, widened by + * `@objectstack/spec` 17.0.0-rc.6 from `string` to + * `string | Record`. + * + * Three read sites were exposed by the bump, and they are three DIFFERENT + * surfaces rather than one repeated: the inline pill row (`TabBar`), the mobile + * dropdown's trigger (`TabBarSelect`, showing only the ACTIVE tab), and that + * dropdown's menu items. Each is pinned separately, because a fix applied to + * one leaves the others rendering `[object Object]` with every type and every + * other assertion still green. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TabBar, TabBarSelect, type ViewTab } from '../TabBar'; + +const tabs: ViewTab[] = [ + { name: 'open', label: { en: 'All Active', 'zh-CN': '全部活跃' }, isDefault: true }, + { name: 'mine', label: 'My Records' }, +]; + +describe('TabBar — inline per-locale tab labels (#4163)', () => { + it('renders the resolved locale string for a map-valued label', () => { + render(); + expect(screen.getByText('All Active')).toBeTruthy(); + }); + + it('never renders the stringified object', () => { + const { container } = render(); + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('leaves a plain-string label exactly as authored', () => { + // Non-vacuity: a resolver that returned '' for everything would pass the + // `[object Object]` assertion above and fail this one. + render(); + expect(screen.getByText('My Records')).toBeTruthy(); + }); + + it('still renders an empty pill for a label-less tab, as before', () => { + // The spec makes `label` optional and `name` the identifier. This card + // deliberately did NOT add a `|| tab.name` fallback, so the observable + // behaviour for a label-less tab is unchanged — pinned so a later "tidy-up" + // is a deliberate decision rather than a drive-by. + render(); + const pill = screen.getByTestId('view-tab-untitled'); + expect(pill.textContent).toBe(''); + }); +}); + +describe('TabBarSelect — inline per-locale tab labels (#4163)', () => { + it('resolves the ACTIVE tab label in the dropdown trigger', () => { + render(); + expect(screen.getByText('All Active')).toBeTruthy(); + }); + + it('never renders the stringified object in the trigger', () => { + const { container } = render(); + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('resolves the label of every tab in the open menu', async () => { + // The third site, and the one most easily left vacuous: the menu is a Radix + // portal that does not exist until the trigger is opened, so the menu-item + // node is asserted FIRST — an assertion about `[object Object]` over a menu + // that never rendered passes on nothing. + render(); + const trigger = screen.getByLabelText('Switch view'); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + + const item = await screen.findByTestId('view-tab-select-open'); + // The trigger shows the ACTIVE tab ('My Records'), so this text can only + // have come from the menu item — the site under test. + expect(item.textContent).toBe('All Active'); + expect(document.body.innerHTML).not.toContain('[object Object]'); + }); +}); diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 3529f94a96..2b75772b13 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -27,7 +27,7 @@ import { import { SchemaRendererContext } from './context/SchemaRendererContext'; import { usePredicateScope } from './hooks/useExpression'; import { usePageVariables } from './hooks/usePageVariables'; -import { resolveI18nLabel } from './utils/i18n'; +import { resolveKeyedI18nLabel } from './utils/i18n'; /** * Dev-mode schema validation. @@ -108,7 +108,7 @@ function validateSchemaOnce(schema: any): _ValidationCacheEntry { function resolveAriaProps(schema: Record): Record { const aria: Record = {}; if (schema.ariaLabel) { - aria['aria-label'] = resolveI18nLabel(schema.ariaLabel); + aria['aria-label'] = resolveKeyedI18nLabel(schema.ariaLabel); } if (schema.ariaDescribedBy) { aria['aria-describedby'] = schema.ariaDescribedBy; diff --git a/packages/react/src/context/AppShellContext.tsx b/packages/react/src/context/AppShellContext.tsx index 829f9adf00..b7a2638902 100644 --- a/packages/react/src/context/AppShellContext.tsx +++ b/packages/react/src/context/AppShellContext.tsx @@ -15,7 +15,26 @@ export function useAdapter(): ObjectStackAdapter | null { // MetadataContext // --------------------------------------------------------------------------- -export interface MetadataState { +/** + * The metadata PROVIDER's cache — the collections `` holds in + * React state, plus its aggregate load flags. + * + * Renamed from `MetadataState` in objectui#4167. `@objectstack/spec` 17.0.0-rc.6 + * publishes `MetadataState` from `@objectstack/spec/system`, and it names a + * different thing in the SAME domain and the SAME two words: a metadata item's + * LIFECYCLE state, `'draft' | 'active' | 'deprecated' | 'archived'` + * (`MetadataStateSchema`). That is the `AuthProviderConfig` judgement from + * objectstack#4115 batch 5, not the `AuthProvider` one — nothing about a JSX + * element can be mistaken for a zod enum, but "the metadata state" absolutely + * can be read as canonical for the lifecycle enum by the next agent, and the + * two are mutually unassignable (an object of five arrays vs a string union), + * so nothing would have caught the misreading at the point it was made. + * + * `Cache` rather than a bare disambiguating prefix because it is what this is: + * `MetadataProvider` is a TTL cache with per-type entries, and these five + * arrays are its materialized contents. + */ +export interface MetadataCacheState { apps: any[]; objects: any[]; dashboards: any[]; @@ -27,7 +46,7 @@ export interface MetadataState { export type MetadataTypeStatus = 'idle' | 'loading' | 'ready' | 'error'; -export interface MetadataContextValue extends MetadataState { +export interface MetadataContextValue extends MetadataCacheState { refresh: (type?: string) => Promise; invalidate: (type: string, name?: string) => void; ensureType: (type: string) => Promise; diff --git a/packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts b/packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts index 5a99206023..fea2a5e208 100644 --- a/packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts +++ b/packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts @@ -48,9 +48,9 @@ import type { OfflineConfig, OfflineSyncConfig, } from '../useOffline'; -import type { NavigationConfig } from '../useNavigationOverlay'; +import type { NavigationConfig, NavigationMode } from '../useNavigationOverlay'; import type { SpecAuthoredInput } from '../../spec-input'; -import type { NavigationConfigSchema } from '@objectstack/spec/ui'; +import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui'; import type { ConflictResolutionStrategy as SpecMergeConflictStrategy } from '@objectstack/spec/api'; /** Every name `@objectstack/spec` exports from any subpath — types AND values. */ @@ -273,6 +273,41 @@ describe('NavigationConfig derives from the spec, requiring only `mode`', () => ]; expect(all).toHaveLength(7); }); + + it('the exported NavigationMode is the spec union AND still this config\'s `mode`', () => { + // objectui#4167. `NavigationMode` used to be spelled `NavigationConfig['mode']` + // — one member access away from the spec, which reads as a hand-written + // union to `check:spec-symbols` and to a person. It is now bound to the + // spec's `NavigationMode` directly, and this is the pin the declaration + // promises: the two spellings must stay the SAME type. + // + // They can come apart in a way nothing else would catch. The equality holds + // today only because `NavigationConfig` above strips the `undefined` that + // `NavigationConfigSchema`'s `.default('page')` puts on `mode`'s authoring + // side. If the spec ever stops defaulting `mode`, or defaults it on a + // narrower union, `NavigationConfig['mode']` moves and the exported alias + // does not — and every call site keeps compiling, because the hook's + // `navigation?.mode ?? 'page'` would still be assignable either way. + // + // Both directions, deliberately: a one-way `extends` is satisfied by a + // narrowing as well as by equality, and a narrowing is exactly the drift + // that would delete a mode the hook switches on. + type _ModeIsSpecMode = Assert>; + type _ModeIsConfigMode = Assert>; + + // Runtime half — the type assertions above are erased, so this is what + // fails visibly if the union ever loses a member. + const everyMode: NavigationMode[] = [ + 'page', + 'drawer', + 'modal', + 'split', + 'popover', + 'new_window', + 'none', + ]; + expect(new Set(everyMode).size).toBe(7); + }); }); /** diff --git a/packages/react/src/hooks/useNavigationOverlay.ts b/packages/react/src/hooks/useNavigationOverlay.ts index 20c3308836..3a03ff4f48 100644 --- a/packages/react/src/hooks/useNavigationOverlay.ts +++ b/packages/react/src/hooks/useNavigationOverlay.ts @@ -19,7 +19,7 @@ import { useState, useCallback, useMemo } from 'react'; -import type { NavigationConfigSchema } from '@objectstack/spec/ui'; +import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui'; import type { SpecAuthoredInput } from '../spec-input'; /** @@ -85,7 +85,23 @@ export function resolveOverlayWidth(navigation: NavigationConfig | undefined): s return undefined; } -export type NavigationMode = NavigationConfig['mode']; +/** + * The overlay modes — the spec's own union, DERIVED since objectui#4167. + * + * rc.6 publishes `NavigationMode` (`z.input`), and + * this alias resolved to exactly it already: `NavigationConfig['mode']` above is + * `NonNullable<…['mode']>`, and stripping the `undefined` that the schema's + * `.default()` puts on the authoring side leaves the seven-member enum itself. + * So the spec reference was one hop away rather than absent — the alias just + * reached it through a member access, which reads as a hand-written union to + * `check:spec-symbols` and, more to the point, to a person. + * + * Bound to the spec directly instead: the seven members now arrive from the + * schema that validates them. `__tests__/offline-nav-performance-spec-parity.test.ts` + * pins that this stays the same type as `NavigationConfig['mode']`, so the two + * spellings cannot silently come apart if the spec ever stops defaulting `mode`. + */ +export type NavigationMode = SpecNavigationMode; export interface UseNavigationOverlayOptions { /** The navigation configuration from the schema */ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a76f55ea91..4432889047 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -17,7 +17,7 @@ export * from './data-invalidation'; export * from './element-data-source/ElementDataSourceGate'; // i18n utilities -export { resolveI18nLabel } from './utils/i18n'; +export { resolveKeyedI18nLabel } from './utils/i18n'; // Write-error surfacing utilities (shared by drag-write plugins so a failed // PATCH — e.g. an RLS 403 — is never silently swallowed). diff --git a/packages/react/src/utils/i18n.ts b/packages/react/src/utils/i18n.ts index cbd394701a..c5ac4ceb4e 100644 --- a/packages/react/src/utils/i18n.ts +++ b/packages/react/src/utils/i18n.ts @@ -1,9 +1,41 @@ /** - * Resolves an I18nLabel to a plain string. - * I18nLabel can be either a string or an object { key, defaultValue?, params? }. - * When it's an object, we return the defaultValue or the key as fallback. + * Resolves objectui's KEYED i18n label to a plain string. + * + * The keyed form is `{ key, defaultValue?, params? }` — a reference INTO a + * translation bundle. This package has no `t()`, so it resolves the reference + * as far as it can without one: `defaultValue`, else the key itself. (The + * app-shell twin of this function takes a `t` and resolves the key properly.) + * + * ## Why this is not called `resolveI18nLabel` (objectui#4167) + * + * `@objectstack/spec` 17.0.0-rc.6 publishes a function of that exact name from + * `@objectstack/spec/ui`, over a DIFFERENT vocabulary: the INLINE LOCALE MAP + * (`{ en: 'Owner', 'zh-CN': '负责人' }`), resolved against a BCP-47 locale + * (`resolveI18nLabel(label, locale)`). Two resolvers, one name, and neither + * accepts the other's shape — which objectstack#4115 calls a planted premise + * rather than a naming nit, because the next agent reads the name and builds + * on it. + * + * rc.6 is also what made the clash dangerous rather than merely untidy. It + * widened the spec's `I18nLabel` from `string` to + * `string | Record< string, string >`, so the SAME authored value can now reach + * either function — and each answers wrongly for the other's input, silently: + * + * this one, given `{ en: 'Owner' }` → `undefined` (no `key`, no + * `defaultValue`) → renders empty; + * the spec's, given `{ key: 'nav.home' }` → treats `key`/`defaultValue`/ + * `params` as locale tags and picks + * one of them as the display text. + * + * Neither is a type error at the call site that matters, and both look like a + * working render until someone reads the output. PR #4169 met this head-on and + * aliased the spec's import as `resolveInlineI18nLabel` in five files, with + * hand-written comments explaining the clash at two of them — a review + * convention, which is exactly what objectstack#4115 exists to replace with a + * rule. `Keyed` is the counterpart of that `Inline`: the name now says which + * vocabulary it resolves, at every call site, with no comment required. */ -export function resolveI18nLabel(label: string | { key: string; defaultValue?: string; params?: Record } | undefined): string | undefined { +export function resolveKeyedI18nLabel(label: string | { key: string; defaultValue?: string; params?: Record } | undefined): string | undefined { if (label === undefined || label === null) return undefined; if (typeof label === 'string') return label; return label.defaultValue || label.key; diff --git a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts index dcdc04b802..6c0e2bc129 100644 --- a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts @@ -50,8 +50,15 @@ import type { ActionParamSchema as SpecActionParamSchema, I18nLabel as SpecI18nLabel, NavigationArea as SpecNavigationArea, - Theme as SpecTheme, - ThemeInput as SpecThemeInput, + // Re-pointed BY SIDE on the rc.6 bump (objectui#4167), not by name. + // Up to rc.5 the spec published `Theme` (= `z.infer`) alongside `ThemeInput` + // (= `z.input`); rc.6 retired every `…Input` alias and moved the bare name + // onto the INPUT side, so `Theme` is now `z.input` and `ThemeParsed` is the + // `z.infer` side. Following the old NAMES here would have swapped both pins + // silently — which is why `ThemeInput` became `Theme` and `Theme` became + // `ThemeParsed`, rather than either binding staying where it was written. + Theme as SpecThemeInput, + ThemeParsed as SpecThemeParsed, } from '@objectstack/spec/ui'; import { NavigationAreaSchema } from '../zod/app.zod.js'; import type { NavigationArea, NavigationItem } from '../app.js'; @@ -271,7 +278,7 @@ describe('NavigationArea derives from the spec', () => { // Theme — derived (re-export of the spec's AUTHORING shape) // ───────────────────────────────────────────────────────────────────────────── -describe('Theme is the spec ThemeInput, not the parsed Theme', () => { +describe('Theme is the spec AUTHORING theme, not the parsed one', () => { it('accepts a theme with no `mode` (the authoring side)', () => { const authored: Theme = { name: 'acme', label: 'Acme', colors: { primary: '#0af' } }; expect(authored.mode).toBeUndefined(); @@ -283,12 +290,17 @@ describe('Theme is the spec ThemeInput, not the parsed Theme', () => { expect(back.name).toBe('acme'); }); - it('is NOT the spec parsed Theme, whose `mode` is required', () => { + it('is NOT the spec parsed theme, whose `mode` is required', () => { // `.default('auto')` has already run in `z.infer`, so the parsed type would // make `mode` mandatory and every stored objectui theme unrepresentable. // If the spec ever drops that default the two collapse and this pin fails, // which is the moment to re-read the derivation comment in `theme.ts`. - type ModeOfParsed = undefined extends SpecTheme['mode'] ? 'optional' : 'required'; + // + // rc.6 spells the `z.infer` side `ThemeParsed`. The pin reads the same fact + // it always did — following the NAME `Theme` here instead would have made + // this assertion compare the authoring side against itself and go green on + // nothing, which is exactly the swap the import comment warns about. + type ModeOfParsed = undefined extends SpecThemeParsed['mode'] ? 'optional' : 'required'; const parsedModeIs: ModeOfParsed = 'required'; expect(parsedModeIs).toBe('required'); diff --git a/packages/types/src/data-protocol.ts b/packages/types/src/data-protocol.ts index ba0c35cdce..f22f0784ee 100644 --- a/packages/types/src/data-protocol.ts +++ b/packages/types/src/data-protocol.ts @@ -550,13 +550,40 @@ export type AdvancedFilterOperator = export interface DateRangeFilter { start?: Date | string; end?: Date | string; - preset?: DateRangePreset; + preset?: FilterBuilderDateRangePreset; } /** - * Date range presets + * Date range presets accepted by the FILTER BUILDER vocabulary in this file. + * + * Renamed from `DateRangePreset` in objectui#4167, because `@objectstack/spec` + * started exporting that name in 17.0.0-rc.6 for a DIFFERENT, narrower set and + * a different surface (objectstack#4115). Keeping both under one name is the + * planted-premise failure that guard exists to stop — and here the two sets + * are not even nested by accident: + * + * - the spec's `DateRangePreset` is `(typeof DATE_RANGE_PRESETS)[number]`, + * thirteen HISTORICAL windows, and it is the dashboard filter-bar + * vocabulary (`dashboard.dateRange.defaultRange`, `globalFilters` of + * `type: 'date'`). Its `superRefine` on `GlobalFilterSchema` rejects + * anything outside those thirteen by name; + * - this one adds eight FUTURE windows (`tomorrow`, `next_week`, + * `next_month`, `next_quarter`, `next_year`, `next_7_days`, `next_30_days`, + * `next_90_days`) for the `FilterBuilderConfig` surface below, where a + * forward-looking range ("due next week") is the point. + * + * So an author or agent who read the spec's thirteen off this name would have + * been told eight windows exist that the dashboard schema rejects, and one who + * read this file's twenty-one off the spec's name would have been told eight + * that work here do not. The prefix says which vocabulary is being spelled; + * `FilterBuilderCondition` (objectui#3159, batch 5) named its sibling the same + * way for the same reason. + * + * Nothing in this repo consumed the old name outside {@link DateRangeFilter} + * directly above, so the rename is a public-surface change with no internal + * call-site churn — see the changeset for the importer-facing note. */ -export type DateRangePreset = +export type FilterBuilderDateRangePreset = | 'today' | 'yesterday' | 'tomorrow' diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f6b60305ea..031ac8178f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -487,7 +487,9 @@ export type { AdvancedFilterCondition, AdvancedFilterOperator, DateRangeFilter, - DateRangePreset, + // Renamed from `DateRangePreset` in objectui#4167 — the spec owns that name + // for the narrower dashboard filter-bar vocabulary (objectstack#4115). + FilterBuilderDateRangePreset, FilterBuilderConfig, FilterFieldConfig, // Validation Schema (Phase 3.5) diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 3b6f0b64ef..cc9b7dbc6d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -95,6 +95,7 @@ import type { RowColorConfig, GalleryConfig, TimelineConfig, + GanttConfig as SpecGanttConfig, } from '@objectstack/spec/ui'; /** @@ -120,56 +121,55 @@ export type ListViewTimelineConfig = TimelineConfig & { /** * Kanban Configuration * Canonical definition from @objectstack/spec/ui (KanbanConfigSchema). + * + * A RE-EXPORT since objectui#4167, not a copy. The three keys the copy spelled + * out (`groupByField` / `summarizeField` / `columns`) were the spec's three + * exactly, and `KanbanConfigSchema` is `$strict`, so there was never a + * divergence to preserve — only a second declaration under the spec's own name + * for the next agent to read as canonical (objectstack#4115). The zod side has + * derived from the spec all along (`zod/objectql.zod.ts`, which additionally + * carries the `groupField` / `cardFields` legacy aliases); this alias is now + * bound to the same source. */ -export type KanbanConfig = { - /** Field to group columns by (usually status/select) */ - groupByField: string; - /** Field to sum at top of column (e.g. amount) */ - summarizeField?: string; - /** Fields to show on cards */ - columns: string[]; -}; +export type { KanbanConfig } from '@objectstack/spec/ui'; /** * Calendar Configuration * Canonical definition from @objectstack/spec/ui (CalendarConfigSchema). + * + * A RE-EXPORT since objectui#4167, for the same reason as `KanbanConfig` above: + * the copy's four keys were the spec's four, on a `$strict` schema. */ -export type CalendarConfig = { - /** Start date field */ - startDateField: string; - /** End date field */ - endDateField?: string; - /** Title field */ - titleField: string; - /** Color field */ - colorField?: string; -}; +export type { CalendarConfig } from '@objectstack/spec/ui'; /** - * Gantt Configuration - * Canonical definition from @objectstack/spec/ui (GanttConfigSchema). + * Gantt Configuration — the spec's `GanttConfigSchema`, plus objectui's one + * remaining display-only extension. + * + * DERIVED since objectui#4167, and the copy it replaces was carrying two false + * claims of exactly the kind objectstack#4115 was filed about: + * + * - it declared SIX keys and called itself "canonical", while rc.6's + * `GanttConfigSchema` declares seventeen. The eleven it never mentioned — + * `parentField`, `typeField`, `baselineStartField`, `baselineEndField`, + * `groupByField`, `resourceView`, `assigneeField`, `effortField`, + * `capacity`, `quickFilters`, `autoZoomToFilter` — are not hypothetical + * upstream additions: `plugin-gantt/src/ObjectGantt.tsx` reads every one of + * them, through a local `GanttConfigEx` intersection that re-declared them + * because this type did not; + * - the `tooltipFields` comment said "not part of the upstream + * GanttConfigSchema". It is, as of rc.6, so the key now arrives from the + * spec and the note is gone with it. + * + * `timeSegments` is the one key the spec genuinely does not model, and it stays + * here declared as objectui's own. That is legal metadata rather than a second + * dialect: `GanttConfigSchema` is `$loose` upstream (see the note at + * `zod/objectql.zod.ts` — "the renderers grow config knobs"), so a key the spec + * does not declare passes its parse instead of being rejected. The intersection + * inherits that looseness, which is the spec's own decision for this vocabulary + * and not a widening taken here. */ -export type GanttConfig = { - /** Start date field */ - startDateField: string; - /** End date field */ - endDateField: string; - /** Title field */ - titleField: string; - /** Progress field (0-100) */ - progressField?: string; - /** Dependencies field */ - dependenciesField?: string; - /** Color field */ - colorField?: string; - /** - * Fields to surface in the hover tooltip (悬浮详情), in display order. - * ObjectUI display extension — not part of the upstream GanttConfigSchema. - * Each entry is either a field name (string) or `{ field, label? }` to - * override the label; values are formatted by field type. When omitted the - * tooltip falls back to the built-in start → end · duration · progress line. - */ - tooltipFields?: Array; +export type GanttConfig = SpecGanttConfig & { /** * Shift segmentation (班次/排班分段). ObjectUI display extension — not part of the * upstream GanttConfigSchema. When set, the day-mode timeline splits each diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2af93336f3..507959e4b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1330,6 +1330,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@objectstack/spec': + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1346,9 +1349,6 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: - '@objectstack/spec': - specifier: ^17.0.0-rc.6 - version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.10)(yaml@2.9.0)) diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index dec1a3535b..63468dc5aa 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -111,6 +111,28 @@ const ALLOW = { "adds a key, retires one, claims an extension name, or widens `value` itself.", issue: 4115, }, + "@object-ui/types:GlobalFilterSchema": { + reason: + "Spec-derived dialect composed the same way as `SelectOptionSchema` above, and excused " + + "for the same reason: the spec's keys flow in BY REFERENCE via " + + "`SpecGlobalFilterSchema.shape`, which this guard cannot see because a const's object " + + "literal is deliberately not descended into (a spread inside `z.object({…})` is " + + "indistinguishable, to an AST pass, from a hand-typed shape). Three keys are then " + + "replaced, each a documented divergence at the declaration: `options` and `optionsFrom` " + + "(objectui's wire forms) and `defaultValue` (widened to carry the `{ preset }` object " + + "`@object-ui/core`'s `normalizeDateDefault` produces and stored dashboards hold, " + + "framework#4475). Drift guard: " + + "packages/types/src/__tests__/report-chart-query-spec-parity.test.ts. " + + "The SPELLING is new, not the dialect: this was `SpecGlobalFilterSchema.extend({…})` " + + "until @objectstack/spec 17.0.0-rc.6 put a refinement on the schema, which makes " + + "`.extend()` throw at module load and types every override as `never` under " + + "`.safeExtend()`. The spread is the only remaining spelling that preserves the shape " + + "byte-for-byte, and it is what moved the derivation out of this guard's sight — so the " + + "collision is an artifact of the forced rewrite, not a new fork (objectui#4167). " + + "The refinement itself is deliberately NOT carried across and the standstill is pinned; " + + "which side moves is a producer/consumer contract decision owned by objectui#4165.", + issue: 4167, + }, "@object-ui/types:ListViewSchema": { reason: "TS twin of the spec-derived `ListViewSchema` zod node (objectql.zod.ts), which DOES " +