diff --git a/.changeset/26452-form-options-locale.md b/.changeset/26452-form-options-locale.md new file mode 100644 index 00000000000..f3ac2fb8e86 --- /dev/null +++ b/.changeset/26452-form-options-locale.md @@ -0,0 +1,12 @@ +--- +'@object-ui/console': patch +--- + +fix(console): the standalone `/forms/:name` route shows select option labels in the language the user chose + +Clause-②: no +Metadata-protocol: none + +- **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`. +- **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. +- **Unchanged.** The public anonymous form `/f/:slug` keeps its bare `fetch`. diff --git a/ui/apps/console/src/components/FormPage.optionLabelLocale-26452.test.tsx b/ui/apps/console/src/components/FormPage.optionLabelLocale-26452.test.tsx new file mode 100644 index 00000000000..5fee21f1492 --- /dev/null +++ b/ui/apps/console/src/components/FormPage.optionLabelLocale-26452.test.tsx @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26452 — the standalone internal form route (`/forms/:name`) + * renders select option labels in the session's language, by the SAME path + * the in-app overlay form (`?form=ID`) takes. + * + * ## How the overlay gets translated options + * + * The overlay is `ModalForm` → `ObjectForm`, whose field builder + * (`fromObjectSchema` in `@object-ui/plugin-form`) hands `field.options` to + * the widget untouched. The translation happened one step earlier, on the + * server: the object schema is read over the host's authenticated fetch + * (`createAuthenticatedFetch`, published to the renderers as + * `SchemaRendererContext.apiFetch`), which stamps `Accept-Language` with the + * session's UI language (``), and the server localizes the schema — + * option labels included — in that language. + * + * ## The defect + * + * `FormPage` read `/meta/object` with a bare `fetch`, so the request carried + * whatever language the BROWSER sends, never the one the user chose. A zh-CN + * user on an en-US browser got field labels in Chinese (those resolve + * client-side through the bundle) over English options. + * + * ## What this file holds + * + * The host fetch below is the REAL `createAuthenticatedFetch` the console + * mounts, and the stubbed server localizes by the `Accept-Language` it + * receives — the documented behaviour of the live one, where a zh-CN browser + * already got Chinese options on this route. The user's choice (`zh-CN`) is + * set on ``, as the i18n provider does; the "browser" sends no + * header at all, which the server answers in English. + * + * Red before the fix: the route ignored the host fetch, so no request carried + * `zh-CN` and every combobox read English. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createI18n } from '@object-ui/i18n'; +import { createAuthenticatedFetch } from '@object-ui/auth'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { FormPage } from './FormPage'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const VIEW_ENVELOPE = { + name: 'showcase_task.form', + object: 'showcase_task', + viewKind: 'form', + label: 'Task', + config: { + type: 'simple', + sections: [{ label: 'Task', fields: ['title', 'status', 'priority'] }], + }, +}; + +/** Authored (English) option labels, as `objectstack.config.ts` declares them. */ +const STATUS_OPTIONS = [ + { value: 'backlog', label: 'Backlog' }, + { value: 'in_review', label: 'In Review' }, + { value: 'done', label: 'Done' }, +]; +const PRIORITY_OPTIONS = [ + { value: 'high', label: 'High' }, + { value: 'urgent', label: 'Urgent' }, +]; + +/** + * The zh-CN bundle's option labels. `urgent` is deliberately absent: an option + * with no translation keeps its authored text. + */ +const ZH_OPTIONS: Record> = { + status: { backlog: '待规划', in_review: '评审中', done: '已完成' }, + priority: { high: '高' }, +}; + +/** The server's `/meta/object` answer, localized by the request's language. */ +function objectSchemaFor(acceptLanguage: string | null) { + const zh = acceptLanguage?.toLowerCase().startsWith('zh') ?? false; + const localize = (field: string, options: Array<{ value: string; label: string }>) => + options.map((o) => ({ ...o, label: (zh && ZH_OPTIONS[field]?.[o.value]) || o.label })); + return { + name: 'showcase_task', + label: zh ? '任务' : 'Task', + fields: { + title: { type: 'text', label: zh ? '标题' : 'Title' }, + status: { type: 'select', label: zh ? '状态' : 'Status', options: localize('status', STATUS_OPTIONS) }, + priority: { type: 'select', label: zh ? '优先级' : 'Priority', options: localize('priority', PRIORITY_OPTIONS) }, + }, + }; +} + +let seenLanguages: Array = []; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** The server. A request with no `Accept-Language` stands for the en-US browser. */ +function stubServer(record: Record) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes('/meta/view/')) return json(VIEW_ENVELOPE); + if (url.includes('/meta/object/')) { + const lang = new Headers(init?.headers).get('Accept-Language'); + seenLanguages.push(lang); + return json(objectSchemaFor(lang)); + } + if (url.includes('/data/showcase_task/task-7')) { + return json({ object: 'showcase_task', id: 'task-7', record: { id: 'task-7', ...record } }); + } + throw new Error(`unstubbed fetch: ${url}`); + }); +} + +/** `/forms/:name` as the console mounts it: inside the shell's renderer provider. */ +function renderStandaloneRoute() { + return render( + + + + } /> + + + , + ); +} + +let previousLang = ''; + +beforeEach(() => { + seenLanguages = []; + createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + // The user's chosen language, where the i18n provider publishes it. + previousLang = document.documentElement.lang; + document.documentElement.lang = 'zh-CN'; +}); + +afterEach(() => { + document.documentElement.lang = previousLang; + vi.unstubAllGlobals(); +}); + +describe('objectstack#26452 — /forms/:name option labels follow the chosen language', () => { + it('reads the object schema in the session language and renders translated option labels', async () => { + vi.stubGlobal('fetch', stubServer({ title: 'Ship it', status: 'in_review', priority: 'high' })); + renderStandaloneRoute(); + + const status = await screen.findByRole('combobox', { name: /状态|Status/ }); + await waitFor(() => expect(status.textContent).toContain('评审中')); + expect(screen.getByRole('combobox', { name: /优先级|Priority/ }).textContent).toContain('高'); + expect(status.textContent).not.toContain('In Review'); + // The schema read went out in the user's language, not the browser's. + expect(seenLanguages).toEqual(['zh-CN']); + }); + + it('an option the bundle does not translate keeps its authored label', async () => { + vi.stubGlobal('fetch', stubServer({ title: 'Ship it', status: 'done', priority: 'urgent' })); + renderStandaloneRoute(); + + const status = await screen.findByRole('combobox', { name: /状态|Status/ }); + await waitFor(() => expect(status.textContent).toContain('已完成')); + expect(screen.getByRole('combobox', { name: /优先级|Priority/ }).textContent).toContain('Urgent'); + }); +}); diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index 5e20661b6a5..e8e5c0833b1 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -116,7 +116,7 @@ * the host half (ids, label association, required, uploads). */ -import { Suspense, useEffect, useId, useMemo, useState, type FormEvent } from 'react'; +import { Suspense, useContext, useEffect, useId, useMemo, useState, type FormEvent } from 'react'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { @@ -148,7 +148,7 @@ import { mapFieldTypeToFormType, resolveFormWidgetType, } from '@object-ui/fields'; -import { usePredicateScope } from '@object-ui/react'; +import { SchemaRendererContext, usePredicateScope, type ApiFetch } from '@object-ui/react'; import { pickLocalized, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n'; import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from '@object-ui/app-shell'; import type { ObjectFormSection } from '@object-ui/types'; @@ -1449,8 +1449,17 @@ export function omitServerOwnedBlanks( } /** Authed/anonymous fetch — credentials included so cookies (auth) flow. */ -async function apiFetch(path: string, init?: RequestInit): Promise { - return fetch(`${API_BASE}${path}`, { +/** + * One request to the framework API, over `channel`. + * + * `channel` is the bare global `fetch` on the public route and the host's + * authenticated fetch on the internal one — see the `channel` binding in + * {@link FormPage} for why the two differ (objectstack#26452). It is resolved + * at call time, never captured at module load, so a test that stubs the + * global is the global this reads. + */ +async function apiFetch(path: string, init?: RequestInit, channel?: ApiFetch): Promise { + return (channel ?? fetch)(`${API_BASE}${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, ...init, @@ -1629,8 +1638,9 @@ async function loadInternalForm( name: string, recordId?: string | null, recordObject?: string | null, + channel?: ApiFetch, ): Promise { - const viewRes = await apiFetch(`/meta/view/${encodeURIComponent(name)}`); + const viewRes = await apiFetch(`/meta/view/${encodeURIComponent(name)}`, undefined, channel); if (!viewRes.ok) { throw new Error(`Form metadata not found: view/${name}`); } @@ -1658,7 +1668,7 @@ async function loadInternalForm( } let objectSchema: ObjectSchemaPayload | null = null; try { - const objRes = await apiFetch(`/meta/object/${encodeURIComponent(objectName)}`); + const objRes = await apiFetch(`/meta/object/${encodeURIComponent(objectName)}`, undefined, channel); if (objRes.ok) { const objBody = await objRes.json(); const objItem = objBody?.item ?? objBody; @@ -1691,6 +1701,8 @@ async function loadInternalForm( if (recordId) { const recRes = await apiFetch( `/data/${encodeURIComponent(objectName)}/${encodeURIComponent(recordId)}`, + undefined, + channel, ); if (!recRes.ok) { const detail = await recRes.text().catch(() => ''); @@ -1725,7 +1737,8 @@ async function submitPublic(slug: string, data: Record): Promis } /** - * Internal mode submit. Auth cookie carries identity. + * Internal mode submit, over `channel` — the host's authenticated fetch when + * the route is mounted inside the console shell (objectstack#26452). * * `POST /data/:object` to create; `PATCH /data/:object/:id` to update the * record `?recordId=` named (objectui#4278). Before that param was read, this @@ -1746,14 +1759,16 @@ async function submitInternal( objectName: string, data: Record, recordId?: string | null, + channel?: ApiFetch, ): Promise { const path = recordId ? `/data/${encodeURIComponent(objectName)}/${encodeURIComponent(recordId)}` : `/data/${encodeURIComponent(objectName)}`; - const res = await apiFetch(path, { - method: recordId ? 'PATCH' : 'POST', - body: JSON.stringify(data), - }); + const res = await apiFetch( + path, + { method: recordId ? 'PATCH' : 'POST', body: JSON.stringify(data) }, + channel, + ); if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error( @@ -2146,6 +2161,30 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { // `language` is the visitor's, read by `buildSections` to collapse a field // entry's inline locale map (objectui#13380). const { t, language } = useObjectTranslation(); + /** + * The fetch the INTERNAL route talks to the API over: the host's + * authenticated fetch, published by the console shell as + * `SchemaRendererContext.apiFetch` (objectstack#26452). + * + * It is the channel the in-app record form (`?form=ID`) reads the object + * schema over, and the channel decides the language of every option label + * on this page. The server localizes `/meta/object` — option labels + * included — by the request's `Accept-Language`, and this fetch stamps that + * header with the session's UI language (`createAuthenticatedFetch`). The + * bare `fetch` this route used sent the BROWSER's language instead, so a + * zh-CN user on an en-US browser read Chinese field labels (resolved + * client-side by `fieldLabel`) over English options. Taking the same channel + * IS the fix: ⛔ no option-label translator lives in this file, and the + * overlay's field builder (`fromObjectSchema` in `@object-ui/plugin-form`) + * runs none either. + * + * The public route keeps the bare `fetch`: it is the anonymous resolver's + * channel, and moving it onto a session-carrying one is not this change. + * With no provider above the route (a bare test harness), the internal + * route falls back to the bare `fetch` too. + */ + const hostApiFetch = useContext(SchemaRendererContext)?.apiFetch; + const channel = mode === 'internal' ? hostApiFetch : undefined; const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -2213,7 +2252,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { ? Promise.reject(new Error(target.reason)) : mode === 'public' ? loadPublicForm(identifier) - : loadInternalForm(identifier, editingId, editingObject); + : loadInternalForm(identifier, editingId, editingObject, channel); loader .then((result) => { if (cancelled) return; @@ -2238,7 +2277,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { return () => { cancelled = true; }; // `target`/`editingId` are derived from `search`, already a dependency. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, identifier, search]); + }, [mode, identifier, search, channel]); const sections = useMemo( () => (loaded ? buildSections(loaded.form, loaded.objectSchema, language) : []), @@ -2347,7 +2386,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { const result = mode === 'public' ? await submitPublic(identifier, payload) - : await submitInternal(loaded.object, payload, editingId); + : await submitInternal(loaded.object, payload, editingId, channel); toast.success(t('form.submitted'), { id: outcomeToastId }); // Behaviour after submit switch (behavior.kind) {