From 1a00c21bacba7ebfb51a163edf62dec849da1ed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:20:55 +0000 Subject: [PATCH] feat(app-shell): console lists link names that point at another object The list host's record-URL builder answered the listed object only, so a lookup cell or a record-pointer cell naming another object's record drew a name that could not be opened. It now answers any routable object through the record page's own builder (appRecordPath over routableObjectNames, exported by RelatedRecordActionsBridge), as a document href carrying the router basename, with in-app navigation on a plain click and no from-trail (a list page has no parent record). The listed object keeps its own route. Claude-Session: https://claude.ai/code/session_01CrvmqMe8FLB3kXU3NHq25T Co-authored-by: Claude --- .changeset/12673-list-cross-object-links.md | 12 + ...ectView.listCrossObjectHost-12673.test.tsx | 218 +++++++++++++++ .../views/ObjectView.listRecordHref.test.ts | 76 ++++- packages/app-shell/src/views/ObjectView.tsx | 88 ++++-- .../src/views/RelatedRecordActionsBridge.tsx | 47 +++- .../listCrossObjectCells-12673.test.tsx | 263 ++++++++++++++++++ 6 files changed, 672 insertions(+), 32 deletions(-) create mode 100644 .changeset/12673-list-cross-object-links.md create mode 100644 packages/app-shell/src/views/ObjectView.listCrossObjectHost-12673.test.tsx create mode 100644 packages/app-shell/src/views/__tests__/listCrossObjectCells-12673.test.tsx diff --git a/.changeset/12673-list-cross-object-links.md b/.changeset/12673-list-cross-object-links.md new file mode 100644 index 0000000000..2b35ad4dfc --- /dev/null +++ b/.changeset/12673-list-cross-object-links.md @@ -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. diff --git a/packages/app-shell/src/views/ObjectView.listCrossObjectHost-12673.test.tsx b/packages/app-shell/src/views/ObjectView.listCrossObjectHost-12673.test.tsx new file mode 100644 index 0000000000..4be088b961 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.listCrossObjectHost-12673.test.tsx @@ -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(); + 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>()), + 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>()), + 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 ( + <> + {host?.recordHref?.(objectName, recordId) ?? '(null)'} +