This is an automated email from the ASF dual-hosted git repository. ephraimanierobi pushed a commit to branch v3-1-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 0e4b760e73cd710a757b9fb55258503f4a1d34e6 Author: Brent Bovenzi <[email protected]> AuthorDate: Thu Jan 15 09:44:40 2026 -0500 Improve Dags Filter UI (#60346) (#60547) * Fix colors and remove reset all * Create ButtonToggle component * Remove manual bg setting (cherry picked from commit 5cadabe9d5ff2ce927d1adad8b4dde02392ff52f) --- .../src/components/ui/ButtonGroupToggle.test.tsx | 88 ++++++++++++++++++++++ .../ui/src/components/ui/ButtonGroupToggle.tsx | 59 +++++++++++++++ .../src/airflow/ui/src/components/ui/index.ts | 1 + .../DagsList/DagsFilters/RequiredActionFilter.tsx} | 47 +++++++----- .../src/pages/DagsList/DagsFilters/TagFilter.tsx | 6 +- .../ui/src/pages/DagsList/DagsList.test.tsx | 8 +- 6 files changed, 183 insertions(+), 26 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/components/ui/ButtonGroupToggle.test.tsx b/airflow-core/src/airflow/ui/src/components/ui/ButtonGroupToggle.test.tsx new file mode 100644 index 00000000000..b4a28c684aa --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/ui/ButtonGroupToggle.test.tsx @@ -0,0 +1,88 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { BaseWrapper } from "src/utils/Wrapper"; + +import { ButtonGroupToggle } from "./ButtonGroupToggle"; + +describe("ButtonGroupToggle", () => { + const options = [ + { label: "All", value: "all" }, + { label: "Active", value: "active" }, + { label: "Paused", value: "paused" }, + ]; + + it("renders all options", () => { + render(<ButtonGroupToggle onChange={vi.fn()} options={options} value="all" />, { + wrapper: BaseWrapper, + }); + + expect(screen.getByText("All")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("Paused")).toBeInTheDocument(); + }); + + it("calls onChange when clicking a button", () => { + const onChange = vi.fn(); + + render(<ButtonGroupToggle onChange={onChange} options={options} value="all" />, { + wrapper: BaseWrapper, + }); + + fireEvent.click(screen.getByText("Active")); + + expect(onChange).toHaveBeenCalledWith("active"); + }); + + it("renders disabled options", () => { + const optionsWithDisabled = [ + { label: "All", value: "all" }, + { disabled: true, label: "Disabled", value: "disabled" }, + ]; + + render(<ButtonGroupToggle onChange={vi.fn()} options={optionsWithDisabled} value="all" />, { + wrapper: BaseWrapper, + }); + + expect(screen.getByText("Disabled")).toBeDisabled(); + }); + + it("supports render function labels", () => { + const optionsWithRenderFn = [ + { label: "All", value: "all" }, + { + label: (isSelected: boolean) => (isSelected ? "Selected!" : "Not Selected"), + value: "toggle", + }, + ]; + + const { rerender } = render( + <ButtonGroupToggle onChange={vi.fn()} options={optionsWithRenderFn} value="all" />, + { wrapper: BaseWrapper }, + ); + + expect(screen.getByText("Not Selected")).toBeInTheDocument(); + + rerender(<ButtonGroupToggle onChange={vi.fn()} options={optionsWithRenderFn} value="toggle" />); + + expect(screen.getByText("Selected!")).toBeInTheDocument(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/components/ui/ButtonGroupToggle.tsx b/airflow-core/src/airflow/ui/src/components/ui/ButtonGroupToggle.tsx new file mode 100644 index 00000000000..90dbdd9c3e3 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/ui/ButtonGroupToggle.tsx @@ -0,0 +1,59 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ButtonGroupProps } from "@chakra-ui/react"; +import { Button, ButtonGroup } from "@chakra-ui/react"; +import type { ReactNode } from "react"; + +export type ButtonGroupOption<T extends string = string> = { + readonly disabled?: boolean; + readonly label: ((isSelected: boolean) => ReactNode) | ReactNode; + readonly value: T; +}; + +type ButtonGroupToggleProps<T extends string = string> = { + readonly onChange: (value: T) => void; + readonly options: Array<ButtonGroupOption<T>>; + readonly value: T; +} & Omit<ButtonGroupProps, "onChange">; + +export const ButtonGroupToggle = <T extends string = string>({ + onChange, + options, + value, + ...rest +}: ButtonGroupToggleProps<T>) => ( + <ButtonGroup attached colorPalette="brand" size="sm" variant="outline" {...rest}> + {options.map((option) => { + const isSelected = option.value === value; + const label = typeof option.label === "function" ? option.label(isSelected) : option.label; + + return ( + <Button + disabled={option.disabled} + key={option.value} + onClick={() => onChange(option.value)} + value={option.value} + variant={isSelected ? "solid" : "outline"} + > + {label} + </Button> + ); + })} + </ButtonGroup> +); diff --git a/airflow-core/src/airflow/ui/src/components/ui/index.ts b/airflow-core/src/airflow/ui/src/components/ui/index.ts index e39d15dd09d..ab1820b992c 100644 --- a/airflow-core/src/airflow/ui/src/components/ui/index.ts +++ b/airflow-core/src/airflow/ui/src/components/ui/index.ts @@ -36,3 +36,4 @@ export * from "./Popover"; export * from "./Checkbox"; export * from "./ResetButton"; export * from "./InputWithAddon"; +export * from "./ButtonGroupToggle"; diff --git a/airflow-core/src/airflow/ui/src/components/ui/index.ts b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx similarity index 51% copy from airflow-core/src/airflow/ui/src/components/ui/index.ts copy to airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx index e39d15dd09d..debab07a25b 100644 --- a/airflow-core/src/airflow/ui/src/components/ui/index.ts +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx @@ -16,23 +16,32 @@ * specific language governing permissions and limitations * under the License. */ +import { Button } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; +import { LuUserRoundPen } from "react-icons/lu"; -export * from "./Dialog"; -export * from "./Pagination"; -export * from "./Select"; -export * from "./Alert"; -export * from "./CloseButton"; -export * from "./InputGroup"; -export * from "./Switch"; -export * from "./Tooltip"; -export * from "./ProgressBar"; -export * from "./Menu"; -export * from "./Accordion"; -export * from "./Button"; -export * from "./Toaster"; -export * from "./Breadcrumb"; -export * from "./Clipboard"; -export * from "./Popover"; -export * from "./Checkbox"; -export * from "./ResetButton"; -export * from "./InputWithAddon"; +import { StateBadge } from "src/components/StateBadge"; + +type Props = { + readonly needsReview: boolean; + readonly onToggle: () => void; +}; + +export const RequiredActionFilter = ({ needsReview, onToggle }: Props) => { + const { t: translate } = useTranslation("hitl"); + + return ( + <Button + colorPalette="brand" + data-testid="dags-needs-review-filter" + onClick={onToggle} + size="sm" + variant={needsReview ? "solid" : "outline"} + > + <StateBadge colorPalette="deferred"> + <LuUserRoundPen /> + </StateBadge> + {translate("requiredAction_other")} + </Button> + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/TagFilter.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/TagFilter.tsx index 001cd0b94f8..f40d5829e32 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/TagFilter.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/TagFilter.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Field, HStack, Text } from "@chakra-ui/react"; +import { Box, Field, HStack, Text } from "@chakra-ui/react"; import { Select as ReactSelect, type MultiValue } from "chakra-react-select"; import { useTranslation } from "react-i18next"; @@ -46,7 +46,7 @@ export const TagFilter = ({ const { t: translate } = useTranslation("common"); return ( - <> + <Box maxWidth="300px" minWidth="64px"> <Field.Root> <ReactSelect aria-label={translate("table.filterByTag")} @@ -106,6 +106,6 @@ export const TagFilter = ({ </Text> </HStack> )} - </> + </Box> ); }; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx index 39608f4a745..1f840cc5d40 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx @@ -26,12 +26,12 @@ describe("Dag Filters", () => { it("Filter by selected last run state", async () => { render(<AppWrapper initialEntries={["/dags"]} />); - await waitFor(() => expect(screen.getByTestId("dags-success-filter")).toBeInTheDocument()); - await waitFor(() => screen.getByTestId("dags-success-filter").click()); + await waitFor(() => expect(screen.getByText("states.success")).toBeInTheDocument()); + await waitFor(() => screen.getByText("states.success").click()); await waitFor(() => expect(screen.getByText("tutorial_taskflow_api_success")).toBeInTheDocument()); - await waitFor(() => expect(screen.getByTestId("dags-failed-filter")).toBeInTheDocument()); - await waitFor(() => screen.getByTestId("dags-failed-filter").click()); + await waitFor(() => expect(screen.getByText("states.failed")).toBeInTheDocument()); + await waitFor(() => screen.getByText("states.failed").click()); await waitFor(() => expect(screen.getByText("tutorial_taskflow_api_failed")).toBeInTheDocument()); }); });
