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
19 changes: 19 additions & 0 deletions .changeset/29023-dashboard-global-filter-duplicate-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
---

fix(spec)!: a dashboard that declares two global filters under one name is refused at every door that parses it

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — `skills/objectstack-ui/rules/dashboards.md` teaches a filter's `name` as "the stable key `filterBindings` references", defaulting to `field`, and no file under `skills/**` shows two filters under one key; every taught use stays true.

**BREAKING** for a dashboard whose `globalFilters` repeats a filter key.

- **Before.** `DashboardSchema` typed `globalFilters` as a plain array, so two entries under one key parsed and the dashboard save door (`PUT /api/v1/meta/dashboard/X`) answered 200. A filter's key is its `name`, or its `field` when it has no `name`: the key the filter bar draws its control under and the key a widget's `filterBindings` names. The board drew one control, and the value typed into it filtered the field of the other definition, so a tile read a number the author never asked for.
- **Now.** `DashboardSchema.globalFilters` refuses the pair, so every door that parses a dashboard refuses it: `defineStack`, `os validate`, `os build` and the dashboard save door (422 `INVALID_METADATA`). One issue per repeated key, at the second declaration's `name` (or its `field`, when that entry has no `name`), naming the key and every index: `This dashboard declares the global filter 'X' twice in globalFilters: globalFilters[i] and globalFilters[j]. …`. The rule sits on the array, so a mirror that spreads `DashboardSchema.shape` keeps it.
- **Not changed.** Distinct names, two filters with distinct names over the same `field`, and the same name on two different dashboards.
- **Fix.** Rename all but one of the colliding entries (and every widget `filterBindings` key that means a renamed one), or delete the copy.
- **Stored rows.** A stored dashboard that repeats a key still loads, with `_diagnostics` reading `valid: false` and carrying the refusal, and its next save (draft or publish) is refused until it is fixed. Nothing rewrites it: which definition keeps the name only the author knows. The upgrade guide entry is `ui-dashboard-global-filter-duplicate-name-refused`.

<!-- adr-0087: registered ui-dashboard-global-filter-duplicate-name-refused -->
2 changes: 1 addition & 1 deletion content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const result = DashboardSchema.parse(data);
| **refreshIntervalSeconds** | `number` | optional | Auto-refresh interval in seconds |
| **refreshInterval** | `never` | optional | [REMOVED] `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **dateRange** | `{ field?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Global dashboard date range filter configuration |
| **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record<string, string>; … }[]` | 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. Each filter name (its field when it has no name) is declared once |
| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 248,
"droppedRefinementSites": 983,
"droppedRefinementSites": 988,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -81,6 +81,7 @@
"manifest.assignmentRules.element.entries.element",
"manifest.assignmentRules.element.entries.element.assignTo",
"manifest.connectors.element.out",
"manifest.dashboards.element.globalFilters",
"manifest.dashboards.element.globalFilters.element",
"manifest.dashboards.element.header.actions.element",
"manifest.dashboards.element.widgets.element",
Expand Down Expand Up @@ -227,6 +228,7 @@
"data.options[1].manifest.assignmentRules.element.entries.element",
"data.options[1].manifest.assignmentRules.element.entries.element.assignTo",
"data.options[1].manifest.connectors.element.out",
"data.options[1].manifest.dashboards.element.globalFilters",
"data.options[1].manifest.dashboards.element.globalFilters.element",
"data.options[1].manifest.dashboards.element.header.actions.element",
"data.options[1].manifest.dashboards.element.widgets.element",
Expand Down Expand Up @@ -365,6 +367,7 @@
"options[1].manifest.assignmentRules.element.entries.element",
"options[1].manifest.assignmentRules.element.entries.element.assignTo",
"options[1].manifest.connectors.element.out",
"options[1].manifest.dashboards.element.globalFilters",
"options[1].manifest.dashboards.element.globalFilters.element",
"options[1].manifest.dashboards.element.header.actions.element",
"options[1].manifest.dashboards.element.widgets.element",
Expand Down Expand Up @@ -443,6 +446,7 @@
"data.packages.element.options[1].manifest.assignmentRules.element.entries.element",
"data.packages.element.options[1].manifest.assignmentRules.element.entries.element.assignTo",
"data.packages.element.options[1].manifest.connectors.element.out",
"data.packages.element.options[1].manifest.dashboards.element.globalFilters",
"data.packages.element.options[1].manifest.dashboards.element.globalFilters.element",
"data.packages.element.options[1].manifest.dashboards.element.header.actions.element",
"data.packages.element.options[1].manifest.dashboards.element.widgets.element",
Expand Down Expand Up @@ -1588,6 +1592,7 @@
},
"ui/Dashboard": {
"sites": [
"globalFilters",
"globalFilters.element",
"header.actions.element",
"widgets.element",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #29023. An accept-set narrowing of the dashboard schema with no conversion:
// two globalFilters entries under one key (name, else field) are refused by the
// dashboard parse. Which definition keeps the name, and which widgets bind the
// renamed one, only the author knows. Same class as
// ui-dashboard-filter-binding-unknown-refused.
export const entry: SemanticMigration = {
id: 'ui-dashboard-global-filter-duplicate-name-refused',
surface: 'dashboard.globalFilters — two or more entries under the same filter key (the entry\'s '
+ '`name`, or its `field` when it has no `name`), e.g. globalFilters: [{ name: \'r\', field: '
+ '\'region\' }, { name: \'r\', field: \'status\' }]',
replacement: 'One entry per filter key on each dashboard. Rename all but one of the colliding entries '
+ '(and every widget `filterBindings` key that means a renamed one), or delete the copy. Two filters '
+ 'over the same `field` stay legal when their names differ.',
reason:
'A global filter\'s key is the one the filter bar draws its control under, the key a widget\'s '
+ '`filterBindings` names and the `page.<name>` variable its value is published as. The schema typed '
+ '`globalFilters` as a plain array, so two entries under one key parsed and the dashboard save door '
+ 'answered 200. The board then drew ONE control, and the value typed into it filtered the field of '
+ 'the other definition: the widget query carried the second entry\'s field and the tile read a number '
+ 'the author never asked for, with nothing on the page to say so. `DashboardSchema` now refuses the '
+ 'pair at every door that parses a dashboard: `defineStack`, `os validate`, `os build` and the '
+ 'dashboard save door (`422 INVALID_METADATA`), with one issue at the second declaration\'s `name` '
+ '(or `field`) naming the key and every index. Not convertible: which definition keeps the name, and '
+ 'whether the copy should go, is a fact only the author holds, and renaming on their behalf would '
+ 'silently unbind every widget that meant the renamed one.',
acceptanceCriteria:
'Each dashboard parses without a `custom` issue at `globalFilters[i].name` (or `.field`) of the form '
+ '"This dashboard declares the global filter X twice in globalFilters: globalFilters[i] and '
+ 'globalFilters[j]", and saves. After renaming, check that every widget `filterBindings` key names the '
+ 'filter it meant, and that `os validate` reports no `dashboard-filter-binding-unknown` finding. A '
+ 'stored `sys_metadata` dashboard that still repeats a key still loads, with `_diagnostics` reading '
+ '`valid: false` and carrying this refusal, and its next save (draft or publish) is refused; nothing '
+ 'rewrites it. Measured before the change: no authored dashboard in this repository\'s examples, '
+ 'platform objects, docs or skills repeats a filter key.',
};
148 changes: 148 additions & 0 deletions packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #29023 — a dashboard declares each global filter key (its `name`, else its
* `field`) once.
*
* Measured on `main` @ `12aad143` before the rule: `DashboardSchema` accepted
* `globalFilters: [{ name: 'r', field: 'region' }, { name: 'r', field: 'status' }]`,
* the save door answered 200, and the board drew ONE control whose value
* filtered `status`, the second definition's field. The refusal is located at
* the second declaration's key and names every index.
*/
import { describe, expect, it } from 'vitest';
import { DashboardSchema } from './dashboard.zod';
import { duplicateGlobalFilterNames } from './duplicate-global-filter-names';
import { globalFilterKey } from '../system/i18n-resolver';

const board = (globalFilters: unknown[]) => ({
name: 'qa_probe_dfn',
label: 'QA probe',
widgets: [],
globalFilters,
});

const customIssues = (input: unknown) => {
const result = DashboardSchema.safeParse(input);
if (result.success) return [];
return result.error.issues.filter((i) => i.code === 'custom');
};

describe('#29023 DashboardSchema refuses two globalFilters under one name', () => {
it('CONTROL: two filters with distinct names parse', () => {
const result = DashboardSchema.safeParse(board([
{ name: 'r', field: 'region', type: 'text' },
{ name: 's', field: 'status', type: 'text' },
]));
expect(result.success).toBe(true);
});

it('CONTROL: two distinct names over the SAME field parse (the key is the name, not the field)', () => {
const result = DashboardSchema.safeParse(board([
{ name: 'region_a', field: 'region', type: 'text' },
{ name: 'region_b', field: 'region', type: 'select', options: [{ value: 'north', label: 'North' }] },
]));
expect(result.success).toBe(true);
});

it('the card reproduction is refused at globalFilters[1].name, naming both indices, the name and the fix', () => {
const result = DashboardSchema.safeParse(board([
{ name: 'r', field: 'region', type: 'text' },
{ name: 'r', field: 'status', type: 'text' },
]));
expect(result.success).toBe(false);
const issues = result.error!.issues;
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ code: 'custom', path: ['globalFilters', 1, 'name'] });
expect(issues[0]!.message.startsWith(
"This dashboard declares the global filter 'r' twice in globalFilters: globalFilters[0] and globalFilters[1].",
)).toBe(true);
expect(issues[0]!.message).toMatch(/Rename all but one of them .* or delete the copy\.$/);
// Runtime strings carry no tracker number (check:doc-authoring).
expect(issues[0]!.message).not.toMatch(/#\d{3,}/);
});

it('two UNNAMED filters on one field collide on the field, located at globalFilters[1].field', () => {
const issues = customIssues(board([
{ field: 'region', type: 'text' },
{ field: 'region', type: 'select', options: [{ value: 'north', label: 'North' }] },
]));
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ path: ['globalFilters', 1, 'field'] });
expect(issues[0]!.message).toContain("the global filter 'region' twice");
expect(issues[0]!.message).toContain('A filter with no `name` is keyed by its `field`.');
});

it('a name colliding with another entry\'s field (no name) is the same key and is refused', () => {
const issues = customIssues(board([
{ field: 'region', type: 'text' },
{ name: 'region', field: 'sales_region', type: 'text' },
]));
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ path: ['globalFilters', 1, 'name'] });
});

it('an empty `name` keys by `field`, as `globalFilterKey` does', () => {
const issues = customIssues(board([
{ name: '', field: 'region', type: 'text' },
{ field: 'region', type: 'text' },
]));
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ path: ['globalFilters', 1, 'field'] });
});

it('three declarations of one name answer ONE issue at the second, listing every index', () => {
const issues = customIssues(board([
{ name: 'r', field: 'region' },
{ name: 'other', field: 'owner' },
{ name: 'r', field: 'status' },
{ name: 'r', field: 'stage' },
]));
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ path: ['globalFilters', 2, 'name'] });
expect(issues[0]!.message.startsWith(
"This dashboard declares the global filter 'r' 3 times in globalFilters: "
+ 'globalFilters[0], globalFilters[2] and globalFilters[3].',
)).toBe(true);
});

it('two repeated names answer one issue each', () => {
const issues = customIssues(board([
{ name: 'a', field: 'region' },
{ name: 'b', field: 'owner' },
{ name: 'a', field: 'status' },
{ name: 'b', field: 'stage' },
]));
expect(issues.map((i) => i.path)).toEqual([['globalFilters', 2, 'name'], ['globalFilters', 3, 'name']]);
});

it('the rule travels with `DashboardSchema.shape.globalFilters` (a mirror spreading the shape keeps it)', () => {
const slot = (DashboardSchema as unknown as { shape: Record<string, { safeParse(v: unknown): { success: boolean } }> })
.shape.globalFilters!;
expect(slot.safeParse([{ name: 'r', field: 'region' }, { name: 'r', field: 'status' }]).success).toBe(false);
expect(slot.safeParse([{ name: 'r', field: 'region' }, { name: 's', field: 'status' }]).success).toBe(true);
expect(slot.safeParse(undefined).success).toBe(true);
});
});

describe('#29023 duplicateGlobalFilterNames keys a filter the way globalFilterKey does', () => {
const filters = [
{ name: 'dept', field: 'department' },
{ field: 'created_at' },
{ name: '', field: 'owner_id' },
{ name: 'stage' , field: 'stage' },
{ label: 'Unkeyed' },
];

it('every keyed filter resolves to globalFilterKey(filter), and an unkeyed one is passed over', () => {
// Doubling each filter makes every keyed one a duplicate, so the list reads back the predicate's keys.
const keys = duplicateGlobalFilterNames([...filters, ...filters]).map((d) => d.name);
expect(keys).toEqual(filters.map((f) => globalFilterKey(f)).filter((k): k is string => k !== undefined));
});

it('a non-array, a non-object entry and an entry with neither key are left to the schema', () => {
expect(duplicateGlobalFilterNames(undefined)).toEqual([]);
expect(duplicateGlobalFilterNames({ name: 'r' })).toEqual([]);
expect(duplicateGlobalFilterNames([null, 'r', 7, { label: 'x' }, { label: 'y' }])).toEqual([]);
});
});
33 changes: 31 additions & 2 deletions packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { DATE_RANGE_PRESETS } from '../data/date-range-presets';
import { ChartTypeSchema, ChartConfigSchema, type ChartType } from './chart.zod';
import { ActionType } from './action.zod';
import { navigationTargetRefusal } from './navigation-target';
import { duplicateGlobalFilterNames } from './duplicate-global-filter-names';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
// `AriaPropsSchema` is no longer imported here: `widgets[].aria` was retired
// (#5010). The shape itself is NOT removed — it stays live on `page.aria` /
Expand Down Expand Up @@ -1668,6 +1669,25 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({
// and it must be this binding, not a copy.
.superRefine(checkGlobalFilterDateDefaultValue));

/**
* A dashboard's `globalFilters` declares each filter key once (#29023). One
* located issue per repeated key, at the SECOND declaration's `name` (or its
* `field`, when that entry is keyed by `field`), naming the key and every
* index. Before this, the dashboard save door saved two filters under one
* name: the board drew one control, and its value filtered the other
* definition's field, so a tile read a number the author never asked for.
*
* Attached to the array, not to the dashboard object, so the rule travels with
* `DashboardSchema.shape.globalFilters` into any mirror that spreads the
* shape. Predicate and message: `duplicate-global-filter-names.ts`
* ({@link duplicateGlobalFilterNames}). Not exported: the schema is the door.
*/
function refuseDuplicateGlobalFilterNames(globalFilters: readonly unknown[], ctx: z.RefinementCtx): void {
for (const duplicate of duplicateGlobalFilterNames(globalFilters)) {
ctx.addIssue({ code: 'custom', path: [duplicate.indexes[1]!, duplicate.secondKey], message: duplicate.message });
}
}

/**
* Dashboard Schema
* Represents a page containing multiple visualizations.
Expand Down Expand Up @@ -1768,8 +1788,17 @@ export const DashboardSchema = lazySchema(() => strictObject({
allowCustomRange: z.boolean().default(true).describe('Allow users to pick a custom date range'),
}).optional().describe('Global dashboard date range filter configuration'),

/** Global Filters */
globalFilters: z.array(GlobalFilterSchema).optional().describe('Global filters that apply to all widgets in the dashboard'),
/**
* Global Filters. Each filter's key (its `name`, else its `field`) is
* declared once per dashboard: the key is what the filter bar draws a
* control under and what a widget's `filterBindings` names, so two entries
* under one key are two definitions behind one control. Refused by
* {@link refuseDuplicateGlobalFilterNames} (#29023).
*/
globalFilters: z.array(GlobalFilterSchema)
.superRefine(refuseDuplicateGlobalFilterNames)
.optional()
.describe('Global filters that apply to all widgets in the dashboard. Each filter name (its field when it has no name) is declared once'),

/**
* [#22611] The audience gate — the platform's one capability key, with the
Expand Down
Loading
Loading