Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/13102-details-pointer-type-half.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, data: Record<string, unknown>, details: Record<string, unknown>) {
const dataSource = { getObjectSchema: async () => objectSchema };
return render(
<RecordContextProvider
objectName={String(objectSchema.name)}
recordId={String(data.id)}
data={data}
objectSchema={objectSchema}
dataSource={dataSource as never}
>
<HighlightFieldsProvider>
<div data-testid="body">
<RecordDetailsRenderer schema={details as never} />
</div>
</HighlightFieldsProvider>
</RecordContextProvider>,
);
}

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<CellRendererProps> = ({ value }) => (
<span data-testid="pointer-probe">{JSON.stringify(value)}</span>
);

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();
});
});
86 changes: 84 additions & 2 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,83 @@ 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<S>(
fields: unknown[] | undefined,
sections: S,
objectFields: Readonly<Record<string, { readonly referenceVia?: unknown } | null | undefined>> | 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<string, string>();
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 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) {
const list = sectionFields(s);
if (list) lists.push(list);
}
}
const drawn = new Set<string>();
for (const list of lists) {
for (const entry of list) {
const n = fieldName(entry);
if (n) drawn.add(n);
}
}
const folded = new Set<string>();
for (const [idHalf, typeHalf] of typeHalfOf) {
if (drawn.has(idHalf)) folded.add(typeHalf);
}
if (folded.size === 0) return { fields, sections };

const drop = (list: unknown[]): unknown[] =>
list.filter((e) => {
const n = fieldName(e);
return !(n && folded.has(n));
});
return {
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,
};
}

export interface RecordDetailsRendererProps {
schema?: RecordDetailsComponentProps & Record<string, any>;
className?: string;
Expand Down Expand Up @@ -1028,6 +1105,11 @@ const RecordDetailsBody: React.FC<RecordDetailsRendererProps> = ({
})
: 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
Expand Down Expand Up @@ -1075,8 +1157,8 @@ const RecordDetailsBody: React.FC<RecordDetailsRendererProps> = ({
// `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
Expand Down
Loading