diff --git a/.changeset/29023-dashboard-global-filter-duplicate-name.md b/.changeset/29023-dashboard-global-filter-duplicate-name.md new file mode 100644 index 00000000000..4c0bdfd16c1 --- /dev/null +++ b/.changeset/29023-dashboard-global-filter-duplicate-name.md @@ -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`. + + diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 596513583d6..0b710f2a861 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -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; … }[]` | 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. 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). | diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index a91dce7c42b..8c18e5acf05 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 248, - "droppedRefinementSites": 983, + "droppedRefinementSites": 988, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -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", @@ -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", @@ -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", @@ -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", @@ -1588,6 +1592,7 @@ }, "ui/Dashboard": { "sites": [ + "globalFilters", "globalFilters.element", "header.actions.element", "widgets.element", diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts new file mode 100644 index 00000000000..def130204a5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts @@ -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.` 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.', +}; diff --git a/packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts b/packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts new file mode 100644 index 00000000000..4aecb63d088 --- /dev/null +++ b/packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts @@ -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 }) + .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([]); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 73f489dcea7..9d78c64d54f 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -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` / @@ -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. @@ -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 diff --git a/packages/spec/src/ui/duplicate-global-filter-names.ts b/packages/spec/src/ui/duplicate-global-filter-names.ts new file mode 100644 index 00000000000..8ccea35edf1 --- /dev/null +++ b/packages/spec/src/ui/duplicate-global-filter-names.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE predicate behind "a dashboard declares each global filter name once". + * + * A global filter's key is its `name`, or its `field` when it has no `name` + * (`GlobalFilterSchema.name` is declared as defaulting to `field`; the same + * derivation as `globalFilterKey` in `system/i18n-resolver.ts`, which this + * module cannot import without a cycle through `ui/component.zod.ts`). That key + * is the one the filter bar draws a control under, the one a widget's + * `filterBindings` names, and the `page.` variable the value is + * published as. Two entries under one key are two definitions behind one + * control: the board draws a single control, and the value typed into it + * filters the field of whichever definition the renderer kept, so a tile + * counts rows the author never asked for and nothing says so. + * + * Read by `DashboardSchema`'s `globalFilters` refinement (`dashboard.zod.ts`), + * so every door that parses a dashboard refuses the pair: `defineStack`, + * `os validate`, `os build` and the dashboard save door. + * + * Scope is ONE dashboard's own `globalFilters` array. The reserved name + * `dateRange` (the built-in date range) is a different question and is not + * judged here. + */ + +/** One filter key that a dashboard's `globalFilters` array declares more than once. */ +export interface DuplicateGlobalFilterName { + /** The repeated key (`name`, else `field`). */ + readonly name: string; + /** Every index of `globalFilters` that declares it, ascending. */ + readonly indexes: readonly number[]; + /** + * Which key carries the name on the SECOND declaration: `name` when that + * entry authors one, `field` when the key falls back to it. The refusal is + * located there. + */ + readonly secondKey: 'name' | 'field'; + /** The refusal's message, naming the key, every index and the fix. */ + readonly message: string; +} + +/** The key a global filter is addressed by: its non-empty `name`, else its non-empty `field`. */ +function filterKeyOf(filter: unknown): { name: string; key: 'name' | 'field' } | undefined { + if (filter === null || typeof filter !== 'object') return undefined; + const { name, field } = filter as { name?: unknown; field?: unknown }; + if (typeof name === 'string' && name.length > 0) return { name, key: 'name' }; + if (typeof field === 'string' && field.length > 0) return { name: field, key: 'field' }; + return undefined; +} + +/** `globalFilters[0] and globalFilters[1]`, `globalFilters[0], globalFilters[2] and globalFilters[5]`. */ +function joinIndexes(indexes: readonly number[]): string { + const sites = indexes.map((i) => `globalFilters[${i}]`); + if (sites.length <= 1) return sites.join(''); + return `${sites.slice(0, -1).join(', ')} and ${sites[sites.length - 1]}`; +} + +/** + * Every filter key that `globalFilters` declares more than once, in the order + * each key first appears. An empty list means nothing to refuse. Read on a + * parsed dashboard or a stored body alike: a non-array, a non-object entry and + * an entry with neither a string `name` nor a string `field` are left to the + * schema's own issues. + */ +export function duplicateGlobalFilterNames(globalFilters: unknown): DuplicateGlobalFilterName[] { + if (!Array.isArray(globalFilters)) return []; + const sitesByName = new Map(); + globalFilters.forEach((filter, index) => { + const resolved = filterKeyOf(filter); + if (!resolved) return; + const list = sitesByName.get(resolved.name); + if (list) list.push({ index, key: resolved.key }); + else sitesByName.set(resolved.name, [{ index, key: resolved.key }]); + }); + const out: DuplicateGlobalFilterName[] = []; + for (const [name, sites] of sitesByName) { + if (sites.length < 2) continue; + const indexes = sites.map((s) => s.index); + const unnamed = sites.some((s) => s.key === 'field'); + out.push({ + name, + indexes, + secondKey: sites[1]!.key, + message: + `This dashboard declares the global filter '${name}' ` + + `${indexes.length === 2 ? 'twice' : `${indexes.length} times`} in globalFilters: ${joinIndexes(indexes)}. ` + + 'A filter\'s name is the key the filter bar draws its control under and the key widgets bind it by in ' + + '`filterBindings`, so the board would draw one control whose value filters the field of only one of these ' + + 'definitions. ' + + (unnamed ? 'A filter with no `name` is keyed by its `field`. ' : '') + + 'Rename all but one of them (and every widget `filterBindings` key that means a renamed one), or delete ' + + 'the copy.', + }); + } + return out; +}