Skip to content

Commit 97e7d4e

Browse files
fix(app-shell,react): lookup links on record pages and custom pages carry the console base path (objectui#12233) (#12239)
Fixes #12233 Clause-②: no Item 4 of the list card objectui#12211. The parent stays open for its other items. ## What was wrong A console can be mounted under a router basename. Hosted HotCRM serves it at `/_console`. On a record page, a valued lookup renders as a real anchor through `ReferencedRecordLink` in `@object-ui/fields`, which takes its href from the host's `RelatedRecordActionsValue.recordHref`. On the record page that host is `RelatedRecordActionsBridge`, which `RecordDetailView` mounts around the whole page. Its builder returned the bare router path `/apps/APP/OBJECT/record/ID?from=…`. - A plain left click worked. The anchor hands it to `openRecord`, which calls `navigate`, and `navigate` adds the basename. - A new tab, a middle-click or a copied link sent the browser to the bare route on the server, which answers `ENDPOINT_NOT_FOUND`. ## Premise check, on objectui `origin/main` `6eca5b5a7` - The console's `BrowserRouter basename` comes from the injected base tag (`resolveBasename` in `apps/console/src/App.tsx`), so a hosted console's router lives at `/_console`. Confirmed. - `ReferencedRecordLink` is the only anchor in `@object-ui/fields` that draws a record link. `LookupCellRenderer` uses it for every valued lookup. The owner link is a lookup to `sys_user` through the same renderer, and `UserCellRenderer` draws no link. - Only the bridge's builder adds the `?from=` trail that the measured href carries, so the bridge is the producer. - `RecordDetailView` has two other record-route literals: the favourite entry and the recent-item entry. Both are router paths, and their readers use `navigate` and router `Link`s, so they are correct as they are. This PR leaves `RecordDetailView.tsx` untouched. - The list page's producer (`listRecordActionsValue` in `ObjectView.tsx`) already returns a document path, because it derives from `window.location.pathname`, which includes the basename. The two producers disagreed. Applying the basename in the consumers would have doubled it on the list page, so the fix belongs in the producer. ## The fix Round 2 (head `cc0777aa`) extends round 1 to the second producer of the same defect, under surface amendment `6123360584` on objectui#12233. Both hosts that publish `RelatedRecordActionsValue.recordHref` now share one join. These are the record page's `RelatedRecordActionsBridge` and the custom page's `pageRecordActionsValue`. The join is `documentHref(rootHref, routerPath)`, in the internal module `packages/app-shell/src/views/documentHref.ts`: - Each host keeps one router-path builder (`recordPath`). `openRecord` navigates by that router path, as before, and the router adds the basename. - `recordHref` returns `documentHref(useHref('/'), path)`: the router's own root href, which carries the basename, joined to the path. It equals the href react-router's `Link` renders for that route. A console mounted at the root keeps the bare href. - `PageView` reads `useHref('/')` beside its other router hooks and passes it to `pageRecordActionsValue`, which takes it as a new fourth argument. No widget changes. No `/_console` or `BASE_URL` literal is added anywhere. ## Published contract - `RelatedRecordActionsValue.recordHref` is a member of a published type in `@object-ui/react`. Its type is unchanged. Round 2 adds one sentence to its doc: the value is a document href and carries the host router's basename, while `openRecord` navigates by the router path. Both producers now meet that doc. - `RelatedRecordActionsBridge`, `pageRecordActionsValue` and `documentHref` are not reachable from the `@object-ui/app-shell` entry. Its `exports` are `.` and `./styles.css`, and the barrels name none of the three; `PageView` in the same barrels is the positive control. - No package entry gains or loses an export, a prop or a type member. The only signature change is on the internal `pageRecordActionsValue`. That is the basis for `Clause-②: no`. - The changeset declares `@object-ui/app-shell` `patch` and `@object-ui/react` `patch` (doc only). ## Tests - `packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.basenameHref-12233.test.tsx` (round 1). It mounts the real bridge under a `MemoryRouter`, with the real `LookupCellRenderer` for the three measured links (account, contact, and owner on `sys_user`): - with basename `/_console`, each href equals react-router's `Link` href and keeps `?from=`; - a left click routes to the named record with the basename applied once; - a no-basename control keeps the bare route. - `packages/app-shell/src/views/__tests__/PageView.lookupHrefBasename-12233.test.tsx` (round 2). It mounts the real `PageView` under a real `MemoryRouter`, with the real `LookupCellRenderer`. It checks basename `/_console` against a react-router `Link` oracle and the left-click destination, with a no-basename control. - `PageView.recordNavigator-11293.test.tsx` gains a basename case, and its builder calls pass `'/'` explicitly; no assertion is weakened. - Four `PageView` tests gain a `useHref` entry returning `'/'` in their `react-router-dom` factory mock: `PageView.test.tsx`, `PageView.context-9673`, `PageView.editInStudioNeedsAuthoring-10899` and `page-kind-writing-end-9718`. Without it they fail; see B2. Reverse checks, each from a committed fix through `ablation-replace`. In each, the anchor hit once and the blob changed, and the restore was proven: the blob equals HEAD and `git diff HEAD` is empty. - **A1** (at `3a5e7950`): the bridge's href goes back to the bare router path. Result: `Tests 2 failed | 1 passed (3)`, `expected '/apps/hotcrm/crm_account/record/A-1' to be '/_console/apps/hotcrm/crm_account/record/A-1'`. - **A2** (at `3a5e7950`): the naive fix, where a left click navigates with the prefixed href. Result: `Tests 1 failed | 2 passed (3)`, with a doubled basename. - **B1** (at `cc0777aa`): `pageRecordActionsValue`'s href goes back to the bare path. Result: `Tests 2 failed | 6 passed (8)`, Expected `/_console/apps/crm/account/record/A-1`, Received `/apps/crm/account/record/A-1`; the controls stay green. - **B2** (at `cc0777aa`): `useHref` dropped from `PageView.test.tsx`'s mock. Result: `Error: [vitest] No "useHref" export is defined on the "react-router-dom" mock`. ## Gates, at HEAD `cc0777aa` | command | exit | verdict line | |:--|:-:|:--| | `turbo run build --filter='@object-ui/app-shell^...' --concurrency=2` (dependency closure) | 0 | `Tasks: 28 successful, 28 total` | | `pnpm --filter @object-ui/app-shell type-check` | 0 | no diagnostics | | `pnpm --filter @object-ui/react type-check` | 0 | no diagnostics | | `pnpm --filter @object-ui/app-shell lint` | 0 | `0 errors`; no warning on a changed line | | `pnpm --filter @object-ui/react lint` | 0 | `0 errors`; the one warning in `RelatedRecordActionsContext.tsx` is on an existing export, not the doc lines | | `pnpm exec vitest run` over the narrowed set below | 0 | `Test Files 317 passed (317)`, `Tests 3340 passed (3340)` | | `node scripts/check-changeset-no-major.mjs` | 0 | `No changeset declares a major bump.` | | `node scripts/check-changeset-presence.mjs` | 0 | `12 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)` | | `pnpm check:changeset-claims`, `check:control-bytes`, `check:new-line-citations`, `check:pending-changeset-literals`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:phantom-deps`, `check:unreferenced-sources` | 0 each | OK | **Declared narrowing of the test run.** The run covers every `packages/app-shell` and `apps/console` test whose source names any of these: the bridge, `RecordDetailView`, `recordHref`, `openRecord`, `LookupCellRenderer`, `ReferencedRecordLink`, `AppContent`, `ConsoleShell`, `useRelatedRecordActions`, `RelatedRecordActionsProvider`, `PageView`, `pageRecordActions` or `documentHref`. It also covers such tests elsewhere that import `@object-ui/app-shell`, all 109 `@object-ui/react` test files, and `packages/fields/src/__tests__/lookupCellRecordLink.test.tsx`. The full `@object-ui/app-shell` suite is left to CI's `Test` shards, which are green on this head. ## Acceptance notes - **Sibling PR coordination:** objectui#12198's branch adds a test that mounts `PageView` under a `react-router-dom` mock with no `useHref`. Whichever PR lands second needs a `useHref` entry returning `'/'` in that mock. The seat has handed this to objectui#12198's dev. - **Other record routes:** `RecordDetailView`'s favourite and recent-item record routes are router paths read through `navigate` and router `Link`s, so they are correct as they are and untouched. Session: `https://claude.ai/code/session_01G5TSaEwbEQsKMjhzTB4rEd` --- _Generated by [Claude Code](https://claude.ai/code/session_01G5TSaEwbEQsKMjhzTB4rEd)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21fdeb3 commit 97e7d4e

13 files changed

Lines changed: 381 additions & 23 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/react': patch
4+
---
5+
6+
Lookup links on a record page and on a custom page now carry the console's base path, so the same link works from a left click, a new tab, a middle-click and a copied link (objectui#12233).
7+
8+
On a console mounted under a router basename, such as `/_console`, a lookup value rendered as an anchor whose href was the bare route `/apps/APP/OBJECT/record/ID`. On a record page this hit, for example, a lead's converted account or contact and its owner. The same held for a lookup, or a grid's link column, on a custom page. A plain left click worked, because the anchor hands it to the console router and the router adds the basename. Opening the link in a new tab, middle-clicking it or pasting a copied link sent the browser to the bare route on the server, which answered `ENDPOINT_NOT_FOUND`.
9+
10+
- `@object-ui/app-shell`: both record-link builders, the record page's and the custom page's, now give lookups an href that includes the router's basename. They read the basename from the router, so a console mounted at the root keeps the bare href. A left click still navigates inside the app, to the same record the href names.
11+
- `@object-ui/react`: the documentation of `RelatedRecordActionsValue.recordHref` now says the value is a document href that carries the host router's basename, while `openRecord` navigates by the router path. The type is unchanged.
12+
13+
Nothing is added to or removed from any package entry.

‎packages/app-shell/src/views/PageView.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import { lazy, Suspense } from 'react';
13-
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
13+
import { useParams, useSearchParams, useNavigate, useLocation, useHref } from 'react-router-dom';
1414
import {
1515
SchemaRenderer,
1616
notifyDataChanged,
@@ -67,6 +67,9 @@ export function PageView() {
6767
const [searchParams] = useSearchParams();
6868
const { showDebug } = useMetadataInspector();
6969
const navigate = useNavigate();
70+
// The router's root href, basename included: the page's record navigator
71+
// publishes document hrefs from it (objectui#12233).
72+
const routerRootHref = useHref('/');
7073
const location = useLocation();
7174
// Editing a page mutates the shared metadata definition, so the entry point
7275
// is admin-only (mirrors the view/report/dashboard runtime editors) — AND
@@ -130,7 +133,7 @@ export function PageView() {
130133
// The record navigator the page's blocks open a record through
131134
// (objectui#11293). Not memoised: the schema below is rebuilt on every
132135
// render too, and nothing may rest on this object's identity (AGENTS.md #10).
133-
const pageRecordActions = pageRecordActionsValue(appName, (objects ?? []) as unknown[], navigate);
136+
const pageRecordActions = pageRecordActionsValue(appName, (objects ?? []) as unknown[], navigate, routerRootHref);
134137

135138
return (
136139
// Mount the shared console action runtime so page-level `action:button`s can

‎packages/app-shell/src/views/RelatedRecordActionsBridge.tsx‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
*/
7070

7171
import { useCallback, useMemo } from 'react';
72-
import { useNavigate, useSearchParams } from 'react-router-dom';
72+
import { useHref, useNavigate, useSearchParams } from 'react-router-dom';
7373
import {
7474
RelatedRecordActionsProvider,
7575
notifyDataChanged,
@@ -85,6 +85,7 @@ import { evalRowPredicate, resolveAffordance, type ActionDef, type RowCrudPredic
8585
import { actionRendersAt, type ActionLocation } from '@object-ui/types';
8686
import { usePermissions } from '@object-ui/permissions';
8787
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RECORD_TRAIL_PARAM, appendRecordTrail } from '../urlParams.js';
88+
import { documentHref } from './documentHref.js';
8889

8990
/**
9091
* Notify open related lists for `objectName` to refetch.
@@ -250,6 +251,9 @@ export function RelatedRecordActionsBridge({
250251
// dialog stayed in the authored language.
251252
const localizeActionTexts = useActionTextLocalizer();
252253
const base = appName ? `/apps/${appName}` : '';
254+
// The router's href for its root, basename included (see `documentHref`).
255+
// Read once here, because `recordHref` below runs per rendered lookup.
256+
const routerRootHref = useHref('/');
253257
/** A parent record is in scope: the related lists below sit inside it. */
254258
const hasParentRecord = parentRecordId != null && parentRecordId !== '';
255259

@@ -260,18 +264,18 @@ export function RelatedRecordActionsBridge({
260264
);
261265

262266
/**
263-
* THE record-detail URL builder for this page — one route shape, one place.
267+
* THE record-detail route for this page — one route shape, one place.
264268
*
265-
* Serves both the related list's row navigation (`onView`, below) and, since
266-
* objectui#4336, the lookup values rendered anywhere under this bridge: a
267-
* lookup points at a record of another object, and `LookupCellRenderer` has
268-
* no router, so it asks for the href instead of re-deriving `/apps/:app/
269-
* :object/record/:id` a second time (the #4472 lesson — one resolver).
269+
* A ROUTER path (`/apps/:app/:object/record/:id`, no basename), which is what
270+
* `navigate` takes. Both of this page's ways of reaching a record derive from
271+
* it: the related list's row navigation (`onView`, through `openRecord`) and
272+
* the href the lookup values render (`recordHref`), so the two cannot address
273+
* different records.
270274
*
271275
* `null` when the host cannot route there (no app segment, or an object that
272276
* is not in this console's metadata), which renders as the plain value.
273277
*/
274-
const recordHref = useCallback(
278+
const recordPath = useCallback(
275279
(objectName: string, recordId: string | number): string | null => {
276280
if (!base || !objectName || !routableObjects.has(objectName)) return null;
277281
const url = `${base}/${objectName}/record/${encodeURIComponent(String(recordId))}`;
@@ -296,13 +300,37 @@ export function RelatedRecordActionsBridge({
296300
[base, routableObjects, parentObjectName, parentRecordId, parentTitle],
297301
);
298302

299-
/** SPA-navigate to the destination {@link recordHref} addresses. */
303+
/**
304+
* The same destination as an HREF, for the lookup values rendered anywhere
305+
* under this bridge (objectui#4336): a lookup points at a record of another
306+
* object, and `LookupCellRenderer` has no router, so it asks for the href
307+
* instead of re-deriving `/apps/:app/:object/record/:id` a second time (the
308+
* #4472 lesson — one resolver).
309+
*
310+
* The browser reads an href, not the router, so it carries the router's
311+
* basename (objectui#12233; see {@link documentHref}). The published
312+
* contract offers it for "open in new tab" and "copy link address", and a
313+
* console mounted at `/_console` used to hand out the bare route instead:
314+
* only a plain left click, which `openRecord` routes, reached the record.
315+
*/
316+
const recordHref = useCallback(
317+
(objectName: string, recordId: string | number): string | null => {
318+
const path = recordPath(objectName, recordId);
319+
return path === null ? null : documentHref(routerRootHref, path);
320+
},
321+
[recordPath, routerRootHref],
322+
);
323+
324+
/**
325+
* SPA-navigate to the destination {@link recordHref} addresses — by its
326+
* router path, because `navigate` applies the basename itself.
327+
*/
300328
const openRecord = useCallback(
301329
(objectName: string, recordId: string | number) => {
302-
const href = recordHref(objectName, recordId);
303-
if (href) navigate(href);
330+
const path = recordPath(objectName, recordId);
331+
if (path) navigate(path);
304332
},
305-
[recordHref, navigate],
333+
[recordPath, navigate],
306334
);
307335

308336
// objectstack-ai/objectstack#2604 D3 — open a child create/edit task as the console's global record
@@ -425,7 +453,7 @@ export function RelatedRecordActionsBridge({
425453
};
426454
const handlers: RelatedRecordHandlers = {
427455
// Viewing a child record is always allowed when the list is visible.
428-
// Same builder the lookup links use — `recordHref` above.
456+
// Same route the lookup links address — `recordPath` above.
429457
onView: (id) => openRecord(objectName, id),
430458
};
431459

‎packages/app-shell/src/views/__tests__/PageView.context-9673.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ vi.mock('react-router-dom', () => ({
3838
useParams: () => ({ pageName: PAGE_NAME }),
3939
useSearchParams: () => [new URLSearchParams(search), vi.fn()],
4040
useNavigate: () => vi.fn(),
41+
// PageView reads the router's root href for its record navigator (objectui#12233).
42+
useHref: () => '/',
4143
useLocation: () => ({ pathname: `/apps/cloud/page/${PAGE_NAME}`, search: search ? `?${search}` : '' }),
4244
}));
4345

‎packages/app-shell/src/views/__tests__/PageView.editInStudioNeedsAuthoring-10899.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ vi.mock('react-router-dom', () => ({
3131
useParams: () => ({ pageName: 'welcome' }),
3232
useSearchParams: () => [new URLSearchParams(), vi.fn()],
3333
useNavigate: () => vi.fn(),
34+
// PageView reads the router's root href for its record navigator (objectui#12233).
35+
useHref: () => '/',
3436
useLocation: () => ({ pathname: '/apps/cloud_control/page/welcome', search: '' }),
3537
}));
3638

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#12233 — a lookup on a custom page carries the router's basename,
5+
* as the record page's lookups do.
6+
*
7+
* `PageView` publishes the console's record navigator to the blocks on the
8+
* page (`pageRecordActionsValue`, objectui#11293), and a lookup there renders
9+
* a real anchor from its `recordHref`. That builder returned the bare route
10+
* `/apps/APP/OBJECT/record/ID`, so on a console mounted at `/_console` a new
11+
* tab, a middle-click or a copied link left the console, while a left click
12+
* (routed by `openRecord`) worked.
13+
*
14+
* The page, the router and the lookup are real: `PageView` under a
15+
* `MemoryRouter`, and the real `LookupCellRenderer` reading the context the
16+
* page publishes. `SchemaRenderer` is replaced by that one lookup, the stand-in
17+
* the objectui#11293 pin beside this file uses for "a block on the page". The
18+
* oracle for the href is react-router's own `Link` for the same route. The
19+
* control mounts the same tree with no basename and keeps the bare href.
20+
*/
21+
22+
import * as React from 'react';
23+
import { describe, it, expect, vi, afterEach } from 'vitest';
24+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
25+
import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
26+
import '@testing-library/jest-dom';
27+
import { LookupCellRenderer } from '@object-ui/fields';
28+
29+
const PAGE_NAME = 'the_page';
30+
const APP = 'crm';
31+
const ACCOUNT = { id: 'A-1', name: 'Acme Corp' };
32+
const ACCOUNT_ROUTE = `/apps/${APP}/account/record/${ACCOUNT.id}`;
33+
34+
vi.mock('@object-ui/auth', async (importOriginal) => ({
35+
...(await importOriginal<Record<string, unknown>>()),
36+
useAuth: () => ({
37+
user: { id: 'u1', name: 'User', role: 'user', image: null },
38+
activeOrganization: null,
39+
}),
40+
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
41+
createAuthenticatedFetch: () => vi.fn(),
42+
}));
43+
44+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
45+
...(await importOriginal<Record<string, unknown>>()),
46+
useObjectTranslation: () => ({
47+
t: (k: string, o?: { defaultValue?: string; name?: string }) => o?.defaultValue ?? o?.name ?? k,
48+
}),
49+
}));
50+
51+
vi.mock('../../providers/MetadataProvider', () => ({
52+
useMetadata: () => ({
53+
pages: [{ name: 'the_page', label: 'A Page', type: 'app' }],
54+
objects: [{ name: 'account' }],
55+
}),
56+
}));
57+
58+
vi.mock('../MetadataInspector', () => ({
59+
MetadataPanel: () => null,
60+
useMetadataInspector: () => ({ showDebug: false }),
61+
}));
62+
63+
vi.mock('@object-ui/react', async (orig) => {
64+
const actual = await orig<typeof import('@object-ui/react')>();
65+
return {
66+
...actual,
67+
useAdapter: () => ({}),
68+
// "A block on the page": one real lookup, reading the context PageView
69+
// publishes.
70+
SchemaRenderer: () => (
71+
<LookupCellRenderer
72+
value={{ id: 'A-1', name: 'Acme Corp' }}
73+
field={{ type: 'lookup', reference: 'account' } as never}
74+
/>
75+
),
76+
};
77+
});
78+
79+
import { PageView } from '../PageView';
80+
81+
afterEach(() => {
82+
cleanup();
83+
});
84+
85+
/** The router's own location, basename stripped: where a left click took us. */
86+
function RouterLocation() {
87+
const location = useLocation();
88+
return <output data-testid="router-location">{`${location.pathname}${location.search}`}</output>;
89+
}
90+
91+
function mount(basename?: string) {
92+
const root = basename ?? '';
93+
return render(
94+
<MemoryRouter basename={basename} initialEntries={[`${root}/apps/${APP}/page/${PAGE_NAME}`]}>
95+
<Routes>
96+
<Route path="/apps/:appName/page/:pageName" element={<PageView />} />
97+
<Route path="*" element={null} />
98+
</Routes>
99+
<Link to={ACCOUNT_ROUTE} data-testid="oracle">
100+
oracle
101+
</Link>
102+
<RouterLocation />
103+
</MemoryRouter>,
104+
);
105+
}
106+
107+
const accountAnchor = async () => (await screen.findByText(ACCOUNT.name)).closest('a');
108+
109+
describe('a lookup on a custom page carries the router basename (objectui#12233)', () => {
110+
it('under basename /_console: the href is the one react-router renders for the route, and a left click routes there once', async () => {
111+
mount('/_console');
112+
const anchor = await accountAnchor();
113+
const href = anchor?.getAttribute('href') ?? null;
114+
expect(href).toBe(`/_console${ACCOUNT_ROUTE}`);
115+
expect(href).toBe(screen.getByTestId('oracle').getAttribute('href'));
116+
fireEvent.click(anchor as Element, { button: 0 });
117+
expect(screen.getByTestId('router-location').textContent).toBe(ACCOUNT_ROUTE);
118+
});
119+
120+
it('control, no basename: the href is the bare route, as before', async () => {
121+
mount();
122+
const anchor = await accountAnchor();
123+
expect(anchor?.getAttribute('href')).toBe(ACCOUNT_ROUTE);
124+
expect(anchor?.getAttribute('href')).toBe(screen.getByTestId('oracle').getAttribute('href'));
125+
fireEvent.click(anchor as Element, { button: 0 });
126+
expect(screen.getByTestId('router-location').textContent).toBe(ACCOUNT_ROUTE);
127+
});
128+
});

‎packages/app-shell/src/views/__tests__/PageView.recordNavigator-11293.test.tsx‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ vi.mock('react-router-dom', () => ({
3434
useParams: () => ({ pageName: PAGE_NAME }),
3535
useSearchParams: () => [new URLSearchParams(), vi.fn()],
3636
useNavigate: () => navigate,
37+
// The router's root href, which the page's navigator joins its hrefs to
38+
// (objectui#12233). `/` is a root mount; the basename case is below.
39+
useHref: () => '/',
3740
useLocation: () => ({ pathname: `/apps/crm/page/${PAGE_NAME}`, search: '' }),
3841
}));
3942

@@ -115,20 +118,29 @@ describe('pageRecordActionsValue — the builder the page publishes', () => {
115118
const objects = [{ name: 'account' }, { name: '' }, null, { label: 'nameless' }];
116119

117120
it('addresses a routable object under the app route', () => {
118-
const value = pageRecordActionsValue('crm', objects, vi.fn());
121+
const value = pageRecordActionsValue('crm', objects, vi.fn(), '/');
119122
expect(value.recordHref!('account', 7)).toBe('/apps/crm/account/record/7');
120123
});
121124

125+
it('under a basename, `recordHref` is the document href and `openRecord` still navigates by the router path (objectui#12233)', () => {
126+
const go = vi.fn();
127+
const value = pageRecordActionsValue('crm', objects, go, '/_console');
128+
expect(value.recordHref!('account', 'a/b')).toBe('/_console/apps/crm/account/record/a%2Fb');
129+
value.openRecord!('account', 'a/b');
130+
expect(go).toHaveBeenCalledWith('/apps/crm/account/record/a%2Fb');
131+
expect(value.recordHref!('contact', 7)).toBeNull();
132+
});
133+
122134
it('answers null outside an app route, for an unknown object, and for an empty id', () => {
123-
expect(pageRecordActionsValue(undefined, objects, vi.fn()).recordHref!('account', 7)).toBeNull();
124-
const value = pageRecordActionsValue('crm', objects, vi.fn());
135+
expect(pageRecordActionsValue(undefined, objects, vi.fn(), '/').recordHref!('account', 7)).toBeNull();
136+
const value = pageRecordActionsValue('crm', objects, vi.fn(), '/');
125137
expect(value.recordHref!('contact', 7)).toBeNull();
126138
expect(value.recordHref!('account', '')).toBeNull();
127139
});
128140

129141
it('`openRecord` navigates to exactly the `recordHref` destination, and to nothing without one', () => {
130142
const go = vi.fn();
131-
const value = pageRecordActionsValue('crm', objects, go);
143+
const value = pageRecordActionsValue('crm', objects, go, '/');
132144
value.openRecord!('account', 'a/b');
133145
expect(go).toHaveBeenCalledWith(value.recordHref!('account', 'a/b'));
134146
expect(go).toHaveBeenCalledWith('/apps/crm/account/record/a%2Fb');

‎packages/app-shell/src/views/__tests__/PageView.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ vi.mock('react-router-dom', () => ({
2222
useParams: () => ({ pageName: 'home' }),
2323
useSearchParams: () => [new URLSearchParams(), vi.fn()],
2424
useNavigate: () => vi.fn(),
25+
// PageView reads the router's root href for its record navigator (objectui#12233).
26+
useHref: () => '/',
2527
useLocation: () => ({ pathname: '/apps/cloud/page/home', search: '' }),
2628
}));
2729

0 commit comments

Comments
 (0)