diff --git a/web-common/src/components/table/tanstack-table-column-meta.ts b/web-common/src/components/table/tanstack-table-column-meta.ts index 59bcebab156..3cb35617e87 100644 --- a/web-common/src/components/table/tanstack-table-column-meta.ts +++ b/web-common/src/components/table/tanstack-table-column-meta.ts @@ -7,6 +7,8 @@ declare module "tanstack-table-8-svelte-5" { widthPercent?: number; marginLeft?: string; icon?: ComponentType; + /** Full path of dimension name/value pairs from root to this header */ + dimensionPath?: Record; tooltipFormatter?: (value: unknown) => string | null | undefined; } } diff --git a/web-common/src/features/canvas/CanvasComponent.svelte b/web-common/src/features/canvas/CanvasComponent.svelte index 92bafed4352..db1f9f34729 100644 --- a/web-common/src/features/canvas/CanvasComponent.svelte +++ b/web-common/src/features/canvas/CanvasComponent.svelte @@ -45,6 +45,7 @@ } } export let selected = false; + export let active = false; export let ghost = false; export let allowPointerEvents = true; export let editable = false; @@ -66,6 +67,7 @@ role="presentation" id={componentName} class:selected + class:active class:editable class:opacity-20={ghost} style:pointer-events={!allowPointerEvents ? "none" : "auto"} @@ -111,4 +113,10 @@ outline-style: solid !important; } + + .active { + @apply shadow-md outline-primary-400 outline-[1.5px]; + + outline-style: solid !important; + } diff --git a/web-common/src/features/canvas/CanvasDashboardEmbed.svelte b/web-common/src/features/canvas/CanvasDashboardEmbed.svelte index 386e63c04a2..9a72c112d7c 100644 --- a/web-common/src/features/canvas/CanvasDashboardEmbed.svelte +++ b/web-common/src/features/canvas/CanvasDashboardEmbed.svelte @@ -26,10 +26,12 @@ _maxWidth, filtersEnabledStore, themeName, + activeComponent: activeComponentStore, }, } = getCanvasStore(canvasName, instanceId)); $: components = $componentsStore; + $: activeComponentId = $activeComponentStore; $: filtersEnabled = $filtersEnabledStore; $: maxWidth = $_maxWidth; @@ -56,6 +58,7 @@ {components} {maxWidth} {navigationEnabled} + {activeComponentId} /> {:else}
diff --git a/web-common/src/features/canvas/StaticCanvasRow.svelte b/web-common/src/features/canvas/StaticCanvasRow.svelte index 705dee55dd0..be327a41161 100644 --- a/web-common/src/features/canvas/StaticCanvasRow.svelte +++ b/web-common/src/features/canvas/StaticCanvasRow.svelte @@ -14,6 +14,7 @@ export let components: Map; export let heightUnit: string = "px"; export let navigationEnabled: boolean = true; + export let activeComponentId: string | null = null; $: ({ height, items: _itemIds, widths: itemWidths } = row); @@ -36,7 +37,11 @@ {@const component = components.get(id)} {#if component} - + {:else} {/if} diff --git a/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte b/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte index 22843b5e9cc..1a595790e96 100644 --- a/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte +++ b/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte @@ -79,5 +79,6 @@ {pivotDataStore} pivotConfig={config} {pivotState} + {component} {widthScopeKey} /> diff --git a/web-common/src/features/canvas/components/pivot/CanvasPivotRenderer.svelte b/web-common/src/features/canvas/components/pivot/CanvasPivotRenderer.svelte index 9abdfd1ccfe..a7edeb8257d 100644 --- a/web-common/src/features/canvas/components/pivot/CanvasPivotRenderer.svelte +++ b/web-common/src/features/canvas/components/pivot/CanvasPivotRenderer.svelte @@ -4,13 +4,19 @@ import PivotEmpty from "@rilldata/web-common/features/dashboards/pivot/PivotEmpty.svelte"; import PivotError from "@rilldata/web-common/features/dashboards/pivot/PivotError.svelte"; import PivotTable from "@rilldata/web-common/features/dashboards/pivot/PivotTable.svelte"; - import { - type PivotDataStore, - type PivotDataStoreConfig, - type PivotState, + import type { + PivotDataStore, + PivotDataStoreConfig, + PivotState, } from "@rilldata/web-common/features/dashboards/pivot/types"; + import type { PivotCanvasComponent } from "./index"; + import { + createPivotClickToFilter, + type PivotClickToFilterResult, + } from "./pivot-click-to-filter"; - import type { Readable, Writable } from "svelte/store"; + import { onDestroy } from "svelte"; + import { derived, get, type Readable, type Writable } from "svelte/store"; export let schema: { isValid: boolean; @@ -21,11 +27,62 @@ export let pivotState: Writable; export let widthScopeKey: string; export let hasHeader = false; + export let component: PivotCanvasComponent; $: pivotColumns = splitPivotChips($pivotState.columns); $: hasColumnAndNoMeasure = pivotColumns.dimension.length > 0 && pivotColumns.measure.length === 0; + + // FilterManager and metrics view for filter application + $: canvasEntity = component.parent; + $: filterManager = canvasEntity.filterManager; + $: spec = component.specStore; + $: metricsViewName = $spec?.metrics_view; + $: selfFilteredDimensions = component.selfFilteredDimensions; + + $: whereFilterStore = derived(filterManager.filterMapStore, (filterMap) => { + return metricsViewName ? filterMap.get(metricsViewName) : undefined; + }); + + // Create click-to-filter orchestration; recreated when inputs become available + let clickToFilter: PivotClickToFilterResult | undefined; + + $: { + clickToFilter?.destroy(); + clickToFilter = undefined; + + if (pivotDataStore && pivotConfig && metricsViewName) { + const componentId = component.id; + clickToFilter = createPivotClickToFilter({ + pivotConfig, + pivotDataStore, + filterManager, + metricsViewName, + componentId, + activeComponent: canvasEntity.activeComponent, + selfFilteredDimensions, + whereFilterStore, + onBecomeActive: () => canvasEntity.setActiveComponent(componentId), + onBecomeInactive: () => { + if (get(canvasEntity.activeComponent) === componentId) { + canvasEntity.clearActiveComponent(); + } + }, + }); + } + } + + onDestroy(() => clickToFilter?.destroy()); + + // Unwrap stores from the factory result for template use + $: clickSelectionStore = clickToFilter?.clickSelection; + $: clickSelection = clickSelectionStore ? $clickSelectionStore : undefined; + + $: rowSelectionStateStore = clickToFilter?.rowSelectionState; + $: rowSelectionState = rowSelectionStateStore + ? $rowSelectionStateStore + : undefined;
{ pivotState.update((state) => ({ ...state, @@ -73,6 +133,8 @@ rowPage: page, })); }} + onCellClickToFilter={clickToFilter?.handleCellClickToFilter} + onColumnHeaderClick={clickToFilter?.handleColumnHeaderClick} /> {/if} {/if} diff --git a/web-common/src/features/canvas/components/pivot/index.ts b/web-common/src/features/canvas/components/pivot/index.ts index feea86333c1..ff8b6e4cc9b 100644 --- a/web-common/src/features/canvas/components/pivot/index.ts +++ b/web-common/src/features/canvas/components/pivot/index.ts @@ -54,6 +54,9 @@ export class PivotCanvasComponent extends BaseCanvasComponent< config: Readable; pivotDataStore: ReturnType; pivotState: Writable; + /** Dimensions the pivot itself has filtered via click-to-filter. + * These are excluded from the pivot's own data query so all rows remain visible. */ + selfFilteredDimensions: Writable>; constructor(resource: V1Resource, parent: CanvasEntity, path: ComponentPath) { const type = (resource.component?.state?.validSpec?.renderer ?? @@ -89,12 +92,14 @@ export class PivotCanvasComponent extends BaseCanvasComponent< this.type = type; this.pivotState = writable(this.getInitPivotState(type)); + this.selfFilteredDimensions = writable(new Set()); this.config = createPivotConfig( this.parent, this.specStore, this.pivotState, this.timeAndFilterStore, + this.selfFilteredDimensions, ); this.pivotDataStore = usePivotForCanvas( @@ -191,6 +196,12 @@ export class PivotCanvasComponent extends BaseCanvasComponent< updateTableType(newTableType: "pivot" | "table") { if (!this.parent.fileArtifact) return; + // Clear active component if this pivot was the active one + if (get(this.parent.activeComponent) === this.id) { + this.parent.clearActiveComponent(); + } + this.selfFilteredDimensions.set(new Set()); + this.type = newTableType; this.pivotState.set(this.getInitPivotState(newTableType)); diff --git a/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.spec.ts b/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.spec.ts new file mode 100644 index 00000000000..94f87c6f0c8 --- /dev/null +++ b/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.spec.ts @@ -0,0 +1,1400 @@ +import { + getFiltersForColumnHeader, + getFiltersForRowHeader, +} from "@rilldata/web-common/features/dashboards/pivot/pivot-row-selection"; +import { + getFiltersForCell, + getFiltersFromRow, +} from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; +import type { + PivotDataRow, + PivotDataStore, + PivotDataStoreConfig, + PivotFilter, +} from "@rilldata/web-common/features/dashboards/pivot/types"; +import { + createAndExpression, + createInExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import type { V1Expression } from "@rilldata/web-common/runtime-client"; +import { get, writable, type Readable } from "svelte/store"; +import { describe, expect, it, vi } from "vitest"; +import { + dimKeyFromDimValues, + dimKeyFromRow, +} from "../../../dashboards/pivot/pivot-click-selection"; +import type { FilterManager } from "../../stores/filter-manager"; +import { createPivotClickToFilter } from "./pivot-click-to-filter"; + +// --------------------------------------------------------------------------- +// Partial mocks: override only filter-extraction functions; keep real exports +// (extractDimensionFiltersFromExpression, getActiveDimensionNames, etc.) +// --------------------------------------------------------------------------- + +vi.mock( + "@rilldata/web-common/features/dashboards/pivot/pivot-utils", + async () => ({ + ...(await vi.importActual( + "@rilldata/web-common/features/dashboards/pivot/pivot-utils", + )), + getFiltersFromRow: vi.fn((): PivotFilter => EMPTY_FILTER), + getFiltersForCell: vi.fn((): PivotFilter => EMPTY_FILTER), + }), +); + +vi.mock( + "@rilldata/web-common/features/dashboards/pivot/pivot-row-selection", + async () => ({ + ...(await vi.importActual( + "@rilldata/web-common/features/dashboards/pivot/pivot-row-selection", + )), + getFiltersForRowData: vi.fn((): PivotFilter => EMPTY_FILTER), + getFiltersForRowHeader: vi.fn((): PivotFilter => EMPTY_FILTER), + getFiltersForColumnHeader: vi.fn((): PivotFilter => EMPTY_FILTER), + }), +); + +// --------------------------------------------------------------------------- +// Shared test helpers +// --------------------------------------------------------------------------- + +const EMPTY_FILTER: PivotFilter = { + filters: undefined, + timeRange: { start: undefined, end: undefined }, +}; + +function filter( + ...dims: Array<{ name: string; values: (string | null)[] }> +): PivotFilter { + return { + filters: createAndExpression( + dims.map(({ name, values }) => createInExpression(name, values)), + ), + timeRange: { start: undefined, end: undefined }, + }; +} + +/** Shorthand: single-dimension filter */ +function filter1(name: string, values: (string | null)[]): PivotFilter { + return filter({ name, values }); +} + +function dk(dims: Record, order: string[]): string { + return dimKeyFromDimValues(dims, order); +} + +function stubFilterManager() { + return { + metricsViewFilters: new Map< + string, + { + addDimensionValueSelections: ReturnType; + toggleDimensionValueSelections: ReturnType; + } + >(), + checkTemporaryFilter: vi.fn(), + applyFiltersToUrl: vi.fn(), + } as unknown as FilterManager; +} + +function stubFilterManagerWithClass(metricsViewName: string) { + const fm = stubFilterManager(); + const filterClass = { + addDimensionValueSelections: vi.fn(() => "filter-string"), + toggleDimensionValueSelections: vi.fn(() => "filter-string"), + }; + (fm.metricsViewFilters as unknown as Map).set( + metricsViewName, + filterClass, + ); + return { fm, filterClass }; +} + +function makeConfig( + overrides: Partial & { + rowDimensionNames: string[]; + measureNames: string[]; + }, +) { + return { + colDimensionNames: [], + isFlat: false, + time: { timeDimension: "", timeStart: undefined, timeEnd: undefined }, + ...overrides, + } as unknown as PivotDataStoreConfig; +} + +function stubPivotDataStore( + data: PivotDataRow[], + columnDimensionAxes: Record = {}, +): PivotDataStore { + return writable({ + isFetching: false, + data, + columnDef: [], + assembled: true, + totalColumns: 0, + columnDimensionAxes, + }); +} + +function createFactoryArgs( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + pivotConfig: writable( + makeConfig({ + rowDimensionNames: ["country"], + measureNames: ["total"], + isFlat: true, + }), + ) as Readable, + pivotDataStore: stubPivotDataStore([]), + filterManager: stubFilterManager(), + metricsViewName: "mv1", + componentId: "pivot-1", + activeComponent: writable(null), + selfFilteredDimensions: writable>(new Set()), + whereFilterStore: writable(undefined), + ...overrides, + }; +} + +/** Create factory with a working filterClass and active component */ +function setup( + config: PivotDataStoreConfig, + data: PivotDataRow[], + columnDimensionAxes: Record = {}, +) { + const selfFilteredDimensions = writable>(new Set()); + const { fm, filterClass } = stubFilterManagerWithClass("mv1"); + + const result = createPivotClickToFilter( + createFactoryArgs({ + pivotConfig: writable(config) as Readable, + pivotDataStore: stubPivotDataStore(data, columnDimensionAxes), + filterManager: fm, + activeComponent: writable("pivot-1"), + selfFilteredDimensions, + }), + ); + + return { result, filterClass, selfFilteredDimensions, fm }; +} + +/** Read current click selection */ +function sel(result: ReturnType["result"]) { + return get(result.clickSelection); +} + +/** Assert that toggle was NOT called for a given dimension */ +function expectNoToggle( + filterClass: ReturnType["filterClass"], + dimensionName: string, +) { + const calls = filterClass.toggleDimensionValueSelections.mock.calls.filter( + (call: unknown[]) => call[0] === dimensionName, + ); + expect(calls.length).toBe(0); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("clearActiveComponent", () => { + it("clears selfFilteredDimensions when activeComponent changes", () => { + const activeComponent = writable(null); + const selfFilteredDimensions = writable>(new Set()); + const onBecomeInactive = vi.fn(); + + const result = createPivotClickToFilter( + createFactoryArgs({ + activeComponent, + selfFilteredDimensions, + onBecomeInactive, + }), + ); + + activeComponent.set("pivot-1"); + selfFilteredDimensions.set(new Set(["country"])); + onBecomeInactive.mockClear(); + + // Another component becomes active + activeComponent.set("pivot-2"); + expect(get(selfFilteredDimensions).size).toBe(0); + expect(onBecomeInactive).toHaveBeenCalled(); + + result.destroy(); + }); + + it("does NOT clear when this component is set as active", () => { + const activeComponent = writable(null); + const selfFilteredDimensions = writable>(new Set()); + + const result = createPivotClickToFilter( + createFactoryArgs({ activeComponent, selfFilteredDimensions }), + ); + + selfFilteredDimensions.set(new Set(["country"])); + activeComponent.set("pivot-1"); + + expect(get(selfFilteredDimensions).has("country")).toBe(true); + result.destroy(); + }); +}); + +describe("flat table: single-cell-per-row", () => { + const config = makeConfig({ + rowDimensionNames: ["country", "city"], + measureNames: ["revenue"], + isFlat: true, + }); + const data: PivotDataRow[] = [ + { country: "US", city: "NYC", revenue: 100 }, + { country: "UK", city: "London", revenue: 200 }, + ]; + const dkRow0 = dimKeyFromRow(data[0], ["country", "city"]); + const dkRow1 = dimKeyFromRow(data[1], ["country", "city"]); + + function setupFlat() { + vi.mocked(getFiltersFromRow).mockImplementation((_cfg, _row, colId) => { + if (colId === "country") return filter1("country", ["US"]); + if (colId === "city") return filter1("city", ["NYC"]); + return filter1("country", ["US"]); + }); + return setup(config, data); + } + + it("replaces existing cell in the same row", () => { + const { result } = setupFlat(); + + result.handleCellClickToFilter("0", "country", false, data[0]); + expect(sel(result).isCellSelected(dkRow0, "country")).toBe(true); + + result.handleCellClickToFilter("0", "city", false, data[0]); + expect(sel(result).isCellSelected(dkRow0, "country")).toBe(false); + expect(sel(result).isCellSelected(dkRow0, "city")).toBe(true); + expect(sel(result).cellSelections.size).toBe(1); + + result.destroy(); + }); + + it("deselects by re-clicking the same cell", () => { + const { result } = setupFlat(); + + result.handleCellClickToFilter("0", "country", false, data[0]); + result.handleCellClickToFilter("0", "country", false, data[0]); + expect(sel(result).cellSelections.size).toBe(0); + + result.destroy(); + }); + + it("allows selections across different rows", () => { + const { result } = setupFlat(); + + result.handleCellClickToFilter("0", "country", false, data[0]); + vi.mocked(getFiltersFromRow).mockImplementation(() => + filter1("country", ["UK"]), + ); + result.handleCellClickToFilter("1", "country", false, data[1]); + + expect(sel(result).isCellSelected(dkRow0, "country")).toBe(true); + expect(sel(result).isCellSelected(dkRow1, "country")).toBe(true); + expect(sel(result).cellSelections.size).toBe(2); + + result.destroy(); + }); +}); + +describe("nested table: multi-select", () => { + const config = makeConfig({ + rowDimensionNames: ["country"], + measureNames: ["revenue"], + }); + const data: PivotDataRow[] = [ + { + country: "US", + revenue: 100, + subRows: [{ country: "US-East", revenue: 50 }], + }, + ]; + const dkRow0 = dimKeyFromRow(data[0], ["country"]); + + it("allows multiple cells in the same row", () => { + vi.mocked(getFiltersForCell).mockImplementation(() => + filter1("country", ["US"]), + ); + const { result } = setup(config, data); + + result.handleCellClickToFilter("0", "revenue", false, data[0]); + result.handleCellClickToFilter("0", "other_measure", false, data[0]); + + expect(sel(result).isCellSelected(dkRow0, "revenue")).toBe(true); + expect(sel(result).isCellSelected(dkRow0, "other_measure")).toBe(true); + expect(sel(result).cellSelections.size).toBe(2); + + result.destroy(); + }); +}); + +describe("nested table: cross-parent selection isolation", () => { + const config = makeConfig({ + rowDimensionNames: ["outer", "inner"], + measureNames: ["revenue"], + }); + const data: PivotDataRow[] = [ + { + outer: "A", + revenue: 100, + subRows: [{ outer: "X", inner: "X", revenue: 50 }], + }, + { + outer: "B", + revenue: 200, + subRows: [{ outer: "X", inner: "X", revenue: 75 }], + }, + ]; + const innerRowXUnderA = data[0].subRows![0]; + const dims = ["outer", "inner"]; + + function setupCrossParent() { + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "1.0") + return filter( + { name: "outer", values: ["A"] }, + { name: "inner", values: ["X"] }, + ); + if (rowId === "2.0") + return filter( + { name: "outer", values: ["B"] }, + { name: "inner", values: ["X"] }, + ); + return EMPTY_FILTER; + }); + vi.mocked(getFiltersForRowHeader).mockImplementation((_cfg, rowId) => { + if (rowId === "1.0") + return filter( + { name: "outer", values: ["A"] }, + { name: "inner", values: ["X"] }, + ); + if (rowId === "2.0") + return filter( + { name: "outer", values: ["B"] }, + { name: "inner", values: ["X"] }, + ); + return EMPTY_FILTER; + }); + return setup(config, data); + } + + it("produces distinct dimKeys for same inner value under different parents", () => { + expect(dk({ outer: "A", inner: "X" }, dims)).toBe("A\0X"); + expect(dk({ outer: "B", inner: "X" }, dims)).toBe("B\0X"); + expect(dk({ outer: "A", inner: "X" }, dims)).not.toBe( + dk({ outer: "B", inner: "X" }, dims), + ); + }); + + it("does NOT select X under B when clicking X under A", () => { + const { result } = setupCrossParent(); + + result.handleCellClickToFilter("1.0", "revenue", false, innerRowXUnderA); + + expect( + sel(result).isCellSelected( + dk({ outer: "A", inner: "X" }, dims), + "revenue", + ), + ).toBe(true); + expect( + sel(result).isCellSelected( + dk({ outer: "B", inner: "X" }, dims), + "revenue", + ), + ).toBe(false); + expect(sel(result).cellSelections.size).toBe(1); + + result.destroy(); + }); + + it("does NOT select row header X under B when clicking X under A", () => { + const { result } = setupCrossParent(); + + result.handleCellClickToFilter("1.0", "outer", true, innerRowXUnderA); + + expect( + sel(result).isRowHeaderSelected(dk({ outer: "A", inner: "X" }, dims)), + ).toBe(true); + expect( + sel(result).isRowHeaderSelected(dk({ outer: "B", inner: "X" }, dims)), + ).toBe(false); + + result.destroy(); + }); +}); + +describe("null dimension values", () => { + const config = makeConfig({ + rowDimensionNames: ["country"], + measureNames: ["total"], + isFlat: true, + }); + const data: PivotDataRow[] = [ + { country: null, revenue: 100 }, + { country: "US", revenue: 200 }, + ]; + const dkNull = dimKeyFromRow(data[0], ["country"]); + + function setupNull() { + vi.mocked(getFiltersFromRow).mockImplementation((_cfg, rowData) => + filter1("country", [rowData["country"] as string]), + ); + return setup(config, data); + } + + it("selects a cell with null dimension value", () => { + const { result, filterClass } = setupNull(); + + result.handleCellClickToFilter("0", "total", false, data[0]); + expect(sel(result).isCellSelected(dkNull, "total")).toBe(true); + expect(filterClass.addDimensionValueSelections).toHaveBeenCalledWith( + "country", + [null], + ); + + result.destroy(); + }); + + it("deselects a cell with null dimension value", () => { + const { result, filterClass } = setupNull(); + + result.handleCellClickToFilter("0", "total", false, data[0]); + result.handleCellClickToFilter("0", "total", false, data[0]); + + expect(sel(result).cellSelections.size).toBe(0); + expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalled(); + + result.destroy(); + }); +}); + +describe("selection survives sorting", () => { + it("identifies same row after data order changes", () => { + const { fm } = stubFilterManagerWithClass("mv1"); + const config = makeConfig({ + rowDimensionNames: ["country"], + measureNames: ["total"], + isFlat: true, + }); + + const dataBefore: PivotDataRow[] = [ + { country: "US", revenue: 100 }, + { country: "UK", revenue: 200 }, + ]; + const pivotDataStore = writable({ + isFetching: false, + data: dataBefore, + columnDef: [], + assembled: true, + totalColumns: 0, + columnDimensionAxes: {}, + }); + + vi.mocked(getFiltersFromRow).mockImplementation(() => + filter1("country", ["US"]), + ); + + const result = createPivotClickToFilter( + createFactoryArgs({ + pivotConfig: writable(config) as Readable, + pivotDataStore: pivotDataStore as unknown as PivotDataStore, + filterManager: fm, + activeComponent: writable("pivot-1"), + }), + ); + + const usDk = dimKeyFromRow(dataBefore[0], ["country"]); + result.handleCellClickToFilter("0", "total", false, dataBefore[0]); + expect(sel(result).isCellSelected(usDk, "total")).toBe(true); + + // Simulate sort: UK now first + pivotDataStore.set({ + isFetching: false, + data: [ + { country: "UK", revenue: 200 }, + { country: "US", revenue: 100 }, + ], + columnDef: [], + assembled: true, + totalColumns: 0, + columnDimensionAxes: {}, + }); + + expect(sel(result).isCellSelected(usDk, "total")).toBe(true); + expect( + sel(result).isCellSelected( + dimKeyFromRow({ country: "UK" }, ["country"]), + "total", + ), + ).toBe(false); + + result.destroy(); + }); +}); + +describe("column header level selection constraint", () => { + const config = makeConfig({ + rowDimensionNames: ["country"], + colDimensionNames: ["region", "category", "product"], + measureNames: ["revenue"], + whereFilter: createAndExpression([]), + }); + + function setupColHeaders() { + vi.mocked(getFiltersForColumnHeader).mockImplementation((_cfg, path) => { + const dims = Object.entries(path).map(([name, value]) => ({ + name, + values: [value], + })); + return filter(...dims); + }); + return setup(config, []); + } + + it("allows multiple selections at the same level", () => { + const { result } = setupColHeaders(); + + result.handleColumnHeaderClick({ region: "NA" }); + result.handleColumnHeaderClick({ region: "EU" }); + + expect(sel(result).isColumnHeaderSelected({ region: "NA" })).toBe(true); + expect(sel(result).isColumnHeaderSelected({ region: "EU" })).toBe(true); + expect(sel(result).columnHeaderSelections.size).toBe(2); + + result.destroy(); + }); + + it("replaces selections when clicking a different level", () => { + const { result, filterClass } = setupColHeaders(); + + result.handleColumnHeaderClick({ region: "NA" }); + result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); + + expect(sel(result).isColumnHeaderSelected({ region: "NA" })).toBe(false); + expect( + sel(result).isColumnHeaderSelected({ + region: "NA", + category: "Electronics", + }), + ).toBe(true); + expect(sel(result).columnHeaderSelections.size).toBe(1); + expect(filterClass.addDimensionValueSelections).toHaveBeenCalledWith( + "category", + ["Electronics"], + ); + + result.destroy(); + }); + + it("removes orphaned values when switching levels", () => { + const { result, filterClass } = setupColHeaders(); + + result.handleColumnHeaderClick({ region: "NA" }); + result.handleColumnHeaderClick({ region: "EU" }); + filterClass.toggleDimensionValueSelections.mockClear(); + + result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); + + expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( + "region", + ["EU"], + false, + false, + ); + + result.destroy(); + }); + + it("replaces multiple same-level selections when switching levels", () => { + const { result } = setupColHeaders(); + + result.handleColumnHeaderClick({ region: "NA" }); + result.handleColumnHeaderClick({ region: "EU" }); + result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); + + expect(sel(result).isColumnHeaderSelected({ region: "NA" })).toBe(false); + expect(sel(result).isColumnHeaderSelected({ region: "EU" })).toBe(false); + expect( + sel(result).isColumnHeaderSelected({ + region: "NA", + category: "Electronics", + }), + ).toBe(true); + expect(sel(result).columnHeaderSelections.size).toBe(1); + + result.destroy(); + }); + + it("deselects by re-clicking the same header", () => { + const { result } = setupColHeaders(); + + result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); + result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); + + expect(sel(result).columnHeaderSelections.size).toBe(0); + result.destroy(); + }); + + it("allows fresh selection at any level after all deselected", () => { + const { result } = setupColHeaders(); + + result.handleColumnHeaderClick({ region: "NA" }); + result.handleColumnHeaderClick({ region: "NA" }); // deselect + + result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); + expect( + sel(result).isColumnHeaderSelected({ + region: "NA", + category: "Electronics", + }), + ).toBe(true); + expect(sel(result).columnHeaderSelections.size).toBe(1); + + result.destroy(); + }); + + it("does not remove shared dimension values when switching levels", () => { + const { result, filterClass } = setupColHeaders(); + + result.handleColumnHeaderClick({ region: "NA" }); + result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); + + expectNoToggle(filterClass, "region"); + expect(filterClass.addDimensionValueSelections).toHaveBeenCalledWith( + "category", + ["Electronics"], + ); + + result.destroy(); + }); + + it("removes shared child-level dim values on child-to-parent level switch across multiple children", () => { + const { result, filterClass } = setupColHeaders(); + + // Two leaf (level 2) col headers sharing category=Electronics + result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); + result.handleColumnHeaderClick({ region: "EU", category: "Electronics" }); + filterClass.toggleDimensionValueSelections.mockClear(); + + // Click parent (level 1) — should replace both, dropping the shared + // category value since the new selection doesn't mention category + result.handleColumnHeaderClick({ region: "NA" }); + + expect(sel(result).columnHeaderSelections.size).toBe(1); + expect(sel(result).isColumnHeaderSelected({ region: "NA" })).toBe(true); + + // Shared category=Electronics must be toggled off exactly once and not + // re-added by a duplicate toggle + const categoryCalls = + filterClass.toggleDimensionValueSelections.mock.calls.filter( + (c: unknown[]) => c[0] === "category", + ); + expect(categoryCalls.length).toBe(1); + expect(categoryCalls[0]).toEqual([ + "category", + ["Electronics"], + false, + false, + ]); + + result.destroy(); + }); +}); + +describe("deselect retains shared column filters", () => { + it("retains column dimension values still needed by remaining cells", () => { + const config = makeConfig({ + rowDimensionNames: ["borough"], + colDimensionNames: ["status", "type"], + measureNames: ["revenue"], + isFlat: true, + }); + const data: PivotDataRow[] = [ + { borough: "New York", revenue: 100 }, + { borough: "Bronx", revenue: 200 }, + ]; + + vi.mocked(getFiltersFromRow).mockImplementation((_cfg, rowData) => { + return filter( + { name: "borough", values: [rowData["borough"] as string] }, + { name: "status", values: ["Closed"] }, + { name: "type", values: ["Intersection"] }, + ); + }); + + const { result, filterClass } = setup(config, data); + + result.handleCellClickToFilter("1", "revenue", false, data[0]); + result.handleCellClickToFilter("2", "revenue", false, data[1]); + + const dkNY = dimKeyFromRow(data[0], ["borough"]); + const dkBronx = dimKeyFromRow(data[1], ["borough"]); + expect(sel(result).isCellSelected(dkNY, "revenue")).toBe(true); + expect(sel(result).isCellSelected(dkBronx, "revenue")).toBe(true); + + filterClass.toggleDimensionValueSelections.mockClear(); + result.handleCellClickToFilter("2", "revenue", false, data[1]); + + expect(sel(result).isCellSelected(dkBronx, "revenue")).toBe(false); + expect(sel(result).isCellSelected(dkNY, "revenue")).toBe(true); + + expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( + "borough", + ["Bronx"], + false, + false, + ); + expectNoToggle(filterClass, "status"); + expectNoToggle(filterClass, "type"); + + result.destroy(); + }); +}); + +describe("header/cell mutual exclusivity", () => { + const nestedConfig = makeConfig({ + rowDimensionNames: ["outer", "inner"], + measureNames: ["revenue"], + }); + const nestedData: PivotDataRow[] = [ + { + outer: "Zoom", + revenue: 100, + subRows: [{ outer: "US-East", inner: "US-East", revenue: 50 }], + }, + { + outer: "Airtable", + revenue: 200, + subRows: [{ outer: "US-West", inner: "US-West", revenue: 75 }], + }, + ]; + const dims = ["outer", "inner"]; + const parentZoom = nestedData[0]; + const childUSEast = nestedData[0].subRows![0]; + const childUSWest = nestedData[1].subRows![0]; + + function setupNested() { + vi.mocked(getFiltersForRowHeader).mockImplementation((_cfg, rowId) => { + if (rowId === "1") return filter1("outer", ["Zoom"]); + if (rowId === "2") return filter1("outer", ["Airtable"]); + if (rowId === "1.0") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "inner", values: ["US-East"] }, + ); + if (rowId === "2.0") + return filter( + { name: "outer", values: ["Airtable"] }, + { name: "inner", values: ["US-West"] }, + ); + return EMPTY_FILTER; + }); + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "1.0") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "inner", values: ["US-East"] }, + ); + if (rowId === "2.0") + return filter( + { name: "outer", values: ["Airtable"] }, + { name: "inner", values: ["US-West"] }, + ); + return EMPTY_FILTER; + }); + return setup(nestedConfig, nestedData); + } + + it("row header click evicts child cells under it", () => { + const { result, filterClass } = setupNested(); + const dkChild = dk({ outer: "Zoom", inner: "US-East" }, dims); + const dkZoom = dk({ outer: "Zoom" }, dims); + + result.handleCellClickToFilter("1.0", "revenue", false, childUSEast); + expect(sel(result).isCellSelected(dkChild, "revenue")).toBe(true); + + filterClass.toggleDimensionValueSelections.mockClear(); + result.handleCellClickToFilter("1", "outer", true, parentZoom); + + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(true); + expect(sel(result).isCellSelected(dkChild, "revenue")).toBe(false); + expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( + "inner", + ["US-East"], + false, + false, + ); + + result.destroy(); + }); + + it("cell click evicts ancestor row header", () => { + const { result } = setupNested(); + const dkZoom = dk({ outer: "Zoom" }, dims); + const dkChild = dk({ outer: "Zoom", inner: "US-East" }, dims); + + result.handleCellClickToFilter("1", "outer", true, parentZoom); + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(true); + + result.handleCellClickToFilter("1.0", "revenue", false, childUSEast); + + expect(sel(result).isCellSelected(dkChild, "revenue")).toBe(true); + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(false); + + result.destroy(); + }); + + it("different lineage coexists: header + cell under different parent", () => { + const { result } = setupNested(); + + result.handleCellClickToFilter("1", "outer", true, parentZoom); + result.handleCellClickToFilter("2.0", "revenue", false, childUSWest); + + expect(sel(result).isRowHeaderSelected(dk({ outer: "Zoom" }, dims))).toBe( + true, + ); + expect( + sel(result).isCellSelected( + dk({ outer: "Airtable", inner: "US-West" }, dims), + "revenue", + ), + ).toBe(true); + + result.destroy(); + }); + + it("parent row header click evicts child row header under it", () => { + const { result, filterClass } = setupNested(); + const dkZoom = dk({ outer: "Zoom" }, dims); + const dkChild = dk({ outer: "Zoom", inner: "US-East" }, dims); + + // Select child row header first + result.handleCellClickToFilter("1.0", "inner", true, childUSEast); + expect(sel(result).isRowHeaderSelected(dkChild)).toBe(true); + + // Click parent row header — child must be evicted + filterClass.toggleDimensionValueSelections.mockClear(); + result.handleCellClickToFilter("1", "outer", true, parentZoom); + + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(true); + expect(sel(result).isRowHeaderSelected(dkChild)).toBe(false); + expect(sel(result).rowHeaderSelections.size).toBe(1); + // Orphaned inner value is removed from the global filter + expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( + "inner", + ["US-East"], + false, + false, + ); + + result.destroy(); + }); + + it("child row header click evicts ancestor row header above it", () => { + const { result } = setupNested(); + const dkZoom = dk({ outer: "Zoom" }, dims); + const dkChild = dk({ outer: "Zoom", inner: "US-East" }, dims); + + // Select parent first + result.handleCellClickToFilter("1", "outer", true, parentZoom); + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(true); + + // Click child row header — parent must be evicted + result.handleCellClickToFilter("1.0", "inner", true, childUSEast); + + expect(sel(result).isRowHeaderSelected(dkChild)).toBe(true); + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(false); + expect(sel(result).rowHeaderSelections.size).toBe(1); + + result.destroy(); + }); + + it("parent row header click keeps sibling-lineage row headers intact", () => { + const { result } = setupNested(); + const dkZoom = dk({ outer: "Zoom" }, dims); + const dkAirtableChild = dk({ outer: "Airtable", inner: "US-West" }, dims); + + // Select a child under a different parent first + result.handleCellClickToFilter("2.0", "inner", true, childUSWest); + expect(sel(result).isRowHeaderSelected(dkAirtableChild)).toBe(true); + + // Click Zoom parent — Airtable's child header is a different lineage, must coexist + result.handleCellClickToFilter("1", "outer", true, parentZoom); + + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(true); + expect(sel(result).isRowHeaderSelected(dkAirtableChild)).toBe(true); + expect(sel(result).rowHeaderSelections.size).toBe(2); + + result.destroy(); + }); + + it("parent row cell click evicts child row headers under it", () => { + const { result, filterClass } = setupNested(); + const dkChildHeader = dk({ outer: "Zoom", inner: "US-East" }, dims); + const dkZoom = dk({ outer: "Zoom" }, dims); + + // Select a child row header first + result.handleCellClickToFilter("1.0", "inner", true, childUSEast); + expect(sel(result).isRowHeaderSelected(dkChildHeader)).toBe(true); + + // Click parent row's measure cell — child row header must be evicted + filterClass.toggleDimensionValueSelections.mockClear(); + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "1") return filter1("outer", ["Zoom"]); + return EMPTY_FILTER; + }); + result.handleCellClickToFilter("1", "revenue", false, parentZoom); + + expect(sel(result).isCellSelected(dkZoom, "revenue")).toBe(true); + expect(sel(result).isRowHeaderSelected(dkChildHeader)).toBe(false); + expect(sel(result).rowHeaderSelections.size).toBe(0); + expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( + "inner", + ["US-East"], + false, + false, + ); + + result.destroy(); + }); + + it("child row cell click evicts ancestor parent row header", () => { + const { result } = setupNested(); + const dkZoom = dk({ outer: "Zoom" }, dims); + const dkChild = dk({ outer: "Zoom", inner: "US-East" }, dims); + + // Select parent row header first + result.handleCellClickToFilter("1", "outer", true, parentZoom); + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(true); + + // Click child row's measure cell — parent row header must be evicted + result.handleCellClickToFilter("1.0", "revenue", false, childUSEast); + + expect(sel(result).isCellSelected(dkChild, "revenue")).toBe(true); + expect(sel(result).isRowHeaderSelected(dkZoom)).toBe(false); + expect(sel(result).rowHeaderSelections.size).toBe(0); + + result.destroy(); + }); + + it("parent row cell click evicts child row cells under it", () => { + const { result, filterClass } = setupNested(); + const dkChild = dk({ outer: "Zoom", inner: "US-East" }, dims); + const dkZoom = dk({ outer: "Zoom" }, dims); + + // Select a child measure cell first + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "1.0") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "inner", values: ["US-East"] }, + ); + return EMPTY_FILTER; + }); + result.handleCellClickToFilter("1.0", "revenue", false, childUSEast); + expect(sel(result).isCellSelected(dkChild, "revenue")).toBe(true); + + // Click the parent row's measure cell — child cell must be evicted + filterClass.toggleDimensionValueSelections.mockClear(); + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "1") return filter1("outer", ["Zoom"]); + return EMPTY_FILTER; + }); + result.handleCellClickToFilter("1", "revenue", false, parentZoom); + + expect(sel(result).isCellSelected(dkZoom, "revenue")).toBe(true); + expect(sel(result).isCellSelected(dkChild, "revenue")).toBe(false); + expect(sel(result).cellSelections.size).toBe(1); + // Orphaned inner value is removed from the global filter + expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( + "inner", + ["US-East"], + false, + false, + ); + + result.destroy(); + }); + + it("parent row cell click evicts multiple child row cells under it", () => { + const { result } = setupNested(); + const dkChildEast = dk({ outer: "Zoom", inner: "US-East" }, dims); + const dkChildExpanded = nestedData[0].subRows!; + // Add a second child to the Zoom parent for this test + const childUSWestUnderZoom = { + outer: "US-West", + inner: "US-West", + revenue: 25, + }; + nestedData[0].subRows = [...dkChildExpanded, childUSWestUnderZoom]; + + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "1.0") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "inner", values: ["US-East"] }, + ); + if (rowId === "1.1") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "inner", values: ["US-West"] }, + ); + if (rowId === "1") return filter1("outer", ["Zoom"]); + return EMPTY_FILTER; + }); + + result.handleCellClickToFilter("1.0", "revenue", false, childUSEast); + result.handleCellClickToFilter( + "1.1", + "revenue", + false, + childUSWestUnderZoom, + ); + expect(sel(result).cellSelections.size).toBe(2); + + // Click parent row cell — both children evicted + result.handleCellClickToFilter("1", "revenue", false, parentZoom); + + expect( + sel(result).isCellSelected(dk({ outer: "Zoom" }, dims), "revenue"), + ).toBe(true); + expect(sel(result).isCellSelected(dkChildEast, "revenue")).toBe(false); + expect(sel(result).cellSelections.size).toBe(1); + + // Restore data for subsequent tests + nestedData[0].subRows = dkChildExpanded; + result.destroy(); + }); + + it("child row cell click evicts ancestor parent row cell", () => { + const { result } = setupNested(); + const dkZoom = dk({ outer: "Zoom" }, dims); + const dkChild = dk({ outer: "Zoom", inner: "US-East" }, dims); + + // Select parent row cell first + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "1") return filter1("outer", ["Zoom"]); + if (rowId === "1.0") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "inner", values: ["US-East"] }, + ); + return EMPTY_FILTER; + }); + result.handleCellClickToFilter("1", "revenue", false, parentZoom); + expect(sel(result).isCellSelected(dkZoom, "revenue")).toBe(true); + + // Click child row cell — parent cell must be evicted + result.handleCellClickToFilter("1.0", "revenue", false, childUSEast); + + expect(sel(result).isCellSelected(dkChild, "revenue")).toBe(true); + expect(sel(result).isCellSelected(dkZoom, "revenue")).toBe(false); + expect(sel(result).cellSelections.size).toBe(1); + + result.destroy(); + }); + + it("parent row cell click keeps sibling-lineage cells intact", () => { + const { result } = setupNested(); + const dkAirtableChild = dk({ outer: "Airtable", inner: "US-West" }, dims); + + // Select a cell under Airtable parent + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "2.0") + return filter( + { name: "outer", values: ["Airtable"] }, + { name: "inner", values: ["US-West"] }, + ); + if (rowId === "1") return filter1("outer", ["Zoom"]); + return EMPTY_FILTER; + }); + result.handleCellClickToFilter("2.0", "revenue", false, childUSWest); + expect(sel(result).isCellSelected(dkAirtableChild, "revenue")).toBe(true); + + // Click Zoom parent row cell — Airtable's child cell is a different + // lineage and must coexist + result.handleCellClickToFilter("1", "revenue", false, parentZoom); + + expect( + sel(result).isCellSelected(dk({ outer: "Zoom" }, dims), "revenue"), + ).toBe(true); + expect(sel(result).isCellSelected(dkAirtableChild, "revenue")).toBe(true); + expect(sel(result).cellSelections.size).toBe(2); + + result.destroy(); + }); + + it("parent cell + same-row sibling-column cells coexist (not lineage)", () => { + const { result } = setupNested(); + const dkZoom = dk({ outer: "Zoom" }, dims); + + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId) => { + if (rowId === "1") return filter1("outer", ["Zoom"]); + return EMPTY_FILTER; + }); + + // Two cells in the same parent row, different columns: same dimValues, + // not in a strict subset/superset relationship — both must coexist. + result.handleCellClickToFilter("1", "revenue", false, parentZoom); + result.handleCellClickToFilter("1", "other_measure", false, parentZoom); + + expect(sel(result).isCellSelected(dkZoom, "revenue")).toBe(true); + expect(sel(result).isCellSelected(dkZoom, "other_measure")).toBe(true); + expect(sel(result).cellSelections.size).toBe(2); + + result.destroy(); + }); + + // Nested rows + nested columns: parent-row cell click should evict every + // child-row cell in that lineage regardless of which column the parent or + // child cells sit in. Cell-on-cell row lineage ignores column dims. + describe("with column dimensions", () => { + const nestedColConfig = makeConfig({ + rowDimensionNames: ["outer", "inner"], + colDimensionNames: ["quarter", "env"], + measureNames: ["revenue"], + }); + const colData: PivotDataRow[] = [ + { + outer: "Zoom", + revenue: 100, + subRows: [ + { outer: "US-East", inner: "US-East", revenue: 50 }, + { outer: "US-West", inner: "US-West", revenue: 30 }, + ], + }, + ]; + const colDims = ["outer", "inner"]; + const parentZoomCol = colData[0]; + const childEastCol = colData[0].subRows![0]; + const childWestCol = colData[0].subRows![1]; + + function setupNestedWithCols() { + // Children sit at different leaf columns (Q1×Prod and Q2×Dev). + vi.mocked(getFiltersForCell).mockImplementation((_cfg, rowId, colId) => { + if (rowId === "1.0" && colId === "child_east_col") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "inner", values: ["US-East"] }, + { name: "quarter", values: ["Q1"] }, + { name: "env", values: ["Prod"] }, + ); + if (rowId === "1.1" && colId === "child_west_col") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "inner", values: ["US-West"] }, + { name: "quarter", values: ["Q2"] }, + { name: "env", values: ["Dev"] }, + ); + // Parent row at totals column (no col dims) + if (rowId === "1" && colId === "totals_col") + return filter1("outer", ["Zoom"]); + // Parent row at quarter-aggregate column (one col dim) + if (rowId === "1" && colId === "q1_total_col") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "quarter", values: ["Q1"] }, + ); + // Parent row at quarter+env leaf column (both col dims) + if (rowId === "1" && colId === "q1_prod_col") + return filter( + { name: "outer", values: ["Zoom"] }, + { name: "quarter", values: ["Q1"] }, + { name: "env", values: ["Prod"] }, + ); + return EMPTY_FILTER; + }); + return setup(nestedColConfig, colData); + } + + it("parent cell at totals column evicts all child cells", () => { + const { result } = setupNestedWithCols(); + const dkChildEast = dk({ outer: "Zoom", inner: "US-East" }, colDims); + const dkChildWest = dk({ outer: "Zoom", inner: "US-West" }, colDims); + const dkZoom = dk({ outer: "Zoom" }, colDims); + + result.handleCellClickToFilter( + "1.0", + "child_east_col", + false, + childEastCol, + ); + result.handleCellClickToFilter( + "1.1", + "child_west_col", + false, + childWestCol, + ); + expect(sel(result).cellSelections.size).toBe(2); + + result.handleCellClickToFilter("1", "totals_col", false, parentZoomCol); + + expect(sel(result).isCellSelected(dkZoom, "totals_col")).toBe(true); + expect(sel(result).isCellSelected(dkChildEast, "child_east_col")).toBe( + false, + ); + expect(sel(result).isCellSelected(dkChildWest, "child_west_col")).toBe( + false, + ); + expect(sel(result).cellSelections.size).toBe(1); + + result.destroy(); + }); + + it("parent cell at quarter-aggregate column evicts all child cells across columns", () => { + const { result } = setupNestedWithCols(); + const dkChildEast = dk({ outer: "Zoom", inner: "US-East" }, colDims); + const dkChildWest = dk({ outer: "Zoom", inner: "US-West" }, colDims); + const dkZoom = dk({ outer: "Zoom" }, colDims); + + // Children sit at different quarters + result.handleCellClickToFilter( + "1.0", + "child_east_col", + false, + childEastCol, + ); + result.handleCellClickToFilter( + "1.1", + "child_west_col", + false, + childWestCol, + ); + expect(sel(result).cellSelections.size).toBe(2); + + // Click parent's Q1-total cell — both children must be evicted even + // though one is at Q2. + result.handleCellClickToFilter("1", "q1_total_col", false, parentZoomCol); + + expect(sel(result).isCellSelected(dkZoom, "q1_total_col")).toBe(true); + expect(sel(result).isCellSelected(dkChildEast, "child_east_col")).toBe( + false, + ); + expect(sel(result).isCellSelected(dkChildWest, "child_west_col")).toBe( + false, + ); + expect(sel(result).cellSelections.size).toBe(1); + + result.destroy(); + }); + + it("parent cell at leaf column evicts all child cells across columns", () => { + const { result } = setupNestedWithCols(); + const dkChildEast = dk({ outer: "Zoom", inner: "US-East" }, colDims); + const dkChildWest = dk({ outer: "Zoom", inner: "US-West" }, colDims); + const dkZoom = dk({ outer: "Zoom" }, colDims); + + result.handleCellClickToFilter( + "1.0", + "child_east_col", + false, + childEastCol, + ); + result.handleCellClickToFilter( + "1.1", + "child_west_col", + false, + childWestCol, + ); + expect(sel(result).cellSelections.size).toBe(2); + + // Click parent's Q1×Prod leaf cell — both children must be evicted + // regardless of which column they sit in. + result.handleCellClickToFilter("1", "q1_prod_col", false, parentZoomCol); + + expect(sel(result).isCellSelected(dkZoom, "q1_prod_col")).toBe(true); + expect(sel(result).isCellSelected(dkChildEast, "child_east_col")).toBe( + false, + ); + expect(sel(result).isCellSelected(dkChildWest, "child_west_col")).toBe( + false, + ); + expect(sel(result).cellSelections.size).toBe(1); + + result.destroy(); + }); + }); + + // Column header mutual exclusivity uses flat config with column dims + const flatWithColConfig = makeConfig({ + rowDimensionNames: ["country"], + colDimensionNames: ["region"], + measureNames: ["revenue"], + isFlat: true, + }); + const flatData: PivotDataRow[] = [{ country: "US", revenue: 100 }]; + + function setupFlatWithCol(cellRegion: string) { + vi.mocked(getFiltersFromRow).mockImplementation(() => + filter( + { name: "country", values: ["US"] }, + { name: "region", values: [cellRegion] }, + ), + ); + vi.mocked(getFiltersForColumnHeader).mockImplementation((_cfg, path) => + filter1("region", [path["region"]]), + ); + return setup(flatWithColConfig, flatData); + } + + it("column header evicts cells under it", () => { + const { result, filterClass } = setupFlatWithCol("NA"); + const dkUS = dimKeyFromRow(flatData[0], ["country"]); + + result.handleCellClickToFilter("1", "revenue", false, flatData[0]); + expect(sel(result).isCellSelected(dkUS, "revenue")).toBe(true); + + filterClass.toggleDimensionValueSelections.mockClear(); + result.handleColumnHeaderClick({ region: "NA" }); + + expect(sel(result).isColumnHeaderSelected({ region: "NA" })).toBe(true); + expect(sel(result).isCellSelected(dkUS, "revenue")).toBe(false); + expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( + "country", + ["US"], + false, + false, + ); + + result.destroy(); + }); + + it("cell click evicts ancestor column header", () => { + const { result } = setupFlatWithCol("NA"); + const dkUS = dimKeyFromRow(flatData[0], ["country"]); + + result.handleColumnHeaderClick({ region: "NA" }); + expect(sel(result).isColumnHeaderSelected({ region: "NA" })).toBe(true); + + result.handleCellClickToFilter("1", "revenue", false, flatData[0]); + + expect(sel(result).isCellSelected(dkUS, "revenue")).toBe(true); + expect(sel(result).isColumnHeaderSelected({ region: "NA" })).toBe(false); + + result.destroy(); + }); + + it("column header + cell in different column coexist", () => { + const { result } = setupFlatWithCol("EU"); // cell is in EU column + const dkUS = dimKeyFromRow(flatData[0], ["country"]); + + result.handleColumnHeaderClick({ region: "NA" }); // header is NA + result.handleCellClickToFilter("1", "revenue", false, flatData[0]); + + expect(sel(result).isColumnHeaderSelected({ region: "NA" })).toBe(true); + expect(sel(result).isCellSelected(dkUS, "revenue")).toBe(true); + + result.destroy(); + }); +}); diff --git a/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.ts b/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.ts new file mode 100644 index 00000000000..16d7ababd10 --- /dev/null +++ b/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.ts @@ -0,0 +1,952 @@ +/** + * Factory that creates all click-to-filter orchestration logic for a + * canvas pivot component. + * + * Selections are keyed by dimension values (dimKey) rather than + * positional TanStack row indices, so they survive sorting and + * data refreshes. + * + * Returns readable stores for selection/row-highlight state, click + * handlers for cells and column headers, and a destroy function for + * cleanup. + */ +import { + type PivotClickSelectionState, + type SelectionEntry, + buildClickSelection, + cellKey, + columnHeaderKey, + createEmptyClickSelectionState, + dimKeyFromDimValues, + dimKeyFromRow, +} from "@rilldata/web-common/features/dashboards/pivot/pivot-click-selection"; +import { + type ExtractedFilter, + type PivotRowSelectionState, + computePivotRowSelection, + extractDimensionFiltersFromExpression, + extractSelectionDimensionFilters, + getActiveDimensionNames, + getDimensionValuesForRow, + getFiltersForColumnHeader, + getFiltersForRowData, + getFiltersForRowHeader, +} from "@rilldata/web-common/features/dashboards/pivot/pivot-row-selection"; +import { + getFiltersForCell, + getFiltersFromRow, +} from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; +import type { + PivotDataRow, + PivotDataState, + PivotDataStore, + PivotDataStoreConfig, +} from "@rilldata/web-common/features/dashboards/pivot/types"; +import { + createAndExpression, + createInExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import type { V1Expression } from "@rilldata/web-common/runtime-client"; +import { + type Readable, + type Writable, + derived, + get, + writable, +} from "svelte/store"; +import type { FilterManager } from "../../stores/filter-manager"; + +interface PivotClickToFilterArgs { + pivotConfig: Readable; + pivotDataStore: PivotDataStore; + filterManager: FilterManager; + metricsViewName: string; + componentId: string; + activeComponent: Readable; + selfFilteredDimensions: Writable>; + whereFilterStore: Readable; + onBecomeActive?: () => void; + onBecomeInactive?: () => void; +} + +export interface PivotClickToFilterResult { + clickSelection: Readable; + rowSelectionState: Readable; + handleCellClickToFilter: ( + rowId: string, + columnId: string, + isRowHeader: boolean, + rowData: PivotDataRow, + ) => void; + handleColumnHeaderClick: (dimensionPath: Record) => void; + destroy: () => void; +} + +export function createPivotClickToFilter( + args: PivotClickToFilterArgs, +): PivotClickToFilterResult { + const { + pivotConfig, + pivotDataStore, + filterManager, + metricsViewName, + componentId, + activeComponent, + selfFilteredDimensions, + whereFilterStore, + onBecomeActive, + onBecomeInactive, + } = args; + + // --- Internal click selection state --- + const clickSelectionStore = writable( + createEmptyClickSelectionState(), + ); + + // Svelte store subscriptions fire synchronously on setup. This flag prevents + // onBecomeInactive from firing during the initial subscription cascade before + // the factory has been fully wired into the component tree. + let initialized = false; + + // --- Prune selfFilteredDimensions when filters are cleared externally --- + const pruneUnsub = whereFilterStore.subscribe(($whereFilter) => { + const activeDims = getActiveDimensionNames($whereFilter); + const dims = get(selfFilteredDimensions); + const pruned = new Set(); + let changed = false; + for (const dim of dims) { + if (activeDims.has(dim)) { + pruned.add(dim); + } else { + changed = true; + } + } + if (changed) { + selfFilteredDimensions.set(pruned); + } + }); + + // --- Clear click selection when no self-filtered dimensions remain --- + const clearUnsub = selfFilteredDimensions.subscribe(($selfFiltered) => { + if ($selfFiltered.size === 0) { + clickSelectionStore.set(createEmptyClickSelectionState()); + if (initialized) { + onBecomeInactive?.(); + } + } + }); + + // --- Yield active state when another component becomes active --- + const activeUnsub = activeComponent.subscribe(($activeId) => { + if ($activeId !== componentId) { + selfFilteredDimensions.set(new Set()); + } + }); + + initialized = true; + + // --- Derived row selection state --- + const dimensionFilterMap = derived( + [whereFilterStore, selfFilteredDimensions], + ([$whereFilter, $selfFiltered]) => + extractSelectionDimensionFilters($whereFilter, [...$selfFiltered]), + ); + + const rowSelectionState: Readable = + derived( + [pivotDataStore, pivotConfig, dimensionFilterMap], + ([$pivotData, $config, $dimFilterMap]) => { + if (!$pivotData?.data || !$config) return undefined; + return computePivotRowSelection( + $config, + $pivotData.data, + $dimFilterMap, + ); + }, + ); + + // --- Helpers --- + + /** Capture dimension name→value pairs from a PivotDataRow. */ + function captureDimValues( + rowData: PivotDataRow, + rowDimensionNames: string[], + ): Record { + const result: Record = {}; + for (const dim of rowDimensionNames) { + const val = rowData[dim]; + if (val === null || val === undefined) { + result[dim] = null; + } else if (typeof val === "string" || typeof val === "number") { + result[dim] = String(val); + } + } + return result; + } + + /** Determine the nesting level of a column header from its serialized key. */ + function getColumnHeaderLevel(headerKey: string): number { + const entries = JSON.parse(headerKey) as [string, string][]; + return entries.length; + } + + /** + * Returns the level of currently selected column headers, or -1 if none. + * All selected headers are at the same level due to level-switch enforcement. + */ + function getCurrentColumnHeaderLevel(colHeaders: Set): number { + const first = colHeaders.values().next().value as string | undefined; + return first !== undefined ? getColumnHeaderLevel(first) : -1; + } + + // --- Retained value computation for safe deselect --- + + function collectRetainedDimensionValues( + remainingRowHeaders: Map, + remainingCells: Map, + remainingColHeaders: Set, + ): Map> { + const retainedValues = new Map>(); + + const addRetainedValue = (dimensionName: string, value: string | null) => { + let valueSet = retainedValues.get(dimensionName); + if (!valueSet) { + valueSet = new Set(); + retainedValues.set(dimensionName, valueSet); + } + valueSet.add(value); + }; + + // Values from remaining row header selections (stored dimValues) + for (const entry of remainingRowHeaders.values()) { + for (const [dim, val] of Object.entries(entry.dimValues)) { + addRetainedValue(dim, val); + } + } + + // Values from remaining data cell selections (stored dimValues) + for (const entry of remainingCells.values()) { + for (const [dim, val] of Object.entries(entry.dimValues)) { + addRetainedValue(dim, val); + } + } + + // Values from remaining column header selections + for (const headerKey of remainingColHeaders) { + try { + const entries: [string, string][] = JSON.parse(headerKey); + for (const [dimensionName, value] of entries) { + addRetainedValue(dimensionName, value); + } + } catch { + // Malformed key; skip + } + } + + return retainedValues; + } + + /** + * Shared skeleton for all filter updates: clones selection state, applies + * removals and additions to the FilterManager, updates stores, and syncs URL. + */ + function applyFilterUpdate(opts: { + removals: ExtractedFilter[]; + additions: ExtractedFilter[]; + updateSelectionSets: ( + rowHeaders: Map, + cells: Map, + colHeaders: Set, + ) => void; + }) { + const { removals, additions, updateSelectionSets } = opts; + const allDimFilters = [...removals, ...additions]; + if (allDimFilters.length === 0) return; + + const preExistingDims = getActiveDimensionNames(get(whereFilterStore)); + const filterClass = filterManager.metricsViewFilters.get(metricsViewName); + if (!filterClass) return; + + // Clone and update selection sets + const $clickSelection = get(clickSelectionStore); + const updatedRowHeaders = new Map($clickSelection.rowHeaderSelections); + const updatedCells = new Map($clickSelection.cellSelections); + const updatedColHeaders = new Set($clickSelection.columnHeaderSelections); + updateSelectionSets(updatedRowHeaders, updatedCells, updatedColHeaders); + + // Clear temporary filter status for all affected dimensions + allDimFilters.forEach(({ dimensionName }) => { + filterManager.checkTemporaryFilter(dimensionName, [metricsViewName]); + }); + + let filterString: string | null = null; + + // Remove orphaned values + if (removals.length > 0) { + const retainedValues = collectRetainedDimensionValues( + updatedRowHeaders, + updatedCells, + updatedColHeaders, + ); + + for (const { dimensionName, values } of removals) { + const stillNeeded = retainedValues.get(dimensionName); + const orphanedValues = stillNeeded + ? values.filter((v) => !stillNeeded.has(v)) + : values; + if (orphanedValues.length > 0) { + filterString = filterClass.toggleDimensionValueSelections( + dimensionName, + orphanedValues, + false, + false, + ); + } + } + + // If no orphans were found (all values still retained by other + // selections), get the current filter string for URL sync. + if (filterString === null && additions.length === 0) { + for (const { dimensionName } of removals) { + filterString = filterClass.addDimensionValueSelections( + dimensionName, + [], + ); + } + } + } + + // Add new values + for (const { dimensionName, values } of additions) { + filterString = filterClass.addDimensionValueSelections( + dimensionName, + values, + ); + } + + clickSelectionStore.set( + buildClickSelection(updatedRowHeaders, updatedCells, updatedColHeaders), + ); + + // Mark only newly-added dimensions as self-filtered + const wasInactive = get(selfFilteredDimensions).size === 0; + selfFilteredDimensions.update((dims) => { + const next = new Set(dims); + allDimFilters.forEach(({ dimensionName }) => { + if (!preExistingDims.has(dimensionName)) { + next.add(dimensionName); + } + }); + return next; + }); + if (wasInactive && get(selfFilteredDimensions).size > 0) { + onBecomeActive?.(); + } + + if (filterString !== null) { + filterManager.applyFiltersToUrl( + new Map([[metricsViewName, filterString]]), + ); + } + } + + function applyDimensionFilters( + filters: V1Expression, + isDeselect: boolean, + updateSelectionSets: ( + rowHeaders: Map, + cells: Map, + colHeaders: Set, + ) => void, + ) { + const dimensionFilters = extractDimensionFiltersFromExpression(filters); + if (dimensionFilters.length === 0) return; + + applyFilterUpdate({ + removals: isDeselect ? dimensionFilters : [], + additions: isDeselect ? [] : dimensionFilters, + updateSelectionSets, + }); + } + + /** + * Atomically replaces old selections with new ones in a single URL update. + * Phase 1: removes orphaned filter values from old selections. + * Phase 2: adds new selection's filter values. + * Used by flat table single-cell-per-row and column header level switching. + */ + function applyReplacementFilters( + oldFilters: V1Expression, + newFilters: V1Expression, + updateSelectionSets: ( + rowHeaders: Map, + cells: Map, + colHeaders: Set, + ) => void, + ) { + const oldDimFilters = extractDimensionFiltersFromExpression(oldFilters); + const newDimFilters = extractDimensionFiltersFromExpression(newFilters); + if (oldDimFilters.length === 0 && newDimFilters.length === 0) return; + + applyFilterUpdate({ + removals: oldDimFilters, + additions: newDimFilters, + updateSelectionSets, + }); + } + + /** + * Builds a combined old-filters expression from all existing column header + * selections, then delegates to applyReplacementFilters. + */ + function applyColumnHeaderLevelSwitch( + oldColumnHeaders: Set, + newDimensionPath: Record, + newFilters: V1Expression, + ) { + // Aggregate values per dimension across all old column header selections. + // Deduping matters: without it, a dimension value shared across multiple + // old headers (e.g. Y=Y1 in both {X:X1,Y:Y1} and {X:X2,Y:Y1}) emits + // duplicate IN exprs, which later cause toggleDimensionValueSelections to + // re-toggle (remove then re-add) the shared value. + const valuesByDim = new Map>(); + for (const oldKey of oldColumnHeaders) { + const entries = JSON.parse(oldKey) as [string, string][]; + for (const [dim, value] of entries) { + let set = valuesByDim.get(dim); + if (!set) { + set = new Set(); + valuesByDim.set(dim, set); + } + set.add(value); + } + } + const oldExprs: V1Expression[] = [...valuesByDim.entries()].map( + ([dim, values]) => createInExpression(dim, [...values]), + ); + + const oldFilters = createAndExpression(oldExprs); + const newKey = columnHeaderKey(newDimensionPath); + + applyReplacementFilters( + oldFilters, + newFilters, + (_nextRowHeaders, _nextCells, nextColHeaders) => { + nextColHeaders.clear(); + nextColHeaders.add(newKey); + }, + ); + } + + // --- Lineage-aware mutual exclusivity helpers --- + + /** + * Check whether every (k, v) in `subset` is present in `superset`. Equal + * records also satisfy this; callers needing strictness compare key counts. + */ + function isDimSubset( + subset: Record, + superset: Record, + ): boolean { + return Object.entries(subset).every( + ([dim, val]) => dim in superset && superset[dim] === val, + ); + } + + /** + * Project a dimValues record down to row dimensions only. Row-lineage is + * determined entirely by row dimensions; column dimensions describe which + * intersection was clicked, not the lineage. Row-header entries already + * carry row-dim-only dimValues, so this is a no-op for them. + */ + function rowDimsOnly( + dimValues: Record, + rowDimensionNames: string[], + ): Record { + const result: Record = {}; + for (const dim of rowDimensionNames) { + if (dim in dimValues) result[dim] = dimValues[dim]; + } + return result; + } + + /** + * Find selection entries whose row dimValues are a descendant + * (`direction: "descendants"`) or strict ancestor (`direction: "ancestors"`) + * of the clicked element's row dimValues. + * + * Ancestors are always strict (equal row dims excluded). Descendants are + * strict by default; pass `includeSelf` to also evict same-row entries + * (used by row-header clicks, which take over the row's filter scope). + * + * Works uniformly for cell selections and row-header selections because + * both store dimValues that are row-dim-only after projection. + */ + function findEntriesInRowLineage( + entries: Map, + clickedRowDims: Record, + rowDimensionNames: string[], + direction: "ancestors" | "descendants", + includeSelf = false, + ): string[] { + const clickedSize = Object.keys(clickedRowDims).length; + const keys: string[] = []; + for (const [key, entry] of entries) { + const entryRowDims = rowDimsOnly(entry.dimValues, rowDimensionNames); + const entrySize = Object.keys(entryRowDims).length; + if (direction === "descendants") { + if (entrySize < clickedSize) continue; + if (entrySize === clickedSize && !includeSelf) continue; + if (isDimSubset(clickedRowDims, entryRowDims)) keys.push(key); + } else { + if (entrySize >= clickedSize) continue; + if (isDimSubset(entryRowDims, clickedRowDims)) keys.push(key); + } + } + return keys; + } + + /** + * Find column header keys whose dimension path values are all present + * in the cell's dimValues (i.e., column headers "above" the cell). + */ + function findColHeadersAboveCell( + selection: PivotClickSelectionState, + cellDimValues: Record, + ): string[] { + const keys: string[] = []; + for (const colKey of selection.columnHeaderSelections) { + const entries = JSON.parse(colKey) as [string, string][]; + const allMatch = entries.every( + ([name, value]) => + name in cellDimValues && cellDimValues[name] === value, + ); + if (allMatch) { + keys.push(colKey); + } + } + return keys; + } + + /** + * Find cell selection keys whose dimValues contain all of the column header's + * dimension path values (i.e., cells "under" the column header). + */ + function findCellsUnderColHeader( + selection: PivotClickSelectionState, + dimensionPath: Record, + ): string[] { + const keys: string[] = []; + for (const [key, entry] of selection.cellSelections) { + const allMatch = Object.entries(dimensionPath).every( + ([name, value]) => + name in entry.dimValues && entry.dimValues[name] === value, + ); + if (allMatch) { + keys.push(key); + } + } + return keys; + } + + /** + * Collect dimension values from a set of selection entries as removals. + */ + function collectRemovalsFromEntries( + entries: SelectionEntry[], + ): Map> { + const byDim = new Map>(); + for (const entry of entries) { + for (const [name, value] of Object.entries(entry.dimValues)) { + let set = byDim.get(name); + if (!set) { + set = new Set(); + byDim.set(name, set); + } + set.add(value); + } + } + return byDim; + } + + // --- Click handlers --- + + /** + * Flat table single-cell-per-row: if the same row already has a selected + * dimension cell, atomically replace it with the new click. Returns true + * if the replacement was handled, false to fall through to normal toggle. + */ + function tryFlatTableCellReplacement( + config: PivotDataStoreConfig, + selection: PivotClickSelectionState, + data: PivotDataState, + dk: string, + columnId: string, + rowData: PivotDataRow, + newFilters: V1Expression, + storedDimValues: Record, + upToDimensionIndex: number | undefined, + ): boolean { + // Find existing cell keys for this dimKey (same row) + const existingEntries: [string, SelectionEntry][] = []; + for (const [key, entry] of selection.cellSelections) { + if (entry.dimKey === dk) { + existingEntries.push([key, entry]); + } + } + if (existingEntries.length === 0) return false; + + // Compute old cell's filters so we can remove its orphaned values + const [, oldEntry] = existingEntries[0]; + const oldDimIdx = config.rowDimensionNames.indexOf(oldEntry.columnId); + const oldUpToDimIdx = oldDimIdx >= 0 ? oldDimIdx : undefined; + const oldCellFilters = getFiltersFromRow( + config, + rowData, + oldEntry.columnId, + data.columnDimensionAxes ?? {}, + oldUpToDimIdx, + ); + + if (!oldCellFilters.filters) return false; + + applyReplacementFilters( + oldCellFilters.filters, + newFilters, + (_nextRowHeaders, nextCells) => { + for (const [key] of existingEntries) { + nextCells.delete(key); + } + const newKey = cellKey(dk, columnId); + nextCells.set(newKey, { + dimKey: dk, + dimValues: storedDimValues, + columnId, + dimClickIndex: upToDimensionIndex, + }); + }, + ); + return true; + } + + function handleCellClickToFilter( + rowId: string, + columnId: string, + isRowHeader: boolean, + rowData: PivotDataRow, + ) { + const $config = get(pivotConfig); + const $data = get(pivotDataStore); + if (!$config || !$data?.data) return; + + const $clickSelection = get(clickSelectionStore); + + // In nested mode, row data stores all values under rowDimensions[0], + // so we must use positional rowId navigation to get correct dim→value pairs. + const isNested = !$config.isFlat; + const dimValues = isNested + ? Object.fromEntries( + getDimensionValuesForRow($config, rowId, $data.data).map( + ({ dimensionName, value }) => [dimensionName, value], + ), + ) + : captureDimValues(rowData, $config.rowDimensionNames); + + // For nested child rows (depth > 0), build dimKey from the fully-resolved + // dimValues (which include parent dimension values); dimKeyFromRow only + // sees rowDimensions[0] and would produce identical keys across parents. + const isNestedChild = isNested && rowId.includes("."); + const dk = isNestedChild + ? dimKeyFromDimValues(dimValues, $config.rowDimensionNames) + : dimKeyFromRow(rowData, $config.rowDimensionNames); + + // Determine if this click is deselecting a previously selected element + const isDeselect = isRowHeader + ? $clickSelection.isRowHeaderSelected(dk) + : $clickSelection.isCellSelected(dk, columnId); + + // For flat-table dimension cell clicks, only filter up to (and including) the + // clicked column's dimension index, not all row dimensions. + const flatDimIdx = + !isRowHeader && $config.isFlat + ? $config.rowDimensionNames.indexOf(columnId) + : -1; + const upToDimensionIndex = flatDimIdx >= 0 ? flatDimIdx : undefined; + + const cellFilters = isRowHeader + ? isNested + ? getFiltersForRowHeader($config, rowId, $data.data) + : getFiltersForRowData($config, rowData) + : isNested + ? getFiltersForCell( + $config, + rowId, + columnId, + $data.columnDimensionAxes ?? {}, + $data.data, + ) + : getFiltersFromRow( + $config, + rowData, + columnId, + $data.columnDimensionAxes ?? {}, + upToDimensionIndex, + ); + + if (!cellFilters.filters) return; + + // Build the dimValues to store; for partial dimension clicks, only keep + // dimensions up to the clicked index + const storedDimValues: Record = + upToDimensionIndex !== undefined + ? Object.fromEntries( + Object.entries(dimValues).filter(([dim]) => { + const idx = $config.rowDimensionNames.indexOf(dim); + return idx >= 0 && idx <= upToDimensionIndex; + }), + ) + : { ...dimValues }; + + // For data cell clicks (not row headers), also capture column dimension + // values so collectRetainedDimensionValues can detect shared column filters + // during deselect. Without this, deselecting one cell would orphan column + // filter values still needed by other cells in the same column. + if (!isRowHeader) { + const colDimNames = new Set($config.colDimensionNames); + const allDimFilters = extractDimensionFiltersFromExpression( + cellFilters.filters, + ); + for (const { dimensionName, values } of allDimFilters) { + if ( + colDimNames.has(dimensionName) && + !(dimensionName in storedDimValues) + ) { + storedDimValues[dimensionName] = values[0] ?? null; + } + } + } + + // Flat table: replace existing cell selection in the same row instead of + // accumulating. Nested tables allow multi-select within a row. + if ($config.isFlat && !isRowHeader && !isDeselect) { + const handled = tryFlatTableCellReplacement( + $config, + $clickSelection, + $data, + dk, + columnId, + rowData, + cellFilters.filters, + storedDimValues, + upToDimensionIndex, + ); + if (handled) return; + } + + if (isDeselect) { + // Deselect: simple toggle off + applyDimensionFilters( + cellFilters.filters, + true, + (nextRowHeaders, nextCells) => { + if (isRowHeader) { + nextRowHeaders.delete(dk); + } else { + nextCells.delete(cellKey(dk, columnId)); + } + }, + ); + return; + } + + // Mutual exclusivity: headers and cells in the same row lineage cannot + // coexist. When adding a new selection, evict every entry whose row dims + // are a strict ancestor or descendant of the click — across both row + // headers and cells. Same-row siblings (equal row dims) coexist. + const clickedRowDims = isRowHeader + ? dimValues + : rowDimsOnly(storedDimValues, $config.rowDimensionNames); + + // Cell-on-cell row lineage eviction is nested-only: flat tables handle + // same-row replacement via tryFlatTableCellReplacement, and cross-row + // flat cells don't form a parent/child lineage. + const evictCells = isRowHeader || isNested; + + // Row-header clicks also evict same-row cells (header takes over row's + // filter scope). Cell-on-cell eviction is strict so same-row sibling + // cells in different columns coexist. + const cellKeysToEvict = evictCells + ? [ + ...findEntriesInRowLineage( + $clickSelection.cellSelections, + clickedRowDims, + $config.rowDimensionNames, + "descendants", + isRowHeader, + ), + ...(isRowHeader + ? [] + : findEntriesInRowLineage( + $clickSelection.cellSelections, + clickedRowDims, + $config.rowDimensionNames, + "ancestors", + )), + ] + : []; + const rowHeaderKeysToEvict = [ + ...findEntriesInRowLineage( + $clickSelection.rowHeaderSelections, + clickedRowDims, + $config.rowDimensionNames, + "descendants", + ), + ...findEntriesInRowLineage( + $clickSelection.rowHeaderSelections, + clickedRowDims, + $config.rowDimensionNames, + "ancestors", + ), + ]; + const colHeaderKeysToEvict = isRowHeader + ? [] + : findColHeadersAboveCell($clickSelection, storedDimValues); + + const evictedCellEntries = cellKeysToEvict + .map((k) => $clickSelection.cellSelections.get(k)) + .filter(Boolean) as SelectionEntry[]; + const evictedHeaderEntries = rowHeaderKeysToEvict + .map((k) => $clickSelection.rowHeaderSelections.get(k)) + .filter(Boolean) as SelectionEntry[]; + const evictedDimValues = collectRemovalsFromEntries([ + ...evictedCellEntries, + ...evictedHeaderEntries, + ]); + + // Also collect column header dimension values for removal + for (const colKey of colHeaderKeysToEvict) { + const entries = JSON.parse(colKey) as [string, string][]; + for (const [name, value] of entries) { + let set = evictedDimValues.get(name); + if (!set) { + set = new Set(); + evictedDimValues.set(name, set); + } + set.add(value); + } + } + + const removals: ExtractedFilter[] = [...evictedDimValues.entries()].map( + ([dimensionName, values]) => ({ + dimensionName, + values: [...values], + }), + ); + + const additions = extractDimensionFiltersFromExpression( + cellFilters.filters, + ); + + applyFilterUpdate({ + removals, + additions, + updateSelectionSets: (nextRowHeaders, nextCells, nextColHeaders) => { + for (const k of cellKeysToEvict) nextCells.delete(k); + for (const k of rowHeaderKeysToEvict) nextRowHeaders.delete(k); + for (const k of colHeaderKeysToEvict) nextColHeaders.delete(k); + if (isRowHeader) { + nextRowHeaders.set(dk, { dimKey: dk, dimValues, columnId }); + } else { + nextCells.set(cellKey(dk, columnId), { + dimKey: dk, + dimValues: storedDimValues, + columnId, + dimClickIndex: upToDimensionIndex, + }); + } + }, + }); + } + + function handleColumnHeaderClick(dimensionPath: Record) { + const $config = get(pivotConfig); + if (!$config) return; + + const $clickSelection = get(clickSelectionStore); + const isDeselect = $clickSelection.isColumnHeaderSelected(dimensionPath); + + const colFilters = getFiltersForColumnHeader($config, dimensionPath); + if (!colFilters.filters) return; + + // Enforce single-level constraint: if clicking a header at a different + // level than existing selections, replace all old selections atomically. + const clickLevel = Object.keys(dimensionPath).length; + const currentLevel = getCurrentColumnHeaderLevel( + $clickSelection.columnHeaderSelections, + ); + + if (!isDeselect && currentLevel !== -1 && clickLevel !== currentLevel) { + applyColumnHeaderLevelSwitch( + $clickSelection.columnHeaderSelections, + dimensionPath, + colFilters.filters, + ); + return; + } + + if (isDeselect) { + applyDimensionFilters( + colFilters.filters, + true, + (_nextRowHeaders, _nextCells, nextColHeaders) => { + nextColHeaders.delete(columnHeaderKey(dimensionPath)); + }, + ); + return; + } + + // Mutual exclusivity: evict cells under this column header + const cellKeysToEvict = findCellsUnderColHeader( + $clickSelection, + dimensionPath, + ); + const evictedEntries = cellKeysToEvict + .map((k) => $clickSelection.cellSelections.get(k)) + .filter(Boolean) as SelectionEntry[]; + const evictedDimValues = collectRemovalsFromEntries(evictedEntries); + + const removals: ExtractedFilter[] = [...evictedDimValues.entries()].map( + ([dimensionName, values]) => ({ + dimensionName, + values: [...values], + }), + ); + + const additions = extractDimensionFiltersFromExpression(colFilters.filters); + + applyFilterUpdate({ + removals, + additions, + updateSelectionSets: (_nextRowHeaders, nextCells, nextColHeaders) => { + for (const k of cellKeysToEvict) nextCells.delete(k); + nextColHeaders.add(columnHeaderKey(dimensionPath)); + }, + }); + } + + // --- Cleanup --- + + function destroy() { + pruneUnsub(); + clearUnsub(); + activeUnsub(); + } + + return { + clickSelection: { subscribe: clickSelectionStore.subscribe }, + rowSelectionState, + handleCellClickToFilter, + handleColumnHeaderClick, + destroy, + }; +} diff --git a/web-common/src/features/canvas/components/pivot/util.ts b/web-common/src/features/canvas/components/pivot/util.ts index b42ef73a183..82d29a32593 100644 --- a/web-common/src/features/canvas/components/pivot/util.ts +++ b/web-common/src/features/canvas/components/pivot/util.ts @@ -18,15 +18,45 @@ import { import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import type { TimeAndFilterStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; -import type { - V1Expression, - V1MetricsViewSpec, - V1TimeRange, +import { + V1Operation, + type V1Expression, + type V1MetricsViewSpec, + type V1TimeRange, } from "@rilldata/web-common/runtime-client"; -import { type Readable, type Writable, derived, writable } from "svelte/store"; +import { + type Readable, + type Writable, + derived, + readable, + writable, +} from "svelte/store"; import type { CanvasEntity } from "../../stores/canvas-entity"; import type { PivotSpec, TableSpec } from "./"; +/** + * Strips filters for the pivot's own dimensions from the where filter. + * This lets the pivot query return all rows; selection highlighting + * (via rowSelectionState) shows which rows match the active filters. + * Same pattern as leaderboard's getFiltersForOtherDimensions but for + * multiple dimensions at once. + */ +function excludeOwnDimensionFilters( + where: V1Expression | undefined, + dimensionNames: string[], +): V1Expression | undefined { + if (!where?.cond?.exprs || dimensionNames.length === 0) return where; + const dimSet = new Set(dimensionNames); + // Only exclude IN expressions whose ident matches a self-filtered dimension. + // Other filter types (LIKE, NIN) are never produced by click-to-filter and + // must be preserved even if they share a dimension name. + const filtered = where.cond.exprs.filter((e) => { + if (e.cond?.op !== V1Operation.OPERATION_IN) return true; + return !dimSet.has(e.cond?.exprs?.[0]?.ident ?? ""); + }); + return createAndExpression(filtered); +} + type CacheEntry = { store: ReturnType; unsubscribe: () => void; @@ -62,21 +92,44 @@ export function createPivotConfig( tableSpecStore: Readable, pivotState: Writable, timeAndFilterStore: Readable, + selfFilteredDimensions?: Readable>, ): Readable { + const selfFilteredStore = selfFilteredDimensions ?? readable(null); + return derived( - [canvas.specStore, tableSpecStore, pivotState, timeAndFilterStore], - ([$canvasData, $tableSpec, $pivotState, $timeAndFilterStore]) => { + [ + canvas.specStore, + tableSpecStore, + pivotState, + timeAndFilterStore, + selfFilteredStore, + ], + ([ + $canvasData, + $tableSpec, + $pivotState, + $timeAndFilterStore, + $selfFiltered, + ]) => { const { timeRange, comparisonTimeRange, where } = $timeAndFilterStore; const metricsViewName = $tableSpec.metrics_view; const metricsView = $canvasData?.data?.metricsViews[metricsViewName]?.state?.validSpec ?? {}; + let queryWhere: V1Expression | undefined; + if (!$selfFiltered || $selfFiltered.size === 0) { + queryWhere = where; + } else { + // Only exclude dimensions the pivot itself applied via click-to-filter + queryWhere = excludeOwnDimensionFilters(where, [...$selfFiltered]); + } + return "columns" in $tableSpec ? processFlat( $tableSpec, $pivotState, - where, + queryWhere, metricsView, $timeAndFilterStore, comparisonTimeRange, @@ -86,7 +139,7 @@ export function createPivotConfig( : processPivot( $tableSpec, $pivotState, - where, + queryWhere, metricsView, $timeAndFilterStore, comparisonTimeRange, @@ -170,6 +223,7 @@ export function processPivot( ...state, rowPage: 1, })); + config.pivot.rowPage = 1; } } @@ -256,6 +310,7 @@ export function processFlat( ...state, rowPage: 1, })); + config.pivot.rowPage = 1; } } diff --git a/web-common/src/features/canvas/filters/CanvasFilters.svelte b/web-common/src/features/canvas/filters/CanvasFilters.svelte index 5ce8c537168..7b5ad849c75 100644 --- a/web-common/src/features/canvas/filters/CanvasFilters.svelte +++ b/web-common/src/features/canvas/filters/CanvasFilters.svelte @@ -2,6 +2,7 @@ import { Button } from "@rilldata/web-common/components/button"; import Calendar from "@rilldata/web-common/components/icons/Calendar.svelte"; import Filter from "@rilldata/web-common/components/icons/Filter.svelte"; + import * as Tooltip from "@rilldata/web-common/components/tooltip-v2"; import { getCanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; import AdvancedFilter from "@rilldata/web-common/features/dashboards/filters/AdvancedFilter.svelte"; import DimensionFilter from "@rilldata/web-common/features/dashboards/filters/dimension-filters/DimensionFilter.svelte"; @@ -9,10 +10,9 @@ import { getPanRangeForTimeRange } from "@rilldata/web-common/features/dashboards/state-managers/selectors/charts"; import SuperPill from "@rilldata/web-common/features/dashboards/time-controls/super-pill/SuperPill.svelte"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; - import CanvasComparisonPill from "./CanvasComparisonPill.svelte"; import CanvasFilterButton from "../../dashboards/filters/CanvasFilterButton.svelte"; - import * as Tooltip from "@rilldata/web-common/components/tooltip-v2"; import Metadata from "../../dashboards/time-controls/super-pill/components/Metadata.svelte"; + import CanvasComparisonPill from "./CanvasComparisonPill.svelte"; export let readOnly = false; export let maxWidth: number; @@ -130,6 +130,7 @@