diff --git a/.changeset/6425-objectdatatable-column-holds.md b/.changeset/6425-objectdatatable-column-holds.md new file mode 100644 index 000000000..d222901e9 --- /dev/null +++ b/.changeset/6425-objectdatatable-column-holds.md @@ -0,0 +1,16 @@ +--- +--- + +No published behaviour changes, and nothing declared or retired. + +`ObjectDataTable.enrich()` read six field-meta overrides off the AUTHORED column — three +through `(col as any)`, two more through the column bag's `[key: string]: any`, which +answers `any` just as loudly without the tell (objectui#6425). Those reads now go through +a local `AuthoredColumnOverrides` keyhole: every honoured key carries a written verdict +and the card that owns it, and every other `FieldMeta` member is refused by a DERIVED +`?: never` band, so a seventh one has to be adjudicated instead of admitted by silence. + +Type-only at the seam plus a new test file. `@object-ui/types`' `TableColumn` and its zod +mirror are untouched: whether `format` / `options` / `referenceTo` / `currency` / +`decimals` get declared on the published type or retired stays objectui#6425's open +ruling, and this change is the per-key evidence that ruling needs, not the ruling. diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index 9cd31ad70..87c2fd117 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -129,6 +129,150 @@ export type EnrichedColumn = * adjudicated to escape. */ & { [K in Exclude]?: never }; +/** + * What this widget's column producer is allowed to READ off the AUTHORED + * column as a field-meta override (objectui#6425) — the READ half of the seam + * {@link EnrichedColumn} above fences on the WRITE side. objectui#6373 fenced + * what `enrich` emits; nothing yet fenced what it consumes. + * + * `enrich` hands `buildFieldMeta` six values taken off the authored column. + * Five of them — `format`, `options`, `referenceTo`, `currency`, `decimals` — + * are declared by neither `TableColumn` (`@object-ui/types`) nor its + * `TableColumnSchema` zod mirror. Three were reached through `(col as any)`; + * the other two through {@link NormalizedColumn}'s `[key: string]: any`, which + * answers `any` just as loudly without the tell. So the widget honoured an + * authoring vocabulary the published types refuse, and no artefact in the repo + * said which keys those were or why. + * + * ## ⛔ This declares nothing and retires nothing + * + * Declaring these five on `TableColumn` widens a PUBLISHED type — the runtime + * accepted set would not move, but the promise would, and a declared key + * cannot be withdrawn later without a breaking change. Retiring any of them + * removes a published capability that ships and is tested today. Both are + * maintainer decisions; objectui#6425 stays open for one. What this type does + * instead is make the tolerance VISIBLE and OWNED — every honoured key written + * down with a verdict and an owning card, every other candidate refused by + * default rather than admitted by silence. Same shape objectui#6461 landed for + * `plugin-grid` (`ObjectGridColumnHolds` / `RetiredListColumnKey`); the + * identifiers differ because this producer's seam is a READ, not an emit. + * + * ## The band is DERIVED, and it bites by assignability + * + * `FieldMeta` is the override vocabulary — `buildFieldMeta`'s `overrides` is a + * subset of it — so it is the pool a new tolerance would come from, and the + * same pool {@link EnrichedColumn}'s tombstones derive from. Deriving means a + * seventh `FieldMeta` member is refused here on the day it is added, without + * anyone remembering to extend a hand-written list. + * + * ⚠️ It derives from the OVERRIDE VOCABULARY, not from the authored input + * type, and that difference is forced rather than stylistic. `plugin-grid`'s + * sibling band (`RetiredListColumnKey`, objectui#6461) is + * `Exclude` — it can derive from its authored type + * because `ListColumn` DECLARES its keys. This producer's authored type + * cannot: {@link NormalizedColumn} carries `[key: string]: any`, so `keyof` it + * is `string | number`. Measured on this program — a band built on it accepts + * `'totallyMadeUpKey'` as a member, so it would ban nothing at all. The pool + * therefore has to be the vocabulary the overrides are drawn FROM. + * + * That choice has a cost, and it is stated here rather than left to be + * discovered: a candidate key OUTSIDE `FieldMeta` can never land in the band. + * It is refused by the other half of this type instead — + * `AuthoredColumnOverrides` declares no index signature, so reading an + * unadjudicated key is TS2339 AT THE READ SITE, which is the enforcement + * `enrich` actually runs on (`(col as any).x` and the bag's index signature + * both answered `any` there). Both halves are pinned by the suite. + * + * ⚠️ Measured on this program, in both directions, before this shape was + * written: + * - `?: never` bites by ASSIGNABILITY, not by freshness, so the refusal + * survives a spread — `const s: AuthoredColumnOverrides = someBag` fails on + * a source that DECLARES a banded key, and passes on one that does not. + * - `accessorKey` is required here for a reason that is not decoration: + * without one property in common, TypeScript's weak-type check rejects the + * assignment below outright (TS2559), and a type that refuses everything + * pins nothing. It is the same key {@link NormalizedColumn} already + * declares required, so this makes no new claim about the authored object. + * + * ## A VIEW, not a census of the object + * + * The banded members say "this producer must not SOURCE this override from the + * authored column" — not "this key is absent at run time". `normalizeColumns` + * deliberately leaves the authored spelling in place, so a column really can + * carry `name` / `label`; they are refused HERE because this seam already has + * answers for both and they do not come from the column: `name` comes from + * `accessorKey` and `label` from `col.header`, which objectui#5351 made the one + * place the authored label is translated. + */ + +/** + * The undeclared-but-live override keys this producer holds. Each carries the + * verdict measured on this tree and the card that owns it; all five are + * objectui#6425's, and none is settled by this file. + */ +export interface ObjectDataTableColumnHolds { + /** + * HELD, objectui#6425 — the strongest authoring story of the five. The + * package README documents it (`"Author overrides always win"`, three + * `format` columns in its `object-data-table` example) and + * `ObjectDataTable.cells.test.tsx` renders `$150,000` / `60%` from it. + * Read by `renderFieldValue`'s currency / percent / date branches, by + * `isNumericFieldMeta` (which decides `align`), and by + * `resolveCellRendererType`. + */ + format?: string; + /** + * HELD, objectui#6425 — named as an author override by the package README. + * Read by `SelectCellRenderer` (`field.options`) after `buildFieldMeta`'s + * per-option translation pass. + */ + options?: FieldMeta['options']; + /** + * HELD, objectui#6425 — named as an author override by the package README, + * but MEASURED with no reader on this path: `LookupCellRenderer` resolves + * its target from `reference_to` / `reference`, and `computeLookupExpand` + * builds `$expand` from the OBJECT SCHEMA's field types, never from this + * key. Held rather than retired because retiring a published-documented key + * is objectui#6425's ruling to make, not this file's. + */ + referenceTo?: unknown; + /** + * HELD, objectui#6425 — live but undocumented: no README line and no test + * authored it at column level before this card. Read by `renderFieldValue` + * (`fieldMeta.currency`, the `$`-format branch) and by `resolveFieldCurrency` + * inside `CurrencyCellRenderer`, both ahead of the tenant default (ADR-0053). + */ + currency?: string; + /** + * HELD, objectui#6425 — MEASURED with no reader anywhere: zero `.decimals` + * reads across `@object-ui/fields`, `@object-ui/i18n` and + * `@object-ui/components`. `NumberCellRenderer` reads `scale`, + * `PercentCellRenderer` reads `precision`, and `renderFieldValue`'s percent + * branch parses its digit count out of the format string. Held, not retired, + * for the same reason as `referenceTo`. + */ + decimals?: number; +} + +/** + * The candidate keys this seam refuses — DERIVED from the override vocabulary, + * never hand-listed, so a future `FieldMeta` member has to be adjudicated onto + * {@link ObjectDataTableColumnHolds} to escape. `type` is excluded because + * `TableColumn` declares it (objectui#5853 owns its VALUE set, folded below by + * `normalizeTableColumnType`). + */ +export type UnheldFieldMetaOverrideKey = + Exclude; + +/** The keyhole `enrich` reads the authored column through. See the docblock above. */ +export type AuthoredColumnOverrides = + { accessorKey: string } + /** DECLARED by `TableColumn`; widened to `string` because the authored value + * is folded onto the published union downstream, not at the read. */ + & { type?: string } + & ObjectDataTableColumnHolds + & { [K in UnheldFieldMetaOverrideKey]?: never }; + /** * Shared empty fallback for the resolved row list (objectui#4629). * @@ -520,6 +664,14 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo }; const enrich = (col: NormalizedColumn): EnrichedColumn => { + // ⭐ THE READ SEAM (objectui#6425). Every override below is taken through + // `AuthoredColumnOverrides` rather than off the bag directly, so each key + // arrives with the type and the written verdict its docblock gives it, + // and an unadjudicated `FieldMeta` member is a compile error here instead + // of an `any` nobody had to justify. This assignment is the enforcement — + // it is what fails if the authored shape ever grows a banded key. + const authored: AuthoredColumnOverrides = col; + // Build the shared FieldMeta (translated select options, resolved // referenceTo / currency / decimals). Column-level props override the // schema-derived values. Lookup fields just pass `referenceTo` through — @@ -532,12 +684,12 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo objectName, fieldOptionLabel, overrides: { - type: col.type, - format: col.format, - options: col.options, - referenceTo: (col as any).referenceTo, - currency: (col as any).currency, - decimals: (col as any).decimals, + type: authored.type, + format: authored.format, + options: authored.options, + referenceTo: authored.referenceTo, + currency: authored.currency, + decimals: authored.decimals, }, }); diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.overrideSource-6425.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.overrideSource-6425.test.tsx new file mode 100644 index 000000000..d74294215 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.overrideSource-6425.test.tsx @@ -0,0 +1,281 @@ +/** + * 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. + */ + +/** + * The CONSUMER half of `declared = enforced` for `ObjectDataTable`'s column + * seam (objectui#6425). `ObjectDataTable.emitBoundary-6373.test.tsx` is its + * twin from the other end: that one asks what the producer WRITES into the + * `TableColumn[]` slot, this one asks what it READS off the authored column. + * + * `enrich()` handed `buildFieldMeta` six values taken off the authored column. + * Five — `format`, `options`, `referenceTo`, `currency`, `decimals` — are + * declared by neither `TableColumn` nor its `TableColumnSchema` zod mirror. + * Three arrived through `(col as any)`; the other two through + * `NormalizedColumn`'s `[key: string]: any`, which answers `any` just as + * loudly without the tell. `AuthoredColumnOverrides` replaces both with a + * written hold per key plus a derived refusal band. + * + * ## ⛔ Nothing here declares or retires anything published + * + * The declare-or-retire ruling for the five is objectui#6425's own subject and + * belongs to a maintainer. This file is the PREREQUISITE for it: the runtime + * half below measures, per key, whether an authored override reaches anything + * at all, so the ruling is made on evidence rather than on the plausibility of + * a key's name. + * + * ## Every zero here is paired with a positive control + * + * Two of the five reach no reader. A test that renders two columns and finds + * them equal proves that only if the SAME query shape is shown separating two + * columns elsewhere — otherwise it is measuring a broken harness. So each + * inert pair sits next to a live pair built the same way: `decimals` against + * `currency`, `referenceTo` against `options`. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +vi.mock('@object-ui/react', async () => { + const actual: any = await vi.importActual('@object-ui/react'); + return { + ...actual, + // The same stand-in the cells suite uses: it invokes each column's `cell` + // closure, which is the only thing that reads the overrides under test. + SchemaRenderer: ({ schema }: any) => { + const cols = schema.columns || []; + const rows = schema.data || []; + return ( + + + {rows.map((row: any, i: number) => ( + + {cols.map((c: any) => ( + + ))} + + ))} + +
+ {typeof c.cell === 'function' ? c.cell(row[c.accessorKey], row) : String(row[c.accessorKey] ?? '')} +
+ ); + }, + useDataScope: () => undefined, + SchemaRendererContext: actual.SchemaRendererContext, + }; +}); + +import { ObjectDataTable, normalizeColumns } from '../ObjectDataTable'; +import type { AuthoredColumnOverrides, ObjectDataTableColumnHolds } from '../ObjectDataTable'; + +afterEach(() => cleanup()); + +/** + * Render one table over a row whose two fields hold the SAME value, so the + * only thing that can separate the two cells is the override under test. + */ +async function renderPair( + fields: Record, + row: Record, + columns: any[], +): Promise<{ a: string; b: string }> { + const dataSource = { + find: async () => ({ data: [row] }), + getObjectSchema: async () => ({ fields }), + }; + render( + , + ); + await waitFor(() => expect(screen.getByTestId('cell-a')).toBeInTheDocument(), { timeout: 2000 }); + await waitFor(() => expect(screen.getByTestId('cell-a').textContent).not.toBe(''), { timeout: 2000 }); + return { + a: screen.getByTestId('cell-a').textContent ?? '', + b: screen.getByTestId('cell-b').textContent ?? '', + }; +} + +/* ── the runtime half: what each held key actually reaches ────────────────── */ + +describe('per-key liveness of the five undeclared overrides (#6425)', () => { + it('currency IS live — it beats the symbol inferred from `format`', async () => { + // The positive control for the `decimals` zero below: same harness, same + // two-columns-over-equal-values shape, and it SEPARATES them. + const { a, b } = await renderPair( + { a: { type: 'number' }, b: { type: 'number' } }, + { a: 150000, b: 150000 }, + [ + { header: 'Plain', accessorKey: 'a', format: '$0,0' }, + { header: 'Euro', accessorKey: 'b', format: '$0,0', currency: 'EUR' }, + ], + ); + expect(a).toMatch(/150,000/); + expect(b).toMatch(/150,000/); + expect(b).not.toBe(a); + expect(b).toContain('€'); + }); + + it('decimals reaches NOTHING — no reader exists for it', async () => { + // Measured statically too: zero `.decimals` reads across `@object-ui/fields`, + // `@object-ui/i18n` and `@object-ui/components`. `NumberCellRenderer` reads + // `scale`; `PercentCellRenderer` reads `precision`; `renderFieldValue`'s + // percent branch counts digits in the FORMAT STRING. This pins that as + // behaviour, so making the key live later has to be a deliberate change + // that turns this red — and retiring it stays free. + const { a, b } = await renderPair( + { a: { type: 'number' }, b: { type: 'number' } }, + { a: 3.14159, b: 3.14159 }, + [ + { header: 'Plain', accessorKey: 'a' }, + { header: 'Three places', accessorKey: 'b', decimals: 3 }, + ], + ); + expect(a).not.toBe(''); + expect(b).toBe(a); + }); + + it('options IS live — an authored option list relabels the badge', async () => { + // The positive control for the `referenceTo` zero below. + const schemaOptions = [{ value: 'tech', label: 'Technology' }]; + const { a, b } = await renderPair( + { + a: { type: 'select', options: schemaOptions }, + b: { type: 'select', options: schemaOptions }, + }, + { a: 'tech', b: 'tech' }, + [ + { header: 'From schema', accessorKey: 'a' }, + { header: 'Overridden', accessorKey: 'b', options: [{ value: 'tech', label: 'Overridden' }] }, + ], + ); + expect(a).toBe('Technology'); + expect(b).toBe('Overridden'); + }); + + it('referenceTo reaches NOTHING on this path', async () => { + // `LookupCellRenderer` resolves its target from `reference_to` / `reference` + // — never `referenceTo` — and `computeLookupExpand` builds `$expand` from + // the OBJECT SCHEMA's field types, so authoring it on a column does not add + // the field to the expand whitelist either. Values arrive already expanded. + const expanded = { id: 'acc-1', name: 'Acme' }; + const { a, b } = await renderPair( + { a: { type: 'lookup' }, b: { type: 'lookup' } }, + { a: expanded, b: { ...expanded } }, + [ + { header: 'Plain', accessorKey: 'a' }, + { header: 'With referenceTo', accessorKey: 'b', referenceTo: 'account' }, + ], + ); + expect(a).toContain('Acme'); + expect(b).toBe(a); + }); +}); + +/* ── the type-level half ─────────────────────────────────────────────────── */ + +/** True only when `T` is `any` — the one type `0 extends (1 & T)` admits. */ +type IsAny = 0 extends (1 & T) ? true : false; + +/** What `enrich` receives: the authored bag `normalizeColumns` produces. */ +type AuthoredBag = ReturnType[number]; + +describe('the override reads are typed, and the band can FAIL (#6425)', () => { + it('turns the `any` reads into declared ones', () => { + // ⭐ The before/after pair, in one file and one query shape. The bag is the + // pre-fix read: `(col as any).currency` and `col.decimals` both answered + // `any`, the first loudly and the second through the index signature. The + // keyhole answers the declared type. `IsAny` returning `true` for the bag + // is the positive control that makes the two `false`s a measurement rather + // than a query that never fires. + const bagCurrencyIsAny: IsAny = true; + const bagDecimalsIsAny: IsAny = true; + const heldCurrencyIsAny: IsAny = false; + const heldDecimalsIsAny: IsAny = false; + const heldReferenceToIsAny: IsAny = false; + + expect([bagCurrencyIsAny, bagDecimalsIsAny]).toEqual([true, true]); + expect([heldCurrencyIsAny, heldDecimalsIsAny, heldReferenceToIsAny]).toEqual([false, false, false]); + }); + + it('accepts exactly the adjudicated set', () => { + // The positive control. Without it the refusals below could be satisfied by + // a type that refuses everything, which would pin nothing. + const accepted: AuthoredColumnOverrides = { + accessorKey: 'amount', + type: 'currency', + format: '$0,0', + options: [{ value: 'tech', label: 'Technology' }], + referenceTo: 'account', + currency: 'EUR', + decimals: 2, + }; + expect(accepted.currency).toBe('EUR'); + }); + + it('refuses an unheld FieldMeta member — and the BAND is what refuses it', () => { + // Checked by `tsc -p tsconfig.test.json`, which this package's `type-check` + // script chains and CI's Type Check job runs. A directive whose error stops + // happening is reported UNUSED (TS2578), so this cannot decay into + // decoration. + // + // ⚠️ The source is a VARIABLE, not a fresh object literal, on purpose. A + // literal is refused by the excess-property check whether or not the band + // exists, so it would pin "something refused this" without pinning what — + // exactly the decay the emit-side twin documents. Assigning a non-fresh + // source reaches the band and nothing else: measured by removing the band + // and watching this directive, and only this one, turn TS2578. + const carriesLabel: { accessorKey: string; label?: string } = { accessorKey: 'amount' }; + // @ts-expect-error objectui#6425 — `label` is in the derived refusal band. + const labelRefused: AuthoredColumnOverrides = carriesLabel; + expect(labelRefused.accessorKey).toBe('amount'); + + const carriesName: { accessorKey: string; name?: string } = { accessorKey: 'amount' }; + // @ts-expect-error objectui#6425 — `name` is in the derived refusal band. + const nameRefused: AuthoredColumnOverrides = carriesName; + expect(nameRefused.accessorKey).toBe('amount'); + }); + + it('the same source is ACCEPTED by the holds without the band', () => { + // The band's counter-control: this is `AuthoredColumnOverrides` minus the + // `?: never` members, and it takes the very source the directives above + // refuse. So the refusal comes from the band, not from the holds, not from + // the required `accessorKey`, and not from weak-type detection. + const carriesLabel: { accessorKey: string; label?: string } = { accessorKey: 'amount' }; + const unbanded: { accessorKey: string; type?: string } & ObjectDataTableColumnHolds = carriesLabel; + expect(unbanded.accessorKey).toBe('amount'); + }); + + it('refuses a key outside the override vocabulary written by hand', () => { + // Needs no band: a hand-written property is subject to the excess-property + // check. Kept because it is the OTHER way a tolerance gets added back, and + // separated from the band pins because the two are different machinery. + // @ts-expect-error objectui#6425 — `scale` is not an adjudicated override key. + const written: AuthoredColumnOverrides = { accessorKey: 'amount', scale: 2 }; + expect(written.accessorKey).toBe('amount'); + }); + + it('refuses a READ of an unadjudicated key — the mechanism `enrich` runs on', () => { + // ⭐ The two pins above act on ASSIGNMENT INTO the keyhole. This one acts on + // the read OUT of it, which is what `enrich` actually does: it never + // assigns a banded source, it reads `authored.currency` and friends. The + // keyhole carries no index signature, so a key nobody adjudicated is + // TS2339 AT THE READ — where the pre-fix code answered `any` twice over, + // through `(col as any)` and through `NormalizedColumn`'s `[key: string]: + // any`. This is the enforcement a future maintainer meets first. + const authored = {} as AuthoredColumnOverrides; + // The positive control, same query shape: an adjudicated key reads clean. + expect(authored.currency).toBeUndefined(); + // @ts-expect-error objectui#6425 — unadjudicated key; the read itself is refused. + const unadjudicated = authored.scale; + expect(unadjudicated).toBeUndefined(); + }); +});