Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions web-common/src/features/dashboards/pivot/PivotTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
isShowMoreRow,
splitPivotChips,
} from "@rilldata/web-common/features/dashboards/pivot/pivot-utils";
import {
buildExpandKey,
childExpandKey,
encodeExpandKeyValue,
parentExpandKey,
PIVOT_TOTALS_ROW_ID,
} from "@rilldata/web-common/features/dashboards/pivot/pivot-expand-keys";
import { copyToClipboard } from "@rilldata/web-common/lib/actions/copy-to-clipboard";
import {
createVirtualizer,
Expand Down Expand Up @@ -89,15 +96,30 @@
export let clickSelection: PivotClickSelectionState | undefined = undefined;

const options: Readable<TableOptions<PivotDataRow>> = derived(
[pivotDataStore, pivotState],
([pivotData, state]) => {
[pivotDataStore, pivotState, config],
([pivotData, state, cfg]) => {
let tableData = [...pivotData.data];
if (pivotData.totalsRowData) {
tableData = [pivotData.totalsRowData, ...pivotData.data];
}
const totalsRowData = pivotData.totalsRowData;
const rowDims = cfg.rowDimensionNames;
return {
data: tableData,
columns: pivotData.columnDef,
// Value-based, hierarchical row ids (parent id + this row's value) so
// expansion survives sorting, adding a field, and data refreshes.
getRowId: (row, _index, parent) => {
if (totalsRowData && row === totalsRowData)
return PIVOT_TOTALS_ROW_ID;
if (cfg.isFlat) {
return buildExpandKey(rowDims.map((dim) => row[dim]));
}
const anchor = rowDims[0];
return parent
? childExpandKey(parent.id, row[anchor])
: encodeExpandKeyValue(row[anchor]);
},
state: {
expanded: state.expanded,
sorting: state.sorting,
Expand Down Expand Up @@ -237,7 +259,7 @@
if (needsDomains) {
for (const row of flatRows) {
// Always skip the prepended grand-totals row.
if (hasTotalsRow && row.id === "0") continue;
if (hasTotalsRow && row.id === PIVOT_TOTALS_ROW_ID) continue;
const target = row.subRows.length > 0 ? parentValues : leafValues;
for (const cell of row.getAllCells()) {
const meta = cell.column.columnDef.meta;
Expand Down Expand Up @@ -340,21 +362,20 @@

if (!nextLimit) return;

// Check if this is the outermost dimension or a nested dimension
// Outermost dimension has rowId like "0", "1", etc. (no dots)
// Nested dimensions have rowId like "0.1", "0.1.2", etc.
const isOutermostDimension = !rowId.includes(".");
// The outermost "Show more" row sits at the top level (its parent key
// is the root ""); a nested one has a real parent node whose child
// limit we raise.
const parentKey = parentExpandKey(rowId);

if (isOutermostDimension) {
if (parentKey === "") {
// Handle outermost dimension "Show more" click
if (setPivotOutermostRowLimit) {
setPivotOutermostRowLimit(nextLimit);
}
} else {
// Handle nested dimension "Show more" click
const expandIndex = rowId.split(".").slice(0, -1).join(".");
if (expandIndex && setPivotRowLimitForExpanded) {
setPivotRowLimitForExpanded(expandIndex, nextLimit);
if (setPivotRowLimitForExpanded) {
setPivotRowLimitForExpanded(parentKey, nextLimit);
}
}
return;
Expand Down
53 changes: 53 additions & 0 deletions web-common/src/features/dashboards/pivot/pivot-expand-keys.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
buildExpandKey,
childExpandKey,
expandKeyDepth,
expandKeySegments,
parentExpandKey,
} from "./pivot-expand-keys";

describe("pivot-expand-keys", () => {
it("round-trips a value path through build/segments", () => {
const values = ["Galatea-Stories", "facebook", "Jul 2026"];
const key = buildExpandKey(values);
expect(expandKeySegments(key)).toEqual(values);
expect(expandKeyDepth(key)).toBe(3);
});

it("keeps values that contain dots or spaces intact", () => {
const values = ["a.b.c", "x y z"];
const key = buildExpandKey(values);
expect(expandKeySegments(key)).toEqual(values);
expect(expandKeyDepth(key)).toBe(2);
});

it("encodes null distinctly from a genuinely absent level", () => {
const withNull = buildExpandKey(["app", null]);
const shallow = buildExpandKey(["app"]);
expect(withNull).not.toBe(shallow);
expect(expandKeyDepth(withNull)).toBe(2);
expect(expandKeyDepth(shallow)).toBe(1);
});

it("computes the parent by stripping the deepest segment", () => {
const key = buildExpandKey(["app", "src", "month"]);
const parent = parentExpandKey(key);
expect(expandKeySegments(parent)).toEqual(["app", "src"]);
expect(parentExpandKey(buildExpandKey(["app"]))).toBe("");
expect(parentExpandKey("")).toBe("");
});

it("childExpandKey extends a parent and is inverse of parentExpandKey", () => {
const parent = buildExpandKey(["app", "src"]);
const child = childExpandKey(parent, "month");
expect(expandKeySegments(child)).toEqual(["app", "src", "month"]);
expect(parentExpandKey(child)).toBe(parent);
expect(childExpandKey("", "app")).toBe(buildExpandKey(["app"]));
});

it("treats the empty root key as depth 0", () => {
expect(expandKeyDepth("")).toBe(0);
expect(expandKeySegments("")).toEqual([]);
});
});
41 changes: 41 additions & 0 deletions web-common/src/features/dashboards/pivot/pivot-expand-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Value-based keys for pivot row expansion. A row is keyed by the dimension
* values from the root to it, NUL-joined and hierarchical, so the key stays
* stable across sorting, adding a field, and data refreshes. Mirrors the
* encoding in pivot-click-selection.ts.
*/

// NUL separator, since dimension values won't contain it.
export const EXPAND_KEY_SEP = "\0";

// Sentinel for null values, so a null at depth N differs from an absent level.
const NULL_SENTINEL = "<NULL>";

// The leading separator can't occur in a real id, so this can't collide.
export const PIVOT_TOTALS_ROW_ID = EXPAND_KEY_SEP + "totals";

export function encodeExpandKeyValue(value: unknown): string {
return value === null || value === undefined ? NULL_SENTINEL : String(value);
}

export function buildExpandKey(values: unknown[]): string {
return values.map(encodeExpandKeyValue).join(EXPAND_KEY_SEP);
}

export function expandKeySegments(key: string): string[] {
return key === "" ? [] : key.split(EXPAND_KEY_SEP);
}

export function expandKeyDepth(key: string): number {
return expandKeySegments(key).length;
}

export function parentExpandKey(key: string): string {
const i = key.lastIndexOf(EXPAND_KEY_SEP);
return i === -1 ? "" : key.slice(0, i);
}

export function childExpandKey(parentKey: string, value: unknown): string {
const segment = encodeExpandKeyValue(value);
return parentKey === "" ? segment : parentKey + EXPAND_KEY_SEP + segment;
}
140 changes: 100 additions & 40 deletions web-common/src/features/dashboards/pivot/pivot-expansion.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { describe, expect, it } from "vitest";
import { LOADING_CELL } from "@rilldata/web-common/features/dashboards/pivot/pivot-constants";
import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils";
import { addExpandedDataToPivot } from "./pivot-expansion";
import { buildExpandKey } from "./pivot-expand-keys";
import {
addExpandedDataToPivot,
getValuesForExpandedKey,
} from "./pivot-expansion";
import { type PivotDataRow, type PivotDataStoreConfig } from "./types";

function getConfig(showTotalsRow: boolean): PivotDataStoreConfig {
function getConfig(
showTotalsRow: boolean,
rowDimensionNames = ["publisher", "campaign"],
): PivotDataStoreConfig {
return {
measureNames: ["impressions"],
rowDimensionNames: ["publisher", "campaign"],
rowDimensionNames,
colDimensionNames: [],
allMeasures: [],
allDimensions: [],
Expand Down Expand Up @@ -38,76 +45,129 @@ function getConfig(showTotalsRow: boolean): PivotDataStoreConfig {
} as unknown as PivotDataStoreConfig;
}

describe("pivot expansion", () => {
it("adds expanded rows at the correct index when totals row is hidden", () => {
describe("pivot expansion (value-based keys)", () => {
it("fills the node matched by dimension value, regardless of totals row", () => {
for (const showTotals of [false, true]) {
const tableData: PivotDataRow[] = [
{ publisher: "A", subRows: [{ publisher: LOADING_CELL }] },
{ publisher: "B", subRows: [{ publisher: LOADING_CELL }] },
];

addExpandedDataToPivot(
getConfig(showTotals),
tableData,
["publisher", "campaign"],
{},
[
{
isFetching: false,
expandIndex: buildExpandKey(["B"]),
rowDimensionValues: ["B"],
totals: [{ campaign: "campaign-1", impressions: 10 }],
data: [],
},
],
);

// Row "A" is untouched; row "B" (matched by value, not position) is filled.
expect(tableData[0].subRows?.[0]?.publisher).toBe(LOADING_CELL);
expect(tableData[1].subRows?.[0]).toMatchObject({
publisher: "campaign-1",
campaign: "campaign-1",
impressions: 10,
});
}
});

it("resolves a nested value path to the correct deep node", () => {
const tableData: PivotDataRow[] = [
{
publisher: "A",
subRows: [{ publisher: LOADING_CELL }],
},
{
publisher: "B",
subRows: [{ publisher: LOADING_CELL }],
subRows: [
{ publisher: "camp1", subRows: [{ publisher: LOADING_CELL }] },
],
},
];

addExpandedDataToPivot(
getConfig(false),
getConfig(true, ["publisher", "campaign", "adgroup"]),
tableData,
["publisher", "campaign"],
["publisher", "campaign", "adgroup"],
{},
[
{
isFetching: false,
expandIndex: "1",
rowDimensionValues: ["B"],
totals: [{ campaign: "campaign-1", impressions: 10 }],
expandIndex: buildExpandKey(["A", "camp1"]),
rowDimensionValues: ["A", "camp1"],
totals: [{ adgroup: "ad-1", impressions: 5 }],
data: [],
},
],
);

expect(tableData[0].subRows?.[0]?.publisher).toBe(LOADING_CELL);
expect(tableData[1].subRows?.[0]).toMatchObject({
publisher: "campaign-1",
campaign: "campaign-1",
impressions: 10,
expect(tableData[0].subRows?.[0]?.subRows?.[0]).toMatchObject({
publisher: "ad-1",
adgroup: "ad-1",
impressions: 5,
});
});

it("keeps the totals row offset when totals row is visible", () => {
it("does nothing when the value path matches no row", () => {
const tableData: PivotDataRow[] = [
{
publisher: "A",
subRows: [{ publisher: LOADING_CELL }],
},
{
publisher: "B",
subRows: [{ publisher: LOADING_CELL }],
},
{ publisher: "A", subRows: [{ publisher: LOADING_CELL }] },
];

addExpandedDataToPivot(
getConfig(true),
getConfig(false),
tableData,
["publisher", "campaign"],
{},
[
{
isFetching: false,
expandIndex: "2",
rowDimensionValues: ["B"],
totals: [{ campaign: "campaign-1", impressions: 10 }],
expandIndex: buildExpandKey(["does-not-exist"]),
rowDimensionValues: ["does-not-exist"],
totals: [{ campaign: "c", impressions: 1 }],
data: [],
},
],
);

expect(tableData[0].subRows?.[0]?.publisher).toBe(LOADING_CELL);
expect(tableData[1].subRows?.[0]).toMatchObject({
publisher: "campaign-1",
campaign: "campaign-1",
impressions: 10,
});
});
});

describe("getValuesForExpandedKey", () => {
const tableData: PivotDataRow[] = [
{
publisher: "A",
subRows: [{ publisher: "camp1" }, { publisher: "camp2" }],
},
{ publisher: "B", subRows: [{ publisher: "camp3" }] },
];

it("returns the actual values along the matched path", () => {
expect(
getValuesForExpandedKey(
tableData,
["publisher", "campaign"],
buildExpandKey(["B"]),
),
).toEqual(["B"]);
expect(
getValuesForExpandedKey(
tableData,
["publisher", "campaign"],
buildExpandKey(["A", "camp2"]),
),
).toEqual(["A", "camp2"]);
});

it("stops at the deepest resolvable segment", () => {
expect(
getValuesForExpandedKey(
tableData,
["publisher", "campaign"],
buildExpandKey(["A", "missing"]),
),
).toEqual(["A"]);
});
});
Loading