Skip to content

Commit f6adf48

Browse files
fix(app-shell): a record form opened on the app-name address stays in the app after save (#28066)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ Fixes #28036 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:整页记录表单在保存/取消后"要不要后退"的判断,改为读 react-router 自己写在浏览器历史里的条目序号(`history.state.idx`):只有控制台自己推入过页面(序号大于 0)才后退,否则编辑落到记录页、新建落到列表页,都在应用内。 - **为什么改**:用应用名地址(`/apps/showcase_app/...`)打开表单时,控制台加载时会用一次"替换"把地址换成包 id 地址,这次替换给条目生成了新 key,旧判断(key 不是 `default` 就后退)误以为身后有应用内页面,保存后后退出了应用、提示也丢了。替换不改序号,推入才加一,所以序号能分清这两种情况。 - **风险与回滚**:只改 `RecordFormPage` 的 `goBack` 一处判断,不碰 `AppContent.tsx` 的重定向。从列表、视图、记录页进入表单的路径仍然后退(单测与实栈对照都测了)。回滚 = revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/RecordFormPage.tsx` 的 `goBack`;`RecordFormPage.aliasAddressReturn-28036.test.tsx`;`ui/e2e/live/record-form-direct-url.spec.ts` 的新循环。 - **两分钟验证**:看本 PR 的 `Live E2E UI` 检查日志,`Run the live e2e specs` 里 `record-form-direct-url.spec.ts` 应有两条通过:package id 与 app name 各一条。 - **需要你做的**:无,席位落地。 ## What changes - **`RecordFormPage`'s `goBack`** (`@object-ui/app-shell`). It went Back when `location.key !== 'default'`. The console's canonical-app-pathname effect replaces an app-NAME address with the package-id one on load, and that `replace` gives the entry a generated key with nothing of the console's behind it, so Save and Cancel ran `navigate(-1)` out of the document (#27454's fix held only on the package-id address). The page now reads react-router's own entry index, `history.state.idx`, at the moment of the click: react-router writes 0 on the entry the document loaded on, keeps it on a replace and adds one per push. Back only when it is a number above 0; otherwise the record detail (edit) or the object list (create), inside the app, as before. `useLocation` is no longer needed by the page and is dropped. Why the index rather than a console-written `location.state` flag: the index is already written by every push the console makes, through any component, and kept by every replace, so nothing else in the console has to remember to mark its navigations; a flag would need every pushing call site to set it. - **jsdom tests.** New `RecordFormPage.aliasAddressReturn-28036.test.tsx` loads the form on an app-name address under `BrowserRouter` on jsdom's real history, replays the console's canonical `replace` to the package-id address, and asserts edit Save lands on the record detail with one toast, create Save lands on the list with one toast, edit Cancel lands on the record detail with no toast, and the control: a form PUSHED from a view page, then canonicalised, goes Back to that view. Each cell also asserts the replace minted a non-`default` key, the condition the old test misread. `RecordFormPage.directUrlReturn-27454.test.tsx` keeps its five cells and assertions; its harness moves from `MemoryRouter` to `BrowserRouter` with a real react-router push, because the page now reads the browser history's entry index, which a memory router does not write. - **Live spec.** `ui/e2e/live/record-form-direct-url.spec.ts` now runs its edit case on both addresses (`com.example.showcase` and `showcase_app`); the landing assertion is the package-id record path for both. Its header says why both. - **Changeset** `.changeset/28036-record-form-app-name-address.md`, `@object-ui/app-shell` patch. ## Tests - `pnpm exec vitest run packages/app-shell/src/views/RecordFormPage` (from `ui/`): 11 files, 65 tests passed (head `71c89ea2`). - Ablation, under `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob `4cf71998` to `e207c822`, restored to the HEAD blob, `git diff HEAD` empty): `goBack`'s condition put back to the old key test (`history.state.key` not `default`). Direction written before the run: the three app-name cells RED, the in-app control GREEN, the five #27454 cells GREEN. Observed: exactly that, 3 failed / 6 passed of 9. Run on `93e6b790` and again on `71c89ea2`. - `pnpm --filter @object-ui/app-shell type-check` (both `tsc` programs, the test one included): exit 0 on `71c89ea2`. - `pnpm --dir ui run type-check:e2e`: exit 0. eslint on the four changed files: 0 errors; the 17 warnings are in lines of `RecordFormPage.tsx` this diff does not touch. ## Acceptance notes - **The card's table, re-measured** on this branch (head `1de7cdda`; the later commit changes only the jsdom test harness), with the `Live E2E UI` job's build (its turbo filters plus `VITE_BASE_PATH=/ vite build`), `start-backend.sh` and `vite preview`, one fresh Playwright page per row: | Address | Mode | `history.state` after load | URL after Save | Toasts | |---|---|---|---|---| | `showcase_app` (app name) | edit | key generated, `idx` 0 | record detail, in app | 1 | | `showcase_app` (app name) | new | key generated, `idx` 0 | object list, in app | 1 | | `com.example.showcase` (package id) | edit | `idx` 0 | record detail, in app | 1 | | `com.example.showcase` (package id) | new | `idx` 0 | object list, in app | 1 | | `com.example.showcase`, control: pushed from the list | edit | `idx` 1 | object list (Back), in app | 1 | The app-name rows read `about:blank` and 0 toasts on #28034's stack before this change (the card's table). The new rows came from a throwaway Playwright file, not committed. The control row's push was written the way react-router's own push writes it (`pushState` with `idx` + 1, then the `popstate` its listener reads), because no showcase object declares `editMode: 'page'`, so the console's Edit and New open the modal form and no in-app control there pushes the full-page form; the jsdom control drives a real react-router push. - `pnpm --dir ui run test:e2e:live:ci --retries 0` on the same stack: 16 passed (1.5 min on a shared box), the 15 before plus the new app-name case. - Not changed, noted: `FormPage.tsx` in `ui/apps/console` reads `location.key !== 'default'` for its Cancel, the same test this PR replaces; whether a canonical replace reaches its routes was not measured, and it is outside this card's file surface. `PageHeader` in `ui/packages/layout` still reads `window.history.length > 1` for its back button. - `console/AppContent.tsx` is untouched (seat 4's open PR #27907 holds it). - The project attribution link in the first two lines names the seat's thread, as the order requires. ## Verification Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`), every command run with its exit code recorded before any pipe, reconciled with `--ran`: 34 derived, 34 run, 0 NOT-MEASURED. <details><summary>34 derived gates, plus three outside the derivation</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @object-ui/app-shell type-check :: exit 0 pnpm --dir ui run type-check:e2e :: exit 0 pnpm --dir ui check:new-line-citations :: exit 0 ``` </details> All gate readings on head `71c89ea2`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZxX4vFUoXs7HyvMsp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f0857d2 commit f6adf48

5 files changed

Lines changed: 239 additions & 74 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(console): a full-page record form opened on the app's name address stays in the app after Save
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A record's full-page form (`/apps/APP/OBJECT/record/ID/edit` or `/apps/APP/OBJECT/new`) opened by a pasted link, a bookmark or a new tab on the app's **name** (rather than its package id) saved, then went Back out of the console: the page the tab showed before came back and the save toast was lost. The console moves a name address to the package-id one on load, and that move made the page believe a console page was behind it. Save and Cancel now go Back only when the console itself navigated to the form; otherwise an edit lands on the record and a new record on the object's list, inside the app, with one toast. A form opened from a list, a view or a record page still returns to that page.

‎ui/e2e/live/record-form-direct-url.spec.ts‎

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import { expectToast } from './helpers';
1111
* save toast is lost with the unloaded document and the user lands outside the
1212
* app. The page must instead go to the record inside the app. A fresh
1313
* Playwright page is exactly that entry: its only earlier history entry is
14-
* `about:blank`, not a console page. The URL is the app's one address (its
15-
* package id), the one the console's own links and its address bar carry.
14+
* `about:blank`, not a console page. Both of the app's addresses are opened:
15+
* its package id, the one the console's own links and its address bar carry,
16+
* and its app name, which the console replaces with the package id on load —
17+
* a replace that gives the entry a key with nothing of the console's behind it
18+
* (objectstack-ai/objectstack#28036).
1619
*
1720
* The record is the test's own, created and deleted through the data API;
1821
* the stored value is read back through the same API after the save.
@@ -23,56 +26,59 @@ import { expectToast } from './helpers';
2326
*/
2427

2528
const APP = 'com.example.showcase';
29+
const APP_NAME = 'showcase_app';
2630
const OBJECT = 'showcase_task';
2731
const API = process.env.LIVE_API_URL || 'http://localhost:3000';
2832

29-
test('a full-page edit form opened by URL saves, toasts, and stays inside the app', async ({ page, request }) => {
30-
// A seeded project to hang the task on (`project` is a required master-detail).
31-
const projectsRes = await request.get(`${API}/api/v1/data/showcase_project?$top=1`);
32-
expect(projectsRes.ok(), await projectsRes.text()).toBeTruthy();
33-
const projectsBody = await projectsRes.json();
34-
const project = (projectsBody?.records ?? projectsBody?.data ?? [])[0];
35-
expect(project, 'a seeded showcase_project exists').toBeTruthy();
33+
for (const [address, opened] of [['package id', APP], ['app name', APP_NAME]] as const) {
34+
test(`a full-page edit form opened by URL on its ${address} saves, toasts, and stays inside the app`, async ({ page, request }) => {
35+
// A seeded project to hang the task on (`project` is a required master-detail).
36+
const projectsRes = await request.get(`${API}/api/v1/data/showcase_project?$top=1`);
37+
expect(projectsRes.ok(), await projectsRes.text()).toBeTruthy();
38+
const projectsBody = await projectsRes.json();
39+
const project = (projectsBody?.records ?? projectsBody?.data ?? [])[0];
40+
expect(project, 'a seeded showcase_project exists').toBeTruthy();
3641

37-
const stamp = Date.now();
38-
const createRes = await request.post(`${API}/api/v1/data/${OBJECT}`, {
39-
data: { title: `E2E Direct URL ${stamp}`, project: project.id ?? project._id, status: 'backlog' },
40-
});
41-
expect(createRes.ok(), await createRes.text()).toBeTruthy();
42-
const created = await createRes.json();
43-
const recordId = created?.id ?? created?.record?.id ?? created?.data?.id;
44-
expect(recordId, 'created task id').toBeTruthy();
42+
const stamp = Date.now();
43+
const createRes = await request.post(`${API}/api/v1/data/${OBJECT}`, {
44+
data: { title: `E2E Direct URL ${stamp}`, project: project.id ?? project._id, status: 'backlog' },
45+
});
46+
expect(createRes.ok(), await createRes.text()).toBeTruthy();
47+
const created = await createRes.json();
48+
const recordId = created?.id ?? created?.record?.id ?? created?.data?.id;
49+
expect(recordId, 'created task id').toBeTruthy();
4550

46-
try {
47-
const recordPath = `/apps/${APP}/${OBJECT}/record/${encodeURIComponent(recordId)}`;
48-
await page.goto(`${recordPath}/edit`);
49-
const form = page.getByTestId('record-form-page');
50-
await expect(form).toHaveAttribute('data-mode', 'edit');
51+
try {
52+
const recordPath = `/apps/${APP}/${OBJECT}/record/${encodeURIComponent(recordId)}`;
53+
await page.goto(`/apps/${opened}/${OBJECT}/record/${encodeURIComponent(recordId)}/edit`);
54+
const form = page.getByTestId('record-form-page');
55+
await expect(form).toHaveAttribute('data-mode', 'edit');
5156

52-
const title = form.locator('input[name="title"]');
53-
await expect(title).toHaveValue(`E2E Direct URL ${stamp}`);
54-
const renamed = `E2E Direct URL renamed ${stamp}`;
55-
await title.fill(renamed);
57+
const title = form.locator('input[name="title"]');
58+
await expect(title).toHaveValue(`E2E Direct URL ${stamp}`);
59+
const renamed = `E2E Direct URL renamed ${stamp}`;
60+
await title.fill(renamed);
5661

57-
const [saved] = await Promise.all([
58-
page.waitForResponse(
59-
(r) => ['PATCH', 'PUT'].includes(r.request().method()) && r.url().includes(`/api/v1/data/${OBJECT}/`),
60-
{ timeout: 15_000 },
61-
),
62-
form.locator('button[type="submit"]').click(),
63-
]);
64-
expect(saved.ok(), `the save answered ${saved.status()}`).toBe(true);
62+
const [saved] = await Promise.all([
63+
page.waitForResponse(
64+
(r) => ['PATCH', 'PUT'].includes(r.request().method()) && r.url().includes(`/api/v1/data/${OBJECT}/`),
65+
{ timeout: 15_000 },
66+
),
67+
form.locator('button[type="submit"]').click(),
68+
]);
69+
expect(saved.ok(), `the save answered ${saved.status()}`).toBe(true);
6570

66-
// The toast survives because the document did not unload…
67-
await expectToast(page, new RegExp(`Task "${renamed}" updated`));
68-
// …and the page landed on the record, inside the app.
69-
await expect(page).toHaveURL((url) => url.pathname === recordPath);
70-
await expect(page.getByTestId('record-form-page')).toHaveCount(0);
71+
// The toast survives because the document did not unload…
72+
await expectToast(page, new RegExp(`Task "${renamed}" updated`));
73+
// …and the page landed on the record, inside the app.
74+
await expect(page).toHaveURL((url) => url.pathname === recordPath);
75+
await expect(page.getByTestId('record-form-page')).toHaveCount(0);
7176

72-
const readRes = await request.get(`${API}/api/v1/data/${OBJECT}/${encodeURIComponent(recordId)}`);
73-
expect(readRes.ok(), `GET ${OBJECT}/${recordId} answered ${readRes.status()}`).toBe(true);
74-
expect((await readRes.json()).record?.title).toBe(renamed);
75-
} finally {
76-
await request.delete(`${API}/api/v1/data/${OBJECT}/${encodeURIComponent(recordId)}`);
77-
}
78-
});
77+
const readRes = await request.get(`${API}/api/v1/data/${OBJECT}/${encodeURIComponent(recordId)}`);
78+
expect(readRes.ok(), `GET ${OBJECT}/${recordId} answered ${readRes.status()}`).toBe(true);
79+
expect((await readRes.json()).record?.title).toBe(renamed);
80+
} finally {
81+
await request.delete(`${API}/api/v1/data/${OBJECT}/${encodeURIComponent(recordId)}`);
82+
}
83+
});
84+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* objectstack-ai/objectstack#28036 — a full-page record form opened on its
3+
* app-NAME address stays in the app after save or cancel. The console moves
4+
* that address to the package-id one with a `replace` navigate on load
5+
* (`AppContent`'s canonical app pathname), which gives the entry a generated
6+
* key with nothing of the console's behind it. Each cell replays that replace
7+
* on a real browser history under `BrowserRouter`. Direction, written before
8+
* the run: keyed on `location.key !== 'default'`, every alias cell RED (Back
9+
* leaves the app); the in-app cell GREEN.
10+
*/
11+
12+
import '@testing-library/jest-dom/vitest';
13+
import * as React from 'react';
14+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
15+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
16+
import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
17+
import { I18nProvider } from '@object-ui/i18n';
18+
import { RecordFormPage } from './RecordFormPage';
19+
20+
const h = React.createElement;
21+
22+
const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() }));
23+
24+
vi.mock('sonner', () => ({
25+
toast: Object.assign(vi.fn(), { success: toastSuccess, error: vi.fn(), info: vi.fn(), dismiss: vi.fn() }),
26+
}));
27+
28+
vi.mock('@object-ui/auth', async (importOriginal) => ({
29+
...(await importOriginal<Record<string, unknown>>()),
30+
useAuth: () => ({
31+
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'admin' },
32+
getAuthConfig: async () => ({ features: {} }),
33+
activeOrganization: null,
34+
}),
35+
}));
36+
37+
// Stand-in form: one button per exit, as `ObjectForm` calls `onSuccess(result)` / `onCancel()`.
38+
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
39+
...(await importOriginal<Record<string, unknown>>()),
40+
ObjectForm: ({ schema }: { schema: { onSuccess?: (s?: unknown) => void; onCancel?: () => void } }) =>
41+
h(
42+
'div',
43+
null,
44+
h('button', { type: 'button', 'data-testid': 'fire-success', onClick: () => schema.onSuccess?.({ id: 't1', subject: 'Quarterly review' }) }, 'ok'),
45+
h('button', { type: 'button', 'data-testid': 'fire-cancel', onClick: () => schema.onCancel?.() }, 'cancel'),
46+
),
47+
}));
48+
49+
const TASK = { name: 'showcase_task', label: 'Task', nameField: 'subject', fields: { subject: { type: 'text' } } };
50+
const METADATA = { objects: [TASK], loading: false };
51+
vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => METADATA }));
52+
vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => null }));
53+
54+
const Where = () => h('div', { 'data-testid': 'where' }, useLocation().pathname);
55+
/**
56+
* The console's two navigations: a react-router push to `to`, and the
57+
* replace `AppContent` makes on an app-name address (to its package id).
58+
*/
59+
const Nav = ({ to, canonical }: { to: string; canonical: string }) => {
60+
const navigate = useNavigate();
61+
return h(
62+
React.Fragment,
63+
null,
64+
h('button', { type: 'button', 'data-testid': 'push', onClick: () => { void navigate(to); } }, 'push'),
65+
h('button', { type: 'button', 'data-testid': 'canonical', onClick: () => { void navigate(canonical, { replace: true }); } }, 'canonical'),
66+
);
67+
};
68+
69+
const ALIAS = '/apps/crm_app/showcase_task';
70+
const APP = '/apps/com.example.crm/showcase_task';
71+
const LIST = APP;
72+
const DETAIL = `${APP}/record/t1`;
73+
const VIEW = `${APP}/view/open`;
74+
75+
beforeEach(() => {
76+
toastSuccess.mockClear();
77+
});
78+
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
79+
80+
const formOf = (base: string, mode: 'create' | 'edit') => (mode === 'create' ? `${base}/new` : `${base}/record/t1/edit`);
81+
82+
/**
83+
* Open the form on its app-name address — loaded on it, or pushed from
84+
* `before` — let the console replace it with the package-id address, fire
85+
* `exit`, return where it lands.
86+
*/
87+
async function exitFromAlias(mode: 'create' | 'edit', exit: 'success' | 'cancel', before?: string): Promise<string> {
88+
window.history.replaceState(null, '', before ?? formOf(ALIAS, mode));
89+
render(
90+
h(I18nProvider, {
91+
config: { defaultLanguage: 'en', detectBrowserLanguage: false },
92+
children: h(
93+
BrowserRouter,
94+
null,
95+
h(Nav, { to: formOf(ALIAS, mode), canonical: formOf(APP, mode) }),
96+
h(
97+
Routes,
98+
null,
99+
h(Route, { path: '/apps/:appName/:objectName/new', element: h(RecordFormPage, { mode: 'create' }) }),
100+
h(Route, { path: '/apps/:appName/:objectName/record/:recordId/edit', element: h(RecordFormPage, { mode: 'edit' }) }),
101+
h(Route, { path: '*', element: h(Where) }),
102+
),
103+
),
104+
}),
105+
);
106+
if (before) fireEvent.click(screen.getByTestId('push'));
107+
fireEvent.click(screen.getByTestId('canonical'));
108+
// The replace minted a key, so the old in-app test read this entry as having history.
109+
const { key } = window.history.state as { key?: string };
110+
expect(key).toBeTruthy();
111+
expect(key).not.toBe('default');
112+
fireEvent.click(screen.getByTestId(exit === 'success' ? 'fire-success' : 'fire-cancel'));
113+
return (await screen.findByTestId('where')).textContent ?? '';
114+
}
115+
116+
describe('RecordFormPage — the app-name address stays in the app (objectstack-ai/objectstack#28036)', () => {
117+
it('edit, app-name URL: saves, toasts once, lands on the record detail', async () => {
118+
expect(await exitFromAlias('edit', 'success')).toBe(DETAIL);
119+
expect(toastSuccess).toHaveBeenCalledTimes(1);
120+
expect(toastSuccess).toHaveBeenCalledWith('Task "Quarterly review" updated');
121+
});
122+
123+
it('create, app-name URL: saves, toasts once, lands on the object list', async () => {
124+
expect(await exitFromAlias('create', 'success')).toBe(LIST);
125+
expect(toastSuccess).toHaveBeenCalledTimes(1);
126+
expect(toastSuccess).toHaveBeenCalledWith('Task "Quarterly review" created');
127+
});
128+
129+
it('edit, app-name URL: cancel lands on the record detail, no toast', async () => {
130+
expect(await exitFromAlias('edit', 'cancel')).toBe(DETAIL);
131+
expect(toastSuccess).not.toHaveBeenCalled();
132+
});
133+
134+
it('edit, pushed from a page of the app: save goes back to that page', async () => {
135+
expect(await exitFromAlias('edit', 'success', VIEW)).toBe(VIEW);
136+
expect(toastSuccess).toHaveBeenCalledTimes(1);
137+
});
138+
});

‎ui/packages/app-shell/src/views/RecordFormPage.directUrlReturn-27454.test.tsx‎

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
* tab's own history is longer than the console's, so `window.history.length`
66
* is simulated as 3 on every cell. Direction, written before the run: keyed on
77
* `window.history.length > 1`, every direct-URL cell RED; in-app cells GREEN.
8+
* A real browser history under `BrowserRouter`, since the page reads the entry
9+
* index react-router writes there (objectstack-ai/objectstack#28036).
810
*/
911

1012
import '@testing-library/jest-dom/vitest';
1113
import * as React from 'react';
1214
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1315
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
14-
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
16+
import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
1517
import { I18nProvider } from '@object-ui/i18n';
1618
import { RecordFormPage } from './RecordFormPage';
1719

@@ -50,6 +52,11 @@ vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => METADATA })
5052
vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => null }));
5153

5254
const Where = () => h('div', { 'data-testid': 'where' }, useLocation().pathname);
55+
/** The console's own in-app navigation: a react-router push to `to`. */
56+
const Nav = ({ to }: { to: string }) => {
57+
const navigate = useNavigate();
58+
return h('button', { type: 'button', 'data-testid': 'push', onClick: () => { void navigate(to); } }, 'push');
59+
};
5360

5461
const LIST = '/apps/crm/showcase_task';
5562
const DETAIL = '/apps/crm/showcase_task/record/t1';
@@ -61,16 +68,17 @@ beforeEach(() => {
6168
});
6269
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
6370

64-
/** Mount the form behind `before` (none = direct URL), fire `exit`, return where it lands. */
65-
function exitFrom(mode: 'create' | 'edit', exit: 'success' | 'cancel', before?: string): string {
71+
/** Load the document on `before` and push the form (none = direct URL), fire `exit`, return where it lands. */
72+
async function exitFrom(mode: 'create' | 'edit', exit: 'success' | 'cancel', before?: string): Promise<string> {
6673
const form = mode === 'create' ? `${LIST}/new` : `${DETAIL}/edit`;
67-
const entries = before ? [before, form] : [form];
74+
window.history.replaceState(null, '', before ?? form);
6875
render(
6976
h(I18nProvider, {
7077
config: { defaultLanguage: 'en', detectBrowserLanguage: false },
7178
children: h(
72-
MemoryRouter,
73-
{ initialEntries: entries, initialIndex: entries.length - 1 },
79+
BrowserRouter,
80+
null,
81+
h(Nav, { to: form }),
7482
h(
7583
Routes,
7684
null,
@@ -81,32 +89,33 @@ function exitFrom(mode: 'create' | 'edit', exit: 'success' | 'cancel', before?:
8189
),
8290
}),
8391
);
92+
if (before) fireEvent.click(screen.getByTestId('push'));
8493
fireEvent.click(screen.getByTestId(exit === 'success' ? 'fire-success' : 'fire-cancel'));
85-
return screen.getByTestId('where').textContent ?? '';
94+
return (await screen.findByTestId('where')).textContent ?? '';
8695
}
8796

8897
describe('RecordFormPage — direct-URL entry returns inside the app (objectstack-ai/objectstack#27454)', () => {
89-
it('edit, direct URL: saves, toasts, lands on the record detail', () => {
90-
expect(exitFrom('edit', 'success')).toBe(DETAIL);
98+
it('edit, direct URL: saves, toasts, lands on the record detail', async () => {
99+
expect(await exitFrom('edit', 'success')).toBe(DETAIL);
91100
expect(toastSuccess).toHaveBeenCalledWith('Task "Quarterly review" updated');
92101
});
93102

94-
it('create, direct URL: saves, toasts, lands on the object list', () => {
95-
expect(exitFrom('create', 'success')).toBe(LIST);
103+
it('create, direct URL: saves, toasts, lands on the object list', async () => {
104+
expect(await exitFrom('create', 'success')).toBe(LIST);
96105
expect(toastSuccess).toHaveBeenCalledWith('Task "Quarterly review" created');
97106
});
98107

99-
it('edit, direct URL: cancel lands on the record detail, no toast', () => {
100-
expect(exitFrom('edit', 'cancel')).toBe(DETAIL);
108+
it('edit, direct URL: cancel lands on the record detail, no toast', async () => {
109+
expect(await exitFrom('edit', 'cancel')).toBe(DETAIL);
101110
expect(toastSuccess).not.toHaveBeenCalled();
102111
});
103112

104-
it('edit, in-app entry: save goes back to the page it came from', () => {
105-
expect(exitFrom('edit', 'success', VIEW)).toBe(VIEW);
113+
it('edit, in-app entry: save goes back to the page it came from', async () => {
114+
expect(await exitFrom('edit', 'success', VIEW)).toBe(VIEW);
106115
expect(toastSuccess).toHaveBeenCalledTimes(1);
107116
});
108117

109-
it('create, in-app entry: cancel goes back to the page it came from', () => {
110-
expect(exitFrom('create', 'cancel', VIEW)).toBe(VIEW);
118+
it('create, in-app entry: cancel goes back to the page it came from', async () => {
119+
expect(await exitFrom('create', 'cancel', VIEW)).toBe(VIEW);
111120
});
112121
});

0 commit comments

Comments
 (0)