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());
   });
 });

Reply via email to