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 = () => {
-
-
-
-
-
+
+
+