Skip to content
17 changes: 17 additions & 0 deletions .changeset/24969-form-view-title-translation-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
'@objectstack/rest': minor
'@objectstack/lint': minor
'@objectstack/cli': minor
---

feat(spec): a form view's `title` is translated through `objects.<object>._views.<view>.title`, and `GET /forms/:slug` serves it in the visitor's language

Clause-②: yes (widening)

- **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.<view>` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.
- **Now.** `objects.<object>._views.<view>.title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`.
- **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it.
- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.<key>` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key.
- **`os i18n extract`.** The CLI extractor emits `_views.<view>.title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated.
- **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title.
9 changes: 5 additions & 4 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Translation data for a single object
| **pluralLabel** | `string` | optional | Translated plural label |
| **description** | `string` | optional | Translated object description |
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
Expand All @@ -114,6 +114,7 @@ Translation data for a single field
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated view label |
| **description** | `string` | optional | Translated view description |
| **title** | `string` | optional | Translated form view title — overlays a form view's own `title` (`FormViewSchema.title`); only a form that authors one is translated |
| **emptyState** | `{ title?: string; message?: string }` | optional | Translated empty-state copy shown when the view has no rows |
| **bulkActions** | `Record<string, { label?: string; confirmText?: string; confirmLabel?: string; params?: Record<string, object> }>` | optional | Selection-bar translations keyed by bulk-action def name (`BulkActionDefSchema.name`) |

Expand Down Expand Up @@ -191,7 +192,7 @@ Translation data for a single object
| **pluralLabel** | `string` | optional | Translated plural label |
| **description** | `string` | optional | Translated object description |
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
Expand Down Expand Up @@ -394,7 +395,7 @@ Translation data for a single object
| **pluralLabel** | `string` | optional | Translated plural label |
| **description** | `string` | optional | Translated object description |
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
Expand Down Expand Up @@ -557,7 +558,7 @@ Translation data for a single object
| **pluralLabel** | `string` | optional | Translated plural label |
| **description** | `string` | optional | Translated object description |
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
Expand Down
6 changes: 6 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,8 +602,14 @@ export const ShowcaseTranslationBundle = {
// "Contacts", so the object's own `pluralLabel` above is the right
// word; the `全部…` spelling the task/project lists use belongs to
// labels that actually say "All …".
//
// `_views.create.title` — the sparse `formViews.create` form's own
// heading (`title: 'New contact'`), which `translateView` overlays
// onto the served form's `config.title`. 新建联系人 follows the
// 新建… shape a create heading takes.
_views: {
default: { label: '联系人' },
create: { title: '新建联系人' },
},
// `_sections` — the four groups of the default edit form in
// `ui/views/contact.view.ts`, plus the sparse `formViews.create`
Expand Down
49 changes: 46 additions & 3 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,39 @@ function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: str
pushBulkActionDefs(out, root, view, objectName);
}

/**
* Emit `_views.<view>.title` for every FORM view a view container serves —
* each `formViews.<key>` entry and the default `form` — the key
* `translateView` overlays a form's `title` (`FormViewSchema.title`) from.
*
* **The key is the composer's, asked, never re-derived.** A container form is
* served as the item `expandViewContainer` expands it to, and `translateView`
* keys its title by that item's registry name with the object prefix
* stripped: `formViews.<key>` is `<key>` (renamed on a collision, e.g.
* `default_2` beside a default list), the default `form` is its `name` or
* `form`. Reading the expanded items is the same discipline
* {@link defaultListViewKey} keeps for the default list, so the key written
* here is the key the runtime reads.
*
* **Title only.** The other `_views` tails stay unemitted for a form: a
* container's form entry carries no `label`, and its `description` is not
* overlaid by anything a form renders. A form that authors no title still
* records the key (`pushOptional`), so the coverage gate notices a bundle
* that translates one the source never wrote.
*/
function pushContainerFormTitles(out: ExpectedEntry[], containerObject: string, container: any): void {
if (!container?.form && !container?.formViews) return;
const prefix = `${containerObject}.`;
for (const item of expandViewContainer(containerObject, container)) {
if (item.viewKind !== 'form') continue;
const key = item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name;
const config = item.config as { title?: unknown } | undefined;
pushOptional(out, ['objects', containerObject, '_views', key, 'title'], config?.title, 'view', {
objectName: containerObject,
});
}
}

/**
* Emit `_views.<view>.bulkActions.<def>.*` for a list view's authored
* `bulkActionDefs[]` (#14253's resolver, #14376's walk).
Expand Down Expand Up @@ -1315,19 +1348,29 @@ export function collectExpectedEntries(
// `i18n/missing-view` had zero producers repo-wide while the ratchet
// reported green (#4123).
//
// `formViews` stays uncovered: form views have no counterpart in the
// `viewLabel` / `_views.*` resolver convention, so emitting keys for them
// would expect translations nothing reads.
// A container's form views (`formViews.*`, the default `form`) emit one
// `_views` tail, `title` — the form heading `translateView` overlays (see
// `pushContainerFormTitles`). Their `label` / `description` stay
// unemitted: a container form entry carries no label, and nothing a form
// renders reads a `_views` description.
const views: any[] = Array.isArray(config?.views) ? config.views : [];
for (const view of views) {
const containerObject = viewObjectName(view);
if (!containerObject) continue;

if (view.name) {
pushViewEntries(out, containerObject, view.name, view);
// A `viewKind: 'form'` item carries its form under `config`, title included.
if (view.viewKind === 'form' && view.config && typeof view.config === 'object') {
pushOptional(out, ['objects', containerObject, '_views', view.name, 'title'], view.config.title, 'view', {
objectName: containerObject,
});
}
continue;
}

pushContainerFormTitles(out, containerObject, view);

// The container's default list, keyed exactly as the runtime registry keys
// it — see `defaultListViewKey`. `undefined` means the container declares
// no default list; a key the container's own `listViews` also declares
Expand Down
103 changes: 103 additions & 0 deletions packages/cli/test/i18n-form-title-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// A form view's title (`FormViewSchema.title`) is translated through
// `objects.<object>._views.<view>.title` (`translateView` overlays it onto the
// served form item's `config.title`). The walker emits that slot for every
// form a view container serves, keyed by the composer's runtime identity, so
// `os i18n extract` scaffolds it and the coverage gate reports a locale that
// leaves an authored title untranslated.

import { describe, it, expect } from 'vitest';
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract';
import { computeI18nCoverage } from '../src/utils/i18n-coverage';
import { ObjectTranslationDataSchema } from '@objectstack/spec/system';

const titleKeys = (config: any) =>
collectExpectedEntries(config)
.map((e) => e.path.join('.'))
.filter((p) => p.endsWith('.title') && p.includes('._views.'));

const leadForm = (extra: Record<string, unknown> = {}) => ({
type: 'simple',
data: { provider: 'object', object: 'crm_lead' },
sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }],
...extra,
});

const config = (translations: unknown[] = []) => ({
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
objects: [{ name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }],
views: [
{
list: { type: 'grid', name: 'all_leads', label: 'All Leads', data: { provider: 'object', object: 'crm_lead' } },
form: leadForm({ title: 'New lead' }),
formViews: {
web_to_lead: leadForm({ title: 'Contact us' }),
quick: leadForm(),
},
},
{
name: 'referral',
object: 'crm_lead',
viewKind: 'form',
config: leadForm({ title: 'Refer a friend' }),
},
],
translations,
});

describe('i18n walker — a form view\'s title, `_views.<view>.title`', () => {
it('emits the slot for each form a container serves and for a form item, keyed by the runtime identity', () => {
expect(titleKeys(config()).sort()).toEqual([
'objects.crm_lead._views.form.title',
'objects.crm_lead._views.quick.title',
'objects.crm_lead._views.referral.title',
'objects.crm_lead._views.web_to_lead.title',
]);
});

it('seeds an authored title and leaves a form without one unseeded', () => {
const entries = collectExpectedEntries(config());
const at = (p: string) => entries.find((e) => e.path.join('.') === p);
expect(at('objects.crm_lead._views.web_to_lead.title')).toMatchObject({ sourceValue: 'Contact us', inline: 'Contact us' });
expect(at('objects.crm_lead._views.form.title')).toMatchObject({ sourceValue: 'New lead' });
expect(at('objects.crm_lead._views.quick.title')?.inline).toBeUndefined();
});

it('CONTROL: the list view emits no title slot, and the container forms emit no `label` slot', () => {
const paths = collectExpectedEntries(config()).map((e) => e.path.join('.'));
expect(paths).toContain('objects.crm_lead._views.all_leads.label');
expect(paths).not.toContain('objects.crm_lead._views.all_leads.title');
expect(paths).not.toContain('objects.crm_lead._views.web_to_lead.label');
});

it('`os i18n extract` writes a skeleton the translation schema accepts', () => {
const lead: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.objects?.crm_lead;
expect(lead?._views?.web_to_lead?.title).toBe('Contact us');
const parsed = ObjectTranslationDataSchema.safeParse(lead);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});

it('the coverage gate reports an authored title a supported locale leaves untranslated', () => {
const missing = computeI18nCoverage(config([{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }]))
.issues.filter((i) => i.locale === 'zh-CN' && i.key.endsWith('.title'))
.map((i) => i.key)
.sort();
expect(missing).toEqual([
'objects.crm_lead._views.form.title',
'objects.crm_lead._views.referral.title',
'objects.crm_lead._views.web_to_lead.title',
]);
const covered = computeI18nCoverage(config([{
'zh-CN': {
objects: {
crm_lead: {
label: '线索',
_views: { web_to_lead: { title: '联系我们' }, form: { title: '新建线索' }, referral: { title: '推荐朋友' } },
},
},
},
}])).issues.filter((i) => i.locale === 'zh-CN' && i.key.endsWith('.title'));
expect(covered).toEqual([]);
});
});
Loading
Loading