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
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
75 changes: 75 additions & 0 deletions .changeset/i18n-three-authored-display-surfaces.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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