Skip to content

Commit cc0777a

Browse files
committed
fix(app-shell,react): a custom page's lookup links carry the router basename too
Round 2 of objectui#12233. pageRecordActionsValue, the record navigator PageView publishes to the blocks on a custom page, returned the bare router path as recordHref, the same drop the record page's bridge had. It now takes the router's root href from its caller (PageView reads useHref('/')): recordHref returns the document href, and openRecord keeps navigating by the router path. documentHref moves to one internal module, views/documentHref.ts, that both producers import; no barrel re-exports it. The RelatedRecordActionsValue.recordHref doc in @object-ui/react now says the value is a document href carrying the host router's basename. Doc only; the type is unchanged. The PageView tests that mock react-router-dom with a factory gain a useHref entry, since PageView now reads it. Claude-Session: https://claude.ai/code/session_01G5TSaEwbEQsKMjhzTB4rEd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23225db commit cc0777a

12 files changed

Lines changed: 213 additions & 29 deletions
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
---
22
'@object-ui/app-shell': patch
3+
'@object-ui/react': patch
34
---
45

5-
Lookup links on a record 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).
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).
67

7-
On a console mounted under a router basename, such as `/_console`, a lookup value on a record page (for example a lead's converted account or contact, or its owner) rendered as an anchor whose href was the bare route `/apps/APP/OBJECT/record/ID`. 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`.
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`.
89

9-
The record page's record-link builder now gives lookups an href that includes the router's basename. It reads 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. Nothing is added to or removed from any package entry, and the `recordHref` member's type is unchanged.
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: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
@@ -137,22 +138,6 @@ function evalCreatePredicate(
137138
});
138139
}
139140

140-
/**
141-
* The href a document resolves to the place a router path names, which is the
142-
* href react-router's own `Link` renders for that path (objectui#12233).
143-
*
144-
* `rootHref` is `useHref('/')`, the router's href for its root, so it carries
145-
* the basename the host mounted the router under: `/_console` on a console the
146-
* framework serves there, `/` on a root mount. `navigate(path)` applies that
147-
* basename itself and an anchor's `href` does not. A router path published
148-
* bare as an href therefore works from a plain left click, which the anchor
149-
* hands to the SPA, and from nothing else: a new tab, a middle-click or a
150-
* copied link resolves it against the origin, outside the console.
151-
*/
152-
function documentHref(rootHref: string, routerPath: string): string {
153-
return `${rootHref.replace(/\/+$/, '')}${routerPath}`;
154-
}
155-
156141
export interface RelatedRecordActionsBridgeProps {
157142
/** Current app segment used to build `/apps/:appName/...` routes. */
158143
appName?: string;

‎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

‎packages/app-shell/src/views/__tests__/page-kind-writing-end-9718.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ vi.mock('react-router-dom', () => ({
6363
useParams: () => ({ pageName: PAGE_NAME }),
6464
useSearchParams: () => [new URLSearchParams(), vi.fn()],
6565
useNavigate: () => vi.fn(),
66+
// PageView reads the router's root href for its record navigator (objectui#12233).
67+
useHref: () => '/',
6668
useLocation: () => ({ pathname: `/apps/cloud/page/${PAGE_NAME}`, search: '' }),
6769
}));
6870

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The href a document resolves to the place a router path names, which is the
11+
* href react-router's own `Link` renders for that path (objectui#12233).
12+
*
13+
* `rootHref` is `useHref('/')`, the router's href for its root, so it carries
14+
* the basename the host mounted the router under: `/_console` on a console the
15+
* framework serves there, `/` on a root mount. `navigate(path)` applies that
16+
* basename itself and an anchor's `href` does not. A router path published
17+
* bare as an href therefore works from a plain left click, which the anchor
18+
* hands to the SPA, and from nothing else: a new tab, a middle-click or a
19+
* copied link resolves it against the origin, outside the console.
20+
*
21+
* The one join for every host that publishes
22+
* `RelatedRecordActionsValue.recordHref`: the record page's
23+
* `RelatedRecordActionsBridge` and the custom page's `pageRecordActionsValue`.
24+
* Internal to this package: keep it out of the package entry and its barrels.
25+
*/
26+
export function documentHref(rootHref: string, routerPath: string): string {
27+
return `${rootHref.replace(/\/+$/, '')}${routerPath}`;
28+
}

0 commit comments

Comments
 (0)