diff --git a/ui-v2/src/components/deployments/deployment-details-upcoming-tab.tsx b/ui-v2/src/components/deployments/deployment-details-upcoming-tab.tsx index e600b6786e11..6c3f1406a6dd 100644 --- a/ui-v2/src/components/deployments/deployment-details-upcoming-tab.tsx +++ b/ui-v2/src/components/deployments/deployment-details-upcoming-tab.tsx @@ -1,16 +1,15 @@ import { getRouteApi } from "@tanstack/react-router"; -import { PaginationState } from "@tanstack/react-table"; import { usePaginateFlowRunswithFlows } from "@/api/flow-runs/use-paginate-flow-runs-with-flows"; +import { FlowRunState, SortFilters } from "@/components/flow-runs/data-table"; import { - FlowRunState, - FlowRunsDataTable, FlowRunsFilters, - RowSelectionProvider, - SortFilters, -} from "@/components/flow-runs/data-table"; - -import { useCallback, useMemo } from "react"; + FlowRunsList, + FlowRunsPagination, + FlowRunsRowCount, + type PaginationState, +} from "@/components/flow-runs/flow-runs-list"; +import { useCallback, useMemo, useState } from "react"; const routeApi = getRouteApi("/deployments/deployment/$id"); @@ -21,10 +20,12 @@ type DeploymentDetailsUpcomingTabProps = { export const DeploymentDetailsUpcomingTab = ({ deploymentId, }: DeploymentDetailsUpcomingTabProps) => { - const [pagination, onPaginationChange] = usePagination(); + const [selectedRows, setSelectedRows] = useState>(new Set()); + const [pagination, onChangePagination] = usePagination(); const [search, setSearch] = useSearch(); const [sort, setSort] = useSort(); const [filter, setFilter] = useFilter(); + const resetFilters = useResetFilters(); const { data } = usePaginateFlowRunswithFlows({ deployments: { @@ -39,62 +40,98 @@ export const DeploymentDetailsUpcomingTab = ({ }, operator: "and_", }, - limit: pagination.pageSize, - page: pagination.pageIndex + 1, // + 1 for to account for react table's 0 index + limit: pagination.limit, + page: pagination.page, sort, }); + const handleResetFilters = () => { + resetFilters(); + setSelectedRows(new Set()); + }; + + const addRow = (id: string) => + setSelectedRows((curr) => new Set(curr).add(id)); + const removeRow = (id: string) => + setSelectedRows((curr) => { + const newValue = new Set(curr); + newValue.delete(id); + return newValue; + }); + + const handleSelectRow = (id: string, checked: boolean) => { + if (checked) { + addRow(id); + } else { + removeRow(id); + } + }; + return ( - - +
+ + +
+ + - {data ? ( - 0 && ( + - ) : null} -
+ )} + ); }; +function useResetFilters() { + const navigate = routeApi.useNavigate(); + const resetFilters = useCallback(() => { + void navigate({ + to: ".", + search: (prev) => ({ + ...prev, + upcoming: undefined, + }), + replace: true, + }); + }, [navigate]); + return resetFilters; +} + function usePagination() { const { upcoming } = routeApi.useSearch(); const navigate = routeApi.useNavigate(); - // React Table uses 0-based pagination, so we need to subtract 1 from the page number - const pageIndex = (upcoming?.page ?? 1) - 1; - const pageSize = upcoming?.limit ?? 5; - const pagination: PaginationState = useMemo( - () => ({ - pageIndex, - pageSize, - }), - [pageIndex, pageSize], - ); - - const onPaginationChange = useCallback( - (newPagination: PaginationState) => { + const onChangePagination = useCallback( + (pagination?: PaginationState) => { void navigate({ to: ".", search: (prev) => ({ ...prev, - page: newPagination.pageIndex + 1, - limit: newPagination.pageSize, upcoming: { ...upcoming, - page: newPagination.pageIndex + 1, - limit: newPagination.pageSize, + ...pagination, }, }), replace: true, @@ -103,7 +140,14 @@ function usePagination() { [navigate, upcoming], ); - return [pagination, onPaginationChange] as const; + const pagination = useMemo(() => { + return { + page: upcoming?.page ?? 1, + limit: upcoming?.limit ?? 5, + }; + }, [upcoming?.limit, upcoming?.page]); + + return [pagination, onChangePagination] as const; } function useSearch() { @@ -111,7 +155,7 @@ function useSearch() { const navigate = routeApi.useNavigate(); const onSearch = useCallback( - (value: string) => { + (value?: string) => { void navigate({ to: ".", search: (prev) => ({ @@ -141,7 +185,7 @@ function useSort() { const navigate = routeApi.useNavigate(); const onSort = useCallback( - (value: SortFilters | undefined) => { + (value?: SortFilters) => { void navigate({ to: ".", search: (prev) => ({ @@ -168,7 +212,7 @@ function useFilter() { const navigate = routeApi.useNavigate(); const onFilter = useCallback( - (value: Set) => { + (value?: Set) => { void navigate({ to: ".", search: (prev) => ({ @@ -177,7 +221,7 @@ function useFilter() { ...upcoming, flowRuns: { ...upcoming?.flowRuns, - state: Array.from(value), + state: value ? Array.from(value) : undefined, }, }, }), diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-run-card.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-run-card.tsx new file mode 100644 index 000000000000..17aea61140fe --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-run-card.tsx @@ -0,0 +1,48 @@ +import { components } from "@/api/prefect"; +import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Typography } from "@/components/ui/typography"; +import { cva } from "class-variance-authority"; +import type { FlowRunRow } from "./types"; + +type FlowRunCardProps = + | { + flowRun: FlowRunRow; + } + | { + flowRun: FlowRunRow; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + }; + +export const FlowRunCard = ({ flowRun, ...props }: FlowRunCardProps) => { + return ( + +
+ {"checked" in props && "onCheckedChange" in props && ( + + )} + {flowRun.name} +
+
+ ); +}; + +const stateCardVariants = cva("flex flex-col gap-2 p-4 border-l-8", { + variants: { + state: { + COMPLETED: "border-l-green-600", + FAILED: "border-l-red-600", + RUNNING: "border-l-blue-700", + CANCELLED: "border-l-gray-800", + CANCELLING: "border-l-gray-800", + CRASHED: "border-l-orange-600", + PAUSED: "border-l-gray-800", + PENDING: "border-l-gray-800", + SCHEDULED: "border-l-yellow-700", + } satisfies Record, + }, +}); diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters.tsx new file mode 100644 index 000000000000..6fb52df868cc --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters.tsx @@ -0,0 +1,47 @@ +import { RunNameSearch } from "./flow-runs-filters/run-name-search"; +import { SortFilter } from "./flow-runs-filters/sort-filter"; +import type { SortFilters } from "./flow-runs-filters/sort-filter.constants"; +import { StateFilter } from "./flow-runs-filters/state-filter"; +import type { FlowRunState } from "./flow-runs-filters/state-filters.constants"; + +export type FlowRunsFiltersProps = { + search: { + onChange: (value: string) => void; + value: string; + }; + stateFilter: { + value: Set; + onSelect: (filters: Set) => void; + }; + sort: { + value: SortFilters | undefined; + onSelect: (sort: SortFilters) => void; + }; +}; + +export const FlowRunsFilters = ({ + search, + sort, + stateFilter, +}: FlowRunsFiltersProps) => { + return ( +
+
+
+ search.onChange(e.target.value)} + placeholder="Search by run name" + /> +
+
+ +
+
+ +
+ ); +}; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/flow-runs-filters.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/flow-runs-filters.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/run-name-search.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/run-name-search.tsx new file mode 100644 index 000000000000..95fe261ba8b7 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/run-name-search.tsx @@ -0,0 +1,20 @@ +import { Icon } from "@/components/ui/icons"; +import { Input, type InputProps } from "@/components/ui/input"; + +export const RunNameSearch = (props: InputProps) => { + return ( +
+ + +
+ ); +}; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.constants.ts b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.constants.ts new file mode 100644 index 000000000000..f485ed54e03b --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.constants.ts @@ -0,0 +1,7 @@ +export const SORT_FILTERS = [ + "START_TIME_ASC", + "START_TIME_DESC", + "NAME_ASC", + "NAME_DESC", +] as const; +export type SortFilters = (typeof SORT_FILTERS)[number]; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.test.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.test.tsx new file mode 100644 index 000000000000..e11b802d9955 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { mockPointerEvents } from "@tests/utils/browser"; +import { SortFilter } from "./sort-filter"; + +describe("FlowRunsDataTable -- SortFilter", () => { + beforeAll(mockPointerEvents); + + it("returns correct sort filter for Newest to oldest", async () => { + // Setup + const user = userEvent.setup(); + const mockOnSelectFn = vi.fn(); + render(); + + // Test + await user.click( + screen.getByRole("combobox", { name: /flow run sort order/i }), + ); + await user.click(screen.getByRole("option", { name: /newest to oldest/i })); + + // Assert + expect(mockOnSelectFn).toBeCalledWith("START_TIME_DESC"); + }); + + it("returns correct sort filter for Oldest to newest", async () => { + // Setup + const user = userEvent.setup(); + const mockOnSelectFn = vi.fn(); + render(); + + // Test + await user.click( + screen.getByRole("combobox", { name: /flow run sort order/i }), + ); + await user.click(screen.getByRole("option", { name: /oldest to newest/i })); + + // Assert + expect(mockOnSelectFn).toBeCalledWith("START_TIME_ASC"); + }); + + it("returns correct sort filter for A to Z", async () => { + // Setup + const user = userEvent.setup(); + const mockOnSelectFn = vi.fn(); + render(); + + // Test + await user.click( + screen.getByRole("combobox", { name: /flow run sort order/i }), + ); + await user.click(screen.getByRole("option", { name: /a to z/i })); + + // Assert + expect(mockOnSelectFn).toBeCalledWith("NAME_ASC"); + }); + + it("returns correct sort filter for Z to A", async () => { + // Setup + const user = userEvent.setup(); + const mockOnSelectFn = vi.fn(); + render(); + + // Test + await user.click( + screen.getByRole("combobox", { name: /flow run sort order/i }), + ); + await user.click(screen.getByRole("option", { name: /z to a/i })); + + // Assert + expect(mockOnSelectFn).toBeCalledWith("NAME_DESC"); + }); +}); diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.tsx new file mode 100644 index 000000000000..aee9a4ac7e20 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/sort-filter.tsx @@ -0,0 +1,34 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SortFilters } from "./sort-filter.constants"; + +type SortFilterProps = { + defaultValue?: SortFilters; + onSelect: (filter: SortFilters) => void; + value: undefined | SortFilters; +}; + +export const SortFilter = ({ + defaultValue, + value, + onSelect, +}: SortFilterProps) => { + return ( + + ); +}; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.stories.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.stories.tsx new file mode 100644 index 000000000000..2225cf589285 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { useState } from "react"; +import { StateFilter } from "./state-filter"; +import { FlowRunState } from "./state-filters.constants"; + +const meta: Meta = { + title: "Components/FlowRuns/StateFilter", + component: StateFilterStory, +}; +export default meta; + +function StateFilterStory() { + const [filters, setFilters] = useState>(); + return ; +} + +export const story: StoryObj = { name: "StateFilter" }; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.test.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.test.tsx new file mode 100644 index 000000000000..f05c9085ccba --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { mockPointerEvents } from "@tests/utils/browser"; +import { useState } from "react"; +import { StateFilter } from "./state-filter"; +import type { FlowRunState } from "./state-filters.constants"; + +describe("FlowRunsDataTable -- StateFilter", () => { + beforeAll(mockPointerEvents); + + const TestStateFilter = () => { + const [filters, setFilters] = useState>(); + return ( + + ); + }; + + it("selects All except scheduled option", async () => { + // Setup + const user = userEvent.setup(); + render(); + // Test + await user.click(screen.getByRole("button", { name: /all run states/i })); + await user.click( + screen.getByRole("menuitem", { name: /all except scheduled/i }), + ); + await user.keyboard("{Escape}"); + + // Assert + expect( + screen.getByRole("button", { name: /all except scheduled/i }), + ).toBeVisible(); + }); + + it("selects All run states option", async () => { + // Setup + const user = userEvent.setup(); + render(); + // Test + await user.click(screen.getByRole("button", { name: /all run states/i })); + await user.click(screen.getByRole("menuitem", { name: /all run states/i })); + await user.keyboard("{Escape}"); + + // Assert + expect( + screen.getByRole("button", { name: /all run states/i }), + ).toBeVisible(); + }); + + it("selects a single run state option", async () => { + // Setup + const user = userEvent.setup(); + render(); + // Test + await user.click(screen.getByRole("button", { name: /all run states/i })); + await user.click(screen.getByRole("menuitem", { name: /failed/i })); + + await user.keyboard("{Escape}"); + + // Assert + expect(screen.getByRole("button", { name: /failed/i })).toBeVisible(); + }); + + it("selects multiple run state options", async () => { + // Setup + const user = userEvent.setup(); + render(); + // Test + await user.click(screen.getByRole("button", { name: /all run states/i })); + await user.click(screen.getByRole("menuitem", { name: /timedout/i })); + await user.click(screen.getByRole("menuitem", { name: /crashed/i })); + + await user.click(screen.getByRole("menuitem", { name: /failed/i })); + await user.click(screen.getByRole("menuitem", { name: /running/i })); + await user.click(screen.getByRole("menuitem", { name: /retrying/i })); + + await user.keyboard("{Escape}"); + + // Assert + expect( + screen.getByRole("button", { + name: /timedout crashed failed running \+ 1/i, + }), + ).toBeVisible(); + }); +}); diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.tsx new file mode 100644 index 000000000000..a1d3fdc5d537 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filter.tsx @@ -0,0 +1,153 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icon } from "@/components/ui/icons"; +import { StateBadge } from "@/components/ui/state-badge"; +import { Typography } from "@/components/ui/typography"; +import { useMemo, useState } from "react"; +import { + FLOW_RUN_STATES_MAP, + FLOW_RUN_STATES_NO_SCHEDULED, + type FlowRunState, +} from "./state-filters.constants"; + +const MAX_FILTERS_DISPLAYED = 4; + +type StateFilterProps = { + defaultValue?: Set; + selectedFilters: Set | undefined; + onSelectFilter: (filters: Set) => void; +}; + +export const StateFilter = ({ + defaultValue, + selectedFilters = defaultValue || new Set(), + onSelectFilter, +}: StateFilterProps) => { + const [open, setOpen] = useState(false); + + const isAllButScheduled = useMemo(() => { + const flowRunStatesNoScheduleSet = new Set( + FLOW_RUN_STATES_NO_SCHEDULED, + ); + if ( + selectedFilters.has("Scheduled") || + flowRunStatesNoScheduleSet.size !== selectedFilters.size + ) { + return false; + } + return Array.from(selectedFilters).every((filter) => + flowRunStatesNoScheduleSet.has(filter), + ); + }, [selectedFilters]); + + const handleSelectAllExceptScheduled = () => { + onSelectFilter(new Set(FLOW_RUN_STATES_NO_SCHEDULED)); + }; + + const handleSelectAllRunState = () => { + onSelectFilter(new Set()); + }; + + const handleSelectFilter = (filter: FlowRunState) => { + // if all but scheduled is already selected, create a new set with the single filter + if (isAllButScheduled) { + onSelectFilter(new Set([filter])); + return; + } + const updatedFilters = new Set(selectedFilters); + if (selectedFilters.has(filter)) { + updatedFilters.delete(filter); + } else { + updatedFilters.add(filter); + } + onSelectFilter(updatedFilters); + }; + + const renderSelectedTags = () => { + if (selectedFilters.size === 0) { + return "All run states"; + } + if (isAllButScheduled) { + return "All except scheduled"; + } + + return ( +
+ {Array.from(selectedFilters) + .slice(0, MAX_FILTERS_DISPLAYED) + .map((filter) => ( + + ))} + {selectedFilters.size > MAX_FILTERS_DISPLAYED && ( + + + {selectedFilters.size - MAX_FILTERS_DISPLAYED} + + )} +
+ ); + }; + + return ( + + + + + + { + e.preventDefault(); + handleSelectAllExceptScheduled(); + }} + > + + All except scheduled + + { + e.preventDefault(); + handleSelectAllRunState(); + }} + > + + All run states + + {Object.keys(FLOW_RUN_STATES_MAP).map((filterKey) => ( + { + e.preventDefault(); + handleSelectFilter(filterKey as FlowRunState); + }} + > + + + + ))} + + + ); +}; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filters.constants.ts b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filters.constants.ts new file mode 100644 index 000000000000..2fe28e22c7fe --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-filters/state-filters.constants.ts @@ -0,0 +1,44 @@ +import { components } from "@/api/prefect"; + +export const FLOW_RUN_STATES = [ + "Scheduled", + "Late", + "Resuming", + "AwaitingRetry", + "AwaitingConcurrencySlot", + "Pending", + "Paused", + "Suspended", + "Running", + "Retrying", + "Completed", + "Cached", + "Cancelled", + "Cancelling", + "Crashed", + "Failed", + "TimedOut", +] as const; +export type FlowRunState = (typeof FLOW_RUN_STATES)[number]; +export const FLOW_RUN_STATES_NO_SCHEDULED = FLOW_RUN_STATES.filter( + (flowStateFilter) => flowStateFilter !== "Scheduled", +); +export const FLOW_RUN_STATES_MAP = { + Scheduled: "SCHEDULED", + Late: "SCHEDULED", + Resuming: "SCHEDULED", + AwaitingRetry: "SCHEDULED", + AwaitingConcurrencySlot: "SCHEDULED", + Pending: "PENDING", + Paused: "PAUSED", + Suspended: "PAUSED", + Running: "RUNNING", + Retrying: "RUNNING", + Completed: "COMPLETED", + Cached: "COMPLETED", + Cancelled: "CANCELLED", + Cancelling: "CANCELLING", + Crashed: "CRASHED", + Failed: "FAILED", + TimedOut: "FAILED", +} satisfies Record; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-list.stories.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-list.stories.tsx new file mode 100644 index 000000000000..930339ad5239 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-list.stories.tsx @@ -0,0 +1,146 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { createFakeFlowRunWithDeploymentAndFlow } from "@/mocks/create-fake-flow-run"; +import { + reactQueryDecorator, + routerDecorator, + toastDecorator, +} from "@/storybook/utils"; +import { faker } from "@faker-js/faker"; +import { fn } from "@storybook/test"; +import { buildApiUrl } from "@tests/utils/handlers"; +import { http, HttpResponse } from "msw"; +import { useMemo, useState } from "react"; +import { FlowRunsFilters } from "./flow-runs-filters"; +import type { FlowRunState } from "./flow-runs-filters/state-filters.constants"; +import { FlowRunsList } from "./flow-runs-list"; +import { FlowRunsPagination, PaginationState } from "./flow-runs-pagination"; +import { FlowRunsRowCount } from "./flow-runs-row-count"; + +const MOCK_DATA = [ + createFakeFlowRunWithDeploymentAndFlow({ + id: "0", + state: { type: "SCHEDULED", name: "Late", id: "0" }, + }), + createFakeFlowRunWithDeploymentAndFlow({ + id: "1", + state: { type: "COMPLETED", name: "Cached", id: "0" }, + }), + createFakeFlowRunWithDeploymentAndFlow({ + id: "2", + state: { type: "SCHEDULED", name: "Scheduled", id: "0" }, + }), + createFakeFlowRunWithDeploymentAndFlow({ + id: "3", + state: { type: "COMPLETED", name: "Completed", id: "0" }, + }), + createFakeFlowRunWithDeploymentAndFlow({ + id: "4", + state: { type: "FAILED", name: "Failed", id: "0" }, + }), +]; + +const MOCK_FLOW_RUNS_TASK_COUNT = { + "0": faker.number.int({ min: 0, max: 5 }), + "1": faker.number.int({ min: 0, max: 5 }), + "2": faker.number.int({ min: 0, max: 5 }), + "3": faker.number.int({ min: 0, max: 5 }), + "4": faker.number.int({ min: 0, max: 5 }), +}; + +const meta = { + title: "Components/FlowRuns/FlowRunsList", + render: () => , + decorators: [routerDecorator, reactQueryDecorator, toastDecorator], + parameters: { + msw: { + handlers: [ + http.post(buildApiUrl("/ui/flow_runs/count-task-runs"), () => { + return HttpResponse.json(MOCK_FLOW_RUNS_TASK_COUNT); + }), + ], + }, + }, +} satisfies Meta; + +export default meta; + +export const story: StoryObj = { name: "FlowRunsList" }; + +const FlowRunsListStory = () => { + const [selectedRows, setSelectedRows] = useState>(new Set()); + const [pagination, setPagination] = useState({ + limit: 5, + page: 1, + }); + const [search, setSearch] = useState(""); + const [filters, setFilters] = useState>(new Set()); + + const flowRuns = useMemo(() => { + return MOCK_DATA.filter((flowRun) => + flowRun.name?.toLocaleLowerCase().includes(search.toLowerCase()), + ).filter((flowRun) => + filters.size === 0 + ? flowRun + : filters.has(flowRun.state?.name as FlowRunState), + ); + }, [filters, search]); + + const addRow = (id: string) => + setSelectedRows((curr) => new Set([...Array.from(curr), id])); + + const removeRow = (id: string) => + setSelectedRows( + (curr) => new Set([...Array.from(curr).filter((i) => i !== id)]), + ); + const handleSelectRow = (id: string, checked: boolean) => { + if (checked) { + addRow(id); + } else { + removeRow(id); + } + }; + + const handleResetFilters = () => { + setSelectedRows(new Set()); + setSearch(""); + setFilters(new Set()); + }; + + return ( +
+
+ + +
+ + + + +
+ ); +}; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-list.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-list.tsx new file mode 100644 index 000000000000..55af34e2b3e0 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-list.tsx @@ -0,0 +1,66 @@ +import { Button } from "@/components/ui/button"; +import { Typography } from "@/components/ui/typography"; +import { FlowRunCard } from "./flow-run-card"; +import type { FlowRunRow } from "./types"; + +type FlowRunCardProps = + | { + flowRuns: Array | undefined; + onClearFilters?: () => void; + } + | { + flowRuns: Array | undefined; + onSelect: (id: string, checked: boolean) => void; + selectedRows: Set; + onClearFilters?: () => void; + }; + +export const FlowRunsList = ({ + flowRuns, + onClearFilters, + ...props +}: FlowRunCardProps) => { + if (!flowRuns) { + // Todo: Add Skeleton Loading UX + return "Loading..."; + } + + if (flowRuns.length === 0) { + return ( +
+
+ No runs found + {onClearFilters && ( + + )} +
+
+ ); + } + + return ( +
    + {flowRuns.map((flowRun) => { + // Variant for selectable list + if ("onSelect" in props && "selectedRows" in props) { + return ( +
  • + + props.onSelect(flowRun.id, checked) + } + /> +
  • + ); + } + return ( +
  • + +
  • + ); + })} +
+ ); +}; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-pagination.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-pagination.tsx new file mode 100644 index 000000000000..671c642bcd17 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-pagination.tsx @@ -0,0 +1,104 @@ +import { + Pagination, + PaginationContent, + PaginationFirstButton, + PaginationItem, + PaginationLastButton, + PaginationNextButton, + PaginationPreviousButton, +} from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const PAGINATION_INCREMENTS = [5, 10, 25, 50]; + +export type PaginationState = { + limit: number; + page: number; +}; +export type PaginationStateUpdater = ( + prevState: PaginationState, +) => PaginationState; + +type FlowRunsPaginationProps = { + pages: number; + pagination: PaginationState; + onChangePagination: (pagination: PaginationState) => void; +}; +export const FlowRunsPagination = ({ + pages, + pagination, + onChangePagination, +}: FlowRunsPaginationProps) => { + const handleFirstPage = () => + onChangePagination({ limit: pagination.limit, page: 1 }); + const handlePreviousPage = () => + onChangePagination({ limit: pagination.limit, page: pagination.page - 1 }); + const handleNextPage = () => + onChangePagination({ limit: pagination.limit, page: pagination.page + 1 }); + + const handleLastPage = () => + onChangePagination({ limit: pagination.limit, page: pages }); + + const disablePreviousPage = pagination.page <= 1; + const disableNextPage = pagination.page >= pages; + + return ( +
+
+ Items per page + +
+ + + + + + + + Page {pagination.page} of {pages} + + + + + + + + + +
+ ); +}; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-row-count.tsx b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-row-count.tsx new file mode 100644 index 000000000000..fb636d33b77f --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-row-count.tsx @@ -0,0 +1,129 @@ +import { Button } from "@/components/ui/button"; +import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog"; +import { Icon } from "@/components/ui/icons"; +import { Typography } from "@/components/ui/typography"; +import { pluralize } from "@/utils"; + +import { FlowRunWithFlow } from "@/api/flow-runs"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CheckedState } from "@radix-ui/react-checkbox"; +import { useMemo } from "react"; +import { useDeleteFlowRunsDialog } from "./use-delete-flow-runs-dialog"; + +// type FlowRunsRowCountProps = { +// count: number | undefined; +// results?: Array; +// setSelectedRows?: (rows: Set) => void; +// selectedRows?: Set; +// }; + +type CountOnlyProps = { + count: number | undefined; +}; +type SelectableProps = { + count: number | undefined; + results: Array | undefined; + setSelectedRows: (rows: Set) => void; + selectedRows: Set; +}; +type FlowRunsRowCountProps = CountOnlyProps | SelectableProps; + +export const FlowRunsRowCount = ({ + count = 0, + ...props +}: FlowRunsRowCountProps) => { + // Selectable UX + if ( + "results" in props && + "setSelectedRows" in props && + "selectedRows" in props + ) { + return ; + } + + // Count only UX + return ( + + {count} {pluralize(count, "Flow run")} + + ); +}; + +function SelectedCount({ + count = 0, + results = [], + setSelectedRows, + selectedRows, +}: SelectableProps) { + const [deleteConfirmationDialogState, confirmDelete] = + useDeleteFlowRunsDialog(); + + const resultsIds = useMemo(() => results.map(({ id }) => id), [results]); + + const selectedRowsList = Array.from(selectedRows); + + const ToggleCheckbox = () => { + const isAllRowsSelected = resultsIds.every((id) => selectedRows.has(id)); + const isSomeRowsSelected = resultsIds.some((id) => selectedRows.has(id)); + let checkedState: CheckedState = false; + if (isAllRowsSelected) { + checkedState = true; + } else if (isSomeRowsSelected) { + checkedState = "indeterminate"; + } + return ( + { + if (checked) { + setSelectedRows(new Set(resultsIds)); + } else { + setSelectedRows(new Set()); + } + }} + aria-label="Toggle all" + /> + ); + }; + + // If has selected rows + if (selectedRows.size > 0) + return ( + <> +
+ + + {selectedRowsList.length} selected + + +
+ + + ); + + return ( +
+ {results && setSelectedRows && selectedRows && ( + { + setSelectedRows(new Set(checked ? resultsIds : undefined)); + }} + aria-label="Toggle all" + /> + )} + + {count} {pluralize(count, "Flow run")} + +
+ ); +} diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/index.ts b/ui-v2/src/components/flow-runs/flow-runs-list/index.ts new file mode 100644 index 000000000000..9a41af8848ab --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/index.ts @@ -0,0 +1,8 @@ +export { FlowRunsFilters } from "./flow-runs-filters"; +export { FlowRunsRowCount } from "./flow-runs-row-count"; +export { + FlowRunsPagination, + type PaginationState, +} from "./flow-runs-pagination"; +export { FlowRunCard } from "./flow-run-card"; +export { FlowRunsList } from "./flow-runs-list"; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/types.ts b/ui-v2/src/components/flow-runs/flow-runs-list/types.ts new file mode 100644 index 000000000000..8c17f3725bdf --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/types.ts @@ -0,0 +1,8 @@ +import { Deployment } from "@/api/deployments"; +import { FlowRun } from "@/api/flow-runs"; +import { Flow } from "@/api/flows"; + +export type FlowRunRow = FlowRun & { + flow: Flow; + deployment?: Deployment; +}; diff --git a/ui-v2/src/components/flow-runs/flow-runs-list/use-delete-flow-runs-dialog.ts b/ui-v2/src/components/flow-runs/flow-runs-list/use-delete-flow-runs-dialog.ts new file mode 100644 index 000000000000..e201ea3ac8a6 --- /dev/null +++ b/ui-v2/src/components/flow-runs/flow-runs-list/use-delete-flow-runs-dialog.ts @@ -0,0 +1,60 @@ +import { useDeleteFlowRun } from "@/api/flow-runs"; +import { useDeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog"; +import { useToast } from "@/hooks/use-toast"; + +export const useDeleteFlowRunsDialog = () => { + const { toast } = useToast(); + const [dialogState, confirmDelete] = useDeleteConfirmationDialog(); + + const { mutateAsync } = useDeleteFlowRun(); + + const handleDeletes = async ( + flowRunIds: Array, + onConfirm = () => {}, + ) => { + try { + const res = await Promise.allSettled( + flowRunIds.map((id) => mutateAsync(id)), + ); + const { numFails, numSuccess } = res.reduce( + (accumulator, currentValue) => { + if (currentValue.status === "rejected") { + accumulator.numFails += 1; + } else { + accumulator.numSuccess += 1; + } + return accumulator; + }, + { numFails: 0, numSuccess: 0 }, + ); + if (numFails > 1) { + toast({ title: `${numFails} flow runs failed to delete` }); + } else if (numFails === 1) { + toast({ title: "Flow run failed to delete" }); + } else if (numSuccess > 1) { + toast({ title: `${numSuccess} flow runs deleted` }); + } else { + toast({ title: "Flow run deleted" }); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + console.error("Unknown error while deleting flow run."); + } finally { + onConfirm(); + } + }; + + const handleConfirmDelete = ( + flowRunIds: Array, + onConfirm = () => {}, + ) => + confirmDelete({ + title: "Delete Flow Runs", + description: "Are you sure you want to delete selected flow runs?", + onConfirm: () => { + void handleDeletes(flowRunIds, onConfirm); + }, + }); + + return [dialogState, handleConfirmDelete] as const; +};