Skip to content

Commit ef0bea4

Browse files
committed
fix(rest): GET /forms/:slug localizes the form's section headings under Accept-Language
The public form read translated objectSchema's field labels and spread each section as authored, so an anonymous visitor in a non-English locale read source-language section titles beside translated field labels. The handler now makes one call into the spec's view translator (translateMetadataDocument('view', ...), which reads objects.OBJECT._sections.NAME) beside the existing object call, with the same bundle, locale options and the packaged view base, and serves the translated config through the unchanged field strip. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d4fb3c commit ef0bea4

3 files changed

Lines changed: 270 additions & 6 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): `GET /forms/:slug` serves a public form's section headings in the visitor's language, beside the field labels it already translated
6+
7+
Clause-②: no
8+
9+
An anonymous visitor of a published form cannot load the translation bundle in the browser, so the server is the only place its section titles can be translated. The route translated `objectSchema` (the field labels) under `Accept-Language` and returned each form section as authored, so a zh-CN visitor read English section titles over Chinese field labels. It now localizes each section's `label` and `description` through the same `objects.OBJECT._sections.NAME` keys the signed-in console reads, with the request's locale, the deployment's fallback chain and default locale, and the packaged view as the override base.
10+
11+
With `Accept-Language: zh-CN` over a form whose section `about` is labelled `Tell us about yourself`, and a zh-CN bundle carrying `objects.crm_lead._sections.about.label: 介绍一下你自己`:
12+
13+
- FROM: `form.sections[0].label` is `Tell us about yourself`, and `objectSchema.fields.first_name.label` is `名`.
14+
- TO: `form.sections[0].label` is `介绍一下你自己`; the field label is unchanged (`名`). A section `description` follows the same key's `description`.
15+
16+
What does not change: a section whose key the bundle lacks keeps its authored text; a section with no `name` is served as authored; a section that authors no heading is not given one; a heading edited on top of the packaged view keeps the edit. The response shape (`slug`, `object`, `label`, `form`, `objectSchema`), its top-level `label`, `Vary: Accept-Language`, the declared-field narrowing, the lookup / master-detail / user strip and `POST /forms/:slug/submit` are unchanged.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#23497] `GET /forms/:slug` localizes the served form's SECTION headings
4+
// under `Accept-Language`, beside the field labels it already localized.
5+
//
6+
// An anonymous visitor cannot load the translation bundle client-side (the
7+
// `/i18n` domain answers an anonymous caller 401, and the console's language
8+
// loader answers `{}` signed out), so the client-side
9+
// `objects.OBJECT._sections.NAME` lookup never fires for it. Before this, the
10+
// route translated `objectSchema` and spread each section as authored: a
11+
// zh-CN visitor read English section titles over Chinese field labels.
12+
//
13+
// The RULES live in `@objectstack/spec/system` (`translateView`, which the
14+
// route reaches through `translateMetadataDocument('view', …)`): the key is
15+
// the section's `name`, a missing key keeps the authored text, a nameless
16+
// section is returned as authored, and an explicit override beats the catalog
17+
// against the packaged view. What is pinned here is the ROUTE's half: that it
18+
// makes the call, with the request's locale and the packaged view base, and
19+
// serves its result through the existing lookup / master-detail / user strip.
20+
21+
import { describe, it, expect, vi } from 'vitest';
22+
import { RestServer } from './rest-server';
23+
24+
// [#10126] Pay the first transform of the dist-resolved workspace dep the
25+
// handler reaches through a dynamic `import()` at MODULE LOAD, not inside a
26+
// clocked `it()` body (`scripts/check-test-source-alias.mjs`).
27+
import '@objectstack/spec/system';
28+
29+
const ABOUT_EN = 'Tell us about yourself';
30+
const ABOUT_DESC_EN = 'We will get back to you within a day.';
31+
const ABOUT_ZH = '介绍一下你自己';
32+
const ABOUT_DESC_ZH = '我们会在一天内回复你。';
33+
34+
/**
35+
* The served form view, in the flattened registered shape the protocol
36+
* enumerates (`viewKind: 'form'`, the form under `config`). Three sections:
37+
*
38+
* - `about` — its label and description are in the zh-CN bundle; it also
39+
* declares a lookup field, which the anonymous route strips;
40+
* - `details` — named, but the bundle carries no key for it;
41+
* - a NAMELESS section labelled `More` — the bundle carries
42+
* `_sections.more.label`, so a route that slugged a label into a key would
43+
* translate it.
44+
*/
45+
function formView(aboutLabel = ABOUT_EN) {
46+
return {
47+
name: 'ticket_form',
48+
object: 'ticket',
49+
viewKind: 'form',
50+
label: 'Contact us',
51+
config: {
52+
data: { object: 'ticket' },
53+
sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/test' },
54+
sections: [
55+
{ name: 'about', label: aboutLabel, description: ABOUT_DESC_EN, fields: ['subject', 'contact_id'] },
56+
{ name: 'details', label: 'Details', description: 'Anything else we should know.', fields: ['email'] },
57+
{ label: 'More', fields: ['status'] },
58+
],
59+
},
60+
};
61+
}
62+
63+
const ticketObject = {
64+
name: 'ticket',
65+
label: 'Ticket',
66+
fields: {
67+
subject: { type: 'text', label: 'Subject' },
68+
email: { type: 'text', label: 'Email' },
69+
status: { type: 'select', label: 'Status' },
70+
contact_id: { type: 'lookup', reference: 'contact', label: 'Contact' },
71+
},
72+
};
73+
74+
/**
75+
* The catalog. `en` carries the field labels only, so an `en` request has no
76+
* section key to read; `zh-CN` translates the fields and two `_sections` keys.
77+
*/
78+
const BUNDLE: Record<string, any> = {
79+
en: { objects: { ticket: { fields: { subject: { label: 'Subject' }, email: { label: 'Email' } } } } },
80+
'zh-CN': {
81+
objects: {
82+
ticket: {
83+
fields: { subject: { label: '主题' }, email: { label: '邮箱' }, status: { label: '状态' } },
84+
_sections: {
85+
about: { label: ABOUT_ZH, description: ABOUT_DESC_ZH },
86+
more: { label: '更多' },
87+
},
88+
},
89+
},
90+
},
91+
};
92+
93+
const i18nService = {
94+
getLocales: () => ['en', 'zh-CN'],
95+
getTranslations: (locale: string) => BUNDLE[locale],
96+
getDefaultLocale: () => 'en',
97+
};
98+
99+
function mockServer() {
100+
return {
101+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
102+
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
103+
};
104+
}
105+
106+
/**
107+
* The route over a protocol double. `packagedView`, when given, is what the
108+
* protocol's `getPackagedViewBase` answers — the packaged view before any
109+
* overlay, which the override rule compares the served strings against.
110+
*/
111+
function buildServer(view: ReturnType<typeof formView>, packagedView?: ReturnType<typeof formView>) {
112+
const getPackagedViewBase = vi.fn((name: string) => (name === view.name ? packagedView : undefined));
113+
const protocol: any = {
114+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
115+
getMetaTypes: vi.fn().mockResolvedValue([]),
116+
getMetaItems: vi.fn(async ({ type }: { type: string }) => {
117+
if (type === 'view') return [view];
118+
if (type === 'object') return [ticketObject];
119+
return [];
120+
}),
121+
...(packagedView ? { getPackagedViewBase } : {}),
122+
};
123+
const rest = new RestServer(
124+
mockServer() as any, protocol, { api: { requireAuth: false } } as any,
125+
undefined, undefined, undefined, undefined, undefined,
126+
undefined, undefined, undefined, undefined, undefined,
127+
// i18nServiceProvider — the 14th constructor argument.
128+
async () => i18nService as any,
129+
);
130+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
131+
rest.registerRoutes();
132+
const resolve = rest.getRoutes().find((r) => r.method === 'GET' && r.path.endsWith('/forms/:slug'))!;
133+
const get = async (locale: string) => {
134+
const res: any = { statusCode: 200, body: undefined, headers: {} as Record<string, string> };
135+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
136+
res.json = vi.fn((b: any) => { res.body = b; return res; });
137+
res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; });
138+
await resolve.handler({ params: { slug: 'test' }, query: {}, headers: { 'accept-language': locale } } as any, res);
139+
expect(res.statusCode).toBe(200);
140+
return res;
141+
};
142+
return { get, getPackagedViewBase };
143+
}
144+
145+
const section = (res: any, index: number) => res.body.form.sections[index];
146+
const renderedFields = (res: any) => res.body.form.sections.map((s: any) =>
147+
(s.fields ?? []).map((f: any) => (typeof f === 'string' ? f : f.field)));
148+
149+
describe('[#23497] GET /forms/:slug — section headings are localized under Accept-Language', () => {
150+
it('zh-CN: a named section with a `_sections` key is served its translated label and description', async () => {
151+
const res = await buildServer(formView()).get('zh-CN');
152+
expect(section(res, 0)).toMatchObject({ name: 'about', label: ABOUT_ZH, description: ABOUT_DESC_ZH });
153+
});
154+
155+
it('zh-CN: a named section whose key the bundle lacks keeps its authored label and description', async () => {
156+
const res = await buildServer(formView()).get('zh-CN');
157+
expect(section(res, 1)).toMatchObject({
158+
name: 'details', label: 'Details', description: 'Anything else we should know.',
159+
});
160+
});
161+
162+
it('zh-CN: a section with no `name` is served as authored, even though a key matching its label exists', async () => {
163+
const res = await buildServer(formView()).get('zh-CN');
164+
expect(section(res, 2).label).toBe('More');
165+
expect(section(res, 2).name).toBeUndefined();
166+
});
167+
168+
it('zh-CN: an override beats the catalog — an edited heading keeps the edit, the unedited description translates', async () => {
169+
// The served view carries a heading edited on top of the packaged one;
170+
// the packaged view (the protocol's `getPackagedViewBase`) still ships
171+
// the original. The catalog is the translation of the ORIGINAL, so it
172+
// must not overwrite the edit.
173+
const edited = 'Tell us about yourself (edited)';
174+
const { get, getPackagedViewBase } = buildServer(formView(edited), formView());
175+
const res = await get('zh-CN');
176+
expect(section(res, 0)).toMatchObject({ name: 'about', label: edited, description: ABOUT_DESC_ZH });
177+
// Looked up by the served item's registry name, as the `/meta/view` reads do.
178+
expect(getPackagedViewBase).toHaveBeenCalledWith('ticket_form');
179+
});
180+
181+
it('CONTROL — en: every section is served its authored text', async () => {
182+
const res = await buildServer(formView()).get('en');
183+
expect(res.body.form.sections.map((s: any) => [s.label, s.description])).toEqual([
184+
[ABOUT_EN, ABOUT_DESC_EN],
185+
['Details', 'Anything else we should know.'],
186+
['More', undefined],
187+
]);
188+
});
189+
190+
it('CONTROL: the field labels in `objectSchema` are translated as before', async () => {
191+
const { get } = buildServer(formView());
192+
const zh = await get('zh-CN');
193+
expect(zh.body.objectSchema.fields.subject.label).toBe('主题');
194+
expect(zh.body.objectSchema.fields.email.label).toBe('邮箱');
195+
const en = await get('en');
196+
expect(en.body.objectSchema.fields.subject.label).toBe('Subject');
197+
});
198+
199+
it('CONTROL: the lookup strip still removes a lookup field from a translated section', async () => {
200+
const res = await buildServer(formView()).get('zh-CN');
201+
expect(section(res, 0).label).toBe(ABOUT_ZH);
202+
expect(renderedFields(res)).toEqual([['subject'], ['email'], ['status']]);
203+
expect(res.body.objectSchema.fields.contact_id).toBeUndefined();
204+
});
205+
206+
it('CONTROL: the response shape, its top-level label and `Vary` are unchanged, and the stored view is not mutated', async () => {
207+
const view = formView();
208+
const res = await buildServer(view).get('zh-CN');
209+
expect(Object.keys(res.body).sort()).toEqual(['form', 'label', 'object', 'objectSchema', 'slug']);
210+
expect(res.body).toMatchObject({ slug: 'test', object: 'ticket', label: 'Contact us' });
211+
expect(res.headers.Vary).toBe('Accept-Language');
212+
expect(view.config.sections[0]).toMatchObject({ label: ABOUT_EN, description: ABOUT_DESC_EN });
213+
expect(view.config.sections[0].fields).toEqual(['subject', 'contact_id']);
214+
});
215+
});

‎packages/rest/src/rest-server.ts‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9434,6 +9434,10 @@ export class RestServer {
94349434
// now agree — but they agree by each enforcing the
94359435
// declaration itself, NOT by one deferring to the other.
94369436
let objectSchema: any = null;
9437+
// [#23497] The form served below: the matched form, with its
9438+
// sections' `label` / `description` localized beside the
9439+
// schema's field labels (see the translation block).
9440+
let servedForm: any = match.form;
94379441
try {
94389442
const p = await this.resolveProtocol(environmentId, req);
94399443
if (typeof (p as any).getMetaItems === 'function') {
@@ -9469,9 +9473,10 @@ export class RestServer {
94699473
}
94709474
objectSchema = { name: obj.name, label: obj.label, fields };
94719475
// Localize labels / help text / option labels so anonymous
9472-
// clients render in the visitor's preferred language. The
9473-
// form payload is otherwise un-translated (resolveFormBySlug
9474-
// returns the raw view spec), so we hydrate the schema here.
9476+
// clients render in the visitor's preferred language.
9477+
// `resolveFormBySlug` returns the raw view spec, so both
9478+
// halves of the payload are hydrated here: the schema, and
9479+
// the form's section headings ([#23497], below).
94759480
try {
94769481
const i18n = await this.resolveI18nService(environmentId, req);
94779482
const bundle = this.buildTranslationBundle(i18n);
@@ -9488,6 +9493,34 @@ export class RestServer {
94889493
...RestServer.translateOptionsFor(i18n, locale),
94899494
packagedBase: this.packagedObjectBase(p, 'object', objectSchema?.name),
94909495
});
9496+
// [#23497] The section headings. An anonymous
9497+
// visitor cannot load the bundle client-side (the
9498+
// `/i18n` domain answers it 401), so a section's
9499+
// `objects.OBJECT._sections.NAME` copy reaches it
9500+
// here or not at all. ONE call into the spec's view
9501+
// translator, which owns every rule: the key is the
9502+
// section's `name` (a nameless section is returned
9503+
// as authored), a missing key keeps the authored
9504+
// text, only authored copy is translated, and an
9505+
// explicit override beats the catalog against the
9506+
// packaged view, handed over exactly as the object
9507+
// call above hands its own base. ⛔ No walk of
9508+
// `_sections` and no locale chain here.
9509+
//
9510+
// The protocol enumerates view ITEMS only, so the
9511+
// matched form is the item's `config`, the address
9512+
// `translateView` translates sections at. It runs
9513+
// BEFORE the strip below and returns each section's
9514+
// `fields` untouched, so it cannot re-introduce a
9515+
// stripped field. The top-level `label` keeps its
9516+
// own reading: the translator falls back to the
9517+
// view's machine name where this route falls back
9518+
// to the form's label.
9519+
const view = translateMetadataDocument('view', match.view, bundle, {
9520+
...RestServer.translateOptionsFor(i18n, locale),
9521+
packagedBase: this.packagedObjectBase(p, 'view', match.view?.name),
9522+
});
9523+
if (match.form === match.view?.config) servedForm = view?.config;
94919524
}
94929525
} catch (e: any) {
94939526
logError('[REST] Public form schema translation failed:', e);
@@ -9506,7 +9539,7 @@ export class RestServer {
95069539
// unconditional: no declaration can put record search on the
95079540
// internet through a public form.
95089541
const safeForm = (() => {
9509-
if (!match.form || !Array.isArray(match.form.sections)) return match.form;
9542+
if (!servedForm || !Array.isArray(servedForm.sections)) return servedForm;
95109543
const allow = (name: string): boolean => {
95119544
// [#3022] A declared server-managed anchor (e.g. a
95129545
// FormView listing `owner_id`) is a spec mistake —
@@ -9520,15 +9553,15 @@ export class RestServer {
95209553
// expose unrestricted user search to the internet.
95219554
return t !== 'lookup' && t !== 'master_detail' && t !== 'user';
95229555
};
9523-
const sections = match.form.sections.map((sec: any) => {
9556+
const sections = servedForm.sections.map((sec: any) => {
95249557
const fields = (sec?.fields ?? []).filter((f: any) => {
95259558
const name = typeof f === 'string' ? f : f?.field;
95269559
if (!name) return false;
95279560
return allow(name);
95289561
});
95299562
return { ...sec, fields };
95309563
});
9531-
return { ...match.form, sections };
9564+
return { ...servedForm, sections };
95329565
})();
95339566
res.header('Vary', 'Accept-Language');
95349567
res.json({

0 commit comments

Comments
 (0)