From c2d144a1fa51b10be00b2ad96c170d6026ea086e Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Fri, 18 Sep 2026 13:48:25 -0300 Subject: [PATCH] feat: add multi-select table status filters * Multi-select success/failed/inconclusive pills with shared FilterButton styling * All control toggles full selection or clears every status filter * Persist tableFilter as URL arrays with minify support and legacy query compatibility * Apply filters across builds, boots, and tests tables and related detail pages * Improve TableTopFilters layout for narrow viewports Closes #1428 Signed-off-by: Alan Peixinho --- .../src/components/BootsTable/BootsTable.tsx | 78 +++++---------- .../components/BuildDetails/BuildDetails.tsx | 8 +- .../BuildDetails/BuildDetailsTestSection.tsx | 8 +- .../components/BuildsTable/BuildsTable.tsx | 68 +++++-------- .../BuildsTable/DefaultBuildsColumns.tsx | 3 +- .../src/components/Button/FilterButton.tsx | 26 +++++ .../components/IssueDetails/IssueDetails.tsx | 14 +-- .../IssueDetails/IssueDetailsBuildSection.tsx | 8 +- .../IssueDetails/IssueDetailsTestSection.tsx | 8 +- .../components/Table/TableStatusFilter.tsx | 99 ++++++++++--------- .../src/components/Table/TableTopFilters.tsx | 31 +++--- .../src/components/TestsTable/TestsTable.tsx | 72 +++++--------- .../components/TestsTable/filterTestsTree.ts | 30 ++---- dashboard/src/locales/messages/index.ts | 2 + .../src/pages/BuildDetails/BuildDetails.tsx | 15 ++- .../HardwareBuildDetails.tsx | 14 ++- .../src/pages/IssueDetails/IssueDetails.tsx | 29 ++++-- .../TreeBuildDetails/TreeBuildDetails.tsx | 14 ++- .../components/CompareStatusPairFilter.tsx | 34 ++----- .../pages/TreeDetails/Tabs/Boots/BootsTab.tsx | 14 ++- .../Tabs/Build/TreeDetailsBuildsTable.tsx | 14 ++- .../pages/TreeDetails/Tabs/Tests/TestsTab.tsx | 14 ++- .../hardwareDetails/Tabs/Boots/BootsTab.tsx | 15 ++- .../Tabs/Boots/HardwareDetailsBootsTable.tsx | 14 +-- .../Tabs/Build/HardwareDetailsBuildsTable.tsx | 14 ++- .../Tabs/Tests/HardwareDetailsTestsTable.tsx | 4 +- .../hardwareDetails/Tabs/Tests/TestsTab.tsx | 15 ++- dashboard/src/types/tree/TreeDetails.tsx | 52 +++++----- dashboard/src/utils/search.test.ts | 26 ++--- dashboard/src/utils/search.ts | 14 +++ dashboard/src/utils/tableStatusFilter.test.ts | 39 ++++++++ dashboard/src/utils/tableStatusFilter.ts | 69 +++++++++++++ 32 files changed, 503 insertions(+), 362 deletions(-) create mode 100644 dashboard/src/components/Button/FilterButton.tsx create mode 100644 dashboard/src/utils/tableStatusFilter.test.ts create mode 100644 dashboard/src/utils/tableStatusFilter.ts diff --git a/dashboard/src/components/BootsTable/BootsTable.tsx b/dashboard/src/components/BootsTable/BootsTable.tsx index 4a7cd7841..0ba83b1f2 100644 --- a/dashboard/src/components/BootsTable/BootsTable.tsx +++ b/dashboard/src/components/BootsTable/BootsTable.tsx @@ -15,10 +15,11 @@ import { FormattedMessage, useIntl } from 'react-intl'; import type { TestByCommitHash, - PossibleTableFilters, + TableStatusOption, + TableStatusSelection, + TableStatusToggleValue, TTestByCommitHashResponse, } from '@/types/tree/TreeDetails'; -import { possibleTableFilters } from '@/types/tree/TreeDetails'; import { getStatusGroup } from '@/utils/status'; @@ -53,7 +54,8 @@ import TooltipHardware from '@/components/Table/TooltipHardware'; import { EMPTY_VALUE } from '@/lib/string'; import { UNKNOWN_STRING } from '@/utils/constants/backend'; import { TableTopFilters } from '@/components/Table/TableTopFilters'; -import type { TStatusFilters } from '@/components/Table/TableStatusFilter'; +import { buildStatusFilterChips } from '@/components/Table/TableStatusFilter'; +import { tableStatusFilterValueForColumn } from '@/utils/tableStatusFilter'; const defaultColumns: ColumnDef[] = [ { @@ -65,7 +67,8 @@ const defaultColumns: ColumnDef[] = [ { accessorKey: 'status', filterFn: (row, columnId, filterValue) => - getStatusGroup(row.getValue(columnId)) === filterValue, + !Array.isArray(filterValue) || + filterValue.includes(getStatusGroup(row.getValue(columnId))), header: ({ column }): JSX.Element => ( [] = [ interface IBootsTable { tableKey: TableKeys; testHistory?: TestHistory[]; - filter: PossibleTableFilters; + filter: TableStatusSelection; columns?: ColumnDef[]; getRowLink: (testId: TestHistory['id']) => LinkProps; - onClickFilter: (newFilter: PossibleTableFilters) => void; + onToggleFilter: (option: TableStatusToggleValue) => void; updatePathFilter?: (pathFilter: string) => void; currentPathFilter?: string; } @@ -142,7 +145,7 @@ export function BootsTable({ filter, columns = defaultColumns, getRowLink, - onClickFilter, + onToggleFilter, updatePathFilter, currentPathFilter, }: IBootsTable): JSX.Element { @@ -203,9 +206,8 @@ export function BootsTable({ }, }); - const filterCount: Record = useMemo(() => { - const count: Record = { - all: 0, + const filterCount: Record = useMemo(() => { + const count: Record = { success: 0, failed: 0, inconclusive: 0, @@ -217,61 +219,34 @@ export function BootsTable({ const dataFilter = globalFilter ? rowsOriginal : testsData; - count.all = dataFilter.length; dataFilter.forEach(test => count[getStatusGroup(test.status)]++); return count; }, [testsData, globalFilter, table]); - const checkIfFilterIsSelected = useCallback( - (possibleFilter: PossibleTableFilters): boolean => { - return possibleFilter === filter; - }, - [filter], - ); - - const filters: TStatusFilters[] = useMemo( - () => [ - { - label: intl.formatMessage( - { id: 'global.allCount' }, - { count: filterCount[possibleTableFilters[0]] }, - ), - value: possibleTableFilters[0], - isSelected: checkIfFilterIsSelected(possibleTableFilters[0]), - }, - { - label: intl.formatMessage( + const chips = useMemo( + () => + buildStatusFilterChips({ + success: intl.formatMessage( { id: 'global.successCount' }, - { count: filterCount[possibleTableFilters[1]] }, + { count: filterCount.success }, ), - value: possibleTableFilters[1], - isSelected: checkIfFilterIsSelected(possibleTableFilters[1]), - }, - { - label: intl.formatMessage( + failed: intl.formatMessage( { id: 'global.failedCount' }, - { count: filterCount[possibleTableFilters[2]] }, + { count: filterCount.failed }, ), - value: possibleTableFilters[2], - isSelected: checkIfFilterIsSelected(possibleTableFilters[2]), - }, - { - label: intl.formatMessage( + inconclusive: intl.formatMessage( { id: 'global.inconclusiveCount' }, - { count: filterCount[possibleTableFilters[3]] }, + { count: filterCount.inconclusive }, ), - value: possibleTableFilters[3], - isSelected: checkIfFilterIsSelected(possibleTableFilters[3]), - }, - ], - [intl, filterCount, checkIfFilterIsSelected], + }), + [intl, filterCount], ); useEffect(() => { table .getColumn('status') - ?.setFilterValue(filter !== 'all' ? filter : undefined); + ?.setFilterValue(tableStatusFilterValueForColumn(filter)); }, [filter, table]); const onSearchChange = useCallback( @@ -407,8 +382,9 @@ export function BootsTable({ > diff --git a/dashboard/src/components/BuildDetails/BuildDetails.tsx b/dashboard/src/components/BuildDetails/BuildDetails.tsx index 6dc472a8c..15a801c8a 100644 --- a/dashboard/src/components/BuildDetails/BuildDetails.tsx +++ b/dashboard/src/components/BuildDetails/BuildDetails.tsx @@ -24,7 +24,7 @@ import { Sheet } from '@/components/Sheet'; import type { TableFilter, - PossibleTableFilters, + TableStatusToggleValue, } from '@/types/tree/TreeDetails'; import type { @@ -63,14 +63,14 @@ import BuildDetailsTestSection from './BuildDetailsTestSection'; interface BuildDetailsProps { breadcrumb?: JSX.Element; - onClickFilter: (filter: PossibleTableFilters) => void; + onToggleFilter: (option: TableStatusToggleValue) => void; tableFilter: TableFilter; getTestTableRowLink: (testId: string) => LinkProps; } const BuildDetails = ({ breadcrumb, - onClickFilter, + onToggleFilter, tableFilter, getTestTableRowLink, }: BuildDetailsProps): JSX.Element => { @@ -341,7 +341,7 @@ const BuildDetails = ({ diff --git a/dashboard/src/components/BuildDetails/BuildDetailsTestSection.tsx b/dashboard/src/components/BuildDetails/BuildDetailsTestSection.tsx index 5ce592d91..c75446dca 100644 --- a/dashboard/src/components/BuildDetails/BuildDetailsTestSection.tsx +++ b/dashboard/src/components/BuildDetails/BuildDetailsTestSection.tsx @@ -10,7 +10,7 @@ import { useBuildTests } from '@/api/buildTests'; import type { TableFilter, - PossibleTableFilters, + TableStatusToggleValue, } from '@/types/tree/TreeDetails'; import { TestsTable } from '@/components/TestsTable/TestsTable'; @@ -20,14 +20,14 @@ import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; interface IBuildDetailsTestSection { buildId: string; - onClickFilter: (filter: PossibleTableFilters) => void; + onToggleFilter: (option: TableStatusToggleValue) => void; tableFilter: TableFilter; getRowLink: (testId: string) => LinkProps; } const BuildDetailsTestSection = ({ buildId, - onClickFilter, + onToggleFilter, tableFilter, getRowLink, }: IBuildDetailsTestSection): JSX.Element => { @@ -56,7 +56,7 @@ const BuildDetailsTestSection = ({ diff --git a/dashboard/src/components/BuildsTable/BuildsTable.tsx b/dashboard/src/components/BuildsTable/BuildsTable.tsx index 0c0f818c5..163bf1096 100644 --- a/dashboard/src/components/BuildsTable/BuildsTable.tsx +++ b/dashboard/src/components/BuildsTable/BuildsTable.tsx @@ -18,9 +18,10 @@ import BaseTable, { TableHead } from '@/components/Table/BaseTable'; import { PaginationInfo } from '@/components/Table/PaginationInfo'; import { TableBody, TableCell, TableRow } from '@/components/ui/table'; import { - possibleTableFilters, type AccordionItemBuilds, - type PossibleTableFilters, + type TableStatusOption, + type TableStatusSelection, + type TableStatusToggleValue, } from '@/types/tree/TreeDetails'; import WrapperTableWithLogSheet from '@/pages/TreeDetails/Tabs/WrapperTableWithLogSheet'; @@ -39,7 +40,8 @@ import { getBuildStatusGroup } from '@/utils/status'; import { TableTopFilters } from '@/components/Table/TableTopFilters'; -import type { TStatusFilters } from '@/components/Table/TableStatusFilter'; +import { buildStatusFilterChips } from '@/components/Table/TableStatusFilter'; +import { tableStatusFilterValueForColumn } from '@/utils/tableStatusFilter'; import { defaultBuildColumns } from './DefaultBuildsColumns'; @@ -47,8 +49,8 @@ export interface IBuildsTable { tableKey: TableKeys; buildItems: AccordionItemBuilds[]; columns?: ColumnDef[]; - filter: PossibleTableFilters; - onClickFilter: (filter: PossibleTableFilters) => void; + filter: TableStatusSelection; + onToggleFilter: (option: TableStatusToggleValue) => void; getRowLink: (buildId: string) => LinkProps; sortKey?: string; } @@ -60,7 +62,7 @@ export function BuildsTable({ buildItems, columns = defaultBuildColumns, filter, - onClickFilter, + onToggleFilter, getRowLink, sortKey, }: IBuildsTable): JSX.Element { @@ -110,68 +112,47 @@ export function BuildsTable({ const { globalFilter } = table.getState(); - const filterCount: Record = useMemo(() => { + const filterCount: Record = useMemo(() => { const rowsOriginal = table .getPrePaginationRowModel() .rows.map(row => row.original); const dataFilter = globalFilter ? rowsOriginal : rawData; - const count: Record = { - all: 0, + const count: Record = { success: 0, failed: 0, inconclusive: 0, }; - count.all = dataFilter ? dataFilter.length : 0; dataFilter.forEach(build => count[getBuildStatusGroup(build.status)]++); return count; }, [rawData, globalFilter, table]); - const filters: TStatusFilters[] = useMemo( - () => [ - { - label: intl.formatMessage( - { id: 'global.allCount' }, - { count: filterCount[possibleTableFilters[0]] }, - ), - value: possibleTableFilters[0], - isSelected: filter === possibleTableFilters[0], - }, - { - label: intl.formatMessage( + const chips = useMemo( + () => + buildStatusFilterChips({ + success: intl.formatMessage( { id: 'global.successCount' }, - { count: filterCount[possibleTableFilters[1]] }, + { count: filterCount.success }, ), - value: possibleTableFilters[1], - isSelected: filter === possibleTableFilters[1], - }, - { - label: intl.formatMessage( + failed: intl.formatMessage( { id: 'global.failedCount' }, - { count: filterCount[possibleTableFilters[2]] }, + { count: filterCount.failed }, ), - value: possibleTableFilters[2], - isSelected: filter === possibleTableFilters[2], - }, - { - label: intl.formatMessage( + inconclusive: intl.formatMessage( { id: 'global.inconclusiveCount' }, - { count: filterCount[possibleTableFilters[3]] }, + { count: filterCount.inconclusive }, ), - value: possibleTableFilters[3], - isSelected: filter === possibleTableFilters[3], - }, - ], - [intl, filterCount, filter], + }), + [intl, filterCount], ); useEffect(() => { table .getColumn('status') - ?.setFilterValue(filter !== 'all' ? filter : undefined); + ?.setFilterValue(tableStatusFilterValueForColumn(filter)); }, [filter, table]); const onSearchChange = useCallback( @@ -306,8 +287,9 @@ export function BuildsTable({ > diff --git a/dashboard/src/components/BuildsTable/DefaultBuildsColumns.tsx b/dashboard/src/components/BuildsTable/DefaultBuildsColumns.tsx index c0bff2686..8d1c0a8f9 100644 --- a/dashboard/src/components/BuildsTable/DefaultBuildsColumns.tsx +++ b/dashboard/src/components/BuildsTable/DefaultBuildsColumns.tsx @@ -94,7 +94,8 @@ export const defaultBuildColumns: ColumnDef[] = [ : 'NULL'; }, filterFn: (row, columnId, filterValue) => - getBuildStatusGroup(row.getValue(columnId)) === filterValue, + !Array.isArray(filterValue) || + filterValue.includes(getBuildStatusGroup(row.getValue(columnId))), }, { id: DETAILS_COLUMN_ID, diff --git a/dashboard/src/components/Button/FilterButton.tsx b/dashboard/src/components/Button/FilterButton.tsx new file mode 100644 index 000000000..3a62574ef --- /dev/null +++ b/dashboard/src/components/Button/FilterButton.tsx @@ -0,0 +1,26 @@ +import type { ButtonHTMLAttributes, JSX } from 'react'; + +import { cn } from '@/lib/utils'; + +interface IFilterButton extends ButtonHTMLAttributes { + selected: boolean; +} + +export const FilterButton = ({ + selected, + className, + ...props +}: IFilterButton): JSX.Element => ( + - )), - [filters, onClickFilter], - ); return ( -
- +
+ + {allStatuses && ( + + {' '} + + + )} - {filterButtons} +
+ onToggle('all')}> + + + {chips.map(chip => ( + onToggle(chip.value)} + > + {chip.label} + + ))} +
); }; export default TableStatusFilter; + +export const buildStatusFilterChips = ( + labels: Record, +): TStatusFilterChip[] => + tableStatusOptions.map(value => ({ + value, + label: labels[value], + })); diff --git a/dashboard/src/components/Table/TableTopFilters.tsx b/dashboard/src/components/Table/TableTopFilters.tsx index 44df8963d..0f5c3b7eb 100644 --- a/dashboard/src/components/Table/TableTopFilters.tsx +++ b/dashboard/src/components/Table/TableTopFilters.tsx @@ -1,11 +1,14 @@ import type { JSX } from 'react'; import { useIntl } from 'react-intl'; -import type { PossibleTableFilters } from '@/types/tree/TreeDetails'; +import type { + TableStatusSelection, + TableStatusToggleValue, +} from '@/types/tree/TreeDetails'; import DebounceInput from '@/components/DebounceInput/DebounceInput'; -import type { TStatusFilters } from './TableStatusFilter'; +import type { TStatusFilterChip } from './TableStatusFilter'; import TableStatusFilter from './TableStatusFilter'; import { TableGroupingControls, @@ -13,16 +16,18 @@ import { } from './TableGroupingControls'; interface ITableTopFilters { - filters: TStatusFilters[]; - onClickFilter: (newFilter: PossibleTableFilters) => void; + chips: TStatusFilterChip[]; + selection: TableStatusSelection; + onToggleFilter: (option: TableStatusToggleValue) => void; currentPathFilter?: string; onSearchChange: (e: React.ChangeEvent) => void; groupingControls?: ITableGroupingControls; } export function TableTopFilters({ - filters, - onClickFilter, + chips, + selection, + onToggleFilter, currentPathFilter, onSearchChange, groupingControls, @@ -30,19 +35,23 @@ export function TableTopFilters({ const intl = useIntl(); return ( -
-
- +
+
+
{groupingControls && ( -
+
)} diff --git a/dashboard/src/components/TestsTable/TestsTable.tsx b/dashboard/src/components/TestsTable/TestsTable.tsx index 2fe6e0d0f..00f09ff38 100644 --- a/dashboard/src/components/TestsTable/TestsTable.tsx +++ b/dashboard/src/components/TestsTable/TestsTable.tsx @@ -27,8 +27,10 @@ import { FormattedMessage, useIntl } from 'react-intl'; import type { LinkProps } from '@tanstack/react-router'; -import type { PossibleTableFilters } from '@/types/tree/TreeDetails'; -import { possibleTableFilters } from '@/types/tree/TreeDetails'; +import type { + TableStatusSelection, + TableStatusToggleValue, +} from '@/types/tree/TreeDetails'; import type { TestHistory, TIndividualTest } from '@/types/general'; @@ -44,7 +46,7 @@ import type { TableKeys } from '@/utils/constants/tables'; import { TableTopFilters } from '@/components/Table/TableTopFilters'; -import type { TStatusFilters } from '@/components/Table/TableStatusFilter'; +import { buildStatusFilterChips } from '@/components/Table/TableStatusFilter'; import type { TableGroupingMode } from '@/components/Table/TableGroupingControls'; @@ -65,7 +67,7 @@ import { buildTestsTree } from './buildTestsTree'; import { pruneTree, computeGlobalCounts, - matchByStatus, + matchByStatuses, matchByPathSubstring, matchTestByPathSubstring, } from './filterTestsTree'; @@ -117,8 +119,8 @@ const maxTreeDepth = (rows: UnifiedTestRow[], depth = 0): number => { export interface ITestsTable { tableKey: TableKeys; testHistory?: TestHistory[]; - onClickFilter: (filter: PossibleTableFilters) => void; - filter: PossibleTableFilters; + onToggleFilter: (option: TableStatusToggleValue) => void; + filter: TableStatusSelection; innerColumns?: ColumnDef[]; getRowLink: (testId: TestHistory['id']) => LinkProps; updatePathFilter?: (pathFilter: string) => void; @@ -130,7 +132,7 @@ const DEFAULT_TESTS_SORTING: SortingState = [{ id: 'path_group', desc: false }]; export function TestsTable({ testHistory, - onClickFilter, + onToggleFilter, filter, innerColumns = defaultInnerColumns, getRowLink, @@ -183,10 +185,9 @@ export function TestsTable({ ); const filteredTree = useMemo(() => { - const filtered = - filter === 'all' - ? pathFilteredTree - : pruneTree(pathFilteredTree, { matchTest: matchByStatus(filter) }); + const filtered = pruneTree(pathFilteredTree, { + matchTest: matchByStatuses(filter), + }); return collapseSingleChildChains(filtered); }, [pathFilteredTree, filter]); @@ -237,9 +238,8 @@ export function TestsTable({ }, }); - const filterCount: Record = useMemo( + const filterCount = useMemo( () => ({ - all: globalStatusGroup.total_tests, success: globalStatusGroup.pass_tests, failed: globalStatusGroup.fail_tests, inconclusive: @@ -250,42 +250,23 @@ export function TestsTable({ [globalStatusGroup], ); - const filters: TStatusFilters[] = useMemo( - () => [ - { - label: intl.formatMessage( - { id: 'global.allCount' }, - { count: filterCount[possibleTableFilters[0]] }, - ), - value: possibleTableFilters[0], - isSelected: filter === possibleTableFilters[0], - }, - { - label: intl.formatMessage( + const chips = useMemo( + () => + buildStatusFilterChips({ + success: intl.formatMessage( { id: 'global.successCount' }, - { count: filterCount[possibleTableFilters[1]] }, + { count: filterCount.success }, ), - value: possibleTableFilters[1], - isSelected: filter === possibleTableFilters[1], - }, - { - label: intl.formatMessage( + failed: intl.formatMessage( { id: 'global.failedCount' }, - { count: filterCount[possibleTableFilters[2]] }, + { count: filterCount.failed }, ), - value: possibleTableFilters[2], - isSelected: filter === possibleTableFilters[2], - }, - { - label: intl.formatMessage( + inconclusive: intl.formatMessage( { id: 'global.inconclusiveCount' }, - { count: filterCount[possibleTableFilters[3]] }, + { count: filterCount.inconclusive }, ), - value: possibleTableFilters[3], - isSelected: filter === possibleTableFilters[3], - }, - ], - [filterCount, intl, filter], + }), + [filterCount, intl], ); const onSearchChange = useCallback( @@ -552,8 +533,9 @@ export function TestsTable({ > normalizedCandidate.includes(token)); } -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 const matchByStatuses = + (selection: TableStatusSelection) => + (test: TIndividualTest): boolean => + selection.includes( + test.status === undefined ? 'inconclusive' : getStatusGroup(test.status), + ); export function matchByPathSubstring( path: string, diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index 5904a8a57..629c8d4a2 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -96,6 +96,8 @@ export const messages = { 'filter.platformSubtitle': 'Please select one or more platforms:', 'filter.statusSubtitle': 'Please select one or more Status:', 'filter.tableFilter': 'Status filters:', + 'filter.tableFilterAll': 'All', + 'filter.tableFilterAllStatuses': '(all statuses)', 'filter.testDuration': 'Test duration', 'filter.testIssue': 'Test issue', 'filter.testOrigin': 'Test origin', diff --git a/dashboard/src/pages/BuildDetails/BuildDetails.tsx b/dashboard/src/pages/BuildDetails/BuildDetails.tsx index f256bb5cf..869f06d66 100644 --- a/dashboard/src/pages/BuildDetails/BuildDetails.tsx +++ b/dashboard/src/pages/BuildDetails/BuildDetails.tsx @@ -5,12 +5,14 @@ import { useSearch, useNavigate, useRouterState } from '@tanstack/react-router'; import { zTableFilterInfoDefault, - type PossibleTableFilters, + type TableStatusToggleValue, } from '@/types/tree/TreeDetails'; import BuildDetails from '@/components/BuildDetails/BuildDetails'; import { RedirectFrom } from '@/types/general'; +import { toggleTableStatus } from '@/utils/tableStatusFilter'; + import TreeBuildDetails from '@/pages/TreeBuildDetails'; import HardwareBuildDetails from '@/pages/HardwareBuildDetails'; @@ -31,15 +33,18 @@ const BuildDetailsPage = (): JSX.Element => { [], ); - const onClickFilter = useCallback( - (filter: PossibleTableFilters): void => { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - testsTable: filter, + testsTable: toggleTableStatus( + previousParams.tableFilter?.testsTable, + option, + ), }, }; }, @@ -61,7 +66,7 @@ const BuildDetailsPage = (): JSX.Element => { return ( diff --git a/dashboard/src/pages/HardwareBuildDetails/HardwareBuildDetails.tsx b/dashboard/src/pages/HardwareBuildDetails/HardwareBuildDetails.tsx index aaf10236a..5aac8b2a4 100644 --- a/dashboard/src/pages/HardwareBuildDetails/HardwareBuildDetails.tsx +++ b/dashboard/src/pages/HardwareBuildDetails/HardwareBuildDetails.tsx @@ -8,8 +8,9 @@ import { useSearchStore } from '@/hooks/store/useSearchStore'; import BuildDetails from '@/components/BuildDetails/BuildDetails'; import { zTableFilterInfoDefault, - type PossibleTableFilters, + type TableStatusToggleValue, } from '@/types/tree/TreeDetails'; +import { toggleTableStatus } from '@/utils/tableStatusFilter'; import { RedirectFrom } from '@/types/general'; import { MemoizedHardwareBreadcrumb } from '@/components/Breadcrumb/HardwareBreadcrumb'; @@ -33,15 +34,18 @@ const HardwareBuildDetails = (): JSX.Element => { [hardwareId], ); - const onClickFilter = useCallback( - (filter: PossibleTableFilters): void => { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - testsTable: filter, + testsTable: toggleTableStatus( + previousParams.tableFilter?.testsTable, + option, + ), }, }; }, @@ -59,7 +63,7 @@ const HardwareBuildDetails = (): JSX.Element => { locationMessage="buildDetails.buildDetails" /> } - onClickFilter={onClickFilter} + onToggleFilter={onToggleFilter} tableFilter={searchParams.tableFilter ?? zTableFilterInfoDefault} getTestTableRowLink={getTestTableRowLink} /> diff --git a/dashboard/src/pages/IssueDetails/IssueDetails.tsx b/dashboard/src/pages/IssueDetails/IssueDetails.tsx index eef11dc67..98f4b16ec 100644 --- a/dashboard/src/pages/IssueDetails/IssueDetails.tsx +++ b/dashboard/src/pages/IssueDetails/IssueDetails.tsx @@ -4,8 +4,11 @@ import { useNavigate, useRouterState, useSearch } from '@tanstack/react-router'; import { useCallback, useMemo, type JSX } from 'react'; import { IssueDetails } from '@/components/IssueDetails/IssueDetails'; -import type { PossibleTableFilters } from '@/types/tree/TreeDetails'; -import { zTableFilterInfoDefault } from '@/types/tree/TreeDetails'; +import { + zTableFilterInfoDefault, + type TableStatusToggleValue, +} from '@/types/tree/TreeDetails'; +import { toggleTableStatus } from '@/utils/tableStatusFilter'; import { RedirectFrom } from '@/types/general'; import { MemoizedTreeBreadcrumb } from '@/components/Breadcrumb/TreeBreadcrumb'; import { MemoizedHardwareBreadcrumb } from '@/components/Breadcrumb/HardwareBreadcrumb'; @@ -67,15 +70,18 @@ const IssueDetailsPage = (): JSX.Element => { } }, [historyState.from, historyState.id, previousSearch]); - const onClickTestFilter = useCallback( - (filter: PossibleTableFilters): void => { + const onToggleTestFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - testsTable: filter, + testsTable: toggleTableStatus( + previousParams.tableFilter?.testsTable, + option, + ), }, }; }, @@ -85,15 +91,18 @@ const IssueDetailsPage = (): JSX.Element => { [navigate], ); - const onClickBuildFilter = useCallback( - (filter: PossibleTableFilters): void => { + const onToggleBuildFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - buildsTable: filter, + buildsTable: toggleTableStatus( + previousParams.tableFilter?.buildsTable, + option, + ), }, }; }, @@ -107,9 +116,9 @@ const IssueDetailsPage = (): JSX.Element => { diff --git a/dashboard/src/pages/TreeBuildDetails/TreeBuildDetails.tsx b/dashboard/src/pages/TreeBuildDetails/TreeBuildDetails.tsx index 21a8a7dd2..59b2086ef 100644 --- a/dashboard/src/pages/TreeBuildDetails/TreeBuildDetails.tsx +++ b/dashboard/src/pages/TreeBuildDetails/TreeBuildDetails.tsx @@ -6,8 +6,9 @@ import { useCallback, type JSX } from 'react'; import BuildDetails from '@/components/BuildDetails/BuildDetails'; import { zTableFilterInfoDefault, - type PossibleTableFilters, + type TableStatusToggleValue, } from '@/types/tree/TreeDetails'; +import { toggleTableStatus } from '@/utils/tableStatusFilter'; import { RedirectFrom } from '@/types/general'; import { MemoizedTreeBreadcrumb } from '@/components/Breadcrumb/TreeBreadcrumb'; import { useSearchStore } from '@/hooks/store/useSearchStore'; @@ -32,15 +33,18 @@ const TreeBuildDetails = (): JSX.Element => { [treeId], ); - const onClickFilter = useCallback( - (filter: PossibleTableFilters): void => { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - testsTable: filter, + testsTable: toggleTableStatus( + previousParams.tableFilter?.testsTable, + option, + ), }, }; }, @@ -58,7 +62,7 @@ const TreeBuildDetails = (): JSX.Element => { locationMessage="buildDetails.buildDetails" /> } - onClickFilter={onClickFilter} + onToggleFilter={onToggleFilter} tableFilter={searchParams.tableFilter ?? zTableFilterInfoDefault} getTestTableRowLink={getTestTableRowLink} /> diff --git a/dashboard/src/pages/TreeCompare/components/CompareStatusPairFilter.tsx b/dashboard/src/pages/TreeCompare/components/CompareStatusPairFilter.tsx index 71c9d1a92..71fe898f5 100644 --- a/dashboard/src/pages/TreeCompare/components/CompareStatusPairFilter.tsx +++ b/dashboard/src/pages/TreeCompare/components/CompareStatusPairFilter.tsx @@ -13,6 +13,7 @@ import { import type { MessagesKey } from '@/locales/messages'; import { TooltipIcon } from '@/components/Icons/TooltipIcon'; +import { FilterButton } from '@/components/Button/FilterButton'; import { Button } from '@/components/ui/button'; import { Select, @@ -21,7 +22,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { cn } from '@/lib/utils'; import { changeTypeIsSelected, toggleChangeTypePairs, @@ -142,29 +142,15 @@ export function CompareStatusPairFilter({
- {QUICK_FILTERS.map(option => { - const selected = changeTypeIsSelected(value, option.value); - return ( - - ); - })} + {QUICK_FILTERS.map(option => ( + onChange(toggleChangeTypePairs(value, option.value))} + > + + + ))}
diff --git a/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx index 9bc275961..f59a08f6b 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx @@ -15,7 +15,7 @@ import MemoizedIssuesList from '@/components/Cards/IssuesList'; import { MemoizedHardwareTested } from '@/components/Cards/HardwareTested'; import { type TreeDetailsRouteFrom, - type PossibleTableFilters, + type TableStatusToggleValue, treeDetailsFromMap, zTableFilterInfoDefault, } from '@/types/tree/TreeDetails'; @@ -38,6 +38,7 @@ import { MemoizedFilterCard } from '@/components/Cards/FilterCard'; import { sanitizeTreeinfo } from '@/utils/treeDetails'; import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; import { getStringParam } from '@/utils/utils'; +import { toggleTableStatus } from '@/utils/tableStatusFilter'; interface BootsTabProps { treeDetailsLazyLoaded: TreeDetailsLazyLoaded; @@ -91,15 +92,18 @@ const BootsTab = ({ [navigate, params], ); - const onClickFilter = useCallback( - (newFilter: PossibleTableFilters): void => { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - bootsTable: newFilter, + bootsTable: toggleTableStatus( + previousParams.tableFilter?.bootsTable, + option, + ), }, }; }, @@ -348,7 +352,7 @@ const BootsTab = ({ { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - buildsTable: newFilter, + buildsTable: toggleTableStatus( + previousParams.tableFilter?.buildsTable, + option, + ), }, }; }, @@ -101,7 +105,7 @@ export function TreeDetailsBuildsTable({ tableKey="treeDetailsBuilds" filter={tableFilter.buildsTable} buildItems={buildItems} - onClickFilter={onClickFilter} + onToggleFilter={onToggleFilter} getRowLink={getRowLink} /> ); diff --git a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx index 28237d0d1..16b46da67 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx @@ -14,8 +14,9 @@ import { treeDetailsFromMap, type TreeDetailsRouteFrom, zTableFilterInfoDefault, - type PossibleTableFilters, + type TableStatusToggleValue, } from '@/types/tree/TreeDetails'; +import { toggleTableStatus } from '@/utils/tableStatusFilter'; import MemoizedIssuesList from '@/components/Cards/IssuesList'; import { MemoizedHardwareTested } from '@/components/Cards/HardwareTested'; @@ -156,15 +157,18 @@ const TestsTab = ({ [stateParams, canGoDirect, sanitizedTreeInfo], ); - const onClickFilter = useCallback( - (filter: PossibleTableFilters): void => { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - testsTable: filter, + testsTable: toggleTableStatus( + previousParams.tableFilter?.testsTable, + option, + ), }, }; }, @@ -348,7 +352,7 @@ const TestsTab = ({ { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - bootsTable: newFilter, + bootsTable: toggleTableStatus( + previousParams.tableFilter?.bootsTable, + option, + ), }, }; }, @@ -214,7 +219,7 @@ const BootsTab = ({ getRowLink={getRowLink} filter={tableFilter.bootsTable} testHistory={fullDataResult?.data?.boots} - onClickFilter={onClickFilter} + onToggleFilter={onToggleFilter} updatePathFilter={updatePathFilter} currentPathFilter={currentPathFilter} /> diff --git a/dashboard/src/pages/hardwareDetails/Tabs/Boots/HardwareDetailsBootsTable.tsx b/dashboard/src/pages/hardwareDetails/Tabs/Boots/HardwareDetailsBootsTable.tsx index 5c5674245..93a4897d0 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/Boots/HardwareDetailsBootsTable.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/Boots/HardwareDetailsBootsTable.tsx @@ -17,7 +17,8 @@ import { getStatusGroup } from '@/utils/status'; import type { TestByCommitHash, - PossibleTableFilters, + TableStatusSelection, + TableStatusToggleValue, } from '@/types/tree/TreeDetails'; import type { TestHistory } from '@/types/general'; @@ -42,7 +43,8 @@ export const columns: ColumnDef[] = [ { accessorKey: 'status', filterFn: (row, columnId, filterValue) => - getStatusGroup(row.getValue(columnId)) === filterValue, + !Array.isArray(filterValue) || + filterValue.includes(getStatusGroup(row.getValue(columnId))), header: ({ column }): JSX.Element => ( [] = [ interface IHardwareBootsTable { tableKey: TableKeys; testHistory?: TestHistory[]; - filter: PossibleTableFilters; + filter: TableStatusSelection; getRowLink: (testId: TestHistory['id']) => LinkProps; - onClickFilter: (newFilter: PossibleTableFilters) => void; + onToggleFilter: (option: TableStatusToggleValue) => void; updatePathFilter?: (pathFilter: string) => void; currentPathFilter?: string; } @@ -105,7 +107,7 @@ export const HardwareDetailsBootsTable = ({ testHistory, filter, getRowLink, - onClickFilter, + onToggleFilter, updatePathFilter, currentPathFilter, }: IHardwareBootsTable): JSX.Element => { @@ -116,7 +118,7 @@ export const HardwareDetailsBootsTable = ({ filter={filter} testHistory={testHistory} columns={columns} - onClickFilter={onClickFilter} + onToggleFilter={onToggleFilter} updatePathFilter={updatePathFilter} currentPathFilter={currentPathFilter} /> diff --git a/dashboard/src/pages/hardwareDetails/Tabs/Build/HardwareDetailsBuildsTable.tsx b/dashboard/src/pages/hardwareDetails/Tabs/Build/HardwareDetailsBuildsTable.tsx index c01fc6752..b27c2901f 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/Build/HardwareDetailsBuildsTable.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/Build/HardwareDetailsBuildsTable.tsx @@ -11,10 +11,11 @@ import { TableHeader } from '@/components/Table/TableHeader'; import { zTableFilterInfoDefault, type AccordionItemBuilds, - type PossibleTableFilters, + type TableStatusToggleValue, } from '@/types/tree/TreeDetails'; import { defaultBuildColumns } from '@/components/BuildsTable/DefaultBuildsColumns'; import { sanitizeBuilds } from '@/utils/utils'; +import { toggleTableStatus } from '@/utils/tableStatusFilter'; import type { BuildsTabBuild } from '@/types/general'; export interface THardwareDetailsBuildsTable { @@ -56,15 +57,18 @@ export function HardwareDetailsBuildsTable({ [hardwareId], ); - const onClickFilter = useCallback( - (filter: PossibleTableFilters) => { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue) => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - buildsTable: filter, + buildsTable: toggleTableStatus( + previousParams.tableFilter?.buildsTable, + option, + ), }, }; }, @@ -80,7 +84,7 @@ export function HardwareDetailsBuildsTable({ filter={tableFilter.buildsTable} buildItems={buildItems} columns={hardwareDetailsBuildColumns} - onClickFilter={onClickFilter} + onToggleFilter={onToggleFilter} getRowLink={getRowLink} /> ); diff --git a/dashboard/src/pages/hardwareDetails/Tabs/Tests/HardwareDetailsTestsTable.tsx b/dashboard/src/pages/hardwareDetails/Tabs/Tests/HardwareDetailsTestsTable.tsx index 106e5ff3a..63194036f 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/Tests/HardwareDetailsTestsTable.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/Tests/HardwareDetailsTestsTable.tsx @@ -87,7 +87,7 @@ interface IHardwareDetailsTestTable const HardwareDetailsTestTable = ({ tableKey, filter, - onClickFilter, + onToggleFilter, testHistory, hardwareId, updatePathFilter, @@ -111,7 +111,7 @@ const HardwareDetailsTestTable = ({ { + const onToggleFilter = useCallback( + (option: TableStatusToggleValue): void => { navigate({ search: previousParams => { return { ...previousParams, tableFilter: { ...(previousParams.tableFilter ?? zTableFilterInfoDefault), - testsTable: newFilter, + testsTable: toggleTableStatus( + previousParams.tableFilter?.testsTable, + option, + ), }, }; }, @@ -199,7 +204,7 @@ const TestsTab = ({ testHistory={fullDataResult?.data?.tests} filter={tableFilter.testsTable} hardwareId={hardwareId} - onClickFilter={onClickFilter} + onToggleFilter={onToggleFilter} updatePathFilter={updatePathFilter} currentPathFilter={currentPathFilter} /> diff --git a/dashboard/src/types/tree/TreeDetails.tsx b/dashboard/src/types/tree/TreeDetails.tsx index a30682d0c..1bbfae943 100644 --- a/dashboard/src/types/tree/TreeDetails.tsx +++ b/dashboard/src/types/tree/TreeDetails.tsx @@ -14,6 +14,16 @@ import type { import type { Status } from '@/types/database'; import type { DetailsFilters, Summary } from '@/types/commonDetails'; +import { + defaultTableStatusSelection, + normalizeTableStatusSelection, + possibleTableFilters, + tableStatusOptions, + type TableStatusOption, + type TableStatusSelection, + type TableStatusToggleValue, +} from '@/utils/tableStatusFilter'; + import type { TableTestStatus } from './Tree'; export type AccordionItemBuilds = { @@ -99,19 +109,18 @@ export const possibleTabs = [ 'global.tests', ] as const; -export const possibleTableFilters = [ - 'all', - 'success', - 'failed', - 'inconclusive', -] as const; +export { + possibleTableFilters, + tableStatusOptions, + type TableStatusOption, + type TableStatusSelection, + type TableStatusToggleValue, +}; export const defaultValidadorValues: { tab: (typeof possibleTabs)[number]; - tableFilter: (typeof possibleTableFilters)[number]; } = { tab: 'global.builds', - tableFilter: 'all', }; export const zPossibleTabValidator = z @@ -121,30 +130,29 @@ export const zPossibleTabValidator = z export type PossibleTabs = z.infer; -export const zTableFilterValidator = z - .enum(possibleTableFilters) - .catch(defaultValidadorValues.tableFilter); - -export type PossibleTableFilters = z.infer; +export const zTableStatusSelection = z.preprocess( + normalizeTableStatusSelection, + z.array(z.enum(tableStatusOptions)), +); export const zTableFilterInfo = object({ - buildsTable: zTableFilterValidator, - bootsTable: zTableFilterValidator, - testsTable: zTableFilterValidator, + buildsTable: zTableStatusSelection, + bootsTable: zTableStatusSelection, + testsTable: zTableStatusSelection, }); -export const zTableFilterInfoDefault = { - buildsTable: zTableFilterValidator.parse(''), - bootsTable: zTableFilterValidator.parse(''), - testsTable: zTableFilterValidator.parse(''), +export type TableFilter = z.infer; + +export const zTableFilterInfoDefault: TableFilter = { + buildsTable: [...defaultTableStatusSelection], + bootsTable: [...defaultTableStatusSelection], + testsTable: [...defaultTableStatusSelection], }; export const zTableFilterInfoValidator = zTableFilterInfo .default(zTableFilterInfoDefault) .catch(zTableFilterInfoDefault); -export type TableFilter = z.infer; - export const DEFAULT_TREE_INFO = {}; export const zTreeInformationObject = z.object({ gitBranch: z.string().optional().catch(''), diff --git a/dashboard/src/utils/search.test.ts b/dashboard/src/utils/search.test.ts index ac7bf919b..0d925669e 100644 --- a/dashboard/src/utils/search.test.ts +++ b/dashboard/src/utils/search.test.ts @@ -36,9 +36,9 @@ const nestedObject = { origin: 'maestro', intervalInDays: 7, tableFilter: { - bootsTable: 'all', - buildsTable: 'failed', - testsTable: 'all', + bootsTable: ['success', 'failed', 'inconclusive'], + buildsTable: ['failed'], + testsTable: [], }, tableSort: { buildsTable: 'path', @@ -60,9 +60,9 @@ const nestedObjectMinify = { o: 'maestro', i: 7, tf: { - bt: 'a', - b: 'f', - t: 'a', + bt: ['s', 'f', 'i'], + b: ['f'], + t: [], }, s: { b: 'path', @@ -82,7 +82,7 @@ const nestedObjectMinify = { const nestedObjectStringify = '?o=maestro&i=7' + - '&tf|bt=a&tf|b=f&tf|t=a' + + '&tf|bt[]=s,f,i&tf|b[]=f&tf|t[]' + '&s|b=path&s|t=-startTime' + '&tri|t=android&tri|ch=hash' + '&df|c|defconfig=true&df|a|arm=true&df|tp=amlogic' + @@ -91,9 +91,9 @@ const nestedObjectStringify = const flatObject = { origin: 'maestro', intervalInDays: 7, - 'tableFilter|bootsTable': 'all', - 'tableFilter|buildsTable': 'failed', - 'tableFilter|testsTable': 'all', + 'tableFilter|bootsTable': ['success', 'failed', 'inconclusive'], + 'tableFilter|buildsTable': ['failed'], + 'tableFilter|testsTable': [], 'tableSort|buildsTable': 'path', 'tableSort|testsTable': '-startTime', 'treeInfo|treeName': 'android', @@ -107,9 +107,9 @@ const flatObject = { const flatObjectMinify = { o: 'maestro', i: 7, - 'tf|bt': 'a', - 'tf|b': 'f', - 'tf|t': 'a', + 'tf|bt': ['s', 'f', 'i'], + 'tf|b': ['f'], + 'tf|t': [], 's|b': 'path', 's|t': '-startTime', 'tri|t': 'android', diff --git a/dashboard/src/utils/search.ts b/dashboard/src/utils/search.ts index f3aacc4e5..8186aeee7 100644 --- a/dashboard/src/utils/search.ts +++ b/dashboard/src/utils/search.ts @@ -243,6 +243,12 @@ export const minifyParams = ( key in minifiedParams ? minifiedParams[key as MinifiedParamsKeys] : key; if (isStringRecord(searchParams[key])) { result[newKey] = minifyParams(searchParams[key]); + } else if (Array.isArray(searchParams[key])) { + result[newKey] = (searchParams[key] as unknown[]).map(item => + typeof item === 'string' && item in minifiedValues + ? minifiedValues[item as MinifiedValuesKeys] + : item, + ); } else { const value = typeof searchParams[key] === 'string' && @@ -301,6 +307,14 @@ export const unminifyParams = ( ? (key as GroupedMinifiedKeys) : 'general', ); + } else if (Array.isArray(searchParams[key])) { + result[newKey] = (searchParams[key] as unknown[]).map(item => + typeof item === 'string' && item in groupedMinifiedParams['value'] + ? groupedMinifiedParams['value'][ + item as keyof (typeof groupedMinifiedParams)['value'] + ] + : item, + ); } else { const value = typeof searchParams[key] === 'string' && diff --git a/dashboard/src/utils/tableStatusFilter.test.ts b/dashboard/src/utils/tableStatusFilter.test.ts new file mode 100644 index 000000000..f9ba30721 --- /dev/null +++ b/dashboard/src/utils/tableStatusFilter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { + defaultTableStatusSelection, + normalizeTableStatusSelection, + toggleTableStatus, +} from './tableStatusFilter'; + +describe('toggleTableStatus', () => { + it('allows an empty selection', () => { + expect(toggleTableStatus(['failed'], 'failed')).toStrictEqual([]); + }); + + it('toggles all statuses together', () => { + expect(toggleTableStatus(defaultTableStatusSelection, 'all')).toStrictEqual( + [], + ); + expect(toggleTableStatus([], 'all')).toStrictEqual( + defaultTableStatusSelection, + ); + }); + + it('uses all statuses when no selection exists', () => { + expect(toggleTableStatus(undefined, 'failed')).toStrictEqual([ + 'success', + 'inconclusive', + ]); + }); +}); + +describe('normalizeTableStatusSelection', () => { + it('preserves empty selections and accepts legacy values', () => { + expect(normalizeTableStatusSelection([])).toStrictEqual([]); + expect(normalizeTableStatusSelection('failed')).toStrictEqual(['failed']); + expect(normalizeTableStatusSelection('all')).toStrictEqual( + defaultTableStatusSelection, + ); + }); +}); diff --git a/dashboard/src/utils/tableStatusFilter.ts b/dashboard/src/utils/tableStatusFilter.ts new file mode 100644 index 000000000..1bfc33b23 --- /dev/null +++ b/dashboard/src/utils/tableStatusFilter.ts @@ -0,0 +1,69 @@ +export const tableStatusOptions = [ + 'success', + 'failed', + 'inconclusive', +] as const; + +export type TableStatusOption = (typeof tableStatusOptions)[number]; + +export const possibleTableFilters = ['all', ...tableStatusOptions] as const; + +export type TableStatusToggleValue = (typeof possibleTableFilters)[number]; + +export type TableStatusSelection = TableStatusOption[]; + +export const defaultTableStatusSelection = [...tableStatusOptions]; + +export const isFullTableStatusSelection = ( + selection: TableStatusSelection, +): boolean => tableStatusOptions.every(option => selection.includes(option)); + +export const normalizeTableStatusSelection = ( + value: unknown, +): TableStatusSelection => { + if (Array.isArray(value)) { + return value.filter( + (item): item is TableStatusOption => + typeof item === 'string' && + (tableStatusOptions as readonly string[]).includes(item), + ); + } + + if ( + value === 'all' || + value === '' || + value === null || + value === undefined + ) { + return defaultTableStatusSelection; + } + + if (value === 'success' || value === 'failed' || value === 'inconclusive') { + return [value]; + } + + return defaultTableStatusSelection; +}; + +export const toggleTableStatus = ( + selection: TableStatusSelection | undefined, + option: TableStatusToggleValue, +): TableStatusSelection => { + const active = selection ?? defaultTableStatusSelection; + if (option === 'all') { + return isFullTableStatusSelection(active) + ? [] + : [...defaultTableStatusSelection]; + } + if (active.includes(option)) { + return active.filter(item => item !== option); + } + return [...active, option].sort( + (a, b) => tableStatusOptions.indexOf(a) - tableStatusOptions.indexOf(b), + ); +}; + +export const tableStatusFilterValueForColumn = ( + selection: TableStatusSelection, +): TableStatusSelection | undefined => + isFullTableStatusSelection(selection) ? undefined : selection;