Skip to content

Commit 6d8372a

Browse files
fix(console): a missing /forms/NAME renders the shared localized not-found state (#27739)
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ Fixes #27446 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:控制台 `/forms/NAME` 打开一个不存在的表单时,不再显示英文原文 "Form metadata not found: view/NAME",改为显示控制台通用的「页面不存在」空状态(按会话语言显示,带「返回」按钮)。 - 为什么改:中文等非英文会话里这里漏出一句未翻译的英文报错,和控制台其它「找不到」页面不一致。 - 风险与回滚:只动 `FormPage.tsx` 的内部表单加载与错误渲染;仅 404 走新状态,其它加载失败照旧显示错误横幅。回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/components/FormPage.tsx` 中 `FormNotFoundError` 与 `if (notFound && !loaded)` 两处;再看测试 `FormPage.notFound-27446.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/components/FormPage.notFound-27446.test.tsx`,预期 3 个用例全绿(zh 显示「页面不存在」且页面无英文)。 - 需要你做的:无,席位落地。 ## What changes - `loadInternalForm` threw `Form metadata not found: view/NAME` for every non-OK `/meta/view/NAME` read, and `FormPage` rendered that string verbatim in its error banner, in every session language. - A 404 from that read now throws a module-private `FormNotFoundError`. The page keeps it in its own `notFound` flag (not in the `error` string), and draws the console's shared not-found state: the `Empty` / `EmptyTitle` / `EmptyDescription` layout of the catch-all `RouteNotFound` in app-shell's `AppContent`, with its pack keys `console.notFound.title`, `console.notFound.description` and `console.notFound.back` (Go back, `navigate(-1)`). The metadata route answers 404 both for a view that does not exist and for one the caller may not read, so the page says the one thing both mean. - Every other non-OK read keeps the error banner. Its text is now `Could not load form metadata (STATUS): view/NAME`, because after the split the old wording would claim "not found" for a 403 or a 500. - No `@object-ui/i18n` key is added and no pack value changes (`Clause-②: no`). All ten packs already carry `console.notFound.*`. ## Tests - New `ui/apps/console/src/components/FormPage.notFound-27446.test.tsx`, at the real `/forms/:name` route with no provider wrapped around it: - zh: the state shows `页面不存在`, the zh description and a `返回` button, and the page body holds no Latin word; - en: `Page not found` and `Go back`, and no `Form metadata not found`; - CONTROL: a 500 keeps the error banner and draws no not-found state. - Ablation: with `FormPage.tsx` restored to the merge base, all 3 cases fail (zh and en find no not-found state; CONTROL finds no `Could not load form metadata (500)` text). Restored from `HEAD`, the file's hash matched the HEAD blob. <details><summary>Local runs (head 722dca5)</summary> - `pnpm --dir ui exec vitest run apps/console/src/components/FormPage` (all `FormPage*` suites): Test Files 39 passed (39), Tests 366 passed (366). - `pnpm --filter @object-ui/console run type-check`: exit 0 (the console tsconfig includes `src`, so the new test is compiled). - `pnpm --dir ui exec vitest run scripts/`: Test Files 169 passed, 2 skipped (171). - `pnpm check:new-line-citations` (ui): 0 new citations. - `node scripts/check-sdui-manifest.mjs` and the rest of the `dispatch-gates --commands` list: exit 0, reconciled with `--ran`. - Before pushing, `ui/` was grepped for `Form metadata not found`: the only hit is the message `FormNotFoundError` keeps for logging. No test pinned the old text. </details> ## Acceptance notes - Read only, not reproduced and not filed: the public `/f/:slug` loader (`loadPublicForm`) also builds an English `Failed to load form (STATUS): ...` string that the same banner renders. It is the anonymous route and is left unchanged here. - The other English throws in `loadInternalForm` (a form view with no `object` target, a record link naming another object) still render verbatim. They are author or link faults, not "no such form", and are out of this card's scope. ## Verification In a zh-CN session, open `/_console/forms/no_such_form`: the page shows `页面不存在` / `你访问的链接与该应用中的任何视图都不匹配。` and a `返回` button, with no English text. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 34dc09b commit 6d8372a

3 files changed

Lines changed: 164 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): `/forms/NAME` for a form that does not exist renders the console's localized not-found state, not a raw English error (#27446)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The internal form route threw `Form metadata not found: view/NAME` for any failed `/meta/view` read, and the page rendered that English text verbatim, whatever the session language.
11+
12+
- **A 404 is "no such form".** The page now draws the console's shared not-found state, with the catch-all route's pack keys (`console.notFound.title`, `console.notFound.description`, `console.notFound.back`) in the session language, and a Go back button. The metadata route answers 404 both for a missing view and for one the caller may not read, so the page says the one thing both mean.
13+
- **Every other failure keeps the error banner.** A non-404 failure now says `Could not load form metadata (STATUS): view/NAME`, instead of claiming the form was not found.
14+
- No i18n key is added or changed. The public `/f/:slug` route is unchanged.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#27446 — `/forms/NAME` for a form that does not
5+
* exist rendered the loader's English throw, `Form metadata not found:
6+
* view/NAME`, verbatim in the page body, whatever the session language.
7+
*
8+
* The internal loader now tells a 404 from the `/meta/view` read apart from
9+
* every other load failure, and the page draws the console's shared
10+
* not-found state for it — the copy and pack keys of the catch-all route
11+
* (`console.notFound.*`) — in the session language. Every other load failure
12+
* keeps the error banner (the `CONTROL` case).
13+
*
14+
* No `I18nProvider` is wrapped around the route, for the reason
15+
* `FormPage.chromeI18n-11039.test.tsx` gives: `createI18n` registers the
16+
* process-global instance every unwrapped consumer reads.
17+
*/
18+
19+
import { describe, expect, it, vi, afterEach } from 'vitest';
20+
import { render, screen } from '@testing-library/react';
21+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
22+
import { createI18n } from '@object-ui/i18n';
23+
import { FormPage } from './FormPage';
24+
25+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
26+
27+
/** Answers the `/meta/view` read with `status`; nothing else is reachable. */
28+
function stubViewRead(status: number) {
29+
return vi.fn(async (url: string) => {
30+
if (!String(url).includes('/meta/view/no_such_form')) throw new Error(`unstubbed fetch: ${url}`);
31+
const body = { error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' } };
32+
return {
33+
ok: status >= 200 && status < 300,
34+
status,
35+
statusText: 'Error',
36+
json: async () => body,
37+
text: async () => JSON.stringify(body),
38+
} as unknown as Response;
39+
});
40+
}
41+
42+
function renderMissingForm() {
43+
return render(
44+
<MemoryRouter initialEntries={['/forms/no_such_form']}>
45+
<Routes>
46+
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
47+
</Routes>
48+
</MemoryRouter>,
49+
);
50+
}
51+
52+
function inLanguage(language: string) {
53+
createI18n({ defaultLanguage: language, detectBrowserLanguage: false });
54+
}
55+
56+
afterEach(() => {
57+
vi.unstubAllGlobals();
58+
});
59+
60+
describe('FormPage: a missing form renders the shared localized not-found state (#27446)', () => {
61+
it('zh: the not-found state is the zh pack\'s, and no English reaches the page', async () => {
62+
inLanguage('zh');
63+
vi.stubGlobal('fetch', stubViewRead(404));
64+
renderMissingForm();
65+
66+
const state = await screen.findByTestId('form-not-found');
67+
expect(state).toHaveTextContent('页面不存在');
68+
expect(state).toHaveTextContent('你访问的链接与该应用中的任何视图都不匹配。');
69+
expect(screen.getByRole('button', { name: '返回' })).toBeInTheDocument();
70+
expect(document.body.textContent).not.toMatch(/[A-Za-z]{3,}/);
71+
});
72+
73+
it('en: the same state in English', async () => {
74+
inLanguage('en');
75+
vi.stubGlobal('fetch', stubViewRead(404));
76+
renderMissingForm();
77+
78+
const state = await screen.findByTestId('form-not-found');
79+
expect(state).toHaveTextContent('Page not found');
80+
expect(screen.getByRole('button', { name: 'Go back' })).toBeInTheDocument();
81+
expect(document.body.textContent).not.toContain('Form metadata not found');
82+
});
83+
84+
it('CONTROL: a load failure that is not a 404 keeps the error banner', async () => {
85+
inLanguage('zh');
86+
vi.stubGlobal('fetch', stubViewRead(500));
87+
renderMissingForm();
88+
89+
expect(await screen.findByText(/Could not load form metadata \(500\)/)).toBeInTheDocument();
90+
expect(screen.queryByTestId('form-not-found')).not.toBeInTheDocument();
91+
});
92+
});

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,17 @@ import {
148148
mapFieldTypeToFormType,
149149
resolveFormWidgetType,
150150
} from '@object-ui/fields';
151-
import { Tabs, TabsContent, TabsList, TabsTrigger, cn } from '@object-ui/components';
151+
import {
152+
Button,
153+
Empty,
154+
EmptyDescription,
155+
EmptyTitle,
156+
Tabs,
157+
TabsContent,
158+
TabsList,
159+
TabsTrigger,
160+
cn,
161+
} from '@object-ui/components';
152162
import {
153163
SchemaRendererContext,
154164
extractFieldErrors,
@@ -1709,6 +1719,24 @@ export function resolveInternalForm(
17091719
};
17101720
}
17111721

1722+
/**
1723+
* The internal route's "no such form" (objectstack-ai/objectstack#27446): the
1724+
* `/meta/view` read answered 404. The metadata route answers 404 both for a
1725+
* view that does not exist and for one the caller may not read, on purpose, so
1726+
* the page says the one thing both mean — nothing here by that name.
1727+
*
1728+
* A type of its own, not a message to match, so {@link FormPage} can tell it
1729+
* from every other load failure and draw the console's shared localized
1730+
* not-found state for it instead of this English text. The message is kept
1731+
* for anyone who logs the error; the page never renders it.
1732+
*/
1733+
class FormNotFoundError extends Error {
1734+
constructor(name: string) {
1735+
super(`Form metadata not found: view/${name}`);
1736+
this.name = 'FormNotFoundError';
1737+
}
1738+
}
1739+
17121740
/**
17131741
* Internal mode: pull the FormView metadata directly + the target object's
17141742
* schema, and — when the URL names a record (objectui#4278) — that record.
@@ -1733,8 +1761,9 @@ async function loadInternalForm(
17331761
channel?: ApiFetch,
17341762
): Promise<LoadedForm> {
17351763
const viewRes = await apiFetch(`/meta/view/${encodeURIComponent(name)}`, undefined, channel);
1764+
if (viewRes.status === 404) throw new FormNotFoundError(name);
17361765
if (!viewRes.ok) {
1737-
throw new Error(`Form metadata not found: view/${name}`);
1766+
throw new Error(`Could not load form metadata (${viewRes.status}): view/${name}`);
17381767
}
17391768
const viewBody = await viewRes.json();
17401769
const { label, object: objectName, form } = resolveInternalForm(name, viewBody);
@@ -2336,6 +2365,12 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
23362365

23372366
const [loading, setLoading] = useState(true);
23382367
const [error, setError] = useState<string | null>(null);
2368+
/**
2369+
* The load answered "no such form" ({@link FormNotFoundError}). Its own flag
2370+
* rather than a string in `error`, because the page draws a localized state
2371+
* for it and never shows the thrown text (objectstack-ai/objectstack#27446).
2372+
*/
2373+
const [notFound, setNotFound] = useState(false);
23392374
/**
23402375
* The rows the last submit refused, by field name (objectstack-ai/objectstack#27417).
23412376
* The `error` banner and the outcome toast still say the whole refusal; this
@@ -2421,6 +2456,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
24212456
let cancelled = false;
24222457
setLoading(true);
24232458
setError(null);
2459+
setNotFound(false);
24242460
setFieldErrors({});
24252461
// A `?recordId=` we cannot make sense of never reaches a loader: it is an
24262462
// edit intent this route cannot honour, and #4278 ruling point 2 says
@@ -2446,7 +2482,8 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
24462482
// Clear any previously loaded form: a refusal must not leave an
24472483
// editable form on screen behind the error.
24482484
setLoaded(null);
2449-
setError(e?.message ?? String(e));
2485+
if (e instanceof FormNotFoundError) setNotFound(true);
2486+
else setError(e?.message ?? String(e));
24502487
}
24512488
})
24522489
.finally(() => {
@@ -2818,6 +2855,24 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
28182855
</div>
28192856
);
28202857
}
2858+
if (notFound && !loaded) {
2859+
// The console's shared not-found state, the one its catch-all route
2860+
// (`RouteNotFound` in app-shell's `AppContent`) draws, with the same pack
2861+
// keys: a form is a view, and a missing one is a URL that matches none.
2862+
return (
2863+
<div className="flex min-h-[60vh] w-full items-center justify-center p-8" data-testid="form-not-found">
2864+
<Empty>
2865+
<EmptyTitle>{t('console.notFound.title')}</EmptyTitle>
2866+
<EmptyDescription>{t('console.notFound.description')}</EmptyDescription>
2867+
<div className="mt-4">
2868+
<Button variant="outline" onClick={() => navigate(-1)}>
2869+
{t('console.notFound.back')}
2870+
</Button>
2871+
</div>
2872+
</Empty>
2873+
</div>
2874+
);
2875+
}
28212876
if (error && !loaded) {
28222877
return (
28232878
<div className="mx-auto max-w-2xl p-6">

0 commit comments

Comments
 (0)