diff --git a/cypress/integration/version/downstream_projects.ts b/cypress/integration/version/downstream_projects.ts index 2cadc53ce4..e4cb8376be 100644 --- a/cypress/integration/version/downstream_projects.ts +++ b/cypress/integration/version/downstream_projects.ts @@ -5,16 +5,15 @@ describe("Downstream Projects Tab", () => { cy.visit(DOWNSTREAM_ROUTE); }); - it("shows the number of failed patches in the Downstream tab label", () => { + it("shows number of failed patches in the Downstream tab label", () => { cy.dataCy("downstream-tab-badge").should("exist"); cy.dataCy("downstream-tab-badge").should("contain.text", "1"); }); - it("shows the child patches", () => { + it("renders child patches", () => { cy.dataCy("project-accordion").should("have.length", 3); cy.dataCy("project-title").should("have.length", 3); - // On CI, none of the child patches failed, so no tables should be visible. - cy.dataCy("tasks-table").should("not.be.visible"); + cy.dataCy("downstream-tasks-table").should("have.length", 3); }); it("links to base commit", () => { @@ -28,15 +27,15 @@ describe("Downstream Projects Tab", () => { }); it("filters by test name", () => { - cy.dataCy("accordion-toggle").first().click(); - cy.get("tbody").first().children().should("have.length", 1); - cy.toggleTableFilter(1); - cy.dataCy("taskname-input-wrapper") + cy.dataCy("task-name-filter").eq(1).click(); + cy.dataCy("task-name-filter-wrapper") .find("input") - .focus() - .type("filter") - .type("{enter}"); - cy.get("tbody").first().contains("No Data"); + .as("testnameInputWrapper"); + cy.get("@testnameInputWrapper").focus(); + cy.get("@testnameInputWrapper").type("generate-lint"); + cy.get("@testnameInputWrapper").type("{enter}"); + cy.location("search").should("not.contain", "generate-lint"); // Should not update the URL. + cy.contains("generate-lint").should("be.visible"); }); it("does not push query params to the URL", () => { diff --git a/src/analytics/patch/usePatchAnalytics.ts b/src/analytics/patch/usePatchAnalytics.ts index 2a62c3e4ad..9cdf21a970 100644 --- a/src/analytics/patch/usePatchAnalytics.ts +++ b/src/analytics/patch/usePatchAnalytics.ts @@ -18,13 +18,13 @@ type Action = | TaskSortCategory.BaseStatus | TaskSortCategory.Variant; } + | { + name: "Filter Downstream Tasks Table"; + filterBy: string | string[]; + } | { name: "Sort Downstream Tasks Table"; - sortBy: - | TaskSortCategory.Name - | TaskSortCategory.Status - | TaskSortCategory.BaseStatus - | TaskSortCategory.Variant; + sortBy: TaskSortCategory | TaskSortCategory[]; } | { name: "Restart"; abort: boolean } | { name: "Schedule" } diff --git a/src/analytics/version/useVersionAnalytics.ts b/src/analytics/version/useVersionAnalytics.ts index fd14ff691a..921e3a0a54 100644 --- a/src/analytics/version/useVersionAnalytics.ts +++ b/src/analytics/version/useVersionAnalytics.ts @@ -18,13 +18,13 @@ type Action = | TaskSortCategory.BaseStatus | TaskSortCategory.Variant; } + | { + name: "Filter Downstream Tasks Table"; + filterBy: string | string[]; + } | { name: "Sort Downstream Tasks Table"; - sortBy: - | TaskSortCategory.Name - | TaskSortCategory.Status - | TaskSortCategory.BaseStatus - | TaskSortCategory.Variant; + sortBy: TaskSortCategory | TaskSortCategory[]; } | { name: "Restart"; abort: boolean } | { name: "Schedule" } diff --git a/src/components/TasksTable/Columns.tsx b/src/components/TasksTable/Columns.tsx index 326b95e3f6..d2f5736080 100644 --- a/src/components/TasksTable/Columns.tsx +++ b/src/components/TasksTable/Columns.tsx @@ -46,7 +46,7 @@ export const getColumnsTemplate = ({ ), meta: { search: { - "data-cy": "task-name-filter-popover", + "data-cy": "task-name-filter", placeholder: "Task name regex", }, }, @@ -87,7 +87,7 @@ export const getColumnsTemplate = ({ }, meta: { treeSelect: { - "data-cy": "status-filter-popover", + "data-cy": "status-filter", options: statusOptions, }, }, @@ -113,7 +113,7 @@ export const getColumnsTemplate = ({ ), meta: { treeSelect: { - "data-cy": "base-status-filter-popover", + "data-cy": "base-status-filter", options: baseStatusOptions, }, }, @@ -145,7 +145,7 @@ export const getColumnsTemplate = ({ ), meta: { search: { - "data-cy": "variant-filter-popover", + "data-cy": "variant-filter", placeholder: "Variant name regex", }, }, diff --git a/src/pages/version/downstreamTasks/DownstreamProjectAccordion.tsx b/src/pages/version/downstreamTasks/DownstreamProjectAccordion.tsx index a2b68f1f8b..13cc2c3a97 100644 --- a/src/pages/version/downstreamTasks/DownstreamProjectAccordion.tsx +++ b/src/pages/version/downstreamTasks/DownstreamProjectAccordion.tsx @@ -2,15 +2,9 @@ import { useReducer } from "react"; import { useQuery } from "@apollo/client"; import styled from "@emotion/styled"; import { InlineCode } from "@leafygreen-ui/typography"; -import { Skeleton } from "antd"; -import { TableProps } from "antd/es/table"; -import { Link, useParams } from "react-router-dom"; -import { useVersionAnalytics } from "analytics"; +import { Link } from "react-router-dom"; import { Accordion } from "components/Accordion"; import { PatchStatusBadge } from "components/PatchStatusBadge"; -import TableControl from "components/Table/TableControl"; -import TableWrapper from "components/Table/TableWrapper"; -import TasksTable from "components/TasksTable"; import { getVersionRoute } from "constants/routes"; import { size } from "constants/tokens"; import { useToastContext } from "context/toast"; @@ -18,21 +12,31 @@ import { Parameter, SortDirection, SortOrder, - Task, TaskSortCategory, VersionTasksQuery, VersionTasksQueryVariables, } from "gql/generated/types"; import { VERSION_TASKS } from "gql/queries"; -import { usePolling, useTaskStatuses } from "hooks"; +import { usePolling } from "hooks"; import { PatchStatus } from "types/patch"; -import { queryString, string } from "utils"; +import { string } from "utils"; import { ParametersModal } from "../ParametersModal"; +import { DownstreamTasksTable } from "./DownstreamTasksTable"; import { reducer } from "./reducer"; -const { parseSortString, toSortString } = queryString; const { shortenGithash } = string; +const defaultSorts: SortOrder[] = [ + { + Key: TaskSortCategory.Status, + Direction: SortDirection.Asc, + }, + { + Key: TaskSortCategory.BaseStatus, + Direction: SortDirection.Desc, + }, +]; + interface DownstreamProjectAccordionProps { baseVersionID: string; githash: string; @@ -56,14 +60,6 @@ export const DownstreamProjectAccordion: React.FC< }) => { const dispatchToast = useToastContext(); - const { id } = useParams<{ id: string }>(); - const { sendEvent } = useVersionAnalytics(id); - - const defaultSort: SortOrder = { - Key: TaskSortCategory.Status, - Direction: SortDirection.Asc, - }; - const [state, dispatch] = useReducer(reducer, { baseStatuses: [], limit: 10, @@ -71,107 +67,52 @@ export const DownstreamProjectAccordion: React.FC< statuses: [], taskName: "", variant: "", - baseStatusesInputVal: [], - currentStatusesInputVal: [], - taskNameInputVal: "", - variantInputVal: "", - sorts: [defaultSort], + sorts: defaultSorts, }); const { baseStatuses, limit, page, sorts, statuses, taskName, variant } = state; - const variables = { - versionId: childPatchId, - taskFilterOptions: { - limit, - page, - statuses, - taskName, - variant, - sorts, - baseStatuses, - }, - }; - - const { baseStatusesInputVal, currentStatusesInputVal } = state; - const { baseStatuses: currentBaseStatuses, currentStatuses } = - useTaskStatuses({ - versionId: childPatchId, - }); - - const taskNameInputProps = { - placeholder: "Task name", - value: state.taskNameInputVal, - onChange: ({ target }) => - dispatch({ type: "onChangeTaskNameInput", task: target.value }), - onFilter: () => dispatch({ type: "onFilterTaskNameInput" }), - }; - - const variantInputProps = { - placeholder: "Variant name", - value: state.variantInputVal, - onChange: ({ target }) => - dispatch({ - type: "onChangeVariantInput", - variant: target.value, - }), - onFilter: () => dispatch({ type: "onFilterVariantInput" }), - }; - - const baseStatusSelectorProps = { - state: baseStatusesInputVal, - tData: currentBaseStatuses, - onChange: (s: string[]) => - dispatch({ type: "setAndSubmitBaseStatusesSelector", baseStatuses: s }), - }; - - const statusSelectorProps = { - state: currentStatusesInputVal, - tData: currentStatuses, - onChange: (s: string[]) => - dispatch({ - type: "setAndSubmitStatusesSelector", - statuses: s, - }), - }; - const { data, refetch, startPolling, stopPolling } = useQuery< VersionTasksQuery, VersionTasksQueryVariables >(VERSION_TASKS, { - variables, + variables: { + versionId: childPatchId, + taskFilterOptions: { + baseStatuses, + limit, + page, + sorts, + statuses, + taskName, + variant, + }, + }, fetchPolicy: "cache-and-network", onError: (err) => { dispatchToast.error(`Error fetching downstream tasks ${err}`); }, }); usePolling({ startPolling, stopPolling, refetch }); + const showSkeleton = !data; const { version } = data || {}; const { isPatch, tasks } = version || {}; const { count = 0, data: tasksData = [] } = tasks || {}; - const variantTitle = ( - <> - - {projectName} - - - - ); - - const tableChangeHandler: TableProps["onChange"] = (...[, , sorter]) => - dispatch({ - type: "onSort", - sorts: parseSortString(toSortString(sorter)), - }); - return ( + + {projectName} + + + + } titleTag={FlexContainer} subtitle={ - dispatch({ type: "clearAllFilters" })} - onPageChange={(p) => { - dispatch({ type: "onChangePagination", page: p }); - }} - onPageSizeChange={(l) => { - dispatch({ type: "onChangeLimit", limit: l }); - }} - limit={limit} - page={page} - /> - } - > - {showSkeleton ? ( - - ) : ( - - sendEvent({ - name: "Sort Downstream Tasks Table", - sortBy: sortField, - }) - } - sorts={sorts} - statusSelectorProps={statusSelectorProps} - tableChangeHandler={tableChangeHandler} - taskNameInputProps={taskNameInputProps} - tasks={tasksData} - variantInputProps={variantInputProps} - /> - )} - + ); }; + interface DownstreamMetadataProps { baseVersionID: string; githash: string; diff --git a/src/pages/version/downstreamTasks/DownstreamTasksTable.tsx b/src/pages/version/downstreamTasks/DownstreamTasksTable.tsx new file mode 100644 index 0000000000..a5839d90a1 --- /dev/null +++ b/src/pages/version/downstreamTasks/DownstreamTasksTable.tsx @@ -0,0 +1,156 @@ +import { useRef, useMemo } from "react"; +import { LeafyGreenTable, useLeafyGreenTable } from "@leafygreen-ui/table"; +import { + ColumnFiltersState, + Filters, + Sorting, + SortingState, +} from "@tanstack/react-table"; +import { useParams } from "react-router-dom"; +import { usePatchAnalytics, useVersionAnalytics } from "analytics"; +import { BaseTable } from "components/Table/BaseTable"; +import TableControl from "components/Table/TableControl"; +import { TablePlaceholder } from "components/Table/TablePlaceholder"; +import TableWrapper from "components/Table/TableWrapper"; +import { onChangeHandler } from "components/Table/utils"; +import { getColumnsTemplate } from "components/TasksTable/Columns"; +import { TaskTableInfo } from "components/TasksTable/types"; +import { SortDirection, TaskSortCategory } from "gql/generated/types"; +import { useTaskStatuses } from "hooks"; +import { Action } from "./reducer"; + +const { getDefaultOptions: getDefaultFiltering } = Filters; +const { getDefaultOptions: getDefaultSorting } = Sorting; + +interface DownstreamTasksTableProps { + childPatchId: string; + count: number; + dispatch: (action: Action) => void; + isPatch: boolean; + limit: number; + loading: boolean; + page: number; + taskCount: number; + tasks: TaskTableInfo[]; +} + +export const DownstreamTasksTable: React.FC = ({ + childPatchId, + count, + dispatch, + isPatch, + limit, + loading, + page, + taskCount, + tasks, +}) => { + const { id: versionId } = useParams<{ id: string }>(); + const { sendEvent } = (isPatch ? usePatchAnalytics : useVersionAnalytics)( + versionId, + ); + + const { baseStatuses: baseStatusOptions, currentStatuses: statusOptions } = + useTaskStatuses({ versionId: childPatchId }); + + const onFilterChange = (filterState: ColumnFiltersState) => { + filterState.forEach(({ id, value }) => { + if (id === TaskSortCategory.Name) { + dispatch({ type: "setTaskName", task: value as string }); + } else if (id === TaskSortCategory.Status) { + dispatch({ type: "setStatuses", statuses: value as string[] }); + } else if (id === TaskSortCategory.BaseStatus) { + dispatch({ type: "setBaseStatuses", baseStatuses: value as string[] }); + } else if (id === TaskSortCategory.Variant) { + dispatch({ type: "setVariant", variant: value as string }); + } + }); + sendEvent({ + name: "Filter Downstream Tasks Table", + filterBy: Object.keys(filterState), + }); + }; + + const onSortingChange = (sortingState: SortingState) => { + const updatedSorts = sortingState.map(({ desc, id }) => ({ + Key: id as TaskSortCategory, + Direction: desc ? SortDirection.Desc : SortDirection.Asc, + })); + dispatch({ type: "setSorts", sorts: updatedSorts }); + sendEvent({ + name: "Sort Downstream Tasks Table", + sortBy: sortingState.map(({ id }) => id as TaskSortCategory), + }); + }; + + const columns = useMemo( + () => + getColumnsTemplate({ + baseStatusOptions, + statusOptions, + isPatch, + }), + [baseStatusOptions, statusOptions, isPatch], + ); + + const tableContainerRef = useRef(null); + const table: LeafyGreenTable = + useLeafyGreenTable({ + columns, + containerRef: tableContainerRef, + data: tasks ?? [], + defaultColumn: { + enableMultiSort: true, + sortDescFirst: false, // Handle bug in sorting order (https://github.com/TanStack/table/issues/4289) + }, + initialState: { + sorting: [ + { id: TaskSortCategory.Status, desc: false }, + { id: TaskSortCategory.BaseStatus, desc: true }, + ], + }, + isMultiSortEvent: () => true, // Override default requirement for shift-click to multisort. + maxMultiSortColCount: 2, + manualFiltering: true, + manualPagination: true, + manualSorting: true, + onColumnFiltersChange: onChangeHandler( + (f) => getDefaultFiltering(table).onColumnFiltersChange(f), + onFilterChange, + ), + onSortingChange: onChangeHandler( + (s) => getDefaultSorting(table).onSortingChange(s), + onSortingChange, + ), + getSubRows: (row) => row.executionTasksFull || [], + }); + + return ( + { + dispatch({ type: "clearAllFilters" }); + table.reset(); + }} + onPageChange={(p) => dispatch({ type: "setPage", page: p })} + onPageSizeChange={(l) => dispatch({ type: "setLimit", limit: l })} + page={page} + totalCount={taskCount} + /> + } + > + } + loading={loading} + shouldAlternateRowColor + table={table} + /> + + ); +}; diff --git a/src/pages/version/downstreamTasks/reducer.ts b/src/pages/version/downstreamTasks/reducer.ts index d6a21b902a..8b6d53ac51 100644 --- a/src/pages/version/downstreamTasks/reducer.ts +++ b/src/pages/version/downstreamTasks/reducer.ts @@ -10,59 +10,41 @@ interface QueryParamState { variant: string; } -interface InputValueState { - baseStatusesInputVal: string[]; - currentStatusesInputVal: string[]; - taskNameInputVal: string; - variantInputVal: string; -} - export type Action = - | { type: "onChangeTaskNameInput"; task: string } - | { type: "onChangeVariantInput"; variant: string } - | { type: "onFilterTaskNameInput" } - | { type: "onFilterVariantInput" } - | { type: "setAndSubmitBaseStatusesSelector"; baseStatuses: string[] } - | { type: "setAndSubmitStatusesSelector"; statuses: string[] } + | { type: "setTaskName"; task: string } + | { type: "setVariant"; variant: string } + | { type: "setBaseStatuses"; baseStatuses: string[] } + | { type: "setStatuses"; statuses: string[] } | { type: "clearAllFilters" } - | { type: "onSort"; sorts: SortOrder[] } - | { type: "onChangePagination"; page: number } - | { type: "onChangeLimit"; limit: number }; + | { type: "setSorts"; sorts: SortOrder[] } + | { type: "setPage"; page: number } + | { type: "setLimit"; limit: number }; -export type State = QueryParamState & InputValueState; +export type State = QueryParamState; const resetPage = { page: 0 }; export const reducer = (state: State, action: Action) => { switch (action.type) { - case "onChangeTaskNameInput": - return { - ...state, - taskNameInputVal: action.task, - }; - case "onChangeVariantInput": - return { ...state, variantInputVal: action.variant }; - case "onFilterTaskNameInput": + case "setTaskName": return { ...state, - taskName: state.taskNameInputVal, + taskName: action.task, ...resetPage, }; - case "onFilterVariantInput": + case "setVariant": return { ...state, - variant: state.variantInputVal, + variant: action.variant, ...resetPage, }; - case "setAndSubmitBaseStatusesSelector": + case "setBaseStatuses": return { ...state, - baseStatusesInputVal: action.baseStatuses, baseStatuses: action.baseStatuses, ...resetPage, }; - case "setAndSubmitStatusesSelector": + case "setStatuses": return { ...state, - currentStatusesInputVal: action.statuses, statuses: action.statuses, ...resetPage, }; @@ -70,27 +52,23 @@ export const reducer = (state: State, action: Action) => { return { ...state, ...resetPage, - currentStatusesInputVal: [], statuses: [], - variantInputVal: "", variant: "", - baseStatusesInputVal: [], baseStatuses: [], - taskNameInputVal: "", taskName: "", }; - case "onSort": + case "setSorts": return { ...state, ...resetPage, sorts: action.sorts, }; - case "onChangePagination": + case "setPage": return { ...state, page: action.page < 0 ? 0 : action.page, }; - case "onChangeLimit": + case "setLimit": return { ...state, ...resetPage,