Skip to content

Commit fd97351

Browse files
fix(console): the standalone /forms route shows select option labels in the language the user chose (#26584)
Fixes #26452 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:独立表单路由 `/forms/:name`(及 `/apps/:appName/forms/:name`)改走控制台已认证的 fetch 读写 API,下拉选项标签随用户所选语言显示。 - 为什么改:该路由用裸 `fetch` 读对象 schema,服务端按浏览器的 `Accept-Language` 本地化选项;应用内弹窗表单走的通道带会话语言,所以同一会话两处语言不一致(p1,发版扫描 FAIL)。 - 风险与代价(含回滚):内部路由的请求从此带上 Bearer、`X-Tenant-ID`、`Accept-Language`,与弹窗表单一致;公开表单 `/f/:slug` 不变。回滚即 revert 本 PR。 - 席位意见: - 你要做的:无需操作;席位复核后按常规入队。 ## Summary On the standalone internal form route, a user whose chosen language differs from the browser's saw field labels in their language and every select option in the browser's. The in-app overlay form (`?form=ID`) in the same session showed the options translated. ## Root cause The overlay (`ModalForm` → `ObjectForm`) does not translate option labels on the client. Its field builder (`fromObjectSchema` in `@object-ui/plugin-form`) hands `field.options` to the widget as served. The translation happens on the server: the object schema is read over the console's authenticated fetch (`createAuthenticatedFetch`, published to the renderers as `SchemaRendererContext.apiFetch`). That fetch stamps `Accept-Language` with the session's UI language (the `lang` attribute of the html element), and the server localizes `/meta/object`, option labels included, in that language. `FormPage` read `/meta/view`, `/meta/object` and the record with a bare `fetch`, so the request carried the browser's language. Field labels still came out translated because they resolve on the client through the bundle (`fieldLabel`); options have no client lookup on either form surface, so they followed the browser. This matches the card's Control 2: a zh-CN browser already got Chinese options on this route. ## Fix `ui/apps/console/src/components/FormPage.tsx`: in internal mode, every API request (the form view, object schema and record reads, and the create/update submit) goes through `SchemaRendererContext.apiFetch`, the host's authenticated fetch the console shell publishes. With no provider above the route it falls back to the bare `fetch`, as before. The public anonymous route `/f/:slug` keeps the bare `fetch`. No option-label translator is added. The route now takes the channel the overlay takes, so the server's one resolver answers both surfaces. An option the bundle does not translate keeps its authored label, because that is the server's fallback. `InternalFormRoute.tsx` is unchanged: both internal mounts (`InternalFormRoute` and `InAppFormRoute`) render `FormPage mode="internal"` inside the console shell's `SchemaRendererProvider`. ## Tests New pin `ui/apps/console/src/components/FormPage.optionLabelLocale-26452.test.tsx`. It mounts `/forms/:name` in edit mode inside a `SchemaRendererProvider` whose `apiFetch` is the real `createAuthenticatedFetch({ sameOriginOnly: true })`, with the html element's `lang` set to `zh-CN` and a stub server that localizes `/meta/object` by the request's `Accept-Language`. - Leg 1: the status and priority comboboxes read the zh option labels (`评审中`, `高`), and the schema read carried `zh-CN`. - Leg 2: an option with no zh translation (`urgent`) keeps its authored label `Urgent`. Red first, at `1768fd79` (pin only, no fix): 2 of 2 failed, `AssertionError: expected 'In Review' to contain '评审中'` and `expected 'Done' to contain '已完成'`. Green at `ff64ab6a` (fix): 2 of 2 passed. At head `f0294fa8` (after merging `origin/main`), under the shared verify lock (`VERDICT command-exit 0`): - closure build `pnpm --filter "@object-ui/console^..." build`: ok; - every console test file that imports `FormPage` or `InternalFormRoute` (`FormPage.*`, `formPageRequiredMarker`, `internalFormShell`, `formViewFeaturesRootRefused`, `App.homePermissions`, `App.docsPortalLazy`, `App.uploadAltitude`, `createdRecordPath`, `submitRedirect`, `OAuthConsentPage.agent-scopes`): Test Files 42 passed (42), Tests 415 passed (415); - `pnpm --filter @object-ui/console type-check`: exit 0. The console tsconfig includes `src`, so the new test is type-checked. Live showcase reproduction: NOT MEASURED. Booting the showcase plus a console build and a browser on the shared box was not cheap under the verify-lock queue. The pin models the server's measured behaviour (the card's Control 2) with the real host fetch. ## Gates Local, at `f0294fa8`: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` reports 33 derived, 33 run, 0 NOT-MEASURED, and every one exited 0. That includes `node scripts/check-sdui-manifest.mjs` (intact, 106 components; no registry contract changed, so it was not regenerated) and `pnpm --dir ui exec vitest run scripts/` (178 passed, 2 skipped). CI runs the rest. ## Acceptance notes - The public route `/f/:slug` still reads over the bare `fetch`. For an anonymous visitor the UI language is the detected browser language, so nothing diverges. A signed-in visitor who chose another language would still see options in the browser's language there. Moving that route onto a session-carrying channel changes what the anonymous resolver and submit see, so it is left out of this change. - The card's two related notes (the route's English-only refusal and missing-recordId messages, and `?form=UNKNOWN_ID` opening a blank edit form) are not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e443a60 commit fd97351

3 files changed

Lines changed: 238 additions & 14 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): the standalone `/forms/:name` route shows select option labels in the language the user chose
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** A user whose chosen language differed from the browser's (a zh-CN user on an en-US browser) saw the form's field labels in their language and every select option in the browser's. The route read the form view, the object schema and the record with a bare `fetch`, so the server localized the schema, option labels included, by the browser's `Accept-Language`.
11+
- **Now.** The internal form route (`/forms/:name` and `/apps/:appName/forms/:name`) sends its API requests through the console's authenticated fetch, the same channel the in-app record form (`?form=ID`) reads the object schema over. That fetch sends the session's language as `Accept-Language`, so the server answers option labels in the language the user chose. An option the translation bundle does not cover keeps its authored label. No second translator is added.
12+
- **Unchanged.** The public anonymous form `/f/:slug` keeps its bare `fetch`.
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26452 — the standalone internal form route (`/forms/:name`)
5+
* renders select option labels in the session's language, by the SAME path
6+
* the in-app overlay form (`?form=ID`) takes.
7+
*
8+
* ## How the overlay gets translated options
9+
*
10+
* The overlay is `ModalForm` → `ObjectForm`, whose field builder
11+
* (`fromObjectSchema` in `@object-ui/plugin-form`) hands `field.options` to
12+
* the widget untouched. The translation happened one step earlier, on the
13+
* server: the object schema is read over the host's authenticated fetch
14+
* (`createAuthenticatedFetch`, published to the renderers as
15+
* `SchemaRendererContext.apiFetch`), which stamps `Accept-Language` with the
16+
* session's UI language (`<html lang>`), and the server localizes the schema —
17+
* option labels included — in that language.
18+
*
19+
* ## The defect
20+
*
21+
* `FormPage` read `/meta/object` with a bare `fetch`, so the request carried
22+
* whatever language the BROWSER sends, never the one the user chose. A zh-CN
23+
* user on an en-US browser got field labels in Chinese (those resolve
24+
* client-side through the bundle) over English options.
25+
*
26+
* ## What this file holds
27+
*
28+
* The host fetch below is the REAL `createAuthenticatedFetch` the console
29+
* mounts, and the stubbed server localizes by the `Accept-Language` it
30+
* receives — the documented behaviour of the live one, where a zh-CN browser
31+
* already got Chinese options on this route. The user's choice (`zh-CN`) is
32+
* set on `<html lang>`, as the i18n provider does; the "browser" sends no
33+
* header at all, which the server answers in English.
34+
*
35+
* Red before the fix: the route ignored the host fetch, so no request carried
36+
* `zh-CN` and every combobox read English.
37+
*/
38+
39+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
40+
import { render, screen, waitFor } from '@testing-library/react';
41+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
42+
import { createI18n } from '@object-ui/i18n';
43+
import { createAuthenticatedFetch } from '@object-ui/auth';
44+
import { SchemaRendererProvider } from '@object-ui/react';
45+
import { FormPage } from './FormPage';
46+
47+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
48+
49+
const VIEW_ENVELOPE = {
50+
name: 'showcase_task.form',
51+
object: 'showcase_task',
52+
viewKind: 'form',
53+
label: 'Task',
54+
config: {
55+
type: 'simple',
56+
sections: [{ label: 'Task', fields: ['title', 'status', 'priority'] }],
57+
},
58+
};
59+
60+
/** Authored (English) option labels, as `objectstack.config.ts` declares them. */
61+
const STATUS_OPTIONS = [
62+
{ value: 'backlog', label: 'Backlog' },
63+
{ value: 'in_review', label: 'In Review' },
64+
{ value: 'done', label: 'Done' },
65+
];
66+
const PRIORITY_OPTIONS = [
67+
{ value: 'high', label: 'High' },
68+
{ value: 'urgent', label: 'Urgent' },
69+
];
70+
71+
/**
72+
* The zh-CN bundle's option labels. `urgent` is deliberately absent: an option
73+
* with no translation keeps its authored text.
74+
*/
75+
const ZH_OPTIONS: Record<string, Record<string, string>> = {
76+
status: { backlog: '待规划', in_review: '评审中', done: '已完成' },
77+
priority: { high: '高' },
78+
};
79+
80+
/** The server's `/meta/object` answer, localized by the request's language. */
81+
function objectSchemaFor(acceptLanguage: string | null) {
82+
const zh = acceptLanguage?.toLowerCase().startsWith('zh') ?? false;
83+
const localize = (field: string, options: Array<{ value: string; label: string }>) =>
84+
options.map((o) => ({ ...o, label: (zh && ZH_OPTIONS[field]?.[o.value]) || o.label }));
85+
return {
86+
name: 'showcase_task',
87+
label: zh ? '任务' : 'Task',
88+
fields: {
89+
title: { type: 'text', label: zh ? '标题' : 'Title' },
90+
status: { type: 'select', label: zh ? '状态' : 'Status', options: localize('status', STATUS_OPTIONS) },
91+
priority: { type: 'select', label: zh ? '优先级' : 'Priority', options: localize('priority', PRIORITY_OPTIONS) },
92+
},
93+
};
94+
}
95+
96+
let seenLanguages: Array<string | null> = [];
97+
98+
function json(body: unknown): Response {
99+
return new Response(JSON.stringify(body), {
100+
status: 200,
101+
headers: { 'Content-Type': 'application/json' },
102+
});
103+
}
104+
105+
/** The server. A request with no `Accept-Language` stands for the en-US browser. */
106+
function stubServer(record: Record<string, unknown>) {
107+
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
108+
const url = String(input instanceof Request ? input.url : input);
109+
if (url.includes('/meta/view/')) return json(VIEW_ENVELOPE);
110+
if (url.includes('/meta/object/')) {
111+
const lang = new Headers(init?.headers).get('Accept-Language');
112+
seenLanguages.push(lang);
113+
return json(objectSchemaFor(lang));
114+
}
115+
if (url.includes('/data/showcase_task/task-7')) {
116+
return json({ object: 'showcase_task', id: 'task-7', record: { id: 'task-7', ...record } });
117+
}
118+
throw new Error(`unstubbed fetch: ${url}`);
119+
});
120+
}
121+
122+
/** `/forms/:name` as the console mounts it: inside the shell's renderer provider. */
123+
function renderStandaloneRoute() {
124+
return render(
125+
<SchemaRendererProvider dataSource={null} apiFetch={createAuthenticatedFetch({ sameOriginOnly: true })}>
126+
<MemoryRouter
127+
initialEntries={['/forms/showcase_task.form?recordId=task-7&recordObject=showcase_task']}
128+
>
129+
<Routes>
130+
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
131+
</Routes>
132+
</MemoryRouter>
133+
</SchemaRendererProvider>,
134+
);
135+
}
136+
137+
let previousLang = '';
138+
139+
beforeEach(() => {
140+
seenLanguages = [];
141+
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
142+
// The user's chosen language, where the i18n provider publishes it.
143+
previousLang = document.documentElement.lang;
144+
document.documentElement.lang = 'zh-CN';
145+
});
146+
147+
afterEach(() => {
148+
document.documentElement.lang = previousLang;
149+
vi.unstubAllGlobals();
150+
});
151+
152+
describe('objectstack#26452 — /forms/:name option labels follow the chosen language', () => {
153+
it('reads the object schema in the session language and renders translated option labels', async () => {
154+
vi.stubGlobal('fetch', stubServer({ title: 'Ship it', status: 'in_review', priority: 'high' }));
155+
renderStandaloneRoute();
156+
157+
const status = await screen.findByRole('combobox', { name: /状态|Status/ });
158+
await waitFor(() => expect(status.textContent).toContain('评审中'));
159+
expect(screen.getByRole('combobox', { name: /优先级|Priority/ }).textContent).toContain('高');
160+
expect(status.textContent).not.toContain('In Review');
161+
// The schema read went out in the user's language, not the browser's.
162+
expect(seenLanguages).toEqual(['zh-CN']);
163+
});
164+
165+
it('an option the bundle does not translate keeps its authored label', async () => {
166+
vi.stubGlobal('fetch', stubServer({ title: 'Ship it', status: 'done', priority: 'urgent' }));
167+
renderStandaloneRoute();
168+
169+
const status = await screen.findByRole('combobox', { name: /状态|Status/ });
170+
await waitFor(() => expect(status.textContent).toContain('已完成'));
171+
expect(screen.getByRole('combobox', { name: /优先级|Priority/ }).textContent).toContain('Urgent');
172+
});
173+
});

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

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@
116116
* the host half (ids, label association, required, uploads).
117117
*/
118118

119-
import { Suspense, useEffect, useId, useMemo, useState, type FormEvent } from 'react';
119+
import { Suspense, useContext, useEffect, useId, useMemo, useState, type FormEvent } from 'react';
120120
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
121121
import { toast } from 'sonner';
122122
import {
@@ -148,7 +148,7 @@ import {
148148
mapFieldTypeToFormType,
149149
resolveFormWidgetType,
150150
} from '@object-ui/fields';
151-
import { usePredicateScope } from '@object-ui/react';
151+
import { SchemaRendererContext, usePredicateScope, type ApiFetch } from '@object-ui/react';
152152
import { pickLocalized, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n';
153153
import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from '@object-ui/app-shell';
154154
import type { ObjectFormSection } from '@object-ui/types';
@@ -1449,8 +1449,17 @@ export function omitServerOwnedBlanks(
14491449
}
14501450

14511451
/** Authed/anonymous fetch — credentials included so cookies (auth) flow. */
1452-
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
1453-
return fetch(`${API_BASE}${path}`, {
1452+
/**
1453+
* One request to the framework API, over `channel`.
1454+
*
1455+
* `channel` is the bare global `fetch` on the public route and the host's
1456+
* authenticated fetch on the internal one — see the `channel` binding in
1457+
* {@link FormPage} for why the two differ (objectstack#26452). It is resolved
1458+
* at call time, never captured at module load, so a test that stubs the
1459+
* global is the global this reads.
1460+
*/
1461+
async function apiFetch(path: string, init?: RequestInit, channel?: ApiFetch): Promise<Response> {
1462+
return (channel ?? fetch)(`${API_BASE}${path}`, {
14541463
credentials: 'include',
14551464
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
14561465
...init,
@@ -1629,8 +1638,9 @@ async function loadInternalForm(
16291638
name: string,
16301639
recordId?: string | null,
16311640
recordObject?: string | null,
1641+
channel?: ApiFetch,
16321642
): Promise<LoadedForm> {
1633-
const viewRes = await apiFetch(`/meta/view/${encodeURIComponent(name)}`);
1643+
const viewRes = await apiFetch(`/meta/view/${encodeURIComponent(name)}`, undefined, channel);
16341644
if (!viewRes.ok) {
16351645
throw new Error(`Form metadata not found: view/${name}`);
16361646
}
@@ -1658,7 +1668,7 @@ async function loadInternalForm(
16581668
}
16591669
let objectSchema: ObjectSchemaPayload | null = null;
16601670
try {
1661-
const objRes = await apiFetch(`/meta/object/${encodeURIComponent(objectName)}`);
1671+
const objRes = await apiFetch(`/meta/object/${encodeURIComponent(objectName)}`, undefined, channel);
16621672
if (objRes.ok) {
16631673
const objBody = await objRes.json();
16641674
const objItem = objBody?.item ?? objBody;
@@ -1691,6 +1701,8 @@ async function loadInternalForm(
16911701
if (recordId) {
16921702
const recRes = await apiFetch(
16931703
`/data/${encodeURIComponent(objectName)}/${encodeURIComponent(recordId)}`,
1704+
undefined,
1705+
channel,
16941706
);
16951707
if (!recRes.ok) {
16961708
const detail = await recRes.text().catch(() => '');
@@ -1725,7 +1737,8 @@ async function submitPublic(slug: string, data: Record<string, unknown>): Promis
17251737
}
17261738

17271739
/**
1728-
* Internal mode submit. Auth cookie carries identity.
1740+
* Internal mode submit, over `channel` — the host's authenticated fetch when
1741+
* the route is mounted inside the console shell (objectstack#26452).
17291742
*
17301743
* `POST /data/:object` to create; `PATCH /data/:object/:id` to update the
17311744
* record `?recordId=` named (objectui#4278). Before that param was read, this
@@ -1746,14 +1759,16 @@ async function submitInternal(
17461759
objectName: string,
17471760
data: Record<string, unknown>,
17481761
recordId?: string | null,
1762+
channel?: ApiFetch,
17491763
): Promise<unknown> {
17501764
const path = recordId
17511765
? `/data/${encodeURIComponent(objectName)}/${encodeURIComponent(recordId)}`
17521766
: `/data/${encodeURIComponent(objectName)}`;
1753-
const res = await apiFetch(path, {
1754-
method: recordId ? 'PATCH' : 'POST',
1755-
body: JSON.stringify(data),
1756-
});
1767+
const res = await apiFetch(
1768+
path,
1769+
{ method: recordId ? 'PATCH' : 'POST', body: JSON.stringify(data) },
1770+
channel,
1771+
);
17571772
if (!res.ok) {
17581773
const body = await res.text().catch(() => '');
17591774
throw new Error(
@@ -2146,6 +2161,30 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
21462161
// `language` is the visitor's, read by `buildSections` to collapse a field
21472162
// entry's inline locale map (objectui#13380).
21482163
const { t, language } = useObjectTranslation();
2164+
/**
2165+
* The fetch the INTERNAL route talks to the API over: the host's
2166+
* authenticated fetch, published by the console shell as
2167+
* `SchemaRendererContext.apiFetch` (objectstack#26452).
2168+
*
2169+
* It is the channel the in-app record form (`?form=ID`) reads the object
2170+
* schema over, and the channel decides the language of every option label
2171+
* on this page. The server localizes `/meta/object` — option labels
2172+
* included — by the request's `Accept-Language`, and this fetch stamps that
2173+
* header with the session's UI language (`createAuthenticatedFetch`). The
2174+
* bare `fetch` this route used sent the BROWSER's language instead, so a
2175+
* zh-CN user on an en-US browser read Chinese field labels (resolved
2176+
* client-side by `fieldLabel`) over English options. Taking the same channel
2177+
* IS the fix: ⛔ no option-label translator lives in this file, and the
2178+
* overlay's field builder (`fromObjectSchema` in `@object-ui/plugin-form`)
2179+
* runs none either.
2180+
*
2181+
* The public route keeps the bare `fetch`: it is the anonymous resolver's
2182+
* channel, and moving it onto a session-carrying one is not this change.
2183+
* With no provider above the route (a bare test harness), the internal
2184+
* route falls back to the bare `fetch` too.
2185+
*/
2186+
const hostApiFetch = useContext(SchemaRendererContext)?.apiFetch;
2187+
const channel = mode === 'internal' ? hostApiFetch : undefined;
21492188

21502189
const [loading, setLoading] = useState(true);
21512190
const [error, setError] = useState<string | null>(null);
@@ -2213,7 +2252,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
22132252
? Promise.reject(new Error(target.reason))
22142253
: mode === 'public'
22152254
? loadPublicForm(identifier)
2216-
: loadInternalForm(identifier, editingId, editingObject);
2255+
: loadInternalForm(identifier, editingId, editingObject, channel);
22172256
loader
22182257
.then((result) => {
22192258
if (cancelled) return;
@@ -2238,7 +2277,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
22382277
return () => { cancelled = true; };
22392278
// `target`/`editingId` are derived from `search`, already a dependency.
22402279
// eslint-disable-next-line react-hooks/exhaustive-deps
2241-
}, [mode, identifier, search]);
2280+
}, [mode, identifier, search, channel]);
22422281

22432282
const sections = useMemo<RenderableSection[]>(
22442283
() => (loaded ? buildSections(loaded.form, loaded.objectSchema, language) : []),
@@ -2347,7 +2386,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
23472386
const result =
23482387
mode === 'public'
23492388
? await submitPublic(identifier, payload)
2350-
: await submitInternal(loaded.object, payload, editingId);
2389+
: await submitInternal(loaded.object, payload, editingId, channel);
23512390
toast.success(t('form.submitted'), { id: outcomeToastId });
23522391
// Behaviour after submit
23532392
switch (behavior.kind) {

0 commit comments

Comments
 (0)