Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/7627-shared-record-source-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@object-ui/core': minor
'@object-ui/plugin-calendar': minor
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-grid': minor
'@object-ui/plugin-map': minor
'@object-ui/plugin-tree': minor
'@object-ui/react': minor
---

`@object-ui/core` publishes `resolveRecordSourceObjectName`, the ONE reader for "which
object is this block bound to" (objectui#7627).

Six view plugins each spelled that resolution locally — `ObjectCalendar` twice,
`ObjectGantt`, `ObjectTree` twice, `ObjectMap`, `ObjectGrid` — and had drifted: three
wrote `?? schema.objectName`, one `|| ''`, one `: undefined`, one an `'object' in
dataConfig` test. They now delegate to one function that states the published
objectui#6939 record-source ladder (`data`, then `staticData`, then `objectName`) once.

**No behaviour changes.** Each site's pre-collapse expression is transcribed verbatim
into `record-source.behaviourNeutrality-7627.test.ts` and asserted equal to its
post-collapse spelling across the whole contract-valid input matrix — both bindings
present, data only, `objectName` only, empty `objectName`, empty `data.object`, the
`api` / `value` / `staticData` / array-shorthand providers, and nothing bound.

**Two questions stay two questions.** `normalizeListViewSchema`'s gap-fill (#7477,
ruling B of PR #7628) is untouched and is NOT re-pointed at the new reader: it answers
how `objectName` gets POPULATED when absent, where an already-present `objectName` wins.
The new reader answers which object a block RESOLVES, where the `data` block wins — the
order declared on both published faces in `@object-ui/types` and pinned by
`objectql-record-source-refinement-6939.test.ts`. Merging them would silently override
one standing ruling or the other.

**`ObjectGantt`'s `persistLayoutKey` is deliberately excluded** and keeps its inverted
order, with an in-place comment saying why: its receiver is a localStorage key
(`gantt-layout:KEY:filters`), not a record source, so re-pointing it would orphan every
saved layout and filter-chip set of a view carrying both bindings. Two more sites the
finding listed are not object-name readers at all and were struck: `ObjectGantt`'s
refresh-handler predicate (`object` OR `api`) and `plugin-dashboard`'s `isObjectProvider`
type-guard over a widget's `data`.

`useSettledSchema`'s doc comment stops prescribing the hand-written ladder at all four
lines that taught it, so the copies cannot re-seed from the hook that replaced them.
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ 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.
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
// `@object-ui/plugin-charts` each carried a byte-identical private copy;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* objectui#7627 — the shared record-source reader is BEHAVIOUR-NEUTRAL at every
* site that delegates to it.
*
* Six view plugins each spelled "the object this block is bound to" locally and
* had drifted apart. Collapsing them onto {@link resolveRecordSourceObjectName}
* is only legitimate if it changes nothing any of them resolves, so this file
* TRANSCRIBES each site's pre-collapse expression verbatim and asserts the
* post-collapse spelling agrees with it across the whole contract-valid input
* matrix. A future edit to the reader that moves any site turns this red.
*
* The matrix is contract-valid by construction: `ViewDataSchema`'s `object`
* provider is a `strictObject` carrying exactly `{ provider, object }` with
* `object` REQUIRED, so `{ provider: 'object' }` without an `object` cannot be
* published. The two sites that used to coerce that off-contract shape back to
* `objectName` — `ObjectGrid`'s `'object' in dataConfig` test and `ObjectTree`'s
* header tail — keep their own tail at the site, so their behaviour is pinned
* here too, on both faces of the fork.
*/
import { describe, it, expect } from 'vitest';
import { resolveRecordSourceObjectName } from '../record-source.js';

type Schema = { objectName?: string; data?: unknown; staticData?: unknown[] };
type Cfg = { provider?: string; object?: string; items?: unknown[] } | null;

// --- getDataConfig, transcribed from the plugins (objectui#7632 tracks the
// --- duplication of the PRODUCER; this is a copy for measurement only).
const getDataConfig = (schema: Schema): Cfg => {
if (schema.data) {
if (Array.isArray(schema.data)) return { provider: 'value', items: schema.data };
return schema.data as Cfg;
}
if (schema.staticData) return { provider: 'value', items: schema.staticData };
if (schema.objectName) return { provider: 'object', object: schema.objectName };
return null;
};

/** Every read site, `before` transcribed verbatim from `origin/main` 11edab88. */
const SITES: {
id: string;
before: (s: Schema, c: Cfg) => string | undefined;
after: (s: Schema, c: Cfg) => string | undefined;
}[] = [
{
id: 'ObjectCalendar:309 schemaObjectName',
before: (s, c) => (c?.provider === 'object' ? c.object : s.objectName),
after: (s, c) => resolveRecordSourceObjectName(s, c),
},
{
id: 'ObjectCalendar:969 overlay objectName',
before: (s, c) => (c?.provider === 'object' ? c.object : s.objectName),
after: (s, c) => resolveRecordSourceObjectName(s, c),
},
{
id: 'ObjectGantt:661 resource',
// `??` binds tighter than `?:`, so the pre-collapse line parses as
// `cond ? c.object : (s.objectName ?? '')` — the empty-string floor applied
// to the FALLBACK arm only.
before: (s, c) => (c?.provider === 'object' ? c.object : s.objectName ?? ''),
after: (s, c) => resolveRecordSourceObjectName(s, c) ?? '',
},
{
id: 'ObjectTree:373 schemaKey',
before: (s, c) => (c?.provider === 'object' ? c.object : s.objectName) ?? '',
after: (s, c) => resolveRecordSourceObjectName(s, c) ?? '',
},
{
id: 'ObjectTree:567 headerObjectName',
before: (s, c) => (c?.provider === 'object' ? c.object : undefined) ?? s.objectName,
after: (s, c) => resolveRecordSourceObjectName(s, c) ?? s.objectName,
},
{
id: 'ObjectMap:764 metadata objectName',
before: (s, c) => {
const dataProvider = c?.provider;
const dataObjectName = c?.provider === 'object' ? c.object : undefined;
return dataProvider === 'object' ? dataObjectName : s.objectName;
},
after: (s, c) => resolveRecordSourceObjectName(s, c),
},
{
id: 'ObjectGrid:1206 objectName',
before: (s, c) => (c?.provider === 'object' && c && 'object' in c ? c.object : s.objectName),
after: (s, c) => resolveRecordSourceObjectName(s, c) ?? s.objectName,
},
];

/** The five shapes the dispatch named, plus every other one the ladder reaches. */
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] } }],
['staticData+objectName', { objectName: 'Y', staticData: [1] }],
['staticData-only', { staticData: [1] }],
['array-shorthand', { objectName: 'Y', data: [1, 2] }],
['data-object-empty-string', { objectName: 'Y', data: { provider: 'object', object: '' } }],
['empty-objectName-only', { objectName: '' }],
['nothing-bound', {}],
];

describe('resolveRecordSourceObjectName — behaviour neutrality (objectui#7627)', () => {
for (const [name, schema] of CONTRACT_VALID) {
for (const site of SITES) {
it(`${site.id} is unchanged for "${name}"`, () => {
const cfg = getDataConfig(schema);
expect(site.after(schema, cfg)).toEqual(site.before(schema, cfg));
});
}
}
});

/**
* The OFF-CONTRACT fork. `ViewDataSchema`'s `object` provider declares `object`
* REQUIRED, so `data: { provider: 'object' }` without one cannot be published —
* but two sites used to coerce it back to `objectName` anyway, and one of them
* (`ObjectGrid`) gates permission verdicts with the result. The shared reader
* deliberately does NOT carry that coercion (AGENTS.md #0.1); the two sites keep
* it as their own tail. These cases pin the tails: delete one and this goes red,
* which is the only thing standing between them and a future "redundant `??`"
* cleanup.
*/
const OFF_CONTRACT_TAIL_SITES = ['ObjectTree:567 headerObjectName', 'ObjectGrid:1206 objectName'];

describe('the off-contract `{ provider: "object" }` tail (objectui#7627)', () => {
const offContract: Cfg = { provider: 'object' };

it('is not answered by the shared reader — no lenient rung was added', () => {
expect(resolveRecordSourceObjectName({ objectName: 'accounts' }, offContract)).toBeUndefined();
});

for (const id of OFF_CONTRACT_TAIL_SITES) {
const site = SITES.find((x) => x.id === id)!;
it(`${id} keeps its own tail, so the shape still resolves to \`objectName\``, () => {
const schema = { objectName: 'accounts' };
expect(site.before(schema, offContract)).toBe('accounts');
expect(site.after(schema, offContract)).toBe('accounts');
});
}

it('the sites that never carried the tail still resolve nothing, exactly as before', () => {
const schema = { objectName: 'accounts' };
for (const id of ['ObjectCalendar:309 schemaObjectName', 'ObjectMap:764 metadata objectName']) {
const site = SITES.find((x) => x.id === id)!;
expect(site.after(schema, offContract)).toEqual(site.before(schema, offContract));
expect(site.after(schema, offContract)).toBeUndefined();
}
});
});

describe('resolveRecordSourceObjectName — the ladder it carries (objectui#6939)', () => {
// Bound rather than inlined: callers hand this reader a whole `ViewData`
// (whose `value` member carries `items`), never a fresh literal narrowed to
// the two keys the reader reads.
const valueConfig: Cfg = { provider: 'value', items: [] };

it('reads the resolved record source FIRST when it names an object', () => {
expect(
resolveRecordSourceObjectName(
{ objectName: 'accounts' },
{ provider: 'object', object: 'contacts' },
),
).toBe('contacts');
});

it('falls back to `objectName` when the resolved source names no object', () => {
expect(
resolveRecordSourceObjectName({ objectName: 'accounts' }, valueConfig),
).toBe('accounts');
expect(resolveRecordSourceObjectName({ objectName: 'accounts' }, { provider: 'api' })).toBe(
'accounts',
);
expect(resolveRecordSourceObjectName({ objectName: 'accounts' }, null)).toBe('accounts');
});

it('resolves undefined when nothing names an object', () => {
expect(resolveRecordSourceObjectName({}, null)).toBeUndefined();
expect(resolveRecordSourceObjectName({}, valueConfig)).toBeUndefined();
});

it('passes an empty `object` through — `ViewDataSchema` declares `z.string()`, not a non-empty one, so coercing it here would invent a rung', () => {
expect(
resolveRecordSourceObjectName({ objectName: 'accounts' }, { provider: 'object', object: '' }),
).toBe('');
});

it('adds NO lenient rung for an off-contract `{ provider: "object" }` with no `object` (AGENTS.md #0.1)', () => {
expect(
resolveRecordSourceObjectName({ objectName: 'accounts' }, { provider: 'object' }),
).toBeUndefined();
});

it('is not the `normalizeListViewSchema` gap-fill: an `objectName` present alongside a data block does NOT win here', () => {
// Ruling B (#7628) governs how `objectName` is POPULATED when absent; this
// reader governs which object a block RESOLVES. Merging them would override
// one standing ruling or the other — the whole point of objectui#7627.
expect(
resolveRecordSourceObjectName(
{ objectName: 'ruling_b_value' },
{ provider: 'object', object: 'resolved_source' },
),
).toBe('resolved_source');
});
});
76 changes: 76 additions & 0 deletions packages/core/src/utils/record-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* ObjectUI — the shared record-source object-name reader
* 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 object a view block is bound to, resolved ONCE for the whole renderer
* (objectui#7627).
*
* ## The question this answers, and the one it does not
*
* There are TWO separately-ruled precedence questions about `objectName`, and
* they only look like one question when a single reader is asked to answer
* both:
*
* 1. **Which object does this block RESOLVE, at render time, when it carries
* more than one binding?** — the published three-rung record-source ladder
* (`data`, then `staticData`, then `objectName`), declared on both faces of
* the contract (`ObjectMapSchema.objectName` / `ObjectGanttSchema.objectName`
* in `@object-ui/types`, and the `.describe` on their zod twins:
* *"objectName — the THIRD record source `getDataConfig` resolves, after
* `data` and `staticData`"*), ruled objectui#6939 (2026-09-02) and pinned by
* `objectql-record-source-refinement-6939.test.ts`. **That is this
* function.**
* 2. **How does `objectName` get POPULATED when it is absent?** — the
* authoring-time gap-fill in `normalizeListViewSchema` (objectui#7477,
* ruling B of PR #7628), where an `objectName` already on the schema WINS
* and the `data` block only fills a gap: *"it can never re-point a binding
* that already resolves."*
*
* The two are NOT merged and neither is re-pointed at the other. Merging them
* would override a standing maintainer ruling in whichever direction the merged
* reader happened to pick: at the sites below the binding that already resolves
* IS `data.object`, so ruling B's own words argue for keeping rung 1 as it is.
*
* ## Why `staticData` does not appear here
*
* The ladder's second rung wraps inline rows as `{ provider: 'value', items }`,
* which names no object at all. So for the object-NAME question the three-rung
* ladder reduces to two rungs — the resolved config's object when it names one,
* else the schema's own `objectName`, which is what a `value`/`api`-backed block
* still needs for metadata reads, i18n field labels and permission verdicts.
* Callers pass the ALREADY-RESOLVED config (their `getDataConfig(schema)`
* output), so rung ordering is settled before this function is reached.
*
* ## No lenient rung was added (AGENTS.md #0.1)
*
* `ViewDataSchema`'s `object` provider is a `strictObject` carrying exactly
* `{ provider, object }` with `object` REQUIRED, so `{ provider: 'object' }`
* without an `object` is off-contract. This reader does not coerce that shape
* back to `objectName`; the two call sites that used to (`ObjectGrid`'s
* `'object' in dataConfig` test and `ObjectTree`'s header `?? schema.objectName`
* tail) keep their own tail at the site, so the collapse changes nothing they
* resolve today while the shared rung stays contract-strict.
*
* @param schema - The block's schema; only `objectName` is read.
* @param dataConfig - The RESOLVED data config — the caller's own
* `getDataConfig(schema)` output, `null` when nothing is bound.
* @returns The bound object's name, or `undefined` when neither the resolved
* config nor the schema names one.
*
* @example
* ```ts
* const dataConfig = useMemo(() => getDataConfig(schema), [schema]);
* const objectName = resolveRecordSourceObjectName(schema, dataConfig);
* ```
*/
export function resolveRecordSourceObjectName(
schema: { objectName?: string } | null | undefined,
dataConfig: { provider?: string; object?: string } | null | undefined,
): string | undefined {
return dataConfig?.provider === 'object' ? dataConfig.object : schema?.objectName;
}
6 changes: 3 additions & 3 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
convertSortToQueryParams,
getRecordDisplayName,
createFieldColorResolver,
resolveRecordSourceObjectName,
} from '@object-ui/core';

export interface CalendarSchema {
Expand Down Expand Up @@ -305,8 +306,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
// different object. Comparing it during render means switching objects closes
// the gate in the same commit that changes it, not one commit later, so no
// query can carry the previous object's expand set.
const schemaObjectName =
dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName;
const schemaObjectName = resolveRecordSourceObjectName(schema, dataConfig);
const schemaKey = schemaObjectName ?? '';
/**
* Has the object schema for THIS object finished resolving? Note what this is
Expand Down Expand Up @@ -966,7 +966,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
</Dialog>

{navigation.isOverlay && navigation.isOpen && navigation.selectedRecord && (() => {
const objectName = dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName;
const objectName = resolveRecordSourceObjectName(schema, dataConfig);
const rec = navigation.selectedRecord as Record<string, any>;
const recordId = rec.id ?? rec._id;
if (!objectName || recordId == null) return null;
Expand Down
Loading
Loading