diff --git a/.changeset/12294-record-blocks-element-datasource.md b/.changeset/12294-record-blocks-element-datasource.md new file mode 100644 index 0000000000..af495757d3 --- /dev/null +++ b/.changeset/12294-record-blocks-element-datasource.md @@ -0,0 +1,30 @@ +--- +'@object-ui/plugin-detail': minor +'@object-ui/types': minor +'@object-ui/cli': minor +--- + +`record:details`, `record:highlights` and `record:quick_actions` honour the per-element `dataSource` binding the spec already declares on every page component (objectui#12294). A product `app` page can now compose a record's surfaces from these blocks, with no record page around them: + +```json +{ + "type": "record:highlights", + "properties": { "fields": ["name", "environment_type"] }, + "dataSource": { + "object": "sys_environment", + "filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }], + "limit": 1 + } +} +``` + +- **One record per block.** A record block shows the FIRST row of the bound set by the binding's `sort`, read as one row and under the caller's own identity; the server decides what the caller may read, exactly as on any other read. When no row matches, each block draws what it draws for a record that is not there: `record:details` its "record not found" state, `record:highlights` nothing, `record:quick_actions` its empty bar. A read the server refuses draws the same, so a record the caller may not read looks absent. A read that fails for any other reason is reported in the block's place, with the family's data-source error panel. +- **`record:quick_actions` runs against the bound row.** The object's declared actions (`actionNames`) run with the bound row's id. +- **The binding resolves as on every bound block.** A named `view`, the binding's `filter` (with `{current_user_id}`-style tokens), and its `sort` mean what they mean on a list block, and an unresolvable view or a refused filter draws the same configuration panel. Reference fields come back expanded, limited to the fields the caller may read, as on the record page. +- **The bound record stays fresh.** It re-reads in place whenever a write to its object is announced — by the data source's mutation events or on the data-invalidation bus — so a save or an action elsewhere on the page refreshes it. +- **The record page is unchanged.** A block with no `dataSource` reads the record page's record exactly as before. A bound block placed on a record page shows its bound record instead, as the spec says the binding does, and is kept out of that page's inline-edit session and highlight-field registry: it is read-only there, and it does not hide fields from the page record's details. +- **The registrations declare the key.** The three blocks publish the injected `dataSource` input, so the page authoring diagnostics no longer report it as an unknown prop. +- **`@object-ui/types`: the strict authoring face accepts the binding on the three blocks.** Each arm declares `dataSource` as the spec's `ElementDataSourceSchema`, by reference, as `record:line_items` does; the TypeScript authoring face follows. `objectui validate` refused it as an undeclared key before. +- **`@object-ui/cli`: `objectui validate` warns on a record block whose binding has no `limit: 1`.** The document still validates; the warning names the block's path and asks the author to say which record the block shows. `record:related_list` is not warned about: its binding is the binding of its child rows. + +`record:related_list` is unchanged: its `dataSource` still binds the rows it lists. diff --git a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts index 809dc9e64f..de09b99e25 100644 --- a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts +++ b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts @@ -117,20 +117,30 @@ function bindingDocRows(): Map { const DOC_ROWS = bindingDocRows(); /** - * The key the binding's `object` lands on. The doc: "it lands on the block's - * own object key, which is `objectName` everywhere except `record:line_items`" + * The key the binding's `object` lands on. The doc: "on a block with an object + * key it lands on that key, which is `objectName` everywhere except + * `record:line_items`" * (its row spells that key in the `object` column). * - * `undefined` for the `element:*` blocks: each reads `dataSource.object` - * itself, so the binding lands on no key of theirs (objectui#11880), and since - * objectui#12085 neither `element:number` nor `element:repeater` publishes a - * flat `object` for it to land on. Row 3 asserts that exception is exactly the - * `element:*` tags, so it cannot quietly cover an `object-*` block. + * `undefined` for two kinds of block, and only these: + * - the `element:*` blocks: each reads `dataSource.object` itself, so the + * binding lands on no key of theirs (objectui#11880), and since + * objectui#12085 neither `element:number` nor `element:repeater` publishes a + * flat `object` for it to land on; + * - the three one-record blocks {@link ONE_RECORD_BLOCKS} (objectui#12294): + * the binding's `object` and its first row become the block's RECORD SCOPE + * (`RecordContextProvider`), so it lands on no schema key either. Named one + * by one, ⛔ not a `record:` prefix: `record:related_list` and + * `record:line_items` keep their keys. + * Row 3 asserts the exception is exactly those tags, so it cannot quietly + * cover an `object-*` block. */ +const ONE_RECORD_BLOCKS: ReadonlySet = new Set(['record:details', 'record:highlights', 'record:quick_actions']); + function objectKeyOf(tag: string): string | undefined { const named = /\(`([^`]+)`\)/.exec(DOC_ROWS.get(tag)?.object ?? ''); if (named) return named[1]; - if (tag.startsWith('element:')) return undefined; + if (tag.startsWith('element:') || ONE_RECORD_BLOCKS.has(tag)) return undefined; return 'objectName'; } @@ -245,15 +255,26 @@ describe('objectui#11605 — a bound registration requires no input its binding `<${tag}>: the binding's object lands on "${key}", which the entry does not declare`, ).toContain(key); } - // The exception is the `element:*` tags and nothing else, and it is not - // vacuous: the two whose flat `object` objectui#12085 stopped publishing - // are bound entries of the shipped manifest that declare no `object`. + // The exception is the `element:*` tags plus the three named one-record + // blocks and nothing else, and it is not vacuous: the two whose flat + // `object` objectui#12085 stopped publishing are bound entries of the + // shipped manifest that declare no `object`, and the three one-record + // blocks (objectui#12294) are bound entries that declare no `objectName`. const landsNowhere = BOUND.filter((tag) => objectKeyOf(tag) === undefined); - expect(landsNowhere.every((tag) => tag.startsWith('element:'))).toBe(true); + expect(landsNowhere.every((tag) => tag.startsWith('element:') || ONE_RECORD_BLOCKS.has(tag))).toBe(true); + expect(landsNowhere.filter((tag) => tag.startsWith('object-'))).toEqual([]); for (const tag of ['element:number', 'element:repeater']) { expect(landsNowhere, tag).toContain(tag); expect(inputsOf(tag).map((input) => input.name), tag).not.toContain('object'); } + for (const tag of ONE_RECORD_BLOCKS) { + expect(landsNowhere, tag).toContain(tag); + expect(inputsOf(tag).map((input) => input.name), tag).not.toContain('objectName'); + // The doc row says so, in the words a reader sees. + expect(DOC_ROWS.get(tag)?.object, tag).toContain('its record'); + } + // Named, not a prefix: the other bound record blocks keep their keys. + expect(objectKeyOf('record:related_list')).toBe('objectName'); // Controls on the doc reading: the exception it names, and a full-view row. expect(objectKeyOf('record:line_items')).toBe('childObject'); expect(bindingSupplies('object-grid').has('columns')).toBe(true); diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index bf3f38deab..1e2b465485 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -3301,6 +3301,10 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-detail/src/renderers/__tests__/recordChatterFeedMembers-8071.test.tsx', pins: 'The nested activity-feed config\'s MEMBERS, and the fact that authoring the object REPLACES rather than merges. `record-chatter.tsx` composes `{ position, collapsible, feed: DEFAULTS, ...schema }` — a shallow spread — so an authored `feed` displaces the renderer\'s three defaults wholesale and every member not restated falls back to `RecordActivityTimeline`\'s own default. Those defaults are NOT uniform, which is the trap this pin exists for and is asserted per member: `showCommentInput` is `!== false` (ON when absent) while `enableReactions` and `enableThreading` are `?? false` (OFF), so `feed: {}` silently takes reactions and threading down while leaving the composer up. Each of the three carries its own observable — composer placeholder, the `Add reaction` affordance, and whether a reply is pulled out of the ROOT row list — each with the absent-`feed` control (objectui#8071 slice 6).', }, + 'record:details.dataSource': { + file: 'packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx', + pins: 'The per-element binding\'s members as a ONE-RECORD block reads them (objectui#12294), through the REAL registration and `SchemaRenderer` on an `app` page with no record context, asserted at the bound row\'s field value and option label it draws. `object` names the object read; `filter` reaches the read (lowered by `ElementDataSourceGate`, matched by content); `sort` picks the FIRST row and reaches the read as `$orderby`, with a two-row set stored out of order as its control; `limit` is NOT the read\'s cap: one row is always asked for (`$top: 1`), because the first row of the authored cap and of a one-row read are the same row; `view` is resolved by the gate, and an unresolvable one draws the configuration panel and reads no row. Zero rows and a refused read draw the block\'s no-record state; a failed read is reported in the block\'s place. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block, so the declaration lists the five members generically and this pin carries which of them THIS block reads. The record-page half (unbound = the page record; bound on a record page = the bound row, kept out of the page\'s inline-edit session and highlight registry) and the data-invalidation bus are pinned in `recordBinding.recordPageAndBus-12294.test.tsx`.', + }, 'record:details.fields': { file: 'packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts', pins: 'Members are BARE NAMES: the spec rejects the entry-object spelling on its value, the description is asserted non-empty first and then required to teach no entry shape, and the renderer\'s more tolerant read is recorded as unexercised drift rather than a second contract.', @@ -3325,6 +3329,10 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-detail/src/renderers/__tests__/recordChatterFeedMembers-8071.test.tsx', pins: 'The same key on the same renderer under its other registered name — `record:chatter` and `record:discussion` are one `RecordChatterRenderer` sharing `CHATTER_INPUTS`, so both rows point at the one file, which runs every case against BOTH names. See `record:chatter.feed` for what it constrains (objectui#8071 slice 6).', }, + 'record:highlights.dataSource': { + file: 'packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx', + pins: 'The per-element binding\'s members as a ONE-RECORD block reads them (objectui#12294), through the REAL registration and `SchemaRenderer` on an `app` page with no record context, asserted at the bound row\'s field value it draws. `object` names the object read; `filter` reaches the read (lowered by `ElementDataSourceGate`, matched by content); `sort` picks the FIRST row and reaches the read as `$orderby`, with a two-row set stored out of order as its control; `limit` is NOT the read\'s cap: one row is always asked for (`$top: 1`), because the first row of the authored cap and of a one-row read are the same row; `view` is resolved by the gate, and an unresolvable one draws the configuration panel and reads no row. Zero rows and a refused read draw the block\'s no-record state; a failed read is reported in the block\'s place. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block, so the declaration lists the five members generically and this pin carries which of them THIS block reads. The record-page half (unbound = the page record; bound on a record page = the bound row, kept out of the page\'s inline-edit session and highlight registry) and the data-invalidation bus are pinned in `recordBinding.recordPageAndBus-12294.test.tsx`.', + }, 'record:highlights.fields': { file: 'packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts', pins: 'Members are objects carrying `readonly` PER ENTRY — asserted to be per-entry rather than top-level, and every spec entry key must be discoverable from the `fields` description (objectui#3407).', @@ -3357,6 +3365,10 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-detail/src/renderers/__tests__/recordPathStagesMembers-8071.test.tsx', pins: 'The stage member set `{ value, label, terminal? }`, read through the real renderer. `value` is stage IDENTITY — compared against the record\'s `statusField` to choose the current stage, controlled by moving the RECORD rather than the array, by an unmatched value leaving NO stage current, and by a record carrying some stage\'s LABEL still matching nothing. `label` is the rendered text and the `value` behind it never appears. `terminal` OUTRANKS the `WON_TOKENS`/`LOST_TOKENS` heuristic, pinned only on fixtures where the member CONTRADICTS the heuristic and each paired with the identical stage minus `terminal` to show which way the heuristic was pointing, plus an unclassified control. Deliberately the DESKTOP row only: `record-path.crossRowClassification.test.tsx` owns the cross-row agreement invariant, which is a different claim (objectui#8071 slice 6). The `label` member is an `I18nLabel`: its locale-map arm, resolved before the stages are classified or rendered, is pinned through the real `SchemaRenderer` under `zh` and `en` by `record-path.stageLabelI18nLabel-10993.test.tsx` (objectui#10993).', }, + 'record:quick_actions.dataSource': { + file: 'packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx', + pins: 'The per-element binding\'s members as a ONE-RECORD block reads them (objectui#12294), through the REAL registration and `SchemaRenderer` on an `app` page with no record context, asserted at the record id a declared action runs against. `object` names the object read; `filter` reaches the read (lowered by `ElementDataSourceGate`, matched by content); `sort` picks the FIRST row and reaches the read as `$orderby`, with a two-row set stored out of order as its control; `limit` is NOT the read\'s cap: one row is always asked for (`$top: 1`), because the first row of the authored cap and of a one-row read are the same row; `view` is resolved by the gate, and an unresolvable one draws the configuration panel and reads no row. Zero rows and a refused read draw the block\'s no-record state; a failed read is reported in the block\'s place. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block, so the declaration lists the five members generically and this pin carries which of them THIS block reads. The record-page half (unbound = the page record; bound on a record page = the bound row, kept out of the page\'s inline-edit session and highlight registry) and the data-invalidation bus are pinned in `recordBinding.recordPageAndBus-12294.test.tsx`.', + }, 'record:quick_actions.actionNames': { file: 'packages/plugin-detail/src/__tests__/recordQuickActionsInputs.actionNamesFallback.test.tsx', pins: 'Members are action-id STRINGS resolved against the object\'s own `actions` through the real renderer: with no `actionNames` (and no host `actions`) the metadata layer is NEVER queried and the bar\'s empty placeholder renders, while naming an id drives the same `useMetadataItem` lookup and the resolved action\'s button actually appears — the positive control that keeps the negative case from reading as "the wiring was never exercised". Pre-existing file, promoted to a pin here after being read end to end; it was written for objectui#4663 (the registered description promised a fallback that exists on no path) rather than for this direction, but its second case already drives the exact read this key needs pinned (objectui#8071).', diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index b62c04ab8b..b7d441c18b 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -272,14 +272,17 @@ ignores would be accepted and dropped, which is the defect this binding removes. | `embeddable-form` | ✅ | error-checked only | — no collection query | — | — | | `object-master-detail-form` | ✅ | error-checked only | — no collection query | — | — | | `record:line_items` | ✅ (`childObject`) | filter / sort / limit | ✅ (AND parent scope) | ✅ | ✅ (`limit`) | +| `record:details` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | +| `record:highlights` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | +| `record:quick_actions` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | Reading the `view` column: it lists what a named saved view actually contributes on that block. A view name that does not resolve is reported as a configuration error on **every** block in the table, including the ones that take nothing else from the view — so a typo never passes silently, whatever the block. -Reading the `object` column: it lands on the block's own object key, which is -`objectName` everywhere except `record:line_items`, where the collection the panel +Reading the `object` column: on a block with an object key it lands on that key, +which is `objectName` everywhere except `record:line_items`, where the collection the panel lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part of the binding and stays the author's — it has to name a field on the bound child object, so rebinding `object` without updating it is an authoring error the panel @@ -287,6 +290,40 @@ cannot paper over. A `record:line_items` node bound this way needs no `childObje of its own, and the page compile accepts it; a node that names its child object in neither place shows a configuration hint naming `childObject` and loads nothing. +Two kinds of block have no object key for the binding to land on. The +`element:*` blocks read `dataSource.object` themselves (see below). The three +one-record blocks, whose rows read "its record", take the binding's `object` as +the object of the record they show, and the bound row becomes that record. + +`record:details`, `record:highlights` and `record:quick_actions` show the +record their record context carries. Bound, a block reads the FIRST row of +the bound set by the binding's `sort`, under the caller's own identity, and that +row becomes its record, so a product page shows a record without being a +`type: 'record'` page: + +```json +{ + "type": "record:details", + "properties": { "fields": ["name", "environment_type"] }, + "dataSource": { + "object": "sys_environment", + "filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }], + "limit": 1 + } +} +``` + +When no row matches — or the server refuses the read — the block draws what it +draws for a record that is not there: `record:details` its "record not found" +state, `record:highlights` nothing, `record:quick_actions` an empty bar. A read +that fails any other way is reported in the block's place. `record:quick_actions` +runs the object's declared actions against the bound row's id. The bound row is +re-read whenever a write to its object is announced on the page (the data +source's mutation events, or `notifyDataChanged`). `objectui validate` warns on a +record block whose binding has no `limit: 1`, so the binding says which record it +means. On a record page, a block with no `dataSource` still shows the page's +record; a bound one shows its bound record instead, read-only. + Because the binding lands on `objectName`, a node bound this way needs no `objectName` of its own. The schema validator (`StrictAnyComponentSchema`, which `objectui validate` runs, and the tolerant `safeValidateSchema` alike) counts the binding as a record source of `list-view`, diff --git a/packages/cli/src/__tests__/validate-record-block-binding-limit-12294.test.ts b/packages/cli/src/__tests__/validate-record-block-binding-limit-12294.test.ts new file mode 100644 index 0000000000..5f7b36f7bf --- /dev/null +++ b/packages/cli/src/__tests__/validate-record-block-binding-limit-12294.test.ts @@ -0,0 +1,161 @@ +/** + * 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 validate` and a record block bound by `dataSource` (objectui#12294). + * + * `record:details`, `record:highlights` and `record:quick_actions` show ONE + * record. Bound through the spec's per-element `dataSource`, they draw the + * FIRST row of the bound set by the binding's `sort`, so a binding without + * `limit: 1` is valid, and the validator WARNS on it: the author says which + * record they mean. + * + * Pinned by KIND and by named subject (the path and the block type), never by + * the warning's wording: + * + * - the strict authoring face ACCEPTS the binding on the three blocks (it + * refused it as an undeclared key before), and the document validates; + * - a binding with no `limit: 1` is warned about once, at its path, in a page + * region as at the root; + * - `limit: 1` silences it; a node with no binding is not a bound block; + * - `record:related_list` is never warned about: its binding is the binding of + * its CHILD rows, which are many by design. + * + * Harness (fixtures under `os.tmpdir()`, `process.exit` recorded rather than + * taken) follows `validate-root-path-line.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { validate } from '../commands/validate.js'; +import { findRecordBindingsWithoutLimitOne } from '../utils/record-block-binding-hint.js'; + +/** The CSI sequences chalk may add, built without a raw escape byte in the source. */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +const PRODUCTION = [{ field: 'environment_type', operator: 'equals', value: 'production' }]; + +let dir: string; +let out: string[]; +let exitCodes: number[]; +let restore: () => void; + +const printed = (): string => out.join('\n').replace(ANSI, ''); +/** The lines of the warning block that name a record block. */ +const recordWarnings = (): string[] => + printed().split('\n').filter((l) => /record:(details|highlights|quick_actions|related_list) is bound/.test(l)); + +function writeSchema(name: string, schema: unknown): string { + const file = join(dir, name); + writeFileSync(file, JSON.stringify(schema, null, 2), 'utf-8'); + return file; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'objectui-validate-12294-')); + out = []; + exitCodes = []; + const originalLog = console.log; + const originalError = console.error; + const capture = (...args: unknown[]) => { + out.push(args.map(String).join(' ')); + }; + console.log = capture; + console.error = capture; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCodes.push(code ?? 0); + return undefined as never; + }) as never); + restore = () => { + console.log = originalLog; + console.error = originalError; + exitSpy.mockRestore(); + }; +}); + +afterEach(() => { + restore(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe.each(['record:details', 'record:highlights', 'record:quick_actions'])( + 'objectui validate — %s bound by `dataSource` (objectui#12294)', + (type) => { + it('accepts the binding, and warns when it has no `limit: 1`', async () => { + await validate(writeSchema('bound.json', { type, dataSource: { object: 'sys_environment', filter: PRODUCTION } })); + + expect(exitCodes).toEqual([0]); + expect(printed()).toContain('Schema is valid'); + expect(printed()).toContain('Warnings:'); + const lines = recordWarnings(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('(root)'); + expect(lines[0]).toContain(type); + expect(lines[0]).toContain('limit: 1'); + }); + + it('is silent with `limit: 1`', async () => { + await validate(writeSchema('bound.json', { type, dataSource: { object: 'sys_environment', filter: PRODUCTION, limit: 1 } })); + + expect(exitCodes).toEqual([0]); + expect(recordWarnings()).toEqual([]); + expect(printed()).not.toContain('Warnings:'); + }); + + it('a `limit` other than 1 still warns', async () => { + await validate(writeSchema('bound.json', { type, dataSource: { object: 'sys_environment', limit: 5 } })); + + expect(exitCodes).toEqual([0]); + expect(recordWarnings()).toHaveLength(1); + }); + }, +); + +describe('objectui validate — where the warning applies (objectui#12294)', () => { + it('names the block\'s path inside a page region', async () => { + await validate(writeSchema('page.json', { + type: 'page', + regions: [{ + name: 'main', + components: [ + { type: 'record:highlights', properties: { fields: ['name'] }, dataSource: { object: 'sys_environment', limit: 1 } }, + { type: 'record:details', dataSource: { object: 'sys_environment', sort: [{ field: 'created_at', order: 'desc' }] } }, + ], + }], + })); + + expect(exitCodes).toEqual([0]); + const lines = recordWarnings(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('regions → 0 → components → 1'); + expect(lines[0]).toContain('record:details'); + }); + + it('an unbound record block is not a bound block, and is not warned about', async () => { + await validate(writeSchema('plain.json', { type: 'record:details' })); + + expect(exitCodes).toEqual([0]); + expect(printed()).not.toContain('Warnings:'); + }); + + it('never names `record:related_list`, whose binding is its child rows', () => { + // Read off the detector directly: the strict face's verdict on a related + // list's binding is a separate question this card does not touch. + expect(findRecordBindingsWithoutLimitOne({ + type: 'record:related_list', + dataSource: { object: 'sys_member' }, + })).toEqual([]); + // The same binding on a one-record block is named — the control. + expect(findRecordBindingsWithoutLimitOne({ + type: 'record:details', + dataSource: { object: 'sys_member' }, + })).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index cb1ff520dc..ad05e09db8 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -12,6 +12,10 @@ import { resolve } from 'path'; import { load as loadYaml } from 'js-yaml'; import { describeUndeclaredKey, validateAuthoredDocument } from '../utils/authoring-face.js'; import { findSpecVocabularyFormFields } from '../utils/spec-vocabulary-hint.js'; +import { + describeRecordBindingWithoutLimitOne, + findRecordBindingsWithoutLimitOne, +} from '../utils/record-block-binding-hint.js'; import { explainUnionIssue, findUndeclaredKeys } from '../utils/union-arm-diagnostics.js'; import { formatIssuePath } from '../utils/issue-path.js'; @@ -89,7 +93,11 @@ export async function validate(schemaPath: string) { // schema) but the spec `field` key is dead weight: the renderer ignores // it and strip-mode validation drops it. Valid ≠ clean — say so (#3090). const mixed = findSpecVocabularyFormFields(schema).filter((f) => f.mixedName); - if (mixed.length > 0) { + // A one-record block bound by a `dataSource` that can match several rows + // draws the first by its `sort`; the author should say `limit: 1` + // (objectui#12294). Advisory: the document is valid either way. + const unboundedRecordBindings = findRecordBindingsWithoutLimitOne(schema); + if (mixed.length > 0 || unboundedRecordBindings.length > 0) { console.log(chalk.yellow('\nWarnings:')); for (const m of mixed) { console.log(chalk.yellow( @@ -97,6 +105,9 @@ export async function validate(schemaPath: string) { `vocabularies. The renderer ignores \`field\` here; drop one of the two.`, )); } + for (const finding of unboundedRecordBindings) { + console.log(chalk.yellow(` ${describeRecordBindingWithoutLimitOne(finding)}`)); + } } console.log(''); diff --git a/packages/cli/src/utils/record-block-binding-hint.ts b/packages/cli/src/utils/record-block-binding-hint.ts new file mode 100644 index 0000000000..0ff95f9808 --- /dev/null +++ b/packages/cli/src/utils/record-block-binding-hint.ts @@ -0,0 +1,89 @@ +/** + * 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. + */ + +/** + * A one-record block whose `dataSource` binding does not say WHICH record + * (objectui#12294) — the warning `objectui validate` prints for it. + * + * `record:details`, `record:highlights` and `record:quick_actions` show ONE + * record. Bound through the spec's per-element `dataSource`, they show the + * FIRST row of the bound set by the binding's `sort` (`@object-ui/plugin-detail`, + * `renderers/recordBinding.tsx`). A binding that can match several rows is + * therefore valid, and its first row is drawn — but the author has not said + * that one row is what they meant, so the block quietly picks one. The ruling's + * remedy is a warning, not a refusal: the author declares `limit: 1`. + * + * Advisory only. The document still validates; the strict authoring face + * judges the binding's SHAPE (the spec's `ElementDataSourceSchema`), and this + * reads one value of it. + * + * `record:related_list` is not in the set: its binding is the binding of its + * CHILD rows, which are many by design. + */ + +import { formatIssuePath } from './issue-path.js'; + +/** The blocks that draw ONE record from their binding. */ +export const ONE_RECORD_BLOCK_TYPES: ReadonlySet = new Set([ + 'record:details', + 'record:highlights', + 'record:quick_actions', +]); + +export interface RecordBindingLimitFinding { + /** Where the block sits, spelled the way every other CLI path is (`formatIssuePath`). */ + path: string; + /** The block's `type`. */ + type: string; + /** The object the binding names. */ + object: string; +} + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** + * Every one-record block in `document` whose binding names an object and does + * not declare `limit: 1`. A node without a binding, or with one that names no + * object, is not a bound record block, so it is not reported here. + */ +export function findRecordBindingsWithoutLimitOne( + document: unknown, + path: readonly PropertyKey[] = [], +): RecordBindingLimitFinding[] { + if (Array.isArray(document)) { + return document.flatMap((child, i) => findRecordBindingsWithoutLimitOne(child, [...path, i])); + } + if (!isRecord(document)) return []; + + const findings: RecordBindingLimitFinding[] = []; + const binding = document.dataSource; + if ( + typeof document.type === 'string' + && ONE_RECORD_BLOCK_TYPES.has(document.type) + && isRecord(binding) + && typeof binding.object === 'string' + && binding.object.length > 0 + && binding.limit !== 1 + ) { + findings.push({ path: formatIssuePath(path), type: document.type, object: binding.object }); + } + for (const [key, value] of Object.entries(document)) { + findings.push(...findRecordBindingsWithoutLimitOne(value, [...path, key])); + } + return findings; +} + +/** The warning line for one finding. */ +export function describeRecordBindingWithoutLimitOne(finding: RecordBindingLimitFinding): string { + return ( + `${finding.path}: ${finding.type} is bound to "${finding.object}" by a dataSource without \`limit: 1\`. ` + + 'A record block shows ONE record — the first row of the binding by its `sort` — so declare ' + + '`limit: 1` (and a `sort` when several rows can match) to say which record that is.' + ); +} diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md index 09e7c028f4..5e6509f009 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -58,6 +58,31 @@ and the object it was about to read, instead of an empty shell. Note the record id is `resourceId` here. `object-form`'s is `recordId`; the two blocks do not share that key. +### Record blocks without a record page (`dataSource`) + +`record:details`, `record:highlights` and `record:quick_actions` show the record +their record context carries: on a `type: 'record'` page, the page's record. On +any other page, the spec's per-element `dataSource` binding supplies it — the +block reads the FIRST row of the bound set by the binding's `sort`, under the +caller's own identity: + +```json +{ + "type": "record:highlights", + "properties": { "fields": ["name", "environment_type"] }, + "dataSource": { "object": "sys_environment", "sort": [{ "field": "created_at", "order": "desc" }], "limit": 1 } +} +``` + +No row draws the block's no-record state, and `record:quick_actions` runs the +object's declared actions against the bound row's id. The binding resolves as on +every bound block (a named `view`, an AND-combined `filter`, its `sort`), and the +row is re-read when a write to its object is announced. Declare `limit: 1`: +`objectui validate` warns on a record block whose binding does not. See +**Per-element data binding on a page** in the data-source guide +(`content/docs/guide/data-source.md`). `record:related_list`'s `dataSource` is +different: it binds the rows the list shows. + ### Basic Example ```tsx diff --git a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts index 84da56c853..921be1f4cc 100644 --- a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts @@ -55,7 +55,7 @@ import { listedShapeKeys, resolvePropsShape, } from '@object-ui/test-support'; -import { RecordDetailsProps } from '@objectstack/spec/ui'; +import { PageComponentSchema, RecordDetailsProps } from '@objectstack/spec/ui'; import '../index'; const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -103,6 +103,18 @@ const isTombstoned = (key: string): boolean => /** Top-level keys the spec actually ACCEPTS — tombstones removed. */ const specAcceptedTopLevelKeys = (): string[] => authorableShapeKeys(RecordDetailsProps); +/** + * Keys the spec accepts on the NODE, on every page component — the widening + * `recordRelatedListInputs.spec-parity.test.ts` and the repo-wide + * `registry-inputs-spec-parity.test.ts` already make (objectui#6678). + * `RecordDetailsProps` is this block's PROPS contract, not the whole contract of + * the node: `dataSource`, the per-element binding this block reads since + * objectui#12294 and declares from the seam, is a `PageComponentSchema` key. + * DERIVED from that schema's input shape (it is a `.pipe()`), never listed. + */ +const nodeLevelSpecKeys = (): string[] => + authorableShapeKeys((PageComponentSchema as unknown as { _def?: { in?: unknown } })._def?.in); + /** Member keys of one `sections[]` entry, per the spec. */ const specSectionKeys = (): string[] => listedShapeKeys(specArrayElement('sections')); @@ -328,9 +340,12 @@ describe('record:details — registry inputs vs @objectstack/spec', () => { // shape and rejects every value (see `isTombstoned`). Reading raw shape // keys here is what let the retired `layout` input stay published and green // through the 17.0.0-rc.6 bump (objectui#3818). - const allowed = new Set(specAcceptedTopLevelKeys()); + const allowed = new Set([...specAcceptedTopLevelKeys(), ...nodeLevelSpecKeys()]); const offSpec = inputs().map((i) => i.name).filter((name) => !allowed.has(name)); expect(offSpec).toEqual([]); + // Calibration: the node-level widening is derived and discriminating. + expect(nodeLevelSpecKeys()).toContain('dataSource'); + expect(nodeLevelSpecKeys()).not.toContain('sections'); }); it('publishes `hideFields`, which the renderer has read all along', () => { diff --git a/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts index 2e573f05ab..33ca9562f5 100644 --- a/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts @@ -38,7 +38,8 @@ import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; -import { RecordHighlightsField, RecordHighlightsProps } from '@objectstack/spec/ui'; +import { PageComponentSchema, RecordHighlightsField, RecordHighlightsProps } from '@objectstack/spec/ui'; +import { authorableShapeKeys } from '@object-ui/test-support'; import '../index'; /** Keys of the object arm of the spec's `RecordHighlightsField` union. */ @@ -68,6 +69,17 @@ function specTopLevelKeys(): string[] { return resolved && typeof resolved === 'object' ? Object.keys(resolved) : []; } +/** + * Keys the spec accepts on the NODE, on every page component — the widening + * `recordRelatedListInputs.spec-parity.test.ts` and the repo-wide + * `registry-inputs-spec-parity.test.ts` already make (objectui#6678). + * `dataSource`, the per-element binding this block reads since objectui#12294 + * and declares from the seam, is a `PageComponentSchema` key, not a + * `RecordHighlightsProps` one. DERIVED from that schema's input shape. + */ +const nodeLevelSpecKeys = (): string[] => + authorableShapeKeys((PageComponentSchema as unknown as { _def?: { in?: unknown } })._def?.in); + const config = () => ComponentRegistry.getConfig('record:highlights'); const inputs = () => config()?.inputs ?? []; const fieldsInput = () => inputs().find((i) => i.name === 'fields'); @@ -176,9 +188,12 @@ describe('record:highlights — registry inputs vs @objectstack/spec', () => { }); it('declares no top-level input the spec does not accept', () => { - const allowed = new Set(specTopLevelKeys()); + const allowed = new Set([...specTopLevelKeys(), ...nodeLevelSpecKeys()]); const offSpec = inputs().map((i) => i.name).filter((name) => !allowed.has(name)); expect(offSpec).toEqual([]); + // Calibration: the node-level widening is derived and discriminating. + expect(nodeLevelSpecKeys()).toContain('dataSource'); + expect(nodeLevelSpecKeys()).not.toContain('fields'); }); }); diff --git a/packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx b/packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx new file mode 100644 index 0000000000..44aafe545d --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx @@ -0,0 +1,275 @@ +/** + * 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 record blocks on an `app` page, bound by `dataSource` (objectui#12294). + * + * `record:details`, `record:highlights` and `record:quick_actions` used to read + * their record from the record page's `RecordContextProvider` only, so on an + * `app` page they drew an empty box or "no actions configured" whatever the + * node's `dataSource` binding returned. Each block is mounted here the way an + * `app` page mounts it: through the REAL registration and `SchemaRenderer`, + * under a `SchemaRendererProvider` adapter, with NO record context anywhere. + * + * The card's semantics, per block: + * - one row → that record; + * - zero rows → the block's empty state, and a read the server REFUSES is the + * same empty state (a record you may not read looks absent); + * - two rows → the FIRST by the binding's `sort`, read as ONE row (`$top: 1`). + * + * What a block "shows" is observed per block: the bound row's field value for + * the two display blocks, and for the action bar the record id a declared + * action runs against — the bar's whole job. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, MetadataCtx, SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import type { MetadataContextValue } from '@object-ui/react'; +import '../../index'; + +/** + * `record:details` probes the record's edit verdict over `/api/v1/security/explain` + * (`useRecordEditable`), exactly as on the record page. Answered here with a + * 501 — the probe's fail-open reply — so no read leaves the process. Installed + * once at module scope and never torn down, the shape the network-escape guard + * prescribes for a tree that can read after a test body returns. + */ +vi.stubGlobal('fetch', (async () => new Response(null, { status: 501 })) as unknown as typeof globalThis.fetch); + +const OBJECT = 'sys_environment'; + +const OBJECT_SCHEMA = { + name: OBJECT, + label: 'Environment', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + region: { name: 'region', type: 'text', label: 'Region' }, + rank: { name: 'rank', type: 'number', label: 'Rank' }, + environment_type: { + name: 'environment_type', + type: 'select', + label: 'Type', + options: [ + { value: 'production', label: 'Production' }, + { value: 'sandbox', label: 'Sandbox' }, + ], + }, + }, +}; + +const APPROVE = { name: 'approve', label: 'Approve environment', type: 'api', locations: ['record_header'] }; +const OBJECT_META = { name: OBJECT, label: 'Environment', actions: [APPROVE] }; + +type Row = Record; + +/** + * An adapter whose `find` applies the two query keys a one-record read relies + * on — `$orderby` and `$top` — so "the first row by sort" is a fact about the + * request, not about the order the fixture happens to be written in. + */ +function makeAdapter(rows: Row[], opts: { reject?: unknown } = {}) { + const find = vi.fn(async (_object: string, params?: Record) => { + if (opts.reject) throw opts.reject; + let out = rows.map((r) => ({ ...r })); + const orderby = params?.$orderby as Array<{ field: string; order?: string }> | undefined; + if (Array.isArray(orderby) && orderby.length > 0) { + const { field, order } = orderby[0]; + out.sort((a, b) => (Number(a[field]) - Number(b[field])) * (order === 'desc' ? -1 : 1)); + } + if (typeof params?.$top === 'number') out = out.slice(0, params.$top); + return { data: out, total: out.length }; + }); + return { + find, + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => true), + getObjectSchema: vi.fn(async () => OBJECT_SCHEMA), + onMutation: () => () => {}, + }; +} + +const getItem = vi.fn(async (type: string, name: string) => (type === 'object' && name === OBJECT ? OBJECT_META : null)); +const METADATA: MetadataContextValue = { + apps: [], + objects: [OBJECT_META] as any, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: getItem as unknown as MetadataContextValue['getItem'], + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +const PRODUCTION = [{ field: 'environment_type', operator: 'equals', value: 'production' }]; + +/** An `app` page: the adapter and the action runtime, and no record context at all. */ +function mountOnAppPage(node: Record, adapter: ReturnType, api = vi.fn()) { + const utils = render( + + + { api(ctx); return { success: true }; } }}> + + + + , + ); + return { ...utils, api }; +} + +interface BlockCase { + type: 'record:details' | 'record:highlights' | 'record:quick_actions'; + properties: Record; + /** Resolves when the block shows the record named `name` with id `id`. */ + shows: (m: ReturnType, name: string, id: string) => Promise; + /** Asserts the block shows no record at all (after its read settled). */ + showsNone: (m: ReturnType) => Promise; +} + +const testIdOf = (type: string) => type.replace(/[:_]/g, '-'); + +/** Wait until the scope's own read settled — its loading state is gone. */ +async function settled(type: string) { + await waitFor(() => expect(screen.queryByTestId(`${testIdOf(type)}-loading-record`)).not.toBeInTheDocument()); +} + +const BLOCKS: BlockCase[] = [ + { + type: 'record:highlights', + properties: { fields: ['name', 'rank'] }, + shows: async (_m, name) => { + expect(await screen.findByText(name)).toBeInTheDocument(); + }, + showsNone: async () => { + await settled('record:highlights'); + expect(screen.queryByText('Rank')).not.toBeInTheDocument(); + }, + }, + { + type: 'record:details', + // `region`, not `name`: the body leaves the title field to the page header. + properties: { fields: ['region', 'environment_type'] }, + shows: async (_m, name) => { + expect(await screen.findByText(`${name} region`)).toBeInTheDocument(); + // The OPTION LABEL, which needs the object definition the scope provides + // beside the row: the raw value would be `production`. + expect(await screen.findByText('Production')).toBeInTheDocument(); + }, + showsNone: async () => { + await settled('record:details'); + // DetailView's own no-record state — the one it draws when a record read + // answers nothing — and no field of any record. + expect(await screen.findByText(/not found/i)).toBeInTheDocument(); + expect(screen.queryByText('Production')).not.toBeInTheDocument(); + }, + }, + { + type: 'record:quick_actions', + properties: { actionNames: ['approve'] }, + shows: async (m, _name, id) => { + fireEvent.click(await screen.findByRole('button', { name: /Approve environment/i })); + // The object's declared action runs against the BOUND row's id. + await waitFor(() => expect(m.api).toHaveBeenCalledTimes(1)); + expect(m.api.mock.calls[0][0]).toMatchObject({ recordId: id, objectName: OBJECT }); + }, + showsNone: async (m) => { + await settled('record:quick_actions'); + expect(await screen.findByText(/no actions configured/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Approve environment/i })).not.toBeInTheDocument(); + expect(m.api).not.toHaveBeenCalled(); + }, + }, +]; + +afterEach(() => { + cleanup(); + getItem.mockClear(); +}); + +describe.each(BLOCKS)('$type bound by `dataSource` on an app page (objectui#12294)', (block) => { + const node = (dataSource: Record) => ({ type: block.type, properties: block.properties, dataSource }); + + it('one row → that record, read under the binding with `$top: 1`', async () => { + const adapter = makeAdapter([ + { id: 'env-1', name: 'Prod env', region: 'Prod env region', rank: 1, environment_type: 'production' }, + ]); + const m = mountOnAppPage(node({ object: OBJECT, filter: PRODUCTION, limit: 1 }), adapter); + await block.shows(m, 'Prod env', 'env-1'); + + expect(adapter.find).toHaveBeenCalled(); + const [object, params] = adapter.find.mock.calls[0]; + expect(object).toBe(OBJECT); + expect(params).toMatchObject({ $top: 1 }); + // The binding's filter reached the read (lowered by the gate, so matched by + // content rather than by shape). + expect(JSON.stringify(params?.$filter)).toContain('production'); + }); + + it('zero rows → the block\'s empty state', async () => { + const adapter = makeAdapter([]); + const m = mountOnAppPage(node({ object: OBJECT, filter: PRODUCTION, limit: 1 }), adapter); + await block.showsNone(m); + expect(adapter.find).toHaveBeenCalled(); + }); + + it('a read the server refuses → the same empty state as zero rows', async () => { + const refused = Object.assign(new Error('Forbidden'), { status: 403 }); + const adapter = makeAdapter([{ id: 'env-1', name: 'Prod env', rank: 1 }], { reject: refused }); + const m = mountOnAppPage(node({ object: OBJECT, filter: PRODUCTION, limit: 1 }), adapter); + await block.showsNone(m); + }); + + it('two rows → the FIRST by the binding\'s `sort`, whatever order they are stored in', async () => { + const adapter = makeAdapter([ + { id: 'env-2', name: 'Second env', region: 'Second env region', rank: 2, environment_type: 'production' }, + { id: 'env-1', name: 'First env', region: 'First env region', rank: 1, environment_type: 'production' }, + ]); + const m = mountOnAppPage( + node({ object: OBJECT, filter: PRODUCTION, sort: [{ field: 'rank', order: 'asc' }] }), + adapter, + ); + await block.shows(m, 'First env', 'env-1'); + expect(screen.queryByText('Second env')).not.toBeInTheDocument(); + expect(screen.queryByText('Second env region')).not.toBeInTheDocument(); + expect(adapter.find.mock.calls[0][1]).toMatchObject({ $top: 1, $orderby: [{ field: 'rank', order: 'asc' }] }); + }); + + it('a read that FAILS is reported, not drawn as an absent record', async () => { + const adapter = makeAdapter([], { reject: new Error('upstream exploded') }); + mountOnAppPage(node({ object: OBJECT, limit: 1 }), adapter); + expect(await screen.findByTestId(`${testIdOf(block.type)}-datasource-error`)).toHaveTextContent('upstream exploded'); + }); + + it('a named view that does not resolve draws the family\'s configuration panel, and reads no row', async () => { + const adapter = { ...makeAdapter([{ id: 'env-1', name: 'Prod env' }]), listViews: vi.fn(async () => []) }; + mountOnAppPage(node({ object: OBJECT, view: 'no_such_view' }), adapter); + expect(await screen.findByTestId(`${testIdOf(block.type)}-datasource-error`)).toBeInTheDocument(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('declares the `dataSource` input it reads — through the seam, not by hand', () => { + const inputs = (ComponentRegistry.getConfig(block.type)?.inputs ?? []).map((i) => i.name); + expect(inputs).toContain('dataSource'); + }); +}); + +describe('the declaration stays where the binding is read (objectui#12294)', () => { + it('a record block that reads no binding does not declare one — `record:path` as the control', () => { + const inputs = (ComponentRegistry.getConfig('record:path')?.inputs ?? []).map((i) => i.name); + expect(inputs.length).toBeGreaterThan(0); + expect(inputs).not.toContain('dataSource'); + }); +}); diff --git a/packages/plugin-detail/src/renderers/__tests__/recordBinding.recordPageAndBus-12294.test.tsx b/packages/plugin-detail/src/renderers/__tests__/recordBinding.recordPageAndBus-12294.test.tsx new file mode 100644 index 0000000000..f19aacd1c0 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/recordBinding.recordPageAndBus-12294.test.tsx @@ -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. + * + * The record blocks' `dataSource` binding beside a RECORD PAGE, and on the + * data-invalidation bus (objectui#12294). + * + * 1. The record page path is unchanged: with no binding on the block, the + * page's own record context reaches it, and no row is read. + * 2. With a binding, the binding wins for that block — the spec's + * "Overrides the page-level object context for this block" — and it does + * NOT join the two providers that are about the PAGE's record: the page's + * inline-edit session (whose Save bar writes the page record) and the + * page's highlight-field registry (which trims the page's details body). + * 3. The bound record is a FETCHING record context, so it re-reads when the + * bound object is written elsewhere on the page (objectstack#4444's bar): + * a write through the adapter (the mutation bridge) and a write announced + * on the bus by hand (a server action the host reports) both refresh it in + * place. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, act, cleanup, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { + HighlightFieldsProvider, + InlineEditProvider, + RecordContextProvider, + SchemaRendererProvider, + notifyDataChanged, + useMutationInvalidationBridge, +} from '@object-ui/react'; +import { RecordDetailsRenderer } from '../record-details'; +import { RecordHighlightsRenderer } from '../record-highlights'; + +/** `useRecordEditable`'s explain probe, answered with its fail-open 501 (see the app-page suite). */ +vi.stubGlobal('fetch', (async () => new Response(null, { status: 501 })) as unknown as typeof globalThis.fetch); + +const PAGE_OBJECT = 'crm_account'; +const BOUND_OBJECT = 'sys_environment'; + +const SCHEMAS: Record = { + [PAGE_OBJECT]: { + name: PAGE_OBJECT, + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + region: { name: 'region', type: 'text', label: 'Region' }, + rank: { name: 'rank', type: 'number', label: 'Rank' }, + }, + }, + [BOUND_OBJECT]: { + name: BOUND_OBJECT, + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + region: { name: 'region', type: 'text', label: 'Region' }, + }, + }, +}; + +type Row = Record; + +/** A stateful adapter: `update` writes the store and emits the `MutationEvent` the bridge listens to. */ +function makeAdapter(store: Record) { + const listeners = new Set<(e: { type: string; resource: string; id?: string }) => void>(); + return { + store, + find: vi.fn(async (object: string, params?: { $top?: number }) => { + const rows = (store[object] ?? []).map((r) => ({ ...r })); + return { data: typeof params?.$top === 'number' ? rows.slice(0, params.$top) : rows, total: rows.length }; + }), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async (object: string, id: string, patch: Row) => { + const row = (store[object] ?? []).find((r) => r.id === id); + if (row) Object.assign(row, patch); + for (const l of listeners) l({ type: 'update', resource: object, id }); + return row; + }), + delete: vi.fn(async () => true), + getObjectSchema: vi.fn(async (object: string) => SCHEMAS[object]), + onMutation: (cb: (e: { type: string; resource: string; id?: string }) => void) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + }; +} + +const PAGE_RECORD = { id: 'acc-1', name: 'Page account', region: 'Page region', rank: 7 }; +const BINDING = { object: BOUND_OBJECT, limit: 1 }; + +/** The record page's own providers, around whatever blocks it places. */ +function RecordPage({ adapter, children }: { adapter: ReturnType; children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} + +afterEach(() => cleanup()); + +describe('the record page path, and a bound block placed on it (objectui#12294)', () => { + it('an unbound block reads the page record, and reads no row of its own', async () => { + const adapter = makeAdapter({ [BOUND_OBJECT]: [{ id: 'env-1', name: 'Bound env' }] }); + render( + + + , + ); + expect(await screen.findByText('Page account')).toBeInTheDocument(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('a bound block shows the bound row instead — the binding overrides the page record', async () => { + const adapter = makeAdapter({ [BOUND_OBJECT]: [{ id: 'env-1', name: 'Bound env' }] }); + render( + + + , + ); + expect(await screen.findByText('Bound env')).toBeInTheDocument(); + expect(screen.queryByText('Page account')).not.toBeInTheDocument(); + expect(adapter.find.mock.calls[0][0]).toBe(BOUND_OBJECT); + }); + + it('a bound record:details does not join the page record\'s inline-edit session', async () => { + const adapter = makeAdapter({ [BOUND_OBJECT]: [{ id: 'env-1', name: 'Bound env', region: 'Bound region' }] }); + render( + +
+ +
+
+ +
+
, + ); + const page = screen.getByTestId('page-details'); + const bound = screen.getByTestId('bound-details'); + await within(page).findByText('Page region'); + await within(bound).findByText('Bound region'); + // Control: the page's own details offers in-place editing in this session, + // so the probe below can see the affordance when it is there. + await waitFor(() => expect(page.querySelector('svg.lucide-pencil')).not.toBeNull()); + // The bound record's fields never enter the page record's draft. + expect(bound.querySelector('svg.lucide-pencil')).toBeNull(); + }); + + it('a bound record:highlights does not trim the page record\'s details body', async () => { + const adapter = makeAdapter({ [BOUND_OBJECT]: [{ id: 'env-1', name: 'Bound env', region: 'Bound region' }] }); + const details = ; + + // Control: an UNBOUND highlights strip naming `region` takes it out of the + // page's details body — the dedupe the registry exists for. + const control = render( + + +
{details}
+
, + ); + await waitFor(() => expect(within(screen.getByTestId('body')).queryByText('Page region')).not.toBeInTheDocument()); + control.unmount(); + + render( + + +
{details}
+
, + ); + expect(await screen.findByText('Bound region')).toBeInTheDocument(); + expect(within(screen.getByTestId('body')).getByText('Page region')).toBeInTheDocument(); + }); +}); + +/** Mounts the app-wide mutation bridge, as the console does once per app. */ +function Bridge({ adapter }: { adapter: unknown }) { + useMutationInvalidationBridge(adapter); + return null; +} + +describe('a bound record block is on the data-invalidation bus (objectui#12294, objectstack#4444)', () => { + function mountBound(adapter: ReturnType) { + return render( + + + + , + ); + } + + it('re-reads the bound row after a write through the adapter elsewhere on the page', async () => { + const adapter = makeAdapter({ [BOUND_OBJECT]: [{ id: 'env-1', name: 'Before write' }] }); + mountBound(adapter); + expect(await screen.findByText('Before write')).toBeInTheDocument(); + + // e.g. an inline save in a `record:details` of the same row, or any form. + await act(async () => { + await adapter.update(BOUND_OBJECT, 'env-1', { name: 'After write' }); + }); + + expect(await screen.findByText('After write')).toBeInTheDocument(); + expect(screen.queryByText('Before write')).not.toBeInTheDocument(); + expect(adapter.find).toHaveBeenCalledTimes(2); + }); + + it('re-reads the bound row when a write is announced on the bus by hand', async () => { + const adapter = makeAdapter({ [BOUND_OBJECT]: [{ id: 'env-1', name: 'Before action' }] }); + mountBound(adapter); + expect(await screen.findByText('Before action')).toBeInTheDocument(); + + // e.g. a `record:quick_actions` server action: it writes over raw HTTP, + // past the adapter, and the host announces the change on the bus. + adapter.store[BOUND_OBJECT][0].name = 'After action'; + act(() => notifyDataChanged({ objectName: BOUND_OBJECT, recordId: 'env-1' })); + + expect(await screen.findByText('After action')).toBeInTheDocument(); + }); + + it('does not re-read for a write to another object', async () => { + const adapter = makeAdapter({ [BOUND_OBJECT]: [{ id: 'env-1', name: 'Steady' }] }); + mountBound(adapter); + expect(await screen.findByText('Steady')).toBeInTheDocument(); + act(() => notifyDataChanged({ objectName: PAGE_OBJECT, recordId: 'acc-1' })); + await act(async () => {}); + expect(adapter.find).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 9146423aee..48120285e3 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -7,7 +7,9 @@ * * `record:details` — page-level record component that renders the canonical * field-detail block. Reads the bound record from and - * synthesizes a DetailViewSchema for the existing DetailView component. + * synthesizes a DetailViewSchema for the existing DetailView component. A + * `dataSource` binding on the node supplies that record instead of the record + * page (objectui#12294, see `./recordBinding`). */ import React from 'react'; @@ -31,6 +33,7 @@ import { import { DetailView } from '../DetailView'; import { deriveFieldGroupDetailEntries, type FieldGroupDetailEntry } from '../synth/buildDefaultPageSchema'; import { useRecordAriaProps } from './recordComponentAria'; +import { recordBindingBlock } from './recordBinding'; /** Normalize a field entry (string | {field} | {name}) to its machine name. */ const fieldName = (entry: any): string | null => columnIdentity(entry) ?? null; @@ -221,7 +224,7 @@ export interface RecordDetailsRendererProps { [k: string]: any; } -export const RecordDetailsRenderer: React.FC = ({ +const RecordDetailsBody: React.FC = ({ // ⛔ NOT `{} as any` (objectui#8649). A destructuring default's type joins // the annotated property type at the binding, so `any` here ERASED // `RecordDetailsRendererProps` for every read site in this file: declared @@ -1000,4 +1003,16 @@ export const RecordDetailsRenderer: React.FC = ({ ); }; +/** + * `record:details` with the spec's per-element `dataSource` binding honoured + * (objectui#12294). Unbound, the body reads the record page's record exactly as + * before; bound, it reads the FIRST row of the bound set. `recordBindingBlock` + * (`./recordBinding`, which states the semantics) wraps the body in the gate and + * the one-record scope, and passes the result through the declaration seam. + * + * Wraps the EXPORTED name, as `record:related_list` does, so a host that + * imports this renderer directly gets the binding too. + */ +export const RecordDetailsRenderer: React.FC = recordBindingBlock(RecordDetailsBody, 'record:details'); + export default RecordDetailsRenderer; diff --git a/packages/plugin-detail/src/renderers/record-highlights.tsx b/packages/plugin-detail/src/renderers/record-highlights.tsx index 8c82018af3..18becbbee3 100644 --- a/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -16,6 +16,7 @@ import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import type { RecordHighlightsComponentProps } from '@object-ui/types'; import { HeaderHighlight } from '../HeaderHighlight'; import { useRecordAriaProps } from './recordComponentAria'; +import { recordBindingBlock } from './recordBinding'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -28,7 +29,7 @@ export interface RecordHighlightsRendererProps { [k: string]: any; } -export const RecordHighlightsRenderer: React.FC = ({ +const RecordHighlightsBody: React.FC = ({ // ⛔ NOT `{} as any` — the annotation-erasing default objectui#8649 repaired. // The mechanism and why the spelling tracks the annotation are written once, // at the same site in `record-details.tsx`. @@ -185,4 +186,16 @@ export const RecordHighlightsRenderer: React.FC = ); }; +/** + * `record:highlights` with the spec's per-element `dataSource` binding honoured + * (objectui#12294). Unbound, the body reads the record page's record exactly as + * before; bound, it reads the FIRST row of the bound set. `recordBindingBlock` + * (`./recordBinding`, which states the semantics) wraps the body in the gate and + * the one-record scope, and passes the result through the declaration seam. + * + * Wraps the EXPORTED name, as `record:related_list` does, so a host that + * imports this renderer directly gets the binding too. + */ +export const RecordHighlightsRenderer: React.FC = recordBindingBlock(RecordHighlightsBody, 'record:highlights'); + export default RecordHighlightsRenderer; diff --git a/packages/plugin-detail/src/renderers/record-quick-actions.tsx b/packages/plugin-detail/src/renderers/record-quick-actions.tsx index 59c98158e0..b7a0da1d1e 100644 --- a/packages/plugin-detail/src/renderers/record-quick-actions.tsx +++ b/packages/plugin-detail/src/renderers/record-quick-actions.tsx @@ -33,6 +33,7 @@ import type { ActionDef, ActionLocation } from '@object-ui/core'; import { resolveDeclaredActionIds } from '@object-ui/types'; import type { DeclaredActionsRefusal } from '@object-ui/types'; import { useRecordAriaProps, type AuthoredRecordAria } from './recordComponentAria'; +import { recordBindingBlock } from './recordBinding'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -110,7 +111,7 @@ export interface RecordQuickActionsRendererProps { [k: string]: any; } -export const RecordQuickActionsRenderer: React.FC = ({ +const RecordQuickActionsBody: React.FC = ({ schema = {} as any, className, ...props @@ -173,6 +174,13 @@ export const RecordQuickActionsRenderer: React.FC 0 ? getActionsForLocation(location) : []; + const visibleActions = actions.length > 0 && !noRecord ? getActionsForLocation(location) : []; if (visibleActions.length === 0) { return ( @@ -439,4 +447,16 @@ function QuickActionButton({ ); } +/** + * `record:quick_actions` with the spec's per-element `dataSource` binding honoured + * (objectui#12294). Unbound, the body reads the record page's record exactly as + * before; bound, it reads the FIRST row of the bound set. `recordBindingBlock` + * (`./recordBinding`, which states the semantics) wraps the body in the gate and + * the one-record scope, and passes the result through the declaration seam. + * + * Wraps the EXPORTED name, as `record:related_list` does, so a host that + * imports this renderer directly gets the binding too. + */ +export const RecordQuickActionsRenderer: React.FC = recordBindingBlock(RecordQuickActionsBody, 'record:quick_actions'); + export default RecordQuickActionsRenderer; diff --git a/packages/plugin-detail/src/renderers/recordBinding.tsx b/packages/plugin-detail/src/renderers/recordBinding.tsx new file mode 100644 index 0000000000..9411d8317f --- /dev/null +++ b/packages/plugin-detail/src/renderers/recordBinding.tsx @@ -0,0 +1,377 @@ +/** + * 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 ONE-RECORD binding of the record blocks (objectui#12294). + * + * `PageComponentSchema.dataSource` is the spec's per-element data binding, and + * the spec declares it on every page component ("Overrides the page-level + * object context for this block"). `ElementDataSourceGate` (`@object-ui/react`) + * honours it for the LIST-shaped blocks by mapping the binding onto keys the + * block then queries with. The record blocks query nothing: they read the row + * the record page put in `RecordContextProvider`. So on an `app` page, with no + * record page around them, `record:details`, `record:highlights` and + * `record:quick_actions` drew an empty box or "no actions configured" even + * with a binding that returns one row. + * + * This module is the one-record consumer those three share, and + * {@link recordBindingBlock} is the one way a record block takes it. The gate + * still resolves the binding (the named view, the AND-combined filter, the + * sort, and every configuration panel the family draws), so a record block's + * binding means exactly what it means on a list block. What this adds is the + * last hop: read the bound set, take its FIRST row, and provide that row + * through the existing `RecordContextProvider` — so the block bodies are + * unchanged and there is no second rendering path inside a block. + * + * ## Semantics of the bound set (the card's, verbatim in intent) + * + * - 0 rows → the block is mounted under a record context that names the bound + * object and NO record, so each block draws what it draws for a record that + * is not there: `record:details` its "record not found" state, + * `record:highlights` nothing, `record:quick_actions` its empty bar. A row + * the caller may not read never comes back from the server, and a read the + * server refuses outright (`classifyLoadError` → `forbidden`) is treated the + * same, so "absent" and "not yours to read" are indistinguishable. + * - 1 row → that record. + * - more than one row → the FIRST by the binding's `sort`. Only one row is + * ever asked for (`$top: 1`): the first row of the authored `limit` and the + * first row of a one-row read are the same row under the same `sort`. + * `objectui validate` warns on a record block whose binding has no + * `limit: 1`, so an author says which one they mean. + * + * The row is read with the adapter the page already uses, so under the + * caller's identity: nothing here widens what the server serves. + * + * ## The record page path is untouched + * + * {@link BoundRecordScope} renders its children AS THEY ARE when the block + * carries no binding: no provider, no element, no fetch. A record page's own + * `RecordContextProvider` therefore still reaches the block, byte-identically. + * With a binding, the binding wins, as the spec says it does: the bound record + * shadows the page record for this block only. + * + * ## Freshness: the data-invalidation bus (objectstack#4444's bar) + * + * A fetching record context that does not listen to the bus shows a STALE + * record that looks correct after a write elsewhere on the page — harder to + * notice than an empty one. So the read is keyed on `useDataInvalidation` for + * the bound OBJECT, the same bus `RecordDetailView` keys its record read on. + * Object-scoped, not row-scoped, because the binding is a query: a write to any + * row of the object can change which row is first. A refetch replaces the row + * in place (no remount), and an equal answer is not republished as a new object + * (AGENTS.md #10). + * + * ## What a bound block does NOT join + * + * A bound block sits inside the record page's providers when it is placed on a + * record page, and two of them are about THAT page's record: + * + * - the page's inline-edit session (`InlineEditProvider`): a bound + * `record:details` or `record:highlights` joining it would put the bound + * record's fields into the page record's draft, and the page's Save bar + * would write them to the page record. The bound scope mounts a read-only + * session instead (`canEdit: false`), which is also what an `app` page, + * with no session at all, already gives these blocks; + * - the page's highlight-field registry (`HighlightFieldsProvider`): a bound + * `record:highlights` would otherwise hide fields from the PAGE record's + * `record:details`. The bound scope gets its own registry. + */ + +import * as React from 'react'; +import { + ElementDataSourceErrorPanel, + ElementDataSourceGate, + HighlightFieldsProvider, + InlineEditProvider, + RecordContextProvider, + classifyLoadError, + noDataSourceMessage, + useDataInvalidation, + useFilterScope, + useResolvedDataSource, + useResolvedFilter, + type ElementDataSourceMapping, +} from '@object-ui/react'; +import { usePermissions } from '@object-ui/permissions'; +import { Skeleton } from '@object-ui/components'; +import { + buildExpandFields, + elementDataSourceBlock, + isElementDataSourceConfig, + withoutDeniedFields, +} from '@object-ui/core'; +import type { DataSource, QueryParams } from '@object-ui/types'; + +/** + * What the gate lands for a record block: the bound object on `objectName` + * (the gate's default object key), and the composed `filter` and `sort`. + * + * Read by {@link BoundRecordScope} only — the block bodies receive the schema + * as authored, so none of these keys reaches a body that does not read it. + * `limit` is deliberately unmapped: one row is read whatever the binding caps + * the set at (see the module doc). + */ +const RECORD_BLOCK_DATA_SOURCE: ElementDataSourceMapping = { filter: true, sort: true }; + +/** Stable stand-in for a missing `schema`, so the gate keeps one identity. */ +const NO_SCHEMA: Record = {}; + +/** Does this node carry a binding the gate will resolve? The gate's own predicate. */ +function isBound(schema: unknown): boolean { + return isElementDataSourceConfig((schema as { dataSource?: unknown } | null | undefined)?.dataSource); +} + +/** + * Wrap a record block's body so its `dataSource` binding supplies its record, + * and declare the key through the seam. + * + * The one way a record block takes the binding, so a fourth one cannot take it + * differently: `ElementDataSourceGate` resolves the binding (and draws the + * family's panels for an unresolvable view, a refused filter, or no adapter), + * {@link BoundRecordScope} reads and provides the first row, and + * `elementDataSourceBlock` makes the registration publish the injected + * `dataSource` input — from `@object-ui/core`, the import source + * `check:element-data-source-declaration` holds every call site to. + * + * `requiresDataSource` is set for a BOUND block only: a bound block with no + * adapter has nothing to read its row with, and says so through the family's + * no-data-source panel. An unbound block is the record page path, left as it was. + * + * @param Body The block's renderer, unchanged: it reads `useRecordContext()`. + * @param blockKey The registry key (`record:details`, …); the panel test ids are + * derived from it the way `record-related-list` spells its own. + */ +export function recordBindingBlock( + Body: React.ComponentType, + blockKey: string, +): React.FC { + const testId = recordBlockTestId(blockKey); + const Bound: React.FC = (props) => { + const bound = isBound(props.schema); + return ( + } + mapping={RECORD_BLOCK_DATA_SOURCE} + testId={testId} + requiresDataSource={bound} + noDataSourceMessage={ + bound + ? noDataSourceMessage(blockKey, (props.schema as { dataSource?: { object?: unknown } }).dataSource?.object) + : undefined + } + > + {(landed) => ( + + + + )} + + ); + }; + Bound.displayName = `RecordBinding(${blockKey})`; + return elementDataSourceBlock(Bound); +} + +/** `record:quick_actions` → `record-quick-actions`. */ +function recordBlockTestId(blockKey: string): string { + return blockKey.replace(/[:_]/g, '-'); +} + +interface BoundRecordScopeProps { + /** The block's schema as authored; its `dataSource` says whether it is bound. */ + schema: unknown; + /** The same schema with the gate's {@link RECORD_BLOCK_DATA_SOURCE} landing applied. */ + bound: unknown; + /** `data-testid` stem for this scope's own states (`-loading-record`, …). */ + testId: string; + children: React.ReactNode; +} + +/** + * The block's record context, from its binding when it has one. + * + * Unbound → the children as they are (the record page path). Bound → the + * first row of the bound set, provided through `RecordContextProvider`. + */ +function BoundRecordScope({ schema, bound, testId, children }: BoundRecordScopeProps): React.ReactElement { + if (!isBound(schema)) return <>{children}; + const landed = (bound ?? NO_SCHEMA) as { objectName?: unknown; filter?: unknown; sort?: unknown }; + // The gate calls its children only once the binding composed, and a composed + // binding always names its object (`isElementDataSourceConfig`). + const object = typeof landed.objectName === 'string' ? landed.objectName : ''; + return ( + + {children} + + ); +} + +/** Gated copies of bound rows, keyed on the served row object itself. */ +const READABLE_ROWS = new WeakMap(); + +/** + * The served row without the fields the loaded policy denies — the row a record + * page hands its blocks (`RecordDetailView`'s private `readableRow`, objectui#10499), + * and the SAME gated object for the same served row and the same kept keys, so a + * re-render never republishes an equal row as a new object (AGENTS.md #10). + * + * ⚠️ A second copy of that helper, written here rather than imported: it is + * module-private to `@object-ui/app-shell`, which depends on this package, not + * the other way round. Converging the two hosts' "fetch a record and provide + * it" onto one internal core is the hygiene objectstack#4444 names; it is not + * done in this change. + */ +function readableRow( + record: T, + perms: Pick, 'isLoaded' | 'checkField'>, + objectName: string, +): T { + const row = withoutDeniedFields(record, perms, objectName); + if (row === record || !record || typeof record !== 'object') return row; + const kept = JSON.stringify(Object.keys(row as object)); + const cached = READABLE_ROWS.get(record as object); + if (cached && cached.kept === kept) return cached.row as T; + READABLE_ROWS.set(record as object, { kept, row }); + return row; +} + +type BoundRow = Record; + +/** One settled read, tagged with the query it answered. */ +type BoundRead = + | { key: string; status: 'loaded'; row: BoundRow | undefined } + | { key: string; status: 'failed'; error: string }; + +const firstRow = (result: unknown): BoundRow | undefined => { + // `data` is the ONE rows member `QueryResult` (`@object-ui/types`) declares; + // the bare-array arm is the shape fakes at this seam answer with, as + // `element:number` reads it. + const rows = (result as { data?: unknown } | null | undefined)?.data ?? result; + const row = Array.isArray(rows) ? rows[0] : undefined; + return row && typeof row === 'object' && !Array.isArray(row) ? (row as BoundRow) : undefined; +}; + +/** The row's primary key, as `RelatedList` reads a row's id. */ +const rowId = (row: BoundRow | undefined): string | number | undefined => { + const id = row?.id ?? row?._id; + return typeof id === 'string' || typeof id === 'number' ? id : undefined; +}; + +const sameRow = (a: BoundRow | undefined, b: BoundRow | undefined): boolean => + a === b || (a !== undefined && b !== undefined && JSON.stringify(a) === JSON.stringify(b)); + +interface BoundRecordProviderProps { + object: string; + filter: unknown; + sort: unknown; + testId: string; + children: React.ReactNode; +} + +function BoundRecordProvider({ object, filter, sort, testId, children }: BoundRecordProviderProps): React.ReactElement { + const dataSource = useResolvedDataSource(); + const perms = usePermissions(); + // Placeholder tokens (`{current_user_id}`, the date macros) resolve the way + // every bound block resolves them. The scope is read OUTSIDE the bound + // record, so `{record_id}` is the record page's own record when there is one. + const queryFilter = useResolvedFilter(filter, useFilterScope()); + const filterKey = queryFilter === undefined ? '' : JSON.stringify(queryFilter); + const sortKey = sort === undefined ? '' : JSON.stringify(sort); + + // ── The object definition: field types and labels for the blocks, and the + // reference fields to `$expand`. Best-effort, as `DetailView` reads it. + const canReadSchema = typeof dataSource?.getObjectSchema === 'function'; + const [schemaRead, setSchemaRead] = React.useState<{ object: string; schema: unknown } | null>(null); + React.useEffect(() => { + if (!canReadSchema || !dataSource) return; + let cancelled = false; + Promise.resolve() + .then(() => dataSource.getObjectSchema(object)) + .then( + (schema) => { if (!cancelled) setSchemaRead({ object, schema }); }, + () => { if (!cancelled) setSchemaRead({ object, schema: undefined }); }, + ); + return () => { cancelled = true; }; + }, [canReadSchema, dataSource, object]); + const schemaReady = !canReadSchema || schemaRead?.object === object; + const objectSchema = schemaRead?.object === object ? schemaRead.schema : undefined; + + // Reference fields resolve to their display names, as the record page asks + // for them — and only the ones the loaded policy lets this caller read + // (field-level security on `$expand`, the record page's own gate). Held as a + // string so the read below keys on the list, not on an object (AGENTS.md #10). + const expandable = buildExpandFields((objectSchema as { fields?: Record } | undefined)?.fields); + const expandKey = JSON.stringify( + perms?.isLoaded ? expandable.filter((f) => perms.checkField(object, f, 'read')) : expandable, + ); + + const readKey = JSON.stringify([object, filterKey, sortKey, expandKey]); + // The bus: any write to the bound object re-reads in place (see module doc). + const invalidationNonce = useDataInvalidation(dataSource ? object : undefined); + const [read, setRead] = React.useState(null); + + React.useEffect(() => { + if (!dataSource || !schemaReady) return; + let cancelled = false; + const expand: string[] = JSON.parse(expandKey); + const params: QueryParams = { $top: 1 }; + if (filterKey) params.$filter = JSON.parse(filterKey); + if (sortKey) params.$orderby = JSON.parse(sortKey); + if (expand.length > 0) params.$expand = expand; + Promise.resolve() + .then(() => dataSource.find(object, params)) + .then( + (result) => { + if (cancelled) return; + const row = firstRow(result); + // An equal answer keeps the row object it replaces (AGENTS.md #10). + setRead((prev) => + prev && prev.key === readKey && prev.status === 'loaded' && sameRow(prev.row, row) + ? prev + : { key: readKey, status: 'loaded', row }, + ); + }, + (err: unknown) => { + if (cancelled) return; + setRead( + classifyLoadError(err) === 'forbidden' + ? { key: readKey, status: 'loaded', row: undefined } + : { key: readKey, status: 'failed', error: err instanceof Error ? err.message : String(err) }, + ); + }, + ); + return () => { cancelled = true; }; + // Keyed on the query's DATA — the strings above — never on the filter or + // sort objects they were read from (AGENTS.md #10). `invalidationNonce` is + // the bus: it moves when the bound object is written. + }, [dataSource, object, schemaReady, readKey, filterKey, sortKey, expandKey, invalidationNonce]); + + const current = read?.key === readKey ? read : null; + if (dataSource && !current) { + return ( +
+ +
+ ); + } + if (current?.status === 'failed') { + return ; + } + const row = current?.status === 'loaded' ? current.row : undefined; + return ( + + + {children} + + + ); +} diff --git a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts index d933e4c6fb..da42695ca9 100644 --- a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts @@ -135,6 +135,12 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ // node level beside its bag, whose `object` is required only where no binding // names one (objectui#12056). 'element:repeater', + // objectui#12294: the three one-record blocks read the binding (their + // renderers provide the first bound row as their record), so each arm + // declares it at node level, beside its bag. + 'record:details', + 'record:highlights', + 'record:quick_actions', ]); /** diff --git a/packages/types/src/__tests__/record-block-data-source-arms-12294.test.ts b/packages/types/src/__tests__/record-block-data-source-arms-12294.test.ts new file mode 100644 index 0000000000..d2d3451680 --- /dev/null +++ b/packages/types/src/__tests__/record-block-data-source-arms-12294.test.ts @@ -0,0 +1,121 @@ +/** + * 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 three ONE-RECORD blocks declare the node's `dataSource` binding, by + * reference to the spec (objectui#12294). + * + * `record:details`, `record:highlights` and `record:quick_actions` read the + * binding at runtime (`@object-ui/plugin-detail`, `renderers/recordBinding.tsx`), + * so their arms declare it, as `record:line_items` and the gate-wrapped + * ObjectQL blocks do. Before this the strict authoring face refused it as an + * undeclared key on all three, while the spec's own page component accepts it + * on every component. + * + * - every face, and the TypeScript authoring face, accepts the binding on the + * three; the spec's page component accepts the same nodes (lit control); + * - the binding's VALUE is the spec's verdict: a binding the spec refuses is + * refused at `dataSource`; + * - the member is the spec's `ElementDataSourceSchema` itself; + * - a record block that reads no binding still refuses the key (`record:path`). + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ElementDataSourceSchema, PageComponentSchema } from '@objectstack/spec/ui'; + +import { + RecordDetailsBlockSchema, + RecordHighlightsBlockSchema, + RecordQuickActionsBlockSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import type { PublicBlockNode } from '../authoring-nodes.js'; + +type Issue = { code: string; path: PropertyKey[]; keys?: string[] }; +type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } }; + +const FACES: ReadonlyArray Result]> = [ + ['tolerant', (document) => safeValidateSchema(document)], + ['strict', (document) => StrictAnyComponentSchema.safeParse(document)], +]; + +const issuesOf = (result: Result): Issue[] => (result.success ? [] : (result.error!.issues as unknown as Issue[])); +const pathsOf = (result: Result): string[] => issuesOf(result).map((issue) => issue.path.join('.')); + +const BINDING = { + object: 'sys_environment', + filter: [{ field: 'environment_type', operator: 'equals', value: 'production' }], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 1, +}; + +const ARMS = [ + ['record:details', RecordDetailsBlockSchema], + ['record:highlights', RecordHighlightsBlockSchema], + ['record:quick_actions', RecordQuickActionsBlockSchema], +] as const; + +describe.each(ARMS)('`%s` declares the node\'s `dataSource` binding (objectui#12294)', (type, arm) => { + const BOUND = { type, dataSource: BINDING }; + + it('the spec\'s own page component accepts the bound node (lit control)', () => { + expect(PageComponentSchema.safeParse(BOUND).success).toBe(true); + }); + + it.each(FACES)('%s face: the bound node parses', (_face, judge) => { + expect(judge(BOUND).success).toBe(true); + }); + + it('the member is the spec\'s `ElementDataSourceSchema`, through the import boundary', () => { + const member = (arm.shape.dataSource as unknown as z.ZodOptional).unwrap(); + const spec = ElementDataSourceSchema as unknown as z.ZodType; + expect((member as unknown as { _zod: { def: unknown } })._zod.def) + .toBe((spec as unknown as { _zod: { def: unknown } })._zod.def); + }); + + it.each(FACES)('%s face: a binding the spec refuses is refused at `dataSource`', (_face, judge) => { + for (const dataSource of ['sys_environment', { object: 'sys_environment', limit: 0 }, { limit: 1 }]) { + const document = { type, dataSource }; + // The verdict is the spec's, not this face's. + expect(PageComponentSchema.safeParse(document).success, JSON.stringify(dataSource)).toBe(false); + const result = judge(document); + expect(result.success, JSON.stringify(dataSource)).toBe(false); + expect(pathsOf(result).some((path) => path === 'dataSource' || path.startsWith('dataSource.')), JSON.stringify(dataSource)) + .toBe(true); + } + }); +}); + +describe('a record block that reads no binding still refuses it (objectui#12294)', () => { + it('`record:path`: the strict face refuses `dataSource` as undeclared', () => { + const result = StrictAnyComponentSchema.safeParse({ type: 'record:path', dataSource: BINDING }); + expect(result.success).toBe(false); + const issue = issuesOf(result as Result).find((i) => i.code === 'unrecognized_keys'); + expect(issue?.keys).toContain('dataSource'); + }); +}); + +describe('the TypeScript authoring face follows the arms (objectui#12294)', () => { + it('a typed node literal carries the binding on the three, and is refused it on `record:path`', () => { + // Fresh literals, so the literal member types (`'equals'`, `'desc'`) are checked as written. + const nodes: PublicBlockNode[] = [ + { type: 'record:details', dataSource: { object: 'sys_environment', filter: [{ field: 'environment_type', operator: 'equals', value: 'production' }], limit: 1 } }, + { type: 'record:highlights', dataSource: { object: 'sys_environment', sort: [{ field: 'created_at', order: 'desc' }], limit: 1 } }, + { type: 'record:quick_actions', dataSource: { object: 'sys_environment', limit: 1 } }, + ]; + // Control: the same node without the binding compiles, so the refusal below is the binding's. + const plain: PublicBlockNode = { type: 'record:path' }; + // @ts-expect-error -- `record:path` reads no binding, so its node declares none. + const refused: PublicBlockNode = { type: 'record:path', dataSource: { object: 'sys_environment', limit: 1 } }; + expect(plain.type).toBe('record:path'); + expect(nodes).toHaveLength(3); + expect(refused.type).toBe('record:path'); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index e455f02086..744f15201c 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4798,9 +4798,9 @@ const EXCLUSIONS: Readonly> = { 'public-blocks.zod.ts#PageSidebarBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:sidebar` literal + `properties`, which IS `ComponentPropsMap['page:sidebar']`, + the objectui#10872 batch-6 `body` / `children` refusals (its renderer reads the child list, whose home is the row's `properties.children`); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordDetailsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`, + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordHighlightsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:highlights` literal + `properties`, which IS `ComponentPropsMap['record:highlights']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:highlights` literal + `properties`, which IS `ComponentPropsMap['record:highlights']`, + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordRelatedListBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `record:related_list` literal + `properties`, which IS `ComponentPropsMap['record:related_list']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordPathBlockSchema': @@ -4812,7 +4812,7 @@ const EXCLUSIONS: Readonly> = { 'public-blocks.zod.ts#RecordHistoryBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `record:history` literal + `properties`, which IS `ComponentPropsMap['record:history']`, + the objectui#11321 `entries` / `loading` host-feed-slot refusals (the host's channel, never authored), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordQuickActionsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:quick_actions` literal + `properties`, which IS `ComponentPropsMap['record:quick_actions']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:quick_actions` literal + `properties`, which IS `ComponentPropsMap['record:quick_actions']`, + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordReferenceRailBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `record:reference_rail` literal + `properties`, which IS `ComponentPropsMap['record:reference_rail']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordAlertBlockSchema': diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index fe23fab6f3..aabe0bf9cf 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -301,7 +301,8 @@ export function propsBag(type: string, row: T, description? * ⛔ The other five stay undeclared (no producer; release condition "additive, * when a producer needs them", objectui#10872). `dataSource` is declared per * arm where a renderer reads the binding (`element:number`, - * `record:line_items`, and the ObjectQL blocks), not here. ⛔ Not on `BaseSchema`: that widens every arm of + * `record:line_items`, the three one-record blocks `record:details`, + * `record:highlights` and `record:quick_actions`, and the ObjectQL blocks), not here. ⛔ Not on `BaseSchema`: that widens every arm of * `AnyComponentSchema`, a different accept-set change from the arms above, * each of which has a measured producer. * @@ -638,6 +639,36 @@ export const PageSidebarBlockSchema = BaseSchema.extend({ /* ── record: — record-context blocks ────────────────────────────────────── */ +/** + * The node-level `dataSource` binding of the three ONE-RECORD blocks — + * `record:details`, `record:highlights` and `record:quick_actions` + * (objectui#12294). + * + * `@objectstack/spec`'s `PageComponentSchema.dataSource` declares the binding + * on every page component, and this module declares it per arm where a renderer + * reads it (see `NODE_ENVELOPE`). These three renderers read it: each is + * gate-wrapped (`elementDataSourceBlock`, `ElementDataSourceGate`), resolves + * the binding to the FIRST row of the bound set under the caller's identity, + * and provides that row as the block's record context — so a product `app` + * page binds its record by filter, with no record page around it. Left + * undeclared, the strict authoring face would refuse the binding these + * renderers honour. + * + * The spec's `ElementDataSourceSchema`, by reference, as `record:line_items`, + * `element:number` and the ObjectQL blocks declare it. ONE constant for the + * three arms, because it is one binding with one meaning on all three. + * `record:related_list` is NOT one of them: its binding is the binding of its + * CHILD rows, a different meaning (objectstack#6953). + */ +const RECORD_BLOCK_DATA_SOURCE = stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe( + 'Per-element data binding — `@objectstack/spec` `ElementDataSourceSchema`, the schema ' + + '`PageComponentSchema.dataSource` declares, by reference. Binds the ONE record this block shows: ' + + 'the first row of the bound set by the binding\'s `sort` (declare `limit: 1`), read under the ' + + 'caller\'s identity; it overrides the record page\'s record for this block.', + ); + /** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:details`. */ const RECORD_DETAILS_NEITHER_CHANNEL = neitherContentChannelGuidance( 'record:details', @@ -653,6 +684,8 @@ export const RecordDetailsBlockSchema = BaseSchema.extend({ // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('record:details', stripImportedDefaults(SpecRecordDetailsProps)), properties: propsBag('record:details', stripImportedDefaults(SpecRecordDetailsProps)), + // objectui#12294: the one-record binding this block's renderer reads (`RECORD_BLOCK_DATA_SOURCE`). + dataSource: RECORD_BLOCK_DATA_SOURCE, // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). body: retirementTombstone(RECORD_DETAILS_NEITHER_CHANNEL), @@ -674,6 +707,8 @@ export const RecordHighlightsBlockSchema = BaseSchema.extend({ // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('record:highlights', stripImportedDefaults(SpecRecordHighlightsProps)), properties: propsBag('record:highlights', stripImportedDefaults(SpecRecordHighlightsProps)), + // objectui#12294: the one-record binding this block's renderer reads (`RECORD_BLOCK_DATA_SOURCE`). + dataSource: RECORD_BLOCK_DATA_SOURCE, // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). body: retirementTombstone(RECORD_HIGHLIGHTS_NEITHER_CHANNEL), @@ -924,6 +959,8 @@ export const RecordQuickActionsBlockSchema = BaseSchema.extend({ // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('record:quick_actions', stripImportedDefaults(SpecRecordQuickActionsProps)), properties: propsBag('record:quick_actions', stripImportedDefaults(SpecRecordQuickActionsProps)), + // objectui#12294: the one-record binding this block's renderer reads (`RECORD_BLOCK_DATA_SOURCE`). + dataSource: RECORD_BLOCK_DATA_SOURCE, // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). body: retirementTombstone(RECORD_QUICK_ACTIONS_NEITHER_CHANNEL),