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. diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 2a89c06e4b..6751ebcbfe 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 | 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", diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index af8e8f15c9..c152bea684 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, @@ -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'], 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'),