From 38c84358d8a42f459d95e4d44df07825acb928a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 15:17:46 +0000 Subject: [PATCH 1/5] retire(studio): drop the global_nav designer surfaces with the rc.6 spec bump (objectstack#7100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered — the console's ⌘K palette reads no action metadata at all — and the Studio designer previewed a mock command-palette frame for it anyway. @objectstack/spec 17.0.0-rc.6 retired the member (objectstack#6888, maintainer ruling 2026-08-09 direction 2); this removes the designer surfaces that outlived it, plus the bump that makes them stop compiling. - ActionPreview.tsx: the mock ⌘K placement frame is gone. The metadata strip still echoes the declared token, deliberately — reporting what a draft says is honest; the frame CLAIMED the platform renders it. - ActionDefaultInspector.tsx: the `LOCATION_LABELS` entry is gone. The map is `Record`, so the retirement arrived as a compile error (objectui#3017's mechanism firing as designed). - block-config.ts + both locale tables: the palette option and its now-orphaned i18n key. - components' action:bar doc comment aligned (its enum is `[...ACTION_LOCATIONS]` and followed on its own). - ActionEngine.getActionsForLocation is UNCHANGED: narrowing it would be a second rejection point beside the schema's. The type and `ActionLocationSchema` are the enforcement, both pinned. The bump (all 37 `@objectstack/*` declarations, rc.5 -> rc.6) rides along because the two cannot compile apart, and its fallout is repaired here: the `…Input` alias retirement (types/theme, types/spec-report, core/ActionRunner), the five dead i18n re-exports, the `I18nLabel` widening at 8 render sites, `.extend` -> `.safeExtend` on a now-refined `GlobalFilterSchema`, and two parity lists re-pointed. NOT green: the bump's remaining surface is out of this card's scope and filed — objectui#4165 (contract conflict, blocks), #4167 (adaptation remainder), #4163 (I18nLabel consumer story), #4164 (name reclaim available). Part of objectstack-ai/objectstack#7100 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/global-nav-studio-retire-rc6.md | 35 ++++ apps/console/package.json | 4 +- apps/site/package.json | 2 +- package.json | 2 +- packages/app-shell/package.json | 6 +- packages/app-shell/src/layout/AppSidebar.tsx | 21 +- .../app-shell/src/layout/UnifiedSidebar.tsx | 16 +- .../src/views/metadata-admin/i18n.ts | 9 +- .../inspectors/ActionDefaultInspector.tsx | 10 +- .../inspectors/DashboardDefaultInspector.tsx | 19 +- .../inspectors/DashboardWidgetInspector.tsx | 33 ++- .../metadata-admin/previews/ActionPreview.tsx | 20 +- .../previews/DashboardPreview.tsx | 11 +- .../ActionPreview.locations.test.tsx | 113 +++++++++++ .../previews/__tests__/block-config.test.ts | 41 +++- .../metadata-admin/previews/block-config.ts | 5 +- packages/auth/package.json | 2 +- packages/collaboration/package.json | 2 +- packages/components/package.json | 2 +- .../src/renderers/action/action-bar.tsx | 9 +- packages/core/package.json | 4 +- packages/core/src/actions/ActionRunner.ts | 25 ++- packages/data-objectstack/package.json | 4 +- packages/fields/package.json | 2 +- packages/layout/package.json | 2 +- packages/mobile/package.json | 2 +- packages/plugin-charts/package.json | 2 +- packages/plugin-chatbot/package.json | 2 +- packages/plugin-dashboard/package.json | 2 +- packages/plugin-detail/package.json | 2 +- packages/plugin-form/package.json | 2 +- packages/plugin-gantt/package.json | 2 +- packages/plugin-grid/package.json | 4 +- packages/plugin-list/package.json | 4 +- packages/plugin-map/package.json | 2 +- packages/plugin-report/package.json | 2 +- packages/plugin-timeline/package.json | 2 +- packages/plugin-tree/package.json | 2 +- packages/plugin-view/package.json | 2 +- packages/providers/package.json | 2 +- packages/react/package.json | 2 +- .../hooks/__tests__/useActionEngine.test.ts | 74 +++++++ packages/runner/package.json | 2 +- packages/sdui-parser/package.json | 2 +- packages/types/package.json | 2 +- .../page-nav-misc-spec-parity.test.ts | 37 +++- .../spec-ui-schema-reexports.test.ts | 14 +- packages/types/src/index.ts | 16 +- packages/types/src/spec-report.ts | 16 +- packages/types/src/theme.ts | 23 ++- packages/types/src/zod/complex.zod.ts | 13 +- pnpm-lock.yaml | 190 +++++++++--------- 52 files changed, 628 insertions(+), 196 deletions(-) create mode 100644 .changeset/global-nav-studio-retire-rc6.md create mode 100644 packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.locations.test.tsx diff --git a/.changeset/global-nav-studio-retire-rc6.md b/.changeset/global-nav-studio-retire-rc6.md new file mode 100644 index 0000000000..e3269a1189 --- /dev/null +++ b/.changeset/global-nav-studio-retire-rc6.md @@ -0,0 +1,35 @@ +--- +"@object-ui/app-shell": minor +"@object-ui/components": minor +"@object-ui/types": minor +"@object-ui/core": minor +--- + +Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888). + +## The retirement + +`global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user. + +The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it. + +- `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it. +- `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed. +- `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key. +- `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale. + +`@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time. + +## The dependency move + +All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out. + +Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one. + +## Breaking, in FROM → TO form + +- **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap. +- **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5. +- **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name. +- **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163. +- **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule. diff --git a/apps/console/package.json b/apps/console/package.json index bfe7e8aa01..9560915984 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -89,8 +89,8 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/client": "^17.0.0-rc.5", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/client": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0-rc.6", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "^0.5.20", "@testing-library/jest-dom": "^7.0.0", diff --git a/apps/site/package.json b/apps/site/package.json index 498a84231b..9dbb3fdcd6 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -33,7 +33,7 @@ "@object-ui/plugin-view": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "fumadocs-core": "16.14.1", "fumadocs-mdx": "15.2.2", "fumadocs-ui": "16.14.1", diff --git a/package.json b/package.json index 9d9245721a..8de2c98847 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "devDependencies": { "@changesets/cli": "^2.31.1", "@eslint/js": "^10.0.1", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@playwright/test": "^1.62.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index e533fdde8a..99d94c4087 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -49,9 +49,9 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/formula": "^17.0.0-rc.5", - "@objectstack/lint": "^17.0.0-rc.5", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/formula": "^17.0.0-rc.6", + "@objectstack/lint": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0-rc.6", "@sentry/react": "^10.69.0", "jsonc-parser": "^3.3.1", "lucide-react": "^1.29.0", diff --git a/packages/app-shell/src/layout/AppSidebar.tsx b/packages/app-shell/src/layout/AppSidebar.tsx index 02937540d6..f0aff1e347 100644 --- a/packages/app-shell/src/layout/AppSidebar.tsx +++ b/packages/app-shell/src/layout/AppSidebar.tsx @@ -63,6 +63,13 @@ import { useRecentItems } from '../hooks/useRecentItems'; import { useFavorites } from '../hooks/useFavorites'; import { useNavPins } from '../hooks/useNavPins'; import { resolveI18nLabel, matchAppBySegment, appRouteSegment } from '../utils'; +// Two resolvers, two vocabularies — the alias is what keeps them apart. +// `resolveI18nLabel` above is objectui's own and resolves a TRANSLATION-KEY ref +// (`{ key, defaultValue, params }`) through i18next. `resolveInlineI18nLabel` is +// the spec's `resolveI18nLabel`, new in @objectstack/spec 17.0.0-rc.6, and +// resolves the INLINE per-locale map (`{ en: …, 'zh-CN': … }`) that the same +// release folded into `I18nLabel`. Neither accepts the other's shape. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { useAppContextSelectors } from './ContextSelectors'; @@ -143,7 +150,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri const isWorkspaceAdmin = useIsWorkspaceAdmin(); const navigate = useNavigate(); const location = useLocation(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel: resolveNavObjectLabel, viewLabel: resolveNavViewLabel } = useObjectLabel(); // Swipe-from-left-edge gesture to open sidebar on mobile @@ -514,15 +521,23 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri {visibleAreas.map((area) => { const AreaIcon = getIcon(area.icon); const isActiveArea = area.id === activeArea?.id; + // `NavigationArea.label` is the spec's `I18nLabel`, which + // @objectstack/spec 17.0.0-rc.6 widened from plain `string` + // to `string | Record` (the inline + // per-locale map, folded in from the retired `I18nObject`). + // Rendering it raw would print `[object Object]` for the + // map form, so it goes through the spec's own shared + // resolver rather than a local guess. + const areaLabel = resolveInlineI18nLabel(area.label, language); return ( setActiveAreaId(area.id)} > - {area.label} + {areaLabel} ); diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 50afa55901..fe5383cd0b 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -49,6 +49,10 @@ import { useFavorites } from '../hooks/useFavorites'; import { useNavPins } from '../hooks/useNavPins'; import { useNavActionDispatch } from '../hooks/useNavActionDispatch'; import { resolveI18nLabel, matchAppBySegment, appRouteSegment } from '../utils'; +// Aliased to keep it apart from objectui's own `resolveI18nLabel` above: this is +// the spec's resolver (new in @objectstack/spec 17.0.0-rc.6) for the INLINE +// per-locale map form of `I18nLabel`, not for a translation-key ref. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; // useObjectLabel provides appLabel/appDescription for convention-based // i18n lookup — `{ns}.apps.{name}.label` resolves to the translated label @@ -152,7 +156,7 @@ function isOverviewGroup(item: NavigationItem): boolean { export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { const { isMobile, setOpenMobile } = useSidebar(); const location = useLocation(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel: resolveNavObjectLabel, dashboardLabel: resolveNavDashboardLabel, navGroupLabel: resolveNavGroupLabel, viewLabel: resolveNavViewLabel } = useObjectLabel(); const { context, currentAppName } = useNavigationContext(); const { user, activeOrganization } = useAuth(); @@ -506,15 +510,21 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { {visibleAreas.map((area) => { const AreaIcon = getIcon(area.icon); const isActiveArea = area.id === activeArea?.id; + // Same as AppSidebar: `NavigationArea.label` is the spec's + // `I18nLabel`, widened in @objectstack/spec 17.0.0-rc.6 to + // `string | Record`, so the inline + // per-locale form has to be resolved before it reaches a + // text slot or it renders as `[object Object]`. + const areaLabel = resolveInlineI18nLabel(area.label, language); return ( setActiveAreaId(area.id)} > - {area.label} + {areaLabel} ); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 00ed90875d..5ba584270e 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -661,7 +661,10 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.pageBlock.option.location.record_related': 'Record related', 'engine.inspector.pageBlock.option.location.list_toolbar': 'List toolbar', 'engine.inspector.pageBlock.option.location.list_item': 'List item', - 'engine.inspector.pageBlock.option.location.global_nav': 'Global nav', + // `…option.location.global_nav` retired with the location itself + // (objectstack#6888, @objectstack/spec 17.0.0-rc.6). Its only reader was the + // dropdown option removed from `previews/block-config.ts`; a key kept past its + // option is dead vocabulary that the next author reads as a live surface. 'engine.inspector.pageBlock.field.ai:input.agentName': 'Agent', 'engine.inspector.pageBlock.placeholder.ai:input.agentName': 'agent name', 'engine.inspector.pageBlock.field.ai:input.placeholder': 'Input placeholder', @@ -2392,7 +2395,9 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.pageBlock.option.location.record_related': '记录关联列表', 'engine.inspector.pageBlock.option.location.list_toolbar': '列表工具栏', 'engine.inspector.pageBlock.option.location.list_item': '列表行', - 'engine.inspector.pageBlock.option.location.global_nav': '全局导航', + // `…option.location.global_nav` retired with the location itself — see the + // matching note in the `en` table above. Removed from BOTH tables in the same + // edit so the two key sets stay identical. 'engine.inspector.pageBlock.field.ai:input.agentName': '智能体', 'engine.inspector.pageBlock.placeholder.ai:input.agentName': '智能体名称', 'engine.inspector.pageBlock.field.ai:input.placeholder': '输入框占位提示', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx index 137357d4f1..319b06c2ac 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx @@ -119,8 +119,15 @@ const PARAM_TYPE_OPTS = [ * the spec ADDS is a missing-key error here rather than a silently absent * dropdown entry, and one it REMOVES is an excess-property error. * + * That mechanism is what fired here: `@objectstack/spec` 17.0.0-rc.6 retired + * `global_nav` from `ACTION_LOCATIONS` (objectstack#6888), and the entry that + * used to sit at the end of this map — `'Global nav / command palette'` — + * became a TS2353 excess property the moment the resolved vocabulary dropped to + * six. It is removed rather than re-typed; the six below are the whole + * vocabulary. + * * Insertion order is the display order — an authoring-friendly grouping - * (record → list → global), deliberately not the spec's declaration order. + * (record → list), deliberately not the spec's declaration order. */ const LOCATION_LABELS: Record = { record_header: 'Record header', @@ -129,7 +136,6 @@ const LOCATION_LABELS: Record = { record_related: 'Record · related list', list_toolbar: 'List toolbar', list_item: 'List · row', - global_nav: 'Global nav / command palette', }; const LOCATIONS: Array<{ value: ActionLocation; label: string }> = ( diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx index 48f0714b02..83a4aa1627 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx @@ -45,6 +45,14 @@ import { SchemaForm } from '../SchemaForm'; import { getDashboardForm, getDashboardSchema } from '../dashboard-schema'; import { mergeServerFields } from '../mergeServerFields'; import { t } from '../i18n'; +// `DashboardWidget.title` is the spec's `I18nLabel`, which @objectstack/spec +// 17.0.0-rc.6 widened from plain `string` to `string | Record` +// (the inline per-locale map, folded in from the retired `I18nObject`). Every +// read below lands in a text slot — a ` {w?.type} {!readOnly && ( diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx index d2eceb6890..64b6517b4d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx @@ -33,6 +33,9 @@ import type { DashboardWidgetSchema } from '@object-ui/types'; import { resolveDashboardFilterDefs, type DashboardFilterDef } from '@object-ui/core'; import type { MetadataInspectorProps } from '../inspector-registry'; import { t, tFormat } from '../i18n'; +// The spec's `I18nLabel` resolver (new in @objectstack/spec 17.0.0-rc.6), +// aliased apart from objectui's same-named translation-KEY resolver. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { InspectorCheckboxField, InspectorReorderButtons, moveArray } from './_shared'; import { InspectorComboField, type InspectorComboOption } from './InspectorComboField'; import { DatasetNamesEditor } from './ReportDefaultInspector'; @@ -183,7 +186,11 @@ export function DashboardWidgetInspector({ function moveWidget(to: number) { onPatch({ widgets: moveArray(widgetsAll, index, to) }); if (widget.id) { - onSelectionChange?.({ kind: 'widget', id: widget.id, label: widget.title ?? undefined }); + onSelectionChange?.({ + kind: 'widget', + id: widget.id, + label: resolveInlineI18nLabel(widget.title, locale), + }); } } @@ -195,7 +202,9 @@ export function DashboardWidgetInspector({ {t('engine.inspector.widget.kind', locale)}
- {widget.title || selection.label || `Widget ${index + 1}`} + {resolveInlineI18nLabel(widget.title, locale) || + selection.label || + `Widget ${index + 1}`}
@@ -221,12 +230,30 @@ export function DashboardWidgetInspector({
+ {/* The ONE authoring — not display — read of `widget.title`, and the only + site in this change where following the rc.6 widening mechanically + would have destroyed data. `I18nLabel` now admits an inline per-locale + map, and this is a single-line text input: resolving the map into it + and writing `e.target.value` straight back would silently collapse + every other locale the author wrote, on the first keystroke. So the + input stays the plain-string editor it has always been, and a + map-valued title is shown resolved and READ-ONLY instead of being + flattened. Nothing in any corpus can hit this path yet — `I18nLabel` + was plain `string` through 17.0.0-rc.5, so no stored widget title can + be a map — which is exactly why the conservative branch is safe to + take now and why authoring the map form is follow-up work + (objectui#4163, part 2) rather than a guess made here. */} patchWidget({ title: e.target.value })} disabled={readOnly} + readOnly={widget.title != null && typeof widget.title !== 'string'} /> diff --git a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx index c85fb82dcf..83f24fcf03 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx @@ -578,14 +578,18 @@ function PlacementPreview({ locations, label, icon, variant, iconOnly }: { )} - {locations.includes('global_nav') && ( - -
-
⌘K · Command palette
-
{label}
-
- - )} + {/* No `global_nav` frame. The location was retired from the spec's + `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 (objectstack#6888, + maintainer ruling 2026-08-09 direction 2) because no running-app + surface ever rendered it: the console's ⌘K palette + (`chrome/CommandPalette.tsx`) builds its groups from nav items, + objects, dashboards, pages, reports, recent items, record search and + theme, and reads no action metadata at all. The frame that used to + stand here drew the author a mock "⌘K · Command palette" preview, so + the designer promised a surface the product does not have — the + ADR-0078 "declares, 'renders', reports success, and does nothing" + shape, which is the sharpest harm the ruling names. Pinned negatively + in `__tests__/ActionPreview.locations.test.tsx`. */} ); } diff --git a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx index e1b6ad6e4a..f84ed669ae 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx @@ -23,6 +23,9 @@ import type { MetadataPreviewProps } from '../preview-registry'; import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell'; import { uniqueId, appendArray } from '../inspectors/_shared'; import { t as tr } from '../i18n'; +// The spec's own `I18nLabel` resolver, aliased so it is never confused with +// objectui's same-named translation-KEY resolver in `app-shell/src/utils`. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { AddWidgetPicker } from './AddWidgetPicker'; import { WIDGET_TYPE_META } from './widget-types'; @@ -62,10 +65,14 @@ export function DashboardPreview({ onSelectionChange({ kind: 'widget', id: widgetId, - label: w?.title || widgetId, + // `DashboardWidget.title` became `string | Record` in + // @objectstack/spec 17.0.0-rc.6 (`I18nLabel` absorbed the inline + // per-locale map). `SelectionChange.label` is a `string`, so the map + // form is resolved here rather than stringified at the render site. + label: resolveInlineI18nLabel(w?.title, locale) || widgetId, }); }, - [onSelectionChange, widgets], + [onSelectionChange, widgets, locale], ); const handleReorder = React.useCallback( diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.locations.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.locations.test.tsx new file mode 100644 index 0000000000..09a656cf9f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.locations.test.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#6888 / objectstack#7100 — the placement preview draws a frame for + * every action location the product actually renders, and for no other. + * + * ## What this replaces + * + * `ActionPreview` used to close its `PlacementPreview` with a `global_nav` + * frame drawing the author a mock command palette: + * + * ⌘K · Command palette + * [ New Task ] + * + * No such surface exists. The console's palette (`chrome/CommandPalette.tsx`) + * builds its groups from nav items, objects, dashboards, pages, reports, recent + * items, record search and theme; it holds no reference to `global_nav`, to + * `actionRendersAt`, or to any action-metadata source at all. So an author who + * declared `locations: ['global_nav']`, saw it previewed and shipped it got a + * button that rendered nowhere — the ADR-0078 "declares, 'renders', reports + * success, and does nothing" shape, arriving through the location vocabulary. + * The maintainer's 2026-08-09 ruling on #6888 (direction 2) retired the member; + * @objectstack/spec 17.0.0-rc.6 shipped that retirement. + * + * ## Why the assertions are paired + * + * Deleting the frame makes any "does not render global_nav" check pass forever, + * including if `PlacementPreview` were deleted whole or silently stopped + * rendering. So each negative pin here sits next to a positive one over the SAME + * render: a live location must still draw its frame in the same tree where the + * retired one must not appear. A vacuous pass then requires both halves to fail + * at once, which no plausible edit does. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, within, cleanup } from '@testing-library/react'; +import { ACTION_LOCATIONS } from '@objectstack/spec/ui'; + +import { ActionPreview } from '../ActionPreview'; + +afterEach(cleanup); + +function renderWithLocations(locations: string[]) { + return render( + , + ); +} + +/** + * The "Where it appears" section — the PLACEMENT simulation, and the only + * surface this change is about. + * + * Scoping matters and is not incidental. Higher up, the metadata strip echoes + * every token the draft declares as a chip under "Locations:", so an unscoped + * `queryByText('global_nav')` matches the ECHO and can never distinguish it + * from a frame. The echo is correct and deliberately untouched: it reports what + * this (possibly stale) draft says, which is honest, whereas the frame CLAIMED + * the platform renders it. That distinction is the whole retirement, so the + * test has to be able to see it. + */ +function placement() { + const heading = screen.getByText('Where it appears'); + // Section renders:
{title}
{children}
+ return within(heading.parentElement!.parentElement!); +} + +describe('ActionPreview placement frames', () => { + it('draws a frame for a live location', () => { + renderWithLocations(['record_header']); + expect(placement().getByText('record_header')).toBeTruthy(); + }); + + it('does NOT draw a frame for the retired global_nav location', () => { + // Both halves over one render: `record_header` proves the placement preview + // is alive and drawing, `global_nav` proves the retired frame is gone. + renderWithLocations(['record_header', 'global_nav']); + expect(placement().getByText('record_header')).toBeTruthy(); + expect(placement().queryByText('global_nav')).toBeNull(); + }); + + it('renders no command-palette mock for a global_nav-only action', () => { + renderWithLocations(['global_nav']); + // The mock frame's caption was literally "⌘K · Command palette". Asserting + // the visible string rather than a testid is deliberate: the harm the + // ruling names is what the AUTHOR SEES, so the pin is on what is displayed. + expect(screen.queryByText(/Command palette/i)).toBeNull(); + expect(placement().queryByText('global_nav')).toBeNull(); + // The section header still renders (it is gated on `locations.length`, not + // on how many frames draw), and its body is now empty. That is the truthful + // answer for a stale draft whose only location the platform retired — it + // appears nowhere — so it is pinned rather than left to look like a bug. + expect(screen.getByText('Where it appears')).toBeTruthy(); + }); + + it('the vocabulary this preview draws from no longer declares global_nav', () => { + // The upstream half of the same fact. Without it, a future spec that + // re-added the member would leave the pins above passing while the designer + // silently stopped previewing a location the product does declare. + expect(ACTION_LOCATIONS as readonly string[]).not.toContain('global_nav'); + expect(ACTION_LOCATIONS.length).toBe(6); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index c48cbf5280..0f202c2514 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { PageComponentType, RecordDetailsProps } from '@objectstack/spec/ui'; +import { ACTION_LOCATIONS, PageComponentType, RecordDetailsProps } from '@objectstack/spec/ui'; import { BLOCK_CONFIG, blockHasConfig, type PlaceholderSpec } from '../block-config'; import { BLOCK_TYPE_META, PALETTE_EXCLUSIONS } from '../block-types'; import { t } from '../../i18n'; @@ -243,4 +243,43 @@ describe('page palette ↔ spec PageComponentType coverage', () => { ); expect(orphanPanels, 'these expose a config panel but cannot be authored').toEqual([]); }); + + describe("record:quick_actions `location` — the designer's action-location dropdown", () => { + const locationField = () => + BLOCK_CONFIG['record:quick_actions'].find((f) => f.name === 'location') as + | { options?: Array<{ value: string }> } + | undefined; + + // POSITIVE half. Without it the negative pin below passes on an empty + // option list, which is how a deleted dropdown reads as a passing test. + it('offers exactly the locations the spec declares', () => { + const offered = (locationField()?.options ?? []).map((o) => o.value); + expect([...offered].sort()).toEqual([...ACTION_LOCATIONS].sort()); + }); + + // NEGATIVE pin, converted from the coverage the removed option used to + // carry. `global_nav` was retired from `ACTION_LOCATIONS` in + // @objectstack/spec 17.0.0-rc.6 (objectstack#6888, maintainer ruling + // 2026-08-09 direction 2): no running-app surface ever rendered it, and the + // console's ⌘K palette reads no action metadata at all. An option the + // schema now rejects by name must not be offerable, or the designer teaches + // authors — and every AI copying this corpus — to write metadata that fails + // to parse. + it('does NOT offer the retired global_nav location', () => { + const offered = (locationField()?.options ?? []).map((o) => o.value); + expect(offered.length, 'option list is empty — the pin would be vacuous').toBeGreaterThan(0); + expect(offered).not.toContain('global_nav'); + expect(ACTION_LOCATIONS as readonly string[]).not.toContain('global_nav'); + }); + + // The i18n side of the same removal: an option key kept past its option is + // dead vocabulary the next author reads as a live surface, so BOTH locale + // tables lost `…option.location.global_nav`. `t()` returns the key + // unchanged on a miss, which is exactly "this locale has no translation". + it('has no leftover translation for the retired option in either locale', () => { + const key = 'engine.inspector.pageBlock.option.location.global_nav'; + expect(t(key, 'en-US')).toBe(key); + expect(t(key, 'zh-CN')).toBe(key); + }); + }); }); diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts index 8972f627c0..8996c6699a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -472,7 +472,10 @@ export const BLOCK_CONFIG: Record = { { value: 'record_related', label: 'engine.inspector.pageBlock.option.location.record_related' }, { value: 'list_toolbar', label: 'engine.inspector.pageBlock.option.location.list_toolbar' }, { value: 'list_item', label: 'engine.inspector.pageBlock.option.location.list_item' }, - { value: 'global_nav', label: 'engine.inspector.pageBlock.option.location.global_nav' }, + // No `global_nav` option: the location was retired from the spec's + // `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 (objectstack#6888). + // Offering it here would let the designer author a value the schema now + // rejects by name. Pinned negatively in `__tests__/block-config.test.ts`. ], }, ], diff --git a/packages/auth/package.json b/packages/auth/package.json index e2b9b7f2fe..1453a46e19 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "better-auth": "^1.6.26" }, "devDependencies": { diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json index 50814228d3..5a43c3c6a7 100644 --- a/packages/collaboration/package.json +++ b/packages/collaboration/package.json @@ -41,7 +41,7 @@ "@object-ui/types": "workspace:*" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react": "19.2.18", "react": "19.2.8", "typescript": "^6.0.3", diff --git a/packages/components/package.json b/packages/components/package.json index 16fcff4251..012a656582 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -40,7 +40,7 @@ "@object-ui/react-runtime": "workspace:*", "@object-ui/sdui-parser": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-aspect-ratio": "^1.1.15", diff --git a/packages/components/src/renderers/action/action-bar.tsx b/packages/components/src/renderers/action/action-bar.tsx index f13c30d297..b06e2e9e12 100644 --- a/packages/components/src/renderers/action/action-bar.tsx +++ b/packages/components/src/renderers/action/action-bar.tsx @@ -15,8 +15,13 @@ * `maxVisible` threshold are grouped into an overflow "More" dropdown. * * This is the "bridge" component that connects ActionSchema metadata to the UI, - * enabling server-driven action rendering at list_toolbar, record_header, - * list_item, record_more, record_related, and global_nav locations. + * enabling server-driven action rendering at every location the spec declares: + * list_toolbar, list_item, record_header, record_more, record_related and + * record_section. (`global_nav` used to close that list; it was retired from + * `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 — objectstack#6888 — as a + * location no running-app surface ever rendered. The enum this component + * publishes is `[...ACTION_LOCATIONS]`, so it followed the retirement on its + * own; only this prose had to be aligned.) * * @example * ```tsx diff --git a/packages/core/package.json b/packages/core/package.json index 74e37d70f2..3efa776478 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,8 +32,8 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/formula": "^17.0.0-rc.5", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/formula": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0-rc.6", "lodash": "^4.18.1", "zod": "^4.4.3" }, diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 719f65113e..68373203e1 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -22,7 +22,7 @@ */ import type { RunnableActionType } from '@object-ui/types'; -import type { ActionInput as SpecActionInput } from '@objectstack/spec/ui'; +import type { Action as SpecActionInput } from '@objectstack/spec/ui'; import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator'; import { hasDeclaredPredicate } from '../evaluator/declaredPredicate'; import { globalUndoManager, type UndoableOperation } from './UndoManager'; @@ -244,14 +244,21 @@ export interface ActionDef { // hand-copied: a hand-written duplicate of a spec shape is a second contract // that drifts silently, which is the failure this whole issue is about. // - // Derived from `z.input` (`ActionInput`), not `z.infer` (`Action`), and the - // difference is load-bearing rather than stylistic. `ActionSchema` is a - // `ZodPipe` whose transforms narrow the authored shape — `visible` is authored - // as `string | { dialect, source }` but INFERS to the object form alone. This - // runner consumes authored/stored rows, which #3903 established are rehydrated - // UNPARSED, so it sees the input shape. Deriving from `Action` would have - // type-errored the raw-string predicate that `ActionEngine` explicitly - // supports and that `ActionEngine.visibility.test.ts` pins. + // Derived from `z.input`, not `z.infer`, and the difference is load-bearing + // rather than stylistic. `ActionSchema` is a `ZodPipe` whose transforms narrow + // the authored shape — `visible` is authored as `string | { dialect, source }` + // but INFERS to the object form alone. This runner consumes authored/stored + // rows, which #3903 established are rehydrated UNPARSED, so it sees the input + // shape. Deriving from the `z.infer` side would type-error the raw-string + // predicate that `ActionEngine` explicitly supports and that + // `ActionEngine.visibility.test.ts` pins. + // + // WHICH SPEC NAME carries `z.input` changed under us in `@objectstack/spec` + // 17.0.0-rc.6: up to rc.5 it was `ActionInput` (with `Action` = `z.infer`), + // and rc.6 retired every `…Input` alias and moved the bare name onto the input + // side (`Action` = `z.input`, `ActionParsed` = `z.infer`). The import above is + // therefore `Action as SpecActionInput` — the local alias keeps naming the + // side this file needs, so every derivation below is unchanged in meaning. // // `shortcut` and `bulkEnabled` land here as `undefined`, not as a usable type // — that is correct and deliberate. Spec 17 retired both as `retiredKey()` diff --git a/packages/data-objectstack/package.json b/packages/data-objectstack/package.json index 176c4e6b43..0715bcb71a 100644 --- a/packages/data-objectstack/package.json +++ b/packages/data-objectstack/package.json @@ -32,8 +32,8 @@ "dependencies": { "@object-ui/core": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/client": "^17.0.0-rc.5", - "@objectstack/spec": "^17.0.0-rc.5" + "@objectstack/client": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0-rc.6" }, "devDependencies": { "tsup": "^8.5.1", diff --git a/packages/fields/package.json b/packages/fields/package.json index 1a75f4dc61..a40cafe3f6 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -37,7 +37,7 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "clsx": "^2.1.1", "lucide-react": "^1.29.0", "react-markdown": "^10.1.0", diff --git a/packages/layout/package.json b/packages/layout/package.json index 0fc6ccbb58..2a5add518c 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -42,7 +42,7 @@ "react-router-dom": "^6.0.0 || ^7.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@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/mobile/package.json b/packages/mobile/package.json index 498b988acf..5eb4658521 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -40,7 +40,7 @@ "@object-ui/types": "workspace:*" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react": "19.2.18", "react": "19.2.8", "typescript": "^6.0.3", diff --git a/packages/plugin-charts/package.json b/packages/plugin-charts/package.json index 35355a4547..23a1c46bf0 100644 --- a/packages/plugin-charts/package.json +++ b/packages/plugin-charts/package.json @@ -44,7 +44,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-chatbot/package.json b/packages/plugin-chatbot/package.json index 5f67079a6c..1432a348d4 100644 --- a/packages/plugin-chatbot/package.json +++ b/packages/plugin-chatbot/package.json @@ -38,7 +38,7 @@ "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", "@radix-ui/react-slot": "^1.3.3", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@radix-ui/react-use-controllable-state": "^1.2.6", "ai": "^7.0.56", "class-variance-authority": "^0.7.1", diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json index b44b7c022f..4fcf51d219 100644 --- a/packages/plugin-dashboard/package.json +++ b/packages/plugin-dashboard/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@object-ui/plugin-charts": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react-grid-layout": "^2.1.0", "@vitejs/plugin-react": "^6.0.5", "react-grid-layout": "^2.2.4", diff --git a/packages/plugin-detail/package.json b/packages/plugin-detail/package.json index 9ca27ce13c..90c7eba797 100644 --- a/packages/plugin-detail/package.json +++ b/packages/plugin-detail/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@object-ui/i18n": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0" }, "peerDependencies": { diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json index 9dfeb7efec..2bd1f08031 100644 --- a/packages/plugin-form/package.json +++ b/packages/plugin-form/package.json @@ -28,7 +28,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0" }, "peerDependencies": { diff --git a/packages/plugin-gantt/package.json b/packages/plugin-gantt/package.json index 9f38fe7369..02f46495c4 100644 --- a/packages/plugin-gantt/package.json +++ b/packages/plugin-gantt/package.json @@ -38,7 +38,7 @@ "@object-ui/plugin-detail": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0", "sonner": "^2.0.7" }, diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json index d265529c26..2aa5bdee67 100644 --- a/packages/plugin-grid/package.json +++ b/packages/plugin-grid/package.json @@ -29,7 +29,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@tanstack/react-virtual": "^3.14.9", "exceljs": "^4.4.0", "lucide-react": "^1.29.0" @@ -40,7 +40,7 @@ }, "devDependencies": { "@object-ui/data-objectstack": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@vitejs/plugin-react": "^6.0.5", "msw": "^2.15.0", "typescript": "^6.0.3", diff --git a/packages/plugin-list/package.json b/packages/plugin-list/package.json index d21556228c..fdcf00d853 100644 --- a/packages/plugin-list/package.json +++ b/packages/plugin-list/package.json @@ -42,7 +42,7 @@ "@object-ui/permissions": "workspace:^", "@object-ui/react": "workspace:^", "@object-ui/types": "workspace:^", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, @@ -55,7 +55,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-map/package.json b/packages/plugin-map/package.json index 0b575bd7e3..68ef4142c5 100644 --- a/packages/plugin-map/package.json +++ b/packages/plugin-map/package.json @@ -35,7 +35,7 @@ "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0", "maplibre-gl": "^6.2.0", "react-map-gl": "^8.1.2", diff --git a/packages/plugin-report/package.json b/packages/plugin-report/package.json index 6158f6e033..10316f63d9 100644 --- a/packages/plugin-report/package.json +++ b/packages/plugin-report/package.json @@ -43,7 +43,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/node": "^26.1.2", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/plugin-timeline/package.json b/packages/plugin-timeline/package.json index 8e0fe5e8a5..ef8d156438 100644 --- a/packages/plugin-timeline/package.json +++ b/packages/plugin-timeline/package.json @@ -37,7 +37,7 @@ "@object-ui/mobile": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "class-variance-authority": "^0.7.1", "zod": "^4.4.3" }, diff --git a/packages/plugin-tree/package.json b/packages/plugin-tree/package.json index eacd49c26a..62dc8843ab 100644 --- a/packages/plugin-tree/package.json +++ b/packages/plugin-tree/package.json @@ -36,7 +36,7 @@ "@object-ui/i18n": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0" }, "peerDependencies": { diff --git a/packages/plugin-view/package.json b/packages/plugin-view/package.json index c7095b4e3d..e8217ba256 100644 --- a/packages/plugin-view/package.json +++ b/packages/plugin-view/package.json @@ -31,7 +31,7 @@ "@object-ui/plugin-grid": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "class-variance-authority": "^0.7.1", "lucide-react": "^1.29.0" }, diff --git a/packages/providers/package.json b/packages/providers/package.json index 1ea4937ed7..59a457403a 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5" + "@objectstack/spec": "^17.0.0-rc.6" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/react/package.json b/packages/react/package.json index e32b903772..4db2939c2b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,7 +34,7 @@ "@object-ui/data-objectstack": "workspace:*", "@object-ui/i18n": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "react-hook-form": "^7.84.0" }, "peerDependencies": { diff --git a/packages/react/src/hooks/__tests__/useActionEngine.test.ts b/packages/react/src/hooks/__tests__/useActionEngine.test.ts index 4bfcd9ca27..87cb88368b 100644 --- a/packages/react/src/hooks/__tests__/useActionEngine.test.ts +++ b/packages/react/src/hooks/__tests__/useActionEngine.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; +import { ActionLocationSchema } from '@objectstack/spec/ui'; import { useActionEngine } from '../useActionEngine'; describe('useActionEngine', () => { @@ -28,6 +29,14 @@ describe('useActionEngine', () => { locations: ['list_item'], }, { + // Deliberately STALE, in the same posture as the `bulkEnabled` and + // `shortcut` keys this fixture also keeps: `global_nav` was retired from + // the spec's `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 + // (objectstack#6888), so metadata like this can no longer be authored. + // Kept rather than deleted because the coverage that matters now is the + // INVERSE — see 'does not offer an action declaring the retired + // global_nav location' below. Deleting the entry would delete the only + // input that can prove the retired location leaks nowhere. name: 'global_search', type: 'script', target: 'true', @@ -36,6 +45,16 @@ describe('useActionEngine', () => { }, ]; + /** The six locations `ACTION_LOCATIONS` declares as of 17.0.0-rc.6. */ + const LIVE_LOCATIONS = [ + 'list_toolbar', + 'list_item', + 'record_header', + 'record_more', + 'record_related', + 'record_section', + ] as const; + describe('getActionsForLocation', () => { it('returns actions for list_toolbar', () => { const { result } = renderHook(() => @@ -75,6 +94,61 @@ describe('useActionEngine', () => { const actions = result.current.getActionsForLocation('record_related'); expect(actions.length).toBe(0); }); + + // `global_nav` was retired from `ACTION_LOCATIONS` in @objectstack/spec + // 17.0.0-rc.6 (objectstack#6888) because no running-app surface ever + // rendered it. `sampleActions` still declares it, so this pins what a stale + // declaration can and cannot do: it must surface at NONE of the six live + // locations. + it('does not offer an action declaring the retired global_nav location', () => { + const { result } = renderHook(() => + useActionEngine({ actions: sampleActions }), + ); + + for (const location of LIVE_LOCATIONS) { + const names = result.current + .getActionsForLocation(location) + .map((a) => a.name); + expect(names, `global_search leaked into ${location}`).not.toContain( + 'global_search', + ); + } + }); + + // The OTHER half, and it is deliberately NOT the symmetric one. Asking the + // engine for `'global_nav'` still returns the stale action, and that is + // correct: `ActionEngine.getActionsForLocation` is a literal string match + // over whatever a host registered, not a vocabulary filter. Narrowing it to + // the six live members would be renderer-side enforcement of a spec + // vocabulary — precisely the tolerant/lenient consumer shape AGENTS.md #0.1 + // forbids, and it would put a SECOND rejection point beside the schema's. + // + // Enforcement lives in two places instead, both pinned here: + // 1. the TYPE — `getActionsForLocation(location: ActionLocation)` is now + // six-membered, so no type-correct caller can spell the retired value + // at all (the cast below is what a stale caller would have to write); + // 2. the SCHEMA — `ActionLocationSchema` rejects it by name at authoring + // and publish time, with the retirement message pointing at the fix. + // Between them, metadata carrying `global_nav` cannot be saved, and no live + // caller can ask for it. This test was written expecting the engine to + // return `[]` and measured otherwise; the measurement is what it now pins. + it('leaves the retired location to the schema, not to a runtime filter', () => { + const { result } = renderHook(() => + useActionEngine({ actions: sampleActions }), + ); + + const retired = result.current.getActionsForLocation( + 'global_nav' as (typeof LIVE_LOCATIONS)[number], + ); + expect(retired.map((a) => a.name)).toEqual(['global_search']); + + const rejected = ActionLocationSchema.safeParse('global_nav'); + expect(rejected.success).toBe(false); + expect(rejected.error?.issues?.[0]?.message).toContain( + 'was removed from `ACTION_LOCATIONS`', + ); + expect(ActionLocationSchema.safeParse('record_header').success).toBe(true); + }); }); describe('getBulkActions', () => { diff --git a/packages/runner/package.json b/packages/runner/package.json index 74417bc6ed..c692a4734b 100644 --- a/packages/runner/package.json +++ b/packages/runner/package.json @@ -37,7 +37,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@tailwindcss/postcss": "^4.3.3", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index aaf926c97e..f6f51f7259 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -33,6 +33,6 @@ "devDependencies": { "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5" + "@objectstack/spec": "^17.0.0-rc.6" } } diff --git a/packages/types/package.json b/packages/types/package.json index e8178996c7..ebf2a9058a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -90,7 +90,7 @@ "directory": "packages/types" }, "dependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "zod": "^4.4.3" }, "devDependencies": { 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 6509dd657d..dcdc04b802 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 @@ -561,8 +561,9 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 ['PageRegion', 'PageNodeRegion'], ['PageRegionSchema', 'PageNodeRegionSchema'], ['ResponsiveConfig', 'MobileResponsiveConfig'], - ['WidgetManifest', 'RuntimeWidgetManifest'], - ['WidgetSource', 'RuntimeWidgetSource'], + // `WidgetManifest` / `WidgetSource` moved OUT of this list on the + // 17.0.0-rc.6 bump — see the block below. The tripwire fired for the second + // time, the same way objectui#3363 recorded the first. ])('the spec still owns `%s`, which is why the rename to `%s` happened', (owned) => { // The other direction of the tripwire: if the spec RETIRES one of these, // the local dialect can take the natural name back. A workaround should not @@ -586,6 +587,38 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 * * The third did not move — see the `OfflineConfig` block below. */ + /** + * THE TRIPWIRE FIRED A SECOND TIME, on the @objectstack/spec 17.0.0-rc.6 bump + * (objectstack#7100). rc.6 retired the whole widget-manifest vocabulary — + * `WidgetManifest`, `WidgetSource`, `WidgetProperty`, `WidgetEvent`, + * `WidgetLifecycle` and their `…Schema`/`…Parsed` twins — so the spec stopped + * owning the two names that forced this package's `Runtime…` prefixes, and + * both rows moved from the "spec still owns" list above to this one. + * + * NOT reclaimed here, deliberately. The reclaim is available, not required: + * `RuntimeWidgetManifest`/`RuntimeWidgetSource` are exported names, so taking + * the bare names back is a cross-package rename with its own review, and this + * PR is a `global_nav` retirement that the bump merely rides under. What the + * rows below do is keep the vacancy PINNED — if the spec re-publishes either + * name while objectui holds it, that is a live collision, and this goes red. + * The unlock is recorded rather than taken: objectui#4164. + */ + it.each([ + ['WidgetManifest', 'RuntimeWidgetManifest'], + ['WidgetSource', 'RuntimeWidgetSource'], + ])( + 'the spec no longer owns `%s`, so `%s` keeps its prefix by choice, not by force (objectstack#7100)', + (vacated) => { + expect( + names, + `spec owns '${vacated}' again. The rc.6 retirement that vacated it has ` + + `been undone upstream, so the prefix on the local dialect is load-bearing ` + + `again — move this row back to the "spec still owns" list, and re-triage ` + + `any reclaim planned in objectui#4164.`, + ).not.toContain(vacated); + }, + ); + it.each([ ['GestureType', 'TouchGestureType'], ['GestureConfig', 'TouchGestureConfig'], diff --git a/packages/types/src/__tests__/spec-ui-schema-reexports.test.ts b/packages/types/src/__tests__/spec-ui-schema-reexports.test.ts index aa3dc9f78e..43175daeee 100644 --- a/packages/types/src/__tests__/spec-ui-schema-reexports.test.ts +++ b/packages/types/src/__tests__/spec-ui-schema-reexports.test.ts @@ -92,11 +92,15 @@ const DROPPED_SCHEMA_EXPORTS = [ 'AriaPropsSchema', // I18n 'I18nLabelSchema', - 'I18nObjectSchema', - 'LocaleConfigSchema', - 'PluralRuleSchema', - 'DateFormatSchema', - 'NumberFormatSchema', + // `I18nObjectSchema`, `LocaleConfigSchema`, `PluralRuleSchema`, + // `DateFormatSchema` and `NumberFormatSchema` were removed from this list on + // the @objectstack/spec 17.0.0-rc.6 bump (objectstack#7100): the spec RETIRED + // all five, so each row asserted "this name is not re-exported from + // @object-ui/types" about a name that no longer exists anywhere — vacuously + // true forever, which is what the guard above this list exists to catch. + // `I18nLabelSchema` SURVIVES rc.6 and stays denied; the release folded the + // per-locale record form into `I18nLabel` itself (`string` → + // `string | Record`) and ships `resolveI18nLabel` for it. // Responsive Design 'SpecResponsiveConfigSchema', 'BreakpointColumnMapSchema', diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6b77dbef79..f6b60305ea 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1188,13 +1188,19 @@ export type { // ============================================================================ // v2.0.7 Spec UI Types — I18n // ============================================================================ +// `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` and `NumberFormat` +// (and their `…Schema` values) were retired from `@objectstack/spec/ui` in +// 17.0.0-rc.6. Nothing in this repo imported them from here — the only names +// that looked like consumers are `Intl.NumberFormat` call sites and +// `@object-ui/i18n`'s own locally declared formatter vocabulary in +// `utils/spec-formatters.ts`, which never bound the spec symbols. So these five +// were dead re-exports and are dropped rather than re-declared locally. +// `I18nLabel` SURVIVES rc.6 and stays bound by reference: it is the authoring +// label union (`string | Record`) that `ActionParam.label` and +// friends are typed against, and rc.6 additionally publishes the inline-map +// half as `InlineLocaleMap` plus the shared `resolveI18nLabel` resolver. export type { I18nLabel, - I18nObject, - LocaleConfig, - PluralRule, - DateFormat, - NumberFormat, } from '@objectstack/spec/ui'; // ============================================================================ diff --git a/packages/types/src/spec-report.ts b/packages/types/src/spec-report.ts index 1fb7483475..512e5b333f 100644 --- a/packages/types/src/spec-report.ts +++ b/packages/types/src/spec-report.ts @@ -40,11 +40,19 @@ * when translating a Report column into an ObjectQL `AggregationNode`. */ +// `@objectstack/spec` 17.0.0-rc.6 retired every `…Input` alias and moved the +// bare name onto the `z.input` side: `Report`/`ReportChart` are now +// `z.input` and `ReportParsed`/`ReportChartParsed` carry the +// `z.infer` shape these bindings used to read under the bare name. Both pairs +// below are therefore re-pointed so that each local alias keeps the meaning it +// had at rc.5 — `SpecReport`/`SpecReportChart` stay PARSED, `SpecReportInput`/ +// `SpecReportChartInput` stay INPUT. Following the rename by name alone (`X` → +// `X`) would have silently swapped the two. import type { - Report as SpecReportType_, - ReportInput as SpecReportInputType_, - ReportChart as SpecReportChartType_, - ReportChartInput as SpecReportChartInputType_, + ReportParsed as SpecReportType_, + Report as SpecReportInputType_, + ReportChartParsed as SpecReportChartType_, + ReportChart as SpecReportChartInputType_, } from '@objectstack/spec/ui'; import { diff --git a/packages/types/src/theme.ts b/packages/types/src/theme.ts index 7acb5e5682..57fa904f65 100644 --- a/packages/types/src/theme.ts +++ b/packages/types/src/theme.ts @@ -81,24 +81,31 @@ import type { * by reference (objectstack#4115) rather than restated. * * The interface this replaces was member-for-member identical to the spec's - * `ThemeInput` (verified by the mutual-assignability probe in + * authoring theme shape (verified by the mutual-assignability probe in * `__tests__/page-nav-misc-spec-parity.test.ts`), so the only thing the copy * added was a second place to drift from — under the spec's own symbol name, * which is what makes such a copy read as canonical to the next reader. * - * `ThemeInput`, not `Theme`: the spec's `z.infer` is the - * PARSED shape, where `mode` is required because `.default('auto')` has already - * run. Everything in objectui that carries a `Theme` is on the authoring side — - * theme JSON as stored, edited and handed to `ThemeProvider` — so the input - * shape (`mode` optional) is the one that is true here. This is the + * **The `z.input` shape, not the parsed one**, and in `@objectstack/spec` + * 17.0.0-rc.6 that shape is spelled `Theme`. Up to rc.5 the spec published + * three names — `Theme` (= `z.infer`), `ThemeParsed` (= `z.infer`) and + * `ThemeInput` (= `z.input`) — and this binding took `ThemeInput`. rc.6 retired + * every `…Input` alias and made the bare name the input: `Theme` is now + * `z.input` and `ThemeParsed` is the `z.infer` side. So + * `ThemeInput` → `Theme` is a rename that preserves this binding's meaning + * exactly; re-pointing at `ThemeParsed` would be the silent semantic change. + * The reason the input side is the true one here is unchanged: `mode` is + * required in the parsed shape because `.default('auto')` has already run, and + * everything in objectui that carries a `Theme` is on the authoring side — + * theme JSON as stored, edited and handed to `ThemeProvider`. This is the * `.default()`/`z.input` rule from objectui#3169: writing side → input type. * * This is the canonical JSON shape for a theme. It can be serialized, stored, * and applied at runtime via ThemeProvider. */ -export type { ThemeInput as Theme } from '@objectstack/spec/ui'; +export type { Theme } from '@objectstack/spec/ui'; -import type { ThemeInput as Theme } from '@objectstack/spec/ui'; +import type { Theme } from '@objectstack/spec/ui'; // ============================================================================ // ObjectUI Component Schemas (UI rendering) diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 15ca470ffd..6b9922511a 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -327,8 +327,19 @@ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema. * that object form, which the spec's `string | number | boolean` rejects. * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. + * + * `.safeExtend`, not `.extend`, since @objectstack/spec 17.0.0-rc.6: the spec's + * `GlobalFilterSchema` now carries a refinement, and zod 4 refuses `.extend()` + * on a refined object outright — *"Cannot overwrite keys on object schemas + * containing refinements. Use `.safeExtend()` instead."* — which threw at + * MODULE LOAD and took six `@object-ui/types` suites down with it before this + * file ran a single test. `.safeExtend` is zod's own prescribed replacement and + * KEEPS the refinement (measured: one check before, one check after), so the + * spec's cross-field rule still runs on objectui's dialect. That is the + * behaviour we want — the three divergences below widen individual FIELDS, and + * were never meant to switch off a whole-object rule. */ -export const GlobalFilterSchema = SpecGlobalFilterSchema.extend({ +export const GlobalFilterSchema = SpecGlobalFilterSchema.safeExtend({ options: z.array(z.union([ z.string(), z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e36ecee63..2af93336f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,8 +35,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -267,11 +267,11 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/client': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -405,8 +405,8 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) fumadocs-core: specifier: 16.14.1 version: 16.14.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.29.0(react@19.2.8))(next@16.3.0(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) @@ -721,14 +721,14 @@ importers: specifier: workspace:* version: link:../types '@objectstack/formula': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/lint': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@sentry/react': specifier: ^10.69.0 version: 10.69.0(react@19.2.8) @@ -818,8 +818,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) better-auth: specifier: ^1.6.26 version: 1.6.26(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0)(next@16.3.0(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -911,8 +911,8 @@ importers: version: link:../types devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -947,8 +947,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1131,11 +1131,11 @@ importers: specifier: workspace:* version: link:../types '@objectstack/formula': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -1193,11 +1193,11 @@ importers: specifier: workspace:* version: link:../types '@objectstack/client': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) devDependencies: tsup: specifier: ^8.5.1 @@ -1230,8 +1230,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + 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 @@ -1347,8 +1347,8 @@ importers: version: 3.6.0 devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + 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)) @@ -1372,8 +1372,8 @@ importers: version: link:../types devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1547,8 +1547,8 @@ importers: version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.6)(react@19.2.8)(redux@5.0.1) devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1589,8 +1589,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1708,8 +1708,8 @@ importers: specifier: workspace:* version: link:../plugin-charts '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1817,8 +1817,8 @@ importers: specifier: workspace:* version: link:../i18n '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -1936,8 +1936,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -1991,8 +1991,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -2052,8 +2052,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2193,8 +2193,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2232,8 +2232,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -2382,8 +2382,8 @@ importers: version: 3.6.0 devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/node': specifier: ^26.1.2 version: 26.1.2 @@ -2427,8 +2427,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2482,8 +2482,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -2546,8 +2546,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2580,8 +2580,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2614,8 +2614,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2688,8 +2688,8 @@ importers: version: 1.0.7(tailwindcss@4.3.3) devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2727,14 +2727,14 @@ importers: specifier: workspace:* version: link:../react '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) packages/types: dependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -4080,26 +4080,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.0.0-rc.5': - resolution: {integrity: sha512-E1wiOK90d6CbiYtbQ0157oA8BKTBnf6JfizZ0vcH7gh89rrCCeL6dIsmo4SQyOYh+ZWtBRJMttrhLCLm35LBXQ==} + '@objectstack/client@17.0.0-rc.6': + resolution: {integrity: sha512-gdHhcY3JTRRmjlosIUP2WE4wG0ciwfYWu+HqyvtjFHVnhuNOoDOuzsKy2M/CppeOaKDay21Bs/HNH9ljGRQOnQ==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.0.0-rc.5': - resolution: {integrity: sha512-kw8A9ERMscZwEnOXGDFmnUW0QdYcEhvsiHW6H4HgBQYJEqkbWXjBaS0ujbWxbn/hT5A+pEAiVQc2RcDVx/yHYA==} + '@objectstack/core@17.0.0-rc.6': + resolution: {integrity: sha512-78VndPhlxUXAupQVJ04YDdFbyIYcMJxw1I+Q7dlUuf+NGrlP7VzWwAQZm98dRH3q+M5wEvvV5t0myLhmVEpmKQ==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.0.0-rc.5': - resolution: {integrity: sha512-50DI3S/A8cyR4amwROxOcYD1r4om7bo/3Gf6MugEfWXfejzLpc9EUS3maIlYiW0orAmo20SHA81Q4cB+qGUxGw==} + '@objectstack/formula@17.0.0-rc.6': + resolution: {integrity: sha512-7vK6WgjeaO4rijaI4qZucRLsYGOPnrO/vuSd6wJCr1MpsH9go1yWLVte8itF0mTU0PhqXcnwtPHYCsxYnu4Y1Q==} - '@objectstack/lint@17.0.0-rc.5': - resolution: {integrity: sha512-0huaGN11ub7iqjuRzmiyUoh0Sju1U8tpVkp062ilOOF2bf0xzxgSVUdQrJlFNjV4+gzLJnkbqha1/4Y8Tr4dNA==} + '@objectstack/lint@17.0.0-rc.6': + resolution: {integrity: sha512-xZsa8DB2h76M0b6R4pFNnVZ1IzwGPxRI2vZhsFa5qNMiEWOLTc+WGVB76X18syzh0hhSMf69BR0G1G40/nOa5Q==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.0.0-rc.5': - resolution: {integrity: sha512-gi61YonxZnXQ5gPMa7CShz8d3Yg1ZGYC24jbDtOerbgOlBowc36Qrp3UKIL2JjW6PBQJJxUY+Y0h4lDwAEs/BA==} + '@objectstack/sdui-parser@17.0.0-rc.6': + resolution: {integrity: sha512-bao+TMFCGwu73ilvoJCrXLHWBvXhzHo/um1IHFJPcLXSRyee0Mea8rDy6Q2Ks04/VhYOza4M9rG1npoLk5DqQA==} - '@objectstack/spec@17.0.0-rc.5': - resolution: {integrity: sha512-9AFBwdwCaFPUFxGAShRMvW3UQAW+Gz0ET1CEJEHL5f2Qh/Bu7hGMBT7NY8fhYqfajAroe08jZGx3JMOFgS2v5Q==} + '@objectstack/spec@17.0.0-rc.6': + resolution: {integrity: sha512-xDdZVm7KtpcKZVyt0LSaGQ8OtAgaIZnPqug5zPkndySFGQKdjPtiANUfVVX4H/L25HdEXbzodFZwBbPYR37zFQ==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -12447,32 +12447,32 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/client@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: - '@objectstack/core': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) - '@objectstack/spec': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + '@objectstack/core': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/core@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/core@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) zod: 4.4.3 transitivePeerDependencies: - ai - '@objectstack/formula@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/formula@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/lint@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/lint@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: - '@objectstack/formula': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) - '@objectstack/sdui-parser': 17.0.0-rc.5 - '@objectstack/spec': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + '@objectstack/formula': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) + '@objectstack/sdui-parser': 17.0.0-rc.6 + '@objectstack/spec': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12480,9 +12480,9 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.0.0-rc.5': {} + '@objectstack/sdui-parser@17.0.0-rc.6': {} - '@objectstack/spec@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/spec@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: zod: 4.4.3 optionalDependencies: From db9dad96173ead776a58a863433ddb98f0512c2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 17:15:21 +0000 Subject: [PATCH 2/5] fix(deps): repair the rc.6 fallout the first pass missed, and pin the one conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass over the @objectstack/spec 17.0.0-rc.6 bump. The first commit fixed every COMPILE error; a full `vitest run` (14615 tests) then surfaced the runtime half. All mechanical, each pinned; the one non-mechanical case is left as a tripwire rather than decided. - types/zod/complex.zod.ts: `GlobalFilterSchema` composes by spreading the spec's `.shape`. rc.6 closed BOTH extension doors on a now-refined schema — `.extend()` throws at module load, `.safeExtend()` types the three overridden keys as `never` (TS2322, which only `apps/site`'s build caught). The spread preserves rc.5 behaviour exactly and decides nothing: the refinement-vs-stored-`{preset}` conflict is objectui#4165, pinned by a new tripwire that reds from either side. The pin isolates the refinement by widening `defaultValue` first — the bare spec schema rejects `{preset}` on the OLD field type and the refinement never runs, so the obvious shorter assertion would have pinned the wrong thing. - core/actionKeys.ts: `description` joins SPEC_ACTION_KEYS (rc.6 added it to `ActionSchema`). Inventory only — `ActionDef` is untouched. - metadata-admin flow tests x2: `FlowNodeSchema` became pipe-wrapped, so `.shape` read `undefined`. Access-path repair (walk the wrapper); the blocks themselves are unchanged upstream, and a non-vacuity guard now says so. - clientValidation.optOuts: INVERTED — rc.6 makes `SharingRuleSchema` declare the seven ADR-0010 envelope keys, so the create door now ACCEPTS a stamped `_packageId`. Upstream convergence, pinned as accept, with a bogus-key case proving the door is still a door. - clientValidation.viewDiagnostics: fixture supplies a `label`; rc.6's widened `I18nLabel` made every label a LEAF union, which #3606's root-union expansion does not reach. The resulting bare `Invalid input` is recorded as its own pin (objectui#4163), not papered over. - components/record-picker parity: fixture drops `displayField` — an ADR-0087 D2 tombstone refused by name, which was failing all three `filter` assertions. - console registry parity: `SPEC_SHAPE_EMPTY_ON_THE_PIN` deleted exactly as its own self-clearing test instructed (rc.6 lands `PageContainerProps`), with a tombstone assertion so the deletion cannot be silently undone. - i18n residue ratchet: matches `const { t, … } = useObjectTranslation(` by pattern. Both sidebars legitimately also take `language` now, to resolve the spec's inline per-locale labels. Still red, filed, NOT fixed here: 2 console-parity exemption-list entries and `check:spec-symbols`' 13 name collisions (objectui#4167) — each is per-symbol triage, not a mechanical edit — and objectui#4165, which blocks ready. Part of objectstack-ai/objectstack#7100 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../registry-inputs-spec-parity.test.ts | 54 +++++++------ .../clientValidation.optOuts.test.ts | 34 ++++++++- .../clientValidation.viewDiagnostics.test.ts | 37 ++++++++- ...ow-node-config.spec-reconciliation.test.ts | 40 +++++++++- .../flow-canvas-seeds.spec-parse.test.tsx | 33 +++++++- .../record-picker-inputs-spec-parity.test.ts | 19 ++++- packages/core/src/actions/actionKeys.ts | 9 +++ .../residue-namespaces-3546.test.tsx | 13 +++- .../report-chart-query-spec-parity.test.ts | 75 +++++++++++++++++++ packages/types/src/zod/complex.zod.ts | 49 +++++++++--- 10 files changed, 309 insertions(+), 54 deletions(-) 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 57b5870914..d1b8c3d571 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -265,24 +265,24 @@ const EXPECTED_WITHOUT_INPUTS = [ 'nav:menu', ]; -/** - * Covered blocks whose spec props schema legitimately resolves to ZERO keys on - * the pinned `@objectstack/spec`, so the non-empty probe guard below must not - * judge them (objectui#4027). +/* + * `SPEC_SHAPE_EMPTY_ON_THE_PIN` — DELETED on the @objectstack/spec 17.0.0-rc.6 + * bump (objectstack#7100), exactly as it was designed to be. * - * The guard exists to catch a BROKEN reader — a `specTopLevelKeys` that stopped - * resolving `.shape` and returned `[]` for everything. These three return `[]` - * for a real reason instead: rc.5 still maps them to `EmptyProps`, while - * objectstack#5775 / PR objectstack#6281 replaced that with the shared - * `PageContainerProps` upstream. Without this carve-out the guard would report - * "spec shape did not resolve" for a shape that resolved perfectly well and is - * simply empty — a false accusation against the probe. + * It carved `page:footer` / `page:section` / `page:sidebar` out of the + * non-empty probe guard because rc.5 still mapped all three to `EmptyProps`, + * so their shapes resolved to `{}` for a real reason rather than a broken + * reader (objectui#4027). objectstack#5775 / PR objectstack#6281 replaced that + * with the shared `PageContainerProps` upstream, and rc.6 is where this repo + * resolves it: `children` now appears in each of the three shapes. * - * Self-clearing, and pinned as such by `the empty-shape carve-out still - * describes an empty shape` below: once the pin moves, `children` appears in - * each shape and that assertion fails until this list is deleted. + * The list was self-clearing by construction, and its companion test — `the + * empty-shape carve-out still describes an empty shape` — is what fired, + * carrying its own instruction ("delete it from SPEC_SHAPE_EMPTY_ON_THE_PIN"). + * Both the list and that test are gone; the plain non-empty guard now covers + * all three again, which is the state the carve-out was always temporary + * against. */ -const SPEC_SHAPE_EMPTY_ON_THE_PIN = ['page:footer', 'page:section', 'page:sidebar']; /** * Off-spec top-level inputs ACCEPTED for now, each with the reason. @@ -561,24 +561,20 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // would return `[]`, every input would read as off-spec, and the failure // would look like a repo-wide regression instead of a broken probe. An // empty result here means "fix the reader", not "fix the inputs". - for (const type of covered.filter((t) => !SPEC_SHAPE_EMPTY_ON_THE_PIN.includes(t))) { + for (const type of covered) { expect(specTopLevelKeys(type).length, `${type} spec shape did not resolve`).toBeGreaterThan(0); } }); - it('the empty-shape carve-out still describes an empty shape', () => { - // The carve-out above cannot be allowed to outlive its cause: these three - // resolve to `{}` because the PINNED rc.5 still maps them to `EmptyProps`, - // not because the probe is broken. The moment the pin carries - // `PageContainerProps`, `children` appears in the shape and this assertion - // fails — which is the instruction to delete the list and let the plain - // non-empty guard cover all three again. - for (const type of SPEC_SHAPE_EMPTY_ON_THE_PIN) { - expect(covered, `${type} no longer declares inputs — the carve-out is dead`).toContain(type); - expect( - specTopLevelKeys(type), - `${type}'s spec shape is no longer empty — delete it from SPEC_SHAPE_EMPTY_ON_THE_PIN`, - ).toEqual([]); + it('the three former carve-out blocks now resolve a real shape', () => { + // The tombstone of `SPEC_SHAPE_EMPTY_ON_THE_PIN` (see above). Keeping one + // assertion on the three names is what stops the deletion being silently + // undone by a future pin that regresses them to `EmptyProps` — the guard + // above would then read as "the probe is broken" for all three at once, + // which is the misdiagnosis objectui#4027 was filed about. + for (const type of ['page:footer', 'page:section', 'page:sidebar']) { + expect(covered, `${type} no longer declares inputs`).toContain(type); + expect(specTopLevelKeys(type), `${type} regressed to an empty spec shape`).toContain('children'); } }); diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts index 8ba4b20b60..68a29714b3 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts @@ -109,13 +109,41 @@ describe('validateMetadataDraft("sharing_rule") — objectui#3561', () => { */ it('does not gate the edit door — a stored body may carry `_packageId`', async () => { const stored = { ...SHARING_RULE, _packageId: 'crm_pkg' }; - // Authoring door: strict, so the stamped key is an unrecognized key. + + // AUTHORING DOOR — INVERTED on the @objectstack/spec 17.0.0-rc.6 bump, and + // the inversion is upstream convergence rather than a regression here. + // + // This assertion used to read `expect(create.ok).toBe(false)`, on the stated + // premise that `SharingRuleSchema` "declares NONE of the seven ADR-0010 + // envelope keys" — so a stamped `_packageId` reached the strict object as an + // unrecognized key. rc.6 made the schema declare all seven (`_lock`, + // `_lockDocsUrl`, `_lockReason`, `_lockSource`, `_packageId`, + // `_packageVersion`, `_provenance`), the way `ActionSchema` has since rc.2. + // A stamped body is therefore legal input now, and the create door accepts + // it — which is the behaviour the envelope was always supposed to have. + // + // Pinned as accept, with the strictness it does NOT cost asserted right + // below: the boundary this file guards is "does the client stay no stricter + // than the server", and that still holds. const create = await validateMetadataDraft('sharing_rule', stored, undefined, { mode: 'create', }); - expect(create.ok).toBe(false); + expect(create.ok).toBe(true); + + // …and the door is still a door. A genuinely unknown key is still refused, + // so the assertion above pins "the envelope became legal", not "the schema + // stopped being strict" — without this, reverting the schema to a permissive + // object would leave the pin above green. + const bogus = await validateMetadataDraft( + 'sharing_rule', + { ...SHARING_RULE, notAKeyAnySchemaDeclares: 1 }, + undefined, + { mode: 'create' }, + ); + expect(bogus.ok).toBe(false); + // Edit door: no client gate, so the server stays authoritative and the - // client cannot report a stored body as broken. + // client cannot report a stored body as broken. Unchanged by rc.6. const edit = await validateMetadataDraft('sharing_rule', stored, undefined, { mode: 'edit' }); expect(edit.ok).toBe(true); expect(edit.issues).toEqual([]); diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts index 2649eef86e..24b43ec250 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts @@ -198,11 +198,44 @@ describe('view edit path — union diagnostics are expanded to the selected memb }); it('leaves non-union failures exactly as they were', async () => { - // `page` has a plain object schema — no union, so the mapping is untouched. - const res = await validateMetadataDraft('page', { name: 'p', type: 42 }); + // `page` has a plain object schema — no ROOT union, so the mapping is + // untouched. The draft carries a valid `label`: see the pin below for why + // that is now load-bearing rather than incidental. + const res = await validateMetadataDraft('page', { name: 'page_x', label: 'Page X', type: 42 }); expect(res.ok).toBe(false); expect(res.issues.length).toBeGreaterThan(0); expect(res.issues.every((i) => i.message !== 'Invalid input')).toBe(true); + // The specific message is what "exactly as they were" means here. + expect(res.issues.some((i) => /Invalid option/.test(i.message))).toBe(true); + }); + + /** + * A LEAF union still reports `Invalid input`, and rc.6 created leaf unions + * where there were none — recorded here rather than fixed (objectui#4163). + * + * objectui#3606's expansion is about a ROOT union: it picks the member the + * draft was aiming at so the author reads that member's diagnostics instead of + * zod's aggregate `Invalid input`. It does not, and was never asked to, reach + * a union nested at a FIELD. + * + * That distinction cost nothing until now because the fields in question were + * scalars. @objectstack/spec 17.0.0-rc.6 widened `I18nLabel` from `string` to + * `string | Record< string, string >`, so every `label` in the vocabulary + * became a leaf union — and an author who simply OMITS a required label now + * reads `Invalid input` where they used to read a typed message naming the + * field. It is a small regression, it is upstream in origin, and repairing it + * means teaching the mapper about leaf unions, which is a diagnostics design + * change rather than a bump repair. + * + * Pinned so the gap is measured rather than remembered: when the mapper learns + * leaf unions, this test goes red and should be deleted along with the note. + */ + it('a leaf union (rc.6 `I18nLabel`) still yields the bare `Invalid input` — objectui#4163', async () => { + const res = await validateMetadataDraft('page', { name: 'page_x', type: 'record' }); + expect(res.ok).toBe(false); + const labelIssue = res.issues.find((i) => /(^|\.)label$/.test(String(i.path ?? ''))); + expect(labelIssue, 'expected a diagnostic on `label`').toBeTruthy(); + expect(labelIssue!.message).toBe('Invalid input'); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts index 1a9dea7ded..2c34aa927b 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts @@ -85,6 +85,33 @@ function unwrapped(schema: unknown): unknown { return cur; } +/** + * The object shape behind a schema that may be wrapped in a pipe/effect. + * + * `unwrap()` above only walks `.optional()`/`.default()`; it cannot get past a + * `ZodPipe` or a refinement wrapper, which expose neither `.shape` nor + * `.unwrap`. `FlowNodeSchema` became one of those in @objectstack/spec + * 17.0.0-rc.6, so the direct `FlowNodeSchema.shape` this file used to read went + * `undefined` and every assertion below died on `Cannot read properties of + * undefined`. The BLOCKS themselves are untouched — `connectorConfig`, + * `waitEventConfig` and `boundaryConfig` are all still declared, verified by + * walking the wrapper — so this is an access-path repair, not a changed + * expectation. + */ +function objectShape(schema: unknown, depth = 0): Record | null { + const s = schema as Record | undefined; + if (!s || depth > 8) return null; + if (s.shape) return s.shape as Record; + const def = (s._def ?? s.def) as Record | undefined; + if (!def) return null; + if (def.shape) return def.shape as Record; + for (const key of ['in', 'out', 'innerType', 'schema', 'left', 'right']) { + const found = def[key] ? objectShape(def[key], depth + 1) : null; + if (found) return found; + } + return null; +} + /** A field render-gated behind the never-matching `__legacy__` controller. */ function isLegacyGated(f: FlowConfigField): boolean { return f.showWhen?.field === '__legacy__'; @@ -217,7 +244,16 @@ describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet) // no feature detection: the hand-written groups for wait / connector_action // / boundary_event must edit exactly the keys the spec block declares — // #4161 / #4210 verified them by hand once; this keeps them verified. - const FlowNodeSchema = spec.FlowNodeSchema as { shape: Record }; + const flowNodeShape = objectShape(spec.FlowNodeSchema); + + it('the spec still exposes the FlowNode block shape this suite reads', () => { + // Guards the assertions below from passing vacuously if the walk stops + // resolving — the failure mode rc.6 produced, seen one level up. + expect(flowNodeShape, 'could not resolve FlowNodeSchema’s object shape').toBeTruthy(); + expect(Object.keys(flowNodeShape ?? {})).toEqual( + expect.arrayContaining(['waitEventConfig', 'connectorConfig', 'boundaryConfig']), + ); + }); const BLOCKS: ReadonlyArray<{ type: string; block: string }> = [ { type: 'wait', block: 'waitEventConfig' }, @@ -231,7 +267,7 @@ describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet) .filter((f) => f.path[0] === block) .map((f) => f.path[1]!), )].sort(); - const blockKeys = zodKeys(unwrapped(FlowNodeSchema.shape[block])); + const blockKeys = zodKeys(unwrapped(flowNodeShape?.[block])); expect( blockKeys.filter((k) => !formKeys.includes(k)), diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx index 4dca3a8744..7fed76bfa7 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx @@ -74,6 +74,35 @@ function unwrapped(schema: ZodLike | undefined): ZodLike | undefined { return cur; } +/** + * The object shape behind a schema that may be wrapped in a pipe/effect. + * + * `unwrapped()` walks `.optional()`/`.default()` only, and cannot get past a + * `ZodPipe` or refinement wrapper — which exposes neither `.shape` nor + * `.unwrap`. `FlowNodeSchema` became one in @objectstack/spec 17.0.0-rc.6, so + * the `FlowNodeSchema.shape?.[block]` reads below silently became `undefined` + * and `liveKeys` failed on its own "expected a Zod object schema" guard. The + * blocks are unchanged upstream, so this restores the access path rather than + * the expectation. Same repair, same reason, as + * `inspectors/flow-node-config.spec-reconciliation.test.ts`. + */ +function objectShape(schema: unknown, depth = 0): Record | null { + const s = schema as Record | undefined; + if (!s || depth > 8) return null; + if (s.shape) return s.shape as Record; + const def = (s._def ?? s.def) as Record | undefined; + if (!def) return null; + if (def.shape) return def.shape as Record; + for (const key of ['in', 'out', 'innerType', 'schema', 'left', 'right']) { + const found = def[key] ? objectShape(def[key], depth + 1) : null; + if (found) return found; + } + return null; +} + +/** `FlowNodeSchema`'s block shape, resolved through any wrapper (see above). */ +const flowNodeShape = objectShape(FlowNodeSchema); + /** * Keys a Zod object schema declares as LIVE authoring surface. `retiredKey()` * tombstones stay in the shape so their rejection can carry the upgrade @@ -144,8 +173,8 @@ describe('designer node seeds ↔ spec FlowNodeSchema (#3316)', () => { for (const block of BLOCKS) { const seeded = extras[block]; if (!seeded || typeof seeded !== 'object') continue; - const retired = retiredKeys(FlowNodeSchema.shape?.[block] as ZodLike | undefined); - const live = liveKeys(FlowNodeSchema.shape?.[block] as ZodLike | undefined); + const retired = retiredKeys(flowNodeShape?.[block]); + const live = liveKeys(flowNodeShape?.[block]); for (const key of Object.keys(seeded as Record)) { if (retired.includes(key)) offenders.push(`${type}.${block}.${key} (retired in spec)`); else if (!live.includes(key)) offenders.push(`${type}.${block}.${key} (not declared by spec)`); diff --git a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts index decbc08d2e..115affd955 100644 --- a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts +++ b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts @@ -54,8 +54,20 @@ const inputNames = () => inputs().map((i) => i.name); const input = (name: string) => inputs().find((i) => i.name === name); const filterDescription = () => input('filter')?.description ?? ''; -/** A minimal spec-valid props object, so a `filter` probe fails only on `filter`. */ -const withFilter = (filter: unknown) => ({ object: 'account', displayField: 'name', filter }); +/** + * A minimal spec-valid props object, so a `filter` probe fails only on `filter`. + * + * `displayField: 'name'` used to be the second key here, and it is deliberately + * NOT re-spelled to a survivor: @objectstack/spec 17.0.0 retired + * `element:record_picker`'s `displayField` as an ADR-0087 D2 tombstone (#5775), + * and rc.6 is where this repo first resolves that. A tombstone is refused BY + * NAME, so the old fixture stopped being "minimal and valid" and started being + * "valid except for one retired key" — which made all three `filter` assertions + * below fail on `displayField` instead. `object` alone is a complete valid + * shape, and keeping the fixture at the true minimum is what stops it drifting + * into the same trap again. + */ +const withFilter = (filter: unknown) => ({ object: 'account', filter }); describe('element:record_picker — registry inputs vs @objectstack/spec', () => { it('is registered with a non-empty `inputs` surface', () => { @@ -84,7 +96,6 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // consumer before it was declared here. const undeclared = ElementRecordPickerPropsSchema.safeParse({ object: 'account', - displayField: 'name', notASpecKey: 1, } as never); expect(undeclared.success).toBe(true); @@ -158,7 +169,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => expect(input('filter')).toBeDefined(); expect(input('filter')?.defaultValue).toBeUndefined(); expect( - ElementRecordPickerPropsSchema.safeParse({ object: 'account', displayField: 'name' }).data, + ElementRecordPickerPropsSchema.safeParse({ object: 'account' }).data, ).not.toHaveProperty('filter'); }); diff --git a/packages/core/src/actions/actionKeys.ts b/packages/core/src/actions/actionKeys.ts index e747e95fcc..2358e7af4d 100644 --- a/packages/core/src/actions/actionKeys.ts +++ b/packages/core/src/actions/actionKeys.ts @@ -172,6 +172,15 @@ export const SPEC_ACTION_KEYS = [ 'bulkEnabled', 'component', 'confirmText', + // Added to `ActionSchema` in @objectstack/spec 17.0.0-rc.6. Listed here purely + // because this array's contract is "every property the spec's `ActionSchema` + // declares" — restating the spec, not adopting a feature. `ActionDef` does NOT + // declare `description` and is not changed: whether the runner or any renderer + // should READ it is a separate question, and `actionKeys.pin.test.ts`'s + // `ActionDef` half stays green without it. What this entry buys is that an + // action carrying a `description` is no longer reported as having an unknown + // key, which is the only thing the inventory is consulted for. + 'description', 'disabled', 'errorMessage', 'execute', diff --git a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index d62f9d905c..6070cfd60f 100644 --- a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -770,7 +770,18 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { CREATE_WORKSPACE, ]) { expect(sourceOf(rel), `${rel} no longer binds the pack hook`).toContain('useObjectTranslation'); - expect(sourceOf(rel), `${rel} stopped destructuring t`).toContain('const { t } = useObjectTranslation();'); + // `t` must come out of the hook's destructuring — but it need not be the + // ONLY thing destructured. The literal `const { t } = …` this used to + // match broke the moment a file legitimately also took `language`, which + // both sidebars now do to resolve the spec's inline per-locale + // `I18nLabel` (widened in @objectstack/spec 17.0.0-rc.6). What this + // suite actually depends on is that `t` is i18next's, bound from this + // hook, so the pattern asserts exactly that and stays blind to which + // siblings ride along. + expect( + /const \{[^}]*\bt\b[^}]*\} = useObjectTranslation\(/.test(sourceOf(rel)), + `${rel} stopped destructuring t from useObjectTranslation()`, + ).toBe(true); } // RecordDetailView takes it from @object-ui/react's re-export, with language. expect(sourceOf(RECORD_DETAIL)).toContain('const { t, language } = useObjectTranslation();'); diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index fbca9a317c..f25a2f2584 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -29,6 +29,9 @@ */ import { describe, it, expect } from 'vitest'; +// Needed to isolate the rc.6 `GlobalFilterSchema` refinement from the field type +// it hides behind — see the objectui#4165 pin below. +import { z } from 'zod'; import { createRequire } from 'node:module'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; @@ -149,6 +152,78 @@ describe('GlobalFilterSchema pinned divergences', () => { }).success).toBe(false); }); + /** + * OPEN CONFLICT, pinned rather than resolved — objectui#4165. + * + * The divergence above used to be about a FIELD TYPE, and only that: the spec + * typed `defaultValue` as `string | number | boolean`, objectui widened the key + * to `z.any()`, and widening the key was ENOUGH — nothing else in the spec had + * an opinion about the object form. + * + * @objectstack/spec 17.0.0-rc.6 added a whole-object refinement that refuses + * `{ preset }` BY NAME, with a message enumerating the three spellings a + * `type: 'date'` filter may use (a preset name, an ISO date, a date-macro + * token). A refinement is not a field type, so widening the key no longer + * escapes it: any derivation that CARRIES the refinement refuses the object + * form regardless of how `defaultValue` is typed. + * + * Read the two assertions below in that light, because the obvious shorter + * version is a trap. `SpecGlobalFilterSchema.safeParse({ …, defaultValue: { + * preset } })` does fail — but it fails with `invalid_union` on the FIELD, the + * same way it failed at rc.5, and the refinement never runs. Asserting that + * would pin the OLD behaviour while reading like a pin on the new one. The + * refinement is only observable once the field admits the object, so it is + * isolated here by widening the key first — which is exactly the shape + * objectui's own dialect has. + * + * The conflict this leaves is a producer/consumer one with three possible + * answers (follow the spec and migrate stored dashboards; keep the divergence; + * the spec's refinement is an upstream defect), and it is filed rather than + * guessed — the deciding half belongs to the spec owner. Until it is ruled, + * `GlobalFilterSchema` is composed by spreading the spec's `.shape`, carrying + * the FIELDS by reference and leaving the refinement behind: the exact + * behaviour this schema had before rc.6, so the bump changes nothing here. The + * spread is not a preference — rc.6 closed both other doors (`.extend()` + * throws at module load, `.safeExtend()` types the three overrides as `never`), + * so some spelling had to change, and this is the one that decides nothing. + * + * This test is the tripwire on that standstill and goes red from EITHER side: + * if objectui stops accepting the object form (resolved consumer-side), or if + * the refinement is withdrawn or reworded upstream (resolved producer-side). + */ + it('does NOT carry the spec rc.6 refinement that refuses `{ preset }` (objectui#4165)', () => { + const stored = { field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days' } }; + + // objectui side: still accepts what its own normalizer writes. + expect(GlobalFilterSchema.safeParse(stored).success).toBe(true); + + // Spec side, refinement ISOLATED from the field type: widen `defaultValue` + // exactly as objectui's dialect does, but keep the spec's checks (that is + // what `.safeExtend` preserves). What refuses the value now can only be the + // rc.6 refinement, and asserting its MESSAGE is what separates "the spec's + // `defaultValue` is narrow" (true since forever) from "the spec has a rule + // about this exact shape" (new in rc.6, and the reason #4165 exists). + const specWithRefinement = SpecGlobalFilterSchema.safeExtend({ + defaultValue: z.any().optional(), + }); + const refused = specWithRefinement.safeParse(stored); + expect(refused.success).toBe(false); + expect( + refused.error?.issues?.some((i) => /is not a value a .*date.* filter can resolve/.test(i.message)), + 'the spec no longer refuses `{ preset }` with its rc.6 refinement message — ' + + 'either the refinement was withdrawn or its wording changed. Re-read objectui#4165 ' + + 'before touching this: the standstill it documents may be over.', + ).toBe(true); + + // …and the bare preset NAME, which the refinement's message points authors + // at, is already legal on both sides. That is why #4165's leading candidate + // is a rewrite rather than a capability loss. + const bare = { field: 'created_at', type: 'date', defaultValue: 'last_7_days' }; + expect(specWithRefinement.safeParse(bare).success).toBe(true); + expect(SpecGlobalFilterSchema.safeParse(bare).success).toBe(true); + expect(GlobalFilterSchema.safeParse(bare).success).toBe(true); + }); + it('keeps `optionsFrom.labelField` optional', () => { expect(GlobalFilterSchema.safeParse({ field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 6b9922511a..d3cdebd911 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -328,18 +328,45 @@ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema. * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. * - * `.safeExtend`, not `.extend`, since @objectstack/spec 17.0.0-rc.6: the spec's - * `GlobalFilterSchema` now carries a refinement, and zod 4 refuses `.extend()` - * on a refined object outright — *"Cannot overwrite keys on object schemas - * containing refinements. Use `.safeExtend()` instead."* — which threw at - * MODULE LOAD and took six `@object-ui/types` suites down with it before this - * file ran a single test. `.safeExtend` is zod's own prescribed replacement and - * KEEPS the refinement (measured: one check before, one check after), so the - * spec's cross-field rule still runs on objectui's dialect. That is the - * behaviour we want — the three divergences below widen individual FIELDS, and - * were never meant to switch off a whole-object rule. + * ## Spread, not `.extend` — and NOT `.safeExtend` either (objectui#4165) + * + * @objectstack/spec 17.0.0-rc.6 put a refinement on `GlobalFilterSchema`, and + * that closed BOTH extension doors on a schema whose whole purpose here is to + * override three keys: + * + * - `.extend()` — what this used to be — now throws outright: *"Cannot + * overwrite keys on object schemas containing refinements. Use + * `.safeExtend()` instead."* At MODULE LOAD, taking six `@object-ui/types` + * suites down before any of them ran a test. + * - `.safeExtend()` — zod's own suggested replacement — runs, but is "safe" + * precisely in the sense that it will not let you REPLACE an existing key's + * type. It types every incompatible override as `never`, so `options`, + * `optionsFrom` and `defaultValue` — exactly the three divergences above — + * stop compiling (TS2322, `is not assignable to type 'never'`). + * + * So the spread below is not a style choice: it is the only spelling that keeps + * this schema the shape it has been since objectstack#4115. It composes the + * spec's fields BY REFERENCE (`.shape`, so a spec field change still lands + * here) and replaces the three, which is byte-for-byte what `.extend()` + * produced before rc.6. + * + * What it does NOT do is carry the spec's new refinement across, and that is + * deliberate and TEMPORARY. The refinement rejects `defaultValue: + * { preset: 'last_7_days' }` on a `type: 'date'` filter — the exact object form + * `@object-ui/core`'s `normalizeDateDefault` PRODUCES and stored dashboards + * carry (framework#4475), and the stated reason for the third divergence. That + * is a producer/consumer conflict, not a mechanical repair: adopting the rule + * means changing what objectui writes and migrating stored dashboards, and + * declining it permanently means objectui accepts metadata the platform + * refuses. Neither is a call to make in passing, so this preserves the status + * quo EXACTLY — nothing about this schema's behaviour changes on the rc.6 bump — + * and the ruling is tracked in objectui#4165. `__tests__/report-chart-query- + * spec-parity.test.ts` pins the gap explicitly so it cannot fade into folklore. + * + * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ -export const GlobalFilterSchema = SpecGlobalFilterSchema.safeExtend({ +export const GlobalFilterSchema = z.object({ + ...SpecGlobalFilterSchema.shape, options: z.array(z.union([ z.string(), z.object({ From 7b813b2b2e92b880dec96eeaad3adb779ebb718d Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 10 Aug 2026 18:55:18 +0000 Subject: [PATCH 3/5] fix(deps): triage the 14 rc.6 spec-symbol collisions and refresh the console parity exemptions (#4167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#4167, objectstack#4115. `check:spec-symbols` reported 13 collisions on the @objectstack/spec 17.0.0-rc.6 bump and a 14th (`GlobalFilterSchema`) appeared during the bump itself; each is triaged on its own rather than blanket-renamed. Renamed (3) — the spec's same-named export means something else: MetadataState -> MetadataCacheState (react, app-shell) resolveI18nLabel -> resolveKeyedI18nLabel (react, app-shell, + apps/console) DateRangePreset -> FilterBuilderDateRangePreset (types) Bound or derived (10): DATE_RANGE_PRESETS (+ a `satisfies` completeness pin on the local bounds table), NavigationMode (+ a both-directions type pin), AddressValue, BreakpointColumnMap, BreakpointOrderMap, KanbanConfig, CalendarConfig, GanttConfig. Four copies were losing information: GanttConfig declared 6 of the spec's 17 keys while plugin-gantt reads all of them, AddressValue 5 of 7 parts, and DATE_RANGE_PRESETS was the third copy objectstack#4614 extracted the vocabulary to collapse. ALLOW (1): GlobalFilterSchema, the same `.shape`-spread dialect as SelectOptionSchema beside it — it collided only because rc.6's refinement forced `.extend()` to be respelled, moving a derivation the guard could see into an object literal it deliberately does not descend into. Nothing about #4165 is encoded. Console registry-inputs-spec-parity: all 12 off-spec exemptions deleted (rc.6 landed objectstack#6776 and objectstack#5775, verified per key against the resolved shape), and `element:record_picker` publishes `sort` / `limit` / `emptyText` — read by the renderer all along, and the red demanding them was predicted in writing by the exemption it replaces. 49 tests/2 failing -> 51/0. Also repairs 3 pre-existing type-check errors of this card's own rc.6 family, found only by running `type-check` (which the bump PR did not): the retired `ThemeInput` alias re-pointed BY SIDE, and `DroppedFieldsEvent.reason`'s new `primary_key` arm. The consumer gap that arm exposes is objectui#3935. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- ...ecord-picker-sort-limit-empty-text-4167.md | 46 ++++ .changeset/spec-symbol-collisions-rc6-4167.md | 92 ++++++++ .../registry-inputs-spec-parity.test.ts | 201 ++++++++---------- .../src/pages/system/AppManagementPage.tsx | 6 +- apps/console/src/utils.ts | 14 +- .../app-shell/src/chrome/CommandPalette.tsx | 24 +-- .../app-shell/src/console/ai/AiChatPage.tsx | 10 +- .../app-shell/src/console/home/AppCard.tsx | 6 +- .../src/console/home/HomeAppsStrip.tsx | 4 +- packages/app-shell/src/index.ts | 2 +- packages/app-shell/src/layout/AppHeader.tsx | 4 +- packages/app-shell/src/layout/AppSidebar.tsx | 26 +-- packages/app-shell/src/layout/AppSwitcher.tsx | 6 +- .../app-shell/src/layout/ConsoleLayout.tsx | 6 +- .../app-shell/src/layout/ContextSelectors.tsx | 4 +- .../app-shell/src/layout/UnifiedSidebar.tsx | 9 +- .../AppSidebar.derivedAreaVisibility.test.tsx | 2 +- ...fiedSidebar.derivedAreaVisibility.test.tsx | 2 +- .../appSidebarSettingsTargets.test.tsx | 2 +- .../systemNavDatasourcesHop.test.tsx | 2 +- .../__tests__/systemNavObjectsHop.test.tsx | 2 +- .../systemNavSettingsTarget.test.tsx | 2 +- .../src/providers/MetadataProvider.tsx | 6 +- packages/app-shell/src/utils/index.ts | 21 +- .../app-shell/src/views/DashboardView.tsx | 8 +- .../app-shell/src/views/SearchResultsPage.tsx | 4 +- .../src/renderers/basic/record-picker.tsx | 44 ++++ .../src/renderers/data-display/breadcrumb.tsx | 6 +- .../src/renderers/navigation/header-bar.tsx | 4 +- .../utils/__tests__/dashboard-filters.test.ts | 48 +++++ packages/core/src/utils/dashboard-filters.ts | 55 ++++- .../src/spec-symbol-batch6.test.ts | 22 +- packages/fields/src/widgets/AddressField.tsx | 29 ++- packages/layout/package.json | 2 +- packages/layout/src/ResponsiveGrid.tsx | 42 ++-- .../plugin-designer/src/AppCreationWizard.tsx | 4 +- .../src/NavigationDesigner.tsx | 14 +- packages/react/src/SchemaRenderer.tsx | 4 +- .../react/src/context/AppShellContext.tsx | 23 +- ...ffline-nav-performance-spec-parity.test.ts | 39 +++- .../react/src/hooks/useNavigationOverlay.ts | 20 +- packages/react/src/index.ts | 2 +- packages/react/src/utils/i18n.ts | 40 +++- .../page-nav-misc-spec-parity.test.ts | 22 +- packages/types/src/data-protocol.ts | 33 ++- packages/types/src/index.ts | 4 +- packages/types/src/objectql.ts | 82 +++---- scripts/check-spec-symbol-derivation.mjs | 22 ++ 48 files changed, 771 insertions(+), 301 deletions(-) create mode 100644 .changeset/record-picker-sort-limit-empty-text-4167.md create mode 100644 .changeset/spec-symbol-collisions-rc6-4167.md 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/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/index.ts b/packages/app-shell/src/index.ts index ed72a151b7..aad19f9987 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 82fb3cd7ad..0f31f27e6d 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'; @@ -1115,7 +1115,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/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/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 b847e371f8..cf84b38d70 100644 --- a/packages/fields/src/widgets/AddressField.tsx +++ b/packages/fields/src/widgets/AddressField.tsx @@ -1,12 +1,13 @@ import React, { useId } from 'react'; import { Input, Label, EmptyValue } from '@object-ui/components'; +import type { AddressValue } from '@objectstack/spec/data'; import { FieldWidgetComponentProps } from './types'; import { toDomProps } from './toDomProps'; import { toHostGroupProps } from './toHostGroupProps'; /** - * 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). This widget used @@ -18,14 +19,24 @@ import { toHostGroupProps } from './toHostGroupProps'; * touched did survive an unrelated edit, because the write 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. + * + * The widget still renders five inputs, and that is a deliberate split rather + * than an omission: `formatted` is a derived one-line rendering of the parts + * (see {@link formatAddress}, which composes it) 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 this widget no + * longer looks type-clean while dropping a key the contract carries: the + * `{ ...address }` spread in `handleFieldChange` preserves both, and now says so. */ -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/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/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)} { 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/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/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 " + From 576a7063f1ee8d38e1801fcafeac9ff86bd24987 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 23:22:39 +0000 Subject: [PATCH 4/5] fix(i18n): render I18nLabel objects at the 13 remaining sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec` 17.0.0-rc.6 widened `I18nLabel` from `string` to `string | Record`, so an author may inline a per-locale map (`label: { en: 'Owner', 'zh-CN': '负责人' }`) anywhere the spec accepts a display label. PR #4169 repaired eight such sites; thirteen more 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. Read sites now resolve the map to the viewer's locale instead of reaching a text node as `[object Object]`: - `@object-ui/layout` — `NavigationArea.label` (area switcher button + tooltip) - `@object-ui/plugin-list` — `ViewTab.label` (pill row, dropdown trigger, menu) - `@object-ui/plugin-dashboard` — `DashboardWidget.title` (heading + attribute) - `@object-ui/plugin-designer` — `DashboardWidget.title` (card + preview tile) - `@object-ui/app-shell` — `ActionParam.label` and each option's `label`, fixed on the `RawActionParam` DECLARATION rather than on the test that surfaced it Two edges are deliberate rather than mechanical: `DashboardEditor`'s title `` is an authoring WRITE, not a display. Resolving a map into it and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is GUARDED — a map-valued title renders resolved and read-only, and survives an unrelated edit-and-save round trip untouched. The type error dies from the guard, not from a cast. What Studio should actually offer for authoring a per-locale label is #4163 part 2, which stays unclaimed and pending design. `@object-ui/layout` carries no i18n dependency by design (its whole i18n story is injection), so it uses the PRODUCER's resolver — `resolveI18nLabel` from `@objectstack/spec/ui` — at the spec's documented `en` default rather than hand-rolling a resolution that could drift. The three plugin packages sit inside objectui's i18n tree and already hold the live language, so they use `pickLocalized`; `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts` pins the two resolvers limb for limb so the runtime dashboard and the designer's preview of it cannot start disagreeing about which entry wins. Also re-points four `plugin-list` type-test assertions at `ViewTabParsed`: rc.6's `…Input`-alias retirement moved the bare `ViewTab` onto the input side, so following the NAME left them comparing the authoring side against itself. Invisible until now because `type-check` is `tsc --noEmit && tsc -p tsconfig.typetests.json` and the `&&` never reached that project. Part of #4163 --- .changeset/i18nlabel-render-sites-4163.md | 26 +++ .../src/hooks/useConsoleActionRuntime.tsx | 6 +- .../resolveActionParams.i18nLabel.test.ts | 170 ++++++++++++++++++ .../src/utils/resolveActionParams.ts | 138 +++++++++++++- .../app-shell/src/views/RecordDetailView.tsx | 3 + packages/layout/src/AppSchemaRenderer.tsx | 58 +++++- .../AppSchemaRenderer.areaI18nLabel.test.tsx | 112 ++++++++++++ .../src/DashboardGridLayout.tsx | 18 +- .../DashboardGridLayout.i18nTitle.test.tsx | 92 ++++++++++ .../plugin-designer/src/DashboardEditor.tsx | 92 +++++++++- .../DashboardEditor.i18nTitle.test.tsx | 146 +++++++++++++++ .../i18nLabel-resolver-parity.test.ts | 135 ++++++++++++++ .../src/__tests__/spec-symbol-batch6.test.tsx | 23 ++- .../plugin-list/src/components/TabBar.tsx | 44 ++++- .../__tests__/TabBar.i18nLabel.test.tsx | 87 +++++++++ pnpm-lock.yaml | 6 +- 16 files changed, 1123 insertions(+), 33 deletions(-) create mode 100644 .changeset/i18nlabel-render-sites-4163.md create mode 100644 packages/app-shell/src/utils/resolveActionParams.i18nLabel.test.ts create mode 100644 packages/layout/src/__tests__/AppSchemaRenderer.areaI18nLabel.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardGridLayout.i18nTitle.test.tsx create mode 100644 packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx create mode 100644 packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts create mode 100644 packages/plugin-list/src/components/__tests__/TabBar.i18nLabel.test.tsx 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/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/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/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/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/__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/DashboardEditor.tsx b/packages/plugin-designer/src/DashboardEditor.tsx index e83f9b0d93..44602731b0 100644 --- a/packages/plugin-designer/src/DashboardEditor.tsx +++ b/packages/plugin-designer/src/DashboardEditor.tsx @@ -46,6 +46,7 @@ import { } from 'lucide-react'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import { pickLocalized } from '@object-ui/i18n'; import { useUndoRedo } from './hooks/useUndoRedo'; import { useDesignerTranslation } from './hooks/useDesignerTranslation'; @@ -96,6 +97,64 @@ function createWidgetId(): string { return `widget_${Date.now()}_${widgetCounter}`; } +// ============================================================================ +// Widget title — display vs authoring +// ============================================================================ + +/** + * Resolve `DashboardWidget.title` for DISPLAY. + * + * `@objectstack/spec` 17.0.0-rc.6 widened `I18nLabel` from `string` to + * `string | Record`, 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/__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-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/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)) From 5f5543316488d9188592f645f143cbc6fe15dab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 04:41:10 +0000 Subject: [PATCH 5/5] fix(rc.6): finish the rc.6 adaptation the train never saw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three residual reds on main after the rc.5→rc.6 spec bump (#4169), none of which the train cars themselves covered. QUICK_REFERENCE.md's "Current Release" block still quoted `^17.0.0-rc.5` for both `@objectstack/spec` and `@objectstack/client` while every manifest it names as its anchor declares `rc.6`. The pin test for that block (quick-reference-current-release-4143) landed on main AFTER the train was built, so no car ever ran against it. recordDetailsInputs.spec-parity carried `layout: 'custom'` in both fixtures of its object-sections case. `layout` was removed in @objectstack/spec 17.0.0 (objectstack#6946, ADR-0087 D2) and rc.6 gave it a named `never` rejection, so the object-form fixture failed on `layout` while its `sections` were valid — and the id-list fixture above it kept PASSING on `layout`'s own `invalid_type` without ever reaching `sections`. Dropping the key puts both verdicts back on `sections`; the id-list case now also pins the issue PATH, since a bare code could not tell the two rejections apart. AddressField re-exports `AddressValue` through its declaring module rather than bare. main's #4222 moved the declaration into the pure `address-format` module while this train re-sourced the type from `@objectstack/spec/data`; the merge keeps both by importing the spec type in `address-format` (where the definition now lives) and letting the widget re-export from there, which is the form check:spec-symbols resolves to a declaration site instead of reading as a second local declaration of a name the spec owns. --- QUICK_REFERENCE.md | 4 ++-- packages/fields/src/widgets/AddressField.tsx | 7 +++++- .../recordDetailsInputs.spec-parity.test.ts | 22 ++++++++++++++++--- 3 files changed, 27 insertions(+), 6 deletions(-) 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/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/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);