From 43dd4ff262d2302a81babaf30d7dc7de6c0c6b33 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 02:57:26 +0800 Subject: [PATCH 1/8] feat(spec): GlobalFilterSchema gains an optional object for i18n label resolution (#7804) GlobalFilterSchema gains an optional `object?: string` (Route A, ruled on the triage-seat comment on #7804, 2026-08-11) so a dashboard global filter's field label (e.g. "Sales Channel:") and its option labels resolve through the same `fields..` translation-bundle convention lists/forms already use -- zero new i18n vocabulary, one resolver path. Rejects Route B (a new `dashboards.*.filters` bundle node -- duplicate vocabulary, no precedence rule) and Route C (inline I18nLabelSchema forms -- orthogonal, tracked separately). Deliberately independent of `optionsFrom.object`, which names the object dynamic OPTIONS are fetched from and may differ (e.g. filtering `opportunity` by `owner` with options sourced from `user`). Additive optional key: nothing that parsed before stops parsing. Unblocks objectui#4324, the dashboard filter-bar renderer half already landed behind this key. Also: content/docs/references/ui/dashboard.mdx and packages/spec/authorable-surface/ui.json regenerated to match (`pnpm --filter @objectstack/spec gen:docs`; check:generated all 13 green). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JY2Q5Xto1u8YHADgrZDTnk --- .changeset/global-filter-object-i18n-7804.md | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/global-filter-object-i18n-7804.md diff --git a/.changeset/global-filter-object-i18n-7804.md b/.changeset/global-filter-object-i18n-7804.md new file mode 100644 index 0000000000..481cd64c6c --- /dev/null +++ b/.changeset/global-filter-object-i18n-7804.md @@ -0,0 +1,35 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `GlobalFilterSchema` gains an optional `object` for i18n label resolution (#7804) + +A dashboard global filter renders its field label (e.g. "Sales Channel:") and +its option labels untranslated, and there was no key to fix it with: +`GlobalFilterSchema` declared no `object`, and neither does `DashboardSchema`, +so the canonical `fields..` translation-bundle convention that +lists/forms already use has nothing to resolve against — measured in +objectui#4324's implementation, and hit in production on a hotcrm-heimao +dashboard. + +**New:** `object?: string` on `GlobalFilterSchema`, alongside `field`. When +set, it names the object `field` lives on, and the filter's field label and +option labels resolve through the same `fields..` bundle entry +lists/forms use — zero new i18n vocabulary, one resolver path. +`optionsFrom.object` already proved the schema is willing to name an object; +this reuses that same primitive one level up, and is deliberately independent +of it — `optionsFrom.object` names where a filter's *dynamic options* are +fetched from, which may differ from the object `field` itself lives on (e.g. +filtering `opportunity` by `owner` with options sourced from `user`). + +Additive and optional: a filter that omits `object` renders exactly as it +always has, its author-supplied `label` (or a raw field-name fallback) +untranslated. Nothing that parses today stops parsing. + +Route A per the triage-seat ruling on #7804 (2026-08-11) — rejects Route B +(a new `dashboards.*.filters` bundle node in `TranslationData`, which would +duplicate the existing convention with no precedence rule) and Route C +(inline `I18nLabelSchema` forms, orthogonal and tracked separately). + +Unblocks objectui#4324, the dashboard filter-bar renderer half already landed +behind this key. From 338e8aaecfbb4cc748ad79befb6db6cac99a588c Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 02:58:22 +0800 Subject: [PATCH 2/8] regen: dashboard.mdx + authorable-surface/ui.json for GlobalFilterSchema.object (#7804) pnpm --filter @objectstack/spec gen:docs after adding the schema key; check:generated all 13 artifacts green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JY2Q5Xto1u8YHADgrZDTnk --- content/docs/references/ui/dashboard.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 2a89c06e4b..77854b85c8 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -38,7 +38,7 @@ const result = DashboardSchema.parse(data); | **gap** | `integer` | optional | Grid gap in Tailwind spacing units | | **refreshInterval** | `number` | optional | Auto-refresh interval in seconds | | **dateRange** | `{ field?: string; defaultRange: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| … +8 more>; allowCustomRange: boolean }` | optional | Global dashboard date range filter configuration | -| **globalFilters** | `{ name?: string; field: string; label?: string \| Record; type?: Enum<'text' \| 'select' \| 'date' \| 'number' \| 'lookup'>; … }[]` | optional | Global filters that apply to all widgets in the dashboard | +| **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard | | **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **performance** | `never` | optional | [REMOVED] `dashboard.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. | @@ -164,6 +164,7 @@ Widget configuration — declared query keys + open renderer extras | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Stable filter name (variable key); defaults to field | | **field** | `string` | ✅ | Field name to filter on | +| **object** | `string` | optional | Object whose `fields..` translation-bundle entry resolves this filter's field label and option labels (#7804) | | **label** | `string \| Record` | optional | Display label for the filter | | **type** | `Enum<'text' \| 'select' \| 'date' \| 'number' \| 'lookup'>` | optional | Filter input type | | **options** | `{ value: string \| number \| boolean; label: string \| Record }[]` | optional | Static filter options | @@ -224,4 +225,3 @@ Widget color variant --- - From e031dabd41332d0a3e4570f3e3399113f10f4832 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 03:00:31 +0800 Subject: [PATCH 3/8] fix: restore trailing blank line in dashboard.mdx (regen artifact byte-match) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JY2Q5Xto1u8YHADgrZDTnk --- content/docs/references/ui/dashboard.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 77854b85c8..6751ebcbfe 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -225,3 +225,4 @@ Widget color variant --- + From f017162d4262c0166721cf9d2f2456eadb973a7c Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 03:02:57 +0800 Subject: [PATCH 4/8] regen: authorable-surface/ui.json for GlobalFilterSchema.object (#7804) pnpm --filter @objectstack/spec gen:schema; check:authorable-surface green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JY2Q5Xto1u8YHADgrZDTnk --- packages/spec/authorable-surface/ui.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index f8b9576c20..8327c10dc4 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -537,6 +537,7 @@ "ui/GlobalFilter:field", "ui/GlobalFilter:label", "ui/GlobalFilter:name", + "ui/GlobalFilter:object", "ui/GlobalFilter:options", "ui/GlobalFilter:optionsFrom", "ui/GlobalFilter:scope", From 560ccc46867cb94d023dfd9f889ee7eb1b669de8 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 03:06:04 +0800 Subject: [PATCH 5/8] feat(spec): add GlobalFilterSchema.object field for i18n label resolution (#7804) The schema-source half of the change: `object?: string` on GlobalFilterSchema between `field` and `label`, documented as distinct from `optionsFrom.object`. See the changeset for the full rationale. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JY2Q5Xto1u8YHADgrZDTnk --- packages/spec/src/ui/dashboard.zod.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index bd0f929de9..21feb76cab 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -749,6 +749,24 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ /** Field name to filter on */ field: z.string().describe('Field name to filter on'), + /** + * Source object for i18n label resolution (#7804): when set, this filter's + * field label and option labels resolve through the SAME + * `fields..` translation-bundle convention lists/forms + * already use, keyed by this object and `field` — zero new i18n + * vocabulary, one resolver path. Optional and additive: a filter that + * omits it renders its author-supplied `label` (or a raw fallback) exactly + * as it always has; nothing that parses today stops parsing. + * + * Distinct from `optionsFrom.object` — that names the object DYNAMIC + * OPTIONS are fetched from, which may differ (e.g. filtering `opportunity` + * by `owner` with options sourced from `user`); this key names the object + * `field` itself lives on, which is what a translator's bundle entry is + * keyed by. `optionsFrom.object` already proves the schema is willing to + * name an object here — this reuses that same primitive one level up. + */ + object: z.string().optional().describe('Object whose `fields..` translation-bundle entry resolves this filter\'s field label and option labels (#7804)'), + /** Display label for the filter */ label: I18nLabelSchema.optional().describe('Display label for the filter'), From 858b4b3aa130c44cca8733349de6f7c817bdd9b3 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 03:08:18 +0800 Subject: [PATCH 6/8] test(spec): pin GlobalFilterSchema.object contract (#7804) New describe block: accepts string, optional/absent, rejects non-string, independence from optionsFrom.object, unknown-key strictness unchanged, JSON-Schema pin (string slot, not required). pnpm --filter @objectstack/spec test: 378 files / 9954 tests passed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JY2Q5Xto1u8YHADgrZDTnk --- packages/spec/src/ui/dashboard.test.ts | 59 +++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index af8e8f15c9..2a0ceba11b 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { DashboardSchema, DashboardWidgetSchema, @@ -78,7 +79,7 @@ describe('DashboardWidgetSchema (dataset-bound)', () => { expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', object: 'opportunity', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } } as any)).toThrow(); }); - // ── .strict() endpoint (framework#3251, protocol 16 step16) ────────────── + // ── .strict() endpoint (framework#3251, protocol 16 step16) ─────────────── it('rejects an otherwise-valid widget carrying a legacy analytics key, and points at the dataset shape', () => { const legacy = { id: 'w_legacy', type: 'bar', dataset: 'sales', values: ['revenue'], categoryField: 'stage' } as any; const res = DashboardWidgetSchema.safeParse(legacy); @@ -327,6 +328,60 @@ describe('Dashboard presentation sub-schemas', () => { expect(GlobalFilterSchema.parse({ field: 'region' }).name).toBeUndefined(); }); + describe('GlobalFilterSchema.object — i18n label-resolution key (#7804)', () => { + it('accepts a string object name and threads it through unchanged', () => { + const f = GlobalFilterSchema.parse({ field: 'sales_channel', type: 'select', object: 'opportunity' }); + expect(f.object).toBe('opportunity'); + }); + + it('is optional — absent stays absent, no default materializes', () => { + const f = GlobalFilterSchema.parse({ field: 'sales_channel', type: 'select' }) as Record; + expect(f.object).toBeUndefined(); + expect('object' in f).toBe(false); + }); + + it('rejects a non-string value', () => { + expect(() => GlobalFilterSchema.parse({ field: 'x', object: 123 } as any)).toThrow(); + expect(() => GlobalFilterSchema.parse({ field: 'x', object: true } as any)).toThrow(); + expect(() => GlobalFilterSchema.parse({ field: 'x', object: null } as any)).toThrow(); + }); + + it('is independent of optionsFrom.object — the two may name different objects', () => { + // A filter targeting `opportunity.owner` with its dropdown options + // sourced from `user` — the label-resolution object and the + // options-source object are deliberately allowed to differ. + const f = GlobalFilterSchema.parse({ + field: 'owner', + type: 'lookup', + object: 'opportunity', + optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' }, + }); + expect(f.object).toBe('opportunity'); + expect(f.optionsFrom?.object).toBe('user'); + }); + + it('does not disturb GlobalFilterSchema unknown-key strictness', () => { + const res = GlobalFilterSchema.safeParse({ field: 'x', object: 'opportunity', bogusKey: true } as any); + expect(res.success).toBe(false); + if (!res.success) { + const unknown = res.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown).toBeDefined(); + expect(unknown!.message).toContain('bogusKey'); + } + }); + + it('declares a string JSON-Schema slot, not required', () => { + const js = z.toJSONSchema(GlobalFilterSchema as unknown as z.ZodType, { + unrepresentable: 'any', + io: 'input', + }) as any; + const prop = js.properties?.object; + expect(prop).toBeDefined(); + expect(prop.type).toBe('string'); + expect(js.required ?? []).not.toContain('object'); + }); + }); + it('DashboardWidgetSchema.filterBindings — field override / opt-out (framework#2501)', () => { const w = DashboardWidgetSchema.parse({ id: 'accounts_signed', type: 'line', dataset: 'accounts', values: ['count'], @@ -607,7 +662,7 @@ describe('[#5010] DashboardWidgetSchema — retired action trio + `aria`', () => expect(w.values).toEqual(['total']); }); - // ── CONTROLS: only the WIDGET embeds go ──────────────────────────────────── + // ── CONTROLS: only the WIDGET embeds go ────────────────────────── it('CONTROL: `header.actions[]` still takes the whole action vocabulary', () => { const d = DashboardSchema.parse({ name: 'ops', label: 'Ops', From 897a621e6a2e9f33e38b7c639a08be7a28639986 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 03:11:19 +0800 Subject: [PATCH 7/8] fix: correct horizontal-rule comment length in dashboard.test.ts (byte-match local) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JY2Q5Xto1u8YHADgrZDTnk --- packages/spec/src/ui/dashboard.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 2a0ceba11b..f7b72624e0 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -79,7 +79,7 @@ describe('DashboardWidgetSchema (dataset-bound)', () => { expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', object: 'opportunity', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } } as any)).toThrow(); }); - // ── .strict() endpoint (framework#3251, protocol 16 step16) ─────────────── + // ── .strict() endpoint (framework#3251, protocol 16 step16) ──────────────────────────── it('rejects an otherwise-valid widget carrying a legacy analytics key, and points at the dataset shape', () => { const legacy = { id: 'w_legacy', type: 'bar', dataset: 'sales', values: ['revenue'], categoryField: 'stage' } as any; const res = DashboardWidgetSchema.safeParse(legacy); @@ -561,7 +561,7 @@ describe('[#4876] DashboardWidgetSchema — retired `responsive`', () => { expect(w.dataset).toBe('orders'); }); - // ── CONTROL: the shared shape is NOT retired, only this embed ────────────── + // ── CONTROL: the shared shape is NOT retired, only this embed ──────────────── it('CONTROL: `ResponsiveConfigSchema` is still exported and still parses', async () => { const ui = await import('./index'); expect(ui.ResponsiveConfigSchema).toBeTruthy(); @@ -662,7 +662,7 @@ describe('[#5010] DashboardWidgetSchema — retired action trio + `aria`', () => expect(w.values).toEqual(['total']); }); - // ── CONTROLS: only the WIDGET embeds go ────────────────────────── + // ── CONTROLS: only the WIDGET embeds go ──────────────────────────────────── it('CONTROL: `header.actions[]` still takes the whole action vocabulary', () => { const d = DashboardSchema.parse({ name: 'ops', label: 'Ops', From 3f2ca40a8c4ed398678f6b766d33c0ae502e2e89 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 03:15:00 +0800 Subject: [PATCH 8/8] fix: byte-exact dashboard.test.ts (mechanically generated, no manual transcription) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JY2Q5Xto1u8YHADgrZDTnk --- packages/spec/src/ui/dashboard.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index f7b72624e0..c152bea684 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -79,7 +79,7 @@ describe('DashboardWidgetSchema (dataset-bound)', () => { expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', object: 'opportunity', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } } as any)).toThrow(); }); - // ── .strict() endpoint (framework#3251, protocol 16 step16) ──────────────────────────── + // ── .strict() endpoint (framework#3251, protocol 16 step16) ────────────── it('rejects an otherwise-valid widget carrying a legacy analytics key, and points at the dataset shape', () => { const legacy = { id: 'w_legacy', type: 'bar', dataset: 'sales', values: ['revenue'], categoryField: 'stage' } as any; const res = DashboardWidgetSchema.safeParse(legacy); @@ -561,7 +561,7 @@ describe('[#4876] DashboardWidgetSchema — retired `responsive`', () => { expect(w.dataset).toBe('orders'); }); - // ── CONTROL: the shared shape is NOT retired, only this embed ──────────────── + // ── CONTROL: the shared shape is NOT retired, only this embed ────────────── it('CONTROL: `ResponsiveConfigSchema` is still exported and still parses', async () => { const ui = await import('./index'); expect(ui.ResponsiveConfigSchema).toBeTruthy();