From 83fdc295c04f8731f601e66eac1d61ec67951bb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:06:56 +0000 Subject: [PATCH 1/2] fix(plugin-detail): fold a declared pointer pair's type half into the card (objectui#13102) record:details drew a referenceVia pair's type half (object_name) as its own row of stored machine name beside the record card the id half draws. The type half now leaves the body when its id half is drawn in the same body, read from the object's declared pair only. The row value stays on the record, so the card still resolves the pair. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/13102-details-pointer-type-half.md | 11 ++ ...ord-details.pointerTypeHalf-13102.test.tsx | 171 ++++++++++++++++++ .../src/renderers/record-details.tsx | 78 +++++++- 3 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 .changeset/13102-details-pointer-type-half.md create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-details.pointerTypeHalf-13102.test.tsx diff --git a/.changeset/13102-details-pointer-type-half.md b/.changeset/13102-details-pointer-type-half.md new file mode 100644 index 0000000000..1a2e22cd50 --- /dev/null +++ b/.changeset/13102-details-pointer-type-half.md @@ -0,0 +1,11 @@ +--- +'@object-ui/plugin-detail': patch +--- + +`record:details` no longer draws a record pointer's type half as its own row (objectui#13102). + +An object can declare a polymorphic record pointer as a pair: the id half (for example `record_id`) names its sibling with `referenceVia` (for example `object_name`), and the sibling holds the target object's machine name. The Details body draws the id half as the record card, which names the target object by its label. The type half also drew as a row of its own, so the page showed the stored machine name next to the label that names the same object. This affected the record pages of objects such as `sys_record_share`, `sys_share_link`, `sys_audit_log` and the approval objects. + +The type half now leaves the body when its id half is drawn in the same body. The pair is read from the object's declared fields only, never from a field's name. A type half whose id half the body does not draw keeps its row. The record still carries the value, so the card still resolves the pair. An object that declares no pair draws exactly what it drew before. + +Nothing is added to the package entry: no export, prop or key. diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.pointerTypeHalf-13102.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.pointerTypeHalf-13102.test.tsx new file mode 100644 index 0000000000..a22c6e8433 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.pointerTypeHalf-13102.test.tsx @@ -0,0 +1,171 @@ +/** + * 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. + */ + +/** + * `record:details` folds a declared pointer pair's type half into its id + * half's card (objectui#13102). + * + * The id half (`record_id`) names its sibling with `referenceVia` + * (`object_name`); `DetailSection` draws the id half as the record card, which + * names the target object by its label. The type half used to draw again as a + * row of its own, the stored machine name beside that card. It now leaves the + * body when its id half is drawn in the same body. + * + * Pinned both ways: + * - an object that declares a pair: the card draws, the type half's row does + * not, and the card still resolves the pair from the row's type half; + * - controls: an object with no pair draws every listed field as before, and a + * text field called `object_name` that no field names in `referenceVia` + * still draws. The fold reads the declared pair, never a field name. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { HighlightFieldsProvider, RecordContextProvider } from '@object-ui/react'; +import { + getCellRenderer, + registerFieldRenderer, + RECORD_POINTER_CARD_TYPE, + type CellRendererProps, +} from '@object-ui/fields'; +import { RecordDetailsRenderer } from '../record-details'; + +const SHARE = 'sys_record_share'; + +/** An object declaring one pointer pair, as objectstack's sharing objects do. */ +const SHARE_DEF = { + name: SHARE, + label: 'Record Share', + nameField: 'name', + fields: { + name: { type: 'text', label: 'Name' }, + object_name: { type: 'text', label: 'Target Object Type' }, + record_id: { type: 'text', label: 'Shared Record', referenceVia: 'object_name' }, + access_level: { type: 'text', label: 'Access Level' }, + }, +}; + +const SHARE_ROW = { + id: 'shr_1', + name: 'Share one', + object_name: 'invoice', + record_id: 'inv_1', + access_level: 'access-value', +}; + +/** + * The same field names, and no field declaring `referenceVia`. Both objects + * declare a `nameField`, so the page-H1 dedupe takes `name` and no field this + * file reads. + */ +const PLAIN_DEF = { + name: 'plain_log', + label: 'Plain Log', + nameField: 'name', + fields: { + name: { type: 'text', label: 'Name' }, + object_name: { type: 'text', label: 'Target Object Type' }, + record_id: { type: 'text', label: 'Shared Record' }, + access_level: { type: 'text', label: 'Access Level' }, + }, +}; + +const ALL_FIELDS = ['object_name', 'record_id', 'access_level']; + +function renderBody(objectSchema: Record, data: Record, details: Record) { + const dataSource = { getObjectSchema: async () => objectSchema }; + return render( + + +
+ +
+
+
, + ); +} + +const body = () => within(screen.getByTestId('body')); + +const DEFAULT_POINTER_FACE = getCellRenderer(RECORD_POINTER_CARD_TYPE); + +/** A face that prints the pair it was handed, standing in for the host's record card. */ +const ProbeFace: React.FC = ({ value }) => ( + {JSON.stringify(value)} +); + +beforeAll(() => Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })); + +beforeEach(() => { + // `useRecordEditable` probes `POST /api/v1/security/explain`; serve it from a + // double so no real socket opens. Its answer is orthogonal to which rows draw. + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true }), + text: async () => '{"allowed":true}', + })) as never); + registerFieldRenderer(RECORD_POINTER_CARD_TYPE, ProbeFace); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + registerFieldRenderer(RECORD_POINTER_CARD_TYPE, DEFAULT_POINTER_FACE); +}); + +describe('record:details folds a declared pointer pair type half into the card (objectui#13102)', () => { + it('a pair: the Details body draws the card and no type-half row', async () => { + renderBody(SHARE_DEF, SHARE_ROW, { sections: [{ label: 'Details', fields: ALL_FIELDS }] }); + + const probes = await body().findAllByTestId('pointer-probe'); + expect(probes).toHaveLength(1); + // The card still resolves the pair from the row's type half. + expect(JSON.parse(probes[0].textContent ?? 'null')).toEqual({ objectName: 'invoice', recordId: 'inv_1' }); + + expect(body().getByText('access-value')).toBeInTheDocument(); + expect(body().queryByText('Target Object Type')).not.toBeInTheDocument(); + expect(body().queryByText('invoice')).not.toBeInTheDocument(); + }); + + it('a pair, listed as a direct fields body: the same fold', async () => { + renderBody(SHARE_DEF, SHARE_ROW, { fields: ALL_FIELDS }); + + expect(await body().findAllByTestId('pointer-probe')).toHaveLength(1); + await waitFor(() => expect(body().getByText('access-value')).toBeInTheDocument()); + expect(body().queryByText('Target Object Type')).not.toBeInTheDocument(); + expect(body().queryByText('invoice')).not.toBeInTheDocument(); + }); + + it('a pair whose id half this body does not draw: the type half keeps its row', async () => { + renderBody(SHARE_DEF, SHARE_ROW, { fields: ['object_name', 'access_level'] }); + + await waitFor(() => expect(body().getByText('access-value')).toBeInTheDocument()); + expect(body().queryAllByTestId('pointer-probe')).toHaveLength(0); + expect(body().getByText('Target Object Type')).toBeInTheDocument(); + expect(body().getByText('invoice')).toBeInTheDocument(); + }); + + it('control: an object without a pair draws every listed field, object_name included', async () => { + renderBody(PLAIN_DEF, SHARE_ROW, { sections: [{ label: 'Details', fields: ALL_FIELDS }] }); + + await waitFor(() => expect(body().getByText('access-value')).toBeInTheDocument()); + expect(body().queryAllByTestId('pointer-probe')).toHaveLength(0); + expect(body().getByText('Target Object Type')).toBeInTheDocument(); + expect(body().getByText('invoice')).toBeInTheDocument(); + expect(body().getByText('inv_1')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index f9f875acbd..362e429b30 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -259,6 +259,75 @@ function sectionPredicateVisible( }); } +/** + * Fold a declared pointer pair's TYPE half into its id half's card + * (objectui#13102). + * + * `@objectstack/spec` declares a polymorphic record pointer as a pair: the id + * half (`record_id`) names its sibling with `referenceVia` (`object_name`), and + * the sibling holds the target object's machine name. `DetailSection` draws the + * id half as the record card, which already names the target object by its + * label. Drawing the type half as a row of its own beside that card shows the + * stored machine name next to the label that names the same object. + * + * So a type half leaves the body when its id half is drawn in the same body. + * The pair is read off the OBJECT's declared fields only: a field is a type + * half because a declared field names it in `referenceVia`, never because of + * what it is called. A type half whose id half this body does not draw keeps + * its row, since no card on this body says what it holds. + * + * Only the row goes. The record still carries the value, so the card still + * resolves the pair from the row, and every other reader still reads it. + */ +function foldPointerTypeHalves( + fields: any[] | undefined, + sections: any, + objectFields: Record | undefined, +): { fields: any[] | undefined; sections: any } { + if (!objectFields) return { fields, sections }; + // id half -> type half, for every pair the object declares. + const typeHalfOf = new Map(); + for (const [name, def] of Object.entries(objectFields)) { + const via = def?.referenceVia; + if (typeof via === 'string' && via !== '' && via !== name && objectFields[via] != null) { + typeHalfOf.set(name, via); + } + } + if (typeHalfOf.size === 0) return { fields, sections }; + + const lists: any[][] = []; + if (Array.isArray(fields)) lists.push(fields); + if (Array.isArray(sections)) { + for (const s of sections) if (Array.isArray(s?.fields)) lists.push(s.fields); + } + const drawn = new Set(); + for (const list of lists) { + for (const entry of list) { + const n = fieldName(entry); + if (n) drawn.add(n); + } + } + const folded = new Set(); + for (const [idHalf, typeHalf] of typeHalfOf) { + if (drawn.has(idHalf)) folded.add(typeHalf); + } + if (folded.size === 0) return { fields, sections }; + + const drop = (list: any[] | undefined): any[] | undefined => + Array.isArray(list) + ? list.filter((e) => { + const n = fieldName(e); + return !(n && folded.has(n)); + }) + : list; + return { + fields: drop(fields), + sections: Array.isArray(sections) + ? sections.map((s: any) => (Array.isArray(s?.fields) ? { ...s, fields: drop(s.fields) } : s)) + : sections, + }; +} + export interface RecordDetailsRendererProps { schema?: RecordDetailsComponentProps & Record; className?: string; @@ -1028,6 +1097,11 @@ const RecordDetailsBody: React.FC = ({ }) : schema.sections; + // A pointer pair's type half is drawn by its id half's card, not as a row + // of its own (objectui#13102; see `foldPointerTypeHalves`). Applied after + // every other fold, so it reads the rows this body actually draws. + const body = foldPointerTypeHalves(filteredFields, filteredSections, objSchemaFields); + // Inline-edit by default, but gated by the object's lifecycle: system / // Engine-owned system / append-only / better-auth objects are not // user-editable, so the per-field double-click / pencil affordances must not @@ -1075,8 +1149,8 @@ const RecordDetailsBody: React.FC = ({ // `applyAutoSpan` (which would be a tolerant reader) or in the published // `@object-ui/types` declaration (which would be a surface widening). columns: detailBodyColumns(schema.columns), - sections: filteredSections, - fields: filteredFields, + sections: body.sections, + fields: body.fields, showBack: false, // Suppress DetailView's own Airtable-style header chip. When // record:details is composed under a Lightning page:header the inner From 5a5fd3415eb40ce01d89c3797bdf3899f94604dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:10:42 +0000 Subject: [PATCH 2/2] refactor(plugin-detail): type the pointer type-half fold without any (objectui#13102) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .../src/renderers/record-details.tsx | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 362e429b30..90106dfff7 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -279,11 +279,11 @@ function sectionPredicateVisible( * Only the row goes. The record still carries the value, so the card still * resolves the pair from the row, and every other reader still reads it. */ -function foldPointerTypeHalves( - fields: any[] | undefined, - sections: any, - objectFields: Record | undefined, -): { fields: any[] | undefined; sections: any } { +function foldPointerTypeHalves( + fields: unknown[] | undefined, + sections: S, + objectFields: Readonly> | undefined, +): { fields: unknown[] | undefined; sections: S } { if (!objectFields) return { fields, sections }; // id half -> type half, for every pair the object declares. const typeHalfOf = new Map(); @@ -295,10 +295,17 @@ function foldPointerTypeHalves( } if (typeHalfOf.size === 0) return { fields, sections }; - const lists: any[][] = []; + const sectionFields = (s: unknown): unknown[] | undefined => { + const list = s && typeof s === 'object' ? (s as { fields?: unknown }).fields : undefined; + return Array.isArray(list) ? list : undefined; + }; + const lists: unknown[][] = []; if (Array.isArray(fields)) lists.push(fields); if (Array.isArray(sections)) { - for (const s of sections) if (Array.isArray(s?.fields)) lists.push(s.fields); + for (const s of sections) { + const list = sectionFields(s); + if (list) lists.push(list); + } } const drawn = new Set(); for (const list of lists) { @@ -313,18 +320,19 @@ function foldPointerTypeHalves( } if (folded.size === 0) return { fields, sections }; - const drop = (list: any[] | undefined): any[] | undefined => - Array.isArray(list) - ? list.filter((e) => { - const n = fieldName(e); - return !(n && folded.has(n)); - }) - : list; + const drop = (list: unknown[]): unknown[] => + list.filter((e) => { + const n = fieldName(e); + return !(n && folded.has(n)); + }); return { - fields: drop(fields), - sections: Array.isArray(sections) - ? sections.map((s: any) => (Array.isArray(s?.fields) ? { ...s, fields: drop(s.fields) } : s)) - : sections, + fields: Array.isArray(fields) ? drop(fields) : fields, + sections: (Array.isArray(sections) + ? sections.map((s: unknown) => { + const list = sectionFields(s); + return list ? { ...(s as object), fields: drop(list) } : s; + }) + : sections) as S, }; }