From 826eaadb3984230f5af327336e44beb15b8209f8 Mon Sep 17 00:00:00 2001 From: Devin Villarosa Date: Thu, 13 Feb 2025 20:35:00 -0800 Subject: [PATCH] [UI v2] feat: Adds flow run data table filter components --- .../cron-schedule-form.test.tsx | 1 - .../interval-schedule-form.test.tsx | 2 - .../flow-runs/data-table/run-name-search.tsx | 15 ++ .../flow-runs/data-table/sort-filter.test.tsx | 74 +++++++ .../flow-runs/data-table/sort-filter.tsx | 34 ++++ .../data-table/state-filter.stories.tsx | 17 ++ .../data-table/state-filter.test.tsx | 87 ++++++++ .../flow-runs/data-table/state-filter.tsx | 190 ++++++++++++++++++ ui-v2/src/components/ui/state-badge/index.tsx | 2 +- 9 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 ui-v2/src/components/flow-runs/data-table/run-name-search.tsx create mode 100644 ui-v2/src/components/flow-runs/data-table/sort-filter.test.tsx create mode 100644 ui-v2/src/components/flow-runs/data-table/sort-filter.tsx create mode 100644 ui-v2/src/components/flow-runs/data-table/state-filter.stories.tsx create mode 100644 ui-v2/src/components/flow-runs/data-table/state-filter.test.tsx create mode 100644 ui-v2/src/components/flow-runs/data-table/state-filter.tsx diff --git a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.test.tsx b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.test.tsx index 34306ca4dd48..47c0b13aaaa7 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.test.tsx +++ b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.test.tsx @@ -34,7 +34,6 @@ describe("CronScheduleForm", () => { await user.click(screen.getByLabelText(/active/i)); await user.clear(screen.getByLabelText(/value/i)); await user.type(screen.getByLabelText(/value/i), "* * * * 1/2"); - screen.logTestingPlaygroundURL(); await user.click(screen.getByRole("switch", { name: /day or/i })); await user.click( diff --git a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/interval-schedule-form.test.tsx b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/interval-schedule-form.test.tsx index e139e124eabb..04fddc5677d5 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/interval-schedule-form.test.tsx +++ b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/interval-schedule-form.test.tsx @@ -38,8 +38,6 @@ describe("CronScheduleForm", () => { await user.click(screen.getByRole("combobox", { name: /interval/i })); await user.click(screen.getByRole("option", { name: /hours/i })); - screen.logTestingPlaygroundURL(); - await user.click( screen.getByRole("combobox", { name: /select timezone/i }), ); diff --git a/ui-v2/src/components/flow-runs/data-table/run-name-search.tsx b/ui-v2/src/components/flow-runs/data-table/run-name-search.tsx new file mode 100644 index 000000000000..7309340c01ed --- /dev/null +++ b/ui-v2/src/components/flow-runs/data-table/run-name-search.tsx @@ -0,0 +1,15 @@ +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/data-table/sort-filter.test.tsx b/ui-v2/src/components/flow-runs/data-table/sort-filter.test.tsx new file mode 100644 index 000000000000..7d87d75e2f8f --- /dev/null +++ b/ui-v2/src/components/flow-runs/data-table/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_ASC"); + }); + + 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_DESC"); + }); + + 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/data-table/sort-filter.tsx b/ui-v2/src/components/flow-runs/data-table/sort-filter.tsx new file mode 100644 index 000000000000..9a5124ad1585 --- /dev/null +++ b/ui-v2/src/components/flow-runs/data-table/sort-filter.tsx @@ -0,0 +1,34 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type SortFilters = + | "START_TIME_ASC" + | "START_TIME_DESC" + | "NAME_ASC" + | "NAME_DESC"; + +type SortFilterProps = { + onSelect: (filter: SortFilters) => void; + value: undefined | SortFilters; +}; + +export const SortFilter = ({ value, onSelect }: SortFilterProps) => { + return ( + + ); +}; diff --git a/ui-v2/src/components/flow-runs/data-table/state-filter.stories.tsx b/ui-v2/src/components/flow-runs/data-table/state-filter.stories.tsx new file mode 100644 index 000000000000..a6a33f96721d --- /dev/null +++ b/ui-v2/src/components/flow-runs/data-table/state-filter.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { useState } from "react"; +import { type FlowRunState, StateFilter } from "./state-filter"; + +const meta: Meta = { + title: "Components/FlowRuns/DataTable/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/data-table/state-filter.test.tsx b/ui-v2/src/components/flow-runs/data-table/state-filter.test.tsx new file mode 100644 index 000000000000..6bc3c87c148a --- /dev/null +++ b/ui-v2/src/components/flow-runs/data-table/state-filter.test.tsx @@ -0,0 +1,87 @@ +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 { type FlowRunState, StateFilter } from "./state-filter"; + +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/data-table/state-filter.tsx b/ui-v2/src/components/flow-runs/data-table/state-filter.tsx new file mode 100644 index 000000000000..b86e2da3bcaf --- /dev/null +++ b/ui-v2/src/components/flow-runs/data-table/state-filter.tsx @@ -0,0 +1,190 @@ +import { components } from "@/api/prefect"; +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"; + +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]; +const FLOW_RUN_STATES_NO_SCHEDULED = FLOW_RUN_STATES.filter( + (flowStateFilter) => flowStateFilter !== "Scheduled", +); +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; + +const MAX_FILTERS_DISPLAYED = 4; + +type StateFilterProps = { + selectedFilters: Set | undefined; + onSelectFilter: (filters: Set) => void; +}; + +export const StateFilter = ({ + selectedFilters = 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/ui/state-badge/index.tsx b/ui-v2/src/components/ui/state-badge/index.tsx index 5b8140c40e6a..458da78941f7 100644 --- a/ui-v2/src/components/ui/state-badge/index.tsx +++ b/ui-v2/src/components/ui/state-badge/index.tsx @@ -37,7 +37,7 @@ const stateBadgeVariants = cva("gap-1", { }, }); -type StateBadgeProps = { +export type StateBadgeProps = { type: components["schemas"]["StateType"]; name?: string | null; };