TableNG: Filter and sort sub tables (#104327)

* feat: filter and sort sub tables

* chore: extract row processing into it's own function for filtering and sorting

---------

Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
This commit is contained in:
Alex Spencer 2025-05-06 15:45:26 -06:00 committed by GitHub
parent bf918976b2
commit 2c1851e8c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 83 additions and 23 deletions

View File

@ -56,6 +56,7 @@ import {
getTextAlign,
handleSort,
MapFrameToGridOptions,
processNestedTableRows,
shouldTextOverflow,
} from './utils';
@ -268,14 +269,6 @@ export function TableNG(props: TableNGProps) {
[textWraps, columnTypes, getColumnWidths, headersLength, fieldDisplayType]
);
const getDisplayedValue = (row: TableRow, key: string) => {
const field = props.data.fields.find((field) => {
return getDisplayName(field) === key;
})!;
const displayedValue = formattedValueToString(field.display!(row[key]));
return displayedValue;
};
// Filter rows
const filteredRows = useMemo(() => {
const filterValues = Object.entries(filter);
@ -285,6 +278,13 @@ export function TableNG(props: TableNGProps) {
return rows;
}
// Helper function to get displayed value
const getDisplayedValue = (row: TableRow, key: string) => {
const field = props.data.fields.find((field) => field.name === key)!;
const displayedValue = formattedValueToString(field.display!(row[key]));
return displayedValue;
};
// Update crossFilterOrder
const filterKeys = new Set(filterValues.map(([key]) => key));
filterKeys.forEach((key) => {
@ -300,6 +300,28 @@ export function TableNG(props: TableNGProps) {
// reset crossFilterRows
crossFilterRows.current = {};
// For nested tables, only filter parent rows and keep their children
if (isNestedTable) {
return processNestedTableRows(rows, (parents) =>
parents.filter((row) => {
for (const [key, value] of filterValues) {
const displayedValue = getDisplayedValue(row, key);
if (!value.filteredSet.has(displayedValue)) {
return false;
}
// collect rows for crossFilter
if (!crossFilterRows.current[key]) {
crossFilterRows.current[key] = [row];
} else {
crossFilterRows.current[key].push(row);
}
}
return true;
})
);
}
// Regular filtering for non-nested tables
return rows.filter((row) => {
for (const [key, value] of filterValues) {
const displayedValue = getDisplayedValue(row, key);
@ -315,35 +337,38 @@ export function TableNG(props: TableNGProps) {
}
return true;
});
}, [rows, filter, props.data.fields]); // eslint-disable-line react-hooks/exhaustive-deps
}, [rows, filter, isNestedTable, props.data.fields]);
// Sort rows
const sortedRows = useMemo(() => {
const comparators = sortColumns.map(({ columnKey }) => getComparator(columnTypes[columnKey]));
const sortDirs = sortColumns.map(({ direction }) => (direction === 'ASC' ? 1 : -1));
if (sortColumns.length === 0) {
return filteredRows;
}
return filteredRows.slice().sort((a, b) => {
// Common sort comparator function
const compareRows = (a: TableRow, b: TableRow): number => {
let result = 0;
let sortIndex = 0;
for (const { columnKey } of sortColumns) {
const compare = comparators[sortIndex];
result = sortDirs[sortIndex] * compare(a[columnKey], b[columnKey]);
for (let i = 0; i < sortColumns.length; i++) {
const { columnKey, direction } = sortColumns[i];
const compare = getComparator(columnTypes[columnKey]);
const sortDir = direction === 'ASC' ? 1 : -1;
result = sortDir * compare(a[columnKey], b[columnKey]);
if (result !== 0) {
break;
}
sortIndex += 1;
}
return result;
});
}, [filteredRows, sortColumns, columnTypes]);
};
// Handle nested tables
if (isNestedTable) {
return processNestedTableRows(filteredRows, (parents) => [...parents].sort(compareRows));
}
// Regular sort for tables without nesting
return filteredRows.slice().sort((a, b) => compareRows(a, b));
}, [filteredRows, sortColumns, columnTypes, isNestedTable]);
// Paginated rows
// TODO consolidate calculations into pagination wrapper component and only use when needed

View File

@ -600,6 +600,41 @@ export function migrateTableDisplayModeToCellOptions(displayMode: TableCellDispl
export const getIsNestedTable = (dataFrame: DataFrame): boolean =>
dataFrame.fields.some(({ type }) => type === FieldType.nestedFrames);
/** Processes nested table rows */
export const processNestedTableRows = (
rows: TableRow[],
processParents: (parents: TableRow[]) => TableRow[]
): TableRow[] => {
// Separate parent and child rows
// Array for parentRows: enables sorting and maintains order for iteration
// Map for childRows: provides O(1) lookup by parent index when reconstructing the result
const parentRows: TableRow[] = [];
const childRows: Map<number, TableRow> = new Map();
rows.forEach((row) => {
if (Number(row.__depth) === 0) {
parentRows.push(row);
} else {
childRows.set(Number(row.__index), row);
}
});
// Process parent rows (filter or sort)
const processedParents = processParents(parentRows);
// Reconstruct the result
const result: TableRow[] = [];
processedParents.forEach((row) => {
result.push(row);
const childRow = childRows.get(Number(row.__index));
if (childRow) {
result.push(childRow);
}
});
return result;
};
export const getDisplayName = (field: Field): string => {
return field.state?.displayName ?? field.name;
};