diff --git a/dashboard/src/components/AnimatedIcons/Chevron.tsx b/dashboard/src/components/AnimatedIcons/Chevron.tsx index a08b4f444..bd295b708 100644 --- a/dashboard/src/components/AnimatedIcons/Chevron.tsx +++ b/dashboard/src/components/AnimatedIcons/Chevron.tsx @@ -2,8 +2,27 @@ import { MdChevronRight } from 'react-icons/md'; import type { JSX } from 'react'; -export const ChevronRightAnimate = (): JSX.Element => { +import { cn } from '@/lib/utils'; + +interface ChevronRightAnimateProps { + isExpanded?: boolean; + animated?: boolean; + className?: string; +} + +export const ChevronRightAnimate = ({ + isExpanded, + animated = true, + className, +}: ChevronRightAnimateProps): JSX.Element => { return ( - + ); }; diff --git a/dashboard/src/components/Table/BaseTable.tsx b/dashboard/src/components/Table/BaseTable.tsx index eb192121c..524620fe9 100644 --- a/dashboard/src/components/Table/BaseTable.tsx +++ b/dashboard/src/components/Table/BaseTable.tsx @@ -58,11 +58,13 @@ export const DumbBaseTable = ({ export const DumbTableHeader = ({ children, + className, }: { children: ReactNode; + className?: string; }): JSX.Element => { return ( - + {children} ); diff --git a/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx b/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx index 28c14e01a..1c976a0a6 100644 --- a/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx +++ b/dashboard/src/components/TestsTable/DefaultTestsColumns.tsx @@ -1,4 +1,4 @@ -import type { ColumnDef } from '@tanstack/react-table'; +import type { CellContext, ColumnDef } from '@tanstack/react-table'; import type { JSX } from 'react'; @@ -22,12 +22,43 @@ import { } from '@/components/Table/DetailsColumn'; import { UNKNOWN_STRING } from '@/utils/constants/backend'; +const INDENT_WIDTH = 20; + +const PathCell = ({ + row, + getValue, +}: CellContext): JSX.Element => { + const value = getValue() as string; + const depth = row.depth; + const indent = depth * INDENT_WIDTH; + + const hasSubGroups = + row.original.sub_groups !== undefined && row.original.sub_groups.length > 0; + const hasIndividualTests = row.original.individual_tests.length > 0; + const isExpandable = hasSubGroups || hasIndividualTests; + + return ( +
+ {isExpandable && ( + + + + )} + {value} +
+ ); +}; + export const defaultColumns: ColumnDef[] = [ { accessorKey: 'path_group', header: ({ column }): JSX.Element => ( ), + cell: PathCell, }, { accessorKey: 'pass_tests', @@ -52,10 +83,6 @@ export const defaultColumns: ColumnDef[] = [ ); }, }, - { - id: 'chevron', - cell: (): JSX.Element => , - }, ]; export const defaultInnerColumns: ColumnDef[] = [ diff --git a/dashboard/src/components/TestsTable/TestsTable.tsx b/dashboard/src/components/TestsTable/TestsTable.tsx index 0c6fc1689..0dee50265 100644 --- a/dashboard/src/components/TestsTable/TestsTable.tsx +++ b/dashboard/src/components/TestsTable/TestsTable.tsx @@ -7,13 +7,18 @@ import { flexRender, getCoreRowModel, getExpandedRowModel, - getFilteredRowModel, - getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { Fragment, useCallback, useMemo, useState, type JSX } from 'react'; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useState, + type JSX, +} from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -24,20 +29,15 @@ import { possibleTableFilters } from '@/types/tree/TreeDetails'; import type { TestHistory, TIndividualTest, TPathTests } from '@/types/general'; -import { StatusTable } from '@/utils/constants/database'; - import { TableBody, TableCell, TableRow } from '@/components/ui/table'; -import BaseTable, { TableHead } from '@/components/Table/BaseTable'; - -import { PaginationInfo } from '@/components/Table/PaginationInfo'; - -import { usePaginationState } from '@/hooks/usePaginationState'; +import { + DumbBaseTable, + DumbTableHeader, + TableHead, +} from '@/components/Table/BaseTable'; import type { TableKeys } from '@/utils/constants/tables'; -import { buildHardwareArray, buildTreeBranch } from '@/utils/table'; - -import { EMPTY_VALUE } from '@/lib/string'; import { TableTopFilters } from '@/components/Table/TableTopFilters'; @@ -45,6 +45,13 @@ import type { TStatusFilters } from '@/components/Table/TableStatusFilter'; import { IndividualTestsTable } from './IndividualTestsTable'; import { defaultColumns, defaultInnerColumns } from './DefaultTestsColumns'; +import { buildTestsTree } from './buildTestsTree'; +import { + pruneTree, + computeGlobalCounts, + matchByStatus, + matchByPathSubstring, +} from './filterTestsTree'; export interface ITestsTable { tableKey: TableKeys; @@ -58,47 +65,8 @@ export interface ITestsTable { currentPathFilter?: string; } -type TPathTestsStatus = Pick< - TPathTests, - | 'done_tests' - | 'error_tests' - | 'fail_tests' - | 'miss_tests' - | 'pass_tests' - | 'skip_tests' - | 'null_tests' - | 'total_tests' ->; - -const countStatus = (group: TPathTestsStatus, status?: string): void => { - group.total_tests++; - switch (status?.toUpperCase()) { - case StatusTable.DONE: - group.done_tests++; - break; - case StatusTable.ERROR: - group.error_tests++; - break; - case StatusTable.FAIL: - group.fail_tests++; - break; - case StatusTable.MISS: - group.miss_tests++; - break; - case StatusTable.PASS: - group.pass_tests++; - break; - case StatusTable.SKIP: - group.skip_tests++; - break; - default: - group.null_tests++; - } -}; - // TODO: would be useful if the navigation happened within the table, so the parent component would only be required to pass the navigation url instead of the whole function for the update and the currentPath diffFilter (boots/tests Table) export function TestsTable({ - tableKey, testHistory, onClickFilter, filter, @@ -110,173 +78,59 @@ export function TestsTable({ }: ITestsTable): JSX.Element { const [sorting, setSorting] = useState([]); const [expanded, setExpanded] = useState({}); - const [globalFilter, setGlobalFilter] = useState( + const [pathFilter, setPathFilter] = useState( currentPathFilter, ); - const { pagination, paginationUpdater } = usePaginationState(tableKey); - - const intl = useIntl(); - - const rawData = useMemo((): TPathTests[] => { - type Groups = { - [K: string]: TPathTests; - }; - const groups: Groups = {}; - if (testHistory !== undefined) { - testHistory.forEach(e => { - if (!e.path) { - e.path = EMPTY_VALUE; - } - const parts = e.path.split('.', 1); - const group = parts.length > 0 ? parts[0] : '-'; - if (!(group in groups)) { - groups[group] = { - done_tests: 0, - fail_tests: 0, - miss_tests: 0, - pass_tests: 0, - null_tests: 0, - skip_tests: 0, - error_tests: 0, - total_tests: 0, - path_group: group, - individual_tests: [], - }; - } - groups[group].individual_tests.push({ - id: e.id, - duration: e.duration?.toString() ?? '', - path: e.path, - start_time: e.start_time, - status: e.status, - hardware: buildHardwareArray( - e.environment_compatible, - e.environment_misc, - ), - treeBranch: buildTreeBranch(e.tree_name, e.git_repository_branch), - lab: e.lab, - }); - }); - } - return Object.values(groups); - }, [testHistory]); - const [globalStatusGroup, pathFilteredData] = useMemo((): [ - TPathTestsStatus, - TPathTests[], - ] => { - const path = globalFilter; - const isValidPath = path !== undefined && path !== ''; - const globalGroup: TPathTestsStatus = { - done_tests: 0, - fail_tests: 0, - miss_tests: 0, - pass_tests: 0, - null_tests: 0, - skip_tests: 0, - error_tests: 0, - total_tests: 0, - }; + // Sync pathFilter with URL changes (back/forward navigation, external links) + useEffect(() => { + setPathFilter(currentPathFilter); + }, [currentPathFilter]); - const filteredData = rawData.reduce((acc, test) => { - const localGroup: TPathTestsStatus = { - done_tests: 0, - fail_tests: 0, - miss_tests: 0, - pass_tests: 0, - null_tests: 0, - skip_tests: 0, - error_tests: 0, - total_tests: 0, - }; - const individualTest = test.individual_tests.filter(t => { - let dataIncludesPath = true; - if (isValidPath) { - dataIncludesPath = t.path?.includes(path) ?? false; - } - if (dataIncludesPath) { - countStatus(localGroup, t.status); - countStatus(globalGroup, t.status); - } - return dataIncludesPath; - }); + const intl = useIntl(); - if (individualTest.length > 0) { - acc.push({ - path_group: test.path_group, - individual_tests: individualTest, - ...localGroup, - }); - } + const rawTree = useMemo(() => buildTestsTree(testHistory), [testHistory]); - return acc; - }, []); + const pathFilteredTree = useMemo(() => { + if (!pathFilter) { + return rawTree; + } + return pruneTree(rawTree, { + matchTest: t => t.path?.includes(pathFilter) ?? false, + matchNodePath: matchByPathSubstring(pathFilter), + }); + }, [rawTree, pathFilter]); - return [globalGroup, filteredData]; - }, [globalFilter, rawData]); + const globalStatusGroup = useMemo( + () => computeGlobalCounts(pathFilteredTree), + [pathFilteredTree], + ); - const data = useMemo((): TPathTests[] => { - switch (filter) { - case 'all': - return pathFilteredData; - case 'success': - return pathFilteredData - ?.filter(tests => tests.pass_tests > 0) - .map(test => ({ - ...test, - individual_tests: test.individual_tests.filter( - t => t.status?.toUpperCase() === StatusTable.PASS, - ), - })); - case 'failed': - return pathFilteredData - ?.filter(tests => tests.fail_tests > 0) - .map(test => ({ - ...test, - individual_tests: test.individual_tests.filter( - t => t.status?.toUpperCase() === StatusTable.FAIL, - ), - })); - case 'inconclusive': - return pathFilteredData - ?.filter( - tests => - tests.done_tests > 0 || - tests.error_tests > 0 || - tests.miss_tests > 0 || - tests.skip_tests > 0 || - tests.null_tests > 0, - ) - .map(test => ({ - ...test, - individual_tests: test.individual_tests.filter(t => { - const uppercaseTestStatus = t.status?.toUpperCase(); - const result = - uppercaseTestStatus !== StatusTable.PASS && - uppercaseTestStatus !== StatusTable.FAIL; - return result; - }), - })); - } - }, [filter, pathFilteredData]); + const data = useMemo( + () => + filter === 'all' + ? pathFilteredTree + : pruneTree(pathFilteredTree, { matchTest: matchByStatus(filter) }), + [pathFilteredTree, filter], + ); const table = useReactTable({ data, columns, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onPaginationChange: paginationUpdater, getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getRowCanExpand: _ => true, + getSubRows: row => row.sub_groups, + getRowCanExpand: row => + (row.original.sub_groups !== undefined && + row.original.sub_groups.length > 0) || + row.original.individual_tests.length > 0, getExpandedRowModel: getExpandedRowModel(), onExpandedChange: setExpanded, - onGlobalFilterChange: setGlobalFilter, - getRowId: row => row.path_group, + getRowId: row => + row.path_prefix ? `${row.path_prefix}.${row.path_group}` : row.path_group, state: { sorting, - pagination, expanded, }, }); @@ -334,7 +188,7 @@ export function TestsTable({ const onSearchChange = useCallback( (e: React.ChangeEvent) => { - setGlobalFilter(String(e.target.value)); + setPathFilter(String(e.target.value)); if (updatePathFilter) { updatePathFilter(e.target.value); } @@ -364,37 +218,42 @@ export function TestsTable({ const modelRows = table.getRowModel().rows; const tableRows = useMemo((): JSX.Element[] | JSX.Element => { - return modelRows?.length ? ( - modelRows.map(row => ( - - { - if (row.getCanExpand()) { - row.toggleExpanded(); - } - }} - data-state={row.getIsExpanded() ? 'open' : 'closed'} - > - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - {row.getIsExpanded() && ( - - - - + return modelRows.length ? ( + modelRows.map(row => { + const hasIndividualTests = row.original.individual_tests.length > 0; + + return ( + + { + if (row.getCanExpand()) { + row.toggleExpanded(); + } + }} + data-state={row.getIsExpanded() ? 'open' : 'closed'} + data-depth={row.depth} + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} - )} - - )) + {row.getIsExpanded() && hasIndividualTests && ( + + + + + + )} + + ); + }) ) : ( @@ -402,7 +261,7 @@ export function TestsTable({ ); - }, [columns.length, data, getRowLink, innerColumns, modelRows]); + }, [columns.length, getRowLink, innerColumns, modelRows]); return (
@@ -413,10 +272,14 @@ export function TestsTable({ onSearchChange={onSearchChange} currentPathFilter={currentPathFilter} /> - - {tableRows} - - +
+ + + {tableHeaders} + + {tableRows} + +
); } diff --git a/dashboard/src/components/TestsTable/buildTestsTree.ts b/dashboard/src/components/TestsTable/buildTestsTree.ts new file mode 100644 index 000000000..44a94e448 --- /dev/null +++ b/dashboard/src/components/TestsTable/buildTestsTree.ts @@ -0,0 +1,107 @@ +import type { TestHistory, TPathTests } from '@/types/general'; +import { buildHardwareArray, buildTreeBranch } from '@/utils/table'; +import { EMPTY_VALUE } from '@/lib/string'; + +import { + type GroupNode, + type TPathTestsStatus, + countStatus, + createEmptyNode, + addCounts, +} from './testStatusHelpers'; + +export function buildTestsTree( + testHistory: TestHistory[] | undefined, +): TPathTests[] { + const rootGroups = new Map(); + + if (testHistory !== undefined) { + testHistory.forEach(e => { + const path = e.path || EMPTY_VALUE; + const segments = path === EMPTY_VALUE ? [EMPTY_VALUE] : path.split('.'); + + let currentLevel = rootGroups; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isLastSegment = i === segments.length - 1; + + if (!currentLevel.has(segment)) { + currentLevel.set(segment, createEmptyNode()); + } + + const node = currentLevel.get(segment)!; + + if (isLastSegment) { + countStatus(node, e.status); + node.individual_tests.push({ + id: e.id, + duration: e.duration?.toString() ?? '', + path, + start_time: e.start_time, + status: e.status, + hardware: buildHardwareArray( + e.environment_compatible, + e.environment_misc, + ), + treeBranch: buildTreeBranch(e.tree_name, e.git_repository_branch), + lab: e.lab, + }); + } else { + currentLevel = node.children; + } + } + }); + } + + return toTPathTests(rootGroups, ''); +} + +function toTPathTests( + groups: Map, + parentPath: string, +): TPathTests[] { + const result: TPathTests[] = []; + + groups.forEach((node, segment) => { + const fullPath = parentPath === '' ? segment : `${parentPath}.${segment}`; + + const subGroups = + node.children.size > 0 ? toTPathTests(node.children, fullPath) : []; + + const aggregatedCounts: TPathTestsStatus = { + done_tests: node.done_tests, + error_tests: node.error_tests, + fail_tests: node.fail_tests, + miss_tests: node.miss_tests, + pass_tests: node.pass_tests, + skip_tests: node.skip_tests, + null_tests: node.null_tests, + total_tests: node.total_tests, + }; + + subGroups.forEach(child => { + addCounts(aggregatedCounts, child); + }); + + const hasDirectTests = node.individual_tests.length > 0; + + result.push({ + done_tests: aggregatedCounts.done_tests, + error_tests: aggregatedCounts.error_tests, + fail_tests: aggregatedCounts.fail_tests, + miss_tests: aggregatedCounts.miss_tests, + pass_tests: aggregatedCounts.pass_tests, + null_tests: aggregatedCounts.null_tests, + skip_tests: aggregatedCounts.skip_tests, + total_tests: aggregatedCounts.total_tests, + path_group: segment, + path_prefix: parentPath, + individual_tests: node.individual_tests, + sub_groups: subGroups.length > 0 ? subGroups : undefined, + is_leaf_group: hasDirectTests || subGroups.length === 0, + }); + }); + + return result; +} diff --git a/dashboard/src/components/TestsTable/filterTestsTree.ts b/dashboard/src/components/TestsTable/filterTestsTree.ts new file mode 100644 index 000000000..8c6af4ffa --- /dev/null +++ b/dashboard/src/components/TestsTable/filterTestsTree.ts @@ -0,0 +1,122 @@ +import type { PossibleTableFilters } from '@/types/tree/TreeDetails'; +import type { TIndividualTest, TPathTests } from '@/types/general'; +import { StatusTable } from '@/utils/constants/database'; + +import { + type TPathTestsStatus, + countStatus, + createEmptyGroupStatusCounts, + addCounts, +} from './testStatusHelpers'; + +interface PrunePredicates { + matchTest: (t: TIndividualTest) => boolean; + matchNodePath?: (fullPath: string) => boolean; +} + +export function pruneTree( + nodes: TPathTests[], + predicates: PrunePredicates, +): TPathTests[] { + const results: TPathTests[] = []; + + nodes.forEach(node => { + const fullPath = node.path_prefix + ? `${node.path_prefix}.${node.path_group}` + : node.path_group; + + const nodeMatchesPath = predicates.matchNodePath?.(fullPath) ?? false; + + let filteredSubGroups: TPathTests[] | undefined; + let filteredIndividualTests: TIndividualTest[]; + + if (nodeMatchesPath) { + filteredSubGroups = node.sub_groups; + filteredIndividualTests = node.individual_tests; + } else { + filteredSubGroups = node.sub_groups + ? pruneTree(node.sub_groups, predicates) + : undefined; + filteredIndividualTests = node.individual_tests.filter( + predicates.matchTest, + ); + } + + const hasSubGroups = filteredSubGroups && filteredSubGroups.length > 0; + const hasIndividualTests = filteredIndividualTests.length > 0; + + if (!hasSubGroups && !hasIndividualTests) { + return; + } + + const localGroup: TPathTestsStatus = createEmptyGroupStatusCounts(); + + filteredIndividualTests.forEach(t => { + countStatus(localGroup, t.status); + }); + + if (nodeMatchesPath) { + addCounts(localGroup, node); + } else if (hasSubGroups) { + filteredSubGroups!.forEach(g => { + addCounts(localGroup, g); + }); + } + + results.push({ + ...node, + ...localGroup, + sub_groups: hasSubGroups ? filteredSubGroups : undefined, + individual_tests: filteredIndividualTests, + is_leaf_group: hasIndividualTests || !hasSubGroups, + }); + }); + + return results; +} + +export function computeGlobalCounts(nodes: TPathTests[]): TPathTestsStatus { + const globalGroup: TPathTestsStatus = createEmptyGroupStatusCounts(); + + function walk(nodesToWalk: TPathTests[]): void { + nodesToWalk.forEach(node => { + node.individual_tests.forEach(t => { + countStatus(globalGroup, t.status); + }); + if (node.sub_groups) { + walk(node.sub_groups); + } + }); + } + + walk(nodes); + return globalGroup; +} + +export const matchByStatus = + (filter: PossibleTableFilters) => + (t: TIndividualTest): boolean => { + const uppercaseStatus = t.status?.toUpperCase(); + switch (filter) { + case 'success': + return uppercaseStatus === StatusTable.PASS; + case 'failed': + return uppercaseStatus === StatusTable.FAIL; + case 'inconclusive': + return ( + uppercaseStatus !== StatusTable.PASS && + uppercaseStatus !== StatusTable.FAIL + ); + case 'all': + default: + return true; + } + }; + +export function matchByPathSubstring( + path: string, +): (fullPath: string) => boolean { + return function (fullPath: string): boolean { + return fullPath.includes(path); + }; +} diff --git a/dashboard/src/components/TestsTable/testStatusHelpers.ts b/dashboard/src/components/TestsTable/testStatusHelpers.ts new file mode 100644 index 000000000..0fca127ae --- /dev/null +++ b/dashboard/src/components/TestsTable/testStatusHelpers.ts @@ -0,0 +1,95 @@ +import type { TIndividualTest, TPathTests } from '@/types/general'; +import { StatusTable } from '@/utils/constants/database'; + +export type TPathTestsStatus = Pick< + TPathTests, + | 'done_tests' + | 'error_tests' + | 'fail_tests' + | 'miss_tests' + | 'pass_tests' + | 'skip_tests' + | 'null_tests' + | 'total_tests' +>; + +export type GroupNode = { + done_tests: number; + fail_tests: number; + miss_tests: number; + pass_tests: number; + null_tests: number; + skip_tests: number; + error_tests: number; + total_tests: number; + individual_tests: TIndividualTest[]; + children: Map; +}; + +export const countStatus = (group: TPathTestsStatus, status?: string): void => { + group.total_tests++; + switch (status?.toUpperCase()) { + case StatusTable.DONE: + group.done_tests++; + break; + case StatusTable.ERROR: + group.error_tests++; + break; + case StatusTable.FAIL: + group.fail_tests++; + break; + case StatusTable.MISS: + group.miss_tests++; + break; + case StatusTable.PASS: + group.pass_tests++; + break; + case StatusTable.SKIP: + group.skip_tests++; + break; + default: + group.null_tests++; + } +}; + +export const addCounts = ( + target: TPathTestsStatus, + source: TPathTestsStatus, +): void => { + target.done_tests += source.done_tests; + target.error_tests += source.error_tests; + target.fail_tests += source.fail_tests; + target.miss_tests += source.miss_tests; + target.pass_tests += source.pass_tests; + target.null_tests += source.null_tests; + target.skip_tests += source.skip_tests; + target.total_tests += source.total_tests; +}; + +export const pickCounts = (node: TPathTests): TPathTestsStatus => ({ + done_tests: node.done_tests, + error_tests: node.error_tests, + fail_tests: node.fail_tests, + miss_tests: node.miss_tests, + pass_tests: node.pass_tests, + null_tests: node.null_tests, + skip_tests: node.skip_tests, + total_tests: node.total_tests, +}); + +export const createEmptyGroupStatusCounts = (): TPathTestsStatus => ({ + done_tests: 0, + fail_tests: 0, + miss_tests: 0, + pass_tests: 0, + null_tests: 0, + skip_tests: 0, + error_tests: 0, + total_tests: 0, +}); + +export const createEmptyNode = (): GroupNode => ({ + ...createEmptyGroupStatusCounts(), + individual_tests: [], + children: new Map(), +}); diff --git a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx index f961b89db..7f9a4a5a5 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx @@ -105,6 +105,7 @@ const TestsTab = ({ }, }), params: params, + resetScroll: false, }); }, [navigate, params], diff --git a/dashboard/src/types/general.ts b/dashboard/src/types/general.ts index a092b39be..142cfb12e 100644 --- a/dashboard/src/types/general.ts +++ b/dashboard/src/types/general.ts @@ -17,6 +17,9 @@ export type TPathTests = { null_tests: number; total_tests: number; individual_tests: TIndividualTest[]; + sub_groups?: TPathTests[]; + path_prefix?: string; + is_leaf_group?: boolean; }; export type TIndividualTest = {