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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/6694-dashboard-lookup-reference-meta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@object-ui/plugin-dashboard': patch
---

Feed the lookup cells in `ObjectDataTable` and `RecordDetailDrawer` their reference target,
so schema-aware display-name resolution and drill-through links engage for the first time
(objectui#6694).

Both widgets build their cell meta with `buildFieldMeta` and render it through
`renderFieldValue` → `getCellRenderer` → `LookupCellRenderer` (`@object-ui/fields`). That
renderer resolves its target from `field.reference_to || field.reference`, and `FieldMeta`
carried neither spelling — nor `display_field`. So the renderer resolved `undefined` and two
things failed, independently and both silently:

- `useRefObjectSchema` never loaded the referenced object's schema, so the ADR-0079 /
objectui#2357 resolution never ran and every cell fell back to `pickRecordDisplayName`'s
generic `.name` / `.title` heuristic. Quiet, because that heuristic usually still produces
a readable name — it diverges only when the referenced object's display field is not
literally `name` / `title`, and then it silently shows the wrong one.
- `ReferencedRecordLink`'s `objectName` was always `undefined`, so `navigable` was always
`false` and no lookup cell in either widget ever rendered a real anchor — no
drill-through, no middle-click-new-tab, no copy-link. Quiet, because the cell still
rendered its value as plain text.

This RESTORES intended behaviour rather than adding surface. `ReferencedRecordLink` was
placed in the shared cell renderer precisely so every surface would get the affordance once
("Both surfaces resolve through `LookupCellRenderer`, so the affordance belongs here,
once"), and `plugin-grid`'s `ObjectGrid` has fed it all along via `applyRelationalMeta` at
all three of its column-building call sites. These two widgets simply never adopted that
copy. Nothing new is authorable: the keys come off the OBJECT SCHEMA field def authors
already write, never off a column override — the distinction objectui#6597 measured when it
retired `referenceTo`, and the reason this needs no new column hold.

The copy is made once in `buildFieldMeta`, the seam both widgets funnel through, so the two
surfaces cannot drift — which is what that module exists for.

⚠️ The copy set is three keys where `ObjectGrid`'s `RELATIONAL_META_KEYS` is nine, and the
difference is measured per key, not preferred. The grid's cells are EDITABLE, so its extra
keys drive the inline picker's query (`LookupField` / `UserField` read `id_field`,
`description_field`, `lookup_filters`, `lookupFilters`); these two widgets are read-only and
their render path ends at a cell renderer. `packages/fields/src/index.tsx` reads exactly
`reference_to`, `reference` and `display_field` off a cell's `field` prop; `titleFormat` is
never read off a field meta at all (its readers take it off the object schema, which arrives
here through `useRefObjectSchema(reference_to)`), and `reference_to_field` has zero member
reads anywhere in the repo. Copying the other six would mint six members written on every
call and read by nothing — precisely what objectui#6625 (`decimals`) and objectui#6597
(`referenceTo`) retired from this same file.

No published type widened: `FieldMeta` is internal to the package — it is not re-exported
through the barrel (`dist/index.d.ts` names neither it nor `recordFields`) and the `exports`
map publishes only `"."`, so Node refuses `@object-ui/plugin-dashboard/recordFields` with
`ERR_PACKAGE_PATH_NOT_EXPORTED`.

Behaviour note for existing dashboards: a lookup cell whose referenced object declares a
`nameField` other than `name` / `title` will now show that declared name instead of the
heuristic's pick, and valued lookup cells become links wherever the host publishes
`recordHref`.
14 changes: 12 additions & 2 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,19 @@ export interface ObjectDataTableColumnHolds {}
* tombstone and the band together are the same verdict, unchanged since each
* key's ruling.
*
* The pool is what shrank, so what THIS band still refuses is `name` and
* The pool shrank, then grew again. What THIS band refuses is `name` and
* `label` — both of them `FieldMeta` members with answers this seam already
* has (see the docblock above).
* has (see the docblock above) — plus, since objectui#6694, the three
* relational members that card added: `reference_to`, `reference` and
* `display_field`.
*
* ⭐ Those three are the derivation working as designed, and their verdict is
* the one objectui#6597 already reached for `referenceTo`: an AUTHORED column
* may not source a lookup's reference target. They are refused HERE while
* `buildFieldMeta` writes them freely, because that write's source is the
* OBJECT SCHEMA field def and never the column — the same distinction
* objectui#6597 measured, and the reason adding them needed no new hold. They
* reached this band without anyone extending a list.
*/
export type UnheldFieldMetaOverrideKey =
Exclude<keyof FieldMeta, keyof TableColumn | keyof ObjectDataTableColumnHolds>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
/**
* objectui#6694 — lookup cells in `ObjectDataTable` / `RecordDetailDrawer` must
* carry their reference target.
*
* Both widgets build their cell meta with `buildFieldMeta` and render it through
* `renderFieldValue` -> `getCellRenderer` -> `LookupCellRenderer`
* (`@object-ui/fields`). That renderer resolves its target from
* `field.reference_to || field.reference` and its display field from
* `field.display_field`. `buildFieldMeta` wrote NONE of those spellings, so the
* renderer resolved `undefined` and two things failed — independently, and both
* silently:
*
* 1. `useRefObjectSchema(referenceTo)` never loaded the referenced object's
* schema, so `resolveLookupRecordName` fell through the ADR-0079 resolver to
* `pickRecordDisplayName`'s generic `.name` / `.title` heuristic.
* 2. `ReferencedRecordLink`'s `objectName` was always `undefined`, so
* `navigable` was always `false` and the cell never rendered a real anchor —
* no drill-through, no middle-click-new-tab, no copy-link.
*
* ⚠️ Consequence 1 is pinned with a referenced object whose display field is
* `project_code` — NOT `name` / `title`. That is the whole point: the generic
* fallback usually still produces a readable name, so a fixture whose display
* field IS `name` passes before and after the fix and pins nothing. Each record
* below therefore ALSO carries a `name`, holding the value the broken path
* produced, and every assertion checks that the wrong one is absent.
*
* The two consequences get separate assertions because either can be fixed while
* the other stays broken: consequence 2 needs only `reference_to`, while
* consequence 1 additionally needs the referenced schema to actually load.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import {
SchemaRendererContext,
RelatedRecordActionsProvider,
type RelatedRecordActionsValue,
} from '@object-ui/react';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Only the table shell is stubbed. Everything the assertions depend on —
// `SchemaRendererContext`, `RelatedRecordActionsProvider` — is the REAL
// export by identity, so the contexts this file installs are the same
// objects `@object-ui/fields` reads from.
SchemaRenderer: ({ schema }: any) => {
const cols = schema.columns || [];
const rows = schema.data || [];
return (
<table>
<tbody>
{rows.map((row: any, i: number) => (
<tr key={i}>
{cols.map((c: any) => (
<td key={c.accessorKey}>
{typeof c.cell === 'function'
? c.cell(row[c.accessorKey], row)
: String(row[c.accessorKey] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
);
},
useDataScope: () => undefined,
};
});

import { ObjectDataTable } from '../ObjectDataTable';
import { RecordDetailDrawer } from '../RecordDetailDrawer';
import { buildFieldMeta } from '../recordFields';

/**
* The host's route builder — the ONLY thing that can turn a lookup cell into an
* anchor. `ReferencedRecordLink` calls it with the resolved `objectName`, so it
* is never reached at all while that value is `undefined`.
*/
const HOST: RelatedRecordActionsValue = {
resolve: () => ({}),
recordHref: (objectName, recordId) => `/app/${objectName}/view/${recordId}`,
};

/**
* `refObjectSchemaCache` in `@object-ui/fields` is module-level and never
* cleared, so each case below references its OWN object name. A shared name
* would let one case's resolved schema satisfy another's assertion.
*/
function makeDataSource(opts: {
ownerObject: string;
refObject: string;
rows: any[];
}) {
const ownerSchema = {
name: opts.ownerObject,
fields: {
project: { type: 'lookup', label: 'Project', reference_to: opts.refObject },
},
};
const refSchema = {
name: opts.refObject,
// ADR-0079 canonical record-title pointer. Deliberately NOT `name`/`title`.
nameField: 'project_code',
fields: {
project_code: { type: 'text', label: 'Code' },
name: { type: 'text', label: 'Name' },
},
};
return {
find: async () => ({ data: opts.rows }),
getObjectSchema: async (n: string) => (n === opts.refObject ? refSchema : ownerSchema),
__ownerSchema: ownerSchema,
};
}

/** An `$expand`-ed lookup value whose generic `.name` is the WRONG answer. */
function expandedProject(id: string) {
return { id, project_code: 'APOLLO-7', name: 'generic-fallback-name' };
}

describe('objectui#6694 — ObjectDataTable lookup cells carry their reference target', () => {
it('pin 1: resolves the display name through the REFERENCED object schema, not the generic .name heuristic', async () => {
const ds = makeDataSource({
ownerObject: 'account_6694_a',
refObject: 'project_6694_a',
rows: [{ project: expandedProject('p-1') }],
});

render(
<SchemaRendererContext.Provider value={{ dataSource: ds } as any}>
<ObjectDataTable
schema={{
type: 'object-data-table',
objectName: 'account_6694_a',
columns: [{ header: 'Project', accessorKey: 'project' }],
} as any}
dataSource={ds}
/>
</SchemaRendererContext.Provider>,
);

// `nameField: 'project_code'` wins — the ADR-0079 / issue #2357 resolution
// that only runs once `useRefObjectSchema` has a reference target to load.
await waitFor(
() => expect(screen.getByText('APOLLO-7')).toBeInTheDocument(),
{ timeout: 3000 },
);
// …and the generic heuristic's answer is NOT what rendered. Without this the
// assertion above could pass on a fixture where both agree.
expect(screen.queryByText('generic-fallback-name')).not.toBeInTheDocument();
});

it('pin 2: renders a real drill-through anchor built by the host', async () => {
const ds = makeDataSource({
ownerObject: 'account_6694_b',
refObject: 'project_6694_b',
rows: [{ project: expandedProject('p-2') }],
});

render(
<RelatedRecordActionsProvider value={HOST}>
<SchemaRendererContext.Provider value={{ dataSource: ds } as any}>
<ObjectDataTable
schema={{
type: 'object-data-table',
objectName: 'account_6694_b',
columns: [{ header: 'Project', accessorKey: 'project' }],
} as any}
dataSource={ds}
/>
</SchemaRendererContext.Provider>
</RelatedRecordActionsProvider>,
);

// `navigable` is `!!objectName && recordId != null`. The id was always
// there; the object name is what the missing `reference_to` withheld, so
// this anchor is the whole of consequence 2.
const link = await waitFor(() => screen.getByRole('link'), { timeout: 3000 });
expect(link).toHaveAttribute('href', '/app/project_6694_b/view/p-2');
});
});

describe('objectui#6694 — RecordDetailDrawer lookup rows carry their reference target', () => {
it('pin 1: resolves the display name through the REFERENCED object schema', async () => {
const ds = makeDataSource({
ownerObject: 'account_6694_c',
refObject: 'project_6694_c',
rows: [],
});

render(
<SchemaRendererContext.Provider value={{ dataSource: ds } as any}>
<RecordDetailDrawer
record={{ id: 'a-1', project: expandedProject('p-3') }}
objectName="account_6694_c"
objectSchema={ds.__ownerSchema}
onClose={() => {}}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(
() => expect(screen.getByText('APOLLO-7')).toBeInTheDocument(),
{ timeout: 3000 },
);
expect(screen.queryByText('generic-fallback-name')).not.toBeInTheDocument();
});

it('pin 2: renders a real drill-through anchor built by the host', async () => {
const ds = makeDataSource({
ownerObject: 'account_6694_d',
refObject: 'project_6694_d',
rows: [],
});

render(
<RelatedRecordActionsProvider value={HOST}>
<SchemaRendererContext.Provider value={{ dataSource: ds } as any}>
<RecordDetailDrawer
record={{ id: 'a-2', project: expandedProject('p-4') }}
objectName="account_6694_d"
objectSchema={ds.__ownerSchema}
onClose={() => {}}
/>
</SchemaRendererContext.Provider>
</RelatedRecordActionsProvider>,
);

const link = await waitFor(() => screen.getByRole('link'), { timeout: 3000 });
expect(link).toHaveAttribute('href', '/app/project_6694_d/view/p-4');
});
});

/**
* The copy-set boundary.
*
* `ObjectGrid`'s `applyRelationalMeta` copies NINE keys; this seam copies THREE,
* and the difference is measured rather than preferred: the grid's cells are
* EDITABLE, so its extra keys feed the inline picker (`LookupField` / `UserField`
* read `id_field`, `description_field`, `lookup_filters`, `lookupFilters`).
* These two widgets are read-only — their only render path ends at a CELL
* renderer — and `packages/fields/src/index.tsx` reads exactly three relational
* keys off a cell's `field` prop.
*
* ⛔ This is what stops the omitted six from being added back "for parity": a
* `FieldMeta` member written on every call and read by nothing is precisely what
* objectui#6625 (`decimals`) and objectui#6597 (`referenceTo`) retired from this
* same file. If these widgets ever gain inline editing, that is the event that
* earns the picker keys — not symmetry with the grid.
*/
describe('objectui#6694 — buildFieldMeta copies the cell-read relational keys and no others', () => {
const def = {
type: 'lookup',
reference_to: 'project',
reference: 'project',
display_field: 'project_code',
// The six the grid also copies, which have no reader on this path:
reference_to_field: 'x',
id_field: 'x',
description_field: 'x',
lookup_filters: [['a', '=', 1]],
lookupFilters: [['a', '=', 1]],
titleFormat: '{project_code}',
};

it('copies reference_to / reference / display_field', () => {
const meta = buildFieldMeta({ accessorKey: 'project', label: 'Project', def }) as any;
expect(meta.reference_to).toBe('project');
expect(meta.reference).toBe('project');
expect(meta.display_field).toBe('project_code');
});

it('does NOT copy the picker-only keys', () => {
const meta = buildFieldMeta({ accessorKey: 'project', label: 'Project', def }) as any;
for (const k of [
'reference_to_field', 'id_field', 'description_field',
'lookup_filters', 'lookupFilters', 'titleFormat',
]) {
expect(meta).not.toHaveProperty(k);
}
});

it('adds no relational keys at all to a non-relational field', () => {
const meta = buildFieldMeta({
accessorKey: 'amount', label: 'Amount', def: { type: 'currency' },
}) as any;
for (const k of ['reference_to', 'reference', 'display_field']) {
expect(meta).not.toHaveProperty(k);
}
});
});
Loading
Loading