diff --git a/integration_tests/test/projec.spec.ts-snapshots/project-page-test-run-list.png b/integration_tests/test/projec.spec.ts-snapshots/project-page-test-run-list.png index 4078611b..0c220781 100644 Binary files a/integration_tests/test/projec.spec.ts-snapshots/project-page-test-run-list.png and b/integration_tests/test/projec.spec.ts-snapshots/project-page-test-run-list.png differ diff --git a/src/components/TestRunList/DataGridCustomToolbar.tsx b/src/components/TestRunList/DataGridCustomToolbar.tsx index 88601853..a3568819 100644 --- a/src/components/TestRunList/DataGridCustomToolbar.tsx +++ b/src/components/TestRunList/DataGridCustomToolbar.tsx @@ -1,15 +1,11 @@ import React from "react"; import { Toolbar, Box } from "@mui/material"; -import { - GridToolbarDensitySelector, - GridToolbarFilterButton, -} from "@mui/x-data-grid"; +import { GridToolbarDensitySelector } from "@mui/x-data-grid"; import { BulkOperation } from "./BulkOperation"; export const DataGridCustomToolbar: React.FunctionComponent = () => ( - diff --git a/src/components/TestRunList/StatusFilterOperators.tsx b/src/components/TestRunList/StatusFilterOperators.tsx deleted file mode 100644 index ae3de218..00000000 --- a/src/components/TestRunList/StatusFilterOperators.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from "react"; -import { - FormControl, - InputLabel, - Select, - SelectChangeEvent, -} from "@mui/material"; -import { useTestRunState } from "../../contexts"; -import { - getGridStringOperators, - GridFilterItem, - GridCellParams, - GridFilterOperator, - GridFilterInputValueProps, -} from "@mui/x-data-grid"; -import { TestStatus } from "../../types"; - -const StatusInputComponent = ({ - item, - applyValue, -}: GridFilterInputValueProps) => { - const { testRuns } = useTestRunState(); - - const handleFilterChange = (event: SelectChangeEvent) => { - applyValue({ - ...item, - value: event.target.value as string, - }); - }; - - const filterOptions: Array = Array.from( - new Set(testRuns.map((item) => item.status)), - ); - - return ( - - - Value - - - - ); -}; -export const StatusFilterOperators: GridFilterOperator[] = [ - ...getGridStringOperators() - .filter((operator) => operator.value === "equals") - .map((operator) => ({ - ...operator, - InputComponent: StatusInputComponent, - })), - { - label: "not", - value: "not", - getApplyFilterFn: (filterItem: GridFilterItem) => { - if (!filterItem.field || !filterItem.value || !filterItem.operator) { - return null; - } - - return (params: GridCellParams): boolean => - params.value !== filterItem.value; - }, - InputComponent: StatusInputComponent, - InputComponentProps: { - type: "string", - }, - }, -]; diff --git a/src/components/TestRunList/TagFilterOperators.tsx b/src/components/TestRunList/TagFilterOperators.tsx deleted file mode 100644 index ebbd575d..00000000 --- a/src/components/TestRunList/TagFilterOperators.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - FormControl, - InputLabel, - Select, - SelectChangeEvent, -} from "@mui/material"; -import React from "react"; -import { useTestRunState } from "../../contexts"; -import { - getGridStringOperators, - GridFilterOperator, - GridFilterInputValueProps, -} from "@mui/x-data-grid"; - -const TagInputComponent = ({ item, applyValue }: GridFilterInputValueProps) => { - const { testRuns } = useTestRunState(); - - const handleFilterChange = (event: SelectChangeEvent) => { - applyValue({ - ...item, - value: event.target.value, - }); - }; - - const filterOptions: string[] = Array.from( - new Set( - testRuns - .map((item) => item.os) - .concat(testRuns.map((item) => item.browser)) - .concat(testRuns.map((item) => item.device)) - .concat(testRuns.map((item) => item.viewport)) - .concat(testRuns.map((item) => item.customTags)), - ), - ); - - return ( - - - Value - - - - ); -}; - -export const TagFilterOperators: GridFilterOperator[] = getGridStringOperators() - .filter((operator) => operator.value === "contains") - .map((operator) => ({ - ...operator, - InputComponent: TagInputComponent, - })); diff --git a/src/components/TestRunList/TestRunFilters.tsx b/src/components/TestRunList/TestRunFilters.tsx new file mode 100644 index 00000000..c6ba17ff --- /dev/null +++ b/src/components/TestRunList/TestRunFilters.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { + Grid, + TextField, + Button, + Autocomplete, + Chip, + Paper, +} from "@mui/material"; +import { DebounceInput } from "react-debounce-input"; +import { TestStatus } from "../../types"; + +interface IProps { + tagOptions: string[]; + statusOptions: TestStatus[]; + name: string; + statuses: TestStatus[]; + tags: string[]; + onNameChange: (value: string) => void; + onStatusesChange: (value: TestStatus[]) => void; + onTagsChange: (value: string[]) => void; + onReset: () => void; +} + +const TestRunFilters: React.FunctionComponent = ({ + tagOptions, + statusOptions, + name, + statuses, + tags, + onNameChange, + onStatusesChange, + onTagsChange, + onReset, +}) => { + const hasActiveFilters = + name.length > 0 || statuses.length > 0 || tags.length > 0; + + return ( + + + + onNameChange(event.target.value)} + /> + + + onTagsChange(value)} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + onStatusesChange(value)} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + + + + + ); +}; + +export default TestRunFilters; diff --git a/src/components/TestRunList/index.tsx b/src/components/TestRunList/index.tsx index b7624168..73936174 100644 --- a/src/components/TestRunList/index.tsx +++ b/src/components/TestRunList/index.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Chip, Typography } from "@mui/material"; +import { Box, Chip, Typography } from "@mui/material"; import TestStatusChip from "../TestStatusChip"; import { useTestRunState, @@ -20,9 +20,8 @@ import { gridFilteredSortedRowIdsSelector, } from "@mui/x-data-grid"; import { DataGridCustomToolbar } from "./DataGridCustomToolbar"; -import { StatusFilterOperators } from "./StatusFilterOperators"; -import { TagFilterOperators } from "./TagFilterOperators"; -import { TestStatus } from "../../types"; +import TestRunFilters from "./TestRunFilters"; +import { TestRun, TestStatus } from "../../types"; import { testRunService } from "../../services"; import { useNavigate } from "react-router"; import { buildTestRunLocation } from "../../_helpers/route.helpers"; @@ -37,11 +36,13 @@ const columnsDef: GridColDef[] = [ field: "name", headerName: "Name", flex: 1, + filterable: false, }, { field: "tags", headerName: "Tags", flex: 1, + filterable: false, valueGetter: (params: GridValueGetterParams) => { const tags: string[] = [ params.row["os"], @@ -76,12 +77,12 @@ const columnsDef: GridColDef[] = [ )} ), - filterOperators: TagFilterOperators, }, { field: "status", headerName: "Status", flex: 0.3, + filterable: false, renderCell: (params: GridRenderCellParams) => ( ), @@ -89,10 +90,35 @@ const columnsDef: GridColDef[] = [ const statusOrder = Object.values(TestStatus); return statusOrder.indexOf(v2) - statusOrder.indexOf(v1); }, - filterOperators: StatusFilterOperators, }, ]; +const TAG_FIELDS: Array = [ + "os", + "device", + "browser", + "viewport", + "customTags", +]; + +const STATUS_ORDER = Object.values(TestStatus); + +type TagGroups = Array<[keyof TestRun, Set]>; + +const matchesNameQuery = (run: TestRun, query: string): boolean => + !query || run.name.toLowerCase().includes(query); + +const matchesStatuses = (run: TestRun, statuses: TestStatus[]): boolean => + statuses.length === 0 || statuses.includes(run.status); + +const matchesTagGroups = (run: TestRun, groups: TagGroups): boolean => + groups.every(([field, values]) => values.has(String(run[field] ?? ""))); + +const runTagValues = (run: TestRun): string[] => + TAG_FIELDS.map((field) => run[field]).filter( + (v): v is string => typeof v === "string" && Boolean(v), + ); + const TestRunList: React.FunctionComponent = () => { const apiRef = useGridApiRef(); const { enqueueSnackbar } = useSnackbar(); @@ -113,6 +139,116 @@ const TestRunList: React.FunctionComponent = () => { }, ]); + const [nameFilter, setNameFilter] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState([]); + const [tagFilter, setTagFilter] = React.useState([]); + + const resetFilters = React.useCallback(() => { + setNameFilter(""); + setStatusFilter([]); + setTagFilter([]); + }, []); + + const tagFieldByValue = React.useMemo(() => { + const map = new Map(); + testRuns.forEach((run) => { + TAG_FIELDS.forEach((field) => { + const value = run[field]; + if (typeof value === "string" && value) { + map.set(value, field); + } + }); + }); + return map; + }, [testRuns]); + + const selectedTagsByField = React.useMemo(() => { + const grouped = new Map>(); + tagFilter.forEach((tag) => { + const field = tagFieldByValue.get(tag); + if (!field) { + return; + } + const values = grouped.get(field) ?? new Set(); + values.add(tag); + grouped.set(field, values); + }); + return grouped; + }, [tagFilter, tagFieldByValue]); + + const query = nameFilter.trim().toLowerCase(); + const tagGroups = React.useMemo( + () => Array.from(selectedTagsByField.entries()), + [selectedTagsByField], + ); + + const filteredRows = React.useMemo( + () => + testRuns.filter( + (run) => + matchesNameQuery(run, query) && + matchesStatuses(run, statusFilter) && + matchesTagGroups(run, tagGroups), + ), + [testRuns, query, statusFilter, tagGroups], + ); + + // Options for each filter are derived from rows matching all OTHER filters, + // so only values that would actually return results are offered. + const statusOptions = React.useMemo(() => { + const present = new Set(statusFilter); + testRuns.forEach((run) => { + if (matchesNameQuery(run, query) && matchesTagGroups(run, tagGroups)) { + present.add(run.status); + } + }); + return Array.from(present).sort( + (a, b) => STATUS_ORDER.indexOf(a) - STATUS_ORDER.indexOf(b), + ); + }, [testRuns, query, tagGroups, statusFilter]); + + const tagOptions = React.useMemo(() => { + const present = new Set(tagFilter); + testRuns.forEach((run) => { + if (matchesNameQuery(run, query) && matchesStatuses(run, statusFilter)) { + runTagValues(run).forEach((value) => present.add(value)); + } + }); + const fieldIndex = (value: string): number => { + const field = tagFieldByValue.get(value); + return field ? TAG_FIELDS.indexOf(field) : TAG_FIELDS.length; + }; + return Array.from(present).sort( + (a, b) => fieldIndex(a) - fieldIndex(b) || a.localeCompare(b), + ); + }, [testRuns, query, statusFilter, tagFilter, tagFieldByValue]); + + React.useEffect(() => { + setPaginationModel((prev) => ({ ...prev, page: 0 })); + }, [nameFilter, statusFilter, tagFilter]); + + // Drop selected filter values that no longer exist in the data + // (e.g. after their test runs were deleted). + React.useEffect(() => { + if (testRuns.length === 0) { + return; + } + const existingTags = new Set(); + const existingStatuses = new Set(); + testRuns.forEach((run) => { + runTagValues(run).forEach((value) => existingTags.add(value)); + existingStatuses.add(run.status); + }); + setTagFilter((prev) => { + const next = prev.filter((tag) => existingTags.has(tag)); + return next.length === prev.length ? prev : next; + }); + setStatusFilter((prev) => { + const next = prev.filter((status) => existingStatuses.has(status)); + return next.length === prev.length ? prev : next; + }); + }, [testRuns]); + const getTestRunListCallback = React.useCallback(() => { testRunDispatch({ type: "request", @@ -168,33 +304,53 @@ const TestRunList: React.FunctionComponent = () => { if (selectedBuild) { return ( - setSortModel(model)} - onRowClick={(param: GridRowParams) => { - navigate( - buildTestRunLocation(selectedBuild.id, param.row["id"].toString()), - ); - }} - /> + + + + + + setSortModel(model)} + onRowClick={(param: GridRowParams) => { + navigate( + buildTestRunLocation( + selectedBuild.id, + param.row["id"].toString(), + ), + ); + }} + /> + + ); } diff --git a/src/pages/ProjectPage.tsx b/src/pages/ProjectPage.tsx index b13e9260..29700d05 100644 --- a/src/pages/ProjectPage.tsx +++ b/src/pages/ProjectPage.tsx @@ -46,11 +46,14 @@ const ProjectPage = () => { - - - - - + + +