Skip to content
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
'@objectstack/lint': patch
---

feat(spec,objectql): give three authored display surfaces a bundle key — bulk-action defs, custom validation messages, dataset labels (#14253)

Purely additive: three new translation groups, one new dispatch-table entry, one
new resolution step on the write path. No existing key changes shape, no
resolution order changes, and every surface still falls back to the authored
literal when the bundle carries nothing.

Each of the three carried **authored, user-facing display text that no key in
`TranslationDataSchema` could reach** — not a drifted key, no key. Each rendered
in the source locale inside an otherwise fully translated screen, which is the
bad failure mode: it reads as a styling quirk rather than as a missing
translation. Measured on a real `zh-CN` deployment.

**1. A list view's `bulkActionDefs[]`** —
`objects.<object>._views.<view>.bulkActions.<def_name>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}`,
resolved in `translateView` against `config.bulkActionDefs` (the one address a
served def has: both `ViewItemSchema` and `expandViewContainer` nest the whole
ListView under `config`). A def is part of the *view* document, not an action
document, so it never reached `translateAction`; the selection bar read
`已选择 1 项 · Complete · Skip · 清除`. The def's `label` deliberately stays a
plain `z.string()` on the authoring side — the bar renders it as a React child,
so an inline locale map would be a blank cell rather than a parse error — and
overlaying at the metadata boundary keeps the wire value a plain string. The
documented workaround (`bulkActions: ['<name>']`, promoting a declared action)
is not equivalent: it is N elevated per-record dispatches instead of one
data-plane `updateMany`.

**2. A custom validation rule's `message`** —
`objects.<object>._validations.<rule_name>.message`, spelled by the new
`objectValidationMessageKey` and read on the write path by the rule evaluator.
⚠️ **This adds a key shape, not a channel**: the lookup runs on the *existing*
`i18nService` hook that has localized built-in field-catalog messages and field
labels since #3957. Before it, a deployment got platform-generated refusals in
the caller's language and author-written refusals in the source language inside
one `400 VALIDATION_FAILED` envelope. All five authored-message emitters route
through one seat; a nested `conditional` branch is addressed by the branch's own
name; a platform-generated rejection (an unevaluable predicate) is deliberately
left alone. `messages['validation.field.*']` is unchanged and still overrides the
built-in catalog only.

**3. Dataset labels** — `datasets.<name>.{label,description,dimensions.<d>.label,measures.<m>.label}`
plus `translateDataset` in `METADATA_DOCUMENT_TRANSLATORS`. A dataset reads like
a back-office definition, but a measure label is drawn on the dashboard, under
every metric tile and on every chart axis. Registering the translator is the
whole wiring — `TRANSLATABLE_METADATA_TYPES` is derived from that table and
`@objectstack/rest` reads the derived set (#3786) — so `GET /api/v1/meta/datasets?locale=…`
localizes with nothing else to remember.

Key faces are measured against the authoring schemas rather than mirrored from
the report, so nothing here parses clean and translates nothing: a bulk param's
hint is `help` (not the action-param `helpText`), per-param `options` are refused
because `options[].value` is unconstrained and a value-keyed map cannot address
`true` and `"true"` apart, a def has no `successMessage`, and a dataset dimension
or measure has no `description` — the authoring schema says so itself. Every
exclusion carries `guidance` naming the right home.

Two tombstones stop asserting that no route exists: the retired
`validationMessages` and `errors` guidance now point at
`objects.<object>._validations.<rule>.message`. Retiring `validationMessages`
(17.0.0, #4667, ADR-0049) is **not** reversed — that group was keyed by rule name
at the bundle's top level, so it could not tell two objects' rules apart, and,
the reason it was retired, nothing read it. Its ADR-0087 conversion still strips
it from stored bundles. The replacement is object-scoped and ships its reader in
the same change.

Authors upgrading need do nothing; a bundle that writes none of the three new
groups behaves exactly as before.

<!-- adr-0087: not-required (unpublished) Purely additive: three new optional groups on `TranslationData` / `TranslationItem`, one new dispatch-table entry, and one new lookup on the write path. No authorable key is removed, renamed or re-shaped, so there is no tombstone, no stored shape to rewrite, and nothing mechanical for `objectstack migrate meta` to prescribe. The retired `validationMessages` conversion entry is untouched and still strips the key it always stripped — its guidance text now names a live replacement instead of asserting none exists, which changes what an author is told, not what a stored bundle becomes. -->
1 change: 1 addition & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1565,6 +1565,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down
36 changes: 33 additions & 3 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,11 @@ 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 }>` | optional | View translations keyed by view name |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, 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 |
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |

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

Expand All @@ -113,6 +114,7 @@ Translation data for a single field
| **label** | `string` | optional | Translated view label |
| **description** | `string` | optional | Translated view description |
| **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`) |

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

Expand All @@ -138,6 +140,12 @@ Translation data for a single field
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated tab label |

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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **message** | `string` | optional | Translated rejection message — overlays the rule's authored `message` on every rejected write |


---

Expand Down Expand Up @@ -225,6 +233,7 @@ Translation data for objects, apps, and UI messages
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand All @@ -241,10 +250,11 @@ 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 }>` | optional | View translations keyed by view name |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, 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 |
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |

### Nested Shape: `TranslationData.apps[string]`

Expand Down Expand Up @@ -274,6 +284,15 @@ Translation data for a single object
| **actions** | `Record<string, { label?: string }>` | optional | Header action label translations keyed by action url/key |
| **widgets** | `Record<string, { title?: string; description?: string; subCaption?: string }>` | optional | Widget translations keyed by widget id |

### Nested Shape: `TranslationData.datasets[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated dataset label |
| **description** | `string` | optional | Translated dataset description |
| **dimensions** | `Record<string, { label?: string }>` | optional | Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`) |
| **measures** | `Record<string, { label?: string }>` | optional | Measure translations keyed by measure name (`DatasetMeasureSchema.name`) |

### Nested Shape: `TranslationData.pages[string]`

| Property | Type | Required | Description |
Expand Down Expand Up @@ -364,6 +383,7 @@ One locale of translations — the `translation` metadata type
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand All @@ -390,10 +410,11 @@ 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 }>` | optional | View translations keyed by view name |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, 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 |
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |

### Nested Shape: `TranslationItem.apps[string]`

Expand Down Expand Up @@ -423,6 +444,15 @@ Translation data for a single object
| **actions** | `Record<string, { label?: string }>` | optional | Header action label translations keyed by action url/key |
| **widgets** | `Record<string, { title?: string; description?: string; subCaption?: string }>` | optional | Widget translations keyed by widget id |

### Nested Shape: `TranslationItem.datasets[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated dataset label |
| **description** | `string` | optional | Translated dataset description |
| **dimensions** | `Record<string, { label?: string }>` | optional | Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`) |
| **measures** | `Record<string, { label?: string }>` | optional | Measure translations keyed by measure name (`DatasetMeasureSchema.name`) |

### Nested Shape: `TranslationItem.pages[string]`

| Property | Type | Required | Description |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,4 @@ directory rather than per file.
| `kernel/` | 261 |
| `qa/` | 6 |
| `shared/` | 20 |
| `system/` | 364 |
| `system/` | 370 |
7 changes: 7 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,7 @@ describe('validateTranslationReferences — object-branch coverage vs the schema
fields: { name: { type: 'text', label: 'Name' } },
fieldGroups: [{ key: 'basics', label: 'Basics' }],
actions: [{ name: 'convert_lead', label: 'Convert' }],
validations: [{ name: 'lead_needs_name', type: 'script', message: 'Name is required' }],
},
],
views: [{ name: 'open_leads', objectName: 'crm_lead', label: 'Open Leads' }],
Expand Down Expand Up @@ -1503,6 +1504,11 @@ describe('validateTranslationReferences — object-branch coverage vs the schema
ghost: { _tabs: { ghost_tab: { label: 'Ghost' } } },
path: 'translations[0].en.objects.crm_lead._tabs.ghost_tab',
},
_validations: {
kind: 'reference-checked',
ghost: { _validations: { ghost_rule: { message: 'Ghost' } } },
path: 'translations[0].en.objects.crm_lead._validations.ghost_rule',
},
};

it('classifies every key `ObjectTranslationDataSchema` declares, and no key it does not', () => {
Expand Down Expand Up @@ -1537,6 +1543,7 @@ describe('validateTranslationReferences — object-branch coverage vs the schema
_sections: { basics: { label: 'Basics' } },
_actions: { convert_lead: { label: 'Convert' } },
_tabs: { urgent: { label: 'Urgent' } },
_validations: { lead_needs_name: { message: 'A name is required' } },
}),
);
expect(findings).toEqual([]);
Expand Down
Loading
Loading