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
31 changes: 31 additions & 0 deletions .changeset/25459-form-entry-pack-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/spec': minor
'@objectstack/cli': minor
'@objectstack/lint': minor
---

feat(spec,cli,lint): a form section entry's `label`, `placeholder` and `helpText` are translatable at `objects.<object>._sections.<section>.fields.<field>`

Clause-②: yes (widening)
Metadata-protocol: added

A form section entry (`{ field, label, placeholder, helpText }` in a form view's `sections[].fields`) can override the object field's copy for that form. Before this change a translation pack had no key for those three strings, so a form's entry overrides stayed in the authoring language for every reader, the anonymous `GET /forms/:slug` visitor included.

- **`@objectstack/spec`:** the `_sections.<section>` node of an object translation gains `fields`, keyed by the entry's `field`, each carrying `label`, `placeholder` and `helpText` (strict; `help`, `hint` and `tooltip` are refused with the rename to `helpText`). `translateView` overlays them onto a served form view's `config.sections[].fields[]` under the section rules: the key is the section's `name` and the entry's `field`, only copy the entry authors is translated, a missing key keeps the authored string, and a string edited on top of the packaged view keeps the edit (ADR-0029 D9.2a). An entry in a section with no `name`, and a bare field-name entry, have no key.
- **`@objectstack/cli`:** `os i18n extract` scaffolds the key for every entry string a form view's named section authors, and `os i18n check` / `os lint` coverage report a locale that leaves one untranslated, in the `section` bucket.
- **`@objectstack/lint`:** `translation-target-unknown` reports a `_sections.<section>.fields.<field>` key that no form view section of that name declares as an entry object.

Example (`zh-CN` bundle):

```ts
objects: {
crm_case: {
_sections: {
submitter: {
label: '关于您',
fields: { contact_name: { label: '您的姓名', placeholder: '张三', helpText: '与证件一致' } },
},
},
},
}
```
9 changes: 5 additions & 4 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Translation data for a single object
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_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 |
| **_sections** | `Record<string, { label?: string; description?: string; fields?: Record<string, object> }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |

Expand Down Expand Up @@ -136,6 +136,7 @@ Translation data for a single field
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated section label |
| **description** | `string` | optional | Translated section description |
| **fields** | `Record<string, { label?: string; placeholder?: string; helpText?: string }>` | optional | Form section entry translations keyed by the entry's `field` |

### Nested Shape: `ObjectTranslationData._tabs[string]`

Expand Down Expand Up @@ -194,7 +195,7 @@ Translation data for a single object
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_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 |
| **_sections** | `Record<string, { label?: string; description?: string; fields?: Record<string, object> }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |

Expand Down Expand Up @@ -397,7 +398,7 @@ Translation data for a single object
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_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 |
| **_sections** | `Record<string, { label?: string; description?: string; fields?: Record<string, object> }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |

Expand Down Expand Up @@ -560,7 +561,7 @@ Translation data for a single object
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_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 |
| **_sections** | `Record<string, { label?: string; description?: string; fields?: Record<string, object> }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `system/` | 377 |
| `system/` | 378 |
80 changes: 77 additions & 3 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
* objects.<name>.fields.<field>.placeholder
* objects.<name>.fields.<field>.options.<value>
* objects.<name>._sections.<section>.label
* objects.<name>._sections.<section>.fields.<field>.{label,placeholder,helpText}
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
Expand Down Expand Up @@ -953,12 +954,61 @@ function addSectionList(index: SectionIndex, sections: unknown, objectName: unkn
}
}

/**
* object name → section name → entry `field` → the override copy that entry
* authors, RAW — the `objects.<o>._sections.<s>.fields.<f>.*` keys.
*/
type SectionEntryIndex = Map<string, Map<string, Map<string, Partial<Record<SectionEntryAttr, AuthoredLabel>>>>>;

/** The override copy a form section entry carries (`FormFieldSchema`). */
type SectionEntryAttr = 'label' | 'placeholder' | 'helpText';
const SECTION_ENTRY_ATTRS: readonly SectionEntryAttr[] = ['label', 'placeholder', 'helpText'];

/**
* Read the override copy of a FORM VIEW's section entries into the index —
* the `label`, `placeholder` and `helpText` an entry object authors over the
* object field it renders, addressed under its section's `name` by its own
* `field`, which is where `translateView` reads them.
*
* Form views only: a `record:details` section and a `fieldGroups` group have
* no entry overrides the resolver reads. A nameless section has no address,
* and a bare field-name entry authors no copy, so neither contributes. One
* string, one key: the first authored value wins, as for a heading.
*/
function addSectionEntries(index: SectionEntryIndex, sections: unknown, objectName: unknown): void {
if (!Array.isArray(sections)) return;
if (typeof objectName !== 'string' || objectName.length === 0) return;
for (const section of sections) {
if (!section || typeof section !== 'object') continue;
const s = section as Record<string, unknown>;
if (typeof s.name !== 'string' || s.name.length === 0 || !Array.isArray(s.fields)) continue;
for (const raw of s.fields) {
if (!raw || typeof raw !== 'object') continue;
const entry = raw as Record<string, unknown>;
if (typeof entry.field !== 'string' || entry.field.length === 0) continue;
let byObject = index.get(objectName);
if (!byObject) index.set(objectName, (byObject = new Map()));
let bySection = byObject.get(s.name);
if (!bySection) byObject.set(s.name, (bySection = new Map()));
let copy = bySection.get(entry.field);
if (!copy) bySection.set(entry.field, (copy = {}));
for (const attr of SECTION_ENTRY_ATTRS) {
const authored = asAuthoredLabel(entry[attr]);
if (copy[attr] === undefined && authored !== undefined) copy[attr] = authored;
}
}
}
}

/**
* Emit `objects.<object>._sections.<section>.label` for every section the
* stack renders, from both authoring surfaces.
* stack renders, from both authoring surfaces, and
* `objects.<object>._sections.<section>.fields.<field>.{label,placeholder,helpText}`
* for the override copy a form view's section entries author.
*/
function walkObjectSections(config: any, out: ExpectedEntry[]): void {
const index: SectionIndex = new Map();
const entries: SectionEntryIndex = new Map();

// (a) `fieldGroups` × field `group` membership. `deriveFieldGroupLayout` is
// the shared derivation the renderers themselves consume, so "which
Expand Down Expand Up @@ -991,13 +1041,18 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void {
for (const view of views) {
const containerObject = viewObjectName(view);
addSectionList(index, view?.sections, containerObject);
addSectionEntries(entries, view?.sections, containerObject);
if (view?.form && typeof view.form === 'object') {
addSectionList(index, view.form.sections, viewObjectName(view.form) ?? containerObject);
const formObject = viewObjectName(view.form) ?? containerObject;
addSectionList(index, view.form.sections, formObject);
addSectionEntries(entries, view.form.sections, formObject);
}
if (view?.formViews && typeof view.formViews === 'object') {
for (const form of Object.values<any>(view.formViews)) {
if (!form || typeof form !== 'object') continue;
addSectionList(index, form.sections, viewObjectName(form) ?? containerObject);
const formObject = viewObjectName(form) ?? containerObject;
addSectionList(index, form.sections, formObject);
addSectionEntries(entries, form.sections, formObject);
}
}
}
Expand Down Expand Up @@ -1045,6 +1100,25 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void {
);
}
}

// Authored copy only (`pushEntry`): the resolver translates an entry string
// only where the entry authors one, so a key for a string nobody wrote is
// one nothing reads.
for (const [objectName, sections] of entries) {
for (const [sectionName, fields] of sections) {
for (const [fieldName, copy] of fields) {
for (const attr of SECTION_ENTRY_ATTRS) {
pushEntry(
out,
['objects', objectName, '_sections', sectionName, 'fields', fieldName, attr],
copy[attr],
'section',
{ objectName },
);
}
}
}
}
}

// ─── Object filter-preset tabs (`objects.<o>._tabs.<tab>.label`) ───────
Expand Down
123 changes: 123 additions & 0 deletions packages/cli/test/i18n-form-section-entry-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// A form section entry's override copy — the `label`, `placeholder` and
// `helpText` an entry object authors over the object field it renders — is
// translated through `objects.<object>._sections.<section>.fields.<field>`
// (`translateView` overlays it onto the served section's entries). The walker
// emits that slot for every authored string of a form view's named sections,
// so `os i18n extract` scaffolds it and the coverage gate reports a locale
// that leaves one 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 entryKeys = (config: any) =>
collectExpectedEntries(config)
.map((e) => e.path.join('.'))
.filter((p) => p.includes('._sections.') && p.includes('.fields.'));

const caseForm = (sections: unknown[]) => ({
type: 'simple',
data: { provider: 'object', object: 'crm_case' },
sections,
});

const config = (translations: unknown[] = []) => ({
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
objects: [{
name: 'crm_case',
label: 'Case',
fields: {
contact_name: { type: 'text', label: 'Contact name' },
contact_email: { type: 'email', label: 'Contact email' },
subject: { type: 'text', label: 'Subject' },
},
}],
views: [
{
list: { type: 'grid', name: 'all_cases', label: 'All Cases', data: { provider: 'object', object: 'crm_case' } },
formViews: {
support: caseForm([
{
name: 'submitter',
label: 'About you',
fields: [
{ field: 'contact_name', label: 'Your name', placeholder: 'Jane Doe', helpText: 'As on your ID' },
{ field: 'contact_email', label: 'Your email' },
'subject',
],
},
// No `name`: its entries have no address.
{ label: 'Details', fields: [{ field: 'subject', label: 'What happened' }] },
]),
},
},
],
pages: [{
name: 'case_record',
type: 'record',
object: 'crm_case',
regions: [{
name: 'main',
components: [{
type: 'record:details',
properties: { sections: [{ name: 'page_only', label: 'Page', fields: [{ field: 'subject', label: 'Page label' }] }] },
}],
}],
}],
translations,
});

describe('i18n walker — a form section entry\'s copy, `_sections.<section>.fields.<field>`', () => {
it('emits one slot per AUTHORED entry string of a named form-view section', () => {
expect(entryKeys(config()).sort()).toEqual([
'objects.crm_case._sections.submitter.fields.contact_email.label',
'objects.crm_case._sections.submitter.fields.contact_name.helpText',
'objects.crm_case._sections.submitter.fields.contact_name.label',
'objects.crm_case._sections.submitter.fields.contact_name.placeholder',
]);
});

it('seeds each slot with the authored string, under the `section` bucket', () => {
const at = (p: string) => collectExpectedEntries(config()).find((e) => e.path.join('.') === p);
expect(at('objects.crm_case._sections.submitter.fields.contact_name.placeholder'))
.toMatchObject({ sourceValue: 'Jane Doe', inline: 'Jane Doe', source: 'section', objectName: 'crm_case' });
});

it('`os i18n extract` writes a skeleton the translation schema accepts', () => {
const node: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.objects?.crm_case;
expect(node?._sections?.submitter?.fields?.contact_name).toEqual({
label: 'Your name', placeholder: 'Jane Doe', helpText: 'As on your ID',
});
const parsed = ObjectTranslationDataSchema.safeParse(node);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});

it('the coverage gate reports an authored entry string a supported locale leaves untranslated', () => {
const missing = computeI18nCoverage(config([{ 'zh-CN': { objects: { crm_case: { label: '工单' } } } }]))
.issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.fields.') && i.key.includes('._sections.'))
.map((i) => i.key)
.sort();
expect(missing).toEqual(entryKeys(config()).sort());
const covered = computeI18nCoverage(config([{
'zh-CN': {
objects: {
crm_case: {
label: '工单',
_sections: {
submitter: {
fields: {
contact_name: { label: '您的姓名', placeholder: '张三', helpText: '与证件一致' },
contact_email: { label: '您的邮箱' },
},
},
},
},
},
},
}])).issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.fields.') && i.key.includes('._sections.'));
expect(covered).toEqual([]);
});
});
36 changes: 36 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,42 @@ describe('validateTranslationReferences — orphan keys', () => {
expect(findings).toEqual([]);
});

it('accepts a form section entry\'s copy keyed by its `field`, and flags an entry no section of that name declares', () => {
const stack = (sections: unknown) => ({
...leadStack([
{
'zh-CN': {
objects: {
crm_lead: {
label: '线索',
_sections: {
contact: { label: '联系方式', fields: { name: { label: '您的姓名', placeholder: '张三', helpText: '全名' } } },
basics: { fields: { name: { label: 'x' } } },
},
},
},
},
},
]),
views: [{
name: 'lead_forms',
objectName: 'crm_lead',
formViews: { web: { type: 'simple', data: { provider: 'object', object: 'crm_lead' }, sections } },
}],
});
const findings = validateTranslationReferences(stack([
{ name: 'contact', label: 'Contact', fields: [{ field: 'name', label: 'Your name', placeholder: 'Jane', helpText: 'Full' }, 'status'] },
]));
// `contact.fields.name` resolves; `basics` is a `fieldGroups` group, which has no entries.
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.crm_lead._sections.basics.fields.name',
]);
expect(findings[0].severity).toBe('error');
// A bare field-name entry authors no copy, so its `field` addresses nothing.
const bare = validateTranslationReferences(stack([{ name: 'contact', label: 'Contact', fields: ['name'] }]));
expect(bare.map((f) => f.path)).toContain('translations[0]["zh-CN"].objects.crm_lead._sections.contact.fields.name');
});

it('does not flag implicit audit/system fields', () => {
const findings = validateTranslationReferences(
leadStack([
Expand Down
Loading
Loading