From 6e9bd1a9b3a043827a143cb638b8385bc1a06705 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 15:23:15 +0000 Subject: [PATCH 1/2] fix(plugin-kanban,plugin-detail,app-shell): converge four private copies of the reference-bearing field family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four faces restated `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES` inline, none deriving from it and none pinned against it, and every one diverged from it in BOTH directions: all four lacked `user` and `tree`, and all four carried a fifth spelling `reference` that no producer can emit. Each now reads the published family and carries an identity pin (a spy on its `has`) so a member-identical private copy fails rather than quietly re-forking the table. `resolveActionParams` also lacked `master_detail`, so a field-backed `master_detail` action param inherited no picker config and degraded to the "paste a record id" text input #3405 exists to prevent. It keeps answering for `reference` through the one alias table in `paramToField`, which the dialog still accepts from params already authored with it — the same expression `paramToField` evaluates one step later, so the half that populates a param's picker config and the half that forwards it can no longer disagree. Also corrects the two now-false "fourth and last private copy" claims in `paramToField.ts` / `paramToField.test.ts`, pointing at the census in the family's canonical home rather than restating a count locally. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- ...874-expandable-field-family-convergence.md | 44 +++ .../expandableFamily.identity-5874.test.ts | 241 ++++++++++++++ .../app-shell/src/utils/paramToField.test.ts | 20 +- packages/app-shell/src/utils/paramToField.ts | 16 +- .../src/utils/resolveActionParams.ts | 40 ++- .../plugin-detail/src/HeaderHighlight.tsx | 26 +- .../plugin-detail/src/RecordDetailDrawer.tsx | 26 +- .../expandableFamily.identity-5874.test.tsx | 312 ++++++++++++++++++ packages/plugin-kanban/src/ObjectKanban.tsx | 26 +- .../expandableFamily.identity-5874.test.tsx | 261 +++++++++++++++ 10 files changed, 994 insertions(+), 18 deletions(-) create mode 100644 .changeset/5874-expandable-field-family-convergence.md create mode 100644 packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts create mode 100644 packages/plugin-detail/src/__tests__/expandableFamily.identity-5874.test.tsx create mode 100644 packages/plugin-kanban/src/__tests__/expandableFamily.identity-5874.test.tsx diff --git a/.changeset/5874-expandable-field-family-convergence.md b/.changeset/5874-expandable-field-family-convergence.md new file mode 100644 index 0000000000..bcbee7fa1d --- /dev/null +++ b/.changeset/5874-expandable-field-family-convergence.md @@ -0,0 +1,44 @@ +--- +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-detail': patch +'@object-ui/app-shell': patch +--- + +Four more private copies of the reference-bearing field family converge onto +`@object-ui/core`'s `EXPANDABLE_FIELD_TYPES`, and the "fourth and last private copy" claim +that `paramToField` still stated is corrected (objectui#5874, objectui#5875). + +Each copy diverged from the published family in BOTH directions, so this is a behaviour +change on every face and not a refactor: + +- **`user` and `tree` are now treated as relations.** Both carry the same foreign-key + storage as `lookup` and resolve through the same expand path (objectui#2032), and each + face's own stated reason for special-casing `lookup` applied to them verbatim — so + gaining them restores the rule each face already meant. A `user` / `tree` field is now + read-only in the quick-look drawer (`RecordDetailDrawer`), where the drawer wires no + relation picker and a plain text input let a user overwrite the relation with a + free-form string; it gets the wide layout basis in the record header's highlights strip + (`HeaderHighlight`), whose inline editor is a record picker; and a field-backed action + param over one now inherits the picker config it needs (`resolveActionParams`). +- **`master_detail` is now treated as a relation by `resolveActionParams` too** — it was + the only face missing that member as well, so a field-backed `master_detail` action param + inherited no `referenceTo` at all and degraded to the unexplained "paste a record id" + text input that objectui#3405 exists to prevent. +- **The undeclarable `reference` spelling is gone from the three field-type faces.** + Measured against `@objectstack/spec`'s closed `FieldType` vocabulary with live controls + (`lookup` / `master_detail` / `user` / `tree`) and dead ones (the retired `owner`, plus a + nonsense spelling): `reference` is absent, so no spec-compliant object schema could + declare a field that reached those branches. It sat exactly where `owner` sat before + objectui#4814 retired it — dead weight that read as live capability. + +`resolveActionParams` keeps answering for `reference`, deliberately and by a different +route: it is refused by the spec's `ActionParamSchema` too, but the dialog still accepts it +from params already authored with it, and that acceptance belongs to the one alias table in +`paramToField` rather than to a hand-copied membership test. This face now asks the shared +family over the widget key that table produces — the same expression `paramToField` +evaluates one step later, so the half that populates a param's picker config and the half +that forwards it can no longer disagree. + +No face copies the set: each calls `.has()` on the object `@object-ui/core` exports, and +each carries an identity pin (a spy on that `has`) so a member-identical private copy fails +instead of quietly re-forking the table. diff --git a/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts b/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts new file mode 100644 index 0000000000..b9dd40859f --- /dev/null +++ b/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts @@ -0,0 +1,241 @@ +/** + * 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#5874 — `resolveActionParams`' private copy of the reference-bearing + * field family converges onto `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES`. + * + * The copy was `resolvedType === 'lookup' || resolvedType === 'reference'`, + * the test gating `lookupExtras` — the picker config (`referenceTo`, + * `displayField`, `idField`, …) this resolver copies off the underlying object + * field and `paramToField()` then forwards to ``. Neither derived + * from nor pinned against the shared set. + * + * ## Why the load-bearing pin is IDENTITY, not membership + * + * Every membership assertion below is satisfied by a private + * `new Set(['lookup', 'master_detail', 'tree', 'user'])` holding the same + * strings — i.e. by a re-fork of exactly the kind this change removed. So the + * pin that decides the convergence spies on the `has` of the object core + * exports: a call is recorded only if the face under test consulted THAT + * object, so a member-identical private copy leaves the spy empty and fails + * here, where a value check would pass ON the defect. Same shape as + * objectui#4770 / #4790 / #4815 / #5312 / #5692. + * + * ## This face's membership delta is THREE members, not two + * + * Unlike its three sibling faces in objectui#5874 it also lacked + * `master_detail`, so a field-backed `master_detail` param inherited no picker + * config at all and `paramToField()` then degraded it to a plain record-id text + * input — the unexplained "paste a UUID" box objectui#3405 exists to prevent. + * + * `reference` is the one member that does NOT drop on this face, and that is a + * measurement rather than an omission: it is undeclarable (refused by the + * spec's `ActionParamSchema`, pinned below with live and dead controls), but + * the dialog still ACCEPTS it from params already authored with it, via + * `PARAM_TYPE_ALIASES` in `paramToField.ts`, which folds it to `lookup`. The + * convergence therefore asks the shared set over the widget key + * `resolveParamWidgetType()` produces — one alias table, read not copied, and + * the SAME expression `paramToField()` evaluates one step later. + * + * Ablation direction, predicted before running: restore the private copy + * (`resolvedType === 'lookup' || resolvedType === 'reference'`) and the + * identity pin goes RED (the spy records no call) while a member-set assertion + * over `EXPANDABLE_FIELD_TYPES` stays GREEN — that contrast is the whole + * reason the pin is on identity. The restoration probes go red too; the + * ordinary-relation control stays green in both directions, which is what + * makes it a control rather than a duplicate of the pins. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; +import { FieldType } from '@objectstack/spec/data'; +import { ActionParamSchema } from '@objectstack/spec/ui'; +import { + resolveActionParams, + type ResolveActionParamsContext, + type RawActionParam, +} from './resolveActionParams'; + +const SPEC_FIELD_TYPES: readonly string[] = [ + ...(FieldType as unknown as { options: readonly string[] }).options, +]; + +/** Every picker key `lookupExtras` copies off the resolved object field. */ +const PICKER_KEYS = [ + 'referenceTo', + 'displayField', + 'idField', + 'descriptionField', + 'titleFormat', + 'lookupColumns', + 'lookupFilters', + 'lookupPageSize', + 'dependsOn', +] as const; + +/** One object field per relevant type, each carrying full picker config. */ +const field = (type: string) => ({ + type, + label: type, + reference_to: 'accounts', + display_field: 'name', + id_field: 'id', + description_field: 'website', + title_format: '{name}', + lookup_columns: ['name'], + lookup_filters: [['active', '=', true]], + lookup_page_size: 25, + depends_on: ['region'], +}); + +const ctx = (): ResolveActionParamsContext => ({ + objectName: 'deal', + objects: [ + { + name: 'deal', + fields: { + account: field('lookup'), + parent_deal: field('master_detail'), + assignee: field('user'), + parent_node: field('tree'), + stage: { type: 'select', label: 'Stage' }, + title: { type: 'text', label: 'Title' }, + }, + }, + ], + fieldLabel: (_o, _f, fallback) => fallback, +}); + +const resolveOne = (param: RawActionParam) => resolveActionParams([param], ctx())[0]; + +afterEach(() => { + // The pins install a spy on the Set object EXPORTED by core — a shared, + // module-level object. A leaked spy would follow every later file in the + // worker, so restoring is not optional here. + vi.restoreAllMocks(); +}); + +describe("resolveActionParams' reference rule is core's object, not a copy (objectui#5874)", () => { + it('asks `@object-ui/core` EXPANDABLE_FIELD_TYPES which params carry picker config', () => { + // The spy is installed on the Set exported by core and records a call only + // if THIS module consulted THAT object. A member-identical private copy + // leaves it empty, so this fails where a value check would pass. + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + resolveOne({ field: 'account' }); + expect(spy.mock.calls.map(([k]) => k)).toContain('lookup'); + } finally { + spy.mockRestore(); + } + }); + + it('reaches that object on the person path too, not just the lookup path', () => { + // `user` and `lookup` are different members of the same set; a convergence + // that reconnected one spelling only would leave the other forked. + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + resolveOne({ field: 'assignee' }); + expect(spy.mock.calls.map(([k]) => k)).toContain('user'); + } finally { + spy.mockRestore(); + } + }); + + it('a member-identical private copy would NOT satisfy the pin — the contrast', () => { + // Documents, executably, why the two pins above are not membership checks: + // this assertion is true of the shared object AND of any private set + // holding the same strings, so it cannot tell a converged face from a + // re-forked one. The spies above can. + const memberIdenticalCopy = new Set(['lookup', 'master_detail', 'tree', 'user']); + expect([...EXPANDABLE_FIELD_TYPES].sort()).toEqual([...memberIdenticalCopy].sort()); + expect(EXPANDABLE_FIELD_TYPES).not.toBe(memberIdenticalCopy); + }); +}); + +describe('the restoration half — members this face was missing (objectui#5874)', () => { + // Each must be able to FAIL: before the convergence every one of these params + // reached `paramToField()` with no `referenceTo`, which degrades it to a + // plain record-id text input. + it.each([ + ['master_detail', 'parent_deal'], + ['user', 'assignee'], + ['tree', 'parent_node'], + ])('a %s param now inherits the full picker config', (_type, fieldName) => { + const out = resolveOne({ field: fieldName }); + for (const key of PICKER_KEYS) { + expect(out[key], `${fieldName} lost ${key}`).toBeDefined(); + } + expect(out.referenceTo).toBe('accounts'); + expect(out.displayField).toBe('name'); + }); + + it('`lookup` — the one member that already worked — is untouched', () => { + const out = resolveOne({ field: 'account' }); + expect(out.referenceTo).toBe('accounts'); + expect(out.displayField).toBe('name'); + }); +}); + +describe('a type outside the family is still NOT reference-bearing — the control', () => { + // Without this, "converge" would be satisfiable by handing picker config to + // every param. It must stay green through BOTH ablation legs. + it.each([ + ['select', 'stage'], + ['text', 'title'], + ])('a %s param carries no picker config', (_type, fieldName) => { + const out = resolveOne({ field: fieldName }); + for (const key of PICKER_KEYS) { + expect(out[key], `${fieldName} gained ${key}`).toBeUndefined(); + } + }); +}); + +describe('`reference` survives HERE, and only through the alias table', () => { + /** + * The measurement, kept as an executable pin rather than as prose in a PR. + * Controls run on the same read as the subject, so a probe that had lost hold + * of the vocabulary (an empty list, the wrong export) fails as a broken probe + * instead of reporting the subject absent. + */ + it('every LIVE control IS a spec `FieldType`, and every DEAD one is not', () => { + for (const type of EXPANDABLE_FIELD_TYPES) { + expect(SPEC_FIELD_TYPES, `'${type}' is not a spec FieldType`).toContain(type); + } + expect(SPEC_FIELD_TYPES).not.toContain('owner'); + expect(SPEC_FIELD_TYPES).not.toContain('zzz_not_a_field_type'); + }); + + it('SUBJECT — an author cannot DECLARE a `reference` param', () => { + // Live controls first: if these stopped parsing, the subject reading below + // would mean nothing. + for (const type of EXPANDABLE_FIELD_TYPES) { + expect( + ActionParamSchema.safeParse({ name: 'p', label: 'P', type, reference: 'accounts' }).success, + `'${type}' should be a declarable param type`, + ).toBe(true); + } + for (const type of ['reference', 'owner', 'zzz_not_a_field_type']) { + expect( + ActionParamSchema.safeParse({ name: 'p', label: 'P', type, reference: 'accounts' }).success, + `'${type}' should NOT be a declarable param type`, + ).toBe(false); + } + }); + + it('but a param ALREADY authored with it still gets its picker config', () => { + // The legacy dialect `PARAM_TYPE_ALIASES` keeps (`reference` → `lookup`). + // Dropping this face's `reference` branch outright would have silently + // degraded such a param to a text input — which is why the convergence + // asks the shared set over the FOLDED widget key rather than the raw + // spelling. That fold lives in one place; this pins that it is consulted. + const out = resolveOne({ field: 'account', type: 'reference' }); + expect(out.type).toBe('reference'); + expect(out.referenceTo).toBe('accounts'); + expect(out.displayField).toBe('name'); + }); +}); diff --git a/packages/app-shell/src/utils/paramToField.test.ts b/packages/app-shell/src/utils/paramToField.test.ts index 57d3f13e05..3293bd0574 100644 --- a/packages/app-shell/src/utils/paramToField.test.ts +++ b/packages/app-shell/src/utils/paramToField.test.ts @@ -163,11 +163,21 @@ describe('paramToField', () => { /** * The reference-bearing rule is core's object, not a copy (objectui#5312). * - * This module held the FOURTH and last hand-maintained answer to one question — - * "does this widget resolve a foreign key, so hand it the reference target?" — - * as the inline disjunction `LOOKUP_WIDGET_TYPES.has(type) || type === 'user'`. - * The other three converged on `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES` in - * objectui#4770 / #4790 / #4815. + * This module was the FOURTH CONVERSION of one hand-maintained answer to one + * question — "does this widget resolve a foreign key, so hand it the reference + * target?" — held here as the inline disjunction + * `LOOKUP_WIDGET_TYPES.has(type) || type === 'user'`. The three conversions + * before it were objectui#4770 / #4790 / #4815. + * + * This docblock used to call this face the LAST copy as well as the fourth. + * Fourth converted, yes; last copy, no — objectui#5692 then found two older + * copies in `plugin-dashboard`, and objectui#5874 four more. No replacement + * count is written here on purpose: a hand-kept census is the thing that keeps + * going stale, and a bigger integer would only re-create the defect. The + * census and its falsification live once, in the family's canonical home — see + * the "One family, many consumers — and NO reliable count of them" section of + * `packages/core/src/utils/expand-fields.ts`. What is mechanical, rather than + * hand-kept, is the identity pin below (objectui#5875). * * Every membership assertion in this file is satisfied by a private * `new Set(['lookup', 'master_detail', 'user', 'tree'])` holding the same diff --git a/packages/app-shell/src/utils/paramToField.ts b/packages/app-shell/src/utils/paramToField.ts index 6d69913c73..1800efd219 100644 --- a/packages/app-shell/src/utils/paramToField.ts +++ b/packages/app-shell/src/utils/paramToField.ts @@ -136,10 +136,22 @@ export function paramToField(param: ActionParamDef): Record { // `EXPANDABLE_FIELD_TYPES` from `@object-ui/core`, the one relational-field // family that `buildExpandFields`, the predicate-record projection, the object // form's `needsDataSourceWiring` and the grid's `bulkParamToField` already - // read (objectui#4770 / #4790 / #4815). This face held the fourth and last - // private copy of it — `LOOKUP_WIDGET_TYPES.has(type) || type === 'user'`, + // read (objectui#4770 / #4790 / #4815). This face was the fourth CONVERSION + // of it — the private copy `LOOKUP_WIDGET_TYPES.has(type) || type === 'user'`, // once with a fifth spelling `owner` that objectui#4814 retired (ruling A′). // + // This comment used to add "and last". It was true as far as it had been + // measured and is now known false, twice over: objectui#5692 found two older + // copies in `plugin-dashboard`, and objectui#5874 four more (kanban, detail + // ×2, `resolveActionParams`). No count is restated here on purpose — a + // hand-kept census of this table is exactly what keeps going stale, and + // writing a bigger integer would only re-create the defect. The census, its + // falsification and the lineage of the conversions live in ONE place, the + // family's canonical home: see the "One family, many consumers — and NO + // reliable count of them" and "The LAST-private-copy claim was false" + // sections of `packages/core/src/utils/expand-fields.ts`. The mechanical + // fact, here and on every converted face, is the identity pin (objectui#5875). + // // The comment that stood here claimed the disjunction "moves in lockstep with // plugin-grid's `bulkParamToField` twin — the two param faces are never // split". Measured on the tip before this change, that was false in BOTH diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/app-shell/src/utils/resolveActionParams.ts index 0fa487511d..8d4a93124e 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/app-shell/src/utils/resolveActionParams.ts @@ -29,7 +29,11 @@ * rejects it, the server's `.strict()` parse rejects it, and this resolver * names it via {@link RESOLVED_ONLY_PARAM_KEYS} rather than reading it. */ +import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; import type { ActionParamDef, ActionParamOption } from '@object-ui/core'; +// The fold from a PARAM type spelling to the widget key that renders it — +// the one alias table, read rather than restated. See `isLookupResolvedType`. +import { resolveParamWidgetType } from './paramToField'; 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 @@ -486,7 +490,41 @@ export function resolveActionParam( /** Lookup/reference params carry extra picker config that the dialog * forwards to ``. Without these the picker would fall back * to a plain text input. */ - const isLookupResolvedType = resolvedType === 'lookup' || resolvedType === 'reference'; + // Which params are reference-bearing is NOT restated here. It is + // `EXPANDABLE_FIELD_TYPES` in `@object-ui/core` — the one relational family + // (objectui#4770 / #4790 / #4815 / #5312 / #5692) — asked over the widget key + // `resolveParamWidgetType()` folds this spelling onto. That is deliberately + // the SAME expression `paramToField()` evaluates one step later: this half + // POPULATES the picker group below and that half FORWARDS it to the widget, + // so a divergence between them silently drops a picker's config. Asking the + // one question through the one alias table is what makes them agree + // mechanically rather than by hand (objectui#5874). + // + // The literal that stood here diverged from the family in BOTH directions, + // and on THREE members rather than the two its three sibling faces missed: + // + // - it lacked `master_detail`, `user` and `tree`. A field-backed param over + // any of them inherited NO picker config, so `paramToField()` then found + // no `referenceTo` and degraded the param to a plain record-id text input + // — the unexplained "paste a UUID" box objectui#3405 exists to prevent. + // Gaining them RESTORES the rule this block states; it does not widen it. + // - it carried `reference`, and that spelling survives the change: the fold + // is where it belongs (`PARAM_TYPE_ALIASES` in `paramToField.ts` keeps it + // as a legacy param dialect, folded to `lookup`), not hand-copied into a + // membership test. Measured, not assumed — `reference` is refused by + // `ActionParamSchema` and absent from `@objectstack/spec`'s `FieldType`, + // with `lookup` / `master_detail` / `user` / `tree` accepted as live + // controls and retired `owner` plus a nonsense spelling refused as dead + // ones — so it is undeclarable, but the dialog still ACCEPTS it from + // params already authored with it, and that acceptance is the alias + // table's to state, once. + // + // Pinned by an identity spy on that `has`, so a member-identical private copy + // fails rather than quietly re-forking. Never + // `new Set([...EXPANDABLE_FIELD_TYPES, ...])`. + const isLookupResolvedType = EXPANDABLE_FIELD_TYPES.has( + resolveParamWidgetType(resolvedType), + ); const lookupExtras: Partial = isLookupResolvedType ? { // Inline `reference` wins, matching how every other inline value diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index 28d0dd9838..27988168a6 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -16,6 +16,7 @@ import { TooltipTrigger, } from '@object-ui/components'; import type { HighlightField } from '@object-ui/types'; +import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import { useSafeFieldLabel, useInlineEdit } from '@object-ui/react'; import { Check, X, Pencil } from 'lucide-react'; @@ -140,13 +141,32 @@ export const HeaderHighlight: React.FC = ({ resolvedType === 'currency' || resolvedType === 'percent' || resolvedType === 'decimal'; + // Two independent reasons a chip needs the wide basis, kept + // separate because only one of them is a shared table: + // 1. long-text DISPLAY types, whose value simply does not fit a + // 9rem column — this surface's own list, unchanged here; + // 2. REFERENCE-BEARING types, whose inline editor is a record + // picker (see the `dataSource` prop doc above, which already + // names `lookup` / `user` together as the reference editors). + // The second is NOT restated here: it is `EXPANDABLE_FIELD_TYPES` + // in `@object-ui/core`, the one relational family every other face + // reads (objectui#4770 / #4790 / #4815 / #5312 / #5692). The + // literal that stood here diverged in BOTH directions + // (objectui#5874): it lacked `user` and `tree` — whose picker needs + // the same room as `lookup`'s, so gaining them RESTORES the stated + // rule — and it carried `reference`, a spelling neither source of + // `resolvedType` can produce (`HighlightField['type']` is a closed + // union without it, and `@objectstack/spec`'s `FieldType` refuses + // it), exactly where `owner` sat before objectui#4814 retired it. + // + // Pinned by an identity spy on that `has`. Extending this surface + // later: OR in another surface-local disjunct, the way (1) is + // written. Never `new Set([...EXPANDABLE_FIELD_TYPES, ...])`. const isWide = resolvedType === 'email' || resolvedType === 'url' || resolvedType === 'textarea' || - resolvedType === 'reference' || - resolvedType === 'lookup' || - resolvedType === 'master_detail'; + (!!resolvedType && EXPANDABLE_FIELD_TYPES.has(resolvedType)); const isBoolean = resolvedType === 'boolean'; const isEmpty = value === null || value === undefined || value === ''; diff --git a/packages/plugin-detail/src/RecordDetailDrawer.tsx b/packages/plugin-detail/src/RecordDetailDrawer.tsx index 2dfda410ac..da15724a89 100644 --- a/packages/plugin-detail/src/RecordDetailDrawer.tsx +++ b/packages/plugin-detail/src/RecordDetailDrawer.tsx @@ -27,6 +27,7 @@ import { SheetHeader, SheetTitle, } from '@object-ui/components'; +import { isExpandableFieldType } from '@object-ui/core'; import type { DataSource } from '@object-ui/types'; import { SYSTEM_MANAGED_FIELD_NAMES } from '@object-ui/types'; import { InlineEditProvider } from '@object-ui/react'; @@ -224,10 +225,27 @@ export function RecordDetailDrawer({ .filter((name) => !schemaFields[name]?.hidden) .map((name) => { const def = schemaFields[name] || {}; - const isLookup = - def.type === 'lookup' || - def.type === 'master_detail' || - def.type === 'reference'; + // Which types are reference-bearing is NOT restated here: it is + // `EXPANDABLE_FIELD_TYPES` in `@object-ui/core`, read through + // `isExpandableFieldType` — the same family the `$expand` builder, the + // object form's `needsDataSourceWiring`, the grid's `bulkParamToField` + // and the dashboard's whitelist read (objectui#4770 / #4790 / #4815 / + // #5312 / #5692). The literal that stood here diverged in BOTH + // directions (objectui#5874): + // + // - it lacked `user` and `tree`. Both carry the same foreign-key + // storage as `lookup`, so the reason stated above — the drawer has no + // relation picker, and a plain text input would let the user overwrite + // the relation with a free-form string — applied to them just as much. + // Gaining them RESTORES the stated rule rather than widening it. + // - it carried a fifth spelling `reference`, which no producer can emit: + // absent from `@objectstack/spec`'s closed `FieldType` vocabulary, + // exactly where `owner` sat before objectui#4814 retired it. + // + // Pinned by an identity spy on that `has`, so a member-identical private + // copy fails rather than quietly re-forking the table. Never + // `new Set([...EXPANDABLE_FIELD_TYPES, ...])` — a copy re-forks it. + const isLookup = isExpandableFieldType(def); // Carry through the full field metadata so DetailView's inline-edit // mode can resolve the correct widget (e.g. a select with options // rather than a free-form text input). DetailSection performs the diff --git a/packages/plugin-detail/src/__tests__/expandableFamily.identity-5874.test.tsx b/packages/plugin-detail/src/__tests__/expandableFamily.identity-5874.test.tsx new file mode 100644 index 0000000000..baee10d15d --- /dev/null +++ b/packages/plugin-detail/src/__tests__/expandableFamily.identity-5874.test.tsx @@ -0,0 +1,312 @@ +/** + * 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#5874 — this package's TWO private copies of the reference-bearing + * field family converge onto `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES`. + * + * The copies were both inline disjunctions: + * + * - `RecordDetailDrawer.tsx` — `isLookup`, which forces a field READONLY + * because the drawer wires no relation picker into its inline editor, so a + * plain text input would let the user overwrite the relation with a + * free-form string; + * - `HeaderHighlight.tsx` — the reference-bearing part of the wider `isWide` + * disjunction, which gives a chip the wide layout basis because its inline + * editor is a record picker. + * + * Neither derived from nor pinned against the shared set. + * + * ## Why the load-bearing pin is IDENTITY, not membership + * + * Every membership assertion below is satisfied by a private + * `new Set(['lookup', 'master_detail', 'tree', 'user'])` holding the same + * strings — i.e. by a re-fork of exactly the kind this change removed. So the + * pins that decide the convergence spy on the `has` of the object core + * exports: a call is recorded only if the face under test consulted THAT + * object, so a member-identical private copy leaves the spy empty and fails + * here, where a value check would pass ON the defect. Same shape as + * objectui#4770 / #4790 / #4815 / #5312 / #5692. + * + * ## The membership deltas, and how each was decided + * + * - `user` / `tree` GAINED (restoration, not widening — decided per face on + * the read site): both carry the same foreign-key storage as `lookup`, so + * the reason each face states for special-casing `lookup` applies to them + * verbatim. The drawer's own reason is "no relation picker, so a text input + * would let the user overwrite the relation"; the strip's is "the picker + * needs more room than a KPI number", and the strip's `dataSource` prop doc + * already names `lookup` / `user` together as the reference editors. + * - `reference` DROPPED (measured, not preferred): it is not a declarable + * field type, and on the strip it is not an authorable display type either + * (`HighlightField['type']` is a closed union without it), so neither source + * of these faces' input can produce it. + * + * ## `isWide` is a disjunction of TWO rules and only one of them moved + * + * The long-text DISPLAY types (`email` / `url` / `textarea`) are this surface's + * own list and are deliberately NOT part of the shared family. They are pinned + * below so a future convergence cannot quietly absorb them. + * + * Ablation direction, predicted before running: restore either private copy and + * that face's identity pin goes RED (the spy records no call) while a + * member-set assertion over `EXPANDABLE_FIELD_TYPES` stays GREEN — that + * contrast is the whole reason the pins are on identity. The restoration probes + * go red too; the ordinary-relation and non-relation controls stay green in + * both directions, which is what makes them controls rather than duplicates of + * the pins. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; +import { FieldType } from '@objectstack/spec/data'; +import { HeaderHighlight } from '../HeaderHighlight'; +import { RecordDetailDrawer } from '../RecordDetailDrawer'; + +const SPEC_FIELD_TYPES: readonly string[] = [ + ...(FieldType as unknown as { options: readonly string[] }).options, +]; + +/** + * The drawer hands its derived field list to `DetailView` as `schema.fields`. + * Standing in for `DetailView` captures that list without rendering the whole + * detail tree — the readonly flag under test is a property of the list, not of + * how DetailView paints it. + */ +const capturedFields: { current: any[] } = { current: [] }; +vi.mock('../DetailView', () => ({ + DetailView: ({ schema }: any) => { + capturedFields.current = schema?.fields ?? []; + return
; + }, +})); + +const objectSchema = { + name: 'deal', + fields: { + title: { type: 'text', label: 'Title' }, + account: { type: 'lookup', label: 'Account', reference_to: 'accounts' }, + parent_deal: { type: 'master_detail', label: 'Parent', reference_to: 'deals' }, + assignee: { type: 'user', label: 'Assignee' }, + parent_node: { type: 'tree', label: 'Parent node', reference_to: 'deals' }, + stage: { type: 'select', label: 'Stage' }, + notes: { type: 'textarea', label: 'Notes' }, + }, +}; + +const record = { + id: 'r1', + title: 'Acme renewal', + account: 'acc-1', + parent_deal: 'deal-0', + assignee: 'usr-1', + parent_node: 'deal-0', + stage: 'won', + notes: 'some notes', +}; + +function renderDrawer() { + capturedFields.current = []; + render( + {}} + title="Acme renewal" + record={record} + recordId="r1" + objectName="deal" + objectSchema={objectSchema} + onFieldSave={async () => {}} + />, + ); + return capturedFields.current; +} + +const drawerField = (name: string) => + renderDrawer().find((f: any) => f.name === name); + +/** The layout basis the strip gives a wide chip vs a narrow one. */ +const WIDE_BASIS = 'basis-[16rem]'; +const NARROW_BASIS = 'basis-[9rem]'; + +function renderHighlight(type: string) { + const { container } = render( + , + ); + const chip = container.querySelector(`.${CSS.escape(WIDE_BASIS)}, .${CSS.escape(NARROW_BASIS)}`); + return chip?.className.includes(WIDE_BASIS) ? 'wide' : chip ? 'narrow' : 'missing'; +} + +afterEach(() => { + // The pins install a spy on the Set object EXPORTED by core — a shared, + // module-level object. A leaked spy would follow every later file in the + // worker, so restoring is not optional here. + vi.restoreAllMocks(); +}); + +describe("the drawer's readonly rule is core's object, not a copy (objectui#5874)", () => { + it('`RecordDetailDrawer` asks `@object-ui/core` EXPANDABLE_FIELD_TYPES', () => { + // The spy is installed on the Set exported by core and records a call only + // if THIS face consulted THAT object. A member-identical private copy + // leaves it empty, so this fails where a value check would pass. + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + renderDrawer(); + expect(spy.mock.calls.map(([k]) => k)).toContain('lookup'); + } finally { + spy.mockRestore(); + } + }); + + it('reaches that object on the person path too, not just the lookup path', () => { + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + renderDrawer(); + expect(spy.mock.calls.map(([k]) => k)).toContain('user'); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("the strip's wide rule is core's object, not a copy (objectui#5874)", () => { + it('`HeaderHighlight` asks `@object-ui/core` EXPANDABLE_FIELD_TYPES', () => { + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + renderHighlight('lookup'); + expect(spy.mock.calls.map(([k]) => k)).toContain('lookup'); + } finally { + spy.mockRestore(); + } + }); + + it('reaches that object on the person path too', () => { + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + renderHighlight('user'); + expect(spy.mock.calls.map(([k]) => k)).toContain('user'); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('a member-identical private copy would NOT satisfy the pins — the contrast', () => { + it('the member set alone cannot tell a converged face from a re-forked one', () => { + // Documents, executably, why the four pins above are not membership checks: + // this assertion is true of the shared object AND of any private set + // holding the same strings. The spies above can tell them apart. + const memberIdenticalCopy = new Set(['lookup', 'master_detail', 'tree', 'user']); + expect([...EXPANDABLE_FIELD_TYPES].sort()).toEqual([...memberIdenticalCopy].sort()); + expect(EXPANDABLE_FIELD_TYPES).not.toBe(memberIdenticalCopy); + }); +}); + +describe('the restoration half — members these faces were missing (objectui#5874)', () => { + // Each must be able to FAIL: before the convergence a `user` / `tree` field + // was inline-editable as free text in the drawer, and got the narrow basis in + // the strip. + it.each(['assignee', 'parent_node'])( + 'the drawer now marks %s readonly, like a lookup', + (name) => { + expect(drawerField(name)?.readonly).toBe(true); + }, + ); + + it.each(['user', 'tree'])('the strip now gives a %s chip the wide basis', (type) => { + expect(renderHighlight(type)).toBe('wide'); + }); +}); + +describe('the ordinary relations are untouched — regression control', () => { + // These must stay green through BOTH ablation legs. If they move, the + // convergence took the whole rule with it and the pins above are reporting on + // rubble rather than on a re-homed rule. + it.each(['account', 'parent_deal'])('the drawer still marks %s readonly', (name) => { + expect(drawerField(name)?.readonly).toBe(true); + }); + + it.each(['lookup', 'master_detail'])('the strip still widens a %s chip', (type) => { + expect(renderHighlight(type)).toBe('wide'); + }); +}); + +describe('a type outside the family is still NOT reference-bearing — the control', () => { + // Without this, "converge" would be satisfiable by treating every field as a + // relation. + it.each(['title', 'stage'])('the drawer leaves %s editable', (name) => { + expect(drawerField(name)?.readonly).toBe(false); + }); + + it('the strip leaves a `select` chip narrow', () => { + expect(renderHighlight('select')).toBe('narrow'); + }); +}); + +describe("`isWide`'s OTHER rule — the long-text display types — did not move", () => { + // `email` / `url` / `textarea` widen for a different reason (the value does + // not fit a 9rem column), are this surface's own list, and are deliberately + // NOT members of the shared family. Pinned so a later sweep cannot absorb + // them into it without turning this red. + it.each(['email', 'url', 'textarea'])('a %s chip is still wide', (type) => { + expect(renderHighlight(type)).toBe('wide'); + }); + + it.each(['email', 'url', 'textarea'])('but %s is NOT a family member', (type) => { + expect(EXPANDABLE_FIELD_TYPES.has(type)).toBe(false); + }); +}); + +describe('the `reference` drop is a no-op on real data — the measured direction', () => { + /** + * The measurement, kept as an executable pin rather than as prose in a PR. + * Controls run on the same read as the subject, so a probe that had lost hold + * of the vocabulary (an empty list, the wrong export) fails as a broken probe + * instead of reporting the subject absent. + */ + it('every LIVE control IS a spec `FieldType`, and every DEAD one is not', () => { + for (const type of EXPANDABLE_FIELD_TYPES) { + expect(SPEC_FIELD_TYPES, `'${type}' is not a spec FieldType`).toContain(type); + } + expect(SPEC_FIELD_TYPES).not.toContain('owner'); + expect(SPEC_FIELD_TYPES).not.toContain('zzz_not_a_field_type'); + }); + + it('SUBJECT — `reference` is not a declarable field type', () => { + // The whole licence for dropping it. If the spec ever adds the spelling, + // this goes red and the "should the shared family gain `reference`?" + // question reopens — deliberately, rather than the drop remaining correct + // only by accident. + expect(SPEC_FIELD_TYPES).not.toContain('reference'); + }); + + it('so neither face answers for it any more', () => { + expect(renderHighlight('reference')).toBe('narrow'); + const drawer = render( + {}} + title="x" + record={{ id: 'r1', legacy_ref: 'acc-1' }} + recordId="r1" + objectName="deal" + objectSchema={{ fields: { legacy_ref: { type: 'reference', label: 'Legacy' } } }} + onFieldSave={async () => {}} + />, + ); + void drawer; + expect( + capturedFields.current.find((f: any) => f.name === 'legacy_ref')?.readonly, + ).toBe(false); + }); +}); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 76cd8f9837..cd1b5db113 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -19,7 +19,12 @@ import { import { toast } from '@object-ui/components'; import { createSafeTranslation } from '@object-ui/i18n'; import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail'; -import { extractRecords, buildExpandFields, getRecordDisplayName } from '@object-ui/core'; +import { + extractRecords, + buildExpandFields, + getRecordDisplayName, + isExpandableFieldType, +} from '@object-ui/core'; import { getBadgeColorClasses, getBadgeHexAppearance, getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import { KanbanRenderer, KANBAN_UNCOLUMNED_ID } from './index'; import { KanbanSchema } from './types'; @@ -342,8 +347,23 @@ export const ObjectKanban: React.FC = ({ } if (typeof raw !== 'string') return String(raw); const def = objectDef?.fields?.[key]; - const isLookup = - def?.type === 'lookup' || def?.type === 'master_detail' || def?.type === 'reference'; + // Which types are reference-bearing is NOT restated here: it is + // `EXPANDABLE_FIELD_TYPES` in `@object-ui/core`, read through + // `isExpandableFieldType` — the one relational family that + // `buildExpandFields` (imported above, same file), the object form's + // `needsDataSourceWiring`, the grid's `bulkParamToField`, `app-shell`'s + // `paramToField` and the dashboard's `$expand` whitelist already read + // (objectui#4770 / #4790 / #4815 / #5312 / #5692). The literal that + // stood here diverged from it in BOTH directions: it lacked `user` and + // `tree`, and carried a fifth spelling `reference` that no producer can + // emit — absent from `@objectstack/spec`'s closed `FieldType` and + // refused by `FieldSchema` / `ActionParamSchema` alike, exactly where + // `owner` sat before objectui#4814 retired it (objectui#5874). + // + // Pinned by an identity spy on that `has`, so a member-identical + // private copy fails here rather than quietly re-forking the table. + // Never `new Set([...EXPANDABLE_FIELD_TYPES, ...])` — a copy re-forks it. + const isLookup = isExpandableFieldType(def); if (isLookup && isOpaqueId(raw)) return undefined; if (isOpaqueId(raw)) return undefined; return raw; diff --git a/packages/plugin-kanban/src/__tests__/expandableFamily.identity-5874.test.tsx b/packages/plugin-kanban/src/__tests__/expandableFamily.identity-5874.test.tsx new file mode 100644 index 0000000000..76095b3eeb --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/expandableFamily.identity-5874.test.tsx @@ -0,0 +1,261 @@ +/** + * 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#5874 — this package's private copy of the reference-bearing field + * family converges onto `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES`. + * + * The copy was the inline disjunction `isLookup` inside `resolveDisplay`, the + * card-description helper in `ObjectKanban.tsx`. It neither derived from nor + * pinned against the shared set, and it diverged from it in BOTH directions: + * it lacked `user` and `tree`, and carried a fifth spelling `reference` that no + * producer can emit. + * + * ## Why the load-bearing pin is IDENTITY, not membership + * + * Every membership assertion below is satisfied by a private + * `new Set(['lookup', 'master_detail', 'tree', 'user'])` holding the same + * strings — i.e. by a re-fork of exactly the kind this change removed. So the + * pin that decides the convergence spies on the `has` of the object core + * exports: a call is recorded only if the face under test consulted THAT + * object, so a member-identical private copy leaves the spy empty and fails + * here, where a value check would pass ON the defect. Same shape as + * objectui#4770 / #4790 / #4815 / #5312 / #5692. + * + * ## ⚠️ This face has NO behavioural counter-probe, and that is a finding + * + * The identity pin below is the ONLY thing this file can assert about the + * convergence, because the guard it re-homes is unreachable in its own right. + * Measured on the merge base, `resolveDisplay` reads: + * + * if (isLookup && isOpaqueId(raw)) return undefined; + * if (isOpaqueId(raw)) return undefined; + * + * The second line subsumes the first for every input, so `isLookup` cannot + * change any outcome — no membership delta on this face, in EITHER direction, + * is observable through `ObjectKanban`'s rendered output. That is why there is + * no "a `user` field is now treated as a relation here" probe: writing one + * would mean writing an assertion that cannot fail, which is worse than + * recording the absence. + * + * The subsumption is a DIFFERENT defect from the one this card fixes (a + * redundant guard, not a forked table), so it is filed rather than fixed in + * passing — objectui#6063. Converging the copy is still correct on its own + * terms: the day that guard is made live again it reads the family instead of a + * stale literal, and this pin is what holds it there. + * + * Ablation direction, predicted before running: restore the private copy + * (`def?.type === 'lookup' || def?.type === 'master_detail' || + * def?.type === 'reference'`) and the identity pin goes RED (the spy records no + * call) while the member-set assertion stays GREEN — that contrast is the whole + * reason the pin is on identity rather than on members. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; +import { FieldType } from '@objectstack/spec/data'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-kanban`. +import '../index'; +// The cards asserted below render INSIDE `KanbanRenderer`'s `React.lazy` +// boundary. Importing the chunk at module scope bills the cold transform to the +// import phase (unbounded) instead of racing a `waitFor` budget under full +// parallelism — the objectui#3010 rule, same specifier as `index.tsx`'s factory +// so ESM's module cache makes that factory resolve immediately. +import '../KanbanImpl'; + +const SPEC_FIELD_TYPES: readonly string[] = [ + ...(FieldType as unknown as { options: readonly string[] }).options, +]; + +/** + * A board with NO `cardFields` and NO `highlightFields`, so cards fall to the + * legacy semantic heuristic — the only branch that calls `resolveDisplay`, and + * therefore the only branch that reaches the converged guard. `account` and + * `owner` are two of the hard-coded keys that helper probes. + */ +function makeAdapter(fieldTypes: Record) { + return { + find: vi.fn().mockResolvedValue({ + data: [ + { + id: '1', + name: 'Acme renewal', + status: 'open', + account: 'aXbY9zHWBfjYjZ4', + owner: 'qWeRtY7uIoPa1Sd', + }, + ], + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'deal', + fields: { + name: { type: 'text' }, + status: { type: 'text' }, + account: { type: fieldTypes.account ?? 'lookup' }, + owner: { type: fieldTypes.owner ?? 'user' }, + }, + }), + }; +} + +async function renderBoard(fieldTypes: Record = {}) { + const adapter = makeAdapter(fieldTypes); + const { container } = render( + + + , + ); + // The cards render past a Suspense boundary and after the fetch resolves. + await waitFor(() => expect(container.textContent).toContain('Acme renewal')); + return container; +} + +/** + * Record every `EXPANDABLE_FIELD_TYPES.has` call made while `exercise()` runs, + * ATTRIBUTED to the function that made it, and return the arguments that came + * from `resolveDisplay` alone. + * + * ⚠️ The attribution is load-bearing, not decoration. `ObjectKanban.tsx` also + * imports `buildExpandFields`, which consults the SAME shared object once per + * schema field on every render. A bare `vi.spyOn(...).mock.calls` pin therefore + * stays GREEN when `resolveDisplay` is re-forked — measured, not feared: the + * first draft of this file did exactly that and survived its own ablation, so + * the pin was reporting on `buildExpandFields`' calls and pinning nothing. + * Filtering by the calling frame is what makes the ablation red. + * + * The frame name IS the read site's identifier. Renaming `resolveDisplay` must + * update this helper — the pin naming the site it guards is the point. + */ +function hasCallsFrom(site: string, exercise: () => Promise) { + const seen: string[] = []; + const real = EXPANDABLE_FIELD_TYPES.has.bind(EXPANDABLE_FIELD_TYPES); + const spy = vi + .spyOn(EXPANDABLE_FIELD_TYPES, 'has') + .mockImplementation((key: string) => { + if (new Error().stack?.includes(site)) seen.push(key); + return real(key); + }); + return exercise().then( + () => { + spy.mockRestore(); + return seen; + }, + (err) => { + spy.mockRestore(); + throw err; + }, + ); +} + +afterEach(() => { + // The pin installs a spy on the Set object EXPORTED by core — a shared, + // module-level object. A leaked spy would follow every later file in the + // worker, so restoring is not optional here. + vi.restoreAllMocks(); +}); + +describe("the kanban card's relation rule is core's object, not a copy (objectui#5874)", () => { + it('`resolveDisplay` asks `@object-ui/core` EXPANDABLE_FIELD_TYPES', async () => { + // The spy is installed on the Set exported by core and records a call only + // if THAT object was consulted, from THIS read site. A member-identical + // private copy leaves it empty, so this fails where a value check passes. + const calls = await hasCallsFrom('resolveDisplay', () => + renderBoard({ account: 'lookup' }), + ); + expect(calls).toContain('lookup'); + }); + + it('reaches that object on the person path too, not just the lookup path', async () => { + // `user` and `lookup` are different members of the same set; a convergence + // that reconnected one spelling only would leave the other forked. This is + // as close to the restoration half as this face can be checked — see the + // subsumption note in the file docblock for why there is no behavioural + // probe to pair with it. + const calls = await hasCallsFrom('resolveDisplay', () => + renderBoard({ account: 'user' }), + ); + expect(calls).toContain('user'); + }); + + it('the UNattributed spy would NOT have failed — why the frame filter is here', async () => { + // `buildExpandFields`, imported into the same module, consults the same + // shared object on every render. So "some call happened" is satisfied by a + // face that never converged. This assertion is what the two pins above + // would degrade into without the frame filter, and it is green either way. + const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has'); + try { + await renderBoard({ account: 'lookup' }); + const fromAnywhere = spy.mock.calls.map(([k]) => k); + expect(fromAnywhere.length).toBeGreaterThan(0); + const stacksAttributed = fromAnywhere.length; + expect(stacksAttributed).toBeGreaterThan(1); + } finally { + spy.mockRestore(); + } + }); + + it('a member-identical private copy would NOT satisfy the pin — the contrast', () => { + // Documents, executably, why the two pins above are not membership checks: + // this assertion is true of the shared object AND of any private set + // holding the same strings, so it cannot tell a converged face from a + // re-forked one. The spies above can. + const memberIdenticalCopy = new Set(['lookup', 'master_detail', 'tree', 'user']); + expect([...EXPANDABLE_FIELD_TYPES].sort()).toEqual([...memberIdenticalCopy].sort()); + expect(EXPANDABLE_FIELD_TYPES).not.toBe(memberIdenticalCopy); + }); +}); + +describe('the card still renders — regression control', () => { + // Must stay green through BOTH ablation legs. If it moves, the convergence + // took the card renderer with it and the pin above is reporting on rubble + // rather than on a re-homed rule. + it('a board with no card config still renders its records', async () => { + const container = await renderBoard(); + expect(container.textContent).toContain('Acme renewal'); + expect(container.textContent).toContain('Open'); + }); +}); + +describe('the `reference` drop is a no-op on real data — the measured direction', () => { + /** + * The measurement, kept as an executable pin rather than as prose in a PR. + * Controls run on the same read as the subject, so a probe that had lost hold + * of the vocabulary (an empty list, the wrong export) fails as a broken probe + * instead of reporting the subject absent. + */ + it('every LIVE control IS a spec `FieldType`, and every DEAD one is not', () => { + for (const type of EXPANDABLE_FIELD_TYPES) { + expect(SPEC_FIELD_TYPES, `'${type}' is not a spec FieldType`).toContain(type); + } + expect(SPEC_FIELD_TYPES).not.toContain('owner'); + expect(SPEC_FIELD_TYPES).not.toContain('zzz_not_a_field_type'); + }); + + it('SUBJECT — `reference` is not a declarable field type', () => { + // The whole licence for dropping it. If the spec ever adds the spelling, + // this goes red and the "should the shared family gain `reference`?" + // question reopens — deliberately, rather than the drop remaining correct + // only by accident. + expect(SPEC_FIELD_TYPES).not.toContain('reference'); + }); +}); From 011293ac6f57d02d11e4f9e4e6b6b91dae6386e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 15:46:53 +0000 Subject: [PATCH 2/2] fix(app-shell): give the new paramToField import an explicit .js extension `check:esm-specifiers` (inside the Type Check CI job) caught the relative specifier this PR added: Node's ESM resolver does not extension-search relative specifiers and app-shell's build emits them unchanged, so the published entry could not be imported under plain Node. Part of #5874 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- packages/app-shell/src/utils/resolveActionParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/app-shell/src/utils/resolveActionParams.ts index 8d4a93124e..8f3fccfc7f 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/app-shell/src/utils/resolveActionParams.ts @@ -33,7 +33,7 @@ import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; import type { ActionParamDef, ActionParamOption } from '@object-ui/core'; // The fold from a PARAM type spelling to the widget key that renders it — // the one alias table, read rather than restated. See `isLookupResolvedType`. -import { resolveParamWidgetType } from './paramToField'; +import { resolveParamWidgetType } from './paramToField.js'; 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