Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/26452-form-options-locale.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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 (`<html lang>`), 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 `<html lang>`, 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<string, Record<string, string>> = {
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<string | null> = [];

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<string, unknown>) {
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(
<SchemaRendererProvider dataSource={null} apiFetch={createAuthenticatedFetch({ sameOriginOnly: true })}>
<MemoryRouter
initialEntries={['/forms/showcase_task.form?recordId=task-7&recordObject=showcase_task']}
>
<Routes>
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
</Routes>
</MemoryRouter>
</SchemaRendererProvider>,
);
}

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');
});
});
67 changes: 53 additions & 14 deletions ui/apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -1449,8 +1449,17 @@ export function omitServerOwnedBlanks(
}

/** Authed/anonymous fetch — credentials included so cookies (auth) flow. */
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
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<Response> {
return (channel ?? fetch)(`${API_BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
...init,
Expand Down Expand Up @@ -1629,8 +1638,9 @@ async function loadInternalForm(
name: string,
recordId?: string | null,
recordObject?: string | null,
channel?: ApiFetch,
): Promise<LoadedForm> {
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}`);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => '');
Expand Down Expand Up @@ -1725,7 +1737,8 @@ async function submitPublic(slug: string, data: Record<string, unknown>): 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
Expand All @@ -1746,14 +1759,16 @@ async function submitInternal(
objectName: string,
data: Record<string, unknown>,
recordId?: string | null,
channel?: ApiFetch,
): Promise<unknown> {
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(
Expand Down Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -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;
Expand All @@ -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<RenderableSection[]>(
() => (loaded ? buildSections(loaded.form, loaded.objectSchema, language) : []),
Expand Down Expand Up @@ -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) {
Expand Down
Loading