From 8f3fe7e6d5c48dfa41561c7f9ccc24e752b35816 Mon Sep 17 00:00:00 2001 From: Dhiraj Barnwal Date: Thu, 15 Feb 2024 22:21:47 +0530 Subject: [PATCH] Filter null columns from pivot table (#4046) * initial multi time support * support for single time chip * Filter null columns * Pivot table support for multiple time dimension * Beautify labels * Move totals row to tranformations * temp hover state for readability * Pagination for column def * Page columns * sort acessorts before paging * Reduce query limit to 5000 * lint fix * Add time filters for page --- .../dashboards/pivot/PivotTable.svelte | 59 ++--- .../pivot/pivot-column-definition.ts | 62 ++++-- .../dashboards/pivot/pivot-data-store.ts | 186 ++++++++-------- .../dashboards/pivot/pivot-expansion.ts | 19 +- .../dashboards/pivot/pivot-infinite-scroll.ts | 207 ++++++++++-------- .../dashboards/pivot/pivot-queries.ts | 54 ++++- .../pivot/pivot-table-transformations.ts | 34 +++ .../features/dashboards/pivot/pivot-utils.ts | 89 ++++++-- .../pivot/tests/pivot-utilts.test.ts | 28 +++ .../src/features/dashboards/pivot/types.ts | 2 + 10 files changed, 484 insertions(+), 256 deletions(-) create mode 100644 web-common/src/features/dashboards/pivot/tests/pivot-utilts.test.ts diff --git a/web-common/src/features/dashboards/pivot/PivotTable.svelte b/web-common/src/features/dashboards/pivot/PivotTable.svelte index 3fd7256890a..6834ff87c7c 100644 --- a/web-common/src/features/dashboards/pivot/PivotTable.svelte +++ b/web-common/src/features/dashboards/pivot/PivotTable.svelte @@ -1,4 +1,5 @@ @@ -256,4 +256,9 @@ th:last-of-type > .header-cell { @apply border-r-0; } + + tr:hover, + tr:hover .cell { + @apply bg-slate-100; + } diff --git a/web-common/src/features/dashboards/pivot/pivot-column-definition.ts b/web-common/src/features/dashboards/pivot/pivot-column-definition.ts index 9c85184c423..66b05edaceb 100644 --- a/web-common/src/features/dashboards/pivot/pivot-column-definition.ts +++ b/web-common/src/features/dashboards/pivot/pivot-column-definition.ts @@ -29,15 +29,19 @@ function createColumnDefinitionForDimensions( colDimensions: { label: string; name: string }[], headers: Record, leafData: ColumnDef[], + totals: PivotDataRow, ): ColumnDef[] { const dimensionNames = config.colDimensionNames; const timeConfig = config.time; + const filterColumns = Boolean(dimensionNames.length); + const colValuesIndexMaps = dimensionNames?.map((colDimension) => createIndexMap(headers[colDimension]), ); const levels = dimensionNames.length; + // Recursive function to create nested columns function createNestedColumns( level: number, @@ -53,37 +57,51 @@ function createColumnDefinitionForDimensions( ); // Base case: return leaf columns - return leafData.map((leaf, i) => ({ + const leafNodes = leafData.map((leaf, i) => ({ ...leaf, // Change accessor key to match the nested column structure accessorKey: accessors[i], })); + + if (!filterColumns) { + return leafNodes; + } + return leafNodes.filter((leaf) => + Object.keys(totals).includes(leaf.accessorKey), + ); } // Recursive case: create nested headers const headerValues = headers[dimensionNames?.[level]]; - return headerValues?.map((value) => { - let displayValue = value; - if (isTimeDimension(dimensionNames?.[level], timeConfig?.timeDimension)) { - const timeGrain = getTimeGrainFromDimension(dimensionNames?.[level]); - const dt = addZoneOffset( - removeLocalTimezoneOffset(new Date(value)), - timeConfig?.timeZone, - ); - const timeFormatter = timeFormat( - timeGrain ? TIME_GRAIN[timeGrain].d3format : "%H:%M", - ) as (d: Date) => string; - - displayValue = timeFormatter(dt); - } else if (displayValue === null) displayValue = "null"; - return { - header: displayValue, - columns: createNestedColumns(level + 1, { + return headerValues + ?.map((value) => { + let displayValue = value; + if ( + isTimeDimension(dimensionNames?.[level], timeConfig?.timeDimension) + ) { + const timeGrain = getTimeGrainFromDimension(dimensionNames?.[level]); + const dt = addZoneOffset( + removeLocalTimezoneOffset(new Date(value)), + timeConfig?.timeZone, + ); + const timeFormatter = timeFormat( + timeGrain ? TIME_GRAIN[timeGrain].d3format : "%H:%M", + ) as (d: Date) => string; + + displayValue = timeFormatter(dt); + } else if (displayValue === null) displayValue = "null"; + + const nestedColumns = createNestedColumns(level + 1, { ...colValuePair, [dimensionNames[level]]: value, - }), - }; - }); + }); + + return { + header: displayValue, + columns: nestedColumns, + }; + }) + .filter((column) => column.columns.length > 0); } // Construct column def for Row Totals @@ -141,6 +159,7 @@ function formatRowDimensionValue( export function getColumnDefForPivot( config: PivotDataStoreConfig, columnDimensionAxes: Record | undefined, + totals: PivotDataRow, ) { const IsNested = true; @@ -232,6 +251,7 @@ export function getColumnDefForPivot( colDimensions, columnDimensionAxes || {}, leafColumns, + totals, ); return [...rowDefinitions, ...groupedColDef]; diff --git a/web-common/src/features/dashboards/pivot/pivot-data-store.ts b/web-common/src/features/dashboards/pivot/pivot-data-store.ts index fd0f4f5831e..d00679507cf 100644 --- a/web-common/src/features/dashboards/pivot/pivot-data-store.ts +++ b/web-common/src/features/dashboards/pivot/pivot-data-store.ts @@ -4,6 +4,7 @@ import { useMetricsView } from "@rilldata/web-common/features/dashboards/selecto import { memoizeMetricsStore } from "@rilldata/web-common/features/dashboards/state-managers/memoize-metrics-store"; import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; +import type { TimeRangeString } from "@rilldata/web-common/lib/time/types"; import type { V1MetricsViewAggregationResponse, V1MetricsViewAggregationResponseDataItem, @@ -20,8 +21,10 @@ import { sliceColumnAxesDataForDef } from "./pivot-infinite-scroll"; import { createPivotAggregationRowQuery, getAxisForDimensions, + getTotalsRowQuery, } from "./pivot-queries"; import { + getTotalsRow, prepareNestedPivotData, reduceTableCellDataIntoRows, } from "./pivot-table-transformations"; @@ -29,6 +32,7 @@ import { getFilterForPivotTable, getPivotConfigKey, getSortForAccessor, + getTimeForQuery, getTimeGrainFromDimension, getTotalColumnCount, isTimeDimension, @@ -128,6 +132,7 @@ export function createTableCellQuery( config: PivotDataStoreConfig, anchorDimension: string | undefined, columnDimensionAxesData: Record | undefined, + totalsRow: PivotDataRow, rowDimensionValues: string[], ) { let allDimensions = config.colDimensionNames; @@ -147,13 +152,17 @@ export function createTableCellQuery( } else return { name: dimension }; }); - const filterForInitialTable = getFilterForPivotTable( - config, - columnDimensionAxesData, - rowDimensionValues, - true, - anchorDimension, - ); + const { filters: filterForInitialTable, timeFilters } = + getFilterForPivotTable( + config, + columnDimensionAxesData, + totalsRow, + rowDimensionValues, + true, + anchorDimension, + ); + + const timeRange: TimeRangeString = getTimeForQuery(config.time, timeFilters); const mergedFilter = mergeFilters(filterForInitialTable, config.whereFilter); @@ -170,7 +179,9 @@ export function createTableCellQuery( mergedFilter, config.measureFilter, sortBy, - "10000", + "5000", + "0", + timeRange, ); } @@ -181,6 +192,7 @@ export function createTableCellQuery( */ let lastPivotData: PivotDataRow[] = []; let lastPivotColumnDef: ColumnDef[] = []; +let lastTotalColumns: number = 0; /** * The expanded table has to iterate over itself to find nested dimension values @@ -263,8 +275,6 @@ function createPivotDataStore(ctx: StateManagers): PivotDataStore { columnDimensionAxes?.data, ); - const totalColumns = getTotalColumnCount(columnDimensionAxes?.data); - const rowDimensionAxisQuery = getAxisForDimensions( ctx, config, @@ -285,13 +295,59 @@ function createPivotDataStore(ctx: StateManagers): PivotDataStore { config.whereFilter, ); + let globalTotalsQuery: + | Readable + | CreateQueryResult = + readable(null); + let totalsRowQuery: + | Readable + | CreateQueryResult = + readable(null); + if (rowDimensionNames.length && measureNames.length) { + globalTotalsQuery = createPivotAggregationRowQuery( + ctx, + config.measureNames, + [], + config.whereFilter, + config.measureFilter, + [], + "5000", // Using 5000 for cache hit + ); + } + if ( + (rowDimensionNames.length || colDimensionNames.length) && + measureNames.length + ) { + totalsRowQuery = getTotalsRowQuery( + ctx, + config, + columnDimensionAxes?.data, + ); + } + /** * Derive a store from axes queries */ return derived( - [rowDimensionAxisQuery, rowDimensionUnsortedAxisQuery], - ([rowDimensionAxes, rowDimensionUnsortedAxis], axesSet) => { + [ + rowDimensionAxisQuery, + rowDimensionUnsortedAxisQuery, + globalTotalsQuery, + totalsRowQuery, + ], + ( + [ + rowDimensionAxes, + rowDimensionUnsortedAxis, + globalTotalsResponse, + totalsRowResponse, + ], + axesSet, + ) => { if ( + (globalTotalsResponse !== null && + globalTotalsResponse?.isFetching) || + (totalsRowResponse !== null && totalsRowResponse?.isFetching) || rowDimensionAxes?.isFetching || rowDimensionUnsortedAxis?.isFetching ) { @@ -300,10 +356,19 @@ function createPivotDataStore(ctx: StateManagers): PivotDataStore { data: lastPivotData, columnDef: lastPivotColumnDef, assembled: false, - totalColumns, + totalColumns: lastTotalColumns, }); } + const totalsRow = getTotalsRow( + config, + columnDimensionAxes?.data, + totalsRowResponse?.data?.data, + globalTotalsResponse?.data?.data, + ); + + const totalColumns = getTotalColumnCount(totalsRow); + const { rows: rowDimensionValues, totals: rowTotals } = reconcileMissingDimensionValues( anchorDimension, @@ -311,35 +376,40 @@ function createPivotDataStore(ctx: StateManagers): PivotDataStore { rowDimensionUnsortedAxis, ); - let columnDef = getColumnDefForPivot( - config, - columnDimensionAxes?.data, - ); - let initialTableCellQuery: | Readable | CreateQueryResult = readable(null); + let columnDef: ColumnDef[] = []; if (colDimensionNames.length || !rowDimensionNames.length) { - const slicedAxesDataForPage = sliceColumnAxesDataForDef( - colDimensionNames, + const slicedAxesDataForDef = sliceColumnAxesDataForDef( + config, columnDimensionAxes?.data, - config.pivot.columnPage, - measureNames.length, + totalsRow, ); - columnDef = getColumnDefForPivot(config, slicedAxesDataForPage); + columnDef = getColumnDefForPivot( + config, + slicedAxesDataForDef, + totalsRow, + ); initialTableCellQuery = createTableCellQuery( ctx, config, rowDimensionNames[0], columnDimensionAxes?.data, + totalsRow, rowDimensionValues, ); + } else { + columnDef = getColumnDefForPivot( + config, + columnDimensionAxes?.data, + totalsRow, + ); } - /** * Derive a store from initial table cell data query */ @@ -381,56 +451,15 @@ function createPivotDataStore(ctx: StateManagers): PivotDataStore { config, pivotData, columnDimensionAxes?.data, + totalsRow, ); - let globalTotalsQuery: - | Readable - | CreateQueryResult< - V1MetricsViewAggregationResponse, - unknown - > = readable(null); - let totalsRowQuery: - | Readable - | CreateQueryResult< - V1MetricsViewAggregationResponse, - unknown - > = readable(null); - if (rowDimensionNames.length && measureNames.length) { - /** In some cases the totals query would be the same query as that - * for the initial table cell data. With svelte query cache we would not hit the - * API twice - */ - globalTotalsQuery = createPivotAggregationRowQuery( - ctx, - config.measureNames, - [], - config.whereFilter, - config.measureFilter, - [], - "10000", // Using 10000 for cache hit - ); - totalsRowQuery = createTableCellQuery( - ctx, - config, - undefined, - columnDimensionAxes?.data, - [], - ); - } /** * Derive a store based on expanded rows and totals */ return derived( - [ - globalTotalsQuery, - totalsRowQuery, - expandedSubTableCellQuery, - ], - ([ - globalTotalsResponse, - totalsRowResponse, - expandedRowMeasureValues, - ]) => { + [expandedSubTableCellQuery], + ([expandedRowMeasureValues]) => { prepareNestedPivotData(pivotData, rowDimensionNames); let tableDataExpanded: PivotDataRow[] = pivotData; if (expandedRowMeasureValues?.length) { @@ -448,29 +477,10 @@ function createPivotDataStore(ctx: StateManagers): PivotDataStore { } lastPivotData = tableDataExpanded; lastPivotColumnDef = columnDef; + lastTotalColumns = totalColumns; let assembledTableData = tableDataExpanded; if (rowDimensionNames.length && measureNames.length) { - const totalsRowData = totalsRowResponse?.data?.data; - - const globalTotalsData = - globalTotalsResponse?.data?.data || []; - const totalsRowTable = reduceTableCellDataIntoRows( - config, - "", - [], - columnDimensionAxes?.data || {}, - [], - totalsRowData || [], - ); - - let totalsRow = totalsRowTable[0] || {}; - totalsRow[anchorDimension] = "Total"; - - globalTotalsData.forEach((total) => { - totalsRow = { ...total, ...totalsRow }; - }); - assembledTableData = [totalsRow, ...tableDataExpanded]; } diff --git a/web-common/src/features/dashboards/pivot/pivot-expansion.ts b/web-common/src/features/dashboards/pivot/pivot-expansion.ts index dd88361fd5c..2c997941a91 100644 --- a/web-common/src/features/dashboards/pivot/pivot-expansion.ts +++ b/web-common/src/features/dashboards/pivot/pivot-expansion.ts @@ -68,8 +68,9 @@ export function createSubTableCellQuery( config: PivotDataStoreConfig, anchorDimension: string, columnDimensionAxesData: Record | undefined, + totalsRow: PivotDataRow, rowNestFilters: V1Expression, - timeRange: TimeRangeString | undefined, + timeFilters: TimeFilters[], ) { const allDimensions = config.colDimensionNames.concat([anchorDimension]); @@ -86,10 +87,14 @@ export function createSubTableCellQuery( } else return { name: dimension }; }); - const filterForSubTable = getFilterForPivotTable( - config, - columnDimensionAxesData, + const { filters: filterForSubTable, timeFilters: colTimeFilters } = + getFilterForPivotTable(config, columnDimensionAxesData, totalsRow); + + const timeRange: TimeRangeString = getTimeForQuery( + time, + timeFilters.concat(colTimeFilters), ); + filterForSubTable.cond?.exprs?.push(...(rowNestFilters?.cond?.exprs ?? [])); const sortBy = [ @@ -105,7 +110,7 @@ export function createSubTableCellQuery( filterForSubTable, config.measureFilter, sortBy, - "10000", + "5000", "0", timeRange, ); @@ -129,6 +134,7 @@ export function queryExpandedRowMeasureValues( config: PivotDataStoreConfig, tableData: PivotDataRow[], columnDimensionAxesData: Record | undefined, + totalsRow: PivotDataRow, ): Readable { const { rowDimensionNames } = config; const expanded = config.pivot.expanded; @@ -228,8 +234,9 @@ export function queryExpandedRowMeasureValues( config, anchorDimension, columnDimensionAxesData, + totalsRow, allMergedFilters, - timeRange, + timeFilters, ), ], ([expandIndex, subRowDimensions, subTableData]) => { diff --git a/web-common/src/features/dashboards/pivot/pivot-infinite-scroll.ts b/web-common/src/features/dashboards/pivot/pivot-infinite-scroll.ts index 3aa7af8c517..64a88cecc26 100644 --- a/web-common/src/features/dashboards/pivot/pivot-infinite-scroll.ts +++ b/web-common/src/features/dashboards/pivot/pivot-infinite-scroll.ts @@ -1,92 +1,80 @@ +import type { + PivotDataRow, + PivotDataStoreConfig, + TimeFilters, +} from "@rilldata/web-common/features/dashboards/pivot/types"; import { createInExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import type { V1Expression } from "@rilldata/web-common/runtime-client"; - -const NUM_COLUMNS_PER_PAGE = 50; - -type ColumnNode = { - value: string; - depth: number; -}; - -function getTotalNodes(tree: Array>): number { - return tree.reduce((acc, level) => acc * level.length, 1); -} - -function getColumnPage( - tree: Array>, - pageNumber: number, - pageSize: number, -): Record> { - const startIndex = (pageNumber - 1) * pageSize; - const totalNodes = getTotalNodes(tree); - - if (startIndex >= totalNodes || startIndex < 0) { - return []; // Page number out of range - } - - const result: ColumnNode[] = []; - let currentIndex = 0; - - function dfs(level: number, path: ColumnNode[]) { - if (level === tree.length) { - if (currentIndex >= startIndex && currentIndex < startIndex + pageSize) { - result.push(...path); - } - currentIndex++; - return; - } - for (const child of tree[level]) { - if (currentIndex >= startIndex + pageSize) { - break; // Stop processing once the page is filled - } - dfs(level + 1, [...path, { value: child, depth: level }]); - } - } - - dfs(0, []); - - const groups: Record> = {}; - - result.forEach(({ value, depth }) => { - groups[depth] = groups[depth] || new Set(); - groups[depth].add(value); - }); +import { + extractNumbers, + getTimeGrainFromDimension, + isTimeDimension, + sortAcessors, +} from "./pivot-utils"; + +const NUM_COLUMNS_PER_PAGE = 40; + +function getSortedColumnKeys( + config: PivotDataStoreConfig, + totalsRow: PivotDataRow, +) { + const { measureNames, rowDimensionNames } = config; - return groups; + const allColumnKeys = totalsRow ? Object.keys(totalsRow) : []; + const colHeaderKeys = allColumnKeys.filter( + (key) => !(measureNames.includes(key) || rowDimensionNames[0] === key), + ); + return sortAcessors(colHeaderKeys); } -/** Slice column axes databased on page +/** + * Slice column axes data based on page * number. This is used for column definition in pivot table. */ export function sliceColumnAxesDataForDef( - colDimensionNames: string[], + config: PivotDataStoreConfig, colDimensionAxes: Record = {}, - colDimensionPageNumber: number, - numMeasures: number, + totalsRow: PivotDataRow, ) { + const { colDimensionNames } = config; + const colDimensionPageNumber = config.pivot.columnPage; if (!colDimensionNames.length) return colDimensionAxes; const totalColumnsToBeDisplayed = - Math.floor(NUM_COLUMNS_PER_PAGE / numMeasures) * colDimensionPageNumber; + NUM_COLUMNS_PER_PAGE * colDimensionPageNumber; - const colDimensionValues = colDimensionNames.map((colDimensionName) => { - return colDimensionAxes[colDimensionName]; - }); + const maxIndexVisible: Record = {}; + + const sortedColumnKeys = getSortedColumnKeys(config, totalsRow); - const pageGroups = getColumnPage( - colDimensionValues, - 1, + const columnKeysForPage = sortedColumnKeys.slice( + 0, totalColumnsToBeDisplayed, ); + columnKeysForPage.forEach((accessor) => { + // Strip the measure string from the accessor + const [accessorWithoutMeasure] = accessor.split("m"); + accessorWithoutMeasure.split("_").forEach((part) => { + const { c, v } = extractNumbers(part); + const columnDimensionName = colDimensionNames[c]; + maxIndexVisible[columnDimensionName] = Math.max( + maxIndexVisible[columnDimensionName] || 0, + v + 1, + ); + }); + }); + const slicedAxesData: Record = {}; - Object.keys(pageGroups).forEach((key) => { - const colDimensionName = colDimensionNames[parseInt(key)]; - slicedAxesData[colDimensionName] = Array.from( - pageGroups[key] as Set, - ); + Object.keys(maxIndexVisible).forEach((dimensionName) => { + if (maxIndexVisible[dimensionName] > 0) { + slicedAxesData[dimensionName] = colDimensionAxes[dimensionName].slice( + 0, + maxIndexVisible[dimensionName], + ); + } }); + return slicedAxesData; } @@ -95,31 +83,72 @@ export function sliceColumnAxesDataForDef( * page number and page size */ export function getColumnFiltersForPage( - colDimensionNames: string[], + config: PivotDataStoreConfig, colDimensionAxes: Record = {}, - colDimensionPageNumber: number, - numMeasures: number, -): V1Expression[] { - if (!colDimensionNames.length || numMeasures == 0) return []; + totalsRow: PivotDataRow, +) { + const { measureNames, colDimensionNames } = config; + const colDimensionPageNumber = config.pivot.columnPage; + + if (!colDimensionNames.length || measureNames.length == 0) + return { filters: [], timeFilters: [] }; + + const pageStartIndex = NUM_COLUMNS_PER_PAGE * (colDimensionPageNumber - 1); + + const sortedColumnKeys = getSortedColumnKeys(config, totalsRow); - const effectiveColumnsPerPage = Math.floor( - NUM_COLUMNS_PER_PAGE / numMeasures, + const columnKeysForPage = sortedColumnKeys.slice( + pageStartIndex, + pageStartIndex + NUM_COLUMNS_PER_PAGE, ); - const colDimensionValues = colDimensionNames.map((colDimensionName) => { - return colDimensionAxes[colDimensionName]; + const minIndexVisible: Record = {}; + const maxIndexVisible: Record = {}; + + columnKeysForPage.forEach((accessor) => { + // Strip the measure string from the accessor + const [accessorWithoutMeasure] = accessor.split("m"); + accessorWithoutMeasure.split("_").forEach((part) => { + const { c, v } = extractNumbers(part); + const dimension = colDimensionNames[c]; + maxIndexVisible[dimension] = Math.max( + maxIndexVisible[dimension] || 0, + v + 1, + ); + minIndexVisible[dimension] = Math.min( + minIndexVisible[dimension] ?? Number.MAX_SAFE_INTEGER, + v, + ); + }); }); - const pageGroups = getColumnPage( - colDimensionValues, - colDimensionPageNumber, - effectiveColumnsPerPage, - ); + const slicedAxesData: Record = {}; - return Object.entries(pageGroups).map(([colDimensionId, values]) => - createInExpression( - colDimensionNames[parseInt(colDimensionId)], - Array.from(values), - ), - ); + Object.keys(minIndexVisible).forEach((dimension) => { + slicedAxesData[dimension] = colDimensionAxes[dimension].slice( + minIndexVisible[dimension], + maxIndexVisible[dimension], + ); + }); + + const timeFilters: TimeFilters[] = []; + const filters = colDimensionNames + .filter((dimension) => { + if (isTimeDimension(dimension, config.time.timeDimension)) { + const dates = slicedAxesData[dimension].map((d) => + new Date(d).getTime(), + ); + const timeStart = new Date(Math.min(...dates)).toISOString(); + const timeEnd = new Date(Math.max(...dates)).toISOString(); + const interval = getTimeGrainFromDimension(dimension); + timeFilters.push({ timeStart, timeEnd, interval }); + return false; + } + return true; + }) + .map((dimension) => + createInExpression(dimension, slicedAxesData[dimension]), + ); + + return { filters, timeFilters }; } diff --git a/web-common/src/features/dashboards/pivot/pivot-queries.ts b/web-common/src/features/dashboards/pivot/pivot-queries.ts index 09d5f2703c4..68028b640c1 100644 --- a/web-common/src/features/dashboards/pivot/pivot-queries.ts +++ b/web-common/src/features/dashboards/pivot/pivot-queries.ts @@ -8,7 +8,11 @@ import type { PivotDataStoreConfig, } from "@rilldata/web-common/features/dashboards/pivot/types"; import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; -import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import { + createAndExpression, + createInExpression, + sanitiseExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; import type { TimeRangeString } from "@rilldata/web-common/lib/time/types"; import { @@ -21,6 +25,7 @@ import { } from "@rilldata/web-common/runtime-client"; import type { CreateQueryResult } from "@tanstack/svelte-query"; import { Readable, derived, readable } from "svelte/store"; +import { mergeFilters } from "./pivot-merge-filters"; /** * Wrapper function for Aggregate Query API @@ -166,3 +171,50 @@ export function getAxisForDimensions( }, ); } + +export function getTotalsRowQuery( + ctx: StateManagers, + config: PivotDataStoreConfig, + colDimensionAxes: Record = {}, +) { + const { colDimensionNames } = config; + + const { time } = config; + const dimensionBody = colDimensionNames.map((dimension) => { + if (isTimeDimension(dimension, time.timeDimension)) { + return { + name: time.timeDimension, + timeGrain: getTimeGrainFromDimension(dimension), + timeZone: time.timeZone, + alias: dimension, + }; + } else return { name: dimension }; + }); + + const colFilters = colDimensionNames + .filter((d) => !isTimeDimension(d, time.timeDimension)) + .map((dimension) => + createInExpression(dimension, colDimensionAxes[dimension]), + ); + + const mergedFilter = mergeFilters( + createAndExpression(colFilters), + config.whereFilter, + ); + + const sortBy = [ + { + desc: true, + name: config.measureNames[0], + }, + ]; + return createPivotAggregationRowQuery( + ctx, + config.measureNames, + dimensionBody, + mergedFilter, + config.measureFilter, + sortBy, + "1000", + ); +} diff --git a/web-common/src/features/dashboards/pivot/pivot-table-transformations.ts b/web-common/src/features/dashboards/pivot/pivot-table-transformations.ts index f2f3598b286..cfa2a5a23f9 100644 --- a/web-common/src/features/dashboards/pivot/pivot-table-transformations.ts +++ b/web-common/src/features/dashboards/pivot/pivot-table-transformations.ts @@ -103,3 +103,37 @@ export function reduceTableCellDataIntoRows( return tableData; } + +export function getTotalsRow( + config: PivotDataStoreConfig, + columnDimensionAxes: Record = {}, + totalsRowData: V1MetricsViewAggregationResponseDataItem[] = [], + globalTotalsData: V1MetricsViewAggregationResponseDataItem[] = [], +) { + const { rowDimensionNames, measureNames } = config; + const anchorDimensionName = rowDimensionNames[0]; + + let totalsRow: PivotDataRow = {}; + if (measureNames.length) { + const totalsRowTable = reduceTableCellDataIntoRows( + config, + "", + [], + columnDimensionAxes || {}, + [], + totalsRowData || [], + ); + + totalsRow = totalsRowTable[0] || {}; + + globalTotalsData.forEach((total) => { + totalsRow = { ...total, ...totalsRow }; + }); + + if (anchorDimensionName) { + totalsRow[anchorDimensionName] = "Total"; + } + } + + return totalsRow; +} diff --git a/web-common/src/features/dashboards/pivot/pivot-utils.ts b/web-common/src/features/dashboards/pivot/pivot-utils.ts index 184e989d69c..0f31caaa079 100644 --- a/web-common/src/features/dashboards/pivot/pivot-utils.ts +++ b/web-common/src/features/dashboards/pivot/pivot-utils.ts @@ -18,6 +18,7 @@ import type { import { getColumnFiltersForPage } from "./pivot-infinite-scroll"; import type { PivotAxesData, + PivotDataRow, PivotDataStoreConfig, PivotTimeConfig, TimeFilters, @@ -123,17 +124,23 @@ export function getTimeForQuery( } timeFilters.forEach((filter) => { - // FIXME: Fix type warnings. Are these false positives? - // Using `as` to avoid type warnings - const duration: Period = TIME_GRAIN[filter.interval]?.duration as Period; - const startTimeDt = new Date(filter.timeStart); + let startTimeOfLastInterval: Date | undefined = undefined; + + if (filter.timeEnd) { + startTimeOfLastInterval = new Date(filter.timeEnd); + } else { + startTimeOfLastInterval = startTimeDt; + } + + const duration = TIME_GRAIN[filter.interval]?.duration as Period; const endTimeDt = getOffset( - startTimeDt, + startTimeOfLastInterval, duration, TimeOffsetType.ADD, timeZone, ) as Date; + if (startTimeDt > new Date(timeStart as string)) { timeStart = filter.timeStart; } @@ -184,15 +191,8 @@ export function createIndexMap(arr: T[]): Map { * Returns total number of columns for the table * excluding row and group totals columns */ -export function getTotalColumnCount( - columnDimensionAxes: Record | undefined, -) { - if (!columnDimensionAxes) return 0; - - return Object.values(columnDimensionAxes).reduce( - (acc, columnDimension) => acc * columnDimension.length, - 1, - ); +export function getTotalColumnCount(totalsRow: PivotDataRow) { + return Object.keys(totalsRow).length; } /*** @@ -201,14 +201,15 @@ export function getTotalColumnCount( export function getFilterForPivotTable( config: PivotDataStoreConfig, colDimensionAxes: Record = {}, + totalsRow: PivotDataRow, rowDimensionValues: string[] = [], isInitialTable = false, anchorDimension: string | undefined = undefined, yLimit = 100, -): V1Expression { +) { // TODO: handle for already existing global filters - const { colDimensionNames, rowDimensionNames, time } = config; + const { rowDimensionNames, time } = config; let rowFilters: V1Expression | undefined; if ( @@ -222,19 +223,18 @@ export function getFilterForPivotTable( ); } - const colFiltersForPage = getColumnFiltersForPage( - colDimensionNames.filter( - (dimension) => !isTimeDimension(dimension, time.timeDimension), - ), + const { filters: colFiltersForPage, timeFilters } = getColumnFiltersForPage( + config, colDimensionAxes, - config.pivot.columnPage, - config.measureNames.length, + totalsRow, ); - return createAndExpression([ + const filters = createAndExpression([ ...colFiltersForPage, ...(rowFilters ? [rowFilters] : []), ]); + + return { filters, timeFilters }; } /** @@ -272,7 +272,7 @@ export function getAccessorForCell( /** * Extract the numbers after c and v in a accessor part string */ -function extractNumbers(str: string) { +export function extractNumbers(str: string) { const indexOfC = str.indexOf("c"); const indexOfV = str.indexOf("v"); @@ -282,6 +282,47 @@ function extractNumbers(str: string) { return { c: numberAfterC, v: numberAfterV }; } +export function sortAcessors(accessors: string[]) { + function parseParts(str: string): number[] { + // Extract all occurrences of patterns like cv + const matches = str.match(/c(\d+)v(\d+)/g); + if (!matches) { + return []; + } + // Map each found pattern to its numeric components + const parts: number[] = matches.flatMap((match) => { + const result = /c(\d+)v(\d+)/.exec(match); + if (!result) return []; + const [, cPart, vPart] = result; + return [parseInt(cPart, 10), parseInt(vPart, 10)]; // Convert to numbers for proper comparison + }); + + // Extract m part + const mPartMatch = str.match(/m(\d+)$/); + if (mPartMatch) { + parts.push(parseInt(mPartMatch[1], 10)); // Add m part as a number + } + return parts; + } + + return accessors.sort((a: string, b: string): number => { + const partsA = parseParts(a); + const partsB = parseParts(b); + + // Compare each part until a difference is found + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const partA = partsA[i] || 0; // Default to 0 if undefined + const partB = partsB[i] || 0; // Default to 0 if undefined + if (partA !== partB) { + return partA - partB; + } + } + + // If all parts are equal, consider them equal + return 0; + }); +} + /** * For a given accessor created by getAccessorForCell, get the filter * that can be applied to the table to get sorted data based on the diff --git a/web-common/src/features/dashboards/pivot/tests/pivot-utilts.test.ts b/web-common/src/features/dashboards/pivot/tests/pivot-utilts.test.ts new file mode 100644 index 00000000000..0d38a89223c --- /dev/null +++ b/web-common/src/features/dashboards/pivot/tests/pivot-utilts.test.ts @@ -0,0 +1,28 @@ +import { sortAcessors } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; +import { describe, expect, it } from "vitest"; + +describe("sortAcessors function", () => { + it("should correctly sort accessors with basic sorting", () => { + const input = ["c1v2m3", "c0v0m0", "c2v3m1"]; + const expected = ["c0v0m0", "c1v2m3", "c2v3m1"]; + expect(sortAcessors(input)).toEqual(expected); + }); + + it("should sort accessors with varying numbers of cv sequences", () => { + const input = ["c0v1_c1v2m0", "c0v0_c1v1m2", "c0v0_c1v0m1"]; + const expected = ["c0v0_c1v0m1", "c0v0_c1v1m2", "c0v1_c1v2m0"]; + expect(sortAcessors(input)).toEqual(expected); + }); + + it("should sort accessors with the same c-v values but different m values", () => { + const input = ["c0v1m3", "c0v1m1", "c0v1m2"]; + const expected = ["c0v1m1", "c0v1m2", "c0v1m3"]; + expect(sortAcessors(input)).toEqual(expected); + }); + + it("should sort accessors with multiple c-v-m sequences, including different-number lengths", () => { + const input = ["c1v10_c2v20m30", "c1v2_c2v3m4", "c1v10_c2v3m4"]; + const expected = ["c1v2_c2v3m4", "c1v10_c2v3m4", "c1v10_c2v20m30"]; + expect(sortAcessors(input)).toEqual(expected); + }); +}); diff --git a/web-common/src/features/dashboards/pivot/types.ts b/web-common/src/features/dashboards/pivot/types.ts index 2269e94c7ae..12422b31f5f 100644 --- a/web-common/src/features/dashboards/pivot/types.ts +++ b/web-common/src/features/dashboards/pivot/types.ts @@ -53,6 +53,8 @@ export interface PivotDataRow { export interface TimeFilters { timeStart: string; interval: V1TimeGrain; + // Time end represents the start time of the last interval for a range + timeEnd?: string; } export interface PivotTimeConfig {