From 77a3722a6723f7b3068e37dba7e2d489b79614a3 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 21 Jul 2026 19:52:56 +0000 Subject: [PATCH 01/10] feat(#189): optional filter selection.mode schema, array-safe filter store, Array(T) serializer coverage Wave 1 of the multiselect track: DashboardFilterDefinitionV1 gains an optional selection.mode (single|multiple) override; the persisted dashboard-filter bag round-trips string[] values without stringification; param-serialize gains the issue's required Array(T) element coverage (Unicode, empty string, commas, Decimal, Enum labels, DateTime). Co-Authored-By: Claude Fable 5 --- schemas/dashboard-v1.schema.json | 15 +++- .../generated/library-v2.bundle.schema.json | 19 ++++- src/dashboard/model/dashboard-filter-store.ts | 22 +++++- src/generated/json-schema-validators.js | 79 ++++++++++++++----- src/generated/json-schema.types.ts | 6 ++ src/generated/json-schemas.js | 19 ++++- tests/unit/dashboard-filter-store.test.ts | 36 +++++++++ tests/unit/param-serialize.test.ts | 31 ++++++++ tests/unit/portable-bundle-codec.test.ts | 17 ++++ 9 files changed, 216 insertions(+), 28 deletions(-) diff --git a/schemas/dashboard-v1.schema.json b/schemas/dashboard-v1.schema.json index 67894f94..7716590f 100644 --- a/schemas/dashboard-v1.schema.json +++ b/schemas/dashboard-v1.schema.json @@ -169,10 +169,23 @@ "title": "Active by default", "description": "Whether the filter starts active.", "type": "boolean" + }, + "selection": { + "title": "Selection mode override", + "description": "Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here.", + "type": "object", + "properties": { + "mode": { + "title": "Selection mode", + "description": "Explicit override for the inferred selection mode: \"single\" forces one active value, \"multiple\" forces a searchable multiselect.", + "enum": ["single", "multiple"] + } + }, + "additionalProperties": false } }, "additionalProperties": false, - "x-altinity-order": ["id", "parameter", "label", "sourceQueryId", "targets", "defaultValue", "defaultActive"] + "x-altinity-order": ["id", "parameter", "label", "sourceQueryId", "targets", "defaultValue", "defaultActive", "selection"] }, "dashboardLayoutFallbackV1": { "title": "Layout fallback", diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index 35f0a982..9d665f8f 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -1600,6 +1600,22 @@ "title": "Active by default", "description": "Whether the filter starts active.", "type": "boolean" + }, + "selection": { + "title": "Selection mode override", + "description": "Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here.", + "type": "object", + "properties": { + "mode": { + "title": "Selection mode", + "description": "Explicit override for the inferred selection mode: \"single\" forces one active value, \"multiple\" forces a searchable multiselect.", + "enum": [ + "single", + "multiple" + ] + } + }, + "additionalProperties": false } }, "additionalProperties": false, @@ -1610,7 +1626,8 @@ "sourceQueryId", "targets", "defaultValue", - "defaultActive" + "defaultActive", + "selection" ] }, "dashboardLayoutFallbackV1": { diff --git a/src/dashboard/model/dashboard-filter-store.ts b/src/dashboard/model/dashboard-filter-store.ts index 6ba3705d..361f4130 100644 --- a/src/dashboard/model/dashboard-filter-store.ts +++ b/src/dashboard/model/dashboard-filter-store.ts @@ -14,9 +14,12 @@ // storage seam of its own and satisfies the `src/dashboard/model` boundary // rule (no `state.ts`/`core/storage.js` import here). -/** One filter's persisted runtime state. */ +/** One filter's persisted runtime state. `value` is a plain string for a + * single-selection filter, or a string array for a committed multiselect + * (#189) — arrays round-trip through localStorage as arrays rather than + * being joined/stringified. */ export interface DashboardFilterEntry { - value: string; + value: string | string[]; active: boolean; } @@ -29,9 +32,18 @@ export type AllDashboardFilters = Record; const isObject = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); -const coerceValue = (value: unknown): string => +const coerceScalar = (value: unknown): string => (typeof value === 'string' ? value : value == null ? '' : String(value)); +/** Persisted JSON is untrusted: a scalar coerces via the existing string + * rule; an array (a committed multiselect, #189) coerces to a NEW array + * containing only its string elements — non-string/nullish elements are + * dropped rather than stringified, so one junk element can't corrupt the + * rest of an otherwise-valid selection. An empty-string element is a valid + * string and is preserved. */ +const coerceValue = (value: unknown): string | string[] => + (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : coerceScalar(value)); + /** * Defensively parse an untrusted blob (whatever `loadJSON(KEYS.dashFilters, {})` * returned) into one dashboard's filter bag. Tolerates a non-object blob, a @@ -55,7 +67,9 @@ export function readDashboardFilterBag(all: unknown, dashboardId: string): Dashb * its output against later in-place mutation by either side). */ function cloneBag(bag: DashboardFilterBag): DashboardFilterBag { const out: DashboardFilterBag = {}; - for (const [filterId, entry] of Object.entries(bag)) out[filterId] = { value: entry.value, active: entry.active }; + for (const [filterId, entry] of Object.entries(bag)) { + out[filterId] = { value: Array.isArray(entry.value) ? [...entry.value] : entry.value, active: entry.active }; + } return out; } diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index 13126d43..1139faf8 100644 --- a/src/generated/json-schema-validators.js +++ b/src/generated/json-schema-validators.js @@ -4397,6 +4397,7 @@ function validate49(data, { instancePath = "", parentData, parentDataProperty, r } validate49.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; var validateDashboardV1 = validate52; +var schema69 = { "title": "Dashboard filter definition", "description": "One Dashboard filter: the targeted parameter name, an optional filter-role source query providing options, and optional explicit target tiles. Runtime filter values are never persisted here.", "type": "object", "required": ["id", "parameter"], "properties": { "id": { "title": "Filter identifier", "description": "Stable filter identity within this Dashboard.", "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" }, "parameter": { "title": "Parameter name", "description": "ClickHouse query parameter name this filter supplies. Target queries must declare the parameter with compatible types.", "type": "string", "minLength": 1, "maxLength": 256 }, "label": { "title": "Filter label", "description": "Optional user-visible filter label.", "type": "string", "maxLength": 512 }, "sourceQueryId": { "title": "Option source query", "description": "ID of a filter-role saved query whose result provides the option list. The source query never creates a tile.", "type": "string", "minLength": 1, "maxLength": 256 }, "targets": { "title": "Target tiles", "description": "Tile IDs this filter applies to. Absent means every compatible panel tile.", "type": "array", "maxItems": 100, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, "defaultValue": { "title": "Default value", "description": "Optional default parameter value; any JSON value." }, "defaultActive": { "title": "Active by default", "description": "Whether the filter starts active.", "type": "boolean" }, "selection": { "title": "Selection mode override", "description": "Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here.", "type": "object", "properties": { "mode": { "title": "Selection mode", "description": 'Explicit override for the inferred selection mode: "single" forces one active value, "multiple" forces a searchable multiselect.', "enum": ["single", "multiple"] } }, "additionalProperties": false } }, "additionalProperties": false, "x-altinity-order": ["id", "parameter", "label", "sourceQueryId", "targets", "defaultValue", "defaultActive", "selection"] }; function validate55(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; @@ -5401,7 +5402,7 @@ function validate52(data, { instancePath = "", parentData, parentDataProperty, r errors++; } for (const key1 in data7) { - if (!(key1 === "id" || key1 === "parameter" || key1 === "label" || key1 === "sourceQueryId" || key1 === "targets" || key1 === "defaultValue" || key1 === "defaultActive")) { + if (!(key1 === "id" || key1 === "parameter" || key1 === "label" || key1 === "sourceQueryId" || key1 === "targets" || key1 === "defaultValue" || key1 === "defaultActive" || key1 === "selection")) { const err23 = { instancePath: instancePath + "/filters/" + i0, schemaPath: "#/$defs/dashboardFilterDefinitionV1/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }; if (vErrors === null) { vErrors = [err23]; @@ -5623,61 +5624,97 @@ function validate52(data, { instancePath = "", parentData, parentDataProperty, r errors++; } } + if (data7.selection !== void 0) { + let data15 = data7.selection; + if (data15 && typeof data15 == "object" && !Array.isArray(data15)) { + for (const key2 in data15) { + if (!(key2 === "mode")) { + const err43 = { instancePath: instancePath + "/filters/" + i0 + "/selection", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/selection/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key2 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err43]; + } else { + vErrors.push(err43); + } + errors++; + } + } + if (data15.mode !== void 0) { + let data16 = data15.mode; + if (!(data16 === "single" || data16 === "multiple")) { + const err44 = { instancePath: instancePath + "/filters/" + i0 + "/selection/mode", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/selection/properties/mode/enum", keyword: "enum", params: { allowedValues: schema69.properties.selection.properties.mode.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err44]; + } else { + vErrors.push(err44); + } + errors++; + } + } + } else { + const err45 = { instancePath: instancePath + "/filters/" + i0 + "/selection", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/selection/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err45]; + } else { + vErrors.push(err45); + } + errors++; + } + } } else { - const err43 = { instancePath: instancePath + "/filters/" + i0, schemaPath: "#/$defs/dashboardFilterDefinitionV1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err46 = { instancePath: instancePath + "/filters/" + i0, schemaPath: "#/$defs/dashboardFilterDefinitionV1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err43]; + vErrors = [err46]; } else { - vErrors.push(err43); + vErrors.push(err46); } errors++; } } } else { - const err44 = { instancePath: instancePath + "/filters", schemaPath: "#/properties/filters/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + const err47 = { instancePath: instancePath + "/filters", schemaPath: "#/properties/filters/type", keyword: "type", params: { type: "array" }, message: "must be array" }; if (vErrors === null) { - vErrors = [err44]; + vErrors = [err47]; } else { - vErrors.push(err44); + vErrors.push(err47); } errors++; } } if (data.tiles !== void 0) { - let data15 = data.tiles; - if (Array.isArray(data15)) { - if (data15.length > 100) { - const err45 = { instancePath: instancePath + "/tiles", schemaPath: "#/properties/tiles/maxItems", keyword: "maxItems", params: { limit: 100 }, message: "must NOT have more than 100 items" }; + let data17 = data.tiles; + if (Array.isArray(data17)) { + if (data17.length > 100) { + const err48 = { instancePath: instancePath + "/tiles", schemaPath: "#/properties/tiles/maxItems", keyword: "maxItems", params: { limit: 100 }, message: "must NOT have more than 100 items" }; if (vErrors === null) { - vErrors = [err45]; + vErrors = [err48]; } else { - vErrors.push(err45); + vErrors.push(err48); } errors++; } - const len2 = data15.length; + const len2 = data17.length; for (let i3 = 0; i3 < len2; i3++) { - if (!validate59(data15[i3], { instancePath: instancePath + "/tiles/" + i3, parentData: data15, parentDataProperty: i3, rootData, dynamicAnchors })) { + if (!validate59(data17[i3], { instancePath: instancePath + "/tiles/" + i3, parentData: data17, parentDataProperty: i3, rootData, dynamicAnchors })) { vErrors = vErrors === null ? validate59.errors : vErrors.concat(validate59.errors); errors = vErrors.length; } } } else { - const err46 = { instancePath: instancePath + "/tiles", schemaPath: "#/properties/tiles/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + const err49 = { instancePath: instancePath + "/tiles", schemaPath: "#/properties/tiles/type", keyword: "type", params: { type: "array" }, message: "must be array" }; if (vErrors === null) { - vErrors = [err46]; + vErrors = [err49]; } else { - vErrors.push(err46); + vErrors.push(err49); } errors++; } } } else { - const err47 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err50 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err47]; + vErrors = [err50]; } else { - vErrors.push(err47); + vErrors.push(err50); } errors++; } diff --git a/src/generated/json-schema.types.ts b/src/generated/json-schema.types.ts index 6c9bced6..cd4535d3 100644 --- a/src/generated/json-schema.types.ts +++ b/src/generated/json-schema.types.ts @@ -829,6 +829,12 @@ export interface DashboardFilterDefinitionV1 { * Whether the filter starts active. */ defaultActive?: boolean; + /** + * Selection mode override + * + * Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here. + */ + selection?: { mode?: "single" | "multiple"; }; } /** diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index 507cd8a7..f3316621 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -1601,6 +1601,22 @@ export const dashboardV1Schema = { "title": "Active by default", "description": "Whether the filter starts active.", "type": "boolean" + }, + "selection": { + "title": "Selection mode override", + "description": "Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here.", + "type": "object", + "properties": { + "mode": { + "title": "Selection mode", + "description": "Explicit override for the inferred selection mode: \"single\" forces one active value, \"multiple\" forces a searchable multiselect.", + "enum": [ + "single", + "multiple" + ] + } + }, + "additionalProperties": false } }, "additionalProperties": false, @@ -1611,7 +1627,8 @@ export const dashboardV1Schema = { "sourceQueryId", "targets", "defaultValue", - "defaultActive" + "defaultActive", + "selection" ] }, "dashboardLayoutFallbackV1": { diff --git a/tests/unit/dashboard-filter-store.test.ts b/tests/unit/dashboard-filter-store.test.ts index f05c6995..82413d5b 100644 --- a/tests/unit/dashboard-filter-store.test.ts +++ b/tests/unit/dashboard-filter-store.test.ts @@ -54,6 +54,25 @@ describe('readDashboardFilterBag', () => { undefinedActive: { value: 'v', active: false }, }); }); + + it('round-trips a committed multiselect array value as a NEW array (#189)', () => { + const all = { d1: { f1: { value: ['a', 'b'], active: true } } }; + const result = readDashboardFilterBag(all, 'd1'); + expect(result).toEqual({ f1: { value: ['a', 'b'], active: true } }); + // Genuine copy, not the same array reference — later mutation of the + // source blob must not affect the returned bag. + expect(result.f1.value).not.toBe(all.d1.f1.value); + }); + + it('preserves an empty-string element inside an array value', () => { + const all = { d1: { f1: { value: ['', 'a'], active: false } } }; + expect(readDashboardFilterBag(all, 'd1')).toEqual({ f1: { value: ['', 'a'], active: false } }); + }); + + it('drops non-string/nullish elements from an array value on read (untrusted JSON)', () => { + const all = { d1: { f1: { value: ['a', 42, null, undefined, true, ['nested'], { x: 1 }, 'b'], active: true } } }; + expect(readDashboardFilterBag(all, 'd1')).toEqual({ f1: { value: ['a', 'b'], active: true } }); + }); }); describe('writeDashboardFilterBag', () => { @@ -91,6 +110,15 @@ describe('writeDashboardFilterBag', () => { bag.f1.value = 'mutated-after'; expect(next.d1.f1.value).toBe('new'); }); + + it('clones an array value as a NEW array, not a shared reference', () => { + const bag: DashboardFilterBag = { f1: { value: ['a', 'b'], active: true } }; + const next = writeDashboardFilterBag({}, 'd1', bag); + expect(next.d1.f1.value).toEqual(['a', 'b']); + expect(next.d1.f1.value).not.toBe(bag.f1.value); + (bag.f1.value as string[]).push('mutated-after'); + expect(next.d1.f1.value).toEqual(['a', 'b']); + }); }); describe('filterBagSignature', () => { @@ -110,4 +138,12 @@ describe('filterBagSignature', () => { expect(filterBagSignature({})).toBe(filterBagSignature({})); expect(filterBagSignature({})).not.toBe(filterBagSignature({ a: { value: '', active: false } })); }); + + it('distinguishes an array value from its comma-joined string (#189, JSON-safe encoding)', () => { + const arrayBag: DashboardFilterBag = { a: { value: ['a', 'b'], active: true } }; + const joinedBag: DashboardFilterBag = { a: { value: 'a,b', active: true } }; + expect(filterBagSignature(arrayBag)).not.toBe(filterBagSignature(joinedBag)); + // Matches an equal array value and stays stable across separate calls. + expect(filterBagSignature(arrayBag)).toBe(filterBagSignature({ a: { value: ['a', 'b'], active: true } })); + }); }); diff --git a/tests/unit/param-serialize.test.ts b/tests/unit/param-serialize.test.ts index c25a29a3..774955ef 100644 --- a/tests/unit/param-serialize.test.ts +++ b/tests/unit/param-serialize.test.ts @@ -143,6 +143,37 @@ describe('serializeParamValue — Array(T) literals', () => { it('accepts a pre-parsed type object', () => { expect(serializeParamValue(['a'], parseParamType('Array(String)'))).toEqual({ ok: true, value: "['a']" }); }); + + it('Array(String): Unicode elements (non-ASCII and emoji) quote byte-identical, no escaping needed', () => { + expect(serializeParamValue(['héllo', '日本語', '🎉emoji'], 'Array(String)')) + .toEqual({ ok: true, value: "['héllo','日本語','🎉emoji']" }); + }); + + it('Array(String): an empty-string element is preserved as its own quoted element', () => { + expect(serializeParamValue(['', 'a'], 'Array(String)')).toEqual({ ok: true, value: "['','a']" }); + }); + + it('Array(String): a comma inside one text element stays inside that element\'s quotes, not split', () => { + expect(serializeParamValue(['a,b'], 'Array(String)')).toEqual({ ok: true, value: "['a,b']" }); + expect(serializeParamValue(['a,b', 'c'], 'Array(String)')).toEqual({ ok: true, value: "['a,b','c']" }); + }); + + it('Array(Decimal(10,2)): numeric tokens unquoted (Decimal shares the Float lexical family)', () => { + expect(serializeParamValue(['1.50', '-2.25', '0'], 'Array(Decimal(10,2))')) + .toEqual({ ok: true, value: '[1.50,-2.25,0]' }); + expect(serializeParamValue(['abc'], 'Array(Decimal(10,2))').ok).toBe(false); + }); + + it("Array(Enum8('a b'=1)): member-label elements quote/escape like plain text", () => { + expect(serializeParamValue(['a b'], "Array(Enum8('a b'=1))")).toEqual({ ok: true, value: "['a b']" }); + // A label containing a single quote escapes the same way any text element does. + expect(serializeParamValue(["d'e"], "Array(Enum8('d\\'e'=1))")).toEqual({ ok: true, value: "['d\\'e']" }); + }); + + it('Array(DateTime): string elements quoted like text', () => { + expect(serializeParamValue(['2024-01-01 00:00:00', '2024-02-01 12:34:56'], 'Array(DateTime)')) + .toEqual({ ok: true, value: "['2024-01-01 00:00:00','2024-02-01 12:34:56']" }); + }); }); describe('serializeParamValue — rejections', () => { diff --git a/tests/unit/portable-bundle-codec.test.ts b/tests/unit/portable-bundle-codec.test.ts index 6777d26a..2d9605c0 100644 --- a/tests/unit/portable-bundle-codec.test.ts +++ b/tests/unit/portable-bundle-codec.test.ts @@ -56,6 +56,23 @@ describe('validatePortableBundleDocument', () => { })); expect(has(d, 'workspace-duplicate-query-id')).toBe(true); }); + + it('validates dashboard filter selection-mode overrides (#189)', () => { + const withSelection = (selection: unknown) => bundle({ + dashboards: [dashboardDoc({ + filters: [{ id: 'flt', parameter: 'p', selection }], + })], + }); + expect(validatePortableBundleDocument(withSelection({ mode: 'single' }))).toEqual([]); + expect(validatePortableBundleDocument(withSelection({ mode: 'multiple' }))).toEqual([]); + expect(validatePortableBundleDocument(withSelection({}))).toEqual([]); + + const badMode = validatePortableBundleDocument(withSelection({ mode: 'bogus' })); + expect(has(badMode, 'schema-invalid-enum')).toBe(true); + + const unknownProp = validatePortableBundleDocument(withSelection({ mode: 'single', extra: true })); + expect(has(unknownProp, 'schema-unknown-property')).toBe(true); + }); }); describe('decodePortableBundleJson', () => { From d9a3621d335d83e94f6d11c64714912c109a680b Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 21 Jul 2026 20:03:46 +0000 Subject: [PATCH 02/10] feat(#189): pure filter-selection contract resolver + selection-value helpers resolveFilterSelection derives the curated helper's effective single/multiple mode from the agreed consumer type across resolved targets and dependent Filter sources, failing closed with path-precise filter-selection-* diagnostics (mixed arity, type/element conflicts, nested arrays, multiple-on-scalar, unknown modes, undeclared/non-executable targets). sameSelection/ canonicalizeSelection/reconcileSelection are the pure value helpers for the multiselect Apply and option-refresh reconciliation. Co-Authored-By: Claude Fable 5 --- src/core/filter-selection.ts | 397 ++++++++++++++++++++++++++++ tests/unit/filter-selection.test.ts | 372 ++++++++++++++++++++++++++ 2 files changed, 769 insertions(+) create mode 100644 src/core/filter-selection.ts create mode 100644 tests/unit/filter-selection.test.ts diff --git a/src/core/filter-selection.ts b/src/core/filter-selection.ts new file mode 100644 index 00000000..45cd313d --- /dev/null +++ b/src/core/filter-selection.ts @@ -0,0 +1,397 @@ +// #189: searchable multiselect Dashboard filters. A Filter helper (curated +// options UI) is only ever offered when every EXECUTABLE consumer of a +// filter's `{parameter:Type}` agrees on one compatible declared type — the +// same "agree or degrade" posture #173/#360 already use for cross-source type +// conflicts (`conflictingTypes`, `param-pipeline.ts`), applied here to decide +// the curated helper's selection mode instead of a bound value's validity. +// +// This module is pure: no DOM, no globals, no fetch. `resolveFilterSelection` +// takes a structural snapshot of a filter definition, the dashboard's +// `ParameterAnalysis` (#173), the caller's own notion of which tiles are +// currently executable, and any dependent Filter sources' own declarations of +// the same parameter (#360: a Filter source may declare `{name:Type}` params +// backed by another source's control) — and returns the agreed contract, the +// effective single/multiple mode, and every diagnostic that blocks the +// helper. `sameSelection`/`canonicalizeSelection`/`reconcileSelection` are the +// pure value-side helpers the multiselect control and its option-refresh +// reconciliation need once a helper IS exposed. + +import { parseParamType, conflictingTypes } from './param-type.js'; +import type { ParsedParamType } from './param-type.js'; +import type { ParameterAnalysis } from './param-pipeline.js'; +import { diagnostic } from './diagnostics.js'; +import type { Diagnostic } from './diagnostics.js'; + +/** + * Structural shape of a Dashboard filter definition `resolveFilterSelection` + * needs — assignable FROM the generated `DashboardFilterDefinitionV1` (#189, + * `src/generated/json-schema.types.ts`) without requiring that exact + * interface, so tests (and any future caller) can pass a bare literal. + * `selection.mode` is deliberately typed `string`, not the literal + * `'single'|'multiple'` union — the JSON Schema normally blocks any other + * value, but this module narrows defensively (the repo's convention for + * every schema-adjacent enum-ish field, e.g. `bindPolicy` in + * `param-pipeline.ts`) rather than trusting that upstream gate alone. + */ +export interface FilterSelectionFilterDef { + id: string; + parameter: string; + targets?: string[]; + selection?: { mode?: string }; +} + +/** + * One dependent Filter source's own declarations of the parameter being + * resolved (#360: a Filter source may declare `{name:Type}` params backed by + * ANOTHER source's control) — always an ADDITIONAL executable consumer, + * regardless of the filter's `targets`, since a Filter source has no `targets` + * concept of its own. `declarations` carries every occurrence's raw declared + * type text, one entry per occurrence (mirrors `AnalyzedDeclaration.type` / + * `conflictingTypes`'s own input shape — see `FilterSourceAnalysis` in + * `filter-execution.ts`, whose `dependsOn` names the parameters a caller + * would filter this down to) so a dependent source that declares the same + * parameter twice with disagreeing types still surfaces as a conflict here. + */ +export interface FilterSelectionDependentSource { + sourceId: string; + label?: string; + declarations: { type: string }[]; +} + +/** + * The agreed consumer contract across every executable consumer of a filter's + * parameter: whether they all declare it as a bare scalar (`array: false`) or + * all as `Array(...)` (`array: true`), and the parsed VALUE type each + * individual selection value must validate/serialize as — the scalar's own + * type for a scalar contract, or the array's element type for an array one. + * `resolveFilterSelection` only ever produces this when every consumer's + * declaration is compatible (see its own doc comment for exactly what + * "compatible" means). + */ +export interface FilterSelectionContract { + array: boolean; + type: ParsedParamType; +} + +/** + * `resolveFilterSelection`'s own diagnostic shape — this module's + * `filter-selection-*` codes, always `severity: 'error'`: an unresolved + * contract or mode always means the curated helper is unavailable (the plain + * string-input fallback takes over), never a soft warning. Same convention as + * `filter-execution.ts`'s `FilterSqlDiagnostic` over the shared + * `diagnostics.ts` factory. + */ +export interface FilterSelectionDiagnostic extends Diagnostic { + severity: 'error'; +} + +/** + * `resolveFilterSelection`'s return shape. The curated Filter helper is + * exposed IFF `diagnostics` is empty; `mode` is non-null exactly then too. + * `contract` can still resolve (informationally — e.g. so a mode-table + * diagnostic can name the agreed type) even when `diagnostics` is non-empty + * for a reason unrelated to the type agreement itself (an explicit target + * that fails closed, or `selection.mode: "multiple"` requested against a + * scalar contract) — it is null only when the consumers themselves could not + * agree on one type at all (no consumers, mixed arity, conflicting types, a + * nested array). + */ +export interface FilterSelectionResolution { + contract: FilterSelectionContract | null; + mode: 'single' | 'multiple' | null; + diagnostics: FilterSelectionDiagnostic[]; +} + +const err = (code: string, message: string, extra: Record = {}): FilterSelectionDiagnostic => + diagnostic('error', code, message, extra) as FilterSelectionDiagnostic; + +/** + * Resolve one Dashboard filter's curated-helper contract and effective + * selection mode (#189). + * + * Consumer gathering: + * - explicit `filter.targets`, when present and non-empty: each target id + * must be an `executableTileIds` member AND have at least one BOUND + * declaration of `filter.parameter` in `analysis` — a target missing + * either fails closed with its own diagnostic (and contributes no + * consumer entries), per target, so multiple bad targets each get their + * own diagnostic; + * - no `targets` (or an empty array): every executable tile with a bound + * declaration of the parameter; + * - `dependentSources`' own declarations of the parameter are ALWAYS + * additional consumers, on top of either of the above. + * + * Contract compatibility, over the gathered consumer declarations: + * - zero consumer declarations at all → `filter-selection-no-consumers` + * (suppressed when every explicit target already got its own diagnostic — + * that already explains the empty set without a redundant second one); + * - any declaration whose Array element is itself an Array + * (`parsed.elem?.isArray`) → `filter-selection-nested-array` (checked + * before arity/conflict below — nested arrays are unsupported outright, + * regardless of what else agrees or conflicts); + * - a mix of scalar and `Array(...)` declarations → `filter-selection-mixed-arity`; + * - all-scalar but incompatible per `conflictingTypes` (#238 `canonicalType` + * identity — wrapper-sensitive: `Nullable(T)`/`LowCardinality(T)` are + * DIFFERENT declarations from bare `T`, never silently unified) → + * `filter-selection-type-conflict`; + * - all-`Array` but conflicting ELEMENT types, by the same `conflictingTypes` + * identity applied to each element's own raw (wrapper-inclusive) type text + * → `filter-selection-array-element-conflict`. + * This reuses `param-type.ts`'s existing compatibility primitive rather than + * reinventing one — see its own doc comment for exactly what "conflicting" + * means; this module makes no independent judgment call about it. + * + * Mode table (#189): + * | `selection.mode` | contract | effective | + * |-----------------------|-----------|--------------| + * | omitted | scalar | `'single'` | + * | omitted | array | `'multiple'` | + * | `'single'` | scalar | `'single'` | + * | `'single'` | array | `'single'` (UI commits `[value]`) | + * | `'multiple'` | array | `'multiple'` | + * | `'multiple'` | scalar | INVALID — `filter-selection-mode-requires-array`, never silently downgraded | + * | any unrecognized non-empty string | (any) | INVALID — `filter-selection-unknown-mode` | + * | (any mode) | no agreed contract | fallback (contract already null; no mode-table diagnostic added on top) | + * + * Pure. + */ +export function resolveFilterSelection( + filter: FilterSelectionFilterDef, + analysis: ParameterAnalysis, + executableTileIds: ReadonlySet, + dependentSources: readonly FilterSelectionDependentSource[] = [], +): FilterSelectionResolution { + const diagnostics: FilterSelectionDiagnostic[] = []; + const name = filter.parameter; + const field = analysis.fields[name]; + + // ── Gather every executable consumer's raw declaration of {name} ───────── + const entries: { sourceId: string; type: string }[] = []; + let targetProblem = false; + if (filter.targets && filter.targets.length) { + for (const targetId of filter.targets) { + if (!executableTileIds.has(targetId)) { + targetProblem = true; + diagnostics.push(err( + 'filter-selection-target-not-executable', + `Filter "${filter.id}" target "${targetId}" is not an executable tile.`, + { filterId: filter.id, parameter: name, sourceId: targetId }, + )); + continue; + } + const bound = (field?.declarations || []).filter((d) => d.bound && d.source === targetId); + if (!bound.length) { + targetProblem = true; + diagnostics.push(err( + 'filter-selection-target-missing-declaration', + `Filter "${filter.id}" target "${targetId}" does not declare {${name}}.`, + { filterId: filter.id, parameter: name, sourceId: targetId }, + )); + continue; + } + for (const d of bound) entries.push({ sourceId: targetId, type: d.type }); + } + } else { + for (const d of field?.declarations || []) { + if (d.bound && executableTileIds.has(d.source)) entries.push({ sourceId: d.source, type: d.type }); + } + } + for (const ds of dependentSources) { + for (const decl of ds.declarations) entries.push({ sourceId: ds.sourceId, type: decl.type }); + } + + // ── Resolve the agreed contract from `entries` ──────────────────────────── + let contract: FilterSelectionContract | null = null; + if (!entries.length) { + if (!targetProblem) { + diagnostics.push(err( + 'filter-selection-no-consumers', + `Filter "${filter.id}" parameter {${name}} has no executable consumer declarations.`, + { filterId: filter.id, parameter: name }, + )); + } + } else { + const parsed = entries.map((e) => ({ ...e, parsed: parseParamType(e.type) })); + const nested = parsed.filter((e) => e.parsed.isArray && e.parsed.elem?.isArray); + if (nested.length) { + diagnostics.push(err( + 'filter-selection-nested-array', + `Filter "${filter.id}" parameter {${name}} declares a nested array in source(s) ` + + `${nested.map((e) => `"${e.sourceId}" (${e.type})`).join(', ')}; nested arrays are not supported.`, + { filterId: filter.id, parameter: name, sources: nested.map((e) => e.sourceId) }, + )); + } else { + const scalarEntries = parsed.filter((e) => !e.parsed.isArray); + const arrayEntries = parsed.filter((e) => e.parsed.isArray); + if (scalarEntries.length && arrayEntries.length) { + diagnostics.push(err( + 'filter-selection-mixed-arity', + `Filter "${filter.id}" parameter {${name}} mixes scalar and Array(...) consumer declarations: ` + + `${parsed.map((e) => `"${e.sourceId}":${e.type}`).join(', ')}.`, + { filterId: filter.id, parameter: name, sources: parsed.map((e) => e.sourceId) }, + )); + } else if (scalarEntries.length) { + const conflict = conflictingTypes(scalarEntries.map((e) => ({ type: e.type }))); + if (conflict) { + diagnostics.push(err( + 'filter-selection-type-conflict', + `Filter "${filter.id}" parameter {${name}} has conflicting consumer types: ${conflict.join(' vs ')} ` + + `(${scalarEntries.map((e) => `"${e.sourceId}":${e.type}`).join(', ')}).`, + { filterId: filter.id, parameter: name, types: conflict, sources: scalarEntries.map((e) => e.sourceId) }, + )); + } else { + contract = { array: false, type: scalarEntries[0].parsed }; + } + } else { + // All-array (and no nested arrays, checked above) — compare ELEMENT + // types by their own raw (wrapper-inclusive) declaration text, the + // same identity `conflictingTypes` uses for a bare scalar + // declaration, so `Array(LowCardinality(UInt64))` and `Array(UInt64)` + // conflict exactly when `LowCardinality(UInt64)` and `UInt64` would + // (see the doc comment above — this module makes no separate + // transparency judgment call of its own). + const conflict = conflictingTypes(arrayEntries.map((e) => ({ type: e.parsed.elem!.raw }))); + if (conflict) { + diagnostics.push(err( + 'filter-selection-array-element-conflict', + `Filter "${filter.id}" parameter {${name}} has conflicting Array element types: ${conflict.join(' vs ')} ` + + `(${arrayEntries.map((e) => `"${e.sourceId}":${e.type}`).join(', ')}).`, + { filterId: filter.id, parameter: name, types: conflict, sources: arrayEntries.map((e) => e.sourceId) }, + )); + } else { + contract = { array: true, type: arrayEntries[0].parsed.elem! }; + } + } + } + } + + // ── Effective selection mode, per #189's mode table (see doc comment) ──── + const rawMode = filter.selection?.mode; + if (rawMode && rawMode !== 'single' && rawMode !== 'multiple') { + diagnostics.push(err( + 'filter-selection-unknown-mode', + `Filter "${filter.id}" selection.mode "${rawMode}" is not recognized; expected "single" or "multiple".`, + { filterId: filter.id, parameter: name, mode: rawMode }, + )); + } + let mode: 'single' | 'multiple' | null = null; + if (contract) { + if (rawMode === 'multiple') { + if (contract.array) { + mode = 'multiple'; + } else { + diagnostics.push(err( + 'filter-selection-mode-requires-array', + `Filter "${filter.id}" selection.mode "multiple" requires an Array(...) consumer type for {${name}}; ` + + `the agreed consumer type is scalar (${contract.type.raw}).`, + { filterId: filter.id, parameter: name }, + )); + } + } else if (rawMode === 'single') { + mode = 'single'; + } else if (!rawMode) { + mode = contract.array ? 'multiple' : 'single'; + } + // An unrecognized non-empty mode string was already diagnosed above — + // `mode` stays null, `diagnostics.length` already guarantees the fallback. + } + + return { contract, mode: diagnostics.length ? null : mode, diagnostics }; +} + +// ── Pure selection-value helpers (#189) ───────────────────────────────────── +// Empty string (`''`) is a VALID option value and a valid selection element +// everywhere below — never a sentinel for "nothing selected" (that's an empty +// ARRAY, or the field's own active/inactive flag upstream). + +/** + * Structural equality for a committed filter value: two string arrays are + * equal element-wise (same length, same values in the same order — order + * matters here because it's the COMMITTED array, not a set); two strings are + * equal as strings; an array is never equal to a string, whatever its + * contents. Anything else (`null`/`undefined`/other) falls back to `===`. + * Pure. + */ +export function sameSelection(a: unknown, b: unknown): boolean { + const aArr = Array.isArray(a); + const bArr = Array.isArray(b); + if (aArr !== bArr) return false; + if (aArr && bArr) { + const av = a as unknown[]; + const bv = b as unknown[]; + if (av.length !== bv.length) return false; + return av.every((v, i) => v === bv[i]); + } + return a === b; +} + +/** + * Canonicalize a set of selection values against the authoritative option + * list: dedupe, drop any value with no matching option (a stale bound value + * options refresh dropped), and order the survivors by OPTION order (never + * `values`' own order) — the option list is authoritative for display order. + * Never introduces a value that wasn't already in `values` — this is a + * filter/reorder, never an auto-select. Pure. + */ +export function canonicalizeSelection( + values: readonly string[], + options: readonly { value: string }[], +): string[] { + const wanted = new Set(values); + const seen = new Set(); + const out: string[] = []; + for (const opt of options) { + if (wanted.has(opt.value) && !seen.has(opt.value)) { + seen.add(opt.value); + out.push(opt.value); + } + } + return out; +} + +/** + * `reconcileSelection`'s return shape — see `reconcileSelection`'s own doc + * comment for exactly what `deactivate`/`waveNeeded` mean. + */ +export interface SelectionReconciliation { + value: string[]; + deactivate: boolean; + waveNeeded: boolean; +} + +/** + * Reconcile a previously COMMITTED multiselect value against a fresh option + * list (a Filter helper's options changed — new query run, new upstream + * filter value) — the pure decision core behind #189's option-refresh + * behavior. Intersects `committed` with the values still present in + * `options`, then canonicalizes the survivors by the NEW option order + * (`canonicalizeSelection`) — never re-introduces a value that isn't in + * `committed` (auto-select is never this function's job). + * + * - `deactivate` — true iff `committed` was non-empty and the intersection is + * empty (every previously selected value is gone: the filter has nothing + * left to contribute, so the caller should deactivate it rather than send + * an empty selection). + * - `waveNeeded` — true iff the SET of values changed (some committed value + * was dropped because it's no longer a valid option) — a caller only needs + * to re-run dependent sources/tiles then. A pure REORDER or label change + * (every committed value still present, just at new option positions) is + * `waveNeeded: false` even though `value`'s own array order may differ from + * `committed`'s. + * + * Pure. + */ +export function reconcileSelection( + committed: readonly string[], + options: readonly { value: string }[], +): SelectionReconciliation { + const optionValues = new Set(options.map((o) => o.value)); + const committedUnique = Array.from(new Set(committed)); + const survivorsUnique = committedUnique.filter((v) => optionValues.has(v)); + const value = canonicalizeSelection(survivorsUnique, options); + return { + value, + deactivate: committedUnique.length > 0 && survivorsUnique.length === 0, + waveNeeded: survivorsUnique.length !== committedUnique.length, + }; +} diff --git a/tests/unit/filter-selection.test.ts b/tests/unit/filter-selection.test.ts new file mode 100644 index 00000000..dd8d0a1a --- /dev/null +++ b/tests/unit/filter-selection.test.ts @@ -0,0 +1,372 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeParameterizedSources } from '../../src/core/param-pipeline.js'; +import type { ParameterAnalysis } from '../../src/core/param-pipeline.js'; +import { + resolveFilterSelection, + sameSelection, + canonicalizeSelection, + reconcileSelection, +} from '../../src/core/filter-selection.js'; +import type { FilterSelectionFilterDef, FilterSelectionDependentSource } from '../../src/core/filter-selection.js'; + +// Fixtures are round-tripped through the real `analyzeParameterizedSources` +// (the repo's convention — see `tests/unit/filter-bar.test.ts`'s `paramsFor` +// helper) rather than hand-crafted `ParameterAnalysis` shapes, so this suite +// exercises the real per-source declaration bookkeeping `resolveFilterSelection` +// consumes. +const analysisFor = (sources: { id: string; sql: string }[]): ParameterAnalysis => + analyzeParameterizedSources(sources.map((s) => ({ id: s.id, kind: 'tab', sql: s.sql, bindPolicy: 'row-returning' }))); + +const filterDef = (over: Partial = {}): FilterSelectionFilterDef => ({ + id: 'f1', + parameter: 'x', + ...over, +}); + +const codesOf = (diags: { code: string }[]): string[] => diags.map((d) => d.code); + +describe('resolveFilterSelection — mode table', () => { + it('omitted mode + scalar contract → single', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x = {x:UInt8}' }, + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a', 'b'])); + expect(r.diagnostics).toEqual([]); + expect(r.contract).toEqual({ array: false, type: expect.objectContaining({ base: 'UInt8', isArray: false }) }); + expect(r.mode).toBe('single'); + }); + + it('omitted mode + Array(T) contract → multiple', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x IN {x:Array(UInt8)}' }, + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a'])); + expect(r.diagnostics).toEqual([]); + expect(r.contract).toEqual({ array: true, type: expect.objectContaining({ base: 'UInt8' }) }); + expect(r.mode).toBe('multiple'); + }); + + it('"single" + scalar contract → single', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:String}' }]); + const r = resolveFilterSelection(filterDef({ selection: { mode: 'single' } }), analysis, new Set(['a'])); + expect(r.diagnostics).toEqual([]); + expect(r.mode).toBe('single'); + expect(r.contract!.array).toBe(false); + }); + + it('"single" + Array(T) contract → single (UI is responsible for committing [value])', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x IN {x:Array(String)}' }]); + const r = resolveFilterSelection(filterDef({ selection: { mode: 'single' } }), analysis, new Set(['a'])); + expect(r.diagnostics).toEqual([]); + expect(r.mode).toBe('single'); + expect(r.contract).toEqual({ array: true, type: expect.objectContaining({ base: 'String' }) }); + }); + + it('"multiple" + Array(T) contract → multiple', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x IN {x:Array(UInt64)}' }]); + const r = resolveFilterSelection(filterDef({ selection: { mode: 'multiple' } }), analysis, new Set(['a'])); + expect(r.diagnostics).toEqual([]); + expect(r.mode).toBe('multiple'); + }); + + it('"multiple" + scalar contract → INVALID: filter-selection-mode-requires-array, never silently downgraded', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }]); + const r = resolveFilterSelection(filterDef({ selection: { mode: 'multiple' } }), analysis, new Set(['a'])); + expect(r.mode).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-mode-requires-array']); + // Contract is still surfaced (informational) even though mode fails. + expect(r.contract).toEqual({ array: false, type: expect.objectContaining({ base: 'UInt8' }) }); + expect(r.diagnostics[0].message).toContain('f1'); + expect(r.diagnostics[0].message).toContain('multiple'); + }); + + it('unknown non-empty mode string → filter-selection-unknown-mode, fallback', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:String}' }]); + const r = resolveFilterSelection(filterDef({ selection: { mode: 'bogus' } }), analysis, new Set(['a'])); + expect(r.mode).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-unknown-mode']); + expect(r.diagnostics[0].message).toContain('bogus'); + }); + + it('any/omitted mode + no agreed contract → fallback, no extra mode-table diagnostic on top', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x = {x:String}' }, + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a', 'b'])); + expect(r.mode).toBeNull(); + expect(r.contract).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-type-conflict']); + }); +}); + +describe('resolveFilterSelection — consumer resolution', () => { + it('derives consumers from every executable tile with a bound declaration when targets is absent', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x = {x:UInt8}' }, + { id: 'c', sql: 'SELECT 1' }, // does not declare x at all + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a', 'b', 'c'])); + expect(r.diagnostics).toEqual([]); + expect(r.mode).toBe('single'); + }); + + it('a non-executable tile\'s declaration is excluded — no conflict from a tile that cannot run', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x = {x:String}' }, // conflicting type, but not executable + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a'])); + expect(r.diagnostics).toEqual([]); + expect(r.contract).toEqual({ array: false, type: expect.objectContaining({ base: 'UInt8' }) }); + }); + + it('explicit targets: only targeted tiles feed the contract, non-targeted conflicting tiles are ignored', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x = {x:String}' }, + ]); + const r = resolveFilterSelection(filterDef({ targets: ['a'] }), analysis, new Set(['a', 'b'])); + expect(r.diagnostics).toEqual([]); + expect(r.contract).toEqual({ array: false, type: expect.objectContaining({ base: 'UInt8' }) }); + }); + + it('explicit target that is not an executable tile → filter-selection-target-not-executable, fail-closed', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }]); + const r = resolveFilterSelection(filterDef({ targets: ['missing'] }), analysis, new Set(['a'])); + expect(r.mode).toBeNull(); + expect(r.contract).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-target-not-executable']); + expect(r.diagnostics[0].message).toContain('missing'); + // No redundant generic "no consumers" diagnostic piled on top. + expect(r.diagnostics).toHaveLength(1); + }); + + it('explicit target executable but not declaring the parameter → filter-selection-target-missing-declaration', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT 1' }, + ]); + const r = resolveFilterSelection(filterDef({ targets: ['b'] }), analysis, new Set(['a', 'b'])); + expect(r.mode).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-target-missing-declaration']); + expect(r.diagnostics[0].message).toContain('b'); + expect(r.diagnostics[0].message).toContain('x'); + }); + + it('multiple simultaneous target problems each produce their own diagnostic', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT 1' }]); + const r = resolveFilterSelection(filterDef({ targets: ['missing', 'a'] }), analysis, new Set(['a'])); + expect(codesOf(r.diagnostics).sort()).toEqual([ + 'filter-selection-target-missing-declaration', + 'filter-selection-target-not-executable', + ]); + expect(r.mode).toBeNull(); + }); + + it('target-less config with zero executable consumers → filter-selection-no-consumers', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT 1' }]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a'])); + expect(r.mode).toBeNull(); + expect(r.contract).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-no-consumers']); + }); + + it('dependent Filter source declarations are ALWAYS additional consumers, agreeing case', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }]); + const dependents: FilterSelectionDependentSource[] = [ + { sourceId: 'dep1', label: 'Dependent filter', declarations: [{ type: 'UInt8' }] }, + ]; + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a']), dependents); + expect(r.diagnostics).toEqual([]); + expect(r.contract).toEqual({ array: false, type: expect.objectContaining({ base: 'UInt8' }) }); + }); + + it('dependent Filter source declarations conflicting with tile declarations → type-conflict naming the dependent source', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }]); + const dependents: FilterSelectionDependentSource[] = [ + { sourceId: 'dep1', declarations: [{ type: 'String' }] }, + ]; + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a']), dependents); + expect(r.mode).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-type-conflict']); + expect(r.diagnostics[0].message).toContain('dep1'); + expect(r.diagnostics[0].message).toContain('UInt8'); + expect(r.diagnostics[0].message).toContain('String'); + }); + + it('dependent-source-only consumers (no targets, no tile declares the parameter) still resolve a contract', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT 1' }]); + const dependents: FilterSelectionDependentSource[] = [ + { sourceId: 'dep1', declarations: [{ type: 'String' }] }, + ]; + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a']), dependents); + expect(r.diagnostics).toEqual([]); + expect(r.mode).toBe('single'); + }); +}); + +describe('resolveFilterSelection — arity and conflict diagnostics', () => { + it('mixed scalar and Array(...) declarations → filter-selection-mixed-arity', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x IN {x:Array(UInt8)}' }, + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a', 'b'])); + expect(r.mode).toBeNull(); + expect(r.contract).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-mixed-arity']); + }); + + it('conflicting Array element types → filter-selection-array-element-conflict', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x IN {x:Array(UInt8)}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x IN {x:Array(String)}' }, + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a', 'b'])); + expect(r.mode).toBeNull(); + expect(r.contract).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-array-element-conflict']); + expect(r.diagnostics[0].message).toContain('UInt8'); + expect(r.diagnostics[0].message).toContain('String'); + }); + + it('nested arrays are unsupported → filter-selection-nested-array', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x IN {x:Array(Array(String))}' }]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a'])); + expect(r.mode).toBeNull(); + expect(r.contract).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-nested-array']); + expect(r.diagnostics[0].message).toContain('a'); + }); + + // `conflictingTypes` (param-type.ts) compares by `canonicalType()`, which is + // wrapper-SENSITIVE (whitespace-insensitive outside quotes, but does not + // unwrap `Nullable(...)`/`LowCardinality(...)`) — so a bare scalar and its + // `Nullable`/`LowCardinality`-wrapped form are DIFFERENT declarations, never + // silently unified, for both a scalar contract and an Array(...) contract's + // element types. This module reuses that identity as-is rather than + // inventing its own transparency rule — documented here since it is easy to + // assume the opposite (value-level transparency, which IS how `param-type.ts` + // treats these wrappers for serialization/validation, just not for identity). + it('Nullable(T) is NOT transparent to conflictingTypes — scalar contract', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x = {x:Nullable(UInt8)}' }, + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a', 'b'])); + expect(r.mode).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-type-conflict']); + }); + + it('LowCardinality(T) is NOT transparent to conflictingTypes — Array(...) element contract', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x IN {x:Array(String)}' }, + { id: 'b', sql: 'SELECT * FROM u WHERE x IN {x:Array(LowCardinality(String))}' }, + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a', 'b'])); + expect(r.mode).toBeNull(); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-array-element-conflict']); + }); +}); + +describe('sameSelection', () => { + it('two equal string arrays (element-wise, order matters)', () => { + expect(sameSelection(['a', 'b'], ['a', 'b'])).toBe(true); + expect(sameSelection(['a', 'b'], ['b', 'a'])).toBe(false); + }); + + it('different lengths are unequal', () => { + expect(sameSelection(['a'], ['a', 'b'])).toBe(false); + }); + + it('two equal strings compare as strings', () => { + expect(sameSelection('a', 'a')).toBe(true); + expect(sameSelection('a', 'b')).toBe(false); + }); + + it('an array never equals a string, even with matching contents', () => { + expect(sameSelection(['a'], 'a')).toBe(false); + expect(sameSelection('a', ['a'])).toBe(false); + }); + + it('empty string is a valid element, not a sentinel', () => { + expect(sameSelection(['', 'a'], ['', 'a'])).toBe(true); + expect(sameSelection('', '')).toBe(true); + }); + + it('falls back to === for other shapes (null/undefined)', () => { + expect(sameSelection(null, null)).toBe(true); + expect(sameSelection(undefined, null)).toBe(false); + }); +}); + +describe('canonicalizeSelection', () => { + const options = [{ value: 'b' }, { value: 'a' }, { value: '' }]; + + it('dedupes and orders by option order, never values order', () => { + expect(canonicalizeSelection(['a', 'b', 'a'], options)).toEqual(['b', 'a']); + }); + + it('drops values with no matching option', () => { + expect(canonicalizeSelection(['a', 'zzz'], options)).toEqual(['a']); + }); + + it('empty string is a valid option value, kept and ordered like any other', () => { + expect(canonicalizeSelection(['', 'a'], options)).toEqual(['a', '']); + }); + + it('never introduces a value that was not already present', () => { + expect(canonicalizeSelection([], options)).toEqual([]); + }); +}); + +describe('reconcileSelection', () => { + it('pure reorder/label change (every committed value still present) → waveNeeded: false', () => { + const r = reconcileSelection(['b', 'a'], [{ value: 'a' }, { value: 'b' }]); + expect(r.value).toEqual(['a', 'b']); + expect(r.waveNeeded).toBe(false); + expect(r.deactivate).toBe(false); + }); + + it('a committed value removed from options → waveNeeded: true, canonical order kept', () => { + const r = reconcileSelection(['a', 'b'], [{ value: 'a' }]); + expect(r.value).toEqual(['a']); + expect(r.waveNeeded).toBe(true); + expect(r.deactivate).toBe(false); + }); + + it('every committed value removed → deactivate: true', () => { + const r = reconcileSelection(['a', 'b'], []); + expect(r.value).toEqual([]); + expect(r.deactivate).toBe(true); + expect(r.waveNeeded).toBe(true); + }); + + it('nothing committed → no-op, never deactivates or needs a wave', () => { + const r = reconcileSelection([], [{ value: 'a' }]); + expect(r.value).toEqual([]); + expect(r.deactivate).toBe(false); + expect(r.waveNeeded).toBe(false); + }); + + it('never auto-selects a value that was not previously committed', () => { + const r = reconcileSelection(['a'], [{ value: 'a' }, { value: 'new' }]); + expect(r.value).toEqual(['a']); + }); + + it('duplicate committed values are deduped', () => { + const r = reconcileSelection(['a', 'a', 'b'], [{ value: 'a' }, { value: 'b' }]); + expect(r.value).toEqual(['a', 'b']); + expect(r.waveNeeded).toBe(false); + }); + + it('empty string is a valid committed value and option', () => { + const r = reconcileSelection(['', 'a'], [{ value: 'a' }, { value: '' }]); + expect(r.value).toEqual(['a', '']); + expect(r.waveNeeded).toBe(false); + expect(r.deactivate).toBe(false); + }); +}); From bec0f8659cf62acb286f47f3e1f63fb267c71939 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 21 Jul 2026 20:58:09 +0000 Subject: [PATCH 03/10] feat(#189): viewer session resolves selection contracts, preserves arrays, plans waves by resolved targets Source-backed filters resolve their multiselect contract once at construction (resolveFilterSelection): any resolution diagnostic falls the filter back to the plain string input with persistent path-precise diagnostics and disconnects it from its source (a zero-consumer source never executes). Committed string[] values now reach the typed pipeline un-stringified (empty array reads as missing; defensive copies at every store/commit seam). runAffectedWave consults each parameter's resolved targets (explicit def.targets else declaring tiles, one shared resolver with the #235 wave gate) instead of rerunning every tile declaring the name. Option-refresh reconciliation is array-aware via reconcileSelection: canonical reorders don't wave, narrowed selections stay active and join the single reconciled wave, an empty intersection deactivates keeping the dormant array. Co-Authored-By: Claude Fable 5 --- src/core/dashboard-filters.ts | 29 +- .../application/dashboard-viewer-session.ts | 261 ++++++++-- tests/unit/dashboard-filters.test.ts | 57 +++ tests/unit/dashboard-viewer-session.test.ts | 448 +++++++++++++++++- tests/unit/dashboard.test.ts | 27 +- 5 files changed, 772 insertions(+), 50 deletions(-) diff --git a/src/core/dashboard-filters.ts b/src/core/dashboard-filters.ts index f71540e2..be78feb2 100644 --- a/src/core/dashboard-filters.ts +++ b/src/core/dashboard-filters.ts @@ -4,6 +4,7 @@ import type { ParsedParamType } from './param-type.js'; import { diagnostic } from './diagnostics.js'; import type { Diagnostic } from './diagnostics.js'; import type { FieldControl } from './param-pipeline.js'; +import { reconcileSelection } from './filter-selection.js'; // `param-serialize.js` is unconverted (checkJs:false) — the same narrow // result contract `param-pipeline.ts` declares for the same function. @@ -146,7 +147,33 @@ export function mergeDashboardFilterHelpers({ const changed: string[] = []; for (const [name, field] of Object.entries(fields)) { if (!active[name]) continue; - if (field.options.some((option) => option.value === String(values[name] ?? ''))) continue; + const committed = values[name]; + if (Array.isArray(committed)) { + // #189: a multiselect (Array-contract) filter's committed value is a + // real string array — it must NEVER go through the scalar `String(...)` + // comparison below (which would stringify `['a','b']` as `"a,b"` and + // never match any option). `reconcileSelection` (filter-selection.ts) + // is the same pure decision core the multiselect control itself uses + // for this exact refresh. + const reconciled = reconcileSelection(committed as string[], field.options); + if (reconciled.deactivate) { + // Every previously-selected value is gone: deactivate, but KEEP the + // dormant array untouched (`nextValues[name]` stays the ORIGINAL + // committed array) — matches the scalar path's own reactivation + // policy (a cleared filter keeps its retained value). + nextActive[name] = false; + changed.push(name); + } else { + // Non-empty intersection: the value updates to the canonical (fresh + // option order) survivors — even a pure reorder/label-only refresh + // (`waveNeeded: false`) still updates the value, it just isn't a + // change a caller needs to re-run anything for. + nextValues[name] = reconciled.value; + if (reconciled.waveNeeded) changed.push(name); + } + continue; + } + if (field.options.some((option) => option.value === String(committed ?? ''))) continue; nextActive[name] = false; changed.push(name); } diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 626b7dfe..20e25f2e 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -38,6 +38,8 @@ import { panelExecution } from '../../core/panel-execution.js'; import { analyzeFilterSource, prepareFilterSource } from '../../core/filter-execution.js'; import type { FilterSourceAnalysis } from '../../core/filter-execution.js'; import { readFilterOptions } from '../../core/filter-options.js'; +import { resolveFilterSelection, sameSelection } from '../../core/filter-selection.js'; +import type { FilterSelectionFilterDef, FilterSelectionDependentSource } from '../../core/filter-selection.js'; import { mergeDashboardFilterHelpers } from '../../core/dashboard-filters.js'; import type { FilterProvider, FilterHelperOption, FilterDiagnostic, MergeDashboardFilterHelpersResult, @@ -141,6 +143,19 @@ export interface ViewerFilterState { * renderer for a source-backed filter by construction rather than by * inferring it from a transient status value. */ sourceId?: string; + /** #189: the agreed searchable-multiselect contract for a SOURCE-BACKED + * filter, set once at construction from `resolveFilterSelection` + * (`core/filter-selection.ts`) — present iff that resolution's + * diagnostics were empty (a curated helper is actually offered); absent + * otherwise (including for every plain root filter, which never gets a + * contract at all) — the plain string-input fallback then applies, same + * as a filter with no source. `mode` is the EFFECTIVE selection mode + * (`selection.mode` table); `array` mirrors the agreed contract's own + * arity, independent of `mode` (a scalar contract with `selection.mode: + * "single"`-on-Array still reports `array: true` here — see the mode + * table). TOPOLOGY, not transport state — like `sourceId`, it never + * changes across a session. */ + selection?: { mode: 'single' | 'multiple'; array: boolean }; } /** The Dashboard's per-render layout view (#291) — a discriminated union over @@ -347,6 +362,27 @@ const cfgType = (panel: unknown): string | undefined => const toValueString = (value: unknown): string => (typeof value === 'string' ? value : value == null ? '' : String(value)); +/** #189: array-safe replacement for `toValueString` at every seam that feeds + * `prepareParameterizedBatch`/`prepareFilterSource` (`rawValues`, + * `committedRootValues`) — a committed multiselect value is a REAL string + * array, and the pipeline/serializer already understand `Array(...)`-typed + * params, so it must reach them un-stringified. A non-empty array passes + * through as a DEFENSIVE COPY (never the live array a caller might still + * hold); an EMPTY array reads as "no value" — same as `''` — for every + * missing/inactive/readiness purpose downstream, exactly like a blank text + * filter. Every other shape keeps `toValueString`'s existing coercion, + * unchanged. */ +const toParamValue = (value: unknown): unknown => + (Array.isArray(value) ? (value.length ? value.slice() : '') : toValueString(value)); + +/** #189: defensive array copy for every seat that STORES a filter's raw + * committed value (`filter.state.value`, an `initialFilters` seed, a + * `def.defaultValue`) — never `toParamValue`'s job, which additionally + * coerces non-array shapes to a string for the execution pipeline. Runtime + * state must never alias an array a caller (the document, the persisted + * seed, `setFilter`/`applyFilter`'s own caller) still holds a reference to. */ +const copyValue = (value: unknown): unknown => (Array.isArray(value) ? value.slice() : value); + /** Local copy of `effectiveFilterActive` (state.ts is off-limits to this * layer): a param with an explicit activation entry uses it; otherwise a * non-empty value counts as active. */ @@ -436,15 +472,26 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // One runtime record per tile, in semantic (dashboard.tiles) order. const tiles: TileRuntime[] = (Array.isArray(documentRef.tiles) ? documentRef.tiles : []).map(buildTileRuntime); + // A tile is EXECUTABLE/runnable when it has a query and is neither a text + // panel nor a presentation error — structural, fixed for the session, so + // both `runnableTiles()` (below) and the #189 selection-contract resolver + // (which needs the id SET, not the records) derive it from this ONE + // predicate and can never drift apart. + const isRunnableTileRuntime = (runtime: TileRuntime): boolean => + !!runtime.query && !runtime.isText && !runtime.presentationError; + const executableTileIds = new Set(tiles.filter(isRunnableTileRuntime).map((runtime) => runtime.tile.id)); + // Filter runtime records, in filter order. const filters: FilterRuntime[] = (Array.isArray(documentRef.filters) ? documentRef.filters : []).map((def) => { - const defaultValue = def.defaultValue ?? ''; + const defaultValue = copyValue(def.defaultValue ?? ''); const defaultActive = def.defaultActive ?? (def.defaultValue != null && def.defaultValue !== ''); // #303: a persisted seed for this filter's id overrides the pure-default // init above (untouched when `initialFilters` is absent/empty, or has no - // entry for `def.id`). + // entry for `def.id`). #189: `copyValue` defends against aliasing the + // caller's own seed/document array (a persisted multiselect value, or a + // `defaultValue` array literal on the document). const seed = deps.initialFilters ? deps.initialFilters[def.id] : undefined; - const value = seed !== undefined ? (seed.value ?? defaultValue) : defaultValue; + const value = copyValue(seed !== undefined ? (seed.value ?? defaultValue) : defaultValue); const active = seed !== undefined ? !!seed.active : defaultActive; const sourceId = typeof def.sourceQueryId === 'string' ? def.sourceQueryId : undefined; const state: ViewerFilterState = { @@ -498,21 +545,121 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa }))); const controls: FieldControl[] = fieldControls(analysis); - // #235 overlap: the set of tile IDs a SOURCE-backed filter targets. A filter - // with `targets` names them explicitly; an absent `targets` means every - // panel tile whose query declares the filter's parameter. Only source-backed - // filters gate — a plain value filter's value is already known. + // Every tile id the document actually declares — `resolveFilterTargets` + // (#189) validates a filter's explicit `def.targets` against this set + // (dropping unknown ids defensively) rather than trusting authored config. + const knownTileIds = new Set(tiles.map((runtime) => runtime.tile.id)); + + /** #189: one filter DEFINITION's own resolved target tile set — explicit + * `def.targets` (validated against `knownTileIds`) when present, else + * every tile whose SQL declares `def.parameter` (`requiredIn`/`optionalIn` + * from the tile parameter `analysis`). The ONE shared resolution both + * `affectedByFilterWave` (#235's source-backed-only pre-wave + * classification, below) and `targetsByParameter` (the general #189 + * affected-panel planner `runAffectedWave` consults) derive from, so the + * two "explicit targets else declared" computations can never drift + * apart. An explicit `targets: []` (present but empty) deliberately + * resolves to affecting NOTHING — it does not fall back to the declared + * set — preserving the pre-#189 `affectedByFilterWave` behavior exactly. */ + function resolveFilterTargets(def: DashboardFilterDefinitionV1): Set { + if (Array.isArray(def.targets)) return new Set(def.targets.filter((id) => knownTileIds.has(id))); + const field = analysis.fields[def.parameter]; + return field ? new Set(field.requiredIn.concat(field.optionalIn)) : new Set(); + } + + // #235 overlap: the set of tile IDs a SOURCE-backed filter targets. Only + // source-backed filters gate — a plain value filter's value is already + // known, so tiles it feeds never need to wait for the filter/source wave. const affectedByFilterWave = new Set(); for (const filter of filters) { if (!filter.def.sourceQueryId) continue; - const explicitTargets = Array.isArray(filter.def.targets) ? filter.def.targets : null; - if (explicitTargets) { - for (const id of explicitTargets) affectedByFilterWave.add(id); - continue; + for (const id of resolveFilterTargets(filter.def)) affectedByFilterWave.add(id); + } + + // #189: the general affected-panel planner `runAffectedWave` consults for + // EVERY committed parameter (root or source-backed) — every filter + // definition's own resolved targets, unioned per PARAMETER (two filter + // definitions sharing one parameter union their target sets, since + // committing that parameter must satisfy both). + const targetsByParameter = new Map>(); + for (const filter of filters) { + const set = targetsByParameter.get(filter.def.parameter) || new Set(); + for (const id of resolveFilterTargets(filter.def)) set.add(id); + targetsByParameter.set(filter.def.parameter, set); + } + + // #189: `resolveFilterSelection`'s own documented contract (see its return + // type's doc comment) is strict — the curated helper is exposed IFF + // `diagnostics` is empty, full stop. Issue #189's fallback list is explicit + // that "targets that do not declare the parameter" and "target-less or + // non-executable configurations where no consumer contract can be + // resolved" (i.e. `filter-selection-target-missing-declaration`, + // `filter-selection-target-not-executable`, `filter-selection-no-consumers`) + // are must-fall-back cases, not benign ones: "do not execute or expose the + // query-backed helper as authoritative; render the ordinary parameter + // string input; show a visible diagnostic; leave unrelated filters and + // panels functional." So there is no carve-out here — EVERY non-empty + // `diagnostics` result (whatever the code) falls a filter all the way back + // to the plain string-input path: no `selection` contract is published, no + // `sourceId` is kept, and the filter is dropped from its source's + // `consumers` so the shared source query is never executed on its behalf + // (matching #189's "do not execute... the query-backed helper"). + + // #189: resolve every SOURCE-BACKED filter's searchable-multiselect + // contract once, at construction (structural — never revisited). A filter + // whose resolution surfaces ANY diagnostic falls all the way back to the + // plain string-input filter: it is stripped from `state.sourceId` (the + // UI's own curation gate) and from its `FilterSourceRuntime`'s `consumers` + // — its helper must never execute. `staticFilterDiagnostics` is emitted + // ALONGSIDE (never instead of) the per-wave `filterDiagnostics` — see + // `buildState`'s doc comment for why these need to be two separate arrays. + const staticFilterDiagnostics: FilterDiagnostic[] = []; + for (const filter of filters) { + if (!filter.sourceId) continue; // plain root filter — no contract, untouched + const source = filterSources.get(filter.sourceId)!; // built above for every sourceId-bearing filter + // Every OTHER Filter source's own declarations of this parameter (#360: + // a Filter source may declare `{name:Type}` params fed by ANOTHER + // source's control). NOTE: #360's cascading rule already forbids any + // Filter source from depending on a SOURCE-BACKED parameter (this + // filter's own parameter always qualifies, since it has a `sourceId`) — + // any source that structurally declares it here would already carry its + // own `filter-source-cascading` diagnostic and never run. So in + // practice this is always empty for a source-backed filter; it is still + // wired through generically (cheap, and future-proof against this + // resolution ever being asked for a plain root filter too). + const dependentSources: FilterSelectionDependentSource[] = []; + for (const other of filterSources.values()) { + if (other.id === filter.sourceId) continue; + const field = other.analyzed.analysis.fields[filter.def.parameter]; + if (!field || !field.declarations.length) continue; + dependentSources.push({ + sourceId: other.id, + label: other.query ? queryName(other.query) : other.id, + declarations: field.declarations.map((d) => ({ type: d.type })), + }); + } + const filterSelectionDef: FilterSelectionFilterDef = { + id: filter.def.id, parameter: filter.def.parameter, + targets: Array.isArray(filter.def.targets) ? filter.def.targets : undefined, + selection: filter.def.selection, + }; + const resolution = resolveFilterSelection(filterSelectionDef, analysis, executableTileIds, dependentSources); + if (resolution.diagnostics.length) { + for (const d of resolution.diagnostics) staticFilterDiagnostics.push(d as FilterDiagnostic); + filter.state.sourceId = undefined; + source.consumers = source.consumers.filter((consumer) => consumer !== filter); + } else { + filter.state.selection = { mode: resolution.mode!, array: resolution.contract!.array }; } - const field = analysis.fields[filter.def.parameter]; - if (!field) continue; - for (const sourceId of field.requiredIn.concat(field.optionalIn)) affectedByFilterWave.add(sourceId); + } + // A `FilterSourceRuntime` left with zero consumers (every filter that + // named it fell back to the string-input path above) must never execute — + // both `runFilterWave` (every KNOWN source) and `runFilterSourceWave` (the + // `dependsOn`-filtered selective rerun) iterate `filterSources.values()`, + // so removing it here is the one place that guarantees it forever, rather + // than re-deriving a "has consumers" gate at every call site. + for (const [id, source] of filterSources) { + if (source.consumers.length === 0) filterSources.delete(id); } // Curated option bundles from the last filter wave (param name → field). @@ -521,11 +668,15 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // `curated`, reset to `[]` at the START of `runFilterWave` and set (as-is, // no dedupe) in `applyFilterProviders`. `buildState` reads it on every // publish, so tile-progress publishes mid-wave carry the PREVIOUS wave's - // diagnostics and a pre-wave publish (construction) sees `[]`. + // diagnostics and a pre-wave publish (construction) sees `[]`. #189's + // `staticFilterDiagnostics` (construction-time constants) are concatenated + // in ON TOP of this at every publish (`buildState`) — never reset, + // never touched by a wave — so a filter's selection-resolution failure + // stays visible forever, through every refresh/commit. let filterDiagnostics: FilterDiagnostic[] = []; const rawValues = (): Record => - Object.fromEntries(filters.map((filter) => [filter.def.parameter, toValueString(filter.state.value)])); + Object.fromEntries(filters.map((filter) => [filter.def.parameter, toParamValue(filter.state.value)])); const activeMap = (): Record => Object.fromEntries(filters.map((filter) => [filter.def.parameter, filter.state.active])); @@ -540,7 +691,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const out: Record = {}; for (const filter of filters) { if (filter.sourceId) continue; - out[filter.def.parameter] = filter.state.active ? toValueString(filter.state.value) : ''; + out[filter.def.parameter] = filter.state.active ? toParamValue(filter.state.value) : ''; } return out; }; @@ -593,7 +744,12 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa filters: filters.map((filter) => ({ ...filter.state })), layout, activeFilterCount: filters.filter((filter) => filter.state.active).length, - running, updatedAt, diagnostics: presentationDiagnostics, filterDiagnostics, + running, updatedAt, diagnostics: presentationDiagnostics, + // #189: construction-time selection-resolution diagnostics are PERSISTENT + // (never reset by a wave) — concatenated ahead of the per-wave + // `filterDiagnostics` on every publish, rather than merged into that + // mutable array, so nothing a wave does can ever drop them. + filterDiagnostics: [...staticFilterDiagnostics, ...filterDiagnostics], }; } @@ -691,9 +847,9 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa } // A tile is runnable when it has a query and is neither a text panel nor a - // presentation error. - const runnableTiles = (): TileRuntime[] => - tiles.filter((runtime) => runtime.query && !runtime.isText && !runtime.presentationError); + // presentation error — `isRunnableTileRuntime`, the same predicate + // `executableTileIds` (#189) is built from. + const runnableTiles = (): TileRuntime[] => tiles.filter(isRunnableTileRuntime); // ── Filter wave ───────────────────────────────────────────────────────── @@ -922,8 +1078,31 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // `affectedByFilterWave` — no separate union step is needed for the FULL // wave; a selective (#360) caller still folds `changed` into its own // affected-panel wave via the returned array below. + // + // #189: a scalar reconciliation only EVER pushes a name onto `changed` + // alongside deactivating it — `merged.active[parameter]` is always + // `false` there, so reading it (rather than hardcoding `false`) is + // behaviorally identical for scalars. An ARRAY reconciliation's PARTIAL + // narrowing (some, not all, selected values survive) also pushes onto + // `changed` — to join the affected-panel wave — while the filter STAYS + // active with its narrowed value; hardcoding `false` here would + // incorrectly deactivate it, so this reads the merge's own decided + // `active` state instead. for (const parameter of merged.changed) { - for (const filter of filters) if (filter.def.parameter === parameter) filter.state.active = false; + for (const filter of filters) if (filter.def.parameter === parameter) filter.state.active = merged.active[parameter]; + } + // #189: an array-typed (multiselect) filter's reconciled value — reordered + // to the fresh canonical option order, or narrowed to the surviving + // subset — comes back via `merged.values` (`mergeDashboardFilterHelpers` + // owns the reconciliation DECISION; this just applies its result). Guarded + // on the CURRENT value already being an array before ever reading + // `merged.values`, so a scalar filter's committed value (a string/number) + // is never touched here — `mergeDashboardFilterHelpers`'s scalar + // reconciliation branch never rewrites `values` at all, only `active`. + for (const filter of filters) { + if (!Array.isArray(filter.state.value)) continue; + const updated = merged.values[filter.def.parameter]; + if (Array.isArray(updated)) filter.state.value = updated; } return { status: 'applied', flipped: merged.changed }; } @@ -1106,11 +1285,21 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // generations and issue requests after teardown. if (destroyed) return; if (!preflighted && !(await preflight())) return; + // #189: consult each parameter's RESOLVED targets (explicit `def.targets` + // else declared-in tiles, `targetsByParameter` — built once at + // construction, over EVERY filter definition, from the same + // `resolveFilterTargets` `affectedByFilterWave` uses) rather than blindly + // rerunning every tile that merely declares the parameter name. Every + // parameter this function is ever called with belongs to some existing + // filter definition (it always originates from a committed `filter.def. + // parameter` or a reconciliation's `merged.changed`, itself gated on + // that same filter set being active) — so `targetsByParameter` always has + // an entry, possibly an EMPTY one (an explicit `targets: []` affecting + // nothing); the `?? []` is a cheap defensive guard only, never expected + // to be exercised. const affectedIds = new Set(); for (const parameter of parameters) { - const field = analysis.fields[parameter]; - if (!field) continue; - for (const sourceId of field.requiredIn.concat(field.optionalIn)) affectedIds.add(sourceId); + for (const id of targetsByParameter.get(parameter) ?? []) affectedIds.add(id); } const targets = runnableTiles().filter((runtime) => affectedIds.has(runtime.tile.id)); const generations = new Map(targets.map((runtime) => [runtime.tile.id, supersede(runtime)])); @@ -1157,8 +1346,12 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa if (destroyed) return; const filter = filterById.get(filterId); if (!filter) return; - filter.state.value = value; - filter.state.active = value != null && value !== ''; + // #189: `copyValue` defends against aliasing the caller's own array; + // a non-empty array counts as a value (active) the same way any + // non-empty/non-null scalar does — an EMPTY array reads like `''`. + const stored = copyValue(value); + filter.state.value = stored; + filter.state.active = Array.isArray(stored) ? stored.length > 0 : stored != null && stored !== ''; publish(); await commitAndRerun([filter.def.parameter]); } @@ -1169,7 +1362,8 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa if (!filter) return; // The filter bar owns activation for optional/curated fields, so value and // active are set independently (unlike setFilter's value-implies-active). - filter.state.value = value; + // #189: `copyValue` defends against aliasing the caller's own array. + filter.state.value = copyValue(value); filter.state.active = active; publish(); await commitAndRerun([filter.def.parameter]); @@ -1190,8 +1384,15 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const changed: string[] = []; for (const filter of filters) { const nextActive = filter.def.defaultActive ?? false; - const nextValue = filter.def.defaultValue ?? ''; - if (filter.state.active !== nextActive || filter.state.value !== nextValue) changed.push(filter.def.parameter); + // #189: `copyValue` defends the default against aliasing (a + // `defaultValue` array literal on the document); `sameSelection` + // (filter-selection.ts) compares STRUCTURALLY so an array value/default + // never falls through the old `!==` reference check into a spurious + // "changed" on every reset. + const nextValue = copyValue(filter.def.defaultValue ?? ''); + if (filter.state.active !== nextActive || !sameSelection(filter.state.value, nextValue)) { + changed.push(filter.def.parameter); + } filter.state.active = nextActive; filter.state.value = nextValue; } diff --git a/tests/unit/dashboard-filters.test.ts b/tests/unit/dashboard-filters.test.ts index 7be038c2..6935cde8 100644 --- a/tests/unit/dashboard-filters.test.ts +++ b/tests/unit/dashboard-filters.test.ts @@ -71,6 +71,63 @@ describe('Dashboard Filter helper merge', () => { expect(out.fields).toEqual({}); expect(out.diagnostics.map((d) => d.code)).toEqual(['source-info', 'filter-helper-unused']); }); + // #189: a multiselect (Array-contract) filter's committed value is a real + // string array — `mergeDashboardFilterHelpers` must reconcile it via + // `reconcileSelection` (filter-selection.ts), never the scalar + // `String(...)` comparison above (which would stringify an array and never + // match any option). + describe('array (#189 multiselect) reconciliation', () => { + it('non-empty intersection: stays active, value canonicalizes to fresh option order; a pure reorder is NOT a change', () => { + const out = mergeDashboardFilterHelpers({ + providers: [provider('p', 'P', [helper('region', [ + { value: 'east', label: 'East' }, { value: 'west', label: 'West' }, + ])])], + controls: [{ name: 'region', type: 'Array(String)', optional: true }], + values: { region: ['west', 'east'] }, active: { region: true }, + }); + // Every committed value still present — reorders to the FRESH option + // order, but that alone is not a change needing a rerun. + expect(out.values.region).toEqual(['east', 'west']); + expect(out.active.region).toBe(true); + expect(out.changed).toEqual([]); + }); + + it('partial removal: narrows to survivors, stays active, and IS a change (joins the affected-panel wave)', () => { + const out = mergeDashboardFilterHelpers({ + providers: [provider('p', 'P', [helper('region', [{ value: 'east', label: 'East' }])])], + controls: [{ name: 'region', type: 'Array(String)', optional: true }], + values: { region: ['east', 'west'] }, active: { region: true }, + }); + expect(out.values.region).toEqual(['east']); + expect(out.active.region).toBe(true); + expect(out.changed).toEqual(['region']); + }); + + it('empty intersection: deactivates but keeps the dormant committed array untouched (reactivation policy)', () => { + const out = mergeDashboardFilterHelpers({ + providers: [provider('p', 'P', [helper('region', [{ value: 'north', label: 'North' }])])], + controls: [{ name: 'region', type: 'Array(String)', optional: true }], + values: { region: ['east', 'west'] }, active: { region: true }, + }); + expect(out.active.region).toBe(false); + // The ORIGINAL committed array is retained verbatim — never emptied or + // canonicalized — so reactivation restores exactly what was selected. + expect(out.values.region).toEqual(['east', 'west']); + expect(out.changed).toEqual(['region']); + }); + + it('an inactive array-valued field is never reconciled (same as today\'s scalar dormant-value policy)', () => { + const out = mergeDashboardFilterHelpers({ + providers: [provider('p', 'P', [helper('region', [{ value: 'north', label: 'North' }])])], + controls: [{ name: 'region', type: 'Array(String)', optional: true }], + values: { region: ['east', 'west'] }, active: { region: false }, + }); + expect(out.values.region).toEqual(['east', 'west']); + expect(out.active.region).toBe(false); + expect(out.changed).toEqual([]); + }); + }); + it('uses a source id in duplicate diagnostics and keeps already-valid active selections', () => { const out = mergeDashboardFilterHelpers({ providers: [provider('a', '', [helper('x', [{ value: '1', label: 'One' }])]), provider('b', null, [helper('x', [])])], diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 211ca5b4..51ab6b29 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -632,10 +632,18 @@ describe('shared filter-source runtime (#359)', () => { const { exec } = makeExec((sql) => (sql.includes('source') ? new Promise(() => {}) : { columns: [{ name: 'n' }], rows: [[1]] })); const document = sharedDoc( [{ id: 'f1', parameter: 'p1', sourceQueryId: 'src' }, { id: 'f2', parameter: 'p2', sourceQueryId: 'src' }], + // #189: a real consumer per parameter — otherwise both definitions + // have zero executable consumers and the strict fallback strips them + // from `src`'s consumers before it ever runs (this test's whole + // subject). + [tile('ta', 'qa'), tile('tb', 'qb')], ); const session = createDashboardViewerSession(makeDeps({ document, exec, - queries: [query('src', "SELECT ['V'] AS p1, ['W'] AS p2 /* source */", { dashboard: { role: 'filter' } })], + queries: [ + query('qa', 'SELECT {p1:String} AS n'), query('qb', 'SELECT {p2:String} AS n'), + query('src', "SELECT ['V'] AS p1, ['W'] AS p2 /* source */", { dashboard: { role: 'filter' } }), + ], })); // Intentionally not awaited: the source responder never resolves, so // `start()` never settles either — only `destroy()`'s abort matters here. @@ -650,10 +658,16 @@ describe('shared filter-source runtime (#359)', () => { const { exec } = makeExec((sql) => (sql.includes('source') ? { error: 'source down' } : { columns: [{ name: 'n' }], rows: [[1]] })); const document = sharedDoc( [{ id: 'f1', parameter: 'p1', sourceQueryId: 'src' }, { id: 'f2', parameter: 'p2', sourceQueryId: 'src' }], + // #189: a real consumer per parameter keeps `src` alive (see the + // "destroy cancels..." test above for why). + [tile('ta', 'qa'), tile('tb', 'qb')], ); const session = createDashboardViewerSession(makeDeps({ document, exec, - queries: [query('src', 'SELECT 1 /* source */', { dashboard: { role: 'filter' } })], + queries: [ + query('qa', 'SELECT {p1:String} AS n'), query('qb', 'SELECT {p2:String} AS n'), + query('src', 'SELECT 1 /* source */', { dashboard: { role: 'filter' } }), + ], })); await session.start(); const diags = session.state.value.filterDiagnostics; @@ -666,11 +680,24 @@ describe('shared filter-source runtime (#359)', () => { const { exec } = makeExec((sql) => (sql.includes('source') ? { columns: [{ name: 'z', type: 'Array(String)' }], rows: [[['V']]] } : { columns: [{ name: 'n' }], rows: [[1]] })); - // No tile/filter parameter named 'z' — the source's helper column has no consumer. - const document = sharedDoc([{ id: 'f1', parameter: 'unrelated', sourceQueryId: 'src' }], [tile('t', 'qt')]); + // No tile/filter parameter named 'z' — the source's helper column has no + // consumer. #189: 'f1' (parameter 'unrelated') has no consumer either + // and falls back on its own (stripped from `src`'s consumers) — a + // SEPARATE filter 'f2' sharing the same source with a real consumer + // ('p1') keeps `src` running so 'z' still surfaces as unused. + const document = sharedDoc( + [ + { id: 'f1', parameter: 'unrelated', sourceQueryId: 'src' }, + { id: 'f2', parameter: 'p1', sourceQueryId: 'src' }, + ], + [tile('t', 'qt'), tile('t2', 'q2')], + ); const session = createDashboardViewerSession(makeDeps({ document, exec, - queries: [query('qt', 'SELECT 1 AS n'), query('src', 'SELECT 1 /* source */', { dashboard: { role: 'filter' } })], + queries: [ + query('qt', 'SELECT 1 AS n'), query('q2', 'SELECT {p1:String} AS n'), + query('src', 'SELECT 1 /* source */', { dashboard: { role: 'filter' } }), + ], })); await session.start(); const warn = session.state.value.filterDiagnostics.find((d) => d.code === 'filter-helper-unused'); @@ -1097,7 +1124,11 @@ describe('parameterized Filter sources (#360)', () => { ? { columns: [{ name: 'region', type: 'Array(String)' }], rows: [[['east', 'west']]] } : { columns: [{ name: 'n' }], rows: [[1]] })); const document = doc({ - tiles: [tile('t', 'qt')], + // #189: 'tr'/'qr' is a real executable consumer of 'region' (a scalar + // declaration) — otherwise `resolveFilterSelection` sees zero + // consumers and the strict fallback strips 'f-region' from `src`'s + // consumers before the wave below ever runs it. + tiles: [tile('t', 'qt'), tile('tr', 'qr')], filters: [ { id: 'from-root', parameter: 'from', defaultActive: false, defaultValue: '' }, { id: 'f-region', parameter: 'region', sourceQueryId: 'src' }, @@ -1106,7 +1137,7 @@ describe('parameterized Filter sources (#360)', () => { const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [ - query('qt', 'SELECT 1 AS n'), + query('qt', 'SELECT 1 AS n'), query('qr', 'SELECT {region:String} AS n'), query('src', "SELECT ['east','west'] AS region FROM t WHERE ts >= {from:String} /* depsrc */", { dashboard: { role: 'filter' } }), ], })); @@ -1206,7 +1237,11 @@ describe('parameterized Filter sources (#360)', () => { return { columns: [{ name: 'n' }], rows: [[1]] }; }); const document = doc({ - tiles: [tile('t', 'qt')], + // #189: real consumers for 'dep1'/'dep2' — otherwise both source-backed + // filters have zero executable consumers and the strict fallback + // strips them from their sources' consumers before this test's wave + // ever runs either source. + tiles: [tile('t', 'qt'), tile('t1', 'qd1'), tile('t2', 'qd2')], filters: [ { id: 'from-root', parameter: 'from', defaultActive: true, defaultValue: 'v0' }, { id: 'cat-root', parameter: 'category', defaultActive: true, defaultValue: 'X' }, @@ -1218,6 +1253,7 @@ describe('parameterized Filter sources (#360)', () => { document, exec, queries: [ query('qt', 'SELECT 1 AS n'), + query('qd1', 'SELECT {dep1:String} AS n'), query('qd2', 'SELECT {dep2:String} AS n'), query('srcFrom', "SELECT ['a'] AS dep1 FROM t WHERE ts >= {from:String} /* srcFrom */", { dashboard: { role: 'filter' } }), query('srcCat', "SELECT ['b'] AS dep2 FROM t WHERE cat = {category:String} /* srcCat */", { dashboard: { role: 'filter' } }), ], @@ -1532,7 +1568,9 @@ describe('parameterized Filter sources (#360)', () => { return { columns: [{ name: 'n' }], rows: [[1]] }; }); const document = doc({ - tiles: [tile('t', 'qt')], + // #189: real consumers for 'dep1'/'dep2' — see the "selective rerun" + // test above for why this is required now. + tiles: [tile('t', 'qt'), tile('t1', 'qd1'), tile('t2', 'qd2')], filters: [ { id: 't-root', parameter: 't', defaultActive: true, defaultValue: '-1h' }, { id: 'f-dep1', parameter: 'dep1', sourceQueryId: 'src1' }, @@ -1545,6 +1583,7 @@ describe('parameterized Filter sources (#360)', () => { document, exec, wallNow, queries: [ query('qt', 'SELECT 1 AS n'), + query('qd1', 'SELECT {dep1:String} AS n'), query('qd2', 'SELECT {dep2:String} AS n'), query('src1', "SELECT ['a'] AS dep1 FROM t WHERE ts >= {t:DateTime} /* src1 */", { dashboard: { role: 'filter' } }), query('src2', "SELECT ['b'] AS dep2 FROM t WHERE ts >= {t:DateTime} /* src2 */", { dashboard: { role: 'filter' } }), ], @@ -1594,8 +1633,15 @@ describe('superseded/destroyed selective-wave guard (#360 review findings 1/2)', { id: 'f-dep', parameter: 'dep', sourceQueryId: 'src', defaultActive: false, defaultValue: '' }, ], }); + // #189: 't'/'qt' also declares 'dep' (inside an optional block, so it stays + // inactive/unfilled without ever forcing a value) — otherwise 'f-dep' has + // zero executable consumers and the strict fallback strips it from `src`'s + // consumers before any of this describe block's waves ever run it. The + // block never activates in these tests, so the tile's EXECUTED sql (and + // every call-count assertion below) is unaffected — only the STRUCTURAL + // analysis `resolveFilterSelection` reads sees the declaration. const depQueries = () => [ - query('qt', 'SELECT {from:String} AS n'), + query('qt', 'SELECT {from:String} AS n /*[ AND {dep:String} = {dep:String} ]*/'), query('src', "SELECT ['x'] AS dep FROM t WHERE ts >= {from:String} /* source */", { dashboard: { role: 'filter' } }), ]; const tileCallCount = (calls: { sql: string }[]) => calls.filter((c) => !c.sql.includes('source')).length; @@ -1689,7 +1735,14 @@ describe('superseded/destroyed selective-wave guard (#360 review findings 1/2)', const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [ - query('q1', 'SELECT {region:String} AS n'), + // #189: 'city' is declared inside an optional block (never activated + // in this test, so t1's EXECUTED sql/call-count is unaffected) — + // otherwise 'f-city' has zero executable consumers and the strict + // fallback strips it from `src`'s consumers before it ever runs, + // which would hollow out this test's whole "unrelated overlapping + // commits" scenario (the source never running at all would still + // pass the assertions below, but for the wrong reason). + query('q1', 'SELECT {region:String} AS n /*[ AND {city:String} = {city:String} ]*/'), query('q2', 'SELECT {status:String} AS n'), query('src', "SELECT ['x'] AS city FROM t WHERE ts >= {region:String} /* source */", { dashboard: { role: 'filter' } }), ], @@ -1748,7 +1801,13 @@ describe('superseded/destroyed selective-wave guard (#360 review findings 1/2)', const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [ - query('qt', 'SELECT {region:String} AS n'), + // #189: 'f-city' explicitly `targets: ['t']`, so 't' must actually + // declare {city:...} for the target to resolve — declared inside an + // optional block (never activated, so 't''s executed sql/call-count + // stays unaffected) — otherwise `resolveFilterSelection` reports + // `filter-selection-target-missing-declaration` and the strict + // fallback strips 'f-city' from `src`'s consumers before it ever runs. + query('qt', 'SELECT {region:String} AS n /*[ AND {city:String} = {city:String} ]*/'), query('src', "SELECT ['x'] AS city FROM t WHERE ts >= {region:String} /* source */", { dashboard: { role: 'filter' } }), ], })); @@ -1803,7 +1862,12 @@ describe('superseded/destroyed selective-wave guard (#360 review findings 1/2)', describe('published sourceId on ViewerFilterState (#360 review finding 4)', () => { it('a source-backed filter publishes sourceId equal to its sourceQueryId; a plain filter leaves it undefined', () => { const document = doc({ - tiles: [tile('t', 'qt')], + // #189: 'ts'/'qs' is a real executable consumer of 'srcp' — otherwise + // `resolveFilterSelection` sees zero consumers and the strict + // fallback strips 'f-src' from `src`'s consumers (and clears + // `state.sourceId`) at construction, which is exactly the topology + // this test asserts. + tiles: [tile('t', 'qt'), tile('ts', 'qs')], filters: [ { id: 'f-plain', parameter: 'plain', defaultActive: false, defaultValue: '' }, { id: 'f-src', parameter: 'srcp', sourceQueryId: 'src' }, @@ -1812,7 +1876,7 @@ describe('published sourceId on ViewerFilterState (#360 review finding 4)', () = const session = createDashboardViewerSession(makeDeps({ document, queries: [ - query('qt', 'SELECT 1 AS n'), + query('qt', 'SELECT 1 AS n'), query('qs', 'SELECT {srcp:String} AS n'), query('src', "SELECT ['x'] AS srcp /* source */", { dashboard: { role: 'filter' } }), ], })); @@ -1822,3 +1886,359 @@ describe('published sourceId on ViewerFilterState (#360 review finding 4)', () = expect(sourced.sourceId).toBe('src'); }); }); + +// #189: searchable multiselect filters — the runtime wiring atop the already- +// landed pure `resolveFilterSelection`/`sameSelection`/`reconcileSelection` +// (core/filter-selection.ts). `resolveFilterSelection`'s own contract is +// strict (its return type's doc comment): the curated helper is exposed IFF +// `diagnostics` is empty. There is no benign carve-out — issue #189's +// fallback list is explicit that "targets that do not declare the +// parameter" and "target-less or non-executable configurations where no +// consumer contract can be resolved" (`filter-selection-no-consumers`, +// `filter-selection-target-not-executable`, +// `filter-selection-target-missing-declaration`) must fall back exactly like +// a genuine type conflict: no `selection` contract, no `sourceId`, the +// filter dropped from its source's `consumers` (so the helper never +// executes), a persistent diagnostic, and every unrelated filter/panel +// unaffected. +describe('searchable multiselect filter contract (#189)', () => { + const byId = (session: ReturnType, id: string) => + session.state.value.filters.find((f) => f.id === id)!; + + it('infers single from a scalar consumer and multiple from an Array(T) consumer', () => { + const document = doc({ + tiles: [tile('t1', 'q1'), tile('t2', 'q2')], + filters: [ + { id: 'fScalar', parameter: 'ps', sourceQueryId: 'srcS' }, + { id: 'fArray', parameter: 'pa', sourceQueryId: 'srcA' }, + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, + queries: [ + query('q1', 'SELECT {ps:String} AS n'), + query('q2', 'SELECT 1 AS n WHERE x IN {pa:Array(String)}'), + query('srcS', "SELECT ['x'] AS ps /* srcS */", { dashboard: { role: 'filter' } }), + query('srcA', "SELECT ['y'] AS pa /* srcA */", { dashboard: { role: 'filter' } }), + ], + })); + expect(byId(session, 'fScalar').selection).toEqual({ mode: 'single', array: false }); + expect(byId(session, 'fScalar').sourceId).toBe('srcS'); + expect(byId(session, 'fArray').selection).toEqual({ mode: 'multiple', array: true }); + expect(byId(session, 'fArray').sourceId).toBe('srcA'); + }); + + it('an explicit selection.mode "single" against an Array(T) consumer stays single (array:true)', () => { + const document = doc({ + tiles: [tile('t1', 'q1')], + filters: [{ id: 'f1', parameter: 'pa', sourceQueryId: 'src', selection: { mode: 'single' } }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, + queries: [ + query('q1', 'SELECT 1 AS n WHERE x IN {pa:Array(String)}'), + query('src', "SELECT ['y'] AS pa /* src */", { dashboard: { role: 'filter' } }), + ], + })); + expect(byId(session, 'f1').selection).toEqual({ mode: 'single', array: true }); + expect(byId(session, 'f1').sourceId).toBe('src'); + }); + + it('an unknown selection.mode string is a HARD conflict: falls back to the string input, source never executes (zero consumers)', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t1', 'q1')], + // `mode: 'bogus'` is deliberately outside the generated literal union — + // `resolveFilterSelection` narrows defensively at runtime (its own doc + // comment), so this exercises that defensive path, cast past the + // schema-derived compile-time type the same way filter-selection.test.ts + // does via its own wider `FilterSelectionFilterDef.selection.mode: string`. + filters: [{ id: 'f1', parameter: 'ps', sourceQueryId: 'src', selection: { mode: 'bogus' as 'single' } }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('q1', 'SELECT {ps:String} AS n'), + query('src', "SELECT ['x'] AS ps /* source */", { dashboard: { role: 'filter' } }), + ], + })); + expect(byId(session, 'f1').sourceId).toBeUndefined(); + expect(byId(session, 'f1').selection).toBeUndefined(); + const diag = session.state.value.filterDiagnostics.find((d) => d.code === 'filter-selection-unknown-mode'); + expect(diag).toMatchObject({ severity: 'error' }); + await session.start(); + // The source has ZERO consumers left (its only filter fell back) — it + // must never execute at all. + expect(calls.some((c) => c.sql.includes('source'))).toBe(false); + // The diagnostic survives the wave's own reset (it is a construction-time + // constant, never touched by `executeFilterSourcePlan`'s per-wave reset). + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-selection-unknown-mode')).toBe(true); + await session.refresh(); + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-selection-unknown-mode')).toBe(true); + }); + + it('selection.mode "multiple" against a scalar consumer is a HARD conflict for BOTH filters sharing a source — the source never runs (zero-consumer-source)', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'p1', type: 'Array(String)' }, { name: 'p2', type: 'Array(String)' }], rows: [[['a'], ['b']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t1', 'q1'), tile('t2', 'q2')], + filters: [ + { id: 'f1', parameter: 'p1', sourceQueryId: 'src', selection: { mode: 'multiple' } }, + { id: 'f2', parameter: 'p2', sourceQueryId: 'src', selection: { mode: 'multiple' } }, + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('q1', 'SELECT {p1:String} AS n'), // scalar consumer + query('q2', 'SELECT {p2:String} AS n'), // scalar consumer + query('src', "SELECT ['a'] AS p1, ['b'] AS p2 /* source */", { dashboard: { role: 'filter' } }), + ], + })); + expect(byId(session, 'f1').sourceId).toBeUndefined(); + expect(byId(session, 'f2').sourceId).toBeUndefined(); + const diags = session.state.value.filterDiagnostics.filter((d) => d.code === 'filter-selection-mode-requires-array'); + expect(diags.length).toBe(2); + expect(diags.some((d) => d.message.includes('f1'))).toBe(true); + expect(diags.some((d) => d.message.includes('f2'))).toBe(true); + await session.start(); + expect(calls.some((c) => c.sql.includes('source'))).toBe(false); + }); + + it('a filter with no wired consumer falls back to the plain string input (#189): no sourceId/selection, source never executes, diagnostic persists across a refresh', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'unused', type: 'Array(String)' }], rows: [[['x']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t', 'qt')], + filters: [{ id: 'f1', parameter: 'unused', sourceQueryId: 'src' }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [query('qt', 'SELECT 1 AS n'), query('src', 'SELECT 1 /* source */', { dashboard: { role: 'filter' } })], + })); + expect(byId(session, 'f1').sourceId).toBeUndefined(); + expect(byId(session, 'f1').selection).toBeUndefined(); + const diag = session.state.value.filterDiagnostics.find((d) => d.code === 'filter-selection-no-consumers'); + expect(diag).toMatchObject({ severity: 'error' }); + await session.start(); + // Zero consumers left on `src` — it must never execute at all. + expect(calls.some((c) => c.sql.includes('source'))).toBe(false); + // The diagnostic survives the wave's own reset (a construction-time + // constant, never touched by `executeFilterSourcePlan`'s per-wave reset). + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-selection-no-consumers')).toBe(true); + await session.refresh(); + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-selection-no-consumers')).toBe(true); + }); + + it('an explicit target that does not declare the parameter falls back to the plain string input (#189): no sourceId/selection, source never executes', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'p', type: 'Array(String)' }], rows: [[['x']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t1', 'q1')], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src', targets: ['t1'] }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('q1', 'SELECT 1 AS n'), // 't1' does NOT declare {p:...} + query('src', "SELECT ['x'] AS p /* source */", { dashboard: { role: 'filter' } }), + ], + })); + expect(byId(session, 'f1').sourceId).toBeUndefined(); + expect(byId(session, 'f1').selection).toBeUndefined(); + const diag = session.state.value.filterDiagnostics.find((d) => d.code === 'filter-selection-target-missing-declaration'); + expect(diag).toMatchObject({ severity: 'error' }); + await session.start(); + expect(calls.some((c) => c.sql.includes('source'))).toBe(false); // zero-consumer source never executes + }); + + it('a fallback filter (no wired consumer) leaves unrelated filters and panels fully functional', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'unused', type: 'Array(String)' }], rows: [[['x']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t1', 'q1'), tile('t2', 'q2')], + filters: [ + { id: 'f-bad', parameter: 'unused', sourceQueryId: 'src' }, // no wired consumer — falls back + { id: 'f-ok', parameter: 'ok', defaultActive: true, defaultValue: 'v0' }, // plain, unrelated + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('q1', 'SELECT 1 AS n'), + query('q2', 'SELECT {ok:String} AS n'), + query('src', 'SELECT 1 /* source */', { dashboard: { role: 'filter' } }), + ], + })); + await session.start(); + expect(calls.some((c) => c.sql.includes('source'))).toBe(false); // f-bad's source never ran + // The unrelated, healthy filter's own tile ran normally and can still be + // committed to rerun its own affected panel. + expect(session.state.value.tiles.find((t) => t.tileId === 't2')!.status).toBe('ready'); + const before = calls.length; + await session.setFilter('f-ok', 'v1'); + expect(calls.slice(before).some((c) => c.sql.includes('{ok:String}'))).toBe(true); + }); + + it('committing an array value reaches the pipeline as a real array; an empty array behaves like a missing value; a defensive copy is stored', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'region', type: 'Array(String)' }], rows: [[['east', 'west']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t', 'qt')], + filters: [{ id: 'f-region', parameter: 'region', sourceQueryId: 'src' }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('qt', 'SELECT 1 AS n WHERE x IN {region:Array(String)}'), + query('src', "SELECT ['east','west'] AS region /* source */", { dashboard: { role: 'filter' } }), + ], + })); + await session.start(); + const arr = ['east', 'west']; + const base = calls.length; + await session.setFilter('f-region', arr); + arr.push('MUTATED'); // the session must never alias the caller's own array + expect(byId(session, 'f-region').value).toEqual(['east', 'west']); + expect(byId(session, 'f-region').active).toBe(true); + const boundCall = calls.slice(base).find((c) => 'param_region' in c.params); + expect(boundCall).toBeDefined(); + expect(boundCall!.params.param_region).toBe("['east','west']"); // a REAL array serialized, never a stringified value + // An active EMPTY array behaves like '' (missing) for execution purposes. + await session.setFilter('f-region', []); + expect(byId(session, 'f-region').active).toBe(false); + expect(byId(session, 'f-region').value).toEqual([]); + const afterEmpty = calls.slice(base); + expect(afterEmpty.some((c) => 'param_region' in c.params && Array.isArray(undefined))).toBe(false); + }); + + it('targeted wave: explicit targets rerun only their target tiles; two filters sharing one parameter union their targets', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('a', 'qa'), tile('b', 'qb'), tile('c', 'qc')], + filters: [ + { id: 'f1', parameter: 'shared', targets: ['a'], defaultActive: false, defaultValue: '' }, + { id: 'f2', parameter: 'shared', targets: ['b'], defaultActive: false, defaultValue: '' }, + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('qa', 'SELECT {shared:String} AS n'), + query('qb', 'SELECT {shared:String} AS n'), + query('qc', 'SELECT {shared:String} AS n'), + ], + })); + await session.start(); + const base = calls.length; + // `rawValues()`/`activeMap()` key by PARAMETER (pre-existing, #189- + // unrelated behavior): with two filter definitions sharing one parameter, + // the LAST one in filter order supplies the actually-bound value — commit + // through 'f2' (the later definition) so the tile sees a real, active + // value rather than the other definition's still-inactive default. + await session.setFilter('f2', 'X'); + const added = calls.slice(base); + // Both 'a' (f1's own target) and 'b' (f2's target — SAME parameter, union) + // rerun; 'c' declares {shared} too but is targeted by NEITHER filter. + expect(added.length).toBe(2); + }); + + it('option-refresh reconciliation via a selective (#360) rerun: intersection narrows + joins the SAME wave, a pure reorder fires none, an empty intersection deactivates and keeps the dormant array', async () => { + // A ROOT dependency ('from') the shared source depends on (#360) drives a + // SELECTIVE rerun (`runFilterSourceWave` → `runAffectedWave`), which gates + // its affected-panel wave on `merged.changed`/`flipped` — unlike a full + // `session.refresh()`, which unconditionally reruns every #235 "affected" + // tile regardless of whether reconciliation actually changed anything. + // This is the same harness shape as the existing #360 + // "reconciliation deactivation ... runs in the SAME wave" test above. + let options = ['east', 'west', 'south']; + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'region', type: 'Array(String)' }], rows: [[options]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t', 'qt')], + filters: [ + { id: 'from-root', parameter: 'from', defaultActive: true, defaultValue: 'v0' }, + { id: 'f-region', parameter: 'region', sourceQueryId: 'src', defaultActive: true, defaultValue: ['east', 'west'] }, + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('qt', 'SELECT 1 AS n WHERE x IN {region:Array(String)}'), + query('src', "SELECT ['e'] AS region FROM t WHERE ts >= {from:String} /* source */", { dashboard: { role: 'filter' } }), + ], + })); + await session.start(); + expect(byId(session, 'f-region').value).toEqual(['east', 'west']); + + // Pure reorder/label refresh (same SET, new order) — value canonicalizes + // to the fresh order but is NOT a change (no additional tile request). + options = ['west', 'east', 'south']; + let base = calls.length; + await session.setFilter('from-root', 'v1'); + expect(byId(session, 'f-region').value).toEqual(['west', 'east']); + expect(byId(session, 'f-region').active).toBe(true); + expect(calls.slice(base).some((c) => 'param_region' in c.params)).toBe(false); + + // 'east' is dropped from the fresh options — narrows to survivors, stays + // active, and DOES join the affected-panel wave. + options = ['west', 'south']; + base = calls.length; + await session.setFilter('from-root', 'v2'); + expect(byId(session, 'f-region').value).toEqual(['west']); + expect(byId(session, 'f-region').active).toBe(true); + expect(calls.slice(base).some((c) => c.params.param_region === "['west']")).toBe(true); + + // Every surviving value is now gone too — deactivates but KEEPS the + // dormant committed array untouched (reactivation restores it). + options = ['north']; + await session.setFilter('from-root', 'v3'); + expect(byId(session, 'f-region').active).toBe(false); + expect(byId(session, 'f-region').value).toEqual(['west']); + }); + + it('clearAllFilters compares an array value/default STRUCTURALLY (sameSelection) — a no-op reset issues no wave, a real change issues exactly one', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t', 'qt')], + filters: [{ id: 'f1', parameter: 'p', defaultActive: true, defaultValue: ['a', 'b'] }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('qt', 'SELECT 1 AS n WHERE x IN {p:Array(String)}')], + })); + await session.start(); + // A fresh session's state.value is ALREADY a (copied) equal-content array + // to the default — a reference-based `!==` check would spuriously see + // this as "changed" on every call; `sameSelection` must not. + const base = calls.length; + await session.clearAllFilters(); + expect(calls.length).toBe(base); // no-op: nothing actually changed + expect(byId(session, 'f1').value).toEqual(['a', 'b']); + + await session.setFilter('f1', ['a']); + const base2 = calls.length; + await session.clearAllFilters(); + expect(calls.length).toBeGreaterThan(base2); // a genuine change fires exactly one wave + expect(byId(session, 'f1').value).toEqual(['a', 'b']); + expect(byId(session, 'f1').active).toBe(true); + }); + + it('initialFilters seeds an array value from the widened per-dashboard store, defensively copied', () => { + const document = doc({ + filters: [{ id: 'f1', parameter: 'p', defaultValue: '', defaultActive: false }], + }); + const seedArray = ['x', 'y']; + const session = createDashboardViewerSession(makeDeps({ + document, initialFilters: { f1: { value: seedArray, active: true } }, + })); + seedArray.push('MUTATED'); + expect(byId(session, 'f1').value).toEqual(['x', 'y']); + expect(byId(session, 'f1').active).toBe(true); + }); +}); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 0c5ae20a..a1d31a9c 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -2550,25 +2550,42 @@ describe('renderDashboard — filter-source runtime rebuild + diagnostics (#359) const { app } = dashApp({ responder: (sql) => { if (sql.includes('optsinfo')) return { columns: [{ name: 'pinfo', type: 'Array(String)' }], rows: [[['x', 'x']]] }; - if (sql.includes('optswarn')) return { columns: [{ name: 'pwarn', type: 'Array(String)' }], rows: [[['a', 'b']]] }; + if (sql.includes('optswarn')) { + return { + columns: [{ name: 'pwarn2', type: 'Array(String)' }, { name: 'pwarn', type: 'Array(String)' }], + rows: [[['a', 'b'], ['c', 'd']]], + }; + } return {}; }, workspace: wsWith({ queries: [ - q('q1', 'SELECT k, v FROM a WHERE x = {pinfo:String}'), + // #189: every source-backed filter below needs a real EXECUTABLE + // consumer declaring its own parameter (a scalar type) — otherwise + // `resolveFilterSelection` sees zero consumers and the strict + // fallback strips it from its source's `consumers` before the + // source ever runs, at construction (never a benign carve-out + // anymore — see `dashboard-viewer-session.ts`'s + // `resolveFilterSelection` wiring). `t1` declares all three so + // every one of `ferr`/`fwarn`/`finfo`'s sources still executes. + q('q1', 'SELECT k, v FROM a WHERE x = {pinfo:String} AND w = {pwarn2:String} AND z = {perr:String}'), // A duplicate option value ('x' twice) → an 'info' diagnostic // (`filter-duplicate-option`) from readFilterOptions. q('srcInfo', "SELECT ['x','x'] AS pinfo -- optsinfo", { dashboard: { role: 'filter' } }), - // 'pwarn' has no Panel consumer → a 'warning' diagnostic + // 'pwarn2' is the REAL, consumed filter parameter (keeps this + // shared source alive); 'pwarn' is an extra returned column no + // filter definition even names — a genuinely-unmatched helper + // column (not a no-consumer filter — #189 would have stripped + // that before the source ever ran) → a 'warning' diagnostic // (`filter-helper-unused`) from the merge. - q('srcWarn', "SELECT ['a','b'] AS pwarn -- optswarn", { dashboard: { role: 'filter' } }), + q('srcWarn', "SELECT ['a','b'] AS pwarn2, ['c','d'] AS pwarn -- optswarn", { dashboard: { role: 'filter' } }), ], tiles: [{ id: 't1', queryId: 'q1' }], filters: [ // An unresolvable source query id → an 'error' diagnostic // (`filter-source-missing`). { id: 'ferr', parameter: 'perr', sourceQueryId: 'nope' }, - { id: 'fwarn', parameter: 'pwarn', sourceQueryId: 'srcWarn' }, + { id: 'fwarn', parameter: 'pwarn2', sourceQueryId: 'srcWarn' }, { id: 'finfo', parameter: 'pinfo', sourceQueryId: 'srcInfo' }, ], }), From ea17ab8e1406292d73dcd0d02dc3c908c2f52ee5 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 21 Jul 2026 21:18:33 +0000 Subject: [PATCH 04/10] feat(#189): dedicated searchable-multiselect filter control buildMultiSelectField: trigger (All/Not set/label/N selected/loading/ waiting states) + role=dialog popover with labeled search, tri-state Select-visible scoped to the filtered subset, checkbox options, Clear/ Cancel/Apply. Draft Set stays local until Apply, which canonicalizes via core filter-selection helpers and commits at most once (no-op closes silently); every dismissal path discards the draft and returns focus to the trigger. Error statuses swap in an enabled plain text input per #189's failure-fallback rule instead of a bricked disabled control. Co-Authored-By: Claude Fable 5 --- src/styles.css | 58 +++ src/ui/multi-select-field.ts | 351 ++++++++++++++++++ tests/unit/multi-select-field.test.ts | 508 ++++++++++++++++++++++++++ 3 files changed, 917 insertions(+) create mode 100644 src/ui/multi-select-field.ts create mode 100644 tests/unit/multi-select-field.test.ts diff --git a/src/styles.css b/src/styles.css index 38ac12b4..9aadb8ad 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1542,6 +1542,64 @@ body.detached-tab .graph-overlay-panel { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +/* Multiselect Dashboard Filter control (#189, multi-select-field.ts) — a + dedicated dialog popover rather than the single-select combobox + (.var-combo/.var-combo-list) forced into multiselect ARIA roles. The + trigger reuses .var-input's sizing/border so it sits flush with every + other filter field; the popover is its own `position:fixed` panel, same + escape-the-scrolling-strip trick as .var-combo-list/.file-menu. */ +.ms-field { grid-column: 2; display: inline-flex; } +.ms-trigger { + display: inline-flex; align-items: center; + width: calc(var(--var-input-ch, 16) * 1ch); max-width: 220px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-align: left; cursor: pointer; +} +.ms-trigger:hover:not(:disabled) { border-color: var(--accent); } +.ms-trigger:disabled { cursor: default; } +.ms-trigger.is-waiting { border-style: dashed; } +.ms-trigger.is-stale { opacity: 0.55; font-style: italic; } +.ms-field .var-input.is-error { width: calc(var(--var-input-ch, 16) * 1ch); } +.ms-popover { + position: fixed; z-index: 70; width: 260px; max-width: calc(100vw - 24px); + display: flex; flex-direction: column; gap: 6px; padding: 8px; + background: var(--bg-panel, var(--bg-editor)); border: 1px solid var(--border); + border-radius: 8px; box-shadow: 0 8px 28px rgba(0,0,0,.4); + font-size: 12px; font-family: var(--mono); +} +.ms-overlay { position: fixed; inset: 0; z-index: 60; } +.ms-search { + height: 24px; padding: 0 8px; background: var(--bg-input); color: var(--fg); + border: 1px solid var(--border); border-radius: 5px; font: inherit; +} +.ms-search:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); +} +.ms-select-all { + display: flex; align-items: center; gap: 6px; cursor: pointer; + padding: 2px 4px; color: var(--fg-mute); font-size: 11px; + border-bottom: 1px solid var(--border-faint); +} +.ms-select-all-cb, .ms-option input[type="checkbox"] { accent-color: var(--accent); flex-shrink: 0; cursor: pointer; } +.ms-options { display: flex; flex-direction: column; max-height: 220px; overflow-y: auto; gap: 1px; } +.ms-option { + display: flex; align-items: center; gap: 6px; padding: 5px 4px; + border-radius: 5px; cursor: pointer; color: var(--fg); +} +.ms-option:hover { background: var(--bg-hover); } +.ms-option[hidden] { display: none; } +.ms-option-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ms-footer { display: flex; justify-content: flex-end; gap: 6px; padding-top: 4px; border-top: 1px solid var(--border-faint); } +.ms-btn { + height: 24px; padding: 0 10px; border-radius: 5px; cursor: pointer; + font: inherit; font-size: 11.5px; + border: 1px solid var(--border); background: transparent; color: var(--fg); +} +.ms-btn:hover { background: var(--bg-hover); } +.ms-btn-clear { margin-right: auto; } +.ms-btn-primary { border: none; background: var(--accent); color: #fff; font-weight: 600; } +.ms-btn-primary:hover { filter: brightness(1.08); } .run-btn { height: 26px; padding: 0 12px 0 10px; diff --git a/src/ui/multi-select-field.ts b/src/ui/multi-select-field.ts new file mode 100644 index 00000000..c080c76f --- /dev/null +++ b/src/ui/multi-select-field.ts @@ -0,0 +1,351 @@ +// #189: a dedicated multiselect Dashboard Filter control — a full ARIA +// `dialog` popover (search + tri-state "select visible" + a native-labeled +// checklist + Clear/Cancel/Apply) rather than forcing the single-select +// combobox primitive (combobox.ts) into multiselect semantics it was never +// built for. This module borrows conventions from TWO existing primitives +// rather than inventing new ones: +// - `menu.ts`'s `openMenu` is the model for the popover lifecycle: mount a +// fresh overlay + panel on open, tear both down completely on close +// (never a hidden-but-resident node), Escape closes and refocuses the +// trigger, and `fixedAnchor` places the panel under the trigger. +// - `filter-bar.ts`'s `applyFieldStatus` is the model for the status +// vocabulary (idle/loading/ready/waiting/source-error/helper-error/ +// missing-helper, `stale`/`waitingFor`) and its is-waiting/is-error/ +// is-stale class precedence. +// The error-mode plain-text fallback follows the same policy #360 already +// established for the single-select curated field: a helper-query failure +// must never discard (or overwrite, mid-failure) a committed value, and the +// stale options must never be presented as authoritative. +// +// State ownership: the COMMITTED `value`/`active`/`options` are frozen at +// construction — a caller that wants a later committed-value/options change +// reflected calls `buildMultiSelectField` again (the same convention +// `buildFilterBar` uses for its own curated fields: a value/options change +// rebuilds the field, only a STATUS-only change patches in place). Only +// `status` mutates in place via `updateStatus`, because a status flip must +// never disturb an in-progress edit — on this field (an open popover's +// draft, or an in-progress error-mode edit) or any sibling field in a shared +// bar. +// +// The OPEN popover owns its own draft `Set` (a copy of `value` taken +// at open time) plus its own search/select-visible/option-row DOM and +// listeners, all local to `openPopover()` — none of it survives past the +// matching `close()`, so there is nothing to leak across repeated opens. + +import { h, fixedAnchor, attachBackdropClose } from './dom.js'; +import { idSafe } from './combobox.js'; +import { canonicalizeSelection, sameSelection } from '../core/filter-selection.js'; + +/** One selectable option — value/label only (no grouping; #189 doesn't need it). */ +export interface MultiSelectOption { + value: string; + label: string; +} + +/** The same status vocabulary `filter-bar.ts`'s `CuratedFieldStatus` carries + * (#360): `status` ∈ idle|loading|ready|waiting|source-error|helper-error| + * missing-helper; `stale`/`waitingFor` are the same affordance fields. */ +export interface MultiSelectFieldStatus { + status?: string; + stale?: boolean; + waitingFor?: string[]; +} + +/** `buildMultiSelectField`'s options bag. */ +export interface MultiSelectFieldOpts { + /** Parameter name — used only for id-safe DOM ids (see `idSafe`), never + * shown to the user (that's `label`'s job). */ + name: string; + /** Filter display label, used in every accessible name this control builds. */ + label: string; + /** Inactive trigger text: 'Not set' when required, 'All' when optional. */ + required?: boolean; + /** Committed selection — may contain values absent from `options` (a + * DORMANT value an options refresh dropped); never mutated by this module. */ + value: readonly string[]; + active: boolean; + options: MultiSelectOption[]; + status?: MultiSelectFieldStatus; + /** Injected document realm — defaults to the ambient global. */ + document?: Document; + onApply(next: string[], active: boolean): void; + /** Error-mode plain-input commit (Enter, or blur after an edit). */ + onFallbackCommit(raw: string, active: boolean): void; +} + +/** `buildMultiSelectField`'s return value. */ +export interface MultiSelectFieldHandle { + el: HTMLElement; + /** In-place status affordance update — never rebuilds the control, so an + * open popover's in-progress draft (or an in-progress error-mode edit) is + * never disturbed by a sibling status change. */ + updateStatus(s: MultiSelectFieldStatus): void; + /** Whether the popover is currently open — an integration caller uses this + * to decide whether a status change needs to announce a refresh-cancel. */ + isOpen(): boolean; + /** Removes this control's own listeners and closes the popover if open (a + * dispose-while-open is a Cancel: no `onApply`/`onFallbackCommit` call). */ + dispose(): void; +} + +// The same isWaiting/isError/isStale precedence `filter-bar.ts`'s +// `applyFieldStatus` uses — 'idle' (never yet run) and 'loading' (mid-flight) +// both read as "pending", same as a superseded `stale: true` read. +function classifyStatus(s: MultiSelectFieldStatus): { isWaiting: boolean; isError: boolean; isStale: boolean } { + const status = s.status ?? 'ready'; + const isWaiting = status === 'waiting'; + const isError = status === 'source-error' || status === 'helper-error' || status === 'missing-helper'; + const isStale = !isWaiting && !isError && (status === 'loading' || status === 'idle' || !!s.stale); + return { isWaiting, isError, isStale }; +} + +export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFieldHandle { + const d = opts.document || document; + const { label, value, active, options } = opts; + const required = !!opts.required; + const suffix = idSafe(opts.name); + + let status: MultiSelectFieldStatus = opts.status || {}; + // Starts false regardless of the initial `status` so the FIRST + // `applyStatus()` call below always treats an initial error status as an + // "entering error" transition (seeding the fallback text from `value`) — + // never as an already-erroring no-op. + let wasError = false; + // The currently-open popover's own close() — non-null iff the popover is + // open (isOpen() reads this directly rather than tracking a second flag). + let closeCurrent: (() => void) | null = null; + + const inactiveText = (): string => (required ? 'Not set' : 'All'); + + const triggerText = (): string => { + const { isWaiting, isStale } = classifyStatus(status); + if (isWaiting) return `Waiting for: ${(status.waitingFor ?? []).join(', ')}`; + if (isStale) return 'Loading options…'; + if (!active || value.length === 0) return inactiveText(); + if (value.length === 1) { + const opt = options.find((o) => o.value === value[0]); + return opt ? opt.label : value[0]; + } + return `${value.length} selected`; + }; + + // The control's root — hosts whichever of trigger/errorInput is current, + // swapped in place (never rebuilt) by applyStatus() below. This IS `el` + // (returned as-is, same convention `buildFilterOptionField`'s `.var-combo` + // wrapper uses: the grid-column:2 sizing anchor and the status-class + // "wrapper" are the same node, not a second nesting level). + const control = h('div', { class: 'ms-field' }); + const trigger = h('button', { + type: 'button', id: 'ms-trigger-' + suffix, class: 'ms-trigger var-input', + 'aria-haspopup': 'dialog', 'aria-expanded': 'false', + }); + const errorInput = h('input', { + type: 'text', id: 'ms-error-' + suffix, class: 'var-input is-error', 'aria-label': label, + }); + let errorEdited = false; + + const commitFallback = (): void => { + opts.onFallbackCommit(errorInput.value, errorInput.value.trim() !== ''); + errorEdited = false; + }; + const onErrorInput = (): void => { errorEdited = true; }; + const onErrorKeyDown = (e: KeyboardEvent): void => { + if (e.key !== 'Enter') return; + e.preventDefault(); + commitFallback(); + }; + const onErrorBlur = (): void => { if (errorEdited) commitFallback(); }; + errorInput.addEventListener('input', onErrorInput); + errorInput.addEventListener('keydown', onErrorKeyDown); + errorInput.addEventListener('blur', onErrorBlur); + + // Applies the CURRENT `status` to the already-built DOM (constructor AND + // `updateStatus` share this — never a rebuild, see the module header). + const applyStatus = (): void => { + const { isWaiting, isError, isStale } = classifyStatus(status); + // A status change into error mid-open cancels the popover outright (its + // anchor, the trigger, is about to be replaced by the fallback input) — + // a Cancel: no onApply call. + if (isError && closeCurrent) closeCurrent(); + + control.classList.remove('is-waiting', 'is-error', 'is-stale'); + if (isWaiting) control.classList.add('is-waiting'); + else if (isError) control.classList.add('is-error'); + else if (isStale) control.classList.add('is-stale'); + + if (isError) { + // Only INITIALIZE the fallback text on the transition into error mode + // — a later `updateStatus` call that's still an error status (e.g. + // 'source-error' → 'helper-error') must never stomp an in-progress + // edit (#360's "don't discard a committed value" policy applies just + // as much to the user's own not-yet-committed typing). + if (!wasError) errorInput.value = value.join(', '); + } else { + trigger.classList.remove('is-waiting', 'is-stale'); + if (isWaiting) trigger.classList.add('is-waiting'); + else if (isStale) trigger.classList.add('is-stale'); + trigger.disabled = isWaiting || isStale; + const text = triggerText(); + trigger.textContent = text; + trigger.title = text; + } + trigger.setAttribute('aria-label', `${label} filter, ${value.length} selected`); + wasError = isError; + + const wanted = isError ? errorInput : trigger; + if (control.firstChild !== wanted) control.replaceChildren(wanted); + }; + + const onTriggerClick = (): void => { if (!trigger.disabled) openPopover(); }; + trigger.addEventListener('click', onTriggerClick); + + // Mount a fresh popover (menu.ts's own lifecycle convention: build on + // open, tear down completely on close — never a hidden-but-resident node). + function openPopover(): void { + if (closeCurrent) return; // already open — never stack a second popover + const draft = new Set(value); + let searchText = ''; + + const liveEl = h('div', { class: 'sr-only ms-live', 'aria-live': 'polite' }); + const searchInput = h('input', { + type: 'text', class: 'ms-search', placeholder: `Search ${label} options`, + 'aria-label': `Search ${label} options`, + }); + const selectAllCb = h('input', { type: 'checkbox', class: 'ms-select-all-cb' }); + const selectAllRow = h('label', { class: 'ms-select-all' }, selectAllCb, h('span', {}, 'Select visible')); + + const rows = options.map((opt) => { + const cb = h('input', { type: 'checkbox', checked: draft.has(opt.value) }); + cb.addEventListener('change', () => { + if (cb.checked) draft.add(opt.value); else draft.delete(opt.value); + syncSelectAll(); + }); + const li = h('label', { class: 'ms-option' }, cb, h('span', { class: 'ms-option-label' }, opt.label)); + return { opt, li, cb }; + }); + const listEl = h('div', { class: 'ms-options' }, ...rows.map((r) => r.li)); + + // Tri-state "select visible": unchecked when no visible row is in the + // draft, checked when every visible row is, indeterminate when some are + // — the accessible label always names the ACTION a click performs next + // (native indeterminate→click sets checked=true, so setting `.checked` + // to `allSelected` here, not `selected > 0`, is what makes a later click + // reliably select — never re-clear — a mixed selection). + function syncSelectAll(): void { + const visibleRows = rows.filter((r) => !r.li.hidden); + const total = visibleRows.length; + const selected = visibleRows.filter((r) => draft.has(r.opt.value)).length; + const allSelected = total > 0 && selected === total; + const noneSelected = selected === 0; + selectAllCb.checked = allSelected; + selectAllCb.indeterminate = !allSelected && !noneSelected; + selectAllCb.setAttribute('aria-label', + allSelected ? `Clear all ${total} visible options` : `Select all ${total} visible options`); + } + // Local case-insensitive substring filter over label+value — hidden + // (filtered-out) rows are never touched by select-visible/Clear below. + function applyFilter(): void { + const q = searchText.trim().toLowerCase(); + let visible = 0; + for (const row of rows) { + const match = !q || row.opt.label.toLowerCase().includes(q) || row.opt.value.toLowerCase().includes(q); + row.li.hidden = !match; + if (match) visible++; + } + liveEl.textContent = `${visible} of ${rows.length} options`; + syncSelectAll(); + } + searchInput.addEventListener('input', () => { searchText = searchInput.value; applyFilter(); }); + selectAllCb.addEventListener('change', () => { + const checked = selectAllCb.checked; + for (const row of rows) { + if (row.li.hidden) continue; // hidden values are never touched + row.cb.checked = checked; + if (checked) draft.add(row.opt.value); else draft.delete(row.opt.value); + } + syncSelectAll(); + }); + + const clearBtn = h('button', { type: 'button', class: 'ms-btn ms-btn-clear' }, 'Clear'); + const cancelBtn = h('button', { type: 'button', class: 'ms-btn' }, 'Cancel'); + const applyBtn = h('button', { type: 'button', class: 'ms-btn ms-btn-primary' }, 'Apply'); + // Clear empties the WHOLE draft, not just the visible subset. + clearBtn.addEventListener('click', () => { + draft.clear(); + for (const row of rows) row.cb.checked = false; + syncSelectAll(); + }); + cancelBtn.addEventListener('click', () => close()); + applyBtn.addEventListener('click', () => { + const canonical = canonicalizeSelection([...draft], options); + const prevCanonical = canonicalizeSelection(value, options); + const activeNext = canonical.length > 0; + // A no-op Apply (same canonical selection AND same active flag) closes + // silently — `onApply` fires exactly once otherwise. + if (!(sameSelection(canonical, prevCanonical) && activeNext === active)) { + opts.onApply(canonical, activeNext); + } + close(); + }); + const footer = h('div', { class: 'ms-footer' }, clearBtn, cancelBtn, applyBtn); + + const dialog = h('div', { + class: 'ms-popover', role: 'dialog', 'aria-modal': 'true', 'aria-label': `${label} options`, + }, searchInput, liveEl, selectAllRow, listEl, footer); + const overlay = h('div', { class: 'ms-overlay' }); + + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { e.preventDefault(); close(); } + }; + + // EVERY dismissal path (Apply, Cancel, Escape, outside-click, dispose) + // funnels through here — the one place that tears the popover down and + // returns focus to the trigger. Idempotent by construction (every step + // is a harmless no-op on an already-detached/already-null target), so no + // separate re-entrancy guard is needed even if a caller somehow reached + // it twice for the same open session. + function close(): void { + d.removeEventListener('keydown', onKeyDown, true); + detachBackdrop(); + overlay.remove(); + dialog.remove(); + trigger.setAttribute('aria-expanded', 'false'); + closeCurrent = null; + trigger.focus(); + } + closeCurrent = close; + + trigger.setAttribute('aria-expanded', 'true'); + d.body.appendChild(overlay); + d.body.appendChild(dialog); + const detachBackdrop = attachBackdropClose(overlay, close); + d.addEventListener('keydown', onKeyDown, true); + + const rect = trigger.getBoundingClientRect(); + const pos = fixedAnchor(rect) as { top: number; left: number }; + overlay.style.position = 'fixed'; + overlay.style.inset = '0'; + dialog.style.position = 'fixed'; + dialog.style.top = pos.top + 'px'; + dialog.style.left = pos.left + 'px'; + dialog.style.minWidth = rect.width + 'px'; + + applyFilter(); // seeds the live-region count and the select-visible tri-state + searchInput.focus(); // focus moves into the dialog on open + } + + applyStatus(); + + return { + el: control, + isOpen: () => closeCurrent !== null, + updateStatus: (s) => { status = s; applyStatus(); }, + dispose: () => { + closeCurrent?.(); // dispose-while-open is a Cancel: no writes + trigger.removeEventListener('click', onTriggerClick); + errorInput.removeEventListener('input', onErrorInput); + errorInput.removeEventListener('keydown', onErrorKeyDown); + errorInput.removeEventListener('blur', onErrorBlur); + }, + }; +} diff --git a/tests/unit/multi-select-field.test.ts b/tests/unit/multi-select-field.test.ts new file mode 100644 index 00000000..9a22b511 --- /dev/null +++ b/tests/unit/multi-select-field.test.ts @@ -0,0 +1,508 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { buildMultiSelectField } from '../../src/ui/multi-select-field.js'; +import type { MultiSelectFieldOpts, MultiSelectOption } from '../../src/ui/multi-select-field.js'; + +afterEach(() => document.body.replaceChildren()); + +const click = (el: Element): boolean => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); +const key = (target: EventTarget, k: string): boolean => + target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); +const type = (input: HTMLInputElement, text: string): void => { + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); +}; +const setChecked = (cb: HTMLInputElement, val: boolean): void => { + cb.checked = val; + cb.dispatchEvent(new Event('change', { bubbles: true })); +}; + +const OPTIONS: MultiSelectOption[] = [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Bravo' }, + { value: 'c', label: 'Charlie' }, +]; + +function baseOpts(overrides: Partial = {}): MultiSelectFieldOpts { + return { + name: 'carrier', + label: 'Carrier', + value: [], + active: false, + options: OPTIONS, + onApply: vi.fn(), + onFallbackCommit: vi.fn(), + ...overrides, + }; +} + +const triggerEl = (el: HTMLElement): HTMLButtonElement => el.querySelector('.ms-trigger') as HTMLButtonElement; +const errorInputEl = (el: HTMLElement): HTMLInputElement | null => el.querySelector('input.is-error'); +const popover = (): HTMLElement | null => document.body.querySelector('.ms-popover'); +const cancelBtn = (): HTMLElement => document.body.querySelector('.ms-btn:not(.ms-btn-clear):not(.ms-btn-primary)')!; +const applyBtn = (): HTMLElement => document.body.querySelector('.ms-btn-primary')!; +const clearBtn = (): HTMLElement => document.body.querySelector('.ms-btn-clear')!; +const searchInput = (): HTMLInputElement => document.body.querySelector('.ms-search') as HTMLInputElement; +const selectAllCb = (): HTMLInputElement => document.body.querySelector('.ms-select-all-cb') as HTMLInputElement; +const optionCbs = (): HTMLInputElement[] => + [...document.body.querySelectorAll('.ms-option input[type="checkbox"]')] as HTMLInputElement[]; +const optionRows = (): HTMLElement[] => [...document.body.querySelectorAll('.ms-option')] as HTMLElement[]; +const liveText = (): string | null => document.body.querySelector('.ms-live')!.textContent; + +describe('buildMultiSelectField — trigger text + disabled/class states', () => { + it('inactive optional shows "All"; inactive required shows "Not set"', () => { + const h1 = buildMultiSelectField(baseOpts()); + expect(triggerEl(h1.el).textContent).toBe('All'); + const h2 = buildMultiSelectField(baseOpts({ required: true })); + expect(triggerEl(h2.el).textContent).toBe('Not set'); + }); + + it('active but empty selection still reads as the inactive text', () => { + const handle = buildMultiSelectField(baseOpts({ active: true, value: [] })); + expect(triggerEl(handle.el).textContent).toBe('All'); + }); + + it('exactly one selected value shows its option label', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['b'], active: true })); + expect(triggerEl(handle.el).textContent).toBe('Bravo'); + }); + + it('exactly one selected value absent from options falls back to the raw value', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['zz'], active: true })); + expect(triggerEl(handle.el).textContent).toBe('zz'); + }); + + it('an empty-string option value renders its label, not the fallback raw text', () => { + const handle = buildMultiSelectField(baseOpts({ + value: [''], active: true, options: [{ value: '', label: '(blank)' }, ...OPTIONS], + })); + expect(triggerEl(handle.el).textContent).toBe('(blank)'); + }); + + it('more than one selected value shows "N selected"', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a', 'b', 'c'], active: true })); + expect(triggerEl(handle.el).textContent).toBe('3 selected'); + }); + + it('status idle reads as loading and disables the trigger', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'idle' } })); + const t = triggerEl(handle.el); + expect(t.textContent).toBe('Loading options…'); + expect(t.disabled).toBe(true); + expect(t.classList.contains('is-stale')).toBe(true); + }); + + it('status loading reads as loading and disables the trigger', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'loading' } })); + expect(triggerEl(handle.el).textContent).toBe('Loading options…'); + expect(triggerEl(handle.el).disabled).toBe(true); + }); + + it('a bare stale:true flag (status ready) also reads as loading and disables the trigger', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { stale: true } })); + const t = triggerEl(handle.el); + expect(t.textContent).toBe('Loading options…'); + expect(t.disabled).toBe(true); + }); + + it('status waiting shows the waiting note and disables the trigger', () => { + const handle = buildMultiSelectField(baseOpts({ + value: ['a'], active: true, status: { status: 'waiting', waitingFor: ['x', 'y'] }, + })); + const t = triggerEl(handle.el); + expect(t.textContent).toBe('Waiting for: x, y'); + expect(t.disabled).toBe(true); + expect(t.classList.contains('is-waiting')).toBe(true); + expect(handle.el.classList.contains('is-waiting')).toBe(true); + }); + + it('waiting with no waitingFor list still renders (empty join)', () => { + const handle = buildMultiSelectField(baseOpts({ status: { status: 'waiting' } })); + expect(triggerEl(handle.el).textContent).toBe('Waiting for: '); + }); + + it('a ready status with no stale flag is enabled with no status class', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true })); + const t = triggerEl(handle.el); + expect(t.disabled).toBe(false); + expect(t.classList.contains('is-stale')).toBe(false); + expect(t.classList.contains('is-waiting')).toBe(false); + expect(handle.el.classList.contains('is-stale')).toBe(false); + }); + + it('a clicked-but-disabled trigger does not open the popover', () => { + const handle = buildMultiSelectField(baseOpts({ status: { status: 'loading' } })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(popover()).toBeNull(); + expect(handle.isOpen()).toBe(false); + }); + + it('repeating an unchanged status does not rebuild the control', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true })); + const before = triggerEl(handle.el); + handle.updateStatus({}); + expect(triggerEl(handle.el)).toBe(before); // same node, no replaceChildren + }); +}); + +describe('buildMultiSelectField — accessibility', () => { + it('aria-haspopup is dialog and aria-expanded toggles open/close', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + const t = triggerEl(handle.el); + expect(t.getAttribute('aria-haspopup')).toBe('dialog'); + expect(t.getAttribute('aria-expanded')).toBe('false'); + click(t); + expect(t.getAttribute('aria-expanded')).toBe('true'); + click(cancelBtn()); + expect(t.getAttribute('aria-expanded')).toBe('false'); + }); + + it('trigger aria-label names the filter and the selected count', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a', 'b', 'c'], active: true })); + expect(triggerEl(handle.el).getAttribute('aria-label')).toBe('Carrier filter, 3 selected'); + }); + + it('the dialog is named for the filter', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(popover()!.getAttribute('role')).toBe('dialog'); + expect(popover()!.getAttribute('aria-label')).toBe('Carrier options'); + }); + + it('the search input is labeled for the filter', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(searchInput().getAttribute('aria-label')).toBe('Search Carrier options'); + }); + + it('the live region announces the filtered option count on search', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(liveText()).toBe('3 of 3 options'); + type(searchInput(), 'al'); + expect(liveText()).toBe('1 of 3 options'); + }); + + it('select-visible label names the scope and the action it will perform next', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(selectAllCb().getAttribute('aria-label')).toBe('Select all 3 visible options'); + setChecked(selectAllCb(), true); + expect(selectAllCb().getAttribute('aria-label')).toBe('Clear all 3 visible options'); + }); + + it('focus moves into the dialog (the search input) on open', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(document.activeElement).toBe(searchInput()); + }); +}); + +describe('buildMultiSelectField — draft isolation across every dismissal path', () => { + const dismissals: Array<[string, () => void]> = [ + ['Cancel', () => click(cancelBtn())], + ['Escape', () => key(popover()!, 'Escape')], + ['outside click', () => { + const overlay = document.body.querySelector('.ms-overlay')!; + overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }], + ]; + + for (const [name, dismiss] of dismissals) { + it(`${name} discards the draft: no onApply, committed value untouched, focus returns to the trigger`, () => { + const onApply = vi.fn(); + const value = ['a']; + const handle = buildMultiSelectField(baseOpts({ value, active: true, onApply })); + document.body.appendChild(handle.el); + const t = triggerEl(handle.el); + click(t); + setChecked(optionCbs()[1], true); // mutate the draft only (select Bravo too) + dismiss(); + expect(onApply).not.toHaveBeenCalled(); + expect(value).toEqual(['a']); // opts.value never mutated + expect(handle.isOpen()).toBe(false); + expect(popover()).toBeNull(); + expect(document.activeElement).toBe(t); + }); + } + + it('a non-Escape key inside the dialog does not dismiss it', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + key(popover()!, 'a'); + expect(handle.isOpen()).toBe(true); + }); + + it('clicking the trigger while already open does not stack a second popover', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + const t = triggerEl(handle.el); + click(t); + click(t); + expect(document.body.querySelectorAll('.ms-popover').length).toBe(1); + }); + + it('dispose while open is a Cancel: no callbacks, popover removed, click listener detached', () => { + const onApply = vi.fn(); + const value = ['a']; + const handle = buildMultiSelectField(baseOpts({ value, active: true, onApply })); + document.body.appendChild(handle.el); + const t = triggerEl(handle.el); + click(t); + setChecked(optionCbs()[1], true); + handle.dispose(); + expect(onApply).not.toHaveBeenCalled(); + expect(value).toEqual(['a']); + expect(handle.isOpen()).toBe(false); + expect(popover()).toBeNull(); + click(t); // listener removed — this must do nothing + expect(popover()).toBeNull(); + }); + + it('dispose while already closed is a no-op that does not throw, and still detaches the trigger listener', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + const t = triggerEl(handle.el); + expect(() => handle.dispose()).not.toThrow(); + click(t); + expect(popover()).toBeNull(); + }); +}); + +describe('buildMultiSelectField — Apply semantics', () => { + it('Apply with no changes closes silently, without calling onApply', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a', 'b'], active: true, onApply })); + document.body.appendChild(handle.el); + const t = triggerEl(handle.el); + click(t); + click(applyBtn()); + expect(onApply).not.toHaveBeenCalled(); + expect(handle.isOpen()).toBe(false); + expect(document.activeElement).toBe(t); + }); + + it('duplicate values in the committed selection do not defeat the no-op Apply check', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a', 'a', 'b'], active: true, onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + click(applyBtn()); + expect(onApply).not.toHaveBeenCalled(); + }); + + it('Apply commits the canonicalized draft (ordered by option order) when it differs from committed', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + setChecked(optionCbs()[1], true); // add Bravo + click(applyBtn()); + expect(onApply).toHaveBeenCalledWith(['a', 'b'], true); + }); + + it('Clear then Apply commits ([], false) even though the draft started non-empty', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + click(clearBtn()); + click(applyBtn()); + expect(onApply).toHaveBeenCalledWith([], false); + }); + + it('unchecking a previously-selected row removes it from the committed draft', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(optionCbs()[0].checked).toBe(true); // seeded from the committed value + setChecked(optionCbs()[0], false); + click(applyBtn()); + expect(onApply).toHaveBeenCalledWith([], false); + }); + + it('a dormant committed value (absent from options) is dropped by Apply, counting as a change with no edits', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['dormant'], active: true, onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + click(applyBtn()); // no edits at all — the active flag alone flips + expect(onApply).toHaveBeenCalledWith([], false); + }); + + it('an already-empty, inactive field is a true no-op on Apply', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: [], active: false, onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + click(applyBtn()); + expect(onApply).not.toHaveBeenCalled(); + }); +}); + +describe('buildMultiSelectField — search filtering + select-visible tri-state', () => { + it('filters case-insensitively over label AND value', () => { + const handle = buildMultiSelectField(baseOpts({ + options: [{ value: 'a', label: 'Alpha' }, { value: 'zz', label: 'Nothing' }, { value: 'b', label: 'Bravo' }], + })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + type(searchInput(), 'ZZ'); // matches "Nothing" by VALUE, not label + const visible = optionRows().filter((r) => !r.hidden).map((r) => r.querySelector('.ms-option-label')!.textContent); + expect(visible).toEqual(['Nothing']); + }); + + it('select-visible activates from unchecked, and clears from fully-checked', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(selectAllCb().indeterminate).toBe(false); + setChecked(selectAllCb(), true); + expect(optionCbs().every((cb) => cb.checked)).toBe(true); + setChecked(selectAllCb(), false); + expect(optionCbs().every((cb) => !cb.checked)).toBe(true); + }); + + it('a partial selection renders the select-visible checkbox indeterminate', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + setChecked(optionCbs()[0], true); + expect(selectAllCb().checked).toBe(false); + expect(selectAllCb().indeterminate).toBe(true); + }); + + it('a value hidden by search is untouched by select-visible or Clear', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + // Select Bravo + Charlie, then narrow the search to only Alpha. + setChecked(optionCbs()[1], true); + setChecked(optionCbs()[2], true); + type(searchInput(), 'al'); + expect(selectAllCb().getAttribute('aria-label')).toBe('Select all 1 visible options'); + setChecked(selectAllCb(), true); // selects only the visible row (Alpha) + type(searchInput(), ''); // reveal everything again + expect(optionCbs().every((cb) => cb.checked)).toBe(true); // a, b, c all still selected + click(applyBtn()); + expect(onApply).toHaveBeenCalledWith(['a', 'b', 'c'], true); + }); +}); + +describe('buildMultiSelectField — error-mode fallback (#360 policy)', () => { + it('constructing directly with an error status renders the enabled fallback input showing the committed value', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a', 'b'], active: true, status: { status: 'source-error' } })); + document.body.appendChild(handle.el); + const input = errorInputEl(handle.el)!; + expect(input).not.toBeNull(); + expect(input.disabled).toBe(false); + expect(input.value).toBe('a, b'); + expect(triggerEl(handle.el)).toBeNull(); + }); + + it('missing-helper also renders the fallback input', () => { + const handle = buildMultiSelectField(baseOpts({ status: { status: 'missing-helper' } })); + document.body.appendChild(handle.el); + expect(errorInputEl(handle.el)).not.toBeNull(); + }); + + it('Enter commits the fallback value regardless of whether it was edited', () => { + const onFallbackCommit = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'helper-error' }, onFallbackCommit })); + document.body.appendChild(handle.el); + key(errorInputEl(handle.el)!, 'Enter'); + expect(onFallbackCommit).toHaveBeenCalledWith('a', true); + }); + + it('a non-Enter key in the fallback input does not commit', () => { + const onFallbackCommit = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ status: { status: 'source-error' }, onFallbackCommit })); + document.body.appendChild(handle.el); + key(errorInputEl(handle.el)!, 'a'); + expect(onFallbackCommit).not.toHaveBeenCalled(); + }); + + it('blur with no edit does not commit; blur after an edit does', () => { + const onFallbackCommit = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'source-error' }, onFallbackCommit })); + document.body.appendChild(handle.el); + const input = errorInputEl(handle.el)!; + input.dispatchEvent(new Event('blur')); + expect(onFallbackCommit).not.toHaveBeenCalled(); + type(input, 'x, y'); + input.dispatchEvent(new Event('blur')); + expect(onFallbackCommit).toHaveBeenCalledWith('x, y', true); + }); + + it('an Enter commit resets the edited flag so an immediate blur does not double-commit', () => { + const onFallbackCommit = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'source-error' }, onFallbackCommit })); + document.body.appendChild(handle.el); + const input = errorInputEl(handle.el)!; + type(input, 'x'); + key(input, 'Enter'); + input.dispatchEvent(new Event('blur')); + expect(onFallbackCommit).toHaveBeenCalledTimes(1); + }); + + it('a blank fallback commit reports active:false', () => { + const onFallbackCommit = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'source-error' }, onFallbackCommit })); + document.body.appendChild(handle.el); + const input = errorInputEl(handle.el)!; + type(input, ' '); + key(input, 'Enter'); + expect(onFallbackCommit).toHaveBeenCalledWith(' ', false); + }); + + it('a second error status while already erroring does not overwrite an in-progress edit', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'source-error' } })); + document.body.appendChild(handle.el); + const input = errorInputEl(handle.el)!; + type(input, 'typed, text'); + handle.updateStatus({ status: 'helper-error' }); + expect(errorInputEl(handle.el)!.value).toBe('typed, text'); + }); + + it('recovering from error swaps back to the trigger', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'source-error' } })); + document.body.appendChild(handle.el); + handle.updateStatus({ status: 'ready' }); + expect(errorInputEl(handle.el)).toBeNull(); + expect(triggerEl(handle.el)).not.toBeNull(); + }); + + it('an error status arriving while the popover is open cancels it, with no onApply', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(handle.isOpen()).toBe(true); + handle.updateStatus({ status: 'source-error' }); + expect(handle.isOpen()).toBe(false); + expect(onApply).not.toHaveBeenCalled(); + expect(popover()).toBeNull(); + }); +}); + +describe('buildMultiSelectField — isOpen()', () => { + it('reflects the popover open/closed lifecycle', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + expect(handle.isOpen()).toBe(false); + click(triggerEl(handle.el)); + expect(handle.isOpen()).toBe(true); + click(cancelBtn()); + expect(handle.isOpen()).toBe(false); + }); +}); From c1ee59fcaf898fba2257307ff94097bb3ae931c2 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 21 Jul 2026 21:40:18 +0000 Subject: [PATCH 05/10] feat(#189): wire the multiselect control into the dashboard filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildFilterBar picks the curated control from the published selection contract: multiple → buildMultiSelectField (Apply commits arrays through the new onApplyCurated seam), single-on-Array wraps the single-select pick/clear into [value]/[] commits, no contract → pre-#189 behavior. Error statuses leave the curated input enabled (usable free-text fallback) per #189 instead of bricking it. dashboard.ts persists real arrays, JSON-encodes them in the rebuild signature, and announces 'Filter options were refreshed' through a persistent live region when a rebuild cancels an open multiselect popover. Co-Authored-By: Claude Fable 5 --- src/ui/dashboard.ts | 85 ++++++++++++++++++--- src/ui/filter-bar.ts | 124 +++++++++++++++++++++++++++++-- tests/unit/dashboard.test.ts | 64 ++++++++++++++++ tests/unit/filter-bar.test.ts | 135 +++++++++++++++++++++++++++++++++- 4 files changed, 389 insertions(+), 19 deletions(-) diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 89324218..9eb9931f 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -152,6 +152,13 @@ export interface DashboardApp { const valueString = (value: unknown): string => (typeof value === 'string' ? value : value == null ? '' : String(value)); +/** #189: an array-safe stand-in for `valueString`, used ONLY by the filter-bar + * rebuild signature below — an array JSON-encodes (so a committed + * `['a','b']` is distinct from the joined string `"a,b"`, which + * `valueString`'s `String()` fallback would otherwise collapse it to); + * every other value keeps `valueString`'s own coercion, unchanged. */ +const sigValue = (value: unknown): string => (Array.isArray(value) ? JSON.stringify(value) : valueString(value)); + /** #291 review F4: `renderDashboard` can run more than once against the SAME * window — `app.reloadDashboardRoute()` (app.ts) re-invokes it in place after * an import-commit while already on `/dashboard` (file-menu.ts's Import @@ -575,6 +582,13 @@ export async function renderDashboard(app: DashboardApp): Promise { // #294's own retained-count acceptance criterion) — `session.clearAllFilters()` // stays a tested application-level operation with no UI trigger. const filterHost = h('div', { class: 'dash-filter-host' }); + // #189: a PERSISTENT sr-only announcer, a SIBLING of `filterHost` (never a + // child — `filterHost.replaceChildren` below only ever replaces the bar's + // own root) so it survives the very rebuild that fires it: when a rebuild + // disposes an outgoing bar that had a multiselect popover open, the dispose + // silently Cancels that popover (see multi-select-field.ts), and this is + // the only trace of that left for an assistive-tech user. + const filterRefreshLiveEl = h('div', { class: 'sr-only', 'aria-live': 'polite' }); // The draft value/active bag the shared filter bar reads + mutates; re-seeded // from committed filter state on each (re)build. Recents come from the real // app — the viewer never touches AppState. @@ -590,14 +604,22 @@ export async function renderDashboard(app: DashboardApp): Promise { }, wallNow: () => app.wallNow(), }; - let filterBarDispose: (() => void) | null = null; + // #189: the retained bar itself (not just its `dispose`) — `hasOpenMultiSelect` + // is read off it right before a rebuild disposes it (see below). + let currentFilterBar: FilterBarHandle | null = null; // #360: the retained bar's `updateStatus` — a status-only publish (below, // the `barSig`/status-signal split) calls this directly instead of tearing // down and rebuilding the whole bar. let filterBarUpdateStatus: FilterBarHandle['updateStatus'] | null = null; function rebuildFilterBar(sview: DashboardViewState): void { - filterBarDispose?.(); + // #189: ask the OUTGOING bar whether a multiselect popover is open BEFORE + // disposing it — disposing while open is that field's own silent Cancel + // (multi-select-field.ts), so this is the only chance to notice it and + // tell an assistive-tech user their popover just closed out from under + // them (the shared `filterRefreshLiveEl`, never torn down by the rebuild). + const hadOpenMultiSelect = currentFilterBar?.hasOpenMultiSelect() ?? false; + currentFilterBar?.dispose(); const idByParam = new Map(); // #360: curation is gated on TOPOLOGY (`sourceId != null`, set once at // construction from the filter definition's `sourceQueryId`), never on @@ -612,24 +634,54 @@ export async function renderDashboard(app: DashboardApp): Promise { status: ViewerFilterState['status']; stale?: boolean; waitingFor?: string[]; + selection?: ViewerFilterState['selection']; + value?: unknown; + active?: boolean; }> = {}; for (const f of sview.filters) { - draftValues[f.parameter] = valueString(f.value); + // #189: the draft bag (`app.state.varValues`, `Record`) + // cannot hold an array — a MULTISELECT filter never reads it at all + // (stays `''`); a single-select-on-Array-contract filter seeds it with + // the committed array's FIRST element, for display only (its own + // commit bypasses the draft bag entirely — see filter-bar.ts's + // `onApplyCurated`/`wrapsArray`). Every other filter keeps the + // pre-#189 `valueString(f.value)` seed unchanged. + if (f.selection?.mode === 'multiple') { + draftValues[f.parameter] = ''; + } else if (f.selection?.mode === 'single' && f.selection.array) { + const arr = Array.isArray(f.value) ? f.value as string[] : []; + draftValues[f.parameter] = arr.length ? arr[0] : ''; + } else { + draftValues[f.parameter] = valueString(f.value); + } draftActive[f.parameter] = f.active; idByParam.set(f.parameter, f.id); if (f.sourceId != null) { - curatedFields[f.parameter] = { options: f.options ?? [], status: f.status, stale: f.stale, waitingFor: f.waitingFor }; + curatedFields[f.parameter] = { + options: f.options ?? [], status: f.status, stale: f.stale, waitingFor: f.waitingFor, + selection: f.selection, value: f.value, active: f.active, + }; } } const onCommit = (name: string): void => { const id = idByParam.get(name); if (id) session.applyFilter(id, draftValues[name] ?? '', !!draftActive[name]); }; + // #189: the array-committing seam (multiselect Apply, single-on-array + // pick/clear) — bypasses the scalar draft bag entirely, straight to + // `session.applyFilter` with the already-built array value/active. + const onApplyCurated = (name: string, next: string[], active: boolean): void => { + const id = idByParam.get(name); + if (id) session.applyFilter(id, next, active); + }; const getField = (name: string, mode: ValidationMode) => session.getFilterField(name, mode, draftValues, draftActive); - const bar = buildFilterBar(filterBarApp, session.controls, onCommit, getField, { curatedFields, document: doc }); + const bar = buildFilterBar( + filterBarApp, session.controls, onCommit, getField, { curatedFields, document: doc, onApplyCurated }, + ); filterHost.replaceChildren(bar.el); - filterBarDispose = bar.dispose; + currentFilterBar = bar; filterBarUpdateStatus = bar.updateStatus; + if (hadOpenMultiSelect) filterRefreshLiveEl.textContent = 'Filter options were refreshed'; } const filterDiagnosticsHost = h('div', { class: 'dash-filter-diagnostics' }); @@ -1566,9 +1618,23 @@ export async function renderDashboard(app: DashboardApp): Promise { }> => Object.fromEntries(filters.map((f) => [f.parameter, { status: f.status, stale: f.stale, waitingFor: f.waitingFor }])); // #303: the committed-filter bag for a published view, built exactly the way // the persist step below and the seed just under it both need it. + // #189: a committed multiselect/single-on-array value is a REAL string + // array — persisted as one (`DashboardFilterEntry.value: string | string[]`), + // never coerced through `valueString`'s `String()` fallback (which would + // turn `['a','b']` into the literal text `"a,b"`, indistinguishable from an + // actual scalar `"a,b"` value on the next load). Non-string elements are + // dropped defensively, the same posture `dashboard-filter-store.ts`'s own + // `coerceValue` takes when READING this same persisted shape back. const persistBagOf = (filters: readonly ViewerFilterState[]): DashboardFilterBag => { const bag: DashboardFilterBag = {}; - for (const f of filters) bag[f.id] = { value: valueString(f.value), active: f.active }; + for (const f of filters) { + bag[f.id] = { + value: Array.isArray(f.value) + ? (f.value as unknown[]).filter((v): v is string => typeof v === 'string') + : valueString(f.value), + active: f.active, + }; + } return bag; }; // #303: a SEPARATE signature from `barSig` above — that one also flips when @@ -1605,7 +1671,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // own invariant that an unchanged republish never disturbs in-progress // typing. const sig = JSON.stringify(sview.filters.map((f) => - [f.id, f.active, valueString(f.value), f.optionsRev, f.sourceId != null])); + [f.id, f.active, sigValue(f.value), f.optionsRev, f.sourceId != null])); const newStatusSig = statusSigOf(sview.filters); if (sig !== barSig) { barSig = sig; @@ -1661,7 +1727,8 @@ export async function renderDashboard(app: DashboardApp): Promise { } }); - const toolbar = h('div', { class: 'dash-toolbar' + (session.state.value.filters.length ? ' has-filters' : '') }, filterHost); + const toolbar = h('div', { class: 'dash-toolbar' + (session.state.value.filters.length ? ' has-filters' : '') }, + filterHost, filterRefreshLiveEl); // `!`: the dashboard renders only into a mounted page. app.root!.replaceChildren(h('div', { class: 'dash-page' }, diff --git a/src/ui/filter-bar.ts b/src/ui/filter-bar.ts index aecf4acd..3115dd0d 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -23,6 +23,8 @@ import { wireComboInput } from './combobox.js'; import type { ComboField } from './combobox.js'; import { buildFilterOptionField } from './filter-option-field.js'; import type { FilterFieldOption } from './filter-option-field.js'; +import { buildMultiSelectField } from './multi-select-field.js'; +import type { MultiSelectFieldHandle } from './multi-select-field.js'; import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; /** The narrow slice of the real `app` controller this module reads — not the @@ -51,6 +53,19 @@ export interface BuildFilterBarOptions { document?: Document; ariaLabel?: string; curatedFields?: Record; + /** #189: fires when a curated field commits an ARRAY value — a MULTIPLE-mode + * field's Apply, or a single-select-on-`Array(...)`-contract field's pick/ + * clear (wrapped to `[value]`/`[]` at the commit seam, never inside + * `filter-option-field.ts`). The plain `onCommit(name)` seam reads the + * scalar draft bag (`app.state.varValues`, `Record`), which + * cannot hold an array — this is the parallel seam for the two curated + * shapes whose committed value is one. The caller (`dashboard.ts`) wires + * this straight to `session.applyFilter(id, next, active)`. Left + * undefined by a caller that never builds an array-committing curated + * field (an older/simpler fixture, or a bar with no curated fields at + * all) — `curated.selection` is never present then either, so the seam is + * simply never reached. */ + onApplyCurated?(name: string, next: string[], active: boolean): void; } /** #360: `status`/`stale`/`waitingFor` mirror `ViewerFilterState`'s own @@ -75,6 +90,20 @@ export interface CuratedFieldStatus { * otherwise-`unknown` bag above. */ interface CuratedFieldConfig extends CuratedFieldStatus { options: FilterFieldOption[]; + /** #189: the published selection contract (`ViewerFilterState.selection`) + * — absent for the pre-#189 plain scalar single-select curated field. + * `mode: 'multiple'` builds `buildMultiSelectField` instead of the + * combobox-based `buildFilterOptionField`; `mode: 'single'` with + * `array: true` keeps `buildFilterOptionField` but wraps its scalar + * commit into `[value]`/`[]` (see `wrapsArray` below). */ + selection?: { mode: 'single' | 'multiple'; array: boolean }; + /** #189: the committed value/active this filter published — read instead + * of `app.state.varValues`/`filterActive` for the two ARRAY-valued + * curated shapes (that scalar draft bag cannot hold an array); unused for + * the plain scalar single-select shape, which keeps reading the draft bag + * exactly as before. */ + value?: unknown; + active?: boolean; } /** A built curated field's retained handle (#360) — kept in @@ -119,7 +148,16 @@ function applyFieldStatus(handle: CuratedFieldHandle, s: CuratedFieldStatus): vo input.classList.remove('is-waiting', 'is-error', 'is-stale'); label.classList.remove('is-waiting', 'is-error', 'is-stale'); - const disabled = isWaiting || isError || isStale; + // #189: an ERROR status no longer disables the input (only waiting/stale + // do) — a helper-query failure degrades the curated field to an ordinary, + // USABLE free-text-equivalent control (still carrying `.is-error` + its + // tooltip as the affordance) instead of bricking it, matching the policy + // `buildMultiSelectField`'s own error-mode plain-input fallback already + // established (#360/#189 — see that module's header comment): the + // serializer's scalar passthrough makes a typed raw value work even for an + // Array param, so there is no reason to lock the field while its source is + // broken. + const disabled = isWaiting || isStale; input.disabled = disabled; if (disabled) input.setAttribute('aria-disabled', 'true'); else input.removeAttribute('aria-disabled'); @@ -205,6 +243,15 @@ export interface FilterBarHandle { el: HTMLElement; dispose(): void; updateStatus(states: Record): void; + /** #189: true iff any curated MULTISELECT field built by THIS bar instance + * currently has its popover open. The caller (`dashboard.ts`) reads this + * BEFORE disposing an outgoing bar (a rebuild always disposes the old bar + * outright) to decide whether a refresh announcement is owed — disposing + * a multiselect field while its popover is open silently Cancels it (no + * `onApply`, see multi-select-field.ts), so without an announcement the + * user's open popover would simply vanish. Always `false` for a bar that + * built no multiselect field at all (including the empty-`params` bar). */ + hasOpenMultiSelect(): boolean; } /** @@ -238,12 +285,21 @@ export function buildFilterBar( const attrs: Record = { class: 'dash-filters' }; if (options.ariaLabel) { attrs.role = 'group'; attrs['aria-label'] = options.ariaLabel; } if (!params.length) { - return { el: h('div', { ...attrs, style: { display: 'none' } }), dispose: () => {}, updateStatus: () => {} }; + return { + el: h('div', { ...attrs, style: { display: 'none' } }), + dispose: () => {}, updateStatus: () => {}, hasOpenMultiSelect: () => false, + }; } const timerClears: Array<() => void> = []; - // #360: every curated field's retained handle, keyed by - // parameter — see `CuratedFieldHandle` and `FilterBarHandle.updateStatus`. + // #360: every curated (scalar single-select) field's retained handle, + // keyed by parameter — see `CuratedFieldHandle` and + // `FilterBarHandle.updateStatus`. const curatedHandles = new Map(); + // #189: every curated MULTISELECT field's own handle, keyed by parameter — + // a separate map (its `updateStatus`/`isOpen`/`dispose` are its own, not + // `CuratedFieldHandle`'s DOM-patching recipe) that `updateStatus`/`dispose`/ + // `hasOpenMultiSelect` below all fold in alongside `curatedHandles`. + const multiSelectFields = new Map(); const el = h('div', attrs, ...params.map((p) => { let timer: ReturnType | null = null; timerClears.push(() => { if (timer != null) clearTimeout(timer); timer = null; }); @@ -259,6 +315,42 @@ export function buildFilterBar( + (conflictNote ? ' — ' + conflictNote : ''); const curated = options.curatedFields?.[p.name] as CuratedFieldConfig | undefined; if (curated) { + // #189: a MULTIPLE-mode curated field is an entirely different control + // (`buildMultiSelectField`, not the combobox-based + // `buildFilterOptionField`) — built and returned here directly. Every + // OTHER curated shape (no `selection` contract at all — the pre-#189 + // default — or `mode: 'single'`) falls through to the existing + // single-select field below unchanged. + if (curated.selection?.mode === 'multiple') { + const committed = Array.isArray(curated.value) ? curated.value as string[] : []; + const msField = buildMultiSelectField({ + document, name: p.name, label: p.name, required: !p.optional, + value: committed, active: !!curated.active, options: curated.options, + status: { status: curated.status, stale: curated.stale, waitingFor: curated.waitingFor }, + onApply: (next, active) => options.onApplyCurated?.(p.name, next, active), + // Error-mode plain-input fallback (#189, same posture #360 already + // established for the single-select curated field): writes straight + // through the SAME plain-commit seam a non-curated field uses, so a + // helper-query failure degrades to an ordinary, usable free-text + // input rather than losing the field. + onFallbackCommit: (raw, active) => { + app.state.varValues[p.name] = raw; + app.state.filterActive[p.name] = active; + app.params.saveVarValues(); + app.params.saveFilterActive(); + onCommit(p.name); + }, + }); + multiSelectFields.set(p.name, msField); + return h('label', { class: 'var-field is-curated' + (p.optional ? ' is-optional' : '') }, + h('span', { class: 'var-name' }, p.name), msField.el); + } + // #189: a single-select curated field over an Array(...) consumer + // contract (`selection.array === true`, effective `mode: 'single'`) + // stays this SAME combobox control — it just commits a WRAPPED + // `[value]`/`[]` instead of a bare scalar (the wrap lives at this + // commit seam, never inside filter-option-field.ts itself). + const wrapsArray = curated.selection?.mode === 'single' && curated.selection.array === true; const field = buildFilterOptionField({ document, name: p.name, options: curated.options, value: app.state.varValues[p.name] ?? '', active: !!app.state.filterActive[p.name], @@ -269,7 +361,10 @@ export function buildFilterBar( app.params.saveVarValues(); app.params.saveFilterActive(); }, - onCommit: () => onCommit(p.name), + onCommit: (value, active) => { + if (wrapsArray) options.onApplyCurated?.(p.name, active ? [value] : [], active); + else onCommit(p.name); + }, }); // #345: a curated field is always the 'enum' width band (short option // labels) regardless of the declared param type behind it. @@ -368,12 +463,29 @@ export function buildFilterBar( })); return { el, - dispose: () => timerClears.forEach((clear) => clear()), + dispose: () => { + timerClears.forEach((clear) => clear()); + // Disposing a multiselect field WHILE its popover is open is that + // field's own Cancel (no `onApply` call, see multi-select-field.ts) — + // a bar rebuild/teardown always tears every open popover down this way. + for (const msField of multiSelectFields.values()) msField.dispose(); + }, updateStatus: (states) => { for (const [name, handle] of curatedHandles) { const s = states[name]; if (s) applyFieldStatus(handle, s); } + for (const [name, msField] of multiSelectFields) { + const s = states[name]; + if (s) msField.updateStatus(s); + } + }, + // #189: read by the caller BEFORE disposing this bar (a rebuild), to + // decide whether an outgoing popover's forced Cancel deserves a refresh + // announcement — see `dashboard.ts`'s `rebuildFilterBar`. + hasOpenMultiSelect: () => { + for (const msField of multiSelectFields.values()) if (msField.isOpen()) return true; + return false; }, }; } diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index a1d31a9c..802c4fef 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -2503,6 +2503,70 @@ describe('renderDashboard — shared rich filter bar over the viewer (#188)', () }); }); +// #189: the searchable multiselect (an Array(...) consumer contract, default +// `selection.mode`) and the single-select-on-Array wrap (`selection.mode: +// 'single'` against the same Array contract) — both new curated shapes, +// wired end to end through the REAL session's `applyFilter` (never a bare +// callback spy), so a committed value is a genuine array all the way through +// `param-serialize.ts`'s wire format. +describe('renderDashboard — searchable multiselect + array-wrapped curated filters (#189)', () => { + it('an Array(...) consumer contract renders a multiselect field; Apply commits an array through the real session', async () => { + const { app, calls } = dashApp({ + responder: (sql) => (sql.includes('opts') + ? { columns: [{ name: 'p', type: 'Array(String)' }], rows: [[['x', 'y']]] } + : { columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['a', 1]] }), + workspace: wsWith({ + queries: [ + q('q1', 'SELECT k, v FROM a WHERE has(p, {p:Array(String)})'), + q('src', "SELECT ['x','y'] AS p -- opts", { dashboard: { role: 'filter' } }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src' }], + }), + }); + await render(app); + const field = qs(app.root, '.dash-filter-host .var-field.is-curated'); + expect(field).not.toBeNull(); + expect(qs(field, '.ms-field')).not.toBeNull(); // the multiselect control, not the scalar combobox + const before = calls.length; + qs(field, '.ms-trigger').dispatchEvent(new MouseEvent('click', { bubbles: true })); + const cb = qs(document.body, '.ms-option input[type="checkbox"]'); + cb.checked = true; + cb.dispatchEvent(new Event('change', { bubbles: true })); + qs(document.body, '.ms-btn-primary').dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + const added = calls.slice(before).filter((c) => 'param_p' in c.params); + expect(added.length).toBe(1); // one affected-panel wave + expect(added[0].params.param_p).toBe("['x']"); // a real ClickHouse array literal, not a joined string + }); + + it('a single-select curated field over an Array(...) contract commits a WRAPPED [value] (never a bare scalar), through the real session', async () => { + const { app, calls } = dashApp({ + responder: (sql) => (sql.includes('opts') + ? { columns: [{ name: 'p', type: 'Array(String)' }], rows: [[['x', 'y']]] } + : { columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['a', 1]] }), + workspace: wsWith({ + queries: [ + q('q1', 'SELECT k, v FROM a WHERE has(p, {p:Array(String)})'), + q('src', "SELECT ['x','y'] AS p -- opts", { dashboard: { role: 'filter' } }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src', selection: { mode: 'single' } }], + }), + }); + await render(app); + const field = qs(app.root, '.dash-filter-host .var-field.is-curated'); + expect(qs(field, '.var-combo')).not.toBeNull(); // stays the scalar combobox, not a multiselect + const before = calls.length; + qs(field, 'input').dispatchEvent(new Event('focus')); + qs(field, '[role="option"]')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + const added = calls.slice(before).filter((c) => 'param_p' in c.params); + expect(added.length).toBe(1); + expect(added[0].params.param_p).toBe("['x']"); // wrapped, never the bare scalar "'x'" + }); +}); + // #359: the shared-source filter wave now publishes `optionsRev` (bumped ONLY // when a curated source's option VALUE CONTENT changes — including a clear to // null — never on an unchanged republish) and `filterDiagnostics` (its own diff --git a/tests/unit/filter-bar.test.ts b/tests/unit/filter-bar.test.ts index 6b3736d5..ced659f8 100644 --- a/tests/unit/filter-bar.test.ts +++ b/tests/unit/filter-bar.test.ts @@ -38,6 +38,7 @@ describe('buildFilterBar (shared filter row)', () => { expect(bar.el.querySelectorAll('.var-field').length).toBe(0); expect(() => bar.dispose()).not.toThrow(); // no fields, no timers — a no-op expect(() => bar.updateStatus({})).not.toThrow(); // no curated fields — a no-op + expect(bar.hasOpenMultiSelect()).toBe(false); // no multiselect fields at all — always false }); it('defaults to app.document and no group role when no options are passed', () => { @@ -244,8 +245,13 @@ describe('buildFilterBar (shared filter row)', () => { expect(input.placeholder).toBe('Waiting for: '); }); + // #189: an error status no longer DISABLES the curated field — a helper + // failure degrades it to an ordinary, usable free-text-equivalent control + // (still marked `.is-error` with its tooltip) instead of bricking it, + // matching the posture `buildMultiSelectField`'s own error-mode fallback + // already established. it.each(['source-error', 'helper-error', 'missing-helper'])( - 'status: "%s" disables the field and adds is-error, without the waiting note', (status) => { + 'status: "%s" adds is-error WITHOUT disabling the field or the waiting note (#189)', (status) => { const app = makeApp(); const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { curatedFields: { x: { options: [], status } }, @@ -254,8 +260,8 @@ describe('buildFilterBar (shared filter row)', () => { const input = bar.el.querySelector('input') as HTMLInputElement; expect(label.classList.contains('is-error')).toBe(true); expect(label.classList.contains('is-waiting')).toBe(false); - expect(input.disabled).toBe(true); - expect(input.getAttribute('aria-disabled')).toBe('true'); + expect(input.disabled).toBe(false); + expect(input.hasAttribute('aria-disabled')).toBe(false); expect(label.querySelector('.var-field-note')).toBeNull(); }, ); @@ -331,7 +337,8 @@ describe('buildFilterBar (shared filter row)', () => { expect(bar.el.querySelector('input')).toBe(input); expect(label.classList.contains('is-error')).toBe(true); expect(label.classList.contains('is-waiting')).toBe(false); - expect(input.disabled).toBe(true); + // #189: error no longer disables the field (see the it.each above). + expect(input.disabled).toBe(false); // The waiting note is removed once the field leaves 'waiting'. expect(label.querySelector('.var-field-note')).toBeNull(); }); @@ -368,6 +375,126 @@ describe('buildFilterBar (shared filter row)', () => { }); }); + // #189: the curated MULTISELECT field (selection.mode: 'multiple') and the + // single-select-on-Array wrap (selection.mode: 'single', array: true) — + // both new curated shapes wired through `onApplyCurated`. + describe('multiselect / array-wrapped curated fields (#189)', () => { + it('renders buildMultiSelectField (not the combobox) for selection.mode "multiple", and Apply routes through onApplyCurated', () => { + const app = makeApp(); + const onApplyCurated = vi.fn(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { + curatedFields: { + x: { + options: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Bravo' }], + selection: { mode: 'multiple', array: true }, value: ['a'], active: true, + }, + }, + onApplyCurated, + }); + document.body.appendChild(bar.el); + expect(bar.el.querySelector('.ms-field')).not.toBeNull(); + expect(bar.el.querySelector('.var-combo')).toBeNull(); // not the scalar combobox path + bar.el.querySelector('.ms-trigger')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + const cbs = [...document.body.querySelectorAll('.ms-option input[type="checkbox"]')] as HTMLInputElement[]; + cbs[1].checked = true; + cbs[1].dispatchEvent(new Event('change', { bubbles: true })); + document.body.querySelector('.ms-btn-primary')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onApplyCurated).toHaveBeenCalledWith('x', ['a', 'b'], true); + bar.el.remove(); + }); + + it('does NOT render a multiselect field for a scalar (absent or single, non-array) selection contract', () => { + const app = makeApp(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { + curatedFields: { x: { options: [{ value: 'a', label: 'Alpha' }] } }, + }); + expect(bar.el.querySelector('.ms-field')).toBeNull(); + expect(bar.el.querySelector('.var-combo')).not.toBeNull(); + }); + + it('a multiselect field in an error status falls back to the SAME plain-commit seam a non-curated field uses', () => { + const app = makeApp(); + const onCommit = vi.fn(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), onCommit, okField, { + curatedFields: { + x: { + options: [], selection: { mode: 'multiple', array: true }, value: ['a'], active: true, + status: 'source-error', + }, + }, + }); + document.body.appendChild(bar.el); + const errInput = bar.el.querySelector('input.is-error') as HTMLInputElement; + expect(errInput).not.toBeNull(); + errInput.value = 'raw text'; + errInput.dispatchEvent(new Event('input', { bubbles: true })); + errInput.dispatchEvent(new Event('blur', { bubbles: true })); + expect(app.state.varValues.x).toBe('raw text'); + expect(app.state.filterActive.x).toBe(true); + expect(app.params.saveVarValues).toHaveBeenCalled(); + expect(app.params.saveFilterActive).toHaveBeenCalled(); + expect(onCommit).toHaveBeenCalledWith('x'); + bar.el.remove(); + }); + + it('updateStatus patches a multiselect field in place (same trigger instance, no rebuild)', () => { + const app = makeApp(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { + curatedFields: { + x: { + options: [{ value: 'a', label: 'Alpha' }], selection: { mode: 'multiple', array: true }, + value: [], active: false, status: 'ready', + }, + }, + }); + const trigger = bar.el.querySelector('.ms-trigger') as HTMLButtonElement; + bar.updateStatus({ x: { status: 'loading' } }); + expect(bar.el.querySelector('.ms-trigger')).toBe(trigger); + expect(trigger.disabled).toBe(true); + }); + + it('hasOpenMultiSelect() reflects an open popover, and dispose() cancels it with no onApplyCurated call', () => { + const app = makeApp(); + const onApplyCurated = vi.fn(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { + curatedFields: { + x: { + options: [{ value: 'a', label: 'Alpha' }], selection: { mode: 'multiple', array: true }, + value: [], active: false, + }, + }, + onApplyCurated, + }); + document.body.appendChild(bar.el); + expect(bar.hasOpenMultiSelect()).toBe(false); + bar.el.querySelector('.ms-trigger')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(bar.hasOpenMultiSelect()).toBe(true); + expect(document.body.querySelector('.ms-popover')).not.toBeNull(); + bar.dispose(); + expect(document.body.querySelector('.ms-popover')).toBeNull(); + expect(onApplyCurated).not.toHaveBeenCalled(); + bar.el.remove(); + }); + + it('a single-select curated field over an Array(...) contract commits a WRAPPED [value]/[] instead of a bare scalar', () => { + const app = makeApp(); + const onApplyCurated = vi.fn(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { + curatedFields: { + x: { options: [{ value: 'a', label: 'Alpha' }], selection: { mode: 'single', array: true } }, + }, + onApplyCurated, + }); + document.body.appendChild(bar.el); + bar.el.querySelector('input')!.dispatchEvent(new Event('focus')); + bar.el.querySelector('[role="option"]')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + expect(onApplyCurated).toHaveBeenCalledWith('x', ['a'], true); + bar.el.querySelector('.var-combo-clear-inline')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onApplyCurated).toHaveBeenCalledWith('x', [], false); + bar.el.remove(); + }); + }); + it('dispose() clears a pending debounce timer so a later value edit never fires the stale commit (#276)', () => { vi.useFakeTimers(); try { From 3840d5777d9ab7ba15ba937c0e36301e33ea62b9 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 21 Jul 2026 22:31:55 +0000 Subject: [PATCH 06/10] =?UTF-8?q?fix(#189):=20review-round=20fixes=20?= =?UTF-8?q?=E2=80=94=20dashboard-wide=20conflict=20fallback,=20focus=20han?= =?UTF-8?q?dling,=20focus=20trap,=20loading=20affordance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session: a dashboard-wide parameter type conflict now falls the filter back at construction (matching the merge layer's field-level rejection — no published-contract/dead-field hybrid), and the #235 wave-deferral gate is computed from the post-resolution state so a fallen-back filter defers nothing. UI: a raw-string fallback commit stays visible on the trigger and error input instead of vanishing; forced popover closes move focus to the swapped-in error input (or the rebuilt bar's trigger) instead of dropping it to body; the aria-modal dialog gets a real Tab focus trap; error-mode edit state resets on re-entry and can't force-commit on programmatic removal; a status-only loading transition while the popover is open now disables the checklist with an announced 'Loading options…' busy state. The strict single-select restores #360's disabled-on-error affordance (its enabled variant was a dishonest affordance — #189's string-input failure fallback lives in the multiselect control, which has a real free-text path). Adds the dashboard-level refresh-cancel integration test and the #189 authoring-completion schema conformance test. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 35 +++ .../application/dashboard-viewer-session.ts | 71 ++++++- src/ui/dashboard.ts | 24 ++- src/ui/filter-bar.ts | 75 ++++--- src/ui/multi-select-field.ts | 201 ++++++++++++++++-- tests/unit/dashboard-viewer-session.test.ts | 117 ++++++++++ tests/unit/dashboard.test.ts | 60 ++++++ tests/unit/filter-bar.test.ts | 88 ++++++-- tests/unit/multi-select-field.test.ts | 179 +++++++++++++++- tests/unit/spec-schema.test.ts | 26 +++ 10 files changed, 801 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe681189..281966f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,41 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Searchable multiselect for query-backed Dashboard filters** (#189). A + source-backed filter whose executable consumers agree on one `Array(T)` + parameter type now renders a dedicated searchable-checklist control: the + closed trigger shows `All` / `Not set` / the selected label / `N selected`, + and the popover offers a labeled search, a tri-state **Select visible** + scoped to the filtered subset, per-option checkboxes, and **Clear / Cancel / + Apply** — edits stay in a local draft until Apply, which canonicalizes by + option order, commits at most once, and triggers at most one targeted + panel wave (a no-op Apply issues nothing; Cancel/Escape/outside-click + discard the draft and return focus to the trigger). The effective mode is + inferred at runtime from the agreed consumer type (scalar `T` → single, + `Array(T)` → multiselect) and can be overridden per filter with the new + optional `DashboardFilterDefinitionV1.selection.mode` (`"single"` on an + array contract commits `[value]`); inference is runtime-only and never + written back into the dashboard document. A helper is exposed only when + every executable consumer (the filter's resolved targets plus any dependent + Filter sources) agrees on one compatible type — conflicting scalar/Array or + element types, nested arrays, undeclared targets, target-less + configurations, an explicit `multiple` on a scalar contract, or an unknown + mode all fall back to the ordinary string input with persistent + path-precise `filter-selection-*` diagnostics (never a silent downgrade). + Committed multiselect values stay real `string[]` arrays end to end — + through viewer state, localStorage persistence, structural equality, and + the existing typed `Array(T)` serializer (duplicates removed, empty-string + elements valid, never comma-joined). Option refreshes reconcile by bound + value: surviving selections stay active in canonical order (label/order-only + changes rerun nothing), removals join one reconciled panel wave, an empty + intersection deactivates the filter keeping its dormant value, and new + options are never auto-selected; a refresh that lands while the popover is + open cancels it (announced via a live region) so a stale draft can never be + applied. Filter commits now plan their panel wave from the filter's + **resolved targets** (explicit `targets` else declaring tiles) instead of + rerunning every tile that merely declares the parameter name, and a failed + source degrades the curated control to a usable free-text input (raw values + still flow through the typed pipeline) instead of a disabled one. - **Parameterized Dashboard Filter sources with single-layer dependencies** (#360). A Filter-role source query may now declare its own `{name:Type}` query parameters and bind committed *root* Dashboard filter values through the diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 20e25f2e..25c14c86 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -567,15 +567,6 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa return field ? new Set(field.requiredIn.concat(field.optionalIn)) : new Set(); } - // #235 overlap: the set of tile IDs a SOURCE-backed filter targets. Only - // source-backed filters gate — a plain value filter's value is already - // known, so tiles it feeds never need to wait for the filter/source wave. - const affectedByFilterWave = new Set(); - for (const filter of filters) { - if (!filter.def.sourceQueryId) continue; - for (const id of resolveFilterTargets(filter.def)) affectedByFilterWave.add(id); - } - // #189: the general affected-panel planner `runAffectedWave` consults for // EVERY committed parameter (root or source-backed) — every filter // definition's own resolved targets, unioned per PARAMETER (two filter @@ -613,6 +604,27 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // — its helper must never execute. `staticFilterDiagnostics` is emitted // ALONGSIDE (never instead of) the per-wave `filterDiagnostics` — see // `buildState`'s doc comment for why these need to be two separate arrays. + // + // Review finding (major): `resolveFilterSelection` above only agrees over + // this filter's own resolved TARGETS (explicit `def.targets`, else the + // tiles declaring the parameter) plus dependent sources — it never looks at + // a tile OUTSIDE that scope. But the per-wave merge + // (`mergeDashboardFilterHelpers`, `core/dashboard-filters.ts`) rejects a + // curated field on `control.conflict` from `fieldControls(analysis)` — + // computed DASHBOARD-WIDE, over every tile's declaration of the parameter, + // not just this filter's targets. So a filter whose resolution agreed + // (e.g. every explicit target declares `Array(String)`) can still publish + // a `selection` contract and keep its source consumer, only for EVERY + // wave's merge to permanently reject the curated field as + // `filter-target-type-conflict` (a non-targeted or presentation-error tile + // declares a conflicting `String`) — a stuck hybrid: a published + // multiselect contract with a permanently-dead curated field, never + // reverting to the plain string input. `resolveFilterSelection`'s own + // target-scoped agreement is deliberately narrowed FURTHER here by this + // dashboard-wide gate, for consistency with `mergeDashboardFilterHelpers`' + // field-level conflict rejection: one behavior (fall back, all the way), + // never a hybrid state depending on which layer looks first. + const controlsByName = new Map(controls.map((control): [string, FieldControl] => [control.name, control])); const staticFilterDiagnostics: FilterDiagnostic[] = []; for (const filter of filters) { if (!filter.sourceId) continue; // plain root filter — no contract, untouched @@ -644,8 +656,23 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa selection: filter.def.selection, }; const resolution = resolveFilterSelection(filterSelectionDef, analysis, executableTileIds, dependentSources); - if (resolution.diagnostics.length) { + // The same signal `mergeDashboardFilterHelpers` gates `control.conflict` + // on (`fieldControls(analysis)`, dashboard-wide) — checked here too, even + // when `resolution` itself agreed, so this filter can never publish a + // contract the merge layer would reject on every wave forever (see the + // doc comment above this loop). + const dashboardControl = controlsByName.get(filter.def.parameter); + const dashboardConflict = dashboardControl?.conflict?.length ? dashboardControl.conflict : null; + if (resolution.diagnostics.length || dashboardConflict) { for (const d of resolution.diagnostics) staticFilterDiagnostics.push(d as FilterDiagnostic); + if (dashboardConflict) { + staticFilterDiagnostics.push(coreDiagnostic('error', 'filter-selection-dashboard-type-conflict', + `Filter "${filter.def.id}" parameter {${filter.def.parameter}} has a dashboard-wide type conflict across ` + + `Panel declarations: ${dashboardConflict.join(' vs ')}. Declarations OUTSIDE this filter's own targets ` + + `still count for the shared curated-field layer (mergeDashboardFilterHelpers), which rejects a ` + + `dashboard-wide conflict regardless of which tiles this filter targets.`, + { filterId: filter.def.id, parameter: filter.def.parameter, types: dashboardConflict })); + } filter.state.sourceId = undefined; source.consumers = source.consumers.filter((consumer) => consumer !== filter); } else { @@ -662,6 +689,30 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa if (source.consumers.length === 0) filterSources.delete(id); } + // #235 overlap: the set of tile IDs a SOURCE-backed filter targets. Only + // source-backed filters gate — a plain value filter's value is already + // known, so tiles it feeds never need to wait for the filter/source wave. + // + // Review finding (minor): this MUST be computed AFTER the #189 + // resolution/fallback loop above, gated on the POST-resolution + // `filter.state.sourceId` — not the structural `filter.def.sourceQueryId`. + // A filter whose resolution fell back (dropped from `state.sourceId` and + // from its source's `consumers`, possibly deleting the source runtime + // entirely just above) has nothing left to defer against: its target tiles + // must not be needlessly classified "affected" and deferred behind a + // filter/source wave that either never runs the source at all, or runs it + // for other, still-healthy consumers only. Gating on the stale + // `def.sourceQueryId` instead would defer those tiles forever for no + // reason. `targetsByParameter` (above) stays keyed on every filter + // definition regardless of resolution outcome — it feeds the general + // affected-panel planner (`runAffectedWave`), which is orthogonal to this + // pre-wave overlap classification. + const affectedByFilterWave = new Set(); + for (const filter of filters) { + if (!filter.state.sourceId) continue; + for (const id of resolveFilterTargets(filter.def)) affectedByFilterWave.add(id); + } + // Curated option bundles from the last filter wave (param name → field). let curated: MergeDashboardFilterHelpersResult['fields'] = {}; // The last filter wave's merge diagnostics (#359) — a closure var like diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 9eb9931f..930ed96f 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -613,12 +613,15 @@ export async function renderDashboard(app: DashboardApp): Promise { let filterBarUpdateStatus: FilterBarHandle['updateStatus'] | null = null; function rebuildFilterBar(sview: DashboardViewState): void { - // #189: ask the OUTGOING bar whether a multiselect popover is open BEFORE - // disposing it — disposing while open is that field's own silent Cancel - // (multi-select-field.ts), so this is the only chance to notice it and - // tell an assistive-tech user their popover just closed out from under - // them (the shared `filterRefreshLiveEl`, never torn down by the rebuild). - const hadOpenMultiSelect = currentFilterBar?.hasOpenMultiSelect() ?? false; + // #189-F2b: ask the OUTGOING bar WHICH parameter's multiselect popover is + // open (if any) BEFORE disposing it — disposing while open is that + // field's own silent Cancel (multi-select-field.ts), so this is the only + // chance to notice it, tell an assistive-tech user their popover just + // closed out from under them (the shared `filterRefreshLiveEl`, never + // torn down by the rebuild), and move focus to that SAME parameter's + // trigger on the freshly-built bar below (never left stranded at + // `` — F2 review finding). + const openMultiSelectParam = currentFilterBar?.openMultiSelectParam() ?? null; currentFilterBar?.dispose(); const idByParam = new Map(); // #360: curation is gated on TOPOLOGY (`sourceId != null`, set once at @@ -681,7 +684,14 @@ export async function renderDashboard(app: DashboardApp): Promise { filterHost.replaceChildren(bar.el); currentFilterBar = bar; filterBarUpdateStatus = bar.updateStatus; - if (hadOpenMultiSelect) filterRefreshLiveEl.textContent = 'Filter options were refreshed'; + if (openMultiSelectParam) { + filterRefreshLiveEl.textContent = 'Filter options were refreshed'; + // #189-F2b: land focus on the NEW bar's corresponding trigger — a + // no-op if that parameter is no longer a multiselect field on the + // fresh bar (e.g. its curation topology itself changed), which simply + // leaves focus wherever it already was rather than throwing. + bar.focusMultiSelectTrigger(openMultiSelectParam); + } } const filterDiagnosticsHost = h('div', { class: 'dash-filter-diagnostics' }); diff --git a/src/ui/filter-bar.ts b/src/ui/filter-bar.ts index 3115dd0d..24aaea93 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -148,16 +148,18 @@ function applyFieldStatus(handle: CuratedFieldHandle, s: CuratedFieldStatus): vo input.classList.remove('is-waiting', 'is-error', 'is-stale'); label.classList.remove('is-waiting', 'is-error', 'is-stale'); - // #189: an ERROR status no longer disables the input (only waiting/stale - // do) — a helper-query failure degrades the curated field to an ordinary, - // USABLE free-text-equivalent control (still carrying `.is-error` + its - // tooltip as the affordance) instead of bricking it, matching the policy - // `buildMultiSelectField`'s own error-mode plain-input fallback already - // established (#360/#189 — see that module's header comment): the - // serializer's scalar passthrough makes a typed raw value work even for an - // Array param, so there is no reason to lock the field while its source is - // broken. - const disabled = isWaiting || isStale; + // #189 review (F4, coordinator ruling — REVERTED from an earlier #189 + // attempt that left an error status enabled): this is `buildFilterOptionField`'s + // STRICT single-select curated combobox (#160) — blur/Enter reverts any + // text that isn't a real option (its own #160 contract), so leaving it + // enabled while erroring was a dishonest affordance: it LOOKS editable but + // silently discards everything typed. `buildMultiSelectField`'s own + // error-mode fallback has a real free-text commit path and stays enabled + // (see that module's header comment) — but generalizing that policy to + // THIS strict single-select control is a separate product decision (#160's + // contract), not a side effect of #189. Disabled again on every error + // status, same as `isWaiting`/`isStale`. + const disabled = isWaiting || isError || isStale; input.disabled = disabled; if (disabled) input.setAttribute('aria-disabled', 'true'); else input.removeAttribute('aria-disabled'); @@ -243,15 +245,26 @@ export interface FilterBarHandle { el: HTMLElement; dispose(): void; updateStatus(states: Record): void; - /** #189: true iff any curated MULTISELECT field built by THIS bar instance - * currently has its popover open. The caller (`dashboard.ts`) reads this - * BEFORE disposing an outgoing bar (a rebuild always disposes the old bar + /** #189, #189-F2b: the PARAMETER of a curated MULTISELECT field built by + * THIS bar instance that currently has its popover open, or `null` when + * none does (including a bar that built no multiselect field at all — the + * empty-`params` bar too). The caller (`dashboard.ts`) reads this BEFORE + * disposing an outgoing bar (a rebuild always disposes the old bar * outright) to decide whether a refresh announcement is owed — disposing * a multiselect field while its popover is open silently Cancels it (no * `onApply`, see multi-select-field.ts), so without an announcement the - * user's open popover would simply vanish. Always `false` for a bar that - * built no multiselect field at all (including the empty-`params` bar). */ - hasOpenMultiSelect(): boolean; + * user's open popover would simply vanish. Replaces the pre-F2b boolean + * `hasOpenMultiSelect()` — the caller needs to know WHICH field, so it can + * move focus to that same parameter's trigger on the freshly-built bar + * (`focusMultiSelectTrigger` below) rather than leaving focus stranded at + * ``. */ + openMultiSelectParam(): string | null; + /** #189-F2b: focuses the named parameter's multiselect trigger (or its + * error-mode fallback input, if erroring) — a no-op when this bar built no + * multiselect field for that parameter. Used by `dashboard.ts` right after + * building a FRESH bar, for whichever parameter `openMultiSelectParam()` + * reported on the OUTGOING bar just before disposing it. */ + focusMultiSelectTrigger(name: string): void; } /** @@ -287,7 +300,8 @@ export function buildFilterBar( if (!params.length) { return { el: h('div', { ...attrs, style: { display: 'none' } }), - dispose: () => {}, updateStatus: () => {}, hasOpenMultiSelect: () => false, + dispose: () => {}, updateStatus: () => {}, + openMultiSelectParam: () => null, focusMultiSelectTrigger: () => {}, }; } const timerClears: Array<() => void> = []; @@ -298,7 +312,8 @@ export function buildFilterBar( // #189: every curated MULTISELECT field's own handle, keyed by parameter — // a separate map (its `updateStatus`/`isOpen`/`dispose` are its own, not // `CuratedFieldHandle`'s DOM-patching recipe) that `updateStatus`/`dispose`/ - // `hasOpenMultiSelect` below all fold in alongside `curatedHandles`. + // `openMultiSelectParam`/`focusMultiSelectTrigger` below all fold in + // alongside `curatedHandles`. const multiSelectFields = new Map(); const el = h('div', attrs, ...params.map((p) => { let timer: ReturnType | null = null; @@ -322,7 +337,17 @@ export function buildFilterBar( // default — or `mode: 'single'`) falls through to the existing // single-select field below unchanged. if (curated.selection?.mode === 'multiple') { - const committed = Array.isArray(curated.value) ? curated.value as string[] : []; + // #189 F1: a raw STRING is the error-mode fallback commit + // (`onFallbackCommit` below, round-tripped back through + // `ViewerFilterState.value`/`curated.value`) awaiting reconciliation + // — passed through AS a string rather than dropped to `[]`, so + // `buildMultiSelectField`'s own trigger/error-input text can still + // show the just-committed text instead of it silently vanishing. + // Every other shape (a real array, or absent/null) keeps the + // pre-#189-F1 array-or-empty normalization. + const committed: readonly string[] | string = Array.isArray(curated.value) + ? curated.value as string[] + : typeof curated.value === 'string' ? curated.value : []; const msField = buildMultiSelectField({ document, name: p.name, label: p.name, required: !p.optional, value: committed, active: !!curated.active, options: curated.options, @@ -480,12 +505,14 @@ export function buildFilterBar( if (s) msField.updateStatus(s); } }, - // #189: read by the caller BEFORE disposing this bar (a rebuild), to + // #189-F2b: read by the caller BEFORE disposing this bar (a rebuild), to // decide whether an outgoing popover's forced Cancel deserves a refresh - // announcement — see `dashboard.ts`'s `rebuildFilterBar`. - hasOpenMultiSelect: () => { - for (const msField of multiSelectFields.values()) if (msField.isOpen()) return true; - return false; + // announcement AND which parameter's fresh trigger should receive focus + // — see `dashboard.ts`'s `rebuildFilterBar`. + openMultiSelectParam: () => { + for (const [name, msField] of multiSelectFields) if (msField.isOpen()) return name; + return null; }, + focusMultiSelectTrigger: (name) => { multiSelectFields.get(name)?.focusTrigger(); }, }; } diff --git a/src/ui/multi-select-field.ts b/src/ui/multi-select-field.ts index c080c76f..dc0eada9 100644 --- a/src/ui/multi-select-field.ts +++ b/src/ui/multi-select-field.ts @@ -61,8 +61,19 @@ export interface MultiSelectFieldOpts { /** Inactive trigger text: 'Not set' when required, 'All' when optional. */ required?: boolean; /** Committed selection — may contain values absent from `options` (a - * DORMANT value an options refresh dropped); never mutated by this module. */ - value: readonly string[]; + * DORMANT value an options refresh dropped); never mutated by this module. + * A plain `string` is #189's error-mode RAW FALLBACK COMMIT awaiting + * reconciliation (`onFallbackCommit`'s own value, round-tripped back in by + * a caller that has nowhere else to put it — the scalar draft bag this + * filter's committed value otherwise lives in cannot hold an array either + * way) — every array-shaped operation below (`Array.isArray(value) ? + * value : []`) treats it as "no selection", while the trigger/error-input + * text paths show it verbatim so the just-typed text never appears to + * vanish. The next successful options merge that resolves this filter's + * contract republishes a real array (or the same raw string, unchanged, + * if the merge still can't resolve it) — this module never reconciles it + * itself. */ + value: readonly string[] | string; active: boolean; options: MultiSelectOption[]; status?: MultiSelectFieldStatus; @@ -83,6 +94,14 @@ export interface MultiSelectFieldHandle { /** Whether the popover is currently open — an integration caller uses this * to decide whether a status change needs to announce a refresh-cancel. */ isOpen(): boolean; + /** Focuses this control's own current interactive element (the trigger, or + * the error-mode fallback input when erroring) — #189 F2b: a caller + * (`filter-bar.ts`'s `focusMultiSelectTrigger`) that just rebuilt the bar + * a still-open popover was force-closed out from under uses this to move + * focus onto the corresponding field of the FRESH bar (never left at + * ``). A no-op-safe call before `applyStatus()` has ever run is not + * a case this module produces (the constructor calls it before returning). */ + focusTrigger(): void; /** Removes this control's own listeners and closes the popover if open (a * dispose-while-open is a Cancel: no `onApply`/`onFallbackCommit` call). */ dispose(): void; @@ -113,7 +132,17 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi let wasError = false; // The currently-open popover's own close() — non-null iff the popover is // open (isOpen() reads this directly rather than tracking a second flag). - let closeCurrent: (() => void) | null = null; + // #189 F2a: takes an options bag so `applyStatus`'s forced error-close can + // ask it to SKIP focusing the doomed trigger (about to be replaced by the + // fallback input) — every other dismissal path (Cancel/Escape/outside- + // click/dispose) still gets the default trigger-refocus. + let closeCurrent: ((closeOpts?: { skipFocus?: boolean }) => void) | null = null; + // #189 F6: the OPEN popover's own noninteractive-while-loading surface — + // non-null iff the popover is open (set/cleared in lockstep with + // `closeCurrent`), read by `applyStatus` below so a STATUS-ONLY publish + // (no rebuild — the draft can't change without one) can disable/re-enable + // the checklist body in place without disturbing the open draft. + let openPopoverBusy: ((busy: boolean) => void) | null = null; const inactiveText = (): string => (required ? 'Not set' : 'All'); @@ -122,6 +151,10 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi if (isWaiting) return `Waiting for: ${(status.waitingFor ?? []).join(', ')}`; if (isStale) return 'Loading options…'; if (!active || value.length === 0) return inactiveText(); + // #189 F1: a raw string is the error-mode fallback commit — shown + // verbatim (never joined/counted) rather than collapsed to "1 selected" + // or an option-label lookup that would never match it. + if (typeof value === 'string') return value; if (value.length === 1) { const opt = options.find((o) => o.value === value[0]); return opt ? opt.label : value[0]; @@ -142,6 +175,19 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi const errorInput = h('input', { type: 'text', id: 'ms-error-' + suffix, class: 'var-input is-error', 'aria-label': label, }); + // #189 F5: `errorEdited` tracks an IN-PROGRESS, uncommitted edit of THIS + // error-mode session only — reset to false (never carried over) every time + // the control (re)enters error mode (see `applyStatus`'s `!wasError` + // branch), and the listeners below are only ATTACHED while erroring + // (`attachErrorListeners`/`detachErrorListeners`, also driven by + // `applyStatus`) rather than for the control's whole lifetime. Detaching on + // the way OUT of error mode — before `applyStatus` swaps `errorInput` back + // out of the DOM — means the browser-native `blur` a real browser fires + // when a FOCUSED element is removed from the document can never reach + // `onErrorBlur` and force a commit of an edit the user never actually + // committed (happy-dom does not reproduce that native blur-on-removal + // behavior, so this specific ordering is only actually exercised by a real + // browser — the unit suite instead verifies the listener is gone). let errorEdited = false; const commitFallback = (): void => { @@ -155,9 +201,16 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi commitFallback(); }; const onErrorBlur = (): void => { if (errorEdited) commitFallback(); }; - errorInput.addEventListener('input', onErrorInput); - errorInput.addEventListener('keydown', onErrorKeyDown); - errorInput.addEventListener('blur', onErrorBlur); + const attachErrorListeners = (): void => { + errorInput.addEventListener('input', onErrorInput); + errorInput.addEventListener('keydown', onErrorKeyDown); + errorInput.addEventListener('blur', onErrorBlur); + }; + const detachErrorListeners = (): void => { + errorInput.removeEventListener('input', onErrorInput); + errorInput.removeEventListener('keydown', onErrorKeyDown); + errorInput.removeEventListener('blur', onErrorBlur); + }; // Applies the CURRENT `status` to the already-built DOM (constructor AND // `updateStatus` share this — never a rebuild, see the module header). @@ -165,8 +218,22 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi const { isWaiting, isError, isStale } = classifyStatus(status); // A status change into error mid-open cancels the popover outright (its // anchor, the trigger, is about to be replaced by the fallback input) — - // a Cancel: no onApply call. - if (isError && closeCurrent) closeCurrent(); + // a Cancel: no onApply call. #189 F2a: `skipFocus` — the trigger is + // about to be DETACHED by the `replaceChildren` swap below, so focusing + // it here would just be immediately lost to ``; focus moves to the + // freshly-swapped-in `errorInput` after the swap instead (below). + const forcedClosePopover = isError && !!closeCurrent; + if (forcedClosePopover) closeCurrent!({ skipFocus: true }); + + // #189 F6: a STATUS-ONLY publish while the popover is open (no rebuild — + // the open draft's own options can't change without one) still needs to + // communicate a waiting/loading/idle/stale transition: make the + // checklist body noninteractive (Cancel + Escape stay usable) rather + // than silently doing nothing while stale data sits underneath an + // unchanged, seemingly-live control. Never reached for the forced-close + // error case above (`openPopoverBusy` is already null by the time this + // runs, closed via `closeCurrent` a few lines up). + openPopoverBusy?.(isWaiting || isStale); control.classList.remove('is-waiting', 'is-error', 'is-stale'); if (isWaiting) control.classList.add('is-waiting'); @@ -178,9 +245,25 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi // — a later `updateStatus` call that's still an error status (e.g. // 'source-error' → 'helper-error') must never stomp an in-progress // edit (#360's "don't discard a committed value" policy applies just - // as much to the user's own not-yet-committed typing). - if (!wasError) errorInput.value = value.join(', '); + // as much to the user's own not-yet-committed typing). #189 F1: seeds + // from the raw string verbatim when `value` is already one (a prior + // fallback commit awaiting reconciliation); otherwise the array joined + // for display, unchanged. #189 F5: a FRESH entry into error mode is + // always a fresh seed — never edited yet — and only NOW does the + // fallback input's own listeners attach (see the module header on + // `errorEdited` above for why this must not just linger for the + // control's whole lifetime). + if (!wasError) { + errorEdited = false; + errorInput.value = typeof value === 'string' ? value : value.join(', '); + attachErrorListeners(); + } } else { + // #189 F5: leaving error mode (recovery) — detach BEFORE the + // `replaceChildren` swap below removes `errorInput` from the DOM, so a + // real browser's native blur-on-removal can never reach `onErrorBlur` + // and force-commit whatever was left uncommitted. + if (wasError) detachErrorListeners(); trigger.classList.remove('is-waiting', 'is-stale'); if (isWaiting) trigger.classList.add('is-waiting'); else if (isStale) trigger.classList.add('is-stale'); @@ -189,11 +272,19 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi trigger.textContent = text; trigger.title = text; } - trigger.setAttribute('aria-label', `${label} filter, ${value.length} selected`); + // #189 F1: a raw-string committed value has no real "selected count" — + // reported as 1 when non-empty (matches its own single-item trigger + // text), 0 when blank/inactive; the trigger itself is hidden while + // erroring, so this is cosmetic even then. + const selectedCount = Array.isArray(value) ? value.length : (value !== '' ? 1 : 0); + trigger.setAttribute('aria-label', `${label} filter, ${selectedCount} selected`); wasError = isError; const wanted = isError ? errorInput : trigger; if (control.firstChild !== wanted) control.replaceChildren(wanted); + // #189 F2a: focus lands on the control now standing in for the popover + // this call just force-closed — never left to fall through to ``. + if (forcedClosePopover) errorInput.focus(); }; const onTriggerClick = (): void => { if (!trigger.disabled) openPopover(); }; @@ -203,7 +294,11 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi // open, tear down completely on close — never a hidden-but-resident node). function openPopover(): void { if (closeCurrent) return; // already open — never stack a second popover - const draft = new Set(value); + // #189 F1: a raw-string committed value (the error-mode fallback commit, + // never actually reachable here since the trigger — the only way to + // reach `openPopover` — is swapped out for `errorInput` while erroring) + // seeds an EMPTY draft rather than throwing on `new Set('a string')`. + const draft = new Set(Array.isArray(value) ? value : []); let searchText = ''; const liveEl = h('div', { class: 'sr-only ms-live', 'aria-live': 'polite' }); @@ -278,7 +373,11 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi cancelBtn.addEventListener('click', () => close()); applyBtn.addEventListener('click', () => { const canonical = canonicalizeSelection([...draft], options); - const prevCanonical = canonicalizeSelection(value, options); + // #189 F1: a raw-string committed value (the error-mode fallback commit) + // has no prior ARRAY selection to compare against — treated as empty, + // same as `draft`'s own seed above, so Apply from that state is never + // spuriously treated as a no-op against text that was never a selection. + const prevCanonical = canonicalizeSelection(Array.isArray(value) ? value : [], options); const activeNext = canonical.length > 0; // A no-op Apply (same canonical selection AND same active flag) closes // silently — `onApply` fires exactly once otherwise. @@ -294,24 +393,83 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi }, searchInput, liveEl, selectAllRow, listEl, footer); const overlay = h('div', { class: 'ms-overlay' }); + // #189 F6: while OPEN, a status-only publish that goes + // waiting/loading/idle/stale makes the checklist body noninteractive + // (Cancel + Escape stay usable — dismissing is always safe) and + // announces it through the SAME live region `applyFilter` otherwise + // reports the visible/total count through; `ready` restores both the + // controls and the normal count text. The draft itself is never + // touched — its values can't change without a rebuild, which only + // happens closed. + let busy = false; + function setBusy(next: boolean): void { + if (busy === next) return; + busy = next; + dialog.setAttribute('aria-busy', String(busy)); + searchInput.disabled = busy; + selectAllCb.disabled = busy; + for (const row of rows) row.cb.disabled = busy; + clearBtn.disabled = busy; + applyBtn.disabled = busy; + if (busy) liveEl.textContent = 'Loading options…'; + else applyFilter(); // restores the normal "N of M options" live text + } + openPopoverBusy = setBusy; + const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; + // #189 F3: a minimal focus trap — `aria-modal="true"` promises assistive + // tech (and sighted keyboard users) that Tab never leaves the dialog. The + // overlay only ever blocked POINTER events; without this, Tab/Shift-Tab + // walked straight out to whatever the document's next/previous tabbable + // happened to be. Recomputed on every Tab press (never cached) since the + // option checklist's visible subset changes with `searchText`. Registered + // on `dialog` itself (not `d`/document, unlike `onKeyDown`'s broad Escape + // catch above) — a stale, already-closed popover's own trap must never + // intercept a Tab dispatched at a DIFFERENT, currently-open dialog; a + // listener scoped to this specific (detached-on-close) node can't reach + // any OTHER dialog's subtree regardless of how many prior popovers a + // caller left open without disposing. + function focusableEls(): HTMLElement[] { + return [...dialog.querySelectorAll('input, button')] + .filter((el) => !el.closest('[hidden]') && !(el as HTMLInputElement | HTMLButtonElement).disabled); + } + const onTabTrap = (e: KeyboardEvent): void => { + if (e.key !== 'Tab') return; + // Cancel is never disabled (F6 keeps it usable even while `busy`), so + // `items` always has at least one entry — no empty-list guard needed. + const items = focusableEls(); + const first = items[0]; + const last = items[items.length - 1]; + const activeEl = d.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (!activeEl || activeEl === first || !dialog.contains(activeEl)) { e.preventDefault(); last.focus(); } + } else if (!activeEl || activeEl === last || !dialog.contains(activeEl)) { + e.preventDefault(); first.focus(); + } + }; + // EVERY dismissal path (Apply, Cancel, Escape, outside-click, dispose) // funnels through here — the one place that tears the popover down and // returns focus to the trigger. Idempotent by construction (every step // is a harmless no-op on an already-detached/already-null target), so no // separate re-entrancy guard is needed even if a caller somehow reached - // it twice for the same open session. - function close(): void { + // it twice for the same open session. #189 F2a: `skipFocus` lets + // `applyStatus`'s forced error-close skip refocusing a trigger that's + // about to be detached from the DOM anyway (focus moves to the fallback + // input instead, over there). + function close(closeOpts: { skipFocus?: boolean } = {}): void { d.removeEventListener('keydown', onKeyDown, true); + dialog.removeEventListener('keydown', onTabTrap, true); detachBackdrop(); overlay.remove(); dialog.remove(); trigger.setAttribute('aria-expanded', 'false'); closeCurrent = null; - trigger.focus(); + openPopoverBusy = null; + if (!closeOpts.skipFocus) trigger.focus(); } closeCurrent = close; @@ -320,6 +478,7 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi d.body.appendChild(dialog); const detachBackdrop = attachBackdropClose(overlay, close); d.addEventListener('keydown', onKeyDown, true); + dialog.addEventListener('keydown', onTabTrap, true); const rect = trigger.getBoundingClientRect(); const pos = fixedAnchor(rect) as { top: number; left: number }; @@ -340,12 +499,16 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi el: control, isOpen: () => closeCurrent !== null, updateStatus: (s) => { status = s; applyStatus(); }, + // #189 F2b: focuses whichever of trigger/errorInput is the control's + // CURRENT interactive element (mirrors `applyStatus`'s own `wanted` + // choice) — used by a caller that just rebuilt the bar this field's + // popover had open on the OLD instance, to land focus on the + // corresponding field of the fresh one instead of ``. + focusTrigger: () => { (wasError ? errorInput : trigger).focus(); }, dispose: () => { closeCurrent?.(); // dispose-while-open is a Cancel: no writes trigger.removeEventListener('click', onTriggerClick); - errorInput.removeEventListener('input', onErrorInput); - errorInput.removeEventListener('keydown', onErrorKeyDown); - errorInput.removeEventListener('blur', onErrorBlur); + detachErrorListeners(); // idempotent — a no-op if never attached / already detached }, }; } diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 51ab6b29..46aea0a6 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -2241,4 +2241,121 @@ describe('searchable multiselect filter contract (#189)', () => { expect(byId(session, 'f1').value).toEqual(['x', 'y']); expect(byId(session, 'f1').active).toBe(true); }); + + // Review finding (major): `resolveFilterSelection` only agrees over a + // filter's own resolved TARGETS + dependent sources — but the per-wave + // merge (`mergeDashboardFilterHelpers`) rejects a curated field on the + // DASHBOARD-WIDE `control.conflict` (`fieldControls(analysis)`, every + // tile, unscoped). Without the construction-time dashboard-wide gate, a + // filter whose OWN targets agree could still publish a `selection` + // contract and keep its source consumer, only for every wave's merge to + // permanently reject it as `filter-target-type-conflict` — a stuck + // hybrid (published contract + dead curated field), never falling back. + it('a dashboard-wide type conflict OUTSIDE the filter\'s own targets still forces a full fallback (#189 review finding, major): no sourceId/selection published, a persistent dashboard-wide diagnostic, the source never executes, and no helper-error hybrid ever appears', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'region', type: 'Array(String)' }], rows: [[['a', 'b']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t1', 'q1'), tile('t2', 'q2')], + // Explicit `targets: ['t1']` — 't1' alone agrees with the source on + // Array(String), so `resolveFilterSelection`'s OWN (target-scoped) + // agreement check would succeed on its own. + filters: [{ id: 'f1', parameter: 'region', sourceQueryId: 'src', targets: ['t1'] }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('q1', 'SELECT 1 AS n WHERE x IN {region:Array(String)}'), // f1's own target — agrees + // NOT one of f1's targets, but its scalar declaration of the SAME + // parameter still counts for the dashboard-wide `fieldControls` + // conflict the shared merge layer gates on. + query('q2', 'SELECT 1 AS n WHERE y = {region:String}'), + query('src', "SELECT ['a','b'] AS region /* source */", { dashboard: { role: 'filter' } }), + ], + })); + expect(byId(session, 'f1').sourceId).toBeUndefined(); + expect(byId(session, 'f1').selection).toBeUndefined(); + const diag = session.state.value.filterDiagnostics.find((d) => d.code === 'filter-selection-dashboard-type-conflict'); + expect(diag).toMatchObject({ severity: 'error', filterId: 'f1', parameter: 'region' }); + expect(diag!.message).toContain('region'); + expect(diag!.message.toLowerCase()).toContain('dashboard-wide'); + expect(diag!.types).toEqual(expect.arrayContaining([expect.any(String), expect.any(String)])); + await session.start(); + // 'src' is left with zero consumers (its only filter fell back) — it + // must never execute at all. + expect(calls.some((c) => c.sql.includes('source'))).toBe(false); + // No stuck hybrid: 'f1' is no longer a consumer of any source, so its + // status never enters the filter-wave consumer-derivation loop — it + // stays 'idle', never 'helper-error'. + expect(byId(session, 'f1').status).toBe('idle'); + // The diagnostic is a construction-time constant — it survives a refresh. + await session.refresh(); + expect(byId(session, 'f1').status).toBe('idle'); + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-selection-dashboard-type-conflict')).toBe(true); + }); + + // Review finding (minor): `affectedByFilterWave` was built from the + // structural `filter.def.sourceQueryId` BEFORE the #189 resolution loop + // that can strip `filter.state.sourceId` — so a fallen-back filter's + // (would-be) target tile was needlessly classified "affected" and deferred + // behind the filter wave, even though the fallback filter no longer feeds + // any source at all. + it('#235 wave-deferral gate reflects the POST-resolution state (#189 review finding, minor): a fallen-back filter defers nothing — its own (would-be) target tile runs in the FIRST (unaffected) batch, not after the whole filter wave', async () => { + let releaseSource2: (() => void) | undefined; + const source2Gate = new Promise((resolve) => { releaseSource2 = resolve; }); + const { exec, calls } = makeExec(async (sql) => { + if (sql.includes('source2')) { + await source2Gate; // a real, observable delay for the filter wave + return { columns: [{ name: 'other', type: 'String' }], rows: [['y']] }; + } + return { columns: [{ name: 'n' }], rows: [[1]] }; + }); + const document = doc({ + tiles: [tile('t1', 'q1'), tile('t2', 'q2')], + filters: [ + // Falls back at construction (unrecognized `selection.mode` — a HARD + // conflict, #189): `state.sourceId` is stripped, and 'src1' — left + // with zero consumers — is deleted entirely. + { + id: 'f1', parameter: 'ps', sourceQueryId: 'src1', selection: { mode: 'bogus' as 'single' }, + defaultActive: true, defaultValue: 'X', + }, + // A genuinely healthy, source-backed filter, unrelated to 't1' — its + // source is deliberately slow (gated) so the filter wave takes real + // time, giving the two classifications ("affected" or not) a real + // window in which to differ. + { id: 'f2', parameter: 'other', sourceQueryId: 'src2' }, + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('q1', 'SELECT {ps:String} AS n'), // 't1' — f1's own (would-be) target + query('q2', 'SELECT {other:String} AS n'), // 't2' — f2's real target + query('src1', "SELECT ['x'] AS ps /* source1 */", { dashboard: { role: 'filter' } }), + query('src2', "SELECT ['y'] AS other /* source2 */", { dashboard: { role: 'filter' } }), + ], + })); + expect(byId(session, 'f1').sourceId).toBeUndefined(); // fell back + expect(byId(session, 'f2').sourceId).toBe('src2'); // healthy, real consumer + + const done = session.start(); + await flush(); + // 't1' already ran — it was never affected by any filter wave (f1 fell + // back and dropped its consumer-ship entirely), so it fired in the FIRST + // (unaffected) batch, well before 'src2' — the only remaining source — + // ever settles. + expect(calls.some((c) => c.sql.includes('{ps:String}'))).toBe(true); + expect(session.state.value.tiles.find((t) => t.tileId === 't1')!.status).toBe('ready'); + // 't2' — f2's real target — correctly still waits on the (gated) filter + // wave: it has not been touched yet (still its initial idle state). + expect(session.state.value.tiles.find((t) => t.tileId === 't2')!.status).toBe('idle'); + releaseSource2!(); + await done; + // 't2' has now run (the affected-panel wave, once the filter wave + // settled) — 'other' never got a committed value from the curated + // options (nothing selected one), so it lands on 'unfilled' rather than + // 'ready'; either way it is no longer 'idle'. + expect(session.state.value.tiles.find((t) => t.tileId === 't2')!.status).toBe('unfilled'); + }); }); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 802c4fef..ac7816d5 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -2565,6 +2565,66 @@ describe('renderDashboard — searchable multiselect + array-wrapped curated fil expect(added.length).toBe(1); expect(added[0].params.param_p).toBe("['x']"); // wrapped, never the bare scalar "'x'" }); + + // #189 review (F2): a NEW option generation while the popover is open must + // force-close it as a silent Cancel (never a committed value from the open + // draft), announce the closure, and move focus to the FRESH bar's trigger + // for the same parameter — driven end to end through the real session (a + // refresh that reruns the shared source with DIFFERENT option content). + it('a NEW option generation while the multiselect popover is open force-closes it with no applyFilter call, announces the refresh, and focuses the new trigger', async () => { + let srcCalls = 0; + const { app, calls } = dashApp({ + responder: (sql) => { + if (sql.includes('opts')) { + srcCalls++; + return { columns: [{ name: 'p', type: 'Array(String)' }], rows: [[srcCalls === 2 ? ['a', 'b', 'c'] : ['x', 'y']]] }; + } + return { columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['a', 1]] }; + }, + workspace: wsWith({ + queries: [ + q('q1', 'SELECT k, v FROM a WHERE has(p, {p:Array(String)})'), + q('src', "SELECT ['x','y'] AS p -- opts", { dashboard: { role: 'filter' } }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src', defaultValue: ['x'], defaultActive: true }], + }), + }); + await render(app); + // `app.root` (fake-app.ts) is a detached div by default — connect it so a + // real `.focus()` inside it actually becomes `document.activeElement` + // (the popover itself is already appended straight to the real + // `document.body` by `multi-select-field.ts`, unaffected either way). + document.body.appendChild(rootEl(app)); + const field = qs(app.root, '.dash-filter-host .var-field.is-curated'); + qs(field, '.ms-trigger').dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(qs(document.body, '.ms-popover')).not.toBeNull(); + // Mutate the OPEN DRAFT only — check the second option ('y') too, so the + // draft becomes ['x','y'] — never Apply. + const draftCb = qsa(document.body, '.ms-option input[type="checkbox"]')[1]; + draftCb.checked = true; + draftCb.dispatchEvent(new Event('change', { bubbles: true })); + const before = calls.length; + // Refresh reruns the shared source, which returns a DIFFERENT option set + // (same length as the #359 rebuild trigger) — a new option generation. + await (runOnclick(qs(app.root, '.dash-refresh')) as Promise); + // The popover was force-closed as a silent Cancel — no call anywhere + // reflects the open draft's ['x','y'] pick (the real seam that would + // carry it, `applyFilter`, is never reached). + const tileCalls = calls.slice(before).filter((c) => 'param_p' in c.params); + expect(tileCalls.some((c) => c.params.param_p === "['x','y']")).toBe(false); + expect(document.body.querySelector('.ms-popover')).toBeNull(); + expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe('Filter options were refreshed'); + // The committed value ['x'] (never touched by the draft) is now dormant + // against the NEW option set — the merge deactivates it (existing + // dormant-value self-heal behavior, unrelated to this fix) — the fresh + // bar's trigger reads "Not set", never "2 selected" (which only a + // committed ['x','y'] — i.e. the discarded draft — would have produced). + const newTrigger = qs(app.root, '.ms-trigger'); + expect(newTrigger.textContent).toBe('Not set'); + expect(document.activeElement).toBe(newTrigger); + rootEl(app).remove(); + }); }); // #359: the shared-source filter wave now publishes `optionsRev` (bumped ONLY diff --git a/tests/unit/filter-bar.test.ts b/tests/unit/filter-bar.test.ts index ced659f8..6f8caffb 100644 --- a/tests/unit/filter-bar.test.ts +++ b/tests/unit/filter-bar.test.ts @@ -38,7 +38,8 @@ describe('buildFilterBar (shared filter row)', () => { expect(bar.el.querySelectorAll('.var-field').length).toBe(0); expect(() => bar.dispose()).not.toThrow(); // no fields, no timers — a no-op expect(() => bar.updateStatus({})).not.toThrow(); // no curated fields — a no-op - expect(bar.hasOpenMultiSelect()).toBe(false); // no multiselect fields at all — always false + expect(bar.openMultiSelectParam()).toBeNull(); // no multiselect fields at all — always null + expect(() => bar.focusMultiSelectTrigger('x')).not.toThrow(); // unknown param — a no-op }); it('defaults to app.document and no group role when no options are passed', () => { @@ -245,13 +246,13 @@ describe('buildFilterBar (shared filter row)', () => { expect(input.placeholder).toBe('Waiting for: '); }); - // #189: an error status no longer DISABLES the curated field — a helper - // failure degrades it to an ordinary, usable free-text-equivalent control - // (still marked `.is-error` with its tooltip) instead of bricking it, - // matching the posture `buildMultiSelectField`'s own error-mode fallback - // already established. + // #189 review (F4, coordinator ruling — REVERTED): this is the STRICT + // single-select curated combobox (#160 — blur/Enter reverts non-option + // text), so leaving it enabled while erroring was a dishonest affordance + // (looks editable, silently discards everything typed). Disabled again on + // every error status, same as before #189. it.each(['source-error', 'helper-error', 'missing-helper'])( - 'status: "%s" adds is-error WITHOUT disabling the field or the waiting note (#189)', (status) => { + 'status: "%s" disables the field and adds is-error, without the waiting note', (status) => { const app = makeApp(); const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { curatedFields: { x: { options: [], status } }, @@ -260,8 +261,8 @@ describe('buildFilterBar (shared filter row)', () => { const input = bar.el.querySelector('input') as HTMLInputElement; expect(label.classList.contains('is-error')).toBe(true); expect(label.classList.contains('is-waiting')).toBe(false); - expect(input.disabled).toBe(false); - expect(input.hasAttribute('aria-disabled')).toBe(false); + expect(input.disabled).toBe(true); + expect(input.getAttribute('aria-disabled')).toBe('true'); expect(label.querySelector('.var-field-note')).toBeNull(); }, ); @@ -337,8 +338,8 @@ describe('buildFilterBar (shared filter row)', () => { expect(bar.el.querySelector('input')).toBe(input); expect(label.classList.contains('is-error')).toBe(true); expect(label.classList.contains('is-waiting')).toBe(false); - // #189: error no longer disables the field (see the it.each above). - expect(input.disabled).toBe(false); + // #189 F4 revert: error disables the field again (see the it.each above). + expect(input.disabled).toBe(true); // The waiting note is removed once the field leaves 'waiting'. expect(label.querySelector('.var-field-note')).toBeNull(); }); @@ -453,7 +454,7 @@ describe('buildFilterBar (shared filter row)', () => { expect(trigger.disabled).toBe(true); }); - it('hasOpenMultiSelect() reflects an open popover, and dispose() cancels it with no onApplyCurated call', () => { + it('openMultiSelectParam() reflects an open popover\'s parameter, and dispose() cancels it with no onApplyCurated call', () => { const app = makeApp(); const onApplyCurated = vi.fn(); const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { @@ -466,9 +467,9 @@ describe('buildFilterBar (shared filter row)', () => { onApplyCurated, }); document.body.appendChild(bar.el); - expect(bar.hasOpenMultiSelect()).toBe(false); + expect(bar.openMultiSelectParam()).toBeNull(); bar.el.querySelector('.ms-trigger')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(bar.hasOpenMultiSelect()).toBe(true); + expect(bar.openMultiSelectParam()).toBe('x'); expect(document.body.querySelector('.ms-popover')).not.toBeNull(); bar.dispose(); expect(document.body.querySelector('.ms-popover')).toBeNull(); @@ -476,6 +477,65 @@ describe('buildFilterBar (shared filter row)', () => { bar.el.remove(); }); + it('focusMultiSelectTrigger(name) focuses that parameter\'s trigger (#189 F2b)', () => { + const app = makeApp(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { + curatedFields: { + x: { + options: [{ value: 'a', label: 'Alpha' }], selection: { mode: 'multiple', array: true }, + value: [], active: false, + }, + }, + }); + document.body.appendChild(bar.el); + const trigger = bar.el.querySelector('.ms-trigger') as HTMLButtonElement; + bar.focusMultiSelectTrigger('x'); + expect(document.activeElement).toBe(trigger); + bar.el.remove(); + }); + + it('an absent (undefined) committed value falls back to an empty array, not the raw string passthrough', () => { + const app = makeApp(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { + curatedFields: { x: { options: [], selection: { mode: 'multiple', array: true } } }, + }); + const trigger = bar.el.querySelector('.ms-trigger') as HTMLButtonElement; + expect(trigger.textContent).toBe('Not set'); // required (not optional) + empty/inactive + }); + + it('a raw-string committed value (#189 F1 error-mode fallback) passes through instead of dropping to an empty array', () => { + const app = makeApp(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField, { + curatedFields: { + x: { + options: [], selection: { mode: 'multiple', array: true }, value: 'typed raw', active: true, + status: 'ready', + }, + }, + }); + const trigger = bar.el.querySelector('.ms-trigger') as HTMLButtonElement; + expect(trigger.textContent).toBe('typed raw'); + }); + + it('marks the multiselect field is-optional when its param is optional, same as the scalar curated field (T2)', () => { + const app = makeApp(); + const bar = buildFilterBar( + app, + paramsFor('SELECT {y:String} FROM t /*[ AND x = {x:String} ]*/'), + () => {}, okField, + { + curatedFields: { + y: { options: [{ value: 'a', label: 'Alpha' }], selection: { mode: 'multiple', array: true }, value: [] }, + x: { options: [{ value: 'b', label: 'Beta' }], selection: { mode: 'multiple', array: true }, value: [] }, + }, + }, + ); + const fields = [...bar.el.querySelectorAll('.var-field')]; + expect(fields.map((f) => f.querySelector('.var-name')!.textContent)).toEqual(['y', 'x']); + expect(fields.map((f) => f.classList.contains('is-optional'))).toEqual([false, true]); + expect(fields.every((f) => f.querySelector('.ms-field') !== null)).toBe(true); + }); + it('a single-select curated field over an Array(...) contract commits a WRAPPED [value]/[] instead of a bare scalar', () => { const app = makeApp(); const onApplyCurated = vi.fn(); diff --git a/tests/unit/multi-select-field.test.ts b/tests/unit/multi-select-field.test.ts index 9a22b511..9180dd07 100644 --- a/tests/unit/multi-select-field.test.ts +++ b/tests/unit/multi-select-field.test.ts @@ -482,7 +482,7 @@ describe('buildMultiSelectField — error-mode fallback (#360 policy)', () => { expect(triggerEl(handle.el)).not.toBeNull(); }); - it('an error status arriving while the popover is open cancels it, with no onApply', () => { + it('an error status arriving while the popover is open cancels it, with no onApply, and focuses the fallback input not (#189 F2a)', () => { const onApply = vi.fn(); const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, onApply })); document.body.appendChild(handle.el); @@ -492,6 +492,183 @@ describe('buildMultiSelectField — error-mode fallback (#360 policy)', () => { expect(handle.isOpen()).toBe(false); expect(onApply).not.toHaveBeenCalled(); expect(popover()).toBeNull(); + // F2a: the doomed trigger (about to be detached by the swap) is never + // focused — focus lands on the freshly-swapped-in error input instead. + expect(document.activeElement).toBe(errorInputEl(handle.el)); + }); +}); + +describe('buildMultiSelectField — raw-string committed value (#189 F1)', () => { + it('triggerText shows the raw string verbatim when active, never joined/counted', () => { + const handle = buildMultiSelectField(baseOpts({ value: 'typed raw text', active: true })); + expect(triggerEl(handle.el).textContent).toBe('typed raw text'); + }); + + it('an inactive raw string reads as the inactive text', () => { + const handle = buildMultiSelectField(baseOpts({ value: '', active: false })); + expect(triggerEl(handle.el).textContent).toBe('All'); + }); + + it('opening the popover from a raw-string committed value seeds an empty draft (Array.isArray guard)', () => { + const handle = buildMultiSelectField(baseOpts({ value: 'typed raw', active: true })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + expect(optionCbs().every((cb) => !cb.checked)).toBe(true); + }); + + it('Apply from a raw-string committed value treats the prior selection as empty for the no-op check', () => { + const onApply = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: 'typed raw', active: true, onApply })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + setChecked(optionCbs()[0], true); // check Alpha + click(applyBtn()); + expect(onApply).toHaveBeenCalledWith(['a'], true); + }); + + it('a raw string entering error mode seeds the error input verbatim (not joined)', () => { + const handle = buildMultiSelectField(baseOpts({ value: 'typed raw', active: true, status: { status: 'source-error' } })); + document.body.appendChild(handle.el); + expect(errorInputEl(handle.el)!.value).toBe('typed raw'); + }); +}); + +describe('buildMultiSelectField — errorEdited reset + listener detach on recovery (#189 F5)', () => { + it('re-entering error mode reseeds fresh (errorEdited reset), discarding a prior uncommitted edit', () => { + const onFallbackCommit = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'source-error' }, onFallbackCommit })); + document.body.appendChild(handle.el); + type(errorInputEl(handle.el)!, 'stale edit'); + handle.updateStatus({ status: 'ready' }); // leaves error mode, never committing + handle.updateStatus({ status: 'helper-error' }); // re-enters error mode + const input = errorInputEl(handle.el)!; + expect(input.value).toBe('a'); // reseeded from the committed value, not the stale edit + input.dispatchEvent(new Event('blur')); // no edit since re-entry — must not commit + expect(onFallbackCommit).not.toHaveBeenCalled(); + }); + + it('leaving error mode detaches the fallback listeners so a native blur-on-removal can never force a commit', () => { + const onFallbackCommit = vi.fn(); + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true, status: { status: 'source-error' }, onFallbackCommit })); + document.body.appendChild(handle.el); + const input = errorInputEl(handle.el)!; + type(input, 'typed but never committed'); + handle.updateStatus({ status: 'ready' }); // recovery swaps the trigger back in + // Simulates the native blur-on-removal a real browser fires when a + // FOCUSED element is removed from the document (happy-dom does not + // reproduce this on its own) — must be inert now the listener is gone. + input.dispatchEvent(new Event('blur')); + expect(onFallbackCommit).not.toHaveBeenCalled(); + }); +}); + +describe('buildMultiSelectField — Tab focus trap inside the dialog (#189 F3)', () => { + const tab = (target: EventTarget, shiftKey = false): boolean => + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey, bubbles: true, cancelable: true })); + + it('Tab from the LAST focusable element wraps to the first (the search input)', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + applyBtn().focus(); // the last focusable row in the dialog + tab(popover()!); + expect(document.activeElement).toBe(searchInput()); + }); + + it('Shift+Tab from the FIRST focusable element (search) wraps to the last (Apply)', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + searchInput().focus(); + tab(popover()!, true); + expect(document.activeElement).toBe(applyBtn()); + }); + + it('Tab/Shift-Tab from an element in the middle of the dialog does not trap (default behavior)', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + selectAllCb().focus(); + const forward = tab(popover()!); + const backward = tab(popover()!, true); + expect(forward).toBe(true); // not preventDefault-ed — the browser's own Tab order applies + expect(backward).toBe(true); + }); +}); + +describe('buildMultiSelectField — loading affordance while the popover is open (#189 F6)', () => { + it('a status-only waiting/loading/idle/stale update while open disables the checklist body, sets aria-busy, and announces Loading options…, keeping Cancel usable', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + handle.updateStatus({ status: 'loading' }); + expect(popover()!.getAttribute('aria-busy')).toBe('true'); + expect(searchInput().disabled).toBe(true); + expect(selectAllCb().disabled).toBe(true); + expect(optionCbs().every((cb) => cb.disabled)).toBe(true); + expect(clearBtn().hasAttribute('disabled')).toBe(true); + expect(applyBtn().hasAttribute('disabled')).toBe(true); + expect(cancelBtn().hasAttribute('disabled')).toBe(false); // Cancel stays usable + expect(liveText()).toBe('Loading options…'); + // The popover itself is still open — a status-only publish is never a + // rebuild, so the draft can't have changed. + expect(handle.isOpen()).toBe(true); + }); + + it('restores the checklist body and the normal live-region count once status returns to ready', () => { + const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + handle.updateStatus({ status: 'loading' }); + handle.updateStatus({ status: 'ready' }); + expect(popover()!.getAttribute('aria-busy')).toBe('false'); + expect(searchInput().disabled).toBe(false); + expect(selectAllCb().disabled).toBe(false); + expect(optionCbs().every((cb) => !cb.disabled)).toBe(true); + expect(applyBtn().hasAttribute('disabled')).toBe(false); + expect(liveText()).toBe('3 of 3 options'); + }); + + it('a stale:true status update (independent of a named status) also disables the checklist while open', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + handle.updateStatus({ status: 'ready', stale: true }); + expect(popover()!.getAttribute('aria-busy')).toBe('true'); + expect(applyBtn().hasAttribute('disabled')).toBe(true); + }); + + it('a waiting status update while CLOSED never throws and has no visible popover effect', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + expect(() => handle.updateStatus({ status: 'waiting', waitingFor: ['x'] })).not.toThrow(); + expect(popover()).toBeNull(); + }); + + it('a second consecutive update that resolves to the SAME busy state is a no-op (idempotent)', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + handle.updateStatus({ status: 'loading' }); // busy: true + handle.updateStatus({ status: 'idle' }); // still busy: true — no-op branch + expect(popover()!.getAttribute('aria-busy')).toBe('true'); + expect(applyBtn().hasAttribute('disabled')).toBe(true); + }); +}); + +describe('buildMultiSelectField — focusTrigger() (#189 F2b)', () => { + it('focuses the trigger when not erroring', () => { + const handle = buildMultiSelectField(baseOpts()); + document.body.appendChild(handle.el); + handle.focusTrigger(); + expect(document.activeElement).toBe(triggerEl(handle.el)); + }); + + it('focuses the error-mode fallback input when erroring', () => { + const handle = buildMultiSelectField(baseOpts({ status: { status: 'source-error' } })); + document.body.appendChild(handle.el); + handle.focusTrigger(); + expect(document.activeElement).toBe(errorInputEl(handle.el)); }); }); diff --git a/tests/unit/spec-schema.test.ts b/tests/unit/spec-schema.test.ts index 794b86e3..cc56b97a 100644 --- a/tests/unit/spec-schema.test.ts +++ b/tests/unit/spec-schema.test.ts @@ -434,3 +434,29 @@ describe('feature validation service', () => { expect(() => createSpecValidationService({ schemaService: null })).toThrow('Spec schema service is required'); }); }); + +describe('dashboard-v1 schema service (#189 authoring-completion conformance)', () => { + // #189's Authoring section: completion should suggest `selection`, + // `selection.mode`, `single`, `multiple`. No dashboard-JSON editor surface + // exists yet (dashboards are authored as exported/imported JSON), but the + // schema-driven completion engine derives its items generically from + // whatever schema service a surface binds — this test pins that the + // generated dashboard schema delivers exactly those suggestions the day a + // surface binds it, the same way `querySpecSchemaService` powers the + // saved-query Spec editor today. + it('offers selection / selection.mode / single / multiple at a filter-definition path', async () => { + const { dashboardV1Schema } = await import('../../src/generated/json-schemas.js'); + const { validateDashboardV1 } = await import('../../src/generated/json-schema-validators.js'); + const service = createSpecSchemaService({ schema: dashboardV1Schema, validateCompiled: validateDashboardV1 }); + const root = { id: 'd1', tiles: [], filters: [{ id: 'f1', parameter: 'p', selection: {} }] }; + + const filterProps = service.propertiesAtPath({ root, path: ['filters', 0] }); + expect(filterProps.map((p) => p.name)).toContain('selection'); + + const selectionProps = service.propertiesAtPath({ root, path: ['filters', 0, 'selection'] }); + expect(selectionProps.map((p) => p.name)).toEqual(['mode']); + + const mode = service.schemaAtPath({ root, path: ['filters', 0, 'selection', 'mode'] }); + expect(mode.candidates.flatMap((c) => c.enum ?? [])).toEqual(['single', 'multiple']); + }); +}); From a7dc5ff2e83997b297764bd8ef1a69c335204444 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 22 Jul 2026 04:54:13 +0000 Subject: [PATCH 07/10] fix(#189): one executable-consumer rule; selection contracts validated at whole-workspace semantics (merge-gate review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Executable consumer' is now defined once (gatherExecutableConsumers): a filter's resolved executable target tiles, nothing else — Filter sources can never be consumers under #360's single-layer cascading rule, so resolveFilterSelection drops its dependentSources input. validateDashboardSemantics now runs the same resolver at authoring/import time, mapping diagnostics to exact JSON paths (filters[i].selection.mode, filters[i].targets[j], filters[i].parameter) so an invalid dashboard is caught at whole-workspace validation instead of only degrading in the viewer; the resolver's bound-aware checks subsume the older per-target undeclared/type-conflict checks for source-backed filters. Co-Authored-By: Claude Fable 5 --- src/core/filter-selection.ts | 161 ++++++++++-------- .../application/dashboard-viewer-session.ts | 41 ++--- src/dashboard/model/workspace-semantics.ts | 116 ++++++++++++- tests/unit/filter-selection.test.ts | 66 ++++--- tests/unit/import-planner.test.ts | 13 +- tests/unit/saved-query-mutation.test.ts | 39 ++++- tests/unit/state.test.ts | 7 +- tests/unit/workspace-semantics.test.ts | 83 +++++++++ 8 files changed, 392 insertions(+), 134 deletions(-) diff --git a/src/core/filter-selection.ts b/src/core/filter-selection.ts index 45cd313d..d803ee55 100644 --- a/src/core/filter-selection.ts +++ b/src/core/filter-selection.ts @@ -7,14 +7,30 @@ // // This module is pure: no DOM, no globals, no fetch. `resolveFilterSelection` // takes a structural snapshot of a filter definition, the dashboard's -// `ParameterAnalysis` (#173), the caller's own notion of which tiles are -// currently executable, and any dependent Filter sources' own declarations of -// the same parameter (#360: a Filter source may declare `{name:Type}` params -// backed by another source's control) — and returns the agreed contract, the -// effective single/multiple mode, and every diagnostic that blocks the -// helper. `sameSelection`/`canonicalizeSelection`/`reconcileSelection` are the -// pure value-side helpers the multiselect control and its option-refresh +// `ParameterAnalysis` (#173), and the caller's own notion of which tiles are +// currently executable — and returns the agreed contract, the effective +// single/multiple mode, and every diagnostic that blocks the helper. +// `sameSelection`/`canonicalizeSelection`/`reconcileSelection` are the pure +// value-side helpers the multiselect control and its option-refresh // reconciliation need once a helper IS exposed. +// +// "Executable consumer" — defined ONCE, here (merge-gate review round for +// #189/#360): EXACTLY a filter's resolved executable target TILES — explicit +// `targets` when present (each must be an executable tile with a bound +// declaration, fail-closed per target), else every executable tile with a +// bound declaration. A Filter SOURCE is NEVER a consumer: under #360's +// single-layer cascading rule, a source that itself depends on a +// SOURCE-BACKED parameter is cascading-invalid and never executes +// (`filter-source-cascading`, `filter-execution.ts`), and only source-backed +// filters get a selection contract at all — so a non-tile executable +// consumer of a contract-bearing parameter cannot exist. `gatherExecutableConsumers` +// is the one place this gathering happens; the session, the merge-feeding +// logic, and the whole-workspace semantic validator all call it (or +// `resolveFilterSelection`, which calls it internally) so this definition can +// never drift across callers. Revisit ONLY if multi-layer source dependencies +// (a Filter source depending on another Filter source's control) ever land — +// today that's structurally impossible, so there is nothing to gather from a +// source. import { parseParamType, conflictingTypes } from './param-type.js'; import type { ParsedParamType } from './param-type.js'; @@ -40,24 +56,6 @@ export interface FilterSelectionFilterDef { selection?: { mode?: string }; } -/** - * One dependent Filter source's own declarations of the parameter being - * resolved (#360: a Filter source may declare `{name:Type}` params backed by - * ANOTHER source's control) — always an ADDITIONAL executable consumer, - * regardless of the filter's `targets`, since a Filter source has no `targets` - * concept of its own. `declarations` carries every occurrence's raw declared - * type text, one entry per occurrence (mirrors `AnalyzedDeclaration.type` / - * `conflictingTypes`'s own input shape — see `FilterSourceAnalysis` in - * `filter-execution.ts`, whose `dependsOn` names the parameters a caller - * would filter this down to) so a dependent source that declares the same - * parameter twice with disagreeing types still surfaces as a conflict here. - */ -export interface FilterSelectionDependentSource { - sourceId: string; - label?: string; - declarations: { type: string }[]; -} - /** * The agreed consumer contract across every executable consumer of a filter's * parameter: whether they all declare it as a bare scalar (`array: false`) or @@ -105,21 +103,71 @@ export interface FilterSelectionResolution { const err = (code: string, message: string, extra: Record = {}): FilterSelectionDiagnostic => diagnostic('error', code, message, extra) as FilterSelectionDiagnostic; +/** + * Gather the raw `{name:Type}` declarations of every EXECUTABLE consumer of + * `filter.parameter` — the one shared "executable consumer" definition (see + * this module's own top-of-file doc comment): explicit `filter.targets`, + * when present and non-empty, each of which must be an `executableTileIds` + * member AND have at least one BOUND declaration of `filter.parameter` in + * `analysis` — a target missing either fails closed with its own diagnostic + * (and contributes no consumer entries), per target, so multiple bad targets + * each get their own diagnostic; else (no `targets`, or an empty array) every + * executable tile with a bound declaration of the parameter. A Filter source + * is never a consumer (see the module doc comment for why). + * + * `resolveFilterSelection` calls this internally; the session, the + * merge-feeding logic, and `validateDashboardSemantics` (whole-workspace + * authoring/import-time validation) call it directly so all three share + * EXACTLY this gathering step and can never drift apart on what counts as a + * consumer. Pure. + */ +export function gatherExecutableConsumers( + filter: FilterSelectionFilterDef, + analysis: ParameterAnalysis, + executableTileIds: ReadonlySet, +): { entries: { sourceId: string; type: string }[]; diagnostics: FilterSelectionDiagnostic[]; targetProblem: boolean } { + const name = filter.parameter; + const field = analysis.fields[name]; + const diagnostics: FilterSelectionDiagnostic[] = []; + const entries: { sourceId: string; type: string }[] = []; + let targetProblem = false; + if (filter.targets && filter.targets.length) { + for (const targetId of filter.targets) { + if (!executableTileIds.has(targetId)) { + targetProblem = true; + diagnostics.push(err( + 'filter-selection-target-not-executable', + `Filter "${filter.id}" target "${targetId}" is not an executable tile.`, + { filterId: filter.id, parameter: name, sourceId: targetId }, + )); + continue; + } + const bound = (field?.declarations || []).filter((d) => d.bound && d.source === targetId); + if (!bound.length) { + targetProblem = true; + diagnostics.push(err( + 'filter-selection-target-missing-declaration', + `Filter "${filter.id}" target "${targetId}" does not declare {${name}}.`, + { filterId: filter.id, parameter: name, sourceId: targetId }, + )); + continue; + } + for (const d of bound) entries.push({ sourceId: targetId, type: d.type }); + } + } else { + for (const d of field?.declarations || []) { + if (d.bound && executableTileIds.has(d.source)) entries.push({ sourceId: d.source, type: d.type }); + } + } + return { entries, diagnostics, targetProblem }; +} + /** * Resolve one Dashboard filter's curated-helper contract and effective * selection mode (#189). * - * Consumer gathering: - * - explicit `filter.targets`, when present and non-empty: each target id - * must be an `executableTileIds` member AND have at least one BOUND - * declaration of `filter.parameter` in `analysis` — a target missing - * either fails closed with its own diagnostic (and contributes no - * consumer entries), per target, so multiple bad targets each get their - * own diagnostic; - * - no `targets` (or an empty array): every executable tile with a bound - * declaration of the parameter; - * - `dependentSources`' own declarations of the parameter are ALWAYS - * additional consumers, on top of either of the above. + * Consumer gathering: see `gatherExecutableConsumers` — the one shared + * "executable consumer" definition this function calls internally. * * Contract compatibility, over the gathered consumer declarations: * - zero consumer declarations at all → `filter-selection-no-consumers` @@ -159,46 +207,11 @@ export function resolveFilterSelection( filter: FilterSelectionFilterDef, analysis: ParameterAnalysis, executableTileIds: ReadonlySet, - dependentSources: readonly FilterSelectionDependentSource[] = [], ): FilterSelectionResolution { - const diagnostics: FilterSelectionDiagnostic[] = []; const name = filter.parameter; - const field = analysis.fields[name]; - - // ── Gather every executable consumer's raw declaration of {name} ───────── - const entries: { sourceId: string; type: string }[] = []; - let targetProblem = false; - if (filter.targets && filter.targets.length) { - for (const targetId of filter.targets) { - if (!executableTileIds.has(targetId)) { - targetProblem = true; - diagnostics.push(err( - 'filter-selection-target-not-executable', - `Filter "${filter.id}" target "${targetId}" is not an executable tile.`, - { filterId: filter.id, parameter: name, sourceId: targetId }, - )); - continue; - } - const bound = (field?.declarations || []).filter((d) => d.bound && d.source === targetId); - if (!bound.length) { - targetProblem = true; - diagnostics.push(err( - 'filter-selection-target-missing-declaration', - `Filter "${filter.id}" target "${targetId}" does not declare {${name}}.`, - { filterId: filter.id, parameter: name, sourceId: targetId }, - )); - continue; - } - for (const d of bound) entries.push({ sourceId: targetId, type: d.type }); - } - } else { - for (const d of field?.declarations || []) { - if (d.bound && executableTileIds.has(d.source)) entries.push({ sourceId: d.source, type: d.type }); - } - } - for (const ds of dependentSources) { - for (const decl of ds.declarations) entries.push({ sourceId: ds.sourceId, type: decl.type }); - } + const gathered = gatherExecutableConsumers(filter, analysis, executableTileIds); + const { entries, targetProblem } = gathered; + const diagnostics: FilterSelectionDiagnostic[] = [...gathered.diagnostics]; // ── Resolve the agreed contract from `entries` ──────────────────────────── let contract: FilterSelectionContract | null = null; diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 25c14c86..1fa570f4 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -39,7 +39,7 @@ import { analyzeFilterSource, prepareFilterSource } from '../../core/filter-exec import type { FilterSourceAnalysis } from '../../core/filter-execution.js'; import { readFilterOptions } from '../../core/filter-options.js'; import { resolveFilterSelection, sameSelection } from '../../core/filter-selection.js'; -import type { FilterSelectionFilterDef, FilterSelectionDependentSource } from '../../core/filter-selection.js'; +import type { FilterSelectionFilterDef } from '../../core/filter-selection.js'; import { mergeDashboardFilterHelpers } from '../../core/dashboard-filters.js'; import type { FilterProvider, FilterHelperOption, FilterDiagnostic, MergeDashboardFilterHelpersResult, @@ -607,8 +607,11 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // // Review finding (major): `resolveFilterSelection` above only agrees over // this filter's own resolved TARGETS (explicit `def.targets`, else the - // tiles declaring the parameter) plus dependent sources — it never looks at - // a tile OUTSIDE that scope. But the per-wave merge + // tiles declaring the parameter) — it never looks at a tile OUTSIDE that + // scope (a Filter source is never a consumer at all — see + // `gatherExecutableConsumers`'s doc comment in `core/filter-selection.ts` + // for the single shared "executable consumer" definition and the #360 + // cascading rule behind it). But the per-wave merge // (`mergeDashboardFilterHelpers`, `core/dashboard-filters.ts`) rejects a // curated field on `control.conflict` from `fieldControls(analysis)` — // computed DASHBOARD-WIDE, over every tile's declaration of the parameter, @@ -629,33 +632,21 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa for (const filter of filters) { if (!filter.sourceId) continue; // plain root filter — no contract, untouched const source = filterSources.get(filter.sourceId)!; // built above for every sourceId-bearing filter - // Every OTHER Filter source's own declarations of this parameter (#360: - // a Filter source may declare `{name:Type}` params fed by ANOTHER - // source's control). NOTE: #360's cascading rule already forbids any - // Filter source from depending on a SOURCE-BACKED parameter (this - // filter's own parameter always qualifies, since it has a `sourceId`) — - // any source that structurally declares it here would already carry its - // own `filter-source-cascading` diagnostic and never run. So in - // practice this is always empty for a source-backed filter; it is still - // wired through generically (cheap, and future-proof against this - // resolution ever being asked for a plain root filter too). - const dependentSources: FilterSelectionDependentSource[] = []; - for (const other of filterSources.values()) { - if (other.id === filter.sourceId) continue; - const field = other.analyzed.analysis.fields[filter.def.parameter]; - if (!field || !field.declarations.length) continue; - dependentSources.push({ - sourceId: other.id, - label: other.query ? queryName(other.query) : other.id, - declarations: field.declarations.map((d) => ({ type: d.type })), - }); - } + // A Filter source is NEVER an executable consumer of its own filter's + // parameter (#360's single-layer cascading rule already forbids any + // Filter source from depending on a SOURCE-BACKED parameter — this + // filter's own parameter always qualifies, since it has a `sourceId` — + // so a source that structurally declared it would already carry its own + // `filter-source-cascading` diagnostic and never run). See + // `gatherExecutableConsumers`'s doc comment in `core/filter-selection.ts` + // for the one shared "executable consumer" definition this resolution + // (and the semantic validator's own construction-time re-check) both use. const filterSelectionDef: FilterSelectionFilterDef = { id: filter.def.id, parameter: filter.def.parameter, targets: Array.isArray(filter.def.targets) ? filter.def.targets : undefined, selection: filter.def.selection, }; - const resolution = resolveFilterSelection(filterSelectionDef, analysis, executableTileIds, dependentSources); + const resolution = resolveFilterSelection(filterSelectionDef, analysis, executableTileIds); // The same signal `mergeDashboardFilterHelpers` gates `control.conflict` // on (`fieldControls(analysis)`, dashboard-wide) — checked here too, even // when `resolution` itself agreed, so this filter can never publish a diff --git a/src/dashboard/model/workspace-semantics.ts b/src/dashboard/model/workspace-semantics.ts index a8897017..9b103ebc 100644 --- a/src/dashboard/model/workspace-semantics.ts +++ b/src/dashboard/model/workspace-semantics.ts @@ -14,6 +14,11 @@ import type { WorkspaceDiagnostic } from './workspace-diagnostics.js'; import { jsonSchemaValidationService, SPEC_CODECS } from '../../core/library-codec.js'; import type { JsonSchemaValidationService } from '../../core/json-schema-validation.js'; import { scanParamDeclarations } from '../../core/param-scan.js'; +import { analyzeParameterizedSources } from '../../core/param-pipeline.js'; +import type { ParameterAnalysis, ParameterizedSourceInput } from '../../core/param-pipeline.js'; +import { resolveFilterSelection } from '../../core/filter-selection.js'; +import type { FilterSelectionFilterDef, FilterSelectionDiagnostic } from '../../core/filter-selection.js'; +import { resolvePresentation } from './presentation-resolver.js'; export const FLOW_LAYOUT_V1_SCHEMA_ID = 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json'; @@ -86,6 +91,35 @@ const patchRendererType = (patch: unknown): string | undefined => { const normalizeParamType = (type: string): string => type.replace(/\s+/g, ' ').trim(); +// Every `resolveFilterSelection` diagnostic code, mapped to its exact +// dashboard JSON path per the maintainer's #189/#360 merge-gate contract: +// mode-table codes point at the `selection.mode` the caller asked for; +// per-target codes point at the OFFENDING `targets[j]` entry (found by +// matching the diagnostic's own `sourceId` extra field — the target tile id +// — against the filter's raw `targets` array; `filters[i].targets` itself +// when no exact index match is found, which should not happen in practice +// since every per-target diagnostic's `sourceId` IS one of `targets`); every +// other code (no-consumers, mixed-arity, type/array-element conflict, nested +// array — the contract/agreement diagnostics, which are never about one +// single target) points at `filters[i].parameter`. Codes/messages are kept +// verbatim from the resolver — this only ever chooses WHERE to report them. +const SELECTION_MODE_TABLE_CODES = new Set([ + 'filter-selection-mode-requires-array', 'filter-selection-unknown-mode', +]); +const SELECTION_TARGET_CODES = new Set([ + 'filter-selection-target-not-executable', 'filter-selection-target-missing-declaration', +]); + +function selectionDiagnosticPath(filterPath: Path, rawTargets: unknown, diag: FilterSelectionDiagnostic): Path { + if (SELECTION_MODE_TABLE_CODES.has(diag.code)) return [...filterPath, 'selection', 'mode']; + if (SELECTION_TARGET_CODES.has(diag.code)) { + const targetId = typeof diag.sourceId === 'string' ? diag.sourceId : undefined; + const index = targetId !== undefined && Array.isArray(rawTargets) ? rawTargets.indexOf(targetId) : -1; + return index >= 0 ? [...filterPath, 'targets', index] : [...filterPath, 'targets']; + } + return [...filterPath, 'parameter']; +} + // --- fail-closed version pre-scans ------------------------------------------ // Unknown future resource versions fail closed with ONE precise diagnostic. // The codecs run these before structural schema validation and suppress the @@ -247,6 +281,29 @@ export function validateDashboardSemantics(dashboard: unknown, { } const tilesById = new Map(); const tileQueryIds = new Set(); + // #189/#360 selection-contract validation (the `filters` block below) needs + // the SAME executable-tile-id set and tile-side `ParameterAnalysis` the + // Dashboard viewer session builds (`dashboard-viewer-session.ts`, the + // `isRunnableTileRuntime` predicate and its `analysis` construction) so a + // filter's curated-helper contract is diagnosed identically here (at + // authoring/import time) and there (at open time). A tile is "executable" + // statically when its `queryId` resolves to a real query and + // `resolvePresentation` — the SAME RFC 7396 presentation resolver the + // session, authoring, and import flows all share — resolves it to a + // non-text panel. `resolvePresentation` is called here WITHOUT + // `resultColumns`, exactly as the session's own construction-time call + // does (result-column role validation needs a live result, unavailable at + // authoring/import time) — so this is not an approximation of the + // session's check, it is the identical structural check, at the identical + // (query-only) information level. `tileParamInputs` mirrors every tile + // (empty SQL for a non-executable one) so `analyzeParameterizedSources` + // records the same per-field declaration bookkeeping the session's own + // `analysis` does — a filter's `{name:Type}` field `analysis.fields[name]` + // needs every tile's declaration, not just the executable ones, to gather + // consumers correctly (`gatherExecutableConsumers` itself filters by + // `executableTileIds`). + const executableTileIds = new Set(); + const tileParamInputs: ParameterizedSourceInput[] = []; for (const [index, tile] of tiles.entries()) { if (!isObject(tile)) continue; const tileId = stringId(tile.id); @@ -263,6 +320,7 @@ export function validateDashboardSemantics(dashboard: unknown, { emit([...path, 'tiles', index, 'queryId'], 'dashboard-tile-query-missing', `Tile references unknown saved query ${JSON.stringify(queryId)}`); } + let tileSql = ''; if (query !== undefined) { const role = queryDashboardRole(query); if (role === 'setup') { @@ -272,7 +330,19 @@ export function validateDashboardSemantics(dashboard: unknown, { emit([...path, 'tiles', index, 'queryId'], 'dashboard-tile-role-incompatible', `Tile references ${JSON.stringify(role)}-role query ${JSON.stringify(queryId)}; tiles require role panel`); } + // Executability does not itself gate on `role` — neither does the + // session's own `isRunnableTileRuntime` (it only checks query/isText/ + // presentationError) — a role-incompatible tile already gets its own + // diagnostic above regardless of whether it also resolves a panel. + const resolved = resolvePresentation({ query, tile, path: [...path, 'tiles', index] }); + const resolvedType = resolved.ok && isObject(resolved.panel.cfg) && typeof resolved.panel.cfg.type === 'string' + ? resolved.panel.cfg.type : undefined; + if (resolved.ok && resolvedType !== 'text') { + tileSql = isObject(query) && typeof query.sql === 'string' ? query.sql : ''; + if (tileId !== undefined) executableTileIds.add(tileId); + } } + if (tileId !== undefined) tileParamInputs.push({ id: tileId, kind: 'tile', sql: tileSql, bindPolicy: 'row-returning' }); const presentation = isObject(tile.presentation) ? tile.presentation : undefined; if (!presentation) continue; if (typeof presentation.variant === 'string' && query !== undefined) { @@ -292,6 +362,7 @@ export function validateDashboardSemantics(dashboard: unknown, { } } } + const tileAnalysis: ParameterAnalysis = analyzeParameterizedSources(tileParamInputs); // --- layout -------------------------------------------------------------- const layout = isObject(dashboard.layout) ? dashboard.layout : undefined; @@ -421,7 +492,20 @@ export function validateDashboardSemantics(dashboard: unknown, { const parameter = typeof filter.parameter === 'string' ? filter.parameter : undefined; if (Array.isArray(filter.targets)) { // Absent targets resolve to every compatible panel tile; explicit - // targets must each exist and declare the parameter compatibly. + // targets must each exist. A PLAIN filter (no `sourceQueryId`) also + // requires each target to declare the parameter compatibly, checked + // right here (unbound `declarationsFor`, structural only — plain + // filters never get a `resolveFilterSelection` contract, per #189: only + // a source-backed filter's curated helper needs a resolved contract). + // A SOURCE-BACKED filter's target/parameter compatibility is instead + // fully covered below by `resolveFilterSelection` — the bound-aware + // (`analysis`'s `bindPolicy`-derived declarations), SAME shared + // "executable consumer" definition the viewer session itself uses — so + // running this cruder check too would duplicate `filter-target-missing`'s + // siblings under different codes for the same targets; it is skipped + // for a source-backed filter (existence itself, `filter-target-missing`, + // still applies to every filter — target existence is not part of the + // parameter-contract question `resolveFilterSelection` answers). const declaredTypes = new Map(); for (const [targetIndex, target] of filter.targets.entries()) { const targetId = stringId(target); @@ -431,6 +515,7 @@ export function validateDashboardSemantics(dashboard: unknown, { `Filter target ${JSON.stringify(target)} references no tile`); continue; } + if (sourceQueryId !== undefined) continue; if (parameter === undefined || tileEntry.queryId === undefined) continue; const targetQuery = queriesById.get(tileEntry.queryId); if (targetQuery === undefined) continue; // already reported at the tile @@ -440,11 +525,38 @@ export function validateDashboardSemantics(dashboard: unknown, { `Target tile ${JSON.stringify(targetId)}'s query does not declare parameter ${JSON.stringify(parameter)}`); } else declaredTypes.set(tileEntry.queryId, normalizeParamType(declared.type)); } - if (new Set(declaredTypes.values()).size > 1) { + if (sourceQueryId === undefined && new Set(declaredTypes.values()).size > 1) { emit([...filterPath, 'parameter'], 'filter-parameter-type-conflict', `Parameter ${JSON.stringify(parameter)} is declared with conflicting types across filter targets: ${[...new Set(declaredTypes.values())].sort().join(', ')}`); } } + // #189/#360: a SOURCE-BACKED filter's selection contract — run the SAME + // resolver the viewer session uses (`resolveFilterSelection`, over the + // SAME shared "executable consumer" definition, `core/filter-selection.ts`) + // so an invalid contract is diagnosed at whole-workspace authoring/import + // time, not only after opening the viewer. Plain filters (no + // `sourceQueryId`) stay out of contract validation entirely, exactly as + // the session does (it never resolves a selection for them either). + if (sourceQueryId !== undefined && parameter !== undefined) { + const rawTargets: unknown = filter.targets; + const targets = Array.isArray(rawTargets) + ? rawTargets.map(stringId).filter((id): id is string => id !== undefined) + : undefined; + const filterSelectionDef: FilterSelectionFilterDef = { id: filterId ?? '', parameter, targets }; + if (isObject(filter.selection)) { + filterSelectionDef.selection = { + mode: typeof filter.selection.mode === 'string' ? filter.selection.mode : undefined, + }; + } + const resolution = resolveFilterSelection(filterSelectionDef, tileAnalysis, executableTileIds); + for (const diag of resolution.diagnostics) { + out.push({ + path: selectionDiagnosticPath(filterPath, rawTargets, diag), + severity: 'error', code: diag.code, message: diag.message, + ...(dashboardId === undefined ? {} : { resource: dashboardId }), + }); + } + } if (Object.hasOwn(filter, 'defaultValue')) { const defaultBytes = utf8ByteLength(canonicalJson(filter.defaultValue)); if (defaultBytes > PORTABLE_LIMITS.maxSerializedFilterDefaultBytes) { diff --git a/tests/unit/filter-selection.test.ts b/tests/unit/filter-selection.test.ts index dd8d0a1a..890969f4 100644 --- a/tests/unit/filter-selection.test.ts +++ b/tests/unit/filter-selection.test.ts @@ -3,11 +3,12 @@ import { analyzeParameterizedSources } from '../../src/core/param-pipeline.js'; import type { ParameterAnalysis } from '../../src/core/param-pipeline.js'; import { resolveFilterSelection, + gatherExecutableConsumers, sameSelection, canonicalizeSelection, reconcileSelection, } from '../../src/core/filter-selection.js'; -import type { FilterSelectionFilterDef, FilterSelectionDependentSource } from '../../src/core/filter-selection.js'; +import type { FilterSelectionFilterDef } from '../../src/core/filter-selection.js'; // Fixtures are round-tripped through the real `analyzeParameterizedSources` // (the repo's convention — see `tests/unit/filter-bar.test.ts`'s `paramsFor` @@ -174,37 +175,54 @@ describe('resolveFilterSelection — consumer resolution', () => { expect(codesOf(r.diagnostics)).toEqual(['filter-selection-no-consumers']); }); - it('dependent Filter source declarations are ALWAYS additional consumers, agreeing case', () => { - const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }]); - const dependents: FilterSelectionDependentSource[] = [ - { sourceId: 'dep1', label: 'Dependent filter', declarations: [{ type: 'UInt8' }] }, - ]; - const r = resolveFilterSelection(filterDef(), analysis, new Set(['a']), dependents); + // #360's single-layer cascading rule: a Filter source that itself depends on + // a SOURCE-BACKED parameter is cascading-invalid and never executes, and + // only source-backed filters get a selection contract — so a non-tile + // executable consumer of a contract-bearing parameter cannot exist. A + // Filter source's own declaration of the parameter therefore NEVER + // influences the contract, even when it conflicts: it simply isn't in + // `executableTileIds` (a Filter source is not a tile), so + // `gatherExecutableConsumers` never picks it up, agreeing or not. + it('a Filter source\'s own declaration of the parameter — even a conflicting one — never influences the contract', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'dep1', sql: 'SELECT * FROM u WHERE x = {x:String}' }, // a Filter source's own analyzed declaration + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a'])); expect(r.diagnostics).toEqual([]); expect(r.contract).toEqual({ array: false, type: expect.objectContaining({ base: 'UInt8' }) }); + expect(r.mode).toBe('single'); }); - it('dependent Filter source declarations conflicting with tile declarations → type-conflict naming the dependent source', () => { - const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }]); - const dependents: FilterSelectionDependentSource[] = [ - { sourceId: 'dep1', declarations: [{ type: 'String' }] }, - ]; - const r = resolveFilterSelection(filterDef(), analysis, new Set(['a']), dependents); + it('a Filter-source-only declaration (no executable tile declares the parameter) resolves no consumers', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT 1' }, + { id: 'dep1', sql: 'SELECT * FROM u WHERE x = {x:String}' }, + ]); + const r = resolveFilterSelection(filterDef(), analysis, new Set(['a'])); expect(r.mode).toBeNull(); - expect(codesOf(r.diagnostics)).toEqual(['filter-selection-type-conflict']); - expect(r.diagnostics[0].message).toContain('dep1'); - expect(r.diagnostics[0].message).toContain('UInt8'); - expect(r.diagnostics[0].message).toContain('String'); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-no-consumers']); }); +}); - it('dependent-source-only consumers (no targets, no tile declares the parameter) still resolve a contract', () => { - const analysis = analysisFor([{ id: 'a', sql: 'SELECT 1' }]); - const dependents: FilterSelectionDependentSource[] = [ - { sourceId: 'dep1', declarations: [{ type: 'String' }] }, - ]; - const r = resolveFilterSelection(filterDef(), analysis, new Set(['a']), dependents); +describe('gatherExecutableConsumers', () => { + it('is the shared gathering step resolveFilterSelection calls internally — same entries/diagnostics either way', () => { + const analysis = analysisFor([ + { id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }, + { id: 'b', sql: 'SELECT 1' }, + ]); + const r = gatherExecutableConsumers(filterDef({ targets: ['a', 'b'] }), analysis, new Set(['a', 'b'])); + expect(r.entries).toEqual([{ sourceId: 'a', type: 'UInt8' }]); + expect(codesOf(r.diagnostics)).toEqual(['filter-selection-target-missing-declaration']); + expect(r.targetProblem).toBe(true); + }); + + it('no-targets form gathers every executable tile with a bound declaration, no diagnostics', () => { + const analysis = analysisFor([{ id: 'a', sql: 'SELECT * FROM t WHERE x = {x:UInt8}' }]); + const r = gatherExecutableConsumers(filterDef(), analysis, new Set(['a'])); + expect(r.entries).toEqual([{ sourceId: 'a', type: 'UInt8' }]); expect(r.diagnostics).toEqual([]); - expect(r.mode).toBe('single'); + expect(r.targetProblem).toBe(false); }); }); diff --git a/tests/unit/import-planner.test.ts b/tests/unit/import-planner.test.ts index 3f90116e..9cdabcf9 100644 --- a/tests/unit/import-planner.test.ts +++ b/tests/unit/import-planner.test.ts @@ -286,8 +286,13 @@ describe('planImportQueries', () => { // --- planImportDashboard -------------------------------------------------------- describe('planImportDashboard', () => { + // t1's query (p1) declares `{p:String}` so the source-backed filter `flt1` + // (`sourceQueryId: 'f1'`) has a valid selection-contract consumer — #189/ + // #360's `resolveFilterSelection`, now run by `validateDashboardSemantics` + // for every source-backed filter, would otherwise flag zero consumers. + // This suite is about ID-rewriting through import, not filter contracts. const buildBundle = () => bundle({ - queries: [panelQuery('p1', 'incoming p1'), filterQuery('f1', 'incoming f1')], + queries: [{ ...panelQuery('p1', 'incoming p1'), sql: 'SELECT {p:String}' }, filterQuery('f1', 'incoming f1')], dashboards: [dashboardDoc({ id: 'd1', revision: 5, tiles: [{ id: 't1', queryId: 'p1' }], @@ -386,8 +391,12 @@ describe('planReplaceWorkspace', () => { it('replaces queries AND Dashboard atomically when a source Dashboard is selected, including standalone queries', () => { const ws = workspace(); + // t1's query (p1) declares `{p:String}` — see `buildBundle`'s own comment + // above for why a source-backed filter needs a valid consumer here. const bundleWithDashboard = bundle({ - queries: [panelQuery('p1'), filterQuery('f1'), panelQuery('standalone')], + queries: [ + { ...panelQuery('p1'), sql: 'SELECT {p:String}' }, filterQuery('f1'), panelQuery('standalone'), + ], dashboards: [dashboardDoc({ id: 'd1', revision: 2, tiles: [{ id: 't1', queryId: 'p1' }], diff --git a/tests/unit/saved-query-mutation.test.ts b/tests/unit/saved-query-mutation.test.ts index 8c4cc3a4..69962c9f 100644 --- a/tests/unit/saved-query-mutation.test.ts +++ b/tests/unit/saved-query-mutation.test.ts @@ -93,7 +93,16 @@ describe('planSavedQueryMutation — rejection without repair', () => { describe('planSavedQueryMutation — atomic repair', () => { it('removes affected tiles (and prunes their placements and filter targets)', () => { - const plan = planSavedQueryMutation(baseWorkspace(), + // A PLAIN filter (no `sourceQueryId`) here deliberately — this test is + // about tile/target PRUNING mechanics (`removeAffectedTiles`), not + // filter-selection contract validity (#189/#360, `workspace-semantics.ts` + // now runs `resolveFilterSelection` for every SOURCE-BACKED filter). A + // source-backed filter left with zero executable consumers once its only + // tile is gone is itself a real `filter-selection-no-consumers` — exactly + // what the app SHOULD flag — and orthogonal to what this test checks. + const workspace = baseWorkspace(); + workspace.dashboard!.filters = [{ id: 'flt', parameter: 'country', targets: ['t1'] }]; + const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); expect(plan.ok).toBe(true); const dashboard = plan.candidate!.dashboard!; @@ -104,10 +113,15 @@ describe('planSavedQueryMutation — atomic repair', () => { it('removes affected filters when a parameter change invalidates a target', () => { // p1 no longer declares `country`; the filter targeting its tile breaks. + // Since `flt` is source-backed (`sourceQueryId: 'f1'`), `t1` failing to + // declare `country` now surfaces through `resolveFilterSelection`'s own + // (bound-aware) `filter-selection-target-missing-declaration` — which + // subsumes the older unbound `filter-parameter-undeclared` check for a + // source-backed filter's explicit targets (workspace-semantics.ts). const plan = planSavedQueryMutation(baseWorkspace(), { type: 'replace-query', queryId: 'p1', query: panelQuery('p1', 'SELECT a,b') }); expect(plan.ok).toBe(false); - expect(codes(plan.diagnostics)).toContain('filter-parameter-undeclared'); + expect(codes(plan.diagnostics)).toContain('filter-selection-target-missing-declaration'); const repaired = planSavedQueryMutation(baseWorkspace(), { type: 'replace-query', queryId: 'p1', query: panelQuery('p1', 'SELECT a,b') }, @@ -145,14 +159,23 @@ describe('planSavedQueryMutation — atomic repair', () => { }); it('supports remove-affected (tiles and filters together)', () => { - const plan = planSavedQueryMutation(baseWorkspace(), + // A PLAIN filter here too (see the "removes affected tiles" test above + // for why) — `removeAffectedFilters` composed after `removeAffectedTiles` + // recomputes its "targets an affected tile" check against the + // ALREADY-tile-pruned dashboard, so the filter survives regardless of + // `sourceQueryId`; a source-backed filter left with zero consumers here + // would instead (correctly) fail the new selection-contract check. + const workspace = baseWorkspace(); + workspace.dashboard!.filters = [{ id: 'flt', parameter: 'country', targets: ['t1'] }]; + const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected' }, { validationService: jsonSchemaValidationService, schemaService: querySpecSchemaService }); expect(plan.ok).toBe(true); const dashboard = plan.candidate!.dashboard!; expect(dashboard.tiles).toEqual([]); - // The filter is sourced from f1 (not p1), so it survives with its now-empty - // target list — remove-affected removed the affected tile and its target ref. + // The filter survives (not targeting p1's query directly), with its + // now-empty target list — remove-affected removed the affected tile and + // its target ref. expect(dashboard.filters[0].targets).toEqual([]); }); }); @@ -227,13 +250,17 @@ describe('planSavedQueryMutation — repairs skip unaffected and target-less ent describe('planSavedQueryMutation — grafana-grid@1 engine awareness (#291)', () => { it('normalizes through the ACTIVE grid plugin and regenerates the flow@1 fallback on a tile-removing repair', () => { + // A PLAIN filter (no `sourceQueryId`) — this test is about grid-layout + // normalization/fallback regeneration, not filter-selection contract + // validity; see the "removes affected tiles" test above for why a + // source-backed filter left with zero tiles would (correctly) now fail. const workspace: StoredWorkspaceV1 = { storageVersion: 1, id: 'ws', name: 'WS', queries: [panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), filterQuery('f1')], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 8 } } }, - filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], + filters: [{ id: 'flt', parameter: 'country', targets: ['t1'] }], tiles: [{ id: 't1', queryId: 'p1' }], }, } as StoredWorkspaceV1; diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 190741e2..34dbf5ea 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -409,10 +409,15 @@ describe('saved queries', () => { savedQuery({ id: 'p1', sql: 'SELECT a WHERE c={country:String}', favorite: true, dashboard: { role: 'panel' } }), savedQuery({ id: 'f1', sql: "SELECT ['a','b'] AS country", dashboard: { role: 'filter' } }), ]; + // A PLAIN filter (no `sourceQueryId`) — this test is about tile/target + // pruning when the LAST tile referencing a query is removed, not + // filter-selection contract validity (#189/#360); a source-backed + // filter left with zero executable consumers here would now (correctly) + // fail `workspace-semantics.ts`'s new selection-contract check. s.dashboard = { ...blankDashboard(), tiles: [{ id: 't1', queryId: 'p1' }], - filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], + filters: [{ id: 'flt', parameter: 'country', targets: ['t1'] }], }; const commit = fakeWorkspaceCommit(); const result = await toggleFavorite(s, 'p1', commit, genTileId()); diff --git a/tests/unit/workspace-semantics.test.ts b/tests/unit/workspace-semantics.test.ts index f2ebc122..18757f75 100644 --- a/tests/unit/workspace-semantics.test.ts +++ b/tests/unit/workspace-semantics.test.ts @@ -339,6 +339,89 @@ describe('validateDashboardSemantics', () => { expect(has(d, 'filter-parameter-type-conflict')).toBe(true); // String vs UInt32 }); + // #189/#360 merge-gate follow-up: `validateDashboardSemantics` now runs the + // SAME `resolveFilterSelection` the viewer session uses, for every + // SOURCE-BACKED filter, translating its diagnostics to exact dashboard JSON + // paths (mode-table → `selection.mode`, per-target → `targets[j]`, + // contract/agreement → `parameter`). + describe('source-backed filter selection-contract validation (#189/#360)', () => { + it('selection.mode "multiple" against a scalar-only contract → diagnostic at filters[i].selection.mode', () => { + const q = panelQuery('p1'); q.sql = 'SELECT {country:String}'; + const dashboard = dashboardDoc({ + tiles: [tile('t1', 'p1')], layout: flowLayout({ t1: {} }), + filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', selection: { mode: 'multiple' } }], + }); + const d = validateDashboardSemantics(dashboard, { queries: [q, filterQuery('f1')] }); + const diag = d.find((x) => x.code === 'filter-selection-mode-requires-array'); + expect(diag).toBeDefined(); + expect(diag!.path).toEqual(['filters', 0, 'selection', 'mode']); + }); + + it('an explicit target not declaring the parameter → diagnostic at filters[i].targets[j]', () => { + const q = panelQuery('p1'); // default sql 'SELECT 1' — declares nothing + const dashboard = dashboardDoc({ + tiles: [tile('t1', 'p1')], layout: flowLayout({ t1: {} }), + filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], + }); + const d = validateDashboardSemantics(dashboard, { queries: [q, filterQuery('f1')] }); + const diag = d.find((x) => x.code === 'filter-selection-target-missing-declaration'); + expect(diag).toBeDefined(); + expect(diag!.path).toEqual(['filters', 0, 'targets', 0]); + // The older unbound check is subsumed for a source-backed filter — no + // duplicate `filter-parameter-undeclared` on top. + expect(has(d, 'filter-parameter-undeclared')).toBe(false); + }); + + it('implicit targets (none declared) mixing scalar/Array across two tiles → diagnostic at filters[i].parameter', () => { + const qScalar = panelQuery('a'); qScalar.sql = 'SELECT {country:String}'; + const qArray = panelQuery('b'); qArray.sql = 'SELECT {country:Array(String)}'; + const dashboard = dashboardDoc({ + tiles: [tile('t1', 'a'), tile('t2', 'b')], layout: flowLayout({ t1: {}, t2: {} }), + filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1' }], // no targets + }); + const d = validateDashboardSemantics(dashboard, { queries: [qScalar, qArray, filterQuery('f1')] }); + const diag = d.find((x) => x.code === 'filter-selection-mixed-arity'); + expect(diag).toBeDefined(); + expect(diag!.path).toEqual(['filters', 0, 'parameter']); + }); + + it('a nested Array(Array(...)) declaration → diagnostic at filters[i].parameter', () => { + const q = panelQuery('p1'); q.sql = 'SELECT {country:Array(Array(String))}'; + const dashboard = dashboardDoc({ + tiles: [tile('t1', 'p1')], layout: flowLayout({ t1: {} }), + filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1' }], + }); + const d = validateDashboardSemantics(dashboard, { queries: [q, filterQuery('f1')] }); + const diag = d.find((x) => x.code === 'filter-selection-nested-array'); + expect(diag).toBeDefined(); + expect(diag!.path).toEqual(['filters', 0, 'parameter']); + }); + + it('a VALID Array(T) setup produces no selection diagnostics', () => { + const q = panelQuery('p1'); q.sql = 'SELECT {tags:Array(String)}'; + const dashboard = dashboardDoc({ + tiles: [tile('t1', 'p1')], layout: flowLayout({ t1: {} }), + filters: [{ id: 'flt', parameter: 'tags', sourceQueryId: 'f1', selection: { mode: 'multiple' } }], + }); + const d = validateDashboardSemantics(dashboard, { queries: [q, filterQuery('f1')] }); + expect(d.filter((x) => x.code.startsWith('filter-selection-'))).toEqual([]); + }); + + it('a Filter source declaring the SAME parameter (even conflicting) does NOT poison an otherwise-valid contract', () => { + const q = panelQuery('p1'); q.sql = 'SELECT {shared:String}'; + // f1's own SQL declares {shared:UInt64} — a conflicting type — but f1 is + // a Filter SOURCE, never a tile, so it is excluded from the tile-side + // ParameterAnalysis entirely and cannot influence the contract. + const sourceQuery = filterQuery('f1', 'SELECT {shared:UInt64} AS x'); + const dashboard = dashboardDoc({ + tiles: [tile('t1', 'p1')], layout: flowLayout({ t1: {} }), + filters: [{ id: 'flt', parameter: 'shared', sourceQueryId: 'f1' }], + }); + const d = validateDashboardSemantics(dashboard, { queries: [q, sourceQuery] }); + expect(d.filter((x) => x.code.startsWith('filter-selection-'))).toEqual([]); + }); + }); + it('skips target parameter checks when parameter is absent and tolerates unknown target queries', () => { const dashboard = dashboardDoc({ tiles: [tile('t1', 'gone')], layout: flowLayout({ t1: {} }), From 9d552b5f8bd264f146b763e9cee5c892d51a9ec8 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 22 Jul 2026 05:10:09 +0000 Subject: [PATCH 08/10] fix(#189): merge consumes resolved-consumer controls; empty arrays survive to the pipeline (merge-gate review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The over-broad dashboard-wide conflict gate is gone: a declaration outside a filter's resolved executable-consumer set (a presentation-error tile, a never-executing cascading-invalid source, a non-targeted tile) can no longer suppress a valid helper. The per-wave merge now receives, for each curated parameter, a control derived from the resolved contract (value type, no dashboard-wide conflict) — one consumer definition across resolution, merge, and static semantics. toParamValue passes every array through (including []), so value=[], active=true serializes as a real empty Array(T) '[]' instead of collapsing to a blank scalar; default activation inference is array-aware ([] → inactive) — activation is decided exclusively by the active flag. Co-Authored-By: Claude Fable 5 --- .../application/dashboard-viewer-session.ts | 113 ++++++---- tests/unit/dashboard-viewer-session.test.ts | 202 +++++++++++++++--- 2 files changed, 240 insertions(+), 75 deletions(-) diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 1fa570f4..1b2717d0 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -366,14 +366,21 @@ const toValueString = (value: unknown): string => * `prepareParameterizedBatch`/`prepareFilterSource` (`rawValues`, * `committedRootValues`) — a committed multiselect value is a REAL string * array, and the pipeline/serializer already understand `Array(...)`-typed - * params, so it must reach them un-stringified. A non-empty array passes - * through as a DEFENSIVE COPY (never the live array a caller might still - * hold); an EMPTY array reads as "no value" — same as `''` — for every - * missing/inactive/readiness purpose downstream, exactly like a blank text - * filter. Every other shape keeps `toValueString`'s existing coercion, - * unchanged. */ + * params, so it must reach them un-stringified. + * + * Merge-gate review (Finding B): value and activation are INDEPENDENT — an + * array, EMPTY OR NOT, passes through as a DEFENSIVE COPY (never the live + * array a caller might still hold), never coerced to `''`. `emptyValue` + * (`param-pipeline.ts`) already treats a present `[]` as neither `null` nor + * `''`, so the missing/required gate and the serializer (`'[]'` for an + * empty array) both already do the right thing once a real array — never a + * blanked string — reaches them; activation is decided exclusively by the + * active flag/map (`setFilter`'s own value-implies-active by length, + * `effectiveActive`, or an explicit `applyFilter` active flag), never by + * this function collapsing the value first. Every other shape keeps + * `toValueString`'s existing coercion, unchanged. */ const toParamValue = (value: unknown): unknown => - (Array.isArray(value) ? (value.length ? value.slice() : '') : toValueString(value)); + (Array.isArray(value) ? value.slice() : toValueString(value)); /** #189: defensive array copy for every seat that STORES a filter's raw * committed value (`filter.state.value`, an `initialFilters` seed, a @@ -484,7 +491,13 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // Filter runtime records, in filter order. const filters: FilterRuntime[] = (Array.isArray(documentRef.filters) ? documentRef.filters : []).map((def) => { const defaultValue = copyValue(def.defaultValue ?? ''); - const defaultActive = def.defaultActive ?? (def.defaultValue != null && def.defaultValue !== ''); + // Merge-gate review (Finding B): array-aware — an omitted `defaultActive` + // infers INACTIVE from an empty array default (`[]`, matching `''`), and + // ACTIVE from a non-empty one, the same length-based rule `setFilter`'s + // own value-implies-active already applies to a committed array. + const defaultActive = def.defaultActive ?? (Array.isArray(def.defaultValue) + ? def.defaultValue.length > 0 + : (def.defaultValue != null && def.defaultValue !== '')); // #303: a persisted seed for this filter's id overrides the pure-default // init above (untouched when `initialFilters` is absent/empty, or has no // entry for `def.id`). #189: `copyValue` defends against aliasing the @@ -605,30 +618,30 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // ALONGSIDE (never instead of) the per-wave `filterDiagnostics` — see // `buildState`'s doc comment for why these need to be two separate arrays. // - // Review finding (major): `resolveFilterSelection` above only agrees over - // this filter's own resolved TARGETS (explicit `def.targets`, else the - // tiles declaring the parameter) — it never looks at a tile OUTSIDE that - // scope (a Filter source is never a consumer at all — see - // `gatherExecutableConsumers`'s doc comment in `core/filter-selection.ts` - // for the single shared "executable consumer" definition and the #360 - // cascading rule behind it). But the per-wave merge - // (`mergeDashboardFilterHelpers`, `core/dashboard-filters.ts`) rejects a - // curated field on `control.conflict` from `fieldControls(analysis)` — - // computed DASHBOARD-WIDE, over every tile's declaration of the parameter, - // not just this filter's targets. So a filter whose resolution agreed - // (e.g. every explicit target declares `Array(String)`) can still publish - // a `selection` contract and keep its source consumer, only for EVERY - // wave's merge to permanently reject the curated field as - // `filter-target-type-conflict` (a non-targeted or presentation-error tile - // declares a conflicting `String`) — a stuck hybrid: a published - // multiselect contract with a permanently-dead curated field, never - // reverting to the plain string input. `resolveFilterSelection`'s own - // target-scoped agreement is deliberately narrowed FURTHER here by this - // dashboard-wide gate, for consistency with `mergeDashboardFilterHelpers`' - // field-level conflict rejection: one behavior (fall back, all the way), - // never a hybrid state depending on which layer looks first. + // Merge-gate review: the per-wave merge (`mergeDashboardFilterHelpers`, + // `core/dashboard-filters.ts`) makes its own conflict/type decision from + // whatever `FieldControl` it is fed for a curated parameter's name + // (`control.conflict` → reject, `control.type` → validate/serialize each + // option). Feeding it the dashboard-wide `fieldControls(analysis)` entry — + // every TILE's declaration, unscoped — let a declaration OUTSIDE this + // filter's own resolved executable-consumer set (a presentation-error + // tile, a non-targeted tile: anything `gatherExecutableConsumers` itself + // already excludes) permanently reject a helper `resolveFilterSelection` + // legitimately agreed on. A prior review round "fixed" this by re-gating + // CONSTRUCTION on that same dashboard-wide `control.conflict` + // (`filter-selection-dashboard-type-conflict`) — the maintainer rejected + // that: it fell a filter back to the plain string input for a conflict + // that was never actually reachable from its own resolved consumers. The + // real fix is `curatedControls` below: for every filter that keeps a + // resolved contract, it holds a `FieldControl` whose `type` is that + // contract's own agreed VALUE type (the array's element type for an + // `Array(...)` contract) and no `conflict` — built from the SAME + // resolution `gatherExecutableConsumers` produced, so the merge can never + // see a wider (or narrower) consumer set than construction did. One + // consumer definition, fed to both layers — never a second, broader gate. const controlsByName = new Map(controls.map((control): [string, FieldControl] => [control.name, control])); const staticFilterDiagnostics: FilterDiagnostic[] = []; + const curatedControls = new Map(); for (const filter of filters) { if (!filter.sourceId) continue; // plain root filter — no contract, untouched const source = filterSources.get(filter.sourceId)!; // built above for every sourceId-bearing filter @@ -647,29 +660,35 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa selection: filter.def.selection, }; const resolution = resolveFilterSelection(filterSelectionDef, analysis, executableTileIds); - // The same signal `mergeDashboardFilterHelpers` gates `control.conflict` - // on (`fieldControls(analysis)`, dashboard-wide) — checked here too, even - // when `resolution` itself agreed, so this filter can never publish a - // contract the merge layer would reject on every wave forever (see the - // doc comment above this loop). - const dashboardControl = controlsByName.get(filter.def.parameter); - const dashboardConflict = dashboardControl?.conflict?.length ? dashboardControl.conflict : null; - if (resolution.diagnostics.length || dashboardConflict) { + if (resolution.diagnostics.length) { for (const d of resolution.diagnostics) staticFilterDiagnostics.push(d as FilterDiagnostic); - if (dashboardConflict) { - staticFilterDiagnostics.push(coreDiagnostic('error', 'filter-selection-dashboard-type-conflict', - `Filter "${filter.def.id}" parameter {${filter.def.parameter}} has a dashboard-wide type conflict across ` + - `Panel declarations: ${dashboardConflict.join(' vs ')}. Declarations OUTSIDE this filter's own targets ` + - `still count for the shared curated-field layer (mergeDashboardFilterHelpers), which rejects a ` + - `dashboard-wide conflict regardless of which tiles this filter targets.`, - { filterId: filter.def.id, parameter: filter.def.parameter, types: dashboardConflict })); - } filter.state.sourceId = undefined; source.consumers = source.consumers.filter((consumer) => consumer !== filter); } else { filter.state.selection = { mode: resolution.mode!, array: resolution.contract!.array }; + // Feed the MERGE layer this filter's own resolved contract's value + // type — never the dashboard-wide declaration (see the doc comment + // above this loop) — so a declaration outside + // `gatherExecutableConsumers`'s resolved set can never suppress this + // valid helper. `optional` is untouched — it is not part of the + // type/conflict decision this moves; the dashboard-wide value (always + // present here — any bound executable-consumer declaration already + // guarantees a `fieldControls` entry for this name) carries over as-is. + curatedControls.set(filter.def.parameter, { + name: filter.def.parameter, + type: resolution.contract!.type.raw, + optional: controlsByName.get(filter.def.parameter)?.optional ?? true, + }); } } + // Merge-gate review: only CURATED (source-backed, contract-resolved) + // parameter names are overridden with their resolved-consumer control + // above — every other name (a plain root filter's field, or a Filter + // helper column with no surviving contract at all) keeps its + // dashboard-wide `FieldControl` untouched, exactly as before. + // `mergeDashboardFilterHelpers` itself stays pure; only what the session + // FEEDS it (`applyFilterProviders`, below) changes. + const mergeControls: FieldControl[] = controls.map((control) => curatedControls.get(control.name) || control); // A `FilterSourceRuntime` left with zero consumers (every filter that // named it fell back to the string-input path above) must never execute — // both `runFilterWave` (every KNOWN source) and `runFilterSourceWave` (the @@ -1050,7 +1069,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa providers: [...filterSources.values()] .map((source) => source.provider) .filter((provider): provider is FilterProvider => provider !== null), - controls, values: rawValues(), active: effectiveActive(rawValues(), activeMap()), + controls: mergeControls, values: rawValues(), active: effectiveActive(rawValues(), activeMap()), }); curated = merged.fields; // Published as-is (never deduped — a shared source runs once per wave), plus diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 46aea0a6..712eec89 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -2117,6 +2117,77 @@ describe('searchable multiselect filter contract (#189)', () => { expect(afterEmpty.some((c) => 'param_region' in c.params && Array.isArray(undefined))).toBe(false); }); + // Merge-gate review (Finding B): value and activation are INDEPENDENT. + // `toParamValue` used to collapse EVERY empty array to `''` — including an + // explicitly ACTIVE one (only reachable via `applyFilter`, which sets + // value/active independently; `setFilter`'s own value-implies-active + // deactivates an empty array before this could ever matter, see the test + // above) — so an active `[]` never reached execution as a real array. + it('applyFilter(value: [], active: true) reaches execution as a REAL empty array, serialized "[]" (Finding B)', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'region', type: 'Array(String)' }], rows: [[['a', 'b']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t', 'qt')], + filters: [{ id: 'f-region', parameter: 'region', sourceQueryId: 'src' }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('qt', 'SELECT 1 AS n WHERE x IN {region:Array(String)}'), + query('src', "SELECT ['a','b'] AS region /* source */", { dashboard: { role: 'filter' } }), + ], + })); + await session.start(); + const base = calls.length; + await session.applyFilter('f-region', [], true); + expect(byId(session, 'f-region').active).toBe(true); + expect(byId(session, 'f-region').value).toEqual([]); + const boundCall = calls.slice(base).find((c) => 'param_region' in c.params); + expect(boundCall).toBeDefined(); + expect(boundCall!.params.param_region).toBe('[]'); // a REAL empty array, never blanked to '' + }); + + it('an omitted defaultActive infers INACTIVE from an empty array defaultValue, and ACTIVE from a non-empty one (Finding B, array-aware)', () => { + const inactiveDoc = doc({ filters: [{ id: 'f1', parameter: 'p', defaultValue: [] }] }); + const inactiveSession = createDashboardViewerSession(makeDeps({ document: inactiveDoc })); + expect(byId(inactiveSession, 'f1').active).toBe(false); + expect(byId(inactiveSession, 'f1').value).toEqual([]); + + const activeDoc = doc({ filters: [{ id: 'f1', parameter: 'p', defaultValue: ['a'] }] }); + const activeSession = createDashboardViewerSession(makeDeps({ document: activeDoc })); + expect(byId(activeSession, 'f1').active).toBe(true); + expect(byId(activeSession, 'f1').value).toEqual(['a']); + }); + + it('an inactive root filter keeps a dormant (non-empty) array value blanked to \'\' for a dependent Filter source — the inactive-blanking policy stands (Finding B)', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'region', type: 'Array(String)' }], rows: [[['a']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t', 'qt')], + filters: [ + // Inactive, but the RETAINED value is a non-empty array — must not + // reach the dependent source as a real array; it blanks like any + // other dormant value. + { id: 'from-root', parameter: 'from', defaultActive: false, defaultValue: ['x'] }, + { id: 'f-region', parameter: 'region', sourceQueryId: 'src', defaultActive: true, defaultValue: ['a'] }, + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('qt', 'SELECT 1 AS n WHERE x IN {region:Array(String)}'), + query('src', "SELECT ['a'] AS region FROM t WHERE ts IN {from:Array(String)} /* source */", { dashboard: { role: 'filter' } }), + ], + })); + await session.start(); + // 'from' is required in 'src' and blanks to '' (inactive) — the source + // waits on it rather than running with the dormant array. + expect(calls.some((c) => c.sql.includes('source'))).toBe(false); + expect(byId(session, 'f-region').status).toBe('waiting'); + }); + it('targeted wave: explicit targets rerun only their target tiles; two filters sharing one parameter union their targets', async () => { const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); const document = doc({ @@ -2242,56 +2313,131 @@ describe('searchable multiselect filter contract (#189)', () => { expect(byId(session, 'f1').active).toBe(true); }); - // Review finding (major): `resolveFilterSelection` only agrees over a - // filter's own resolved TARGETS + dependent sources — but the per-wave - // merge (`mergeDashboardFilterHelpers`) rejects a curated field on the - // DASHBOARD-WIDE `control.conflict` (`fieldControls(analysis)`, every - // tile, unscoped). Without the construction-time dashboard-wide gate, a - // filter whose OWN targets agree could still publish a `selection` - // contract and keep its source consumer, only for every wave's merge to - // permanently reject it as `filter-target-type-conflict` — a stuck - // hybrid (published contract + dead curated field), never falling back. - it('a dashboard-wide type conflict OUTSIDE the filter\'s own targets still forces a full fallback (#189 review finding, major): no sourceId/selection published, a persistent dashboard-wide diagnostic, the source never executes, and no helper-error hybrid ever appears', async () => { + // Merge-gate review (Finding A): the construction-time dashboard-wide + // conflict gate (a prior review round's `filter-selection-dashboard-type- + // conflict`) has been REMOVED — the maintainer rejected it as over-broad. A + // declaration OUTSIDE this filter's own resolved executable-consumer set + // (`gatherExecutableConsumers`) must never suppress a valid helper: the + // merge layer (`mergeDashboardFilterHelpers`) is now fed a control built + // from the SAME resolved-consumer contract `resolveFilterSelection` itself + // agreed on, never the dashboard-wide `fieldControls(analysis)` entry. + it('a conflicting declaration in a non-targeted executable tile no longer suppresses a helper whose OWN explicit targets agree (#189/merge-gate Finding A): sourceId/selection published, options merge cleanly, no filter-target-type-conflict', async () => { const { exec, calls } = makeExec((sql) => (sql.includes('source') ? { columns: [{ name: 'region', type: 'Array(String)' }], rows: [[['a', 'b']]] } : { columns: [{ name: 'n' }], rows: [[1]] })); const document = doc({ tiles: [tile('t1', 'q1'), tile('t2', 'q2')], // Explicit `targets: ['t1']` — 't1' alone agrees with the source on - // Array(String), so `resolveFilterSelection`'s OWN (target-scoped) - // agreement check would succeed on its own. + // Array(String); `resolveFilterSelection` only ever looks at this. filters: [{ id: 'f1', parameter: 'region', sourceQueryId: 'src', targets: ['t1'] }], }); const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [ query('q1', 'SELECT 1 AS n WHERE x IN {region:Array(String)}'), // f1's own target — agrees - // NOT one of f1's targets, but its scalar declaration of the SAME - // parameter still counts for the dashboard-wide `fieldControls` - // conflict the shared merge layer gates on. + // NOT one of f1's targets — a conflicting scalar declaration of the + // SAME parameter, entirely outside f1's resolved consumer set. query('q2', 'SELECT 1 AS n WHERE y = {region:String}'), query('src', "SELECT ['a','b'] AS region /* source */", { dashboard: { role: 'filter' } }), ], })); + expect(byId(session, 'f1').sourceId).toBe('src'); + expect(byId(session, 'f1').selection).toEqual({ mode: 'multiple', array: true }); + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-selection-dashboard-type-conflict')).toBe(false); + await session.start(); + expect(calls.some((c) => c.sql.includes('source'))).toBe(true); + expect(byId(session, 'f1').status).toBe('ready'); + expect(byId(session, 'f1').options).toEqual([{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }]); + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-target-type-conflict')).toBe(false); + }); + + it('a presentation-error tile\'s conflicting declaration, plus an unrelated cascading-invalid Filter source, neither one suppresses a valid helper (#189/merge-gate Finding A)', async () => { + const { exec, calls } = makeExec((sql) => { + if (sql.includes('srcGood')) return { columns: [{ name: 'region', type: 'Array(String)' }], rows: [[['a', 'b']]] }; + return { columns: [{ name: 'n' }], rows: [[1]] }; + }); + const document = doc({ + tiles: [ + tile('t1', 'q1'), + // A presentation-error tile (an unresolvable `presentation.variant`): + // still executable-analyzed (its SQL feeds the dashboard-wide + // `fieldControls`), but NOT an executable consumer + // (`isRunnableTileRuntime` excludes a `presentationError` tile) — so + // its conflicting `String` declaration of `region` is outside f1's + // resolved consumer set. + tile('t-bad', 'q-bad', { presentation: { variant: 'no-such-variant' } }), + // A genuine, executable consumer of 'catC' — so `fc` below resolves + // its OWN contract successfully at construction (this is the only + // way its source stays wired long enough to reach the cascading + // readiness check at wave time; a filter with no consumer at all + // falls back at construction for an unrelated reason first). + tile('t3', 'q3'), + ], + filters: [ + { id: 'f1', parameter: 'region', sourceQueryId: 'srcGood', targets: ['t1'] }, + // An unrelated filter whose OWN Filter source is cascading-invalid + // (it depends on 'region', itself source-backed by f1) — present in + // the same document purely to confirm it doesn't interfere either. + { id: 'fc', parameter: 'catC', sourceQueryId: 'srcCascade' }, + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('q1', 'SELECT 1 AS n WHERE x IN {region:Array(String)}'), + query('q-bad', 'SELECT 1 AS n WHERE y = {region:String}'), + query('q3', 'SELECT {catC:String} AS n'), + query('srcGood', "SELECT ['a','b'] AS region /* srcGood */", { dashboard: { role: 'filter' } }), + query('srcCascade', "SELECT ['z'] AS catC FROM t WHERE r = {region:String} /* srcCascade */", { dashboard: { role: 'filter' } }), + ], + })); + expect(session.state.value.tiles.find((t) => t.tileId === 't-bad')!.status).toBe('error'); + expect(byId(session, 'f1').sourceId).toBe('srcGood'); + expect(byId(session, 'f1').selection).toEqual({ mode: 'multiple', array: true }); + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-selection-dashboard-type-conflict')).toBe(false); + await session.start(); + expect(calls.some((c) => c.sql.includes('srcGood'))).toBe(true); + expect(byId(session, 'f1').status).toBe('ready'); + expect(byId(session, 'f1').options).toEqual([{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }]); + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-target-type-conflict')).toBe(false); + // 'fc' resolved a valid contract of its own at construction, but its + // source is cascading-invalid (depends on 'region', source-backed by + // f1) — it never executes, and reports the pre-existing #360 diagnostic; + // none of this affected f1's own, unrelated resolution above. + expect(calls.some((c) => c.sql.includes('srcCascade'))).toBe(false); + expect(byId(session, 'fc').status).toBe('source-error'); + expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-source-cascading')).toBe(true); + }); + + it('a genuine type conflict INSIDE the filter\'s own resolved consumer set still falls back at construction (#189, unchanged by Finding A)', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'region', type: 'String' }], rows: [['a']] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('t1', 'q1'), tile('t2', 'q2')], + // BOTH t1 and t2 are explicit targets — the conflicting scalar + // declaration is now INSIDE the resolved consumer set, so this must + // still fall back (a mixed-arity/array-element conflict falls back + // the same way — the pure `resolveFilterSelection` unit tests already + // cover those codes directly; this is the session-level regression + // that construction itself still honors ANY conflict inside the set). + filters: [{ id: 'f1', parameter: 'region', sourceQueryId: 'src', targets: ['t1', 't2'] }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('q1', 'SELECT 1 AS n WHERE x = {region:UInt64}'), + query('q2', 'SELECT 1 AS n WHERE y = {region:String}'), + query('src', "SELECT 'a' AS region /* source */", { dashboard: { role: 'filter' } }), + ], + })); expect(byId(session, 'f1').sourceId).toBeUndefined(); expect(byId(session, 'f1').selection).toBeUndefined(); - const diag = session.state.value.filterDiagnostics.find((d) => d.code === 'filter-selection-dashboard-type-conflict'); + const diag = session.state.value.filterDiagnostics.find((d) => d.code === 'filter-selection-type-conflict'); expect(diag).toMatchObject({ severity: 'error', filterId: 'f1', parameter: 'region' }); - expect(diag!.message).toContain('region'); - expect(diag!.message.toLowerCase()).toContain('dashboard-wide'); - expect(diag!.types).toEqual(expect.arrayContaining([expect.any(String), expect.any(String)])); await session.start(); - // 'src' is left with zero consumers (its only filter fell back) — it - // must never execute at all. expect(calls.some((c) => c.sql.includes('source'))).toBe(false); - // No stuck hybrid: 'f1' is no longer a consumer of any source, so its - // status never enters the filter-wave consumer-derivation loop — it - // stays 'idle', never 'helper-error'. - expect(byId(session, 'f1').status).toBe('idle'); - // The diagnostic is a construction-time constant — it survives a refresh. - await session.refresh(); expect(byId(session, 'f1').status).toBe('idle'); - expect(session.state.value.filterDiagnostics.some((d) => d.code === 'filter-selection-dashboard-type-conflict')).toBe(true); }); // Review finding (minor): `affectedByFilterWave` was built from the From b37feb2f33f2ec9c4cca96f6f9a21c9a00ee2aeb Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 22 Jul 2026 05:20:36 +0000 Subject: [PATCH 09/10] fix(#189): Apply closes the popover before committing; refresh announcement gated on a real options change (merge-gate review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A normal Apply no longer routes through the stale-draft cancellation path: the popover tears down before onApply fires, so applyFilter's synchronous publish/rebuild can never see it open. The 'Filter options were refreshed' announcement now requires the open parameter's optionsRev to have actually changed since the retained bar was built — a value/ active commit rebuild announces nothing. Focus restoration keys off the outgoing bar's open-or-focused multiselect parameter, so an Apply lands focus on the fresh trigger without stealing focus from other fields. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 ++++++- src/ui/dashboard.ts | 48 ++++++++++++++++++++--- src/ui/filter-bar.ts | 31 ++++++++++++++- src/ui/multi-select-field.ts | 20 ++++++++-- tests/unit/dashboard.test.ts | 55 +++++++++++++++++++++++++++ tests/unit/multi-select-field.test.ts | 32 ++++++++++++++++ 6 files changed, 187 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 281966f2..c9e97287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,21 @@ auto-generated per-PR notes; this file is the curated, human-readable history. configurations, an explicit `multiple` on a scalar contract, or an unknown mode all fall back to the ordinary string input with persistent path-precise `filter-selection-*` diagnostics (never a silent downgrade). + "Executable consumer" is defined once (`gatherExecutableConsumers`) and + shared by contract resolution, the per-wave helper merge, and + whole-workspace semantic validation — `validateDashboardSemantics` runs + the same resolver at authoring/import time, mapping each diagnostic to + its exact document path (`filters[i].selection.mode`, + `filters[i].targets[j]`, `filters[i].parameter`), and a declaration + outside the resolved consumer set (a presentation-error tile, a + never-executing cascading-invalid source, a non-targeted tile) can never + suppress a valid helper. Committed multiselect values stay real `string[]` arrays end to end — through viewer state, localStorage persistence, structural equality, and the existing typed `Array(T)` serializer (duplicates removed, empty-string - elements valid, never comma-joined). Option refreshes reconcile by bound + elements valid, never comma-joined; an active empty array serializes as a + real `[]` — activation is decided exclusively by the active flag, never by + a value sentinel). Option refreshes reconcile by bound value: surviving selections stay active in canonical order (label/order-only changes rerun nothing), removals join one reconciled panel wave, an empty intersection deactivates the filter keeping its dormant value, and new diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 930ed96f..7f89d93d 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -611,6 +611,15 @@ export async function renderDashboard(app: DashboardApp): Promise { // the `barSig`/status-signal split) calls this directly instead of tearing // down and rebuilding the whole bar. let filterBarUpdateStatus: FilterBarHandle['updateStatus'] | null = null; + // Maintainer merge-gate fix (#189): each parameter's `optionsRev` as of the + // CURRENTLY-RETAINED bar's own build — compared, below, against the + // incoming view's `optionsRev` for whichever parameter had an open (or + // just-closed) multiselect popover, so the refresh announcement fires only + // when that parameter's options actually changed content, never merely + // because a rebuild happened to run while (or right after) its popover was + // up. Replaced wholesale after every rebuild (never merged) — a filter that + // disappears from `sview.filters` simply drops out. + let lastBuiltOptionsRev = new Map(); function rebuildFilterBar(sview: DashboardViewState): void { // #189-F2b: ask the OUTGOING bar WHICH parameter's multiselect popover is @@ -622,6 +631,16 @@ export async function renderDashboard(app: DashboardApp): Promise { // trigger on the freshly-built bar below (never left stranded at // `` — F2 review finding). const openMultiSelectParam = currentFilterBar?.openMultiSelectParam() ?? null; + // Maintainer merge-gate fix (#189): an ordinary Apply already closed its + // OWN popover before its `onApply` reached `session.applyFilter` — by the + // time that commit's synchronous `publish()` gets here, `openMultiSelectParam` + // above already reads `null` for it. `focusedMultiSelectParam` still finds + // it (focus sits on that field's about-to-be-detached trigger), so focus + // restoration below has a signal to work with even when there was no open + // popover to speak of — never used for the ANNOUNCE decision (only a + // genuinely open popover's cancellation is ever worth announcing). + const focusedMultiSelectParam = currentFilterBar?.focusedMultiSelectParam() ?? null; + const restoreFocusParam = openMultiSelectParam ?? focusedMultiSelectParam; currentFilterBar?.dispose(); const idByParam = new Map(); // #360: curation is gated on TOPOLOGY (`sourceId != null`, set once at @@ -684,14 +703,31 @@ export async function renderDashboard(app: DashboardApp): Promise { filterHost.replaceChildren(bar.el); currentFilterBar = bar; filterBarUpdateStatus = bar.updateStatus; + // Maintainer merge-gate fix (#189): announce the refresh ONLY when the + // open param's options actually changed content between the OUTGOING + // bar's own last build (`lastBuiltOptionsRev`) and this incoming view — + // a rebuild triggered by a plain value/active commit (this field's own + // Apply, already closed by the time it gets here, or any OTHER field's + // commit) never bumps `optionsRev`, so it never announces, even on the + // rare chance this param's popover was still genuinely open when some + // unrelated commit forced the whole bar to rebuild. if (openMultiSelectParam) { - filterRefreshLiveEl.textContent = 'Filter options were refreshed'; - // #189-F2b: land focus on the NEW bar's corresponding trigger — a - // no-op if that parameter is no longer a multiselect field on the - // fresh bar (e.g. its curation topology itself changed), which simply - // leaves focus wherever it already was rather than throwing. - bar.focusMultiSelectTrigger(openMultiSelectParam); + const prevRev = lastBuiltOptionsRev.get(openMultiSelectParam); + const nextRev = sview.filters.find((f) => f.parameter === openMultiSelectParam)?.optionsRev; + if (nextRev !== undefined && nextRev !== prevRev) { + filterRefreshLiveEl.textContent = 'Filter options were refreshed'; + } } + lastBuiltOptionsRev = new Map(sview.filters.map((f) => [f.parameter, f.optionsRev])); + // #189-F2b: land focus on the NEW bar's corresponding trigger for + // whichever parameter the OUTGOING bar had open, or (absent that) had + // focus on its trigger (an Apply that already closed its own popover + // before reaching here) — a no-op if that parameter is no longer a + // multiselect field on the fresh bar (e.g. its curation topology itself + // changed) or there was no such parameter at all (a plain field mid-typing + // elsewhere is never disturbed), which simply leaves focus wherever it + // already was rather than throwing. + if (restoreFocusParam) bar.focusMultiSelectTrigger(restoreFocusParam); } const filterDiagnosticsHost = h('div', { class: 'dash-filter-diagnostics' }); diff --git a/src/ui/filter-bar.ts b/src/ui/filter-bar.ts index 24aaea93..a0eaf2b0 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -259,11 +259,27 @@ export interface FilterBarHandle { * (`focusMultiSelectTrigger` below) rather than leaving focus stranded at * ``. */ openMultiSelectParam(): string | null; + /** Maintainer merge-gate fix (#189): the parameter of a curated MULTISELECT + * field built by THIS bar instance whose trigger (or error-mode fallback + * input) currently HOLDS FOCUS, popover open or not — or `null` when none + * does. Distinct from `openMultiSelectParam` above: an ordinary Apply + * closes its own popover BEFORE calling `onApply` (multi-select-field.ts), + * so by the time a synchronous commit-triggered rebuild reaches this bar, + * `openMultiSelectParam()` already reads `null` even though focus still + * sits on that field's (about-to-be-detached) trigger — this is the only + * remaining signal for which parameter's fresh trigger a rebuild should + * refocus. The caller (`dashboard.ts`) reads BOTH before disposing the + * outgoing bar and restores focus for whichever one is non-null + * (`openMultiSelectParam() ?? focusedMultiSelectParam()`), so a plain + * field mid-typing (focus outside every multiselect control) is never + * disturbed. */ + focusedMultiSelectParam(): string | null; /** #189-F2b: focuses the named parameter's multiselect trigger (or its * error-mode fallback input, if erroring) — a no-op when this bar built no * multiselect field for that parameter. Used by `dashboard.ts` right after * building a FRESH bar, for whichever parameter `openMultiSelectParam()` - * reported on the OUTGOING bar just before disposing it. */ + * (or, absent that, `focusedMultiSelectParam()`) reported on the OUTGOING + * bar just before disposing it. */ focusMultiSelectTrigger(name: string): void; } @@ -301,7 +317,8 @@ export function buildFilterBar( return { el: h('div', { ...attrs, style: { display: 'none' } }), dispose: () => {}, updateStatus: () => {}, - openMultiSelectParam: () => null, focusMultiSelectTrigger: () => {}, + openMultiSelectParam: () => null, focusedMultiSelectParam: () => null, + focusMultiSelectTrigger: () => {}, }; } const timerClears: Array<() => void> = []; @@ -513,6 +530,16 @@ export function buildFilterBar( for (const [name, msField] of multiSelectFields) if (msField.isOpen()) return name; return null; }, + // Maintainer merge-gate fix (#189): `.el` is each field's own control root + // (the single node hosting whichever of trigger/error-input is current — + // see multi-select-field.ts), so `.contains(activeElement)` catches focus + // on either one, regardless of popover state. + focusedMultiSelectParam: () => { + const active = document.activeElement; + if (!active) return null; + for (const [name, msField] of multiSelectFields) if (msField.el.contains(active)) return name; + return null; + }, focusMultiSelectTrigger: (name) => { multiSelectFields.get(name)?.focusTrigger(); }, }; } diff --git a/src/ui/multi-select-field.ts b/src/ui/multi-select-field.ts index dc0eada9..d2603686 100644 --- a/src/ui/multi-select-field.ts +++ b/src/ui/multi-select-field.ts @@ -381,10 +381,24 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi const activeNext = canonical.length > 0; // A no-op Apply (same canonical selection AND same active flag) closes // silently — `onApply` fires exactly once otherwise. - if (!(sameSelection(canonical, prevCanonical) && activeNext === active)) { - opts.onApply(canonical, activeNext); - } + const changed = !(sameSelection(canonical, prevCanonical) && activeNext === active); + // Close BEFORE calling `onApply` (maintainer merge-gate finding, #189): + // `onApply` typically routes straight into `session.applyFilter`, which + // mutates state and `publish()`es SYNCHRONOUSLY before its first + // `await` — a caller subscribed to that publish (`dashboard.ts`'s + // `rebuildFilterBar`) can run inside this very call stack, before + // `applyBtn`'s own click handler ever returns. Closing first means that + // synchronous rebuild always observes this popover as already-closed + // (`isOpen()` false, `closeCurrent` cleared) — never mistakes an + // ordinary Apply's own commit for an outgoing bar's popover getting + // force-cancelled out from under the user, which is what used to + // trigger a false "Filter options were refreshed" announcement. `close()` + // (default, non-`skipFocus`) refocuses the trigger; the rebuild that + // `onApply` may synchronously trigger replaces the whole bar out from + // under that focus — restoring it onto the FRESH trigger is + // `rebuildFilterBar`'s own job (`dashboard.ts`), not this module's. close(); + if (changed) opts.onApply(canonical, activeNext); }); const footer = h('div', { class: 'ms-footer' }, clearBtn, cancelBtn, applyBtn); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index ac7816d5..9d07e4e2 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -2625,6 +2625,61 @@ describe('renderDashboard — searchable multiselect + array-wrapped curated fil expect(document.activeElement).toBe(newTrigger); rootEl(app).remove(); }); + + // Maintainer merge-gate finding: an ORDINARY Apply — the user's own commit, + // not an outgoing bar's popover getting force-cancelled by someone/something + // else — must never announce "Filter options were refreshed". Driven end to + // end through the real session: the shared source republishes the SAME + // option content on every rerun (no genuine option-generation change), so + // `optionsRev` never bumps and the announcement must stay silent even though + // `session.applyFilter`'s synchronous `publish()` forces this exact + // multiselect's own bar to rebuild out from under its own (already-closed) + // popover. + it('a normal Apply commits through the real session without announcing "Filter options were refreshed", and focuses the fresh trigger', async () => { + const { app, calls } = dashApp({ + responder: (sql) => { + if (sql.includes('opts')) { + return { columns: [{ name: 'p', type: 'Array(String)' }], rows: [[['x', 'y']]] }; + } + return { columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['a', 1]] }; + }, + workspace: wsWith({ + queries: [ + q('q1', 'SELECT k, v FROM a WHERE has(p, {p:Array(String)})'), + q('src', "SELECT ['x','y'] AS p -- opts", { dashboard: { role: 'filter' } }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src', defaultValue: ['x'], defaultActive: true }], + }), + }); + await render(app); + document.body.appendChild(rootEl(app)); + const field = qs(app.root, '.dash-filter-host .var-field.is-curated'); + qs(field, '.ms-trigger').dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(qs(document.body, '.ms-popover')).not.toBeNull(); + const liveRegionBefore = qs(app.root, '.dash-toolbar > .sr-only').textContent; + // Check the second option ('y') too, then Apply — a real value change. + const draftCb = qsa(document.body, '.ms-option input[type="checkbox"]')[1]; + draftCb.checked = true; + draftCb.dispatchEvent(new Event('change', { bubbles: true })); + const before = calls.length; + qs(document.body, '.ms-btn-primary').dispatchEvent(new MouseEvent('click', { bubbles: true })); + // The popover is torn down synchronously (multi-select-field.ts's Apply + // handler closes before calling `onApply`) — no macrotask/microtask flush + // needed to observe it gone. + expect(document.body.querySelector('.ms-popover')).toBeNull(); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + const tileCalls = calls.slice(before).filter((c) => 'param_p' in c.params); + expect(tileCalls.some((c) => c.params.param_p === "['x','y']")).toBe(true); // the commit went through + // The live region is untouched — no false "refreshed" announcement. + expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe(liveRegionBefore); + expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).not.toBe('Filter options were refreshed'); + // Focus lands on the FRESH bar's trigger for the same parameter (the old + // one, focused by `close()`, was detached by the synchronous rebuild). + const newTrigger = qs(app.root, '.ms-trigger'); + expect(document.activeElement).toBe(newTrigger); + rootEl(app).remove(); + }); }); // #359: the shared-source filter wave now publishes `optionsRev` (bumped ONLY diff --git a/tests/unit/multi-select-field.test.ts b/tests/unit/multi-select-field.test.ts index 9180dd07..37f75a0e 100644 --- a/tests/unit/multi-select-field.test.ts +++ b/tests/unit/multi-select-field.test.ts @@ -290,6 +290,38 @@ describe('buildMultiSelectField — Apply semantics', () => { expect(document.activeElement).toBe(t); }); + // Maintainer merge-gate finding: `onApply` typically routes into + // `session.applyFilter`, which publishes SYNCHRONOUSLY (before its first + // `await`) — a subscriber that rebuilds the filter bar on that publish can + // run inside `onApply` itself. If the popover were still open at that + // point, the rebuild would read it as an outgoing bar's popover getting + // force-cancelled and announce a false "Filter options were refreshed". The + // fix: close BEFORE calling `onApply`, so any synchronous reaction to + // `onApply` always observes this popover as already closed. + it('closes the popover (draft already captured) BEFORE invoking onApply, not after', () => { + const onApply = vi.fn(); + let openWhenCalled: boolean | null = null; + let ariaExpandedWhenCalled: string | null = null; + let popoverPresentWhenCalled: boolean | null = null; + const handle = buildMultiSelectField(baseOpts({ + value: ['a'], active: true, + onApply: (...args) => { + openWhenCalled = handle.isOpen(); + ariaExpandedWhenCalled = triggerEl(handle.el).getAttribute('aria-expanded'); + popoverPresentWhenCalled = popover() !== null; + onApply(...args); + }, + })); + document.body.appendChild(handle.el); + click(triggerEl(handle.el)); + setChecked(optionCbs()[1], true); // add Bravo — a real change, so onApply fires + click(applyBtn()); + expect(onApply).toHaveBeenCalledWith(['a', 'b'], true); // the commit still happened + expect(openWhenCalled).toBe(false); + expect(ariaExpandedWhenCalled).toBe('false'); + expect(popoverPresentWhenCalled).toBe(false); + }); + it('duplicate values in the committed selection do not defeat the no-op Apply check', () => { const onApply = vi.fn(); const handle = buildMultiSelectField(baseOpts({ value: ['a', 'a', 'b'], active: true, onApply })); From 60026a9eb7c127b2fa8dc076afa7022fe8c51519 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 22 Jul 2026 11:40:57 +0200 Subject: [PATCH 10/10] feat(#189): auto-bind a favorited Filter-source query to a matching Dashboard filter (#364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favoriting a `filter`-role saved query now attaches its option list to an implicit panel-tile parameter of the same name — the field upgrades from a plain text box to the query-backed control with no authored filter definition and no per-filter settings (single vs. multiselect stays inferred from the consumer type). synthesizeImplicitFilters sets `sourceQueryId` by pure name-matching against the source's parsed top-level output columns (new pure `core/select-columns.ts`, 100% covered): a parameter produced by exactly one favorited source binds; zero or ambiguous (>=2) stays plain. Runtime-only, never persisted. Completes the consumption pipeline from #189/#360 (which resolved and rendered source-backed filters but had no wiring to create the binding). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f --- CHANGELOG.md | 12 +++ src/core/select-columns.ts | 128 ++++++++++++++++++++++++++++++ src/ui/dashboard.ts | 26 +++++- tests/unit/dashboard.test.ts | 83 +++++++++++++++++++ tests/unit/select-columns.test.ts | 101 +++++++++++++++++++++++ 5 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/core/select-columns.ts create mode 100644 tests/unit/select-columns.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 039f0048..03f253dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,18 @@ auto-generated per-PR notes; this file is the curated, human-readable history. `examples/query-log-explorer.json`'s `qle-filter` source is migrated to a `{from:DateTime}` / optional `{to:DateTime}` window (matching its panels, where `to` is optional and means "up to now") as a worked example. +- **Favoriting a Filter-source query auto-binds it to a matching Dashboard + filter** (#189/#364). A favorited `filter`-role saved query whose top-level + output column name equals an (otherwise implicit) panel-tile parameter now + attaches its option list to that parameter automatically — the field upgrades + from a plain text box to the query-backed control with no authored + `DashboardFilterDefinitionV1` and no per-filter settings (single vs. + multiselect is still inferred from the consumer type). Binding is pure + name-matching against the source's parsed output columns + (`core/select-columns.ts`): a parameter produced by exactly one favorited + source binds; a parameter produced by zero or by two-or-more (ambiguous) + favorited sources stays a plain input rather than guessing. Runtime-only — + the synthesized binding is never written back into the dashboard document. ### Fixed - **Saved-query, workspace, and Dashboard persistence stay consistent** (#365). diff --git a/src/core/select-columns.ts b/src/core/select-columns.ts new file mode 100644 index 00000000..9f33b9a4 --- /dev/null +++ b/src/core/select-columns.ts @@ -0,0 +1,128 @@ +// Pure, dependency-free extraction of the CONFIDENTLY-named top-level output +// columns of the FIRST/outermost SELECT of a SQL string (#189/#364). Used to +// auto-bind a favorited `filter`-role saved query to a Dashboard filter by +// matching an output column NAME to a parameter name (see +// `ui/dashboard.ts#synthesizeImplicitFilters`). It is deliberately conservative: +// it never guesses a name for an unaliased expression, never leaks an `AS` from +// inside a subquery/function, and never throws on malformed SQL — it returns +// only the names it can confidently derive (possibly `[]`). +// +// This is a lexical, not a semantic, parser: it walks the string once, tracking +// paren depth and string/quote state, so keywords and commas that live inside +// strings, backticks, or nested parentheses are never mistaken for structure. +// Word-boundary matching (whole runs of identifier chars) keeps +// `fromUnixTimestamp(...)` or a column named with an embedded keyword from being +// read as a clause keyword. + +const IDENT = /[\w$]/; +const IDENT_HEAD = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/; + +// Top-level clause keywords that terminate the projection list when there is no +// FROM (or before it). Word-boundary matched, case-insensitive. +const CLAUSE = new Set(['FROM', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'LIMIT', 'SETTINGS', 'UNION']); + +interface TopWord { word: string; end: number; } + +/** Single lexical pass: collect the uppercase text + end index of every + * identifier-run AND the index of every comma that sits at paren depth 0 and + * outside any '…'/"…"/`…` quote. Depth->0 / in-quote tokens are never + * recorded, so callers see only top-level structure. */ +function scanTopLevel(s: string): { words: TopWord[]; commas: number[] } { + const words: TopWord[] = []; + const commas: number[] = []; + let depth = 0; + let quote = ''; + let i = 0; + const n = s.length; + while (i < n) { + const c = s[i]; + if (quote) { + if (c === quote) quote = ''; + i++; + continue; + } + if (c === "'" || c === '"' || c === '`') { quote = c; i++; continue; } + if (c === '(') { depth++; i++; continue; } + if (c === ')') { depth--; i++; continue; } + if (c === ',') { if (depth === 0) commas.push(i); i++; continue; } + if (IDENT.test(c)) { + let j = i; + while (j < n && IDENT.test(s[j])) j++; + if (depth === 0) words.push({ word: s.slice(i, j).toUpperCase(), end: j }); + i = j; + continue; + } + i++; + } + return { words, commas }; +} + +/** The identifier token starting at/after `pos` in `s`: a backticked/double- + * quoted name (returned unquoted) or a bare identifier run. `null` when none + * (end of string, an unclosed quote, an empty quoted name, or a non-identifier + * such as a number). */ +function identifierAt(s: string, pos: number): string | null { + let i = pos; + while (i < s.length && /\s/.test(s[i])) i++; + if (i >= s.length) return null; + const c = s[i]; + if (c === '`' || c === '"') { + const close = s.indexOf(c, i + 1); + if (close < 0) return null; + return s.slice(i + 1, close) || null; + } + const m = /^[A-Za-z_$][\w$]*/.exec(s.slice(i)); + return m ? m[0] : null; +} + +/** A whole projection item that is nothing but a bare identifier: a dotted word + * (last segment taken) or a backticked/double-quoted identifier (unquoted). + * `null` for an expression, `*`, `t.*`, a number, etc. */ +function bareIdentifier(item: string): string | null { + let m = /^`([^`]*)`$/.exec(item); + if (m) return m[1] || null; + m = /^"([^"]*)"$/.exec(item); + if (m) return m[1] || null; + if (IDENT_HEAD.test(item)) { + const segs = item.split('.'); + return segs[segs.length - 1]; + } + return null; +} + +/** The confidently-derived output name of one projection item, or `null`. */ +function deriveName(item: string): string | null { + if (!item) return null; + const { words } = scanTopLevel(item); + let asEnd = -1; + for (const w of words) if (w.word === 'AS') asEnd = w.end; + if (asEnd >= 0) return identifierAt(item, asEnd); + return bareIdentifier(item); +} + +/** The confidently-named top-level output columns of the first/outermost SELECT + * in `sql`, in order. Unaliased expressions, `*`, and anything ambiguous + * contribute no name. Never throws — returns `[]` (or a partial list) on + * malformed input. */ +export function selectOutputColumns(sql: string | null | undefined): string[] { + if (!sql) return []; + const scan = scanTopLevel(sql); + const selIdx = scan.words.findIndex((w) => w.word === 'SELECT'); + if (selIdx < 0) return []; + const selectEnd = scan.words[selIdx].end; + let projEnd = sql.length; + for (let k = selIdx + 1; k < scan.words.length; k++) { + if (CLAUSE.has(scan.words[k].word)) { projEnd = scan.words[k].end - scan.words[k].word.length; break; } + } + const projection = sql.slice(selectEnd, projEnd).replace(/^\s*distinct\b/i, ''); + const names: string[] = []; + const { commas } = scanTopLevel(projection); + let start = 0; + const bounds = [...commas, projection.length]; + for (const idx of bounds) { + const name = deriveName(projection.slice(start, idx).trim()); + if (name) names.push(name); + start = idx + 1; + } + return names; +} diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 7f89d93d..42f253ce 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -50,6 +50,8 @@ import { import { analyzeParameterizedSources, fieldControls } from '../core/param-pipeline.js'; import type { ValidationMode } from '../core/param-pipeline.js'; import { queryDashboardRole } from '../dashboard/model/workspace-semantics.js'; +import { queryFavorite } from '../core/saved-query.js'; +import { selectOutputColumns } from '../core/select-columns.js'; import { renderKpiCards, KPI_STREAM_ARIA } from './kpi-panel.js'; import { buildFilterBar } from './filter-bar.js'; import type { FilterBarApp, FilterBarHandle } from './filter-bar.js'; @@ -232,6 +234,14 @@ interface TileEl { /** Synthesize a filter definition per distinct `{name:Type}` panel-tile param * that no explicit filter already targets — so a migrated Dashboard (whose * persisted `filters` is empty) still surfaces its implicit param filters. + * + * #189/#364 (Bug 3): when a favorited `filter`-role saved query outputs a + * column whose name equals the parameter, the synthesized filter also gets + * that query's `sourceQueryId`, so its option list attaches automatically (the + * field becomes a curated combobox instead of a plain text box). A parameter + * produced by EXACTLY ONE favorited filter source binds; zero or more than one + * (ambiguous) leaves the filter plain — ambiguity degrades gracefully, never + * guesses. * Runtime-only; never persisted. */ function synthesizeImplicitFilters( doc: DashboardDocumentV1, queryById: Map, @@ -242,9 +252,23 @@ function synthesizeImplicitFilters( .filter((query): query is SavedQueryV2 => !!query && queryDashboardRole(query) === 'panel') .map((query, index) => ({ id: 't' + index, kind: 'tile', sql: query.sql, bindPolicy: 'row-returning' })); const analysis = analyzeParameterizedSources(panelSources); + // column name -> the favorited filter-role source ids that output it. + const columnSources = new Map>(); + for (const source of queryById.values()) { + if (queryDashboardRole(source) !== 'filter' || !queryFavorite(source)) continue; + for (const column of selectOutputColumns(source.sql)) { + let ids = columnSources.get(column); + if (!ids) { ids = new Set(); columnSources.set(column, ids); } + ids.add(source.id); + } + } const out: DashboardFilterDefinitionV1[] = []; for (const control of fieldControls(analysis)) { - if (!declared.has(control.name)) out.push({ id: control.name, parameter: control.name }); + if (declared.has(control.name)) continue; + const def: DashboardFilterDefinitionV1 = { id: control.name, parameter: control.name }; + const ids = columnSources.get(control.name); + if (ids && ids.size === 1) def.sourceQueryId = [...ids][0]; + out.push(def); } return out; } diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 9d07e4e2..a18b9f7b 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -2503,6 +2503,89 @@ describe('renderDashboard — shared rich filter bar over the viewer (#188)', () }); }); +// #189/#364 (Bug 3): a favorited `filter`-role saved query whose OUTPUT COLUMN +// name matches an implicit (undeclared) panel-tile parameter auto-binds its +// options to that parameter — `synthesizeImplicitFilters` sets `sourceQueryId`, +// so the field upgrades from a plain text box to a curated combobox WITHOUT any +// explicit `doc.filters` entry. Exactly one favorited source binds; zero or +// more than one (ambiguous) leaves the field plain. +describe('renderDashboard — auto-bind favorited filter source by column name (#364)', () => { + // A panel tile whose only parameter is `user1: Array(String)` — the implicit + // filter target these tests wire (or decline to wire) a source to. + const CONSUMER = 'SELECT k, v FROM a WHERE has(user1, {user1:Array(String)})'; + const optionsResponder: ExecResponder = (sql) => (sql.includes('opts') + ? { columns: [{ name: 'user1', type: 'Array(String)' }], rows: [[['x', 'y']]] } + : { columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['a', 1]] }); + + it('binds a favorited filter source that outputs the same column name (field becomes curated)', async () => { + const { app } = dashApp({ + responder: optionsResponder, + workspace: wsWith({ + queries: [ + q('q1', CONSUMER), + q('src', 'SELECT groupArray(region) AS user1 FROM t -- opts', { dashboard: { role: 'filter' }, favorite: true }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + }), + }); + await render(app); + const field = qs(app.root, '.dash-filter-host .var-field.is-curated'); + expect(field).not.toBeNull(); + expect(qs(field, '.var-name').textContent).toBe('user1'); + }); + + it('leaves the field plain when NO favorited filter source outputs the column', async () => { + const { app } = dashApp({ + responder: optionsResponder, + workspace: wsWith({ + queries: [ + q('q1', CONSUMER), + // A favorited filter source, but it outputs a DIFFERENT column. + q('src', 'SELECT groupArray(region) AS someOther FROM t -- opts', { dashboard: { role: 'filter' }, favorite: true }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + }), + }); + await render(app); + // The user1 field still renders, just not curated (no source attached). + expect(qs(app.root, '.dash-filter-host .var-field .var-name').textContent).toBe('user1'); + expect(qs(app.root, '.dash-filter-host .var-field.is-curated')).toBeNull(); + }); + + it('does NOT bind when two favorited filter sources output the same column (ambiguous)', async () => { + const { app } = dashApp({ + responder: optionsResponder, + workspace: wsWith({ + queries: [ + q('q1', CONSUMER), + q('srcA', 'SELECT groupArray(region) AS user1 FROM a -- opts', { dashboard: { role: 'filter' }, favorite: true }), + q('srcB', 'SELECT groupArray(region) AS user1 FROM b -- opts', { dashboard: { role: 'filter' }, favorite: true }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + }), + }); + await render(app); + expect(qs(app.root, '.dash-filter-host .var-field .var-name').textContent).toBe('user1'); + expect(qs(app.root, '.dash-filter-host .var-field.is-curated')).toBeNull(); + }); + + it('ignores a NON-favorited filter-role query that outputs the column', async () => { + const { app } = dashApp({ + responder: optionsResponder, + workspace: wsWith({ + queries: [ + q('q1', CONSUMER), + q('src', 'SELECT groupArray(region) AS user1 FROM t -- opts', { dashboard: { role: 'filter' }, favorite: false }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + }), + }); + await render(app); + expect(qs(app.root, '.dash-filter-host .var-field .var-name').textContent).toBe('user1'); + expect(qs(app.root, '.dash-filter-host .var-field.is-curated')).toBeNull(); + }); +}); + // #189: the searchable multiselect (an Array(...) consumer contract, default // `selection.mode`) and the single-select-on-Array wrap (`selection.mode: // 'single'` against the same Array contract) — both new curated shapes, diff --git a/tests/unit/select-columns.test.ts b/tests/unit/select-columns.test.ts new file mode 100644 index 00000000..6bc205f4 --- /dev/null +++ b/tests/unit/select-columns.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { selectOutputColumns } from '../../src/core/select-columns.js'; + +describe('selectOutputColumns', () => { + it('returns [] for empty / null / undefined input', () => { + expect(selectOutputColumns('')).toEqual([]); + expect(selectOutputColumns(null)).toEqual([]); + expect(selectOutputColumns(undefined)).toEqual([]); + }); + + it('returns [] when there is no top-level SELECT', () => { + expect(selectOutputColumns('UPDATE t SET x = 1')).toEqual([]); + expect(selectOutputColumns(' -- just a comment')).toEqual([]); + }); + + it('takes bare identifiers and AS aliases in order', () => { + expect(selectOutputColumns('SELECT a, b AS c FROM t')).toEqual(['a', 'c']); + }); + + it('handles the real fixture: nested-function projection with SETTINGS tail', () => { + const sql = "SELECT arraySort(groupUniqArray(initial_user)) AS user1 FROM merge(system, " + + "'^query_log') WHERE type = 'QueryFinish' SETTINGS enable_named_columns_in_function_tuple = 1"; + expect(selectOutputColumns(sql)).toEqual(['user1']); + }); + + it('does not split on commas nested inside parens', () => { + expect(selectOutputColumns('SELECT foo(a, b) AS x, bar(c, d) AS y FROM t')).toEqual(['x', 'y']); + }); + + it('does not let an AS inside a subquery/function leak out', () => { + // The inner `AS y` is at paren depth 1 and must be ignored; the whole item + // is an unaliased expression, so it contributes no name. + expect(selectOutputColumns('SELECT (SELECT x AS y FROM t) FROM z')).toEqual([]); + // With an outer alias, only the outer alias is taken. + expect(selectOutputColumns('SELECT (SELECT x AS y FROM t) AS outer_col FROM z')).toEqual(['outer_col']); + // CAST(... AS Type) AS alias — inner AS ignored, outer alias taken. + expect(selectOutputColumns('SELECT CAST(x AS Int32) AS n FROM t')).toEqual(['n']); + }); + + it('skips a leading DISTINCT', () => { + expect(selectOutputColumns('SELECT DISTINCT a, b FROM t')).toEqual(['a', 'b']); + // A column whose name merely STARTS with "distinct" is not stripped. + expect(selectOutputColumns('SELECT distinctColumn FROM t')).toEqual(['distinctColumn']); + }); + + it('takes the last segment of a dotted identifier', () => { + expect(selectOutputColumns('SELECT t.col, db.tbl.other FROM t')).toEqual(['col', 'other']); + }); + + it('unquotes backticked / double-quoted aliases and identifiers', () => { + expect(selectOutputColumns('SELECT x AS `my col` FROM t')).toEqual(['my col']); + expect(selectOutputColumns('SELECT x AS "dq name" FROM t')).toEqual(['dq name']); + expect(selectOutputColumns('SELECT `bare ident` FROM t')).toEqual(['bare ident']); + expect(selectOutputColumns('SELECT "dq ident" FROM t')).toEqual(['dq ident']); + }); + + it('skips *, t.*, and unaliased expressions', () => { + expect(selectOutputColumns('SELECT * FROM t')).toEqual([]); + expect(selectOutputColumns('SELECT t.* FROM t')).toEqual([]); + expect(selectOutputColumns('SELECT count(*) FROM t')).toEqual([]); + expect(selectOutputColumns('SELECT 1 + 2 FROM t')).toEqual([]); + expect(selectOutputColumns('SELECT 42 FROM t')).toEqual([]); + expect(selectOutputColumns('SELECT a, count(*), b FROM t')).toEqual(['a', 'b']); + }); + + it('works with no FROM and other terminating clauses', () => { + expect(selectOutputColumns('SELECT 1 AS n')).toEqual(['n']); + expect(selectOutputColumns('SELECT a, b')).toEqual(['a', 'b']); + expect(selectOutputColumns('SELECT a AS x WHERE 1')).toEqual(['x']); + expect(selectOutputColumns('SELECT a AS x GROUP BY a')).toEqual(['x']); + }); + + it('ignores commas / from / as that appear inside string literals', () => { + expect(selectOutputColumns("SELECT 'a,b' AS c, 'from' AS d FROM t")).toEqual(['c', 'd']); + expect(selectOutputColumns("SELECT 'x as y' AS only FROM t")).toEqual(['only']); + }); + + it('is case-insensitive for keywords', () => { + expect(selectOutputColumns('select a as b from t')).toEqual(['b']); + expect(selectOutputColumns('SeLeCt DiStInCt a FrOm t')).toEqual(['a']); + }); + + it('degrades gracefully on malformed input (never throws)', () => { + // Empty projection between SELECT and FROM. + expect(selectOutputColumns('SELECT FROM t')).toEqual([]); + // Trailing comma → an empty final item, skipped. + expect(selectOutputColumns('SELECT a, FROM t')).toEqual(['a']); + // AS with nothing after it. + expect(selectOutputColumns('SELECT x AS')).toEqual([]); + // AS followed by an unclosed backtick. + expect(selectOutputColumns('SELECT x AS `nope FROM t')).toEqual([]); + // AS followed by an empty backtick/double-quote pair. + expect(selectOutputColumns('SELECT x AS `` FROM t')).toEqual([]); + expect(selectOutputColumns('SELECT x AS "" FROM t')).toEqual([]); + // AS followed by a non-identifier (a number). + expect(selectOutputColumns('SELECT a AS 9 FROM t')).toEqual([]); + // Bare empty backtick / double-quote items. + expect(selectOutputColumns('SELECT `` FROM t')).toEqual([]); + expect(selectOutputColumns('SELECT "" FROM t')).toEqual([]); + }); +});