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
12 changes: 12 additions & 0 deletions .changeset/12673-list-cross-object-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
---

Console lists link names that point at another object's record, the way the record page does (objectui#12673).

In an object list, a lookup value (an opportunity's account) and a record-pointer value (the record an approval request is about) showed the target record's name but could not be opened: the list page built record links for the listed object only. The list page now builds them with the record page's own builder, so these names link to `/apps/APP/OBJECT/record/ID` in the current app. A plain click opens the record in the console; ⌘-click, Ctrl-click or a middle click opens it in a new tab, and the copied link carries the console's base path.

- A click elsewhere on the row still opens the row's own record.
- A target the viewer cannot read, or that no longer exists, keeps its "record unavailable" face and draws no link.
- An object this console has no route to stays plain text.
- The record page's links, including their way back to the parent record, are unchanged. A list page has no parent record, so links opened from a list carry no way-back entry.
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* 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#12673 — the real `ObjectView` publishes, to everything its list
* renders, a record-URL builder that answers ANY routable object, and it is
* the record page's builder: the same route the record page's
* `RelatedRecordActionsBridge` hands its lookups, minus the `?from=` trail a
* list page has no parent record for.
*
* The list body is a probe (the `ObjectView.listLinksPlural-11733` harness)
* that reads `recordHref` / `openRecord` off the context the page publishes,
* so what is pinned is the page's wiring, under a router mounted at
* `/_console`. The cells that consume it are pinned with the real grid in
* `__tests__/listCrossObjectCells-12673.test.tsx`.
*
* CONTROL: the record page's bridge, mounted on the same objects, keeps its
* `?from=` trail and its answer for an unroutable object.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
import { useRelatedRecordActions } from '@object-ui/react';

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
const perms = {
check: () => ({ allowed: true }),
checkField: () => true,
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
isLoaded: false,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
};
const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] };
return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms };
});

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }),
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
createAuthenticatedFetch: () => vi.fn(),
}));

vi.mock('@object-ui/collaboration', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useRealtimeSubscription: () => ({ lastMessage: null }),
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
}));

vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));

/** What the probe reads off the page's record-link context. */
function HrefProbe({ objectName, recordId, testId }: { objectName: string; recordId: string; testId: string }) {
const host = useRelatedRecordActions();
return (
<>
<output data-testid={`${testId}-href`}>{host?.recordHref?.(objectName, recordId) ?? '(null)'}</output>
<button type="button" data-testid={`${testId}-open`} onClick={() => host?.openRecord?.(objectName, recordId)} />
</>
);
}

vi.mock('@object-ui/plugin-view', async (importOriginal) => {
const ListProbe = () => (
<>
<HrefProbe objectName="crm_account" recordId="acc-1" testId="account" />
<HrefProbe objectName="legacy_ledger" recordId="led-1" testId="ledger" />
</>
);
return {
...(await importOriginal<Record<string, unknown>>()),
ObjectView: ListProbe,
ViewTabBar: () => null,
ManageViewsDialog: () => null,
};
});

vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));

import { ObjectView } from './ObjectView';
import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge';
import { ExpressionProvider } from '../providers/ExpressionProvider';

const BASENAME = '/_console';
const FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } };
const LIST_VIEW = { grid: { type: 'grid', label: 'All', columns: ['name'] } };
/** The console's object definitions; `legacy_ledger` is not among them. */
const OBJECTS = [
{ name: 'crm_opportunity', label: 'Opportunity', fields: FIELDS, listViews: LIST_VIEW },
{ name: 'crm_account', label: 'Account', fields: FIELDS, listViews: LIST_VIEW },
];

/** The record route: where an in-app open landed. */
function LandedProbe() {
const location = useLocation();
return <div data-testid="landed">{`${location.pathname}${location.search}`}</div>;
}

beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),
),
);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

async function mountList(): Promise<void> {
const dataSource = {
find: vi.fn(async () => ({ data: [], total: 0 })),
findOne: vi.fn(async () => null),
create: vi.fn(async () => ({})),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
} as never;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'user' }}>
<MemoryRouter basename={BASENAME} initialEntries={[`${BASENAME}/apps/hotcrm/crm_opportunity`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={dataSource} objects={OBJECTS} onEdit={() => {}} />}
/>
<Route path="/apps/:appName/:objectName/record/:recordId" element={<LandedProbe />} />
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
await screen.findByTestId('account-href', {}, { timeout: 5000 });
}

function mountRecordPageBridge() {
render(
<MemoryRouter basename={BASENAME} initialEntries={[`${BASENAME}/apps/hotcrm/crm_opportunity/record/opp-1`]}>
<RelatedRecordActionsBridge
appName="hotcrm"
objects={OBJECTS}
dataSource={null}
parentObjectName="crm_opportunity"
parentRecordId="opp-1"
parentTitle="Globex Manufacturing Suite"
>
<HrefProbe objectName="crm_account" recordId="acc-1" testId="account" />
<HrefProbe objectName="legacy_ledger" recordId="led-1" testId="ledger" />
</RelatedRecordActionsBridge>
</MemoryRouter>,
);
}

const text = (testId: string) => screen.getByTestId(testId).textContent ?? '';

describe('ObjectView publishes the record page builder to its list (objectui#12673)', () => {
it('another routable object: the href carries the basename and the app, with no `?from=` trail', async () => {
await mountList();
expect(text('account-href')).toBe('/_console/apps/hotcrm/crm_account/record/acc-1');
});

it('another routable object: a left click opens that record in-app', async () => {
await mountList();
fireEvent.click(screen.getByTestId('account-open'));
expect(text('landed')).toBe('/apps/hotcrm/crm_account/record/acc-1');
});

it('an object the console cannot route to: no href, and opening it goes nowhere', async () => {
await mountList();
expect(text('ledger-href')).toBe('(null)');
fireEvent.click(screen.getByTestId('ledger-open'));
expect(screen.queryByTestId('landed')).toBeNull();
});

it('one builder: the list page addresses the same route the record page does', async () => {
await mountList();
const fromList = text('account-href');
cleanup();
mountRecordPageBridge();
expect(text('account-href').split('?')[0]).toBe(fromList);
});
});

describe('CONTROL — the record page links are unchanged (objectui#12673)', () => {
it('the record page keeps its `?from=` trail for the parent record', () => {
mountRecordPageBridge();
const href = text('account-href');
expect(href.startsWith('/_console/apps/hotcrm/crm_account/record/acc-1?from=')).toBe(true);
});

it('the record page still answers no href for an object it cannot route to', () => {
mountRecordPageBridge();
expect(text('ledger-href')).toBe('(null)');
});
});
76 changes: 70 additions & 6 deletions packages/app-shell/src/views/ObjectView.listRecordHref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
*/
import { describe, it, expect, vi } from 'vitest';

import { listRecordDetailUrl, listRecordActionsValue } from './ObjectView';
import { listRecordDetailUrl, listRecordActionsValue, type ListRecordRoutes } from './ObjectView';

describe('listRecordDetailUrl — one record-route shape for the list surface (#4490)', () => {
it('strips the trailing view segment and appends the record route', () => {
Expand Down Expand Up @@ -70,12 +70,13 @@ describe('listRecordActionsValue — what the list page publishes (#4490)', () =
expect(value.recordHref!('crm_lead', 'r1')).toBe('/apps/demo/crm_lead/record/r1');
});

it('publishes NO href for any other object — this builder cannot name one', () => {
it('without the console routes, publishes NO href for any other object', () => {
const value = listRecordActionsValue('crm_lead', vi.fn(), pathname);
// A lookup cell pointing at a third object must render as the plain value,
// which is what `null` means to the consumer. The console-wide builder is
// the detail page's bridge, which has the routable-object set this page
// does not — inventing a URL here is exactly the #4472 mistake.
// With no routable-object set there is nothing to build another object's
// route from, so a lookup cell pointing at a third object renders as the
// plain value, which is what `null` means to the consumer. Inventing a URL
// here is exactly the #4472 mistake; `ObjectView` passes the routes
// (objectui#12673, pinned below).
expect(value.recordHref!('crm_account', 'acc-1')).toBeNull();
});

Expand Down Expand Up @@ -122,3 +123,66 @@ describe('listRecordActionsValue — what the list page publishes (#4490)', () =
);
});
});

describe('listRecordActionsValue — any routable object, through the record page builder (objectui#12673)', () => {
const pathname = () => '/_console/apps/demo/crm_lead/view/all';
const routes = (over: Partial<ListRecordRoutes> = {}): ListRecordRoutes => ({
appName: 'demo',
objects: [{ name: 'crm_lead' }, { name: 'crm_account' }, { name: 'sys_approval_request' }],
navigate: vi.fn(),
routerRootHref: '/_console/',
...over,
});

it('publishes the document href of another routable object: basename, app, object, encoded id', () => {
const value = listRecordActionsValue('crm_lead', vi.fn(), pathname, routes());
expect(value.recordHref!('crm_account', 'a/1')).toBe('/_console/apps/demo/crm_account/record/a%2F1');
});

it('adds no `?from=` trail: a list page has no parent record', () => {
const value = listRecordActionsValue('crm_lead', vi.fn(), pathname, routes());
expect(value.recordHref!('crm_account', 'acc-1')).not.toContain('?');
});

it('openRecord navigates by the ROUTER path (the router applies its basename)', () => {
const r = routes();
const openPage = vi.fn();
const value = listRecordActionsValue('crm_lead', openPage, pathname, r);
value.openRecord!('crm_account', 'acc-1');
expect(r.navigate).toHaveBeenCalledTimes(1);
expect(r.navigate).toHaveBeenCalledWith('/apps/demo/crm_account/record/acc-1');
// Another object's record is not the listed object's record page.
expect(openPage).not.toHaveBeenCalled();
});

it('an object the console cannot route to: no href, and openRecord does nothing', () => {
const r = routes();
const value = listRecordActionsValue('crm_lead', vi.fn(), pathname, r);
expect(value.recordHref!('legacy_ledger', 'x-1')).toBeNull();
value.openRecord!('legacy_ledger', 'x-1');
expect(r.navigate).not.toHaveBeenCalled();
});

it('no app segment: no href for another object', () => {
const value = listRecordActionsValue('crm_lead', vi.fn(), pathname, routes({ appName: undefined }));
expect(value.recordHref!('crm_account', 'acc-1')).toBeNull();
});

it('a record with no id: no href, and openRecord does nothing', () => {
const r = routes();
const value = listRecordActionsValue('crm_lead', vi.fn(), pathname, r);
expect(value.recordHref!('crm_account', '')).toBeNull();
value.openRecord!('crm_account', '');
expect(r.navigate).not.toHaveBeenCalled();
});

it('the listed object keeps its own route and its own record navigation (control)', () => {
const r = routes();
const openPage = vi.fn();
const value = listRecordActionsValue('crm_lead', openPage, pathname, r);
expect(value.recordHref!('crm_lead', 'r1')).toBe('/_console/apps/demo/crm_lead/record/r1');
value.openRecord!('crm_lead', 'r1');
expect(openPage).toHaveBeenCalledWith('r1');
expect(r.navigate).not.toHaveBeenCalled();
});
});
Loading
Loading