From 44755f60846c113d1c76666129d5f5f3ab3eb370 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:02:37 +0000 Subject: [PATCH 1/7] feat(plugin-detail,types,cli): record blocks honour the per-element dataSource binding record:details, record:highlights and record:quick_actions now read their record from the node's `dataSource` binding when one is set: the gate resolves the binding as on every bound block, and a shared one-record scope reads the first row of the bound set under the caller's identity and provides it through RecordContextProvider. Unbound, the record page path is unchanged. The strict authoring face declares the binding on the three arms by reference to the spec's ElementDataSourceSchema, and `objectui validate` warns on a bound record block without `limit: 1`. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude --- packages/cli/src/commands/validate.ts | 13 +- .../src/utils/record-block-binding-hint.ts | 89 +++++ .../src/renderers/record-details.tsx | 31 +- .../src/renderers/record-highlights.tsx | 27 +- .../src/renderers/record-quick-actions.tsx | 36 +- .../src/renderers/recordBinding.tsx | 344 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 6 +- packages/types/src/zod/public-blocks.zod.ts | 39 +- 8 files changed, 572 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/utils/record-block-binding-hint.ts create mode 100644 packages/plugin-detail/src/renderers/recordBinding.tsx 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/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 9146423aee..142f344b49 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -7,11 +7,13 @@ * * `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'; -import { useRecordContext, useHighlightFieldNames, useSafeFieldLabel, usePredicateScope } from '@object-ui/react'; +import { ElementDataSourceGate, useRecordContext, useHighlightFieldNames, useSafeFieldLabel, usePredicateScope } from '@object-ui/react'; import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { RecordDetailsComponentProps } from '@object-ui/types'; @@ -19,6 +21,7 @@ import { columnIdentity, declaredNameField, deriveTitleField, + elementDataSourceBlock, formatTitleTemplate, resolveAffordance, type FieldRulePredicate, @@ -31,6 +34,7 @@ import { import { DetailView } from '../DetailView'; import { deriveFieldGroupDetailEntries, type FieldGroupDetailEntry } from '../synth/buildDefaultPageSchema'; import { useRecordAriaProps } from './recordComponentAria'; +import { BoundRecordScope, recordBlockGateProps, recordBlockTestId } from './recordBinding'; /** Normalize a field entry (string | {field} | {name}) to its machine name. */ const fieldName = (entry: any): string | null => columnIdentity(entry) ?? null; @@ -221,7 +225,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 +1004,25 @@ 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, provided by + * `BoundRecordScope` (`./recordBinding`, which states the semantics). The gate + * resolves the binding the way every bound block resolves it, and the seam + * (`elementDataSourceBlock`) makes this registration declare the key. + * + * 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 = elementDataSourceBlock((props) => ( + + {(bound) => ( + + + + )} + +)); + 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..dba36af990 100644 --- a/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -11,11 +11,13 @@ */ import React from 'react'; -import { useRecordContext, useRegisterHighlightFields } from '@object-ui/react'; +import { ElementDataSourceGate, useRecordContext, useRegisterHighlightFields } from '@object-ui/react'; +import { elementDataSourceBlock } from '@object-ui/core'; import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import type { RecordHighlightsComponentProps } from '@object-ui/types'; import { HeaderHighlight } from '../HeaderHighlight'; import { useRecordAriaProps } from './recordComponentAria'; +import { BoundRecordScope, recordBlockGateProps, recordBlockTestId } from './recordBinding'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -28,7 +30,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 +187,25 @@ 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, provided by + * `BoundRecordScope` (`./recordBinding`, which states the semantics). The gate + * resolves the binding the way every bound block resolves it, and the seam + * (`elementDataSourceBlock`) makes this registration declare the key. + * + * 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 = elementDataSourceBlock((props) => ( + + {(bound) => ( + + + + )} + +)); + 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..ae77184ac6 100644 --- a/packages/plugin-detail/src/renderers/record-quick-actions.tsx +++ b/packages/plugin-detail/src/renderers/record-quick-actions.tsx @@ -16,7 +16,8 @@ */ import React from 'react'; -import { useRecordContext, useActionEngine, useMetadataItem, useCondition, toPredicateInput, useActionTextLocalizer } from '@object-ui/react'; +import { ElementDataSourceGate, useRecordContext, useActionEngine, useMetadataItem, useCondition, toPredicateInput, useActionTextLocalizer } from '@object-ui/react'; +import { elementDataSourceBlock } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { Button, @@ -33,6 +34,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 { BoundRecordScope, recordBlockGateProps, recordBlockTestId } from './recordBinding'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -110,7 +112,7 @@ export interface RecordQuickActionsRendererProps { [k: string]: any; } -export const RecordQuickActionsRenderer: React.FC = ({ +const RecordQuickActionsBody: React.FC = ({ schema = {} as any, className, ...props @@ -173,6 +175,13 @@ export const RecordQuickActionsRenderer: React.FC 0 ? getActionsForLocation(location) : []; + const visibleActions = actions.length > 0 && !noRecord ? getActionsForLocation(location) : []; if (visibleActions.length === 0) { return ( @@ -439,4 +448,25 @@ 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, provided by + * `BoundRecordScope` (`./recordBinding`, which states the semantics). The gate + * resolves the binding the way every bound block resolves it, and the seam + * (`elementDataSourceBlock`) makes this registration declare the key. + * + * 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 = elementDataSourceBlock((props) => ( + + {(bound) => ( + + + + )} + +)); + 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..66ebc9d0c4 --- /dev/null +++ b/packages/plugin-detail/src/renderers/recordBinding.tsx @@ -0,0 +1,344 @@ +/** + * 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. 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, + 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, 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). + */ +export 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); +} + +/** + * The `ElementDataSourceGate` props of a record block, other than `children`. + * + * `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 and is left + * exactly as it was. + * + * @param schema The block's schema as authored. + * @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 recordBlockGateProps(schema: unknown, blockKey: string) { + const bound = isBound(schema); + return { + schema: (schema ?? NO_SCHEMA) as Record, + mapping: RECORD_BLOCK_DATA_SOURCE, + testId: recordBlockTestId(blockKey), + requiresDataSource: bound, + noDataSourceMessage: bound + ? noDataSourceMessage(blockKey, (schema as { dataSource?: { object?: unknown } }).dataSource?.object) + : undefined, + }; +} + +/** `record:quick_actions` → `record-quick-actions`. */ +export function recordBlockTestId(blockKey: string): string { + return blockKey.replace(/[:_]/g, '-'); +} + +export 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`. + */ +export 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__/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), From 197ba41bf046ffd25793ff808a50fd62e8d3e2c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:08:46 +0000 Subject: [PATCH 2/7] refactor(plugin-detail): one recordBindingBlock seam for the record blocks; tests The gate, the one-record scope and the declaration seam move into one `recordBindingBlock` wrapper, so a record block takes the binding one way and cannot forget the seam. Tests pin each block on an app page with one, zero and two bound rows, a refused and a failed read, the record page path, the bound scope's isolation from the page's inline-edit session and highlight registry, the data-invalidation bus, the validate warning and the strict face. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude --- ...e-record-block-binding-limit-12294.test.ts | 161 ++++++++++ .../recordBinding.appPage-12294.test.tsx | 275 ++++++++++++++++++ ...ordBinding.recordPageAndBus-12294.test.tsx | 241 +++++++++++++++ .../src/renderers/record-details.tsx | 22 +- .../src/renderers/record-highlights.tsx | 22 +- .../src/renderers/record-quick-actions.tsx | 22 +- .../src/renderers/recordBinding.tsx | 89 ++++-- ...ecord-block-data-source-arms-12294.test.ts | 117 ++++++++ 8 files changed, 873 insertions(+), 76 deletions(-) create mode 100644 packages/cli/src/__tests__/validate-record-block-binding-limit-12294.test.ts create mode 100644 packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx create mode 100644 packages/plugin-detail/src/renderers/__tests__/recordBinding.recordPageAndBus-12294.test.tsx create mode 100644 packages/types/src/__tests__/record-block-data-source-arms-12294.test.ts 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/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 142f344b49..48120285e3 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -13,7 +13,7 @@ */ import React from 'react'; -import { ElementDataSourceGate, useRecordContext, useHighlightFieldNames, useSafeFieldLabel, usePredicateScope } from '@object-ui/react'; +import { useRecordContext, useHighlightFieldNames, useSafeFieldLabel, usePredicateScope } from '@object-ui/react'; import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { RecordDetailsComponentProps } from '@object-ui/types'; @@ -21,7 +21,6 @@ import { columnIdentity, declaredNameField, deriveTitleField, - elementDataSourceBlock, formatTitleTemplate, resolveAffordance, type FieldRulePredicate, @@ -34,7 +33,7 @@ import { import { DetailView } from '../DetailView'; import { deriveFieldGroupDetailEntries, type FieldGroupDetailEntry } from '../synth/buildDefaultPageSchema'; import { useRecordAriaProps } from './recordComponentAria'; -import { BoundRecordScope, recordBlockGateProps, recordBlockTestId } from './recordBinding'; +import { recordBindingBlock } from './recordBinding'; /** Normalize a field entry (string | {field} | {name}) to its machine name. */ const fieldName = (entry: any): string | null => columnIdentity(entry) ?? null; @@ -1007,22 +1006,13 @@ const RecordDetailsBody: 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, provided by - * `BoundRecordScope` (`./recordBinding`, which states the semantics). The gate - * resolves the binding the way every bound block resolves it, and the seam - * (`elementDataSourceBlock`) makes this registration declare the key. + * 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 = elementDataSourceBlock((props) => ( - - {(bound) => ( - - - - )} - -)); +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 dba36af990..18becbbee3 100644 --- a/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -11,13 +11,12 @@ */ import React from 'react'; -import { ElementDataSourceGate, useRecordContext, useRegisterHighlightFields } from '@object-ui/react'; -import { elementDataSourceBlock } from '@object-ui/core'; +import { useRecordContext, useRegisterHighlightFields } from '@object-ui/react'; import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import type { RecordHighlightsComponentProps } from '@object-ui/types'; import { HeaderHighlight } from '../HeaderHighlight'; import { useRecordAriaProps } from './recordComponentAria'; -import { BoundRecordScope, recordBlockGateProps, recordBlockTestId } from './recordBinding'; +import { recordBindingBlock } from './recordBinding'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -190,22 +189,13 @@ const RecordHighlightsBody: 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, provided by - * `BoundRecordScope` (`./recordBinding`, which states the semantics). The gate - * resolves the binding the way every bound block resolves it, and the seam - * (`elementDataSourceBlock`) makes this registration declare the key. + * 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 = elementDataSourceBlock((props) => ( - - {(bound) => ( - - - - )} - -)); +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 ae77184ac6..b7a0da1d1e 100644 --- a/packages/plugin-detail/src/renderers/record-quick-actions.tsx +++ b/packages/plugin-detail/src/renderers/record-quick-actions.tsx @@ -16,8 +16,7 @@ */ import React from 'react'; -import { ElementDataSourceGate, useRecordContext, useActionEngine, useMetadataItem, useCondition, toPredicateInput, useActionTextLocalizer } from '@object-ui/react'; -import { elementDataSourceBlock } from '@object-ui/core'; +import { useRecordContext, useActionEngine, useMetadataItem, useCondition, toPredicateInput, useActionTextLocalizer } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { Button, @@ -34,7 +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 { BoundRecordScope, recordBlockGateProps, recordBlockTestId } from './recordBinding'; +import { recordBindingBlock } from './recordBinding'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -451,22 +450,13 @@ 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, provided by - * `BoundRecordScope` (`./recordBinding`, which states the semantics). The gate - * resolves the binding the way every bound block resolves it, and the seam - * (`elementDataSourceBlock`) makes this registration declare the key. + * 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 = elementDataSourceBlock((props) => ( - - {(bound) => ( - - - - )} - -)); +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 index 66ebc9d0c4..35cbaf3533 100644 --- a/packages/plugin-detail/src/renderers/recordBinding.tsx +++ b/packages/plugin-detail/src/renderers/recordBinding.tsx @@ -17,13 +17,14 @@ * `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. 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. + * 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) * @@ -82,6 +83,7 @@ import * as React from 'react'; import { ElementDataSourceErrorPanel, + ElementDataSourceGate, HighlightFieldsProvider, InlineEditProvider, RecordContextProvider, @@ -95,7 +97,12 @@ import { } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { Skeleton } from '@object-ui/components'; -import { buildExpandFields, isElementDataSourceConfig, withoutDeniedFields } from '@object-ui/core'; +import { + buildExpandFields, + elementDataSourceBlock, + isElementDataSourceConfig, + withoutDeniedFields, +} from '@object-ui/core'; import type { DataSource, QueryParams } from '@object-ui/types'; /** @@ -107,7 +114,7 @@ import type { DataSource, QueryParams } from '@object-ui/types'; * `limit` is deliberately unmapped: one row is read whatever the binding caps * the set at (see the module doc). */ -export const RECORD_BLOCK_DATA_SOURCE: ElementDataSourceMapping = { filter: true, sort: true }; +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 = {}; @@ -118,36 +125,62 @@ function isBound(schema: unknown): boolean { } /** - * The `ElementDataSourceGate` props of a record block, other than `children`. + * 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 and is left - * exactly as it was. + * no-data-source panel. An unbound block is the record page path, left as it was. * - * @param schema The block's schema as authored. - * @param blockKey The registry key (`record:details`, …); the panel test ids - * are derived from it the way `record-related-list` spells its own. + * @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 recordBlockGateProps(schema: unknown, blockKey: string) { - const bound = isBound(schema); - return { - schema: (schema ?? NO_SCHEMA) as Record, - mapping: RECORD_BLOCK_DATA_SOURCE, - testId: recordBlockTestId(blockKey), - requiresDataSource: bound, - noDataSourceMessage: bound - ? noDataSourceMessage(blockKey, (schema as { dataSource?: { object?: unknown } }).dataSource?.object) - : undefined, +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`. */ -export function recordBlockTestId(blockKey: string): string { +function recordBlockTestId(blockKey: string): string { return blockKey.replace(/[:_]/g, '-'); } -export interface BoundRecordScopeProps { +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. */ @@ -163,7 +196,7 @@ export interface BoundRecordScopeProps { * Unbound → the children as they are (the record page path). Bound → the * first row of the bound set, provided through `RecordContextProvider`. */ -export function BoundRecordScope({ schema, bound, testId, children }: BoundRecordScopeProps): React.ReactElement { +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 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..9626aadd12 --- /dev/null +++ b/packages/types/src/__tests__/record-block-data-source-arms-12294.test.ts @@ -0,0 +1,117 @@ +/** + * 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`', () => { + const nodes: PublicBlockNode[] = [ + { type: 'record:details', dataSource: BINDING }, + { type: 'record:highlights', dataSource: BINDING }, + { type: 'record:quick_actions', dataSource: { object: 'sys_environment', limit: 1 } }, + ]; + // @ts-expect-error -- `record:path` reads no binding, so its node declares none. + const refused: PublicBlockNode = { type: 'record:path', dataSource: BINDING }; + expect(nodes).toHaveLength(3); + expect(refused.type).toBe('record:path'); + }); +}); From ed6358deb59a0c065359956ad333abb7a5aba2a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:12:08 +0000 Subject: [PATCH 3/7] docs(plugin-detail): record blocks bound by dataSource; changeset The data-source guide's consumer table gains the three one-record blocks and a paragraph on their semantics; the plugin-detail README documents the binding. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude --- .../12294-record-blocks-element-datasource.md | 30 +++++++++++++++++ content/docs/guide/data-source.md | 33 +++++++++++++++++++ packages/plugin-detail/README.md | 25 ++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 .changeset/12294-record-blocks-element-datasource.md 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/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index b62c04ab8b..5ec4fd036d 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -272,6 +272,9 @@ 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 @@ -287,6 +290,36 @@ 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. +The three one-record blocks — `record:details`, `record:highlights` and +`record:quick_actions` — have no object key to land the binding on: they 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/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 From d21dbecd24c1b64c81c566718f7fe8a268565391 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:22:19 +0000 Subject: [PATCH 4/7] test: register the record blocks' injected dataSource with the parity ledgers The repo-wide registry parity test gains member pins for record:details, record:highlights and record:quick_actions `dataSource`; the two per-block parity tests take the node-level spec keys the related list's already takes; the binding wrapper names its props type the renderer-props way. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude --- .../registry-inputs-spec-parity.test.ts | 12 ++++++++++++ .../recordDetailsInputs.spec-parity.test.ts | 19 +++++++++++++++++-- ...recordHighlightsInputs.spec-parity.test.ts | 19 +++++++++++++++++-- .../src/renderers/recordBinding.tsx | 8 ++++---- 4 files changed, 50 insertions(+), 8 deletions(-) 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/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/recordBinding.tsx b/packages/plugin-detail/src/renderers/recordBinding.tsx index 35cbaf3533..9411d8317f 100644 --- a/packages/plugin-detail/src/renderers/recordBinding.tsx +++ b/packages/plugin-detail/src/renderers/recordBinding.tsx @@ -144,12 +144,12 @@ function isBound(schema: unknown): boolean { * @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

, +export function recordBindingBlock( + Body: React.ComponentType, blockKey: string, -): React.FC

{ +): React.FC { const testId = recordBlockTestId(blockKey); - const Bound: React.FC

= (props) => { + const Bound: React.FC = (props) => { const bound = isBound(props.schema); return ( Date: Tue, 13 Oct 2026 03:30:15 +0000 Subject: [PATCH 5/7] test(types): the three one-record arms join the node-level dataSource ledger Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude --- .../__tests__/public-block-responsive-styles-10872.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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', ]); /** From 4f59ed11b7c23cef43a49425cb13bf41078396a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:50:13 +0000 Subject: [PATCH 6/7] test(types): type the authoring-face pin with fresh literals and a control Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude --- .../record-block-data-source-arms-12294.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index 9626aadd12..d2d3451680 100644 --- 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 @@ -104,13 +104,17 @@ describe('a record block that reads no binding still refuses it (objectui#12294) 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: BINDING }, - { type: 'record:highlights', dataSource: BINDING }, + { 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: BINDING }; + 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'); }); From 1310a8f5989eee717174b0d066c427a9914b08c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:10:20 +0000 Subject: [PATCH 7/7] test(console),docs: the one-record blocks' binding lands on no schema key The binding-doc ledger in objectname-binding-required-11605 read the three one-record rows ("its record") as objectName blocks. Their binding lands on the record scope, not on a schema key, so the "lands on no key" exception is now exactly `element:*` plus record:details, record:highlights and record:quick_actions, named one by one; record:related_list keeps objectName, no `object-*` block can fall into it, and the doc says why. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude --- .../objectname-binding-required-11605.test.ts | 45 ++++++++++++++----- content/docs/guide/data-source.md | 14 +++--- 2 files changed, 42 insertions(+), 17 deletions(-) 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/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index 5ec4fd036d..b7d441c18b 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -281,8 +281,8 @@ 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 @@ -290,9 +290,13 @@ 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. -The three one-record blocks — `record:details`, `record:highlights` and -`record:quick_actions` — have no object key to land the binding on: they show -the record their record context carries. Bound, a block reads the FIRST row of +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: