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
35 changes: 35 additions & 0 deletions .changeset/global-filter-object-i18n-7804.md
Original file line numberDiff line numberDiff line change
@@ -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.<object>.<field>` 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.<object>.<field>` 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.
3 changes: 2 additions & 1 deletion content/docs/references/ui/dashboard.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string>; 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<string, string>; … }[]` | 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. |
Expand DownExpand Up@@ -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.<object>.<field>` translation-bundle entry resolves this filter's field label and option labels (#7804) |
| **label** | `string \| Record<string, string>` | 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<string, string> }[]` | optional | Static filter options |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
55 changes: 55 additions & 0 deletions packages/spec/src/ui/dashboard.test.ts
Original file line numberDiff line numberDiff line change
@@ -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,
Expand DownExpand Up@@ -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<string, unknown>;
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'],
Expand Down
18 changes: 18 additions & 0 deletions packages/spec/src/ui/dashboard.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<object>.<field>` 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.<object>.<field>` 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'),

Expand Down
Loading