From a9efed50f07ddabfdba8ca7acb9542ad4d6200a7 Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Fri, 14 Aug 2026 15:55:08 -0300 Subject: [PATCH 1/2] fix(tests-table): stabilize columns and improve path/lab display Use table-fixed layout with string-based path width and horizontal scroll so columns stay stable while sorting, paginating, and virtualizing rows. Add prefix ellipsis for long paths, 12px tree indent, and group lab summaries matching hardware (count when mixed). Closes #420 Signed-off-by: Alan Peixinho --- dashboard/src/components/Table/BaseTable.tsx | 13 ++- .../TestsTable/DefaultTestsColumns.tsx | 86 +++++++++++-------- .../src/components/TestsTable/TestsTable.tsx | 71 ++++++++++++++- .../TestsTable/collapseTestsTree.test.ts | 39 ++++++++- .../components/TestsTable/groupSummaries.ts | 12 ++- dashboard/src/components/ui/table.tsx | 2 +- .../Tabs/Tests/HardwareDetailsTestsTable.tsx | 18 +--- 7 files changed, 182 insertions(+), 59 deletions(-) diff --git a/dashboard/src/components/Table/BaseTable.tsx b/dashboard/src/components/Table/BaseTable.tsx index 524620fe9..2aea45167 100644 --- a/dashboard/src/components/Table/BaseTable.tsx +++ b/dashboard/src/components/Table/BaseTable.tsx @@ -1,4 +1,10 @@ -import type { ComponentProps, ReactElement, ReactNode, JSX } from 'react'; +import type { + ComponentProps, + CSSProperties, + ReactElement, + ReactNode, + JSX, +} from 'react'; import classNames from 'classnames'; @@ -41,15 +47,18 @@ export const DumbBaseTable = ({ children, className, containerClassName, + style, }: { children: ReactNode; className?: string; containerClassName?: string; + style?: CSSProperties; }): JSX.Element => { return ( {children}
@@ -73,10 +82,12 @@ export const DumbTableHeader = ({ export const TableHead = ({ children, className, + ...props }: ComponentProps): JSX.Element => { return ( {children} diff --git a/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx b/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx index 8b1017669..46e50c837 100644 --- a/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx +++ b/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx @@ -30,9 +30,37 @@ import { UNKNOWN_STRING } from '@/utils/constants/backend'; import { getDateSortKey } from './groupSummaries'; import type { GroupFieldSummary, UnifiedTestRow } from './types'; -const INDENT_WIDTH = 20; +const INDENT_WIDTH = 12; +export const PATH_TREE_INDENT = INDENT_WIDTH; const MUTED_VALUE_CLASS = 'text-gray-500'; +export const PathWithPrefixEllipsis = ({ + value, +}: { + value: string; +}): JSX.Element => ( +
+ + {value} + +
+); + +export const PathWithTooltip = ({ value }: { value: string }): JSX.Element => ( + + +
+ +
+
+ {value} +
+); + export type SortDirectionGetter = (columnId: string) => false | 'asc' | 'desc'; const PathCell = ({ @@ -45,32 +73,30 @@ const PathCell = ({ const isLeaf = row.original.kind === 'leaf'; return ( -
+
{isExpandable && ( - + )} - {!isExpandable && row.depth > 0 && } + {!isExpandable && row.depth > 0 && } - - {isLeaf ? ( -
- + +
+ {isLeaf ? ( + + ) : ( +
{value} - -
- ) : ( -
- {value} -
- )} +
+ )} +
{value}
@@ -165,6 +191,7 @@ function renderUniformGroupCell( context: CellContext, value: unknown, ): ReactNode { + const columnId = getColumnId(column); const leafLikeOriginal = { ...context.row.original, kind: 'leaf' as const, @@ -176,6 +203,9 @@ function renderUniformGroupCell( row: { ...context.row, original: leafLikeOriginal, + // Leaf cells read row.getValue(id); the group row has no leaf field values. + getValue: (id: string) => + id === columnId ? value : context.row.getValue(id), } as Row, }); } @@ -402,23 +432,9 @@ export const defaultInnerColumns: ColumnDef[] = [ header: ({ column }): JSX.Element => ( ), - cell: ({ row }): JSX.Element => { - return ( - - -
- - {row.getValue('path')} - -
-
- {row.getValue('path')} -
- ); - }, + cell: ({ row }): JSX.Element => ( + + ), }, { accessorKey: 'status', diff --git a/dashboard/src/components/TestsTable/TestsTable.tsx b/dashboard/src/components/TestsTable/TestsTable.tsx index b61c54fa1..5dd8dd178 100644 --- a/dashboard/src/components/TestsTable/TestsTable.tsx +++ b/dashboard/src/components/TestsTable/TestsTable.tsx @@ -57,6 +57,7 @@ import WrapperTableWithLogSheet from '@/pages/TreeDetails/Tabs/WrapperTableWithL import { adaptColumnsForUnifiedTable, defaultInnerColumns, + PATH_TREE_INDENT, } from './DefaultTestsColumns'; import { buildTestsTree } from './buildTestsTree'; import { @@ -75,6 +76,41 @@ import type { UnifiedTestRow } from './types'; const ESTIMATED_ROW_HEIGHT = 60; const VIRTUALIZER_OVERSCAN = 5; +const PATH_CHEVRON_WIDTH = 24; +const OTHER_COLUMN_MIN_WIDTH = 88; + +const PATH_CHAR_WIDTH = 7.5; +const PATH_CELL_PADDING = 34; +const MIN_PATH_COLUMN_WIDTH = 120; +const MAX_PATH_COLUMN_WIDTH = 480; + +const pathColumnWidthFromStrings = (strings: string[], extra = 0): number => { + const longest = strings.reduce( + (max, value) => Math.max(max, value.length), + 0, + ); + return Math.round( + Math.min( + MAX_PATH_COLUMN_WIDTH, + Math.max( + MIN_PATH_COLUMN_WIDTH, + longest * PATH_CHAR_WIDTH + PATH_CELL_PADDING + extra, + ), + ), + ); +}; + +const maxTreeDepth = (rows: UnifiedTestRow[], depth = 0): number => { + let max = depth; + for (const row of rows) { + if (row.subRows?.length) { + max = Math.max(max, maxTreeDepth(row.subRows, depth + 1)); + } else { + max = Math.max(max, depth); + } + } + return max; +}; export interface ITestsTable { tableKey: TableKeys; @@ -155,6 +191,20 @@ export function TestsTable({ ); const data = groupingMode === 'ungrouped' ? flatData : groupedData; + const pathColumnWidth = useMemo(() => { + const paths = flatData.map(row => row.path); + const maxDepth = + groupingMode === 'ungrouped' ? 0 : maxTreeDepth(groupedData); + const indentExtra = + maxDepth * PATH_TREE_INDENT + (maxDepth > 0 ? PATH_CHEVRON_WIDTH : 0); + return pathColumnWidthFromStrings(paths, indentExtra); + }, [flatData, groupedData, groupingMode]); + + const tableMinWidth = useMemo( + () => pathColumnWidth + (columns.length - 1) * OTHER_COLUMN_MIN_WIDTH, + [columns.length, pathColumnWidth], + ); + useEffect(() => { setExpanded({}); }, [groupingMode, filteredTree]); @@ -270,12 +320,18 @@ export function TestsTable({ sorting, }); return ( - + {headerComponent} ); }); - }, [groupHeaders, sorting]); + }, [groupHeaders, sorting, pathColumnWidth]); const modelRows = table.getRowModel().rows; @@ -492,8 +548,15 @@ export function TestsTable({ currentPathFilter={currentPathFilter} groupingControls={groupingControls} /> -
- +
+ {tableHeaders} diff --git a/dashboard/src/components/TestsTable/collapseTestsTree.test.ts b/dashboard/src/components/TestsTable/collapseTestsTree.test.ts index ff3b414be..bcc909eb6 100644 --- a/dashboard/src/components/TestsTable/collapseTestsTree.test.ts +++ b/dashboard/src/components/TestsTable/collapseTestsTree.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { TestHistory } from '@/types/general'; import type { Status } from '@/types/database'; +import { UNKNOWN_STRING } from '@/utils/constants/backend'; import { buildTestsTree } from './buildTestsTree'; import { collapseSingleChildChains } from './collapseTestsTree'; @@ -102,12 +103,44 @@ describe('buildGroupSummaries', () => { it('marks uniform values and mixed counts', () => { const summaries = buildGroupSummaries([ - leaf({ id: '1', lab: 'lab-a', duration: '1s' }), - leaf({ id: '2', lab: 'lab-a', duration: '2s' }), + leaf({ id: '1', hardware: ['hw-a'], duration: '1s', lab: 'lab-b' }), + leaf({ id: '2', hardware: ['hw-a'], duration: '2s', lab: 'lab-a' }), ]); - expect(summaries.lab).toEqual({ kind: 'uniform', value: 'lab-a' }); + expect(summaries.hardware).toEqual({ kind: 'uniform', value: ['hw-a'] }); expect(summaries.duration).toEqual({ kind: 'mixed', count: 2 }); + expect(summaries.lab).toEqual({ kind: 'mixed', count: 2 }); + }); + + it('counts unique child labs', () => { + const summaries = buildGroupSummaries([ + leaf({ id: '1', lab: 'z-lab' }), + leaf({ id: '2', lab: 'a-lab' }), + leaf({ id: '3', lab: 'z-lab' }), + ]); + + expect(summaries.lab).toEqual({ kind: 'mixed', count: 2 }); + }); + + it('counts Unknown as a distinct lab', () => { + const summaries = buildGroupSummaries([ + leaf({ id: '1', lab: 'lab-a' }), + leaf({ id: '2' }), + ]); + + expect(summaries.lab).toEqual({ kind: 'mixed', count: 2 }); + }); + + it('shows Unknown when all child labs are missing', () => { + const summaries = buildGroupSummaries([ + leaf({ id: '1' }), + leaf({ id: '2' }), + ]); + + expect(summaries.lab).toEqual({ + kind: 'uniform', + value: UNKNOWN_STRING, + }); }); it('builds a date range from distinct start times', () => { diff --git a/dashboard/src/components/TestsTable/groupSummaries.ts b/dashboard/src/components/TestsTable/groupSummaries.ts index 79ffad65a..51d3c96d9 100644 --- a/dashboard/src/components/TestsTable/groupSummaries.ts +++ b/dashboard/src/components/TestsTable/groupSummaries.ts @@ -1,9 +1,10 @@ +import { UNKNOWN_STRING } from '@/utils/constants/backend'; + import type { GroupFieldSummary, UnifiedTestRow } from './types'; const AGGREGATED_FIELDS = [ 'start_time', 'duration', - 'lab', 'hardware', 'treeBranch', ] as const; @@ -78,6 +79,13 @@ function summarizeDates(values: unknown[]): GroupFieldSummary { return { kind: 'dateRange', min: min.value, max: max.value }; } +function normalizeLab(value: unknown): string { + if (value === undefined || value === null || value === '') { + return UNKNOWN_STRING; + } + return String(value); +} + export function buildGroupSummaries( children: UnifiedTestRow[], ): Partial> { @@ -92,6 +100,8 @@ export function buildGroupSummaries( : summarizeGeneric(values); } + summaries.lab = summarizeGeneric(leaves.map(leaf => normalizeLab(leaf.lab))); + return summaries; } diff --git a/dashboard/src/components/ui/table.tsx b/dashboard/src/components/ui/table.tsx index 5204fb664..9f155bd9b 100644 --- a/dashboard/src/components/ui/table.tsx +++ b/dashboard/src/components/ui/table.tsx @@ -114,7 +114,7 @@ const TableCellWithLink = React.forwardRef< className={cn('align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} > - + {children} diff --git a/dashboard/src/pages/hardwareDetails/Tabs/Tests/HardwareDetailsTestsTable.tsx b/dashboard/src/pages/hardwareDetails/Tabs/Tests/HardwareDetailsTestsTable.tsx index 87b28c61e..106e5ff3a 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/Tests/HardwareDetailsTestsTable.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/Tests/HardwareDetailsTestsTable.tsx @@ -4,8 +4,7 @@ import { useCallback, type JSX } from 'react'; import type { LinkProps } from '@tanstack/react-router'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/Tooltip'; - +import { PathWithTooltip } from '@/components/TestsTable/DefaultTestsColumns'; import type { ITestsTable } from '@/components/TestsTable/TestsTable'; import { TestsTable } from '@/components/TestsTable/TestsTable'; import { TableHeader } from '@/components/Table/TableHeader'; @@ -25,18 +24,9 @@ const innerColumns: ColumnDef[] = [ header: ({ column }): JSX.Element => ( ), - cell: ({ row }): JSX.Element => { - return ( - - -
- {row.getValue('path')} -
-
- {row.getValue('path')} -
- ); - }, + cell: ({ row }): JSX.Element => ( + + ), }, { accessorKey: 'treeBranch', From e34f51575757ebc3f519c40f06daf6b66606e2df Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Wed, 26 Aug 2026 17:06:10 -0300 Subject: [PATCH 2/2] fix(tests-table): restore keyboard focus on path tooltips and cell links Replace asChild div triggers with buttons so collapsed groups and leaf path tooltips are reachable by Tab. Inset cell-link focus rings so they are not clipped by overflow-hidden table cells. Signed-off-by: Alan Peixinho --- .../components/TestsTable/DefaultTestsColumns.tsx | 12 ++++++++---- dashboard/src/components/ui/table.tsx | 10 +++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx b/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx index 46e50c837..8c21397aa 100644 --- a/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx +++ b/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx @@ -53,9 +53,9 @@ export const PathWithPrefixEllipsis = ({ export const PathWithTooltip = ({ value }: { value: string }): JSX.Element => ( -
+
+
{value}
@@ -88,7 +88,11 @@ const PathCell = ({ {!isExpandable && row.depth > 0 && } -
+ {/* Activation bubbles to the row's onClick, which toggles groups. */} +
)} -
+ {value} diff --git a/dashboard/src/components/ui/table.tsx b/dashboard/src/components/ui/table.tsx index 9f155bd9b..27799edd1 100644 --- a/dashboard/src/components/ui/table.tsx +++ b/dashboard/src/components/ui/table.tsx @@ -114,7 +114,15 @@ const TableCellWithLink = React.forwardRef< className={cn('align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} > - + {children}