diff --git a/.changeset/7632-shared-record-source-config.md b/.changeset/7632-shared-record-source-config.md new file mode 100644 index 000000000..b455fd38b --- /dev/null +++ b/.changeset/7632-shared-record-source-config.md @@ -0,0 +1,50 @@ +--- +'@object-ui/core': minor +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-gantt': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-map': patch +'@object-ui/plugin-tree': patch +--- + +One shared record-source ladder, five plugins delegate (objectui#7632). + +`@object-ui/core` publishes `resolveRecordSourceConfig(schema)` — the ONE implementation +of the ruled three-rung record source ladder: `data` first, then `staticData` wrapped as +`{ provider: 'value', items }`, then `objectName` folded to `{ provider: 'object' }`, and +`null` when nothing is bound. It is the PRODUCER whose output the objectui#7627 reader +`resolveRecordSourceObjectName` consumes, and it now sits beside it in the same module. + +That ladder is published contract on both faces — `packages/types/src/objectql.ts` and its +zod mirror both ship `.describe()` strings naming `getDataConfig`'s order (objectui#6939, +maintainer ruling 2026-09-02), pinned by `objectql-record-source-refinement-6939.test.ts` — +and it was hand-copied into five plugin components with no gate holding them together. A +change to the ruled order had five edit sites and nothing that noticed a missed one; that +is the AGENTS.md #0.1 drift class. + +**No behaviour changes.** `ObjectCalendar`, `ObjectGantt` and `ObjectTree` now call the +shared reader directly. `ObjectGrid` and `ObjectMap` keep their own bare-array `data` +shorthand as a documented head above the shared call and are otherwise unchanged. +`record-source-config.behaviourNeutrality-7632.test.ts` transcribes all five pre-collapse +bodies verbatim and asserts the post-collapse spelling agrees with each across the whole +input matrix, so a later edit to the shared reader that moves any site turns red. + +**Two divergences were measured rather than assumed, and both are preserved.** + +`ObjectCalendar`'s `'data' in schema && schema.data` guards exist because its parameter is +the union `ObjectGridSchema | CalendarSchema` and `CalendarSchema` declares neither key. +That is a TypeScript narrowing device with no runtime effect — an absent property reads +`undefined`, falsy either way — so the guard could never change which rung is taken. The +shared reader's optional-property parameter accepts the union directly, and the +equivalence is pinned on a fixture that really lacks both keys rather than argued. + +`ObjectGrid` and `ObjectMap` normalize a bare-array `data` to `{ provider: 'value', items }`; +`ObjectCalendar`, `ObjectGantt` and `ObjectTree` do not, and return the array verbatim. That +is a real divergence on off-contract input — `ViewData` is a discriminated union over object +variants, so an array under `data` cannot be published. It is NOT unified here: the shared +rung stays contract-strict and the two sites keep the head locally, the same way objectui#7627 +left the off-contract `{ provider: 'object' }` tails at their sites. Both sides of the fork are +pinned, so neither folding the head in nor deleting it as redundant can happen silently. + +`ObjectTree`'s copy took `schema: any`; it now goes through the shared reader's typed +parameter. Types are erased at runtime, so nothing it resolves moves. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ed74e676a..88bf1dcd6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,14 +112,17 @@ export * from './utils/predicate-fields.js'; // may group by a field it never shows (objectui#7179). export * from './utils/grouping-fields.js'; export * from './utils/normalize-list-view.js'; -// The ONE record-source object-name reader (objectui#7627). Six view plugins -// each spelled "the object this block is bound to — the resolved data config's -// object when it names one, else `objectName`" locally, and had drifted. It is -// deliberately SEPARATE from the `normalizeListViewSchema` gap-fill above: -// that one answers how `objectName` gets POPULATED when absent (#7477 ruling -// B), this one answers which object a block RESOLVES (the objectui#6939 -// three-rung ladder). Merging them would override one standing ruling or the -// other. +// The ONE record-source ladder, both halves. `resolveRecordSourceConfig` +// (objectui#7632) is the PRODUCER — the ruled `data` / `staticData` / +// `objectName` ladder, hand-copied into five view plugins with no gate holding +// them together. `resolveRecordSourceObjectName` (objectui#7627) is the READER +// over its output: six view plugins each spelled "the object this block is +// bound to — the resolved data config's object when it names one, else +// `objectName`" locally, and had drifted. Both are deliberately SEPARATE from +// the `normalizeListViewSchema` gap-fill above: that one answers how +// `objectName` gets POPULATED when absent (#7477 ruling B), these answer which +// object a block RESOLVES (the objectui#6939 three-rung ladder). Merging them +// would override one standing ruling or the other. export * from './utils/record-source.js'; // The single home for the VALUE fallback prettifier (a stored value becomes a // display string when nothing resolves it). `@object-ui/fields` and diff --git a/packages/core/src/utils/__tests__/record-source-config.behaviourNeutrality-7632.test.ts b/packages/core/src/utils/__tests__/record-source-config.behaviourNeutrality-7632.test.ts new file mode 100644 index 000000000..ac944db79 --- /dev/null +++ b/packages/core/src/utils/__tests__/record-source-config.behaviourNeutrality-7632.test.ts @@ -0,0 +1,213 @@ +/** + * objectui#7632 — the shared record-source LADDER is BEHAVIOUR-NEUTRAL at every + * site that delegates to it. + * + * Five view plugins each hand-copied `getDataConfig` — the ruled three-rung + * ladder `data` -> `staticData` -> `objectName`, published on both faces of the + * contract (objectui#6939) and pinned by + * `objectql-record-source-refinement-6939.test.ts`. Collapsing them onto + * {@link resolveRecordSourceConfig} is only legitimate if it changes nothing any + * of them resolves, so this file TRANSCRIBES each site's pre-collapse body + * verbatim from `origin/main` 1ec291c0 and asserts the post-collapse spelling + * agrees with it across the whole input matrix. A future edit to the shared + * reader that moves any site turns this red. + * + * ## The population is NOT four-identical-plus-one + * + * The card measured "four byte-identical modulo the parameter type, plus + * calendar's `in` guards". Re-measuring on 1ec291c0 found THREE shapes, and the + * third one is behavioural: + * + * - `ObjectGantt`, `ObjectTree` — the bare ladder. + * - `ObjectCalendar` — `'data' in schema && schema.data` guards, because its + * parameter is `ObjectGridSchema | CalendarSchema` and `CalendarSchema` + * declares neither `data` nor `staticData`. Type-level only: an absent + * property reads `undefined`, which is falsy either way. `CALENDAR_IN_GUARD` + * below pins that equivalence directly, on a schema that really lacks both + * keys. + * - `ObjectGrid`, `ObjectMap` — a bare-array `data` shorthand normalized to + * `{ provider: 'value', items }`, which the other three do NOT have. This is + * a REAL divergence on off-contract input: those three return the array + * verbatim, so `dataConfig.provider` is `undefined` downstream and the block + * draws nothing. It is preserved, not flattened — the two sites keep the + * head locally and the shared rung stays contract-strict (AGENTS.md #0.1), + * the same way objectui#7627 left the off-contract `{ provider: 'object' }` + * tails at their sites. + */ +import { describe, it, expect } from 'vitest'; +import { resolveRecordSourceConfig } from '../record-source.js'; + +type Schema = { objectName?: string; data?: any; staticData?: any[] }; +type Cfg = { provider?: string; object?: string; items?: unknown[] } | null; + +/** The bare ladder — `ObjectGantt:321` and `ObjectTree:93`, verbatim. */ +const beforeBare = (schema: Schema): Cfg => { + if (schema.data) return schema.data; + if (schema.staticData) return { provider: 'value', items: schema.staticData }; + if (schema.objectName) return { provider: 'object', object: schema.objectName }; + return null; +}; + +/** `ObjectCalendar:118`, verbatim — the `in`-guarded ladder. */ +const beforeCalendar = (schema: Schema): Cfg => { + if ('data' in schema && schema.data) return schema.data; + if ('staticData' in schema && schema.staticData) { + return { provider: 'value', items: schema.staticData }; + } + if (schema.objectName) return { provider: 'object', object: schema.objectName }; + return null; +}; + +/** `ObjectGrid:428`, verbatim — the ladder with the array shorthand inside rung 1. */ +const beforeGrid = (schema: Schema): Cfg => { + if (schema.data) { + if (Array.isArray(schema.data)) return { provider: 'value', items: schema.data }; + return schema.data; + } + if (schema.staticData) return { provider: 'value', items: schema.staticData }; + if (schema.objectName) return { provider: 'object', object: schema.objectName }; + return null; +}; + +/** `ObjectMap:128`, verbatim — same shape as grid, spelled through `authored`. */ +const beforeMap = (schema: Schema): Cfg => { + if (schema.data) { + const authored: unknown = schema.data; + if (Array.isArray(authored)) return { provider: 'value', items: authored }; + return schema.data; + } + if (schema.staticData) return { provider: 'value', items: schema.staticData }; + if (schema.objectName) return { provider: 'object', object: schema.objectName }; + return null; +}; + +/** The post-collapse spelling now compiled into grid and map: head, then shared rung. */ +const afterArrayHead = (schema: Schema): Cfg => { + if (Array.isArray(schema.data)) return { provider: 'value', items: schema.data }; + return resolveRecordSourceConfig(schema); +}; + +const SITES: { id: string; before: (s: Schema) => Cfg; after: (s: Schema) => Cfg }[] = [ + { id: 'ObjectGantt:321', before: beforeBare, after: resolveRecordSourceConfig }, + { id: 'ObjectTree:93', before: beforeBare, after: resolveRecordSourceConfig }, + { id: 'ObjectCalendar:118', before: beforeCalendar, after: resolveRecordSourceConfig }, + { id: 'ObjectGrid:428', before: beforeGrid, after: afterArrayHead }, + { id: 'ObjectMap:128', before: beforeMap, after: afterArrayHead }, +]; + +/** + * Contract-valid by construction: `ViewDataSchema` is a + * `z.discriminatedUnion('provider', [...])` over object variants whose `object` + * member declares `object` REQUIRED. + */ +const CONTRACT_VALID: [string, Schema][] = [ + ['both-bindings', { objectName: 'Y', data: { provider: 'object', object: 'X' } }], + ['data-only', { data: { provider: 'object', object: 'X' } }], + ['objectName-only', { objectName: 'Y' }], + ['empty-objectName', { objectName: '', data: { provider: 'object', object: 'X' } }], + ['api-provider', { objectName: 'Y', data: { provider: 'api', read: { url: '/x' } } }], + ['api-provider-no-name', { data: { provider: 'api', read: { url: '/x' } } }], + ['value-provider', { objectName: 'Y', data: { provider: 'value', items: [1] } }], + ['value-provider-empty-items', { objectName: 'Y', data: { provider: 'value', items: [] } }], + ['staticData+objectName', { objectName: 'Y', staticData: [1] }], + ['staticData-only', { staticData: [1] }], + ['staticData-empty', { objectName: 'Y', staticData: [] }], + ['data-object-empty-string', { objectName: 'Y', data: { provider: 'object', object: '' } }], + ['empty-objectName-only', { objectName: '' }], + ['nothing-bound', {}], + ['all-three', { objectName: 'Y', staticData: [1], data: { provider: 'object', object: 'X' } }], +]; + +describe('resolveRecordSourceConfig — behaviour neutrality on contract-valid input (objectui#7632)', () => { + for (const [name, schema] of CONTRACT_VALID) { + for (const site of SITES) { + it(`${site.id} is unchanged for "${name}"`, () => { + expect(site.after(schema)).toEqual(site.before(schema)); + }); + } + } + + it('the matrix is a LIT control: every rung of the ladder is actually exercised', () => { + const reached = new Set( + CONTRACT_VALID.map(([, s]) => { + const cfg = resolveRecordSourceConfig(s); + if (cfg === null) return 'null'; + if (s.data) return 'data'; + if (s.staticData) return 'staticData'; + return 'objectName'; + }), + ); + // A matrix that never reaches a rung cannot prove that rung neutral. + expect([...reached].sort()).toEqual(['data', 'null', 'objectName', 'staticData']); + }); +}); + +/** + * The OFF-CONTRACT fork: a bare array under `data`. `ViewData` admits no array + * variant, so this cannot be published — but grid and map normalize it anyway + * and the other three do not. The collapse deliberately does NOT unify them; + * these cases pin BOTH sides of the fork, so neither a "fold the head into the + * shared reader" simplification nor a "drop the redundant head" cleanup can + * happen silently. + */ +const ARRAY_SHORTHAND: [string, Schema][] = [ + ['array-shorthand', { objectName: 'Y', data: [1, 2] }], + ['array-shorthand-empty', { objectName: 'Y', data: [] }], + ['array-shorthand-no-name', { data: [{ id: 1 }] }], + ['array-shorthand+staticData', { staticData: [9], data: [1] }], +]; + +describe('the off-contract bare-array `data` shorthand (objectui#7632)', () => { + for (const [name, schema] of ARRAY_SHORTHAND) { + it(`grid and map still normalize it for "${name}"`, () => { + expect(afterArrayHead(schema)).toEqual({ provider: 'value', items: schema.data }); + expect(afterArrayHead(schema)).toEqual(beforeGrid(schema)); + expect(afterArrayHead(schema)).toEqual(beforeMap(schema)); + }); + + it(`gantt, tree and calendar still return it verbatim for "${name}"`, () => { + expect(resolveRecordSourceConfig(schema)).toBe(schema.data); + expect(resolveRecordSourceConfig(schema)).toEqual(beforeBare(schema)); + expect(resolveRecordSourceConfig(schema)).toEqual(beforeCalendar(schema)); + }); + } + + it('an empty array is truthy, which is why hoisting the head is neutral', () => { + // The whole neutrality of the hoist rests on this: `if (schema.data)` could + // never let an array fall through to rung 2 or 3, so checking the array + // FIRST cannot change which rung is taken. + expect(Boolean([])).toBe(true); + expect(beforeGrid({ objectName: 'Y', staticData: [9], data: [] })).toEqual({ + provider: 'value', + items: [], + }); + }); +}); + +/** + * `ObjectCalendar`'s `in` guards, on the schema shape that motivated them: + * `CalendarSchema` declares neither `data` nor `staticData`, so the guard is a + * TypeScript narrowing device with no runtime effect. Pinned directly rather + * than argued, because "the guard is load-bearing" was the card's claim. + */ +describe('the `in`-guard divergence is type-level, not behavioural (objectui#7632)', () => { + const CALENDAR_IN_GUARD: [string, Schema][] = [ + ['no data/staticData keys at all', { objectName: 'Y' }], + ['keys present but undefined', { objectName: 'Y', data: undefined, staticData: undefined }], + ['keys absent, nothing bound', {}], + ['staticData only, no data key', { staticData: [1] }], + ]; + + for (const [name, schema] of CALENDAR_IN_GUARD) { + it(`the guarded and unguarded ladders agree for "${name}"`, () => { + expect(resolveRecordSourceConfig(schema)).toEqual(beforeCalendar(schema)); + expect(beforeCalendar(schema)).toEqual(beforeBare(schema)); + }); + } + + it('is a LIT control: the fixtures really do lack the keys', () => { + expect('data' in CALENDAR_IN_GUARD[0][1]).toBe(false); + expect('staticData' in CALENDAR_IN_GUARD[0][1]).toBe(false); + expect('data' in CALENDAR_IN_GUARD[1][1]).toBe(true); + }); +}); diff --git a/packages/core/src/utils/record-source.ts b/packages/core/src/utils/record-source.ts index 3df99dd38..e6baa3f66 100644 --- a/packages/core/src/utils/record-source.ts +++ b/packages/core/src/utils/record-source.ts @@ -1,11 +1,15 @@ /** - * ObjectUI — the shared record-source object-name reader + * ObjectUI — the shared record-source readers: the ruled three-rung ladder + * (`resolveRecordSourceConfig`) and the object-name it resolves to + * (`resolveRecordSourceObjectName`) * 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. */ +import type { ViewData } from '@object-ui/types'; + /** * The object a view block is bound to, resolved ONCE for the whole renderer * (objectui#7627). @@ -74,3 +78,93 @@ export function resolveRecordSourceObjectName( ): string | undefined { return dataConfig?.provider === 'object' ? dataConfig.object : schema?.objectName; } + +/** + * The block's record source, resolved from the ruled three-rung ladder + * (objectui#7632). + * + * ## The ruled contract this is the ONE implementation of + * + * `data`, then `staticData`, then `objectName` — declared on both faces of the + * published contract and pinned by + * `objectql-record-source-refinement-6939.test.ts`: + * + * 1. **`data`** — *"Data source configuration. Read FIRST by `getDataConfig`"*. + * Returned verbatim, so an `api`/`value`/`object` provider config reaches + * the caller exactly as the author wrote it. + * 2. **`staticData`** — *"Inline records — read SECOND by `getDataConfig`, + * wrapped into a `{ provider: value }` config"*. + * 3. **`objectName`** — *"the THIRD record source `getDataConfig` resolves, + * after `data` and `staticData`"*, folded to `{ provider: 'object' }`. + * + * `null` when none of the three is present — the same "nothing is drawn" signal + * the zod `requireRecordSource` refinement is written against. + * + * This is the PRODUCER whose output {@link resolveRecordSourceObjectName} (the + * objectui#7627 reader) consumes; that function's docblock describes the same + * ladder from the consuming end. Five plugins — calendar, gantt, grid, map and + * tree — each carried a hand-copy of this ladder with no gate holding them + * together, which is the AGENTS.md #0.1 drift class: a change to the ruled + * order had five edit sites and nothing noticed a missed one. + * + * ## No lenient rung was added (AGENTS.md #0.1) + * + * Two things the hand-copies did are deliberately NOT folded in here: + * + * - **The bare-array `data` shorthand.** `ObjectGrid` and `ObjectMap` normalize + * `data: [...]` to `{ provider: 'value', items }`; calendar, gantt and tree + * do not, and return the array verbatim. That shorthand is off-contract — + * `ViewData` is a `z.discriminatedUnion('provider', [...])` over OBJECT + * variants, so an array under `data` cannot be published — and the two sites + * that accept it keep it as their own documented head, exactly as the + * objectui#7627 collapse left `ObjectGrid`'s and `ObjectTree`'s off-contract + * `{ provider: 'object' }` tails at the site. Hoisting their check is + * behaviour-neutral because an array is ALWAYS truthy, `[]` included, so it + * could never have reached rung 2 or 3. + * - **Null tolerance.** All five copies dereference `schema` unguarded and + * would throw on `null`; no site passes one, so no `?.` was added. + * + * `ObjectCalendar`'s copy guarded with `'data' in schema && schema.data` + * because its parameter is the union `ObjectGridSchema | CalendarSchema` and + * `CalendarSchema` declares neither `data` nor `staticData`. That `in` test is + * a TYPESCRIPT narrowing device, not a behavioural one: when the property is + * absent the read yields `undefined`, which is falsy either way, so the guard + * can never change which rung is taken. The optional-property parameter below + * accepts that union directly, which is why the guard is gone rather than + * flattened away. + * + * @param schema - The block's schema; only `data`, `staticData` and + * `objectName` are read. + * @returns The resolved data config, or `null` when nothing is bound. + * + * @example + * ```ts + * const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]); + * const objectName = resolveRecordSourceObjectName(schema, dataConfig); + * ``` + */ +export function resolveRecordSourceConfig(schema: { + objectName?: string; + data?: ViewData; + staticData?: any[]; +}): ViewData | null { + if (schema.data) { + return schema.data; + } + + if (schema.staticData) { + return { + provider: 'value', + items: schema.staticData, + }; + } + + if (schema.objectName) { + return { + provider: 'object', + object: schema.objectName, + }; + } + + return null; +} diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 2db6268f8..97f0707e4 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -23,7 +23,7 @@ */ import React, { useEffect, useState, useCallback, useMemo } from 'react'; -import type { ObjectGridSchema, DataSource, ViewData, CalendarConfig } from '@object-ui/types'; +import type { ObjectGridSchema, DataSource, CalendarConfig } from '@object-ui/types'; import { CalendarView, type CalendarViewEvent } from './CalendarView'; import { usePullToRefresh } from '@object-ui/mobile'; import { @@ -61,6 +61,7 @@ import { convertSortToQueryParams, getRecordDisplayName, createFieldColorResolver, + resolveRecordSourceConfig, resolveRecordSourceObjectName, } from '@object-ui/core'; @@ -112,31 +113,6 @@ export interface ObjectCalendarComponentProps { locale?: string; } -/** - * Helper to get data configuration from schema - */ -function getDataConfig(schema: ObjectGridSchema | CalendarSchema): ViewData | null { - if ('data' in schema && schema.data) { - return schema.data; - } - - if ('staticData' in schema && schema.staticData) { - return { - provider: 'value', - items: schema.staticData, - }; - } - - if (schema.objectName) { - return { - provider: 'object', - object: schema.objectName, - }; - } - - return null; -} - /** * Helper to get calendar configuration from schema */ @@ -263,7 +239,7 @@ export const ObjectCalendar: React.FC = ({ enabled: !!dataSource && !!schema.objectName, }); - const dataConfig = useMemo(() => getDataConfig(schema), [ + const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [ (schema as any).data, (schema as any).staticData, schema.objectName, @@ -281,7 +257,7 @@ export const ObjectCalendar: React.FC = ({ * The record-fetch effect below used to key on `dataConfig` itself — the * whole memoised object identity. `useMemo` carries no semantic * guarantee (React may discard its cache and recompute), and - * `getDataConfig(schema)` builds a fresh wrapper object on every call + * `resolveRecordSourceConfig(schema)` builds a fresh wrapper object on every call * even when its own deps haven't changed, so a discard alone was enough * to re-run the effect and refetch. `dataProvider` and `dataItems` are * the remaining primitive fields that effect reads off `dataConfig` — diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 5c250c2eb..e01d65ff9 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -24,7 +24,7 @@ import React, { useContext, useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { toast } from 'sonner'; -import type { ObjectGanttSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types'; +import type { ObjectGanttSchema, DataSource, GanttConfig } from '@object-ui/types'; import { GanttConfigSchema } from '@objectstack/spec/ui'; // Aliased on import, following PR #4169's convention: this repo has its OWN // `resolveI18nLabel` over a DIFFERENT vocabulary (the KEYED `{ key, defaultValue }` @@ -61,6 +61,7 @@ import { getRecordDisplayName, resolveDataSource, createFieldColorResolver, + resolveRecordSourceConfig, resolveRecordSourceObjectName, } from '@object-ui/core'; import { @@ -315,31 +316,6 @@ export interface ObjectGanttProps { ) => boolean | Promise; } -/** - * Helper to get data configuration from schema - */ -function getDataConfig(schema: ObjectGanttSchema): ViewData | null { - if (schema.data) { - return schema.data; - } - - if (schema.staticData) { - return { - provider: 'value', - items: schema.staticData, - }; - } - - if (schema.objectName) { - return { - provider: 'object', - object: schema.objectName, - }; - } - - return null; -} - /** * Pull a human-readable message out of a failed write. ApiDataSource embeds * the raw response body at the end of its Error message @@ -614,7 +590,7 @@ export const ObjectGantt: React.FC = ({ }); }, [t]); - const rawDataConfig = getDataConfig(schema); + const rawDataConfig = resolveRecordSourceConfig(schema); // Memoize dataConfig using deep comparison to prevent infinite loops const dataConfig = useMemo(() => { return rawDataConfig; diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index c61dbfe25..7d7213663 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -36,7 +36,7 @@ import { RefreshIndicator, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, isObjectInlineEditable, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceObjectName } from '@object-ui/core'; +import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, isObjectInlineEditable, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react'; import { useRowColor } from './useRowColor'; @@ -422,41 +422,35 @@ export interface ObjectGridComponentProps extends ObjectGridExternalPaginationPr } /** - * Helper to get data configuration from schema - * Handles both new ViewData format and legacy inline data + * Helper to get data configuration from schema. + * + * The ruled three-rung ladder itself (`data`, then `staticData`, then + * `objectName`) is `resolveRecordSourceConfig` in `@object-ui/core` — ONE + * implementation of a contract published on both faces (objectui#6939), which + * this file used to hand-copy (objectui#7632). + * + * What stays here is the head above it: the bare-array `data` shorthand. It is + * OFF-CONTRACT — `ViewData` is a `z.discriminatedUnion('provider', [...])` over + * object variants, so an array under `data` cannot be published — and only this + * block and `ObjectMap` normalize it inside their ladder; calendar, gantt and + * tree return the array verbatim. So it is kept at the site rather than folded + * into the shared rung, exactly as the objectui#7627 collapse left this file's + * off-contract `{ provider: 'object' }` tail at the site (AGENTS.md #0.1). + * + * Hoisting the check above the shared call is behaviour-neutral: an array is + * ALWAYS truthy, `[]` included, so `if (schema.data)` could never have let one + * fall through to `staticData` or `objectName`. */ function getDataConfig(schema: ObjectGridSchema): ViewData | null { - // New format: explicit data configuration - if (schema.data) { - // Check if data is an array (shorthand format) or already a ViewData object - if (Array.isArray(schema.data)) { - // Convert array shorthand to proper ViewData format - return { - provider: 'value', - items: schema.data, - }; - } - // Already in ViewData format - return schema.data; - } - - // Legacy format: staticData field - if (schema.staticData) { + // Array shorthand -> the declared `value` provider (see docblock above). + if (Array.isArray(schema.data)) { return { provider: 'value', - items: schema.staticData, + items: schema.data, }; } - - // Default: use object provider with objectName - if (schema.objectName) { - return { - provider: 'object', - object: schema.objectName, - }; - } - - return null; + + return resolveRecordSourceConfig(schema); } /** diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 373f3d570..2ba40d38f 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -35,6 +35,7 @@ import { buildExpandFields, convertSortToQueryParams, getRecordDisplayName, + resolveRecordSourceConfig, resolveRecordSourceObjectName, } from '@object-ui/core'; import MapGL, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre'; @@ -123,54 +124,54 @@ const FLAT_MAP_CONFIG_KEYS = (Object.keys(ObjectMapConfigSchema.shape) as (keyof ); /** - * Helper to get data configuration from schema + * Helper to get data configuration from schema. + * + * The ruled three-rung ladder itself (`data`, then `staticData`, then + * `objectName`) is `resolveRecordSourceConfig` in `@object-ui/core` — ONE + * implementation of a contract published on both faces (objectui#6939), which + * this file used to hand-copy (objectui#7632). What stays here is the head + * above it, unchanged: the array shorthand. */ function getDataConfig(schema: ObjectMapSchema): ViewData | null { - if (schema.data) { - // Array shorthand -> the declared `value` provider. - // - // `ObjectMapSchema.data` is declared `ViewData`, and `ViewData` resolves to - // @objectstack/spec's `ViewDataSchema` — a `z.discriminatedUnion('provider', - // [...])` over OBJECT variants, whose `value` member additionally declares - // `aliases: { data: 'items', rows: 'items', records: 'items' }`. So a bare - // array under `data` is off-contract twice over, and `staticData` is this - // schema's declared door for inline rows. - // - // It is normalized rather than rejected because the array shorthand is a - // deliberate, commented convention across this block family — ObjectGrid's - // own `getDataConfig` ("Check if data is an array (shorthand format)"), - // ListView ("Also support schema.data as a plain array (shorthand for value - // provider)"), ObjectTree, ObjectChart, ObjectDataTable and - // calendar-view-renderer all accept it. An author (or a generator) that - // learned the shorthand from `object-grid` writes it for `object-map` next; - // dropping it HERE alone would leave the one block in the family that - // answers the shorthand with a silently empty map. - // - // Normalizing at this single boundary — instead of a second short-circuit - // inside the fetch effect below — is what lets that effect read `dataConfig` - // only, which is already one of its dependencies (objectui#5305). - const authored: unknown = schema.data; - if (Array.isArray(authored)) { - return { provider: 'value', items: authored }; - } - return schema.data; - } - - if (schema.staticData) { - return { - provider: 'value', - items: schema.staticData, - }; - } - - if (schema.objectName) { - return { - provider: 'object', - object: schema.objectName, - }; + // Array shorthand -> the declared `value` provider. + // + // `ObjectMapSchema.data` is declared `ViewData`, and `ViewData` resolves to + // @objectstack/spec's `ViewDataSchema` — a `z.discriminatedUnion('provider', + // [...])` over OBJECT variants, whose `value` member additionally declares + // `aliases: { data: 'items', rows: 'items', records: 'items' }`. So a bare + // array under `data` is off-contract twice over, and `staticData` is this + // schema's declared door for inline rows. + // + // It is normalized rather than rejected because the array shorthand is a + // deliberate, commented convention across this block family — ObjectGrid's + // own `getDataConfig` ("Check if data is an array (shorthand format)"), + // ListView ("Also support schema.data as a plain array (shorthand for value + // provider)"), ObjectChart, ObjectDataTable and calendar-view-renderer all + // accept it. An author (or a generator) that learned the shorthand from + // `object-grid` writes it for `object-map` next; dropping it HERE alone would + // leave the one block in the family that answers the shorthand with a + // silently empty map. + // + // ObjectTree was named in this list until objectui#7632 measured it: it has + // no `Array.isArray(schema.data)` anywhere, so it answers the shorthand with + // a silently empty tree today. ObjectGantt and ObjectCalendar do not accept + // it either. That divergence is NOT resolved here — it is the reason this + // head stays at the site instead of being folded into the shared rung + // (AGENTS.md #0.1), and it is filed separately rather than fixed in passing. + // + // Normalizing at this single boundary — instead of a second short-circuit + // inside the fetch effect below — is what lets that effect read `dataConfig` + // only, which is already one of its dependencies (objectui#5305). + // + // Hoisting this check above the shared call is behaviour-neutral: an array is + // ALWAYS truthy, `[]` included, so the `if (schema.data)` that used to wrap + // it could never have let one fall through to `staticData` or `objectName`. + const authored: unknown = schema.data; + if (Array.isArray(authored)) { + return { provider: 'value', items: authored }; } - - return null; + + return resolveRecordSourceConfig(schema); } const isDev = (): boolean => diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 021f457d8..f41d74b2e 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -20,7 +20,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import type { DataSource, ViewData } from '@object-ui/types'; +import type { DataSource } from '@object-ui/types'; import { useNavigationOverlay, useSafeFieldLabel, @@ -38,6 +38,7 @@ import { isExpandableFieldType, getRecordDisplayName, humanizeLabel, + resolveRecordSourceConfig, resolveRecordSourceObjectName, } from '@object-ui/core'; import { ChevronRight, ChevronDown } from 'lucide-react'; @@ -90,13 +91,6 @@ interface TreeNode { children: TreeNode[]; } -function getDataConfig(schema: any): ViewData | null { - if (schema.data) return schema.data; - if (schema.staticData) return { provider: 'value', items: schema.staticData }; - if (schema.objectName) return { provider: 'object', object: schema.objectName }; - return null; -} - /** * Normalize a field entry to its string key. Hosts like ListView pass columns * as field *objects* (`{ name | fieldName | field, label, … }`), not bare @@ -362,7 +356,7 @@ export const ObjectTree: React.FC = ({ }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const dataConfig = useMemo(() => getDataConfig(schema), [schema]); + const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]); /** * The object THIS render is bound to, as a plain string — so the resolution @@ -425,7 +419,7 @@ export const ObjectTree: React.FC = ({ * The record-fetch effect below used to key on `dataConfig` itself — the * whole memoised object identity. `useMemo` carries no semantic guarantee: * React is permitted to discard its cache and recompute, and - * `getDataConfig(schema)` builds a fresh `{ provider, object }` / + * `resolveRecordSourceConfig(schema)` builds a fresh `{ provider, object }` / * `{ provider, items }` wrapper object on every call even when `schema` * hasn't changed. So a discard (not just a `schema` change) was enough to * re-run the effect and refetch, with nothing about the bound object