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
25 changes: 25 additions & 0 deletions .changeset/lookup-values-link-to-referenced-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/react': minor
'@object-ui/fields': patch
'@object-ui/app-shell': patch
---

fix(detail): lookup field values link to the referenced record

A valued lookup on a record detail page rendered as plain text plus a copy
button — the referenced document's name was visible but unreachable, so users
copied the number and searched for it from the list page instead. Lookup cells
inside a related list that pointed at a third object were dead the same way.

`LookupCellRenderer` — the one cell renderer both surfaces resolve through —
now renders the display value as a link to the referenced record. The display
name resolution, the copy affordance and every non-lookup field are unchanged,
and a lookup with no value still renders its placeholder rather than an empty
link.

The URL is not assembled in the renderer. `RelatedRecordActionsContext` gains
an optional `recordHref` / `openRecord` pair, published by the console's
`RelatedRecordActionsBridge` from the SAME builder its related-list row
navigation already used, so there is one record-route shape rather than a
second one. A host that does not provide it (Studio designer, embedded
renderers, standalone grids) renders exactly what it rendered before.
80 changes: 57 additions & 23 deletions packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,58 @@ export function RelatedRecordActionsBridge({
const localizeActionTexts = useActionTextLocalizer();
const base = appName ? `/apps/${appName}` : '';

/** Objects this host can route to — the record route exists per object def. */
const routableObjects = useMemo(
() => new Set((objects ?? []).map((o: any) => o?.name).filter(Boolean)),
[objects],
);

/**
* THE record-detail URL builder for this page — one route shape, one place.
*
* Serves both the related list's row navigation (`onView`, below) and, since
* objectui#4336, the lookup values rendered anywhere under this bridge: a
* lookup points at a record of another object, and `LookupCellRenderer` has
* no router, so it asks for the href instead of re-deriving `/apps/:app/
* :object/record/:id` a second time (the #4472 lesson — one resolver).
*
* `null` when the host cannot route there (no app segment, or an object that
* is not in this console's metadata), which renders as the plain value.
*/
const recordHref = useCallback(
(objectName: string, recordId: string | number): string | null => {
if (!base || !objectName || !routableObjects.has(objectName)) return null;
const url = `${base}/${objectName}/record/${encodeURIComponent(String(recordId))}`;
// Carry the parent record into the target's `?from=` trail so the
// breadcrumb (and the record body's back link) can path back up.
// Read the current trail off the live URL — this bridge outlives a
// single search-params snapshot, so a fresh read avoids a stale
// closure and keeps nested drill-ins accumulating correctly.
if (parentObjectName && parentRecordId) {
const rawFrom = new URLSearchParams(window.location.search).get(RECORD_TRAIL_PARAM);
const trail = appendRecordTrail(rawFrom, {
o: parentObjectName,
i: parentRecordId,
...(parentTitle ? { t: parentTitle } : {}),
});
const sp = new URLSearchParams();
sp.set(RECORD_TRAIL_PARAM, trail);
return `${url}?${sp.toString()}`;
}
return url;
},
[base, routableObjects, parentObjectName, parentRecordId, parentTitle],
);

/** SPA-navigate to the destination {@link recordHref} addresses. */
const openRecord = useCallback(
(objectName: string, recordId: string | number) => {
const href = recordHref(objectName, recordId);
if (href) navigate(href);
},
[recordHref, navigate],
);

// #2604 D3 — open a child create/edit task as the console's global record
// form overlay, by URL params. Pushes ONE history entry (Back = close, the
// parent detail stays mounted underneath). The read side lives in
Expand Down Expand Up @@ -202,30 +254,10 @@ export function RelatedRecordActionsBridge({
edit: rawAff.edit && can(objectName, 'update'),
delete: rawAff.delete && can(objectName, 'delete'),
};
const detailUrl = (id: string | number) => {
const url = `${base}/${objectName}/record/${encodeURIComponent(String(id))}`;
// Carry the parent record into the child's `?from=` trail so the
// breadcrumb (and the record body's back link) can path back up.
// Read the current trail off the live URL — this bridge outlives a
// single search-params snapshot, so a fresh read avoids a stale
// closure and keeps nested drill-ins accumulating correctly.
if (parentObjectName && parentRecordId) {
const rawFrom = new URLSearchParams(window.location.search).get(RECORD_TRAIL_PARAM);
const trail = appendRecordTrail(rawFrom, {
o: parentObjectName,
i: parentRecordId,
...(parentTitle ? { t: parentTitle } : {}),
});
const sp = new URLSearchParams();
sp.set(RECORD_TRAIL_PARAM, trail);
return `${url}?${sp.toString()}`;
}
return url;
};

const handlers: RelatedRecordHandlers = {
// Viewing a child record is always allowed when the list is visible.
onView: (id) => navigate(detailUrl(id)),
// Same builder the lookup links use — `recordHref` above.
onView: (id) => openRecord(objectName, id),
};

if (aff.create) {
Expand Down Expand Up @@ -270,8 +302,10 @@ export function RelatedRecordActionsBridge({

return handlers;
},
recordHref,
openRecord,
}),
[objects, base, navigate, dataSource, localizeActionTexts, runRowAction, openChildForm, parentObjectName, parentRecordId, parentTitle, getObjectApiOperations, can],
[objects, base, dataSource, localizeActionTexts, runRowAction, openChildForm, recordHref, openRecord, getObjectApiOperations, can],
);

return (
Expand Down
171 changes: 171 additions & 0 deletions packages/fields/src/__tests__/lookupCellRecordLink.test.tsx
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.
*/

/**
* objectui#4336 — a lookup value rendered read-only was plain text: the record
* detail page showed `TYG1WX20260812001001` with a copy button and no way to
* reach the referenced work order, and the related list's lookup columns had
* the same dead cells (the reporter's own check: no `<a>` anywhere over the
* value, every `href` null).
*
* `LookupCellRenderer` is the ONE cell renderer both surfaces resolve through
* (`DetailSection` and `RelatedList` each call `getCellRenderer('lookup')`), so
* the link belongs here — same path, both halves.
*
* The href is NOT assembled here. This package has no router and no idea what
* an app's record route looks like; the host that owns record URLs publishes
* them through `RelatedRecordActionsContext` (`recordHref` / `openRecord`),
* which is the same builder the related list's row navigation already uses. No
* host → no link, and the value renders exactly as it did before.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { LookupCellRenderer } from '../index';
import { RelatedRecordActionsProvider } from '@object-ui/react';

/** The reporter's shape: `$expand`-ed lookup value (display name + record id). */
const WORK_ORDER = { id: 'wo-1', name: 'TYG1WX20260812001001' };
const FIELD = { type: 'lookup', reference_to: 'mtc_work_order' } as any;

/** A host that can route to records, mirroring the console bridge's shape. */
function makeHost(overrides: Record<string, unknown> = {}) {
return {
resolve: () => ({}),
recordHref: (objectName: string, recordId: string | number) =>
`/apps/demo/${objectName}/record/${encodeURIComponent(String(recordId))}`,
openRecord: vi.fn(),
...overrides,
} as any;
}

function renderWithHost(node: React.ReactElement, host: any = makeHost()) {
return render(
<RelatedRecordActionsProvider value={host}>{node}</RelatedRecordActionsProvider>,
);
}

describe('LookupCellRenderer — the value links to the referenced record (#4336)', () => {
it('expanded value: renders an anchor at the host-built record href', () => {
const { container } = renderWithHost(
<LookupCellRenderer value={WORK_ORDER} field={FIELD} />,
);

const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
expect(anchor).toHaveAttribute('href', '/apps/demo/mtc_work_order/record/wo-1');
// Display-name resolution is untouched — the anchor carries the same text.
expect(anchor).toHaveTextContent('TYG1WX20260812001001');
});

it('primitive id value: links through the same host builder', () => {
const { container } = renderWithHost(
<LookupCellRenderer
value="wo-2"
field={{ ...FIELD, options: [{ value: 'wo-2', label: 'TYG1WX20260812001002' }] }}
/>,
);

const anchor = container.querySelector('a');
expect(anchor).toHaveAttribute('href', '/apps/demo/mtc_work_order/record/wo-2');
expect(anchor).toHaveTextContent('TYG1WX20260812001002');
});

it('multi-value: each referenced record gets its own anchor', () => {
const { container } = renderWithHost(
<LookupCellRenderer
value={[WORK_ORDER, { id: 'wo-3', name: 'TYG1WX20260812001003' }]}
field={FIELD}
/>,
);

const hrefs = Array.from(container.querySelectorAll('a')).map((a) => a.getAttribute('href'));
expect(hrefs).toEqual([
'/apps/demo/mtc_work_order/record/wo-1',
'/apps/demo/mtc_work_order/record/wo-3',
]);
});

it('plain click navigates through the host (SPA) instead of reloading', () => {
const host = makeHost();
const rowClick = vi.fn();
const { container } = render(
<RelatedRecordActionsProvider value={host}>
{/* The surfaces around this cell carry their own click handlers — the
detail row copies the value, a related-list row opens the row's own
record. Activating the link must do neither. */}
<div onClick={rowClick}>
<LookupCellRenderer value={WORK_ORDER} field={FIELD} />
</div>
</RelatedRecordActionsProvider>,
);

const anchor = container.querySelector('a')!;
const event = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
fireEvent(anchor, event);

expect(host.openRecord).toHaveBeenCalledWith('mtc_work_order', 'wo-1');
expect(event.defaultPrevented).toBe(true);
expect(rowClick).not.toHaveBeenCalled();
});

it('modifier click keeps the browser default (open in a new tab)', () => {
const host = makeHost();
const { container } = renderWithHost(
<LookupCellRenderer value={WORK_ORDER} field={FIELD} />,
host,
);

const anchor = container.querySelector('a')!;
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
metaKey: true,
});
fireEvent(anchor, event);

expect(host.openRecord).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
});

describe('LookupCellRenderer — must-not-change (#4336)', () => {
it('no host: renders exactly as before, with no anchor', () => {
const { container } = render(<LookupCellRenderer value={WORK_ORDER} field={FIELD} />);

expect(container.querySelector('a')).toBeNull();
expect(screen.getByText('TYG1WX20260812001001')).toBeInTheDocument();
});

it('host cannot route to the target object: no anchor, same text', () => {
const { container } = renderWithHost(
<LookupCellRenderer value={WORK_ORDER} field={FIELD} />,
makeHost({ recordHref: () => null }),
);

expect(container.querySelector('a')).toBeNull();
expect(screen.getByText('TYG1WX20260812001001')).toBeInTheDocument();
});

it('no reference target declared: no anchor', () => {
const { container } = renderWithHost(
<LookupCellRenderer value={WORK_ORDER} field={{ type: 'lookup' } as any} />,
);

expect(container.querySelector('a')).toBeNull();
});

it('empty value: the placeholder stays a placeholder — no empty link', () => {
const { container } = renderWithHost(<LookupCellRenderer value={null} field={FIELD} />);

expect(container.querySelector('a')).toBeNull();
});
});
Loading
Loading