From 34438d18dc4c60a3e8e01d8fdb1c72cf19d879d5 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Mon, 1 Jun 2026 15:12:43 +0530 Subject: [PATCH 1/6] feat: tag support in pivot table sidebar Adds tag-aware bulk actions to the Explore pivot. The sidebar now has a dedicated Tags column when the metrics view defines tags on dimensions or measures, mirroring the Explore dropdown's two-column layout. Each tag row supports: - Click to filter the Measures and Dimensions sections to items in that tag - Drag the tag onto Rows / Columns to bulk-add (dims skipped from Rows) - Drag onto a contextual Auto-arrange zone (dims to Rows, measures to Columns) - CMD / Ctrl + Click or + Drop to replace the target zone instead of appending Replace paths also clean the opposite zone so a dimension never appears in both rows and columns at once, and append paths skip items already placed in either zone. Reuses the existing tag-utils module and hoists the tag index into a new shared state-manager selector consumed by both the Explore dropdown and the pivot sidebar. Extracts the CMD/Ctrl modifier store and the "Filtered by tag" banner into reusable pieces. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../menu/DashboardMetricsDraggableList.svelte | 28 +- .../components/menu/TagFilterBanner.svelte | 38 +++ .../leaderboard/LeaderboardControls.svelte | 2 + .../features/dashboards/pivot/AddField.svelte | 58 +++- .../features/dashboards/pivot/DragList.svelte | 58 +++- .../dashboards/pivot/PivotDisplay.svelte | 5 + .../dashboards/pivot/PivotHeader.svelte | 112 ++++++- .../dashboards/pivot/PivotSidebar.svelte | 293 +++++++++++++++++- .../dashboards/pivot/PivotTagRow.svelte | 215 +++++++++++++ .../dashboards/pivot/pivot-tag-utils.spec.ts | 135 ++++++++ .../features/dashboards/pivot/pivot-utils.ts | 47 +++ .../state-managers/selectors/index.ts | 8 + .../state-managers/selectors/tags.ts | 24 ++ .../dashboards/stores/dashboard-stores.ts | 70 ++++- .../MetricsTimeSeriesCharts.svelte | 2 + web-common/src/lib/modifier-key.ts | 23 ++ 16 files changed, 1075 insertions(+), 43 deletions(-) create mode 100644 web-common/src/components/menu/TagFilterBanner.svelte create mode 100644 web-common/src/features/dashboards/pivot/PivotTagRow.svelte create mode 100644 web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts create mode 100644 web-common/src/features/dashboards/state-managers/selectors/tags.ts create mode 100644 web-common/src/lib/modifier-key.ts diff --git a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte index cee6fafee227..5589d7da8206 100644 --- a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte +++ b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte @@ -1,7 +1,6 @@ + +
+ Filter: + + {tagName} + + + + + + + Clear filter + + +
diff --git a/web-common/src/features/dashboards/leaderboard/LeaderboardControls.svelte b/web-common/src/features/dashboards/leaderboard/LeaderboardControls.svelte index dd7e18ea7bbe..9702e04f1f74 100644 --- a/web-common/src/features/dashboards/leaderboard/LeaderboardControls.svelte +++ b/web-common/src/features/dashboards/leaderboard/LeaderboardControls.svelte @@ -15,6 +15,7 @@ measures: { getMeasureByName, visibleMeasures }, leaderboard: { leaderboardSortByMeasureName, leaderboardMeasureNames }, dimensions: { visibleDimensions, allDimensions }, + tags: { dimensionTagIndex }, }, actions: { contextColumn: { setContextColumn }, @@ -78,6 +79,7 @@ onSelectedChange={(items) => setDimensionVisibility(items, allDimensionNames)} allItems={$allDimensions} + tagIndex={$dimensionTagIndex} selectedItems={visibleDimensionsNames} /> - +
+ {#if hasTags} +
+

Tags

+ {#if filteredTags.length === 0} +

No matching tags

+ {:else} + {#each filteredTags as tag (tag.name)} + {@const items = tagItemsFor(tag.name)} + addTagToRows(tag.name, replace)} + onAddColumns={(replace) => addTagToColumns(tag.name, replace)} + onAutoArrange={(replace) => autoArrangeTag(tag.name, replace)} + onDragStart={(e, rect) => + handleTagDragStart(e, tag.name, items, rect)} + /> + {/each} + {/if} +
+ {/if} - +
+ {#if selectedTag} + + {/if} - + + + +
+
+{#if tagDragActive && tagDragChip} + +{/if} + diff --git a/web-common/src/features/dashboards/pivot/PivotTagRow.svelte b/web-common/src/features/dashboards/pivot/PivotTagRow.svelte new file mode 100644 index 000000000000..1459d93ab927 --- /dev/null +++ b/web-common/src/features/dashboards/pivot/PivotTagRow.svelte @@ -0,0 +1,215 @@ + + + + + diff --git a/web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts b/web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts new file mode 100644 index 000000000000..fa004325541e --- /dev/null +++ b/web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts @@ -0,0 +1,135 @@ +import { buildTagIndex } from "@rilldata/web-common/components/menu/tag-utils"; +import type { + MetricsViewSpecDimension, + MetricsViewSpecMeasure, +} from "@rilldata/web-common/runtime-client"; +import { describe, expect, it } from "vitest"; +import { + dimensionToChipData, + measureToChipData, + splitTagItems, +} from "./pivot-utils"; +import { PivotChipType } from "./types"; + +const dim = ( + name: string, + tags: string[] = [], + displayName?: string, +): MetricsViewSpecDimension => ({ + name, + displayName, + tags, +}); + +const meas = ( + name: string, + tags: string[] = [], + displayName?: string, +): MetricsViewSpecMeasure => ({ + name, + displayName, + tags, +}); + +describe("dimensionToChipData", () => { + it("uses displayName when present", () => { + expect(dimensionToChipData(dim("country", [], "Country"))).toEqual({ + id: "country", + title: "Country", + type: PivotChipType.Dimension, + description: undefined, + }); + }); + + it("falls back to name when displayName is missing", () => { + expect(dimensionToChipData(dim("region"))).toEqual({ + id: "region", + title: "region", + type: PivotChipType.Dimension, + description: undefined, + }); + }); +}); + +describe("measureToChipData", () => { + it("projects a measure spec", () => { + expect(measureToChipData(meas("revenue", [], "Revenue"))).toEqual({ + id: "revenue", + title: "Revenue", + type: PivotChipType.Measure, + description: undefined, + }); + }); +}); + +describe("splitTagItems", () => { + const dimensions = [ + dim("country", ["Geography", "Customer"], "Country"), + dim("region", ["Geography"], "Region"), + dim("segment", ["Customer"], "Segment"), + ]; + const measures = [ + meas("revenue", ["Geography", "Finance"], "Revenue"), + meas("profit", ["Finance"], "Profit"), + ]; + const dimIndex = buildTagIndex(dimensions); + const measIndex = buildTagIndex(measures); + + it("splits a mixed tag into dim chips and measure chips", () => { + const { dimensions: dims, measures: meas } = splitTagItems( + "Geography", + dimIndex, + measIndex, + ); + expect(dims.map((c) => c.id)).toEqual(["country", "region"]); + expect(meas.map((c) => c.id)).toEqual(["revenue"]); + expect(dims.every((c) => c.type === PivotChipType.Dimension)).toBe(true); + expect(meas.every((c) => c.type === PivotChipType.Measure)).toBe(true); + }); + + it("returns only dimensions for a pure-dimension tag", () => { + const { dimensions: dims, measures: meas } = splitTagItems( + "Customer", + dimIndex, + measIndex, + ); + expect(dims.map((c) => c.id)).toEqual(["country", "segment"]); + expect(meas).toEqual([]); + }); + + it("returns only measures for a pure-measure tag", () => { + const { dimensions: dims, measures: meas } = splitTagItems( + "Finance", + dimIndex, + measIndex, + ); + expect(dims).toEqual([]); + expect(meas.map((c) => c.id)).toEqual(["revenue", "profit"]); + }); + + it("returns empty arrays for an unknown tag", () => { + expect(splitTagItems("Nope", dimIndex, measIndex)).toEqual({ + dimensions: [], + measures: [], + }); + }); + + it("preserves spec order in output", () => { + const orderedDims = [ + dim("a", ["T"]), + dim("b", ["T"]), + dim("c", ["T"]), + ]; + const orderedMeas = [ + meas("x", ["T"]), + meas("y", ["T"]), + ]; + const { dimensions: dims, measures: meas2 } = splitTagItems( + "T", + buildTagIndex(orderedDims), + buildTagIndex(orderedMeas), + ); + expect(dims.map((c) => c.id)).toEqual(["a", "b", "c"]); + expect(meas2.map((c) => c.id)).toEqual(["x", "y"]); + }); +}); diff --git a/web-common/src/features/dashboards/pivot/pivot-utils.ts b/web-common/src/features/dashboards/pivot/pivot-utils.ts index 821afec7b8a1..eef7b35a3848 100644 --- a/web-common/src/features/dashboards/pivot/pivot-utils.ts +++ b/web-common/src/features/dashboards/pivot/pivot-utils.ts @@ -1,3 +1,4 @@ +import { itemsInTag, type TagIndex } from "@rilldata/web-common/components/menu/tag-utils"; import { getValuesForExpandedKey } from "@rilldata/web-common/features/dashboards/pivot/pivot-expansion"; import { createAndExpression, @@ -12,6 +13,8 @@ import { type TimeRangeString, } from "@rilldata/web-common/lib/time/types"; import type { + MetricsViewSpecDimension, + MetricsViewSpecMeasure, V1Expression, V1MetricsViewAggregationMeasure, V1MetricsViewAggregationResponse, @@ -746,3 +749,47 @@ export function isShowMoreRow(row: Row): boolean { const firstCell = row?.getVisibleCells()?.[0]; return firstCell?.getValue() === SHOW_MORE_BUTTON; } + +/** + * Project a dimension or measure spec into the PivotChipData shape used by + * the sidebar and bulk-add paths. Mirrors the projection in + * state-managers/selectors/pivot.ts so chips look identical regardless of + * whether they came from drag-and-drop or a tag bulk-add. + */ +export function dimensionToChipData( + d: MetricsViewSpecDimension, +): PivotChipData { + return { + id: d.name || d.column || "Unknown", + title: d.displayName || d.name || d.column || "Unknown", + type: PivotChipType.Dimension, + description: d.description, + }; +} + +export function measureToChipData(m: MetricsViewSpecMeasure): PivotChipData { + return { + id: m.name || "Unknown", + title: m.displayName || m.name || "Unknown", + type: PivotChipType.Measure, + description: m.description, + }; +} + +/** + * Split the items in a tag into dimension chips and measure chips, preserving + * spec order. Used by the pivot sidebar's bulk-add actions: "Add all to rows" + * consumes the dimensions output, "Add all to columns" concatenates both, and + * "Auto-arrange" routes each list to its natural zone. + */ +export function splitTagItems( + tagName: string, + dimensionTagIndex: TagIndex, + measureTagIndex: TagIndex, +): { dimensions: PivotChipData[]; measures: PivotChipData[] } { + const dimensions = itemsInTag(dimensionTagIndex, tagName) + .map((d) => dimensionToChipData(d as MetricsViewSpecDimension)); + const measures = itemsInTag(measureTagIndex, tagName) + .map((m) => measureToChipData(m as MetricsViewSpecMeasure)); + return { dimensions, measures }; +} diff --git a/web-common/src/features/dashboards/state-managers/selectors/index.ts b/web-common/src/features/dashboards/state-managers/selectors/index.ts index 719bb68492d3..0465bed8b579 100644 --- a/web-common/src/features/dashboards/state-managers/selectors/index.ts +++ b/web-common/src/features/dashboards/state-managers/selectors/index.ts @@ -15,6 +15,7 @@ import { dimensionSelectors } from "./dimensions"; import { measureSelectors } from "./measures"; import { pivotSelectors } from "./pivot"; import { sortingSelectors } from "./sorting"; +import { tagSelectors } from "./tags"; import { timeRangeSelectors } from "./time-range"; import type { ReadablesObj, SelectorFnsObj } from "./types"; import { leaderboardSelectors } from "./leaderboard"; @@ -139,6 +140,13 @@ export const createStateManagerReadables = ( */ pivot: createReadablesFromSelectors(pivotSelectors, dashboardDataReadables), + /** + * Readables exposing shared tag indices over dimensions, measures, and + * their union. Consumed by tag-aware surfaces (dimension/measure dropdown, + * pivot sidebar) so the index is built once per spec change. + */ + tags: createReadablesFromSelectors(tagSelectors, dashboardDataReadables), + /** * Readables related to the chart interactions state */ diff --git a/web-common/src/features/dashboards/state-managers/selectors/tags.ts b/web-common/src/features/dashboards/state-managers/selectors/tags.ts new file mode 100644 index 000000000000..5e4842765389 --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/selectors/tags.ts @@ -0,0 +1,24 @@ +import { + buildTagIndex, + type TagIndex, +} from "@rilldata/web-common/components/menu/tag-utils"; +import { allDimensions } from "./dimensions"; +import { allMeasures } from "./measures"; +import type { DashboardDataSources } from "./types"; + +export const dimensionTagIndex = (dashData: DashboardDataSources): TagIndex => + buildTagIndex(allDimensions(dashData)); + +export const measureTagIndex = (dashData: DashboardDataSources): TagIndex => + buildTagIndex(allMeasures(dashData)); + +// Combined index over dimensions + measures, used by surfaces (e.g. the pivot +// sidebar) where both kinds participate in a single tag-filtered list. +export const combinedTagIndex = (dashData: DashboardDataSources): TagIndex => + buildTagIndex([...allDimensions(dashData), ...allMeasures(dashData)]); + +export const tagSelectors = { + dimensionTagIndex, + measureTagIndex, + combinedTagIndex, +}; diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index b8fb1e27328a..43944cd81293 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -303,17 +303,79 @@ const metricsViewReducers = { }, addPivotField(name: string, value: PivotChipData, rows: boolean) { + metricsExplorerStore.addPivotFields( + name, + [value], + value.type === PivotChipType.Measure ? "columns" : rows ? "rows" : "columns", + ); + }, + + // Replace the rows zone with the given values. Anything in the new rows + // that already lived in columns is removed from columns, so the same + // dimension never appears in both zones. + replacePivotRows(name: string, values: PivotChipData[]) { updateMetricsExplorerByName(name, (exploreState) => { exploreState.pivot.rowPage = 1; exploreState.pivot.activeCell = null; exploreState.pivot.expanded = {}; + const ids = new Set(values.map((v) => v.id)); + exploreState.pivot.rows = values.filter( + (v) => v.type !== PivotChipType.Measure, + ); + exploreState.pivot.columns = exploreState.pivot.columns.filter( + (c) => !ids.has(c.id), + ); + }); + }, - if (value.type === PivotChipType.Measure) { - exploreState.pivot.columns.push(value); - } else { - if (rows) { + // Replace the columns zone with the given values, removing any of them + // from rows so a dimension never appears in both zones. + replacePivotColumns(name: string, values: PivotChipData[]) { + updateMetricsExplorerByName(name, (exploreState) => { + exploreState.pivot.rowPage = 1; + exploreState.pivot.activeCell = null; + exploreState.pivot.expanded = {}; + const ids = new Set(values.map((v) => v.id)); + exploreState.pivot.columns = values; + exploreState.pivot.rows = exploreState.pivot.rows.filter( + (r) => !ids.has(r.id), + ); + }); + }, + + // Bulk-add fields to either the rows or columns zone in a single + // store transaction. Measures targeted at "rows" are silently skipped + // because the rows zone does not accept measures. Items already present + // in either zone are skipped — a pivot dimension only makes sense in one + // axis at a time, and tag bulk-adds shouldn't create duplicates across + // rows and columns. + addPivotFields( + name: string, + values: PivotChipData[], + target: "rows" | "columns", + ) { + updateMetricsExplorerByName(name, (exploreState) => { + exploreState.pivot.rowPage = 1; + exploreState.pivot.activeCell = null; + exploreState.pivot.expanded = {}; + + const existingRows = new Set( + exploreState.pivot.rows.map((c) => c.id), + ); + const existingCols = new Set( + exploreState.pivot.columns.map((c) => c.id), + ); + + for (const value of values) { + if (existingRows.has(value.id) || existingCols.has(value.id)) { + continue; + } + if (target === "rows") { + if (value.type === PivotChipType.Measure) continue; + existingRows.add(value.id); exploreState.pivot.rows.push(value); } else { + existingCols.add(value.id); exploreState.pivot.columns.push(value); } } diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 559305143cd6..ac71ac8140ab 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -62,6 +62,7 @@ measures: { allMeasures, visibleMeasures, getMeasureByName }, dimensionFilters: { includedDimensionValues }, charts: { canPanLeft, canPanRight, getNewPanRange }, + tags: { measureTagIndex }, }, actions: { measures: { setMeasureVisibility }, @@ -283,6 +284,7 @@ onSelectedChange={(items) => setMeasureVisibility(items, allMeasureNames)} allItems={$allMeasures} + tagIndex={$measureTagIndex} selectedItems={visibleMeasureNames} /> diff --git a/web-common/src/lib/modifier-key.ts b/web-common/src/lib/modifier-key.ts new file mode 100644 index 000000000000..1a5318fbab6a --- /dev/null +++ b/web-common/src/lib/modifier-key.ts @@ -0,0 +1,23 @@ +import { derived, writable, type Readable } from "svelte/store"; + +// Tracks whether the platform's "modify" key (CMD on macOS, Ctrl elsewhere) +// is currently held. Subscribe via `$modifierHeld` for reactive UI; read +// the value at the moment of action from the original MouseEvent's +// `metaKey || ctrlKey` so a fast click that arrives before the keydown +// listener fires still picks up the held key. +const _modifierHeld = writable(false); + +if (typeof window !== "undefined") { + window.addEventListener("keydown", (e) => { + if (e.metaKey || e.ctrlKey) _modifierHeld.set(true); + }); + window.addEventListener("keyup", (e) => { + if (!e.metaKey && !e.ctrlKey) _modifierHeld.set(false); + }); + window.addEventListener("blur", () => _modifierHeld.set(false)); +} + +export const modifierHeld: Readable = derived( + _modifierHeld, + ($v) => $v, +); From 493f6dcd5aff070c789f096400cb18612628a114 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Mon, 1 Jun 2026 15:19:19 +0530 Subject: [PATCH 2/6] chore: fix prettier formatting Co-Authored-By: Claude Opus 4.7 (1M context) --- .../menu/DashboardMetricsDraggableList.svelte | 5 +---- .../components/menu/TagFilterBanner.svelte | 4 +--- .../features/dashboards/pivot/DragList.svelte | 6 +----- .../dashboards/pivot/PivotHeader.svelte | 20 ++++--------------- .../dashboards/pivot/PivotSidebar.svelte | 1 - .../dashboards/pivot/PivotTagRow.svelte | 5 +---- .../dashboards/pivot/pivot-tag-utils.spec.ts | 11 ++-------- .../features/dashboards/pivot/pivot-utils.ts | 15 +++++++++----- .../dashboards/stores/dashboard-stores.ts | 14 ++++++------- 9 files changed, 27 insertions(+), 54 deletions(-) diff --git a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte index 5589d7da8206..6c623a0d3b01 100644 --- a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte +++ b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte @@ -197,10 +197,7 @@
{#if filterActive && selectedTag} - + {/if} {#key selectedTag} diff --git a/web-common/src/components/menu/TagFilterBanner.svelte b/web-common/src/components/menu/TagFilterBanner.svelte index 4d830834f368..f1b8bdeab21a 100644 --- a/web-common/src/components/menu/TagFilterBanner.svelte +++ b/web-common/src/components/menu/TagFilterBanner.svelte @@ -10,9 +10,7 @@ let { tagName, onClear }: Props = $props(); -
+
Filter: {tagName} diff --git a/web-common/src/features/dashboards/pivot/DragList.svelte b/web-common/src/features/dashboards/pivot/DragList.svelte index c2aeffcf7165..e1f399b7f0b8 100644 --- a/web-common/src/features/dashboards/pivot/DragList.svelte +++ b/web-common/src/features/dashboards/pivot/DragList.svelte @@ -182,11 +182,7 @@ if (replace) { metricsExplorerStore.replacePivotColumns($exploreName, all); } else if (all.length > 0) { - metricsExplorerStore.addPivotFields( - $exploreName, - all, - "columns", - ); + metricsExplorerStore.addPivotFields($exploreName, all, "columns"); } } } else if (dragChip && ghostIndex !== null) { diff --git a/web-common/src/features/dashboards/pivot/PivotHeader.svelte b/web-common/src/features/dashboards/pivot/PivotHeader.svelte index 2fe4129a05ab..d901fce71db8 100644 --- a/web-common/src/features/dashboards/pivot/PivotHeader.svelte +++ b/web-common/src/features/dashboards/pivot/PivotHeader.svelte @@ -43,18 +43,10 @@ metricsExplorerStore.replacePivotColumns($exploreName, measures); } else { if (dimensions.length > 0) { - metricsExplorerStore.addPivotFields( - $exploreName, - dimensions, - "rows", - ); + metricsExplorerStore.addPivotFields($exploreName, dimensions, "rows"); } if (measures.length > 0) { - metricsExplorerStore.addPivotFields( - $exploreName, - measures, - "columns", - ); + metricsExplorerStore.addPivotFields($exploreName, measures, "columns"); } } dragDataStore.set(null); @@ -96,10 +88,7 @@
{/if} {#if showAutoArrange && dragData?.tagPayload} -
+
Auto @@ -130,8 +119,7 @@ → rows, {dragData.tagPayload.measures.length} {dragData.tagPayload.measures.length === 1 ? "measure" : "measures"} - → columns - ( + Drop to replace) + → columns ( + Drop to replace) {/if}
diff --git a/web-common/src/features/dashboards/pivot/PivotSidebar.svelte b/web-common/src/features/dashboards/pivot/PivotSidebar.svelte index 1cadfcb27a75..2900a96e9006 100644 --- a/web-common/src/features/dashboards/pivot/PivotSidebar.svelte +++ b/web-common/src/features/dashboards/pivot/PivotSidebar.svelte @@ -362,5 +362,4 @@ @apply uppercase font-semibold text-[10px]; @apply px-1.5 pt-1 pb-1 text-fg-secondary; } - diff --git a/web-common/src/features/dashboards/pivot/PivotTagRow.svelte b/web-common/src/features/dashboards/pivot/PivotTagRow.svelte index 1459d93ab927..33084fbf3af5 100644 --- a/web-common/src/features/dashboards/pivot/PivotTagRow.svelte +++ b/web-common/src/features/dashboards/pivot/PivotTagRow.svelte @@ -46,10 +46,7 @@ onDragStart(e, rowEl.getBoundingClientRect()); } - function handleClick( - e: MouseEvent, - action: (replace: boolean) => void, - ) { + function handleClick(e: MouseEvent, action: (replace: boolean) => void) { // Read the modifier off the event itself so a click that happens before // the global keydown fires still picks up the held key. action(e.metaKey || e.ctrlKey); diff --git a/web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts b/web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts index fa004325541e..0e4c69aa6176 100644 --- a/web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts +++ b/web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts @@ -115,15 +115,8 @@ describe("splitTagItems", () => { }); it("preserves spec order in output", () => { - const orderedDims = [ - dim("a", ["T"]), - dim("b", ["T"]), - dim("c", ["T"]), - ]; - const orderedMeas = [ - meas("x", ["T"]), - meas("y", ["T"]), - ]; + const orderedDims = [dim("a", ["T"]), dim("b", ["T"]), dim("c", ["T"])]; + const orderedMeas = [meas("x", ["T"]), meas("y", ["T"])]; const { dimensions: dims, measures: meas2 } = splitTagItems( "T", buildTagIndex(orderedDims), diff --git a/web-common/src/features/dashboards/pivot/pivot-utils.ts b/web-common/src/features/dashboards/pivot/pivot-utils.ts index eef7b35a3848..2c6261cda70c 100644 --- a/web-common/src/features/dashboards/pivot/pivot-utils.ts +++ b/web-common/src/features/dashboards/pivot/pivot-utils.ts @@ -1,4 +1,7 @@ -import { itemsInTag, type TagIndex } from "@rilldata/web-common/components/menu/tag-utils"; +import { + itemsInTag, + type TagIndex, +} from "@rilldata/web-common/components/menu/tag-utils"; import { getValuesForExpandedKey } from "@rilldata/web-common/features/dashboards/pivot/pivot-expansion"; import { createAndExpression, @@ -787,9 +790,11 @@ export function splitTagItems( dimensionTagIndex: TagIndex, measureTagIndex: TagIndex, ): { dimensions: PivotChipData[]; measures: PivotChipData[] } { - const dimensions = itemsInTag(dimensionTagIndex, tagName) - .map((d) => dimensionToChipData(d as MetricsViewSpecDimension)); - const measures = itemsInTag(measureTagIndex, tagName) - .map((m) => measureToChipData(m as MetricsViewSpecMeasure)); + const dimensions = itemsInTag(dimensionTagIndex, tagName).map((d) => + dimensionToChipData(d as MetricsViewSpecDimension), + ); + const measures = itemsInTag(measureTagIndex, tagName).map((m) => + measureToChipData(m as MetricsViewSpecMeasure), + ); return { dimensions, measures }; } diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index 43944cd81293..5a35539bcf86 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -306,7 +306,11 @@ const metricsViewReducers = { metricsExplorerStore.addPivotFields( name, [value], - value.type === PivotChipType.Measure ? "columns" : rows ? "rows" : "columns", + value.type === PivotChipType.Measure + ? "columns" + : rows + ? "rows" + : "columns", ); }, @@ -359,12 +363,8 @@ const metricsViewReducers = { exploreState.pivot.activeCell = null; exploreState.pivot.expanded = {}; - const existingRows = new Set( - exploreState.pivot.rows.map((c) => c.id), - ); - const existingCols = new Set( - exploreState.pivot.columns.map((c) => c.id), - ); + const existingRows = new Set(exploreState.pivot.rows.map((c) => c.id)); + const existingCols = new Set(exploreState.pivot.columns.map((c) => c.id)); for (const value of values) { if (existingRows.has(value.id) || existingCols.has(value.id)) { From ee01b8690bcbc959c3f92538429fe88054a232bd Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Tue, 2 Jun 2026 16:45:20 +0530 Subject: [PATCH 3/6] refactor: address review on pivot tag bulk-add - Keep dashboard-stores.ts light: remove addPivotFields, replacePivotRows, replacePivotColumns. Restore addPivotField to its original form. Callers compute the new row/column arrays and call the existing setPivotRows / setPivotColumns instead. - Add pure helpers in pivot-utils.ts (appendChipsToZone, replaceZoneCleaningOther) for cross-zone-aware bulk updates. - Move drag setup, portal rendering, and bulk-update logic into PivotTagRow so the sidebar just wires props. - PivotSidebar takes setRows / setColumns instead of importing metricsExplorerStore. PivotDisplay wires the setters from the store. - DragList tag-drop computes new items for its zone and calls onUpdate instead of invoking the store directly. The component is no longer coupled to the explore concept. - Extract PivotAutoArrangeZone component. It owns the drop handling and modifier-aware copy; PivotHeader just renders it with the rows / columns / setters props. - TagFilterBanner: wrap the Tooltip.Trigger button in a {#snippet child()} so flex alignment of the cancel icon is correct. - AddField bulk-add now loops addPivotField for each item (since addPivotFields is removed). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/menu/TagFilterBanner.svelte | 19 +- .../features/dashboards/pivot/AddField.svelte | 12 +- .../features/dashboards/pivot/DragList.svelte | 33 +-- .../pivot/PivotAutoArrangeZone.svelte | 120 +++++++++ .../dashboards/pivot/PivotDisplay.svelte | 4 +- .../dashboards/pivot/PivotHeader.svelte | 118 +-------- .../dashboards/pivot/PivotSidebar.svelte | 164 +----------- .../dashboards/pivot/PivotTagRow.svelte | 235 ++++++++++++++---- .../features/dashboards/pivot/pivot-utils.ts | 40 +++ .../dashboards/stores/dashboard-stores.ts | 70 +----- 10 files changed, 402 insertions(+), 413 deletions(-) create mode 100644 web-common/src/features/dashboards/pivot/PivotAutoArrangeZone.svelte diff --git a/web-common/src/components/menu/TagFilterBanner.svelte b/web-common/src/components/menu/TagFilterBanner.svelte index f1b8bdeab21a..addca27d0e34 100644 --- a/web-common/src/components/menu/TagFilterBanner.svelte +++ b/web-common/src/components/menu/TagFilterBanner.svelte @@ -17,14 +17,17 @@ - + {#snippet child({ props })} + + {/snippet} 0) { - metricsExplorerStore.addPivotFields( - $exploreName, - dimensions, - "rows", - ); - } - } else if (zone === "columns") { - const all = [...dimensions, ...measures]; - if (replace) { - metricsExplorerStore.replacePivotColumns($exploreName, all); - } else if (all.length > 0) { - metricsExplorerStore.addPivotFields($exploreName, all, "columns"); - } + const newItems = + zone === "rows" ? dimensions : [...dimensions, ...measures]; + if (replace) { + onUpdate(newItems); + } else if (newItems.length > 0) { + const existing = new Set(items.map((c) => c.id)); + const additions = newItems.filter((c) => !existing.has(c.id)); + if (additions.length > 0) onUpdate([...items, ...additions]); } } else if (dragChip && ghostIndex !== null) { const temp = [...items]; diff --git a/web-common/src/features/dashboards/pivot/PivotAutoArrangeZone.svelte b/web-common/src/features/dashboards/pivot/PivotAutoArrangeZone.svelte new file mode 100644 index 000000000000..5ebc37affbe0 --- /dev/null +++ b/web-common/src/features/dashboards/pivot/PivotAutoArrangeZone.svelte @@ -0,0 +1,120 @@ + + +{#if visible && dragData?.tagPayload} +
+ + Auto + + +
+{/if} + + diff --git a/web-common/src/features/dashboards/pivot/PivotDisplay.svelte b/web-common/src/features/dashboards/pivot/PivotDisplay.svelte index 9e59a8e7f6b1..57f1c5a0ffd5 100644 --- a/web-common/src/features/dashboards/pivot/PivotDisplay.svelte +++ b/web-common/src/features/dashboards/pivot/PivotDisplay.svelte @@ -104,7 +104,9 @@ combinedTagIndex={$combinedTagIndex} dimensionTagIndex={$dimensionTagIndex} measureTagIndex={$measureTagIndex} - exploreName={$exploreName} + setRows={(rows) => metricsExplorerStore.setPivotRows($exploreName, rows)} + setColumns={(columns) => + metricsExplorerStore.setPivotColumns($exploreName, columns)} {timeControlsForPillActions} /> {/if} diff --git a/web-common/src/features/dashboards/pivot/PivotHeader.svelte b/web-common/src/features/dashboards/pivot/PivotHeader.svelte index d901fce71db8..56b09a0b9322 100644 --- a/web-common/src/features/dashboards/pivot/PivotHeader.svelte +++ b/web-common/src/features/dashboards/pivot/PivotHeader.svelte @@ -1,13 +1,10 @@
addTagToRows(tag.name, replace)} - onAddColumns={(replace) => addTagToColumns(tag.name, replace)} - onAutoArrange={(replace) => autoArrangeTag(tag.name, replace)} - onDragStart={(e, rect) => - handleTagDragStart(e, tag.name, items, rect)} + {setRows} + {setColumns} + onSelect={() => toggleTagFilter(tag.name)} /> {/each} {/if} @@ -311,16 +171,6 @@
-{#if tagDragActive && tagDragChip} - -{/if} -