diff --git a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte index cee6fafee227..6c623a0d3b01 100644 --- a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte +++ b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte @@ -1,7 +1,6 @@ + +
+ Filter: + + {tagName} + + + + {#snippet child({ props })} + + {/snippet} + + + 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} /> diff --git a/web-common/src/features/dashboards/pivot/DragList.svelte b/web-common/src/features/dashboards/pivot/DragList.svelte index 11213ff7882f..31a4b374b59d 100644 --- a/web-common/src/features/dashboards/pivot/DragList.svelte +++ b/web-common/src/features/dashboards/pivot/DragList.svelte @@ -26,12 +26,28 @@ } from "@rilldata/web-common/features/dashboards/pivot/time-pill-utils"; import { timePillSelectors } from "./time-pill-store"; - export type Zone = "rows" | "columns" | "Time" | "Measures" | "Dimensions"; + export type Zone = + | "rows" + | "columns" + | "Time" + | "Measures" + | "Dimensions" + | "tags"; + + // When a tag chip is being dragged the drop receivers do a bulk-add instead + // of the per-chip splice path. The dimensions and measures arrays are + // precomputed at drag-start so each receiver does not have to re-split. + export type TagDragPayload = { + tagName: string; + dimensions: PivotChipData[]; + measures: PivotChipData[]; + }; export type DragData = { source: Zone; width: number; chip: PivotChipData; + tagPayload?: TagDragPayload; }; export const dragDataStore = writable(null); @@ -70,10 +86,14 @@ (i) => i.type !== PivotChipType.Measure, ); + $: isTagDrag = !!dragData?.tagPayload; + $: isValidDropZone = isDropLocation && dragData && - (zone === "columns" || dragChip?.type !== PivotChipType.Measure); + (isTagDrag || + zone === "columns" || + dragChip?.type !== PivotChipType.Measure); // Get available grains from the store const availableGrainsStore = timePillSelectors.getAvailableGrains("time"); @@ -134,12 +154,33 @@ window.removeEventListener("mousemove", detectDragStart); } - function handleDrop() { + function handleDrop(e: MouseEvent) { if (zoneStartedDrag) $controllerStore?.abort("Drag cancelled - item dropped"); + // Holding CMD (mac) or Ctrl flips the tag drop from append to replace, + // matching the click-side affordance on the tag row. + const replace = e.metaKey || e.ctrlKey; + if (isValidDropZone) { - if (dragChip && ghostIndex !== null) { + if (dragData?.tagPayload) { + // Bulk-add path for tag drops. Skips ghost-index positioning since + // we are inserting multiple chips, not a single one. Cross-zone + // cleanup on replace happens in the auto-arrange zone or the click + // affordances on the tag row — DragList only manages its own zone. + const { dimensions, measures } = dragData.tagPayload; + const newItems = + zone === "rows" ? dimensions : [...dimensions, ...measures]; + if (newItems.length === 0) { + // Pure-measure tag dropped on rows, for instance: nothing to do. + } else if (replace) { + onUpdate(newItems); + } else { + 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]; let chipToAdd = dragChip; 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 abbe939868b7..57f1c5a0ffd5 100644 --- a/web-common/src/features/dashboards/pivot/PivotDisplay.svelte +++ b/web-common/src/features/dashboards/pivot/PivotDisplay.svelte @@ -26,6 +26,7 @@ validSpecStore, selectors: { pivot: { columns, measures, dimensions }, + tags: { combinedTagIndex, dimensionTagIndex, measureTagIndex }, }, timeRangeSummaryStore, } = stateManagers; @@ -100,6 +101,12 @@ pivotState={enrichedPivotState} measures={$measures} dimensions={$dimensions} + combinedTagIndex={$combinedTagIndex} + dimensionTagIndex={$dimensionTagIndex} + measureTagIndex={$measureTagIndex} + 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 2277a0034a02..56b09a0b9322 100644 --- a/web-common/src/features/dashboards/pivot/PivotHeader.svelte +++ b/web-common/src/features/dashboards/pivot/PivotHeader.svelte @@ -4,6 +4,7 @@ import { splitPivotChips } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils.ts"; import { slide } from "svelte/transition"; import DragList from "./DragList.svelte"; + import PivotAutoArrangeZone from "./PivotAutoArrangeZone.svelte"; import { lastNestState } from "./PivotToolbar.svelte"; import { PivotChipType, type PivotChipData, type PivotState } from "./types"; @@ -15,6 +16,7 @@ $: splitColumns = splitPivotChips(columns); $: fullColumns = splitColumns.dimension.concat(splitColumns.measure); $: isFlat = tableMode === "flat"; + $: columnsForList = isFlat ? columns : fullColumns; function updateColumn(items: PivotChipData[]) { // Reset lastNestState when columns are updated @@ -32,13 +34,7 @@
{#if !isFlat} -
+
Rows @@ -49,6 +45,12 @@ onUpdate={updateRows} />
+ {/if}
@@ -58,7 +60,7 @@ diff --git a/web-common/src/features/dashboards/pivot/PivotSidebar.svelte b/web-common/src/features/dashboards/pivot/PivotSidebar.svelte index 21e6ee560a57..666636689eb8 100644 --- a/web-common/src/features/dashboards/pivot/PivotSidebar.svelte +++ b/web-common/src/features/dashboards/pivot/PivotSidebar.svelte @@ -1,11 +1,17 @@ - - - - - +
+ {#if hasTags} +
+

Tags

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

No matching tags

+ {:else} + {#each filteredTags as tag (tag.name)} + {@const items = tagItemsFor(tag.name)} + toggleTagFilter(tag.name)} + /> + {/each} + {/if} +
+ {/if} + +
+ {#if selectedTag} + + {/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..49dc5a46b282 --- /dev/null +++ b/web-common/src/features/dashboards/pivot/PivotTagRow.svelte @@ -0,0 +1,361 @@ + + + + +{#if dragActive && dragChip} + +{/if} + + 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..0e4c69aa6176 --- /dev/null +++ b/web-common/src/features/dashboards/pivot/pivot-tag-utils.spec.ts @@ -0,0 +1,128 @@ +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..0e82723f1fb8 100644 --- a/web-common/src/features/dashboards/pivot/pivot-utils.ts +++ b/web-common/src/features/dashboards/pivot/pivot-utils.ts @@ -1,3 +1,7 @@ +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 +16,8 @@ import { type TimeRangeString, } from "@rilldata/web-common/lib/time/types"; import type { + MetricsViewSpecDimension, + MetricsViewSpecMeasure, V1Expression, V1MetricsViewAggregationMeasure, V1MetricsViewAggregationResponse, @@ -746,3 +752,89 @@ 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 }; +} + +/** + * Append new chips to a zone, skipping any whose id already exists in the + * zone or in the opposite zone. Used for tag bulk-add when the caller has + * access to both zones (cross-zone dedup keeps a dimension from appearing + * in both rows and columns). Returns a new array; preserves the original + * order of the zone followed by the new items. + */ +export function appendChipsToZone( + zoneItems: PivotChipData[], + otherZoneItems: PivotChipData[], + newItems: PivotChipData[], +): PivotChipData[] { + const existing = new Set([ + ...zoneItems.map((c) => c.id), + ...otherZoneItems.map((c) => c.id), + ]); + const additions: PivotChipData[] = []; + for (const item of newItems) { + if (existing.has(item.id)) continue; + existing.add(item.id); + additions.push(item); + } + return [...zoneItems, ...additions]; +} + +/** + * Replace a zone's contents with new chips, removing those chips' ids from + * the opposite zone so a dimension never appears in both zones at once. + */ +export function replaceZoneCleaningOther( + newItems: PivotChipData[], + otherZoneItems: PivotChipData[], +): { zone: PivotChipData[]; other: PivotChipData[] } { + const ids = new Set(newItems.map((v) => v.id)); + return { + zone: newItems, + other: otherZoneItems.filter((c) => !ids.has(c.id)), + }; +} 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/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..41feccf60981 --- /dev/null +++ b/web-common/src/lib/modifier-key.ts @@ -0,0 +1,20 @@ +import { 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 = _modifierHeld as Readable;