diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx b/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx index 75e336d615f19..6b28ba0b9587c 100644 --- a/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx +++ b/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx @@ -34,7 +34,6 @@ export const ToggleTableDisplay = ({ display, setDisplay }: Props) => { setDisplay("card")} title={translate("toggleCardView")} variant={display === "card" ? "solid" : "outline"} @@ -43,7 +42,6 @@ export const ToggleTableDisplay = ({ display, setDisplay }: Props) => { setDisplay("table")} title={translate("toggleTableView")} variant={display === "table" ? "solid" : "outline"} 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 0000000000000..b4a28c684aa26 --- /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(, { + 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(, { + 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(, { + 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( + , + { wrapper: BaseWrapper }, + ); + + expect(screen.getByText("Not Selected")).toBeInTheDocument(); + + rerender(); + + 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 0000000000000..90dbdd9c3e3ff --- /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 = { + readonly disabled?: boolean; + readonly label: ((isSelected: boolean) => ReactNode) | ReactNode; + readonly value: T; +}; + +type ButtonGroupToggleProps = { + readonly onChange: (value: T) => void; + readonly options: Array>; + readonly value: T; +} & Omit; + +export const ButtonGroupToggle = ({ + onChange, + options, + value, + ...rest +}: ButtonGroupToggleProps) => ( + + {options.map((option) => { + const isSelected = option.value === value; + const label = typeof option.label === "function" ? option.label(isSelected) : option.label; + + return ( + + ); + })} + +); 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 b2db1b811b0ce..d7ce843b1192b 100644 --- a/airflow-core/src/airflow/ui/src/components/ui/index.ts +++ b/airflow-core/src/airflow/ui/src/components/ui/index.ts @@ -33,3 +33,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/pages/DagsList/DagsFilters/DagsFilters.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx index 7bac1898496d0..3a1ee4fdf5819 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx @@ -16,20 +16,19 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Flex } from "@chakra-ui/react"; +import { HStack } from "@chakra-ui/react"; import type { MultiValue } from "chakra-react-select"; import { useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTableURLState } from "src/components/DataTable/useTableUrlState"; -import { ResetButton } from "src/components/ui"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useConfig } from "src/queries/useConfig"; import { useDagTagsInfinite } from "src/queries/useDagTagsInfinite"; -import { getFilterCount } from "src/utils/filterUtils"; import { FavoriteFilter } from "./FavoriteFilter"; import { PausedFilter } from "./PausedFilter"; +import { RequiredActionFilter } from "./RequiredActionFilter"; import { StateFilters } from "./StateFilters"; import { TagFilter } from "./TagFilter"; @@ -43,6 +42,21 @@ const { TAGS_MATCH_MODE: TAGS_MATCH_MODE_PARAM, }: SearchParamsKeysType = SearchParamsKeys; +type StateValue = "all" | "failed" | "queued" | "running" | "success"; +type BooleanFilterValue = "all" | "false" | "true"; + +const stateValues: ReadonlyArray = ["failed", "queued", "running", "success"]; +const booleanFilterValues: ReadonlyArray = ["all", "true", "false"]; + +const toStateValue = (value: string | null): StateValue => + stateValues.includes(value as StateValue) ? (value as StateValue) : "all"; + +const toBooleanFilterValue = ( + value: string | null, + defaultValue: BooleanFilterValue = "all", +): BooleanFilterValue => + booleanFilterValues.includes(value as BooleanFilterValue) ? (value as BooleanFilterValue) : defaultValue; + export const DagsFilters = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -52,11 +66,6 @@ export const DagsFilters = () => { const state = searchParams.get(LAST_DAG_RUN_STATE_PARAM); const selectedTags = searchParams.getAll(TAGS_PARAM); const tagFilterMode = searchParams.get(TAGS_MATCH_MODE_PARAM) ?? "any"; - const isAll = state === null; - const isRunning = state === "running"; - const isFailed = state === "failed"; - const isQueued = state === "queued"; - const isSuccess = state === "success"; const [pattern, setPattern] = useState(""); @@ -67,61 +76,56 @@ export const DagsFilters = () => { }); const hidePausedDagsByDefault = Boolean(useConfig("hide_paused_dags_by_default")); - const defaultShowPaused = hidePausedDagsByDefault ? "false" : "all"; + const defaultShowPaused: BooleanFilterValue = hidePausedDagsByDefault ? "false" : "all"; const { setTableURLState, tableURLState } = useTableURLState(); const { pagination, sorting } = tableURLState; - const handlePausedChange: React.MouseEventHandler = ({ currentTarget: { value } }) => { - if (value === "all") { - searchParams.delete(PAUSED_PARAM); - } else { - searchParams.set(PAUSED_PARAM, value); - } + const resetPagination = () => { setTableURLState({ pagination: { ...pagination, pageIndex: 0 }, sorting, }); searchParams.delete(OFFSET_PARAM); + }; + + const handlePausedChange = (value: BooleanFilterValue) => { + if (value === "all") { + searchParams.delete(PAUSED_PARAM); + } else { + searchParams.set(PAUSED_PARAM, value); + } + resetPagination(); setSearchParams(searchParams); }; - const handleFavoriteChange: React.MouseEventHandler = ({ currentTarget: { value } }) => { + const handleFavoriteChange = (value: BooleanFilterValue) => { if (value === "all") { searchParams.delete(FAVORITE_PARAM); } else { searchParams.set(FAVORITE_PARAM, value); } - setTableURLState({ - pagination: { ...pagination, pageIndex: 0 }, - sorting, - }); - searchParams.delete(OFFSET_PARAM); + resetPagination(); setSearchParams(searchParams); }; - const handleStateChange: React.MouseEventHandler = ({ currentTarget: { value } }) => { + const handleStateChange = (value: StateValue) => { if (value === "all") { searchParams.delete(LAST_DAG_RUN_STATE_PARAM); + } else { + searchParams.set(LAST_DAG_RUN_STATE_PARAM, value); + } + resetPagination(); + setSearchParams(searchParams); + }; + + const handleNeedsReviewToggle = () => { + if (needsReview === "true") { searchParams.delete(NEEDS_REVIEW_PARAM); - } else if (value === "needs_review") { - if (needsReview === "true") { - searchParams.delete(NEEDS_REVIEW_PARAM); - } else { - searchParams.set(NEEDS_REVIEW_PARAM, "true"); - } } else { - if (state === value) { - searchParams.delete(LAST_DAG_RUN_STATE_PARAM); - } else { - searchParams.set(LAST_DAG_RUN_STATE_PARAM, value); - } + searchParams.set(NEEDS_REVIEW_PARAM, "true"); } - setTableURLState({ - pagination: { ...pagination, pageIndex: 0 }, - sorting, - }); - searchParams.delete(OFFSET_PARAM); + resetPagination(); setSearchParams(searchParams); }; @@ -142,18 +146,6 @@ export const DagsFilters = () => { setSearchParams(searchParams); }; - const onClearFilters = () => { - searchParams.delete(PAUSED_PARAM); - searchParams.delete(FAVORITE_PARAM); - searchParams.delete(NEEDS_REVIEW_PARAM); - searchParams.delete(LAST_DAG_RUN_STATE_PARAM); - searchParams.delete(TAGS_PARAM); - searchParams.delete(TAGS_MATCH_MODE_PARAM); - - setSearchParams(searchParams); - setPattern(""); - }; - const handleTagModeChange = ({ checked }: { checked: boolean }) => { const mode = checked ? "all" : "any"; @@ -161,52 +153,30 @@ export const DagsFilters = () => { setSearchParams(searchParams); }; - const filterCount = getFilterCount({ - needsReview, - selectedTags, - showFavorites, - showPaused, - state, - }); + const stateValue = toStateValue(state); + const pausedValue = toBooleanFilterValue(showPaused, defaultShowPaused); + const favoriteValue = toBooleanFilterValue(showFavorites); return ( - - - - - - { - void fetchNextPage(); - }} - onMenuScrollToTop={() => { - void fetchPreviousPage(); - }} - onSelectTagsChange={handleSelectTagsChange} - onTagModeChange={handleTagModeChange} - onUpdate={setPattern} - selectedTags={selectedTags} - tagFilterMode={tagFilterMode} - tags={data?.pages.flatMap((dagResponse) => dagResponse.tags) ?? []} - /> - - - - - - - + + + + + { + void fetchNextPage(); + }} + onMenuScrollToTop={() => { + void fetchPreviousPage(); + }} + onSelectTagsChange={handleSelectTagsChange} + onTagModeChange={handleTagModeChange} + onUpdate={setPattern} + selectedTags={selectedTags} + tagFilterMode={tagFilterMode} + tags={data?.pages.flatMap((dagResponse) => dagResponse.tags) ?? []} + /> + + ); }; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/FavoriteFilter.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/FavoriteFilter.tsx index 3554658f98dae..71f0c0e893a56 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/FavoriteFilter.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/FavoriteFilter.tsx @@ -16,55 +16,52 @@ * specific language governing permissions and limitations * under the License. */ -import { Button, ButtonGroup, Icon } from "@chakra-ui/react"; +import { Icon } from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; import { FiStar } from "react-icons/fi"; +import { ButtonGroupToggle } from "src/components/ui/ButtonGroupToggle"; + +type FavoriteValue = "all" | "false" | "true"; + type Props = { - readonly onFavoriteChange: React.MouseEventHandler; - readonly showFavorites: string | null; + readonly onChange: (value: FavoriteValue) => void; + readonly value: FavoriteValue; }; -export const FavoriteFilter = ({ onFavoriteChange, showFavorites }: Props) => { - const { t: translate } = useTranslation("dags"); +const StarIcon = ({ filled, isSelected }: { readonly filled: boolean; readonly isSelected: boolean }) => ( + + + +); - const currentValue = showFavorites ?? "all"; +const renderFavoriteLabel = + (text: string, filled: boolean) => + (isSelected: boolean): React.ReactNode => ( + <> + + {text} + + ); + +export const FavoriteFilter = ({ onChange, value }: Props) => { + const { t: translate } = useTranslation("dags"); return ( - - - - - + + onChange={onChange} + options={[ + { label: translate("filters.favorite.all"), value: "all" }, + { + label: renderFavoriteLabel(translate("filters.favorite.favorite"), true), + value: "true", + }, + { + label: renderFavoriteLabel(translate("filters.favorite.unfavorite"), false), + value: "false", + }, + ]} + value={value} + /> ); }; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/PausedFilter.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/PausedFilter.tsx index ec21d60510905..bd02ac68bfa48 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/PausedFilter.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/PausedFilter.tsx @@ -16,49 +16,25 @@ * specific language governing permissions and limitations * under the License. */ -import { Button, ButtonGroup } from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; +import { ButtonGroupToggle } from "src/components/ui"; + +type PausedValue = "all" | "false" | "true"; + type Props = { - readonly defaultShowPaused: string; - readonly onPausedChange: React.MouseEventHandler; - readonly showPaused: string | null; + readonly onChange: (value: PausedValue) => void; + readonly value: PausedValue; }; -export const PausedFilter = ({ defaultShowPaused, onPausedChange, showPaused }: Props) => { +export const PausedFilter = ({ onChange, value }: Props) => { const { t: translate } = useTranslation("dags"); - const currentValue = showPaused ?? defaultShowPaused; + const options = [ + { label: translate("filters.paused.all"), value: "all" as const }, + { label: translate("filters.paused.active"), value: "false" as const }, + { label: translate("filters.paused.paused"), value: "true" as const }, + ]; - return ( - - - - - - ); + return onChange={onChange} options={options} value={value} />; }; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx new file mode 100644 index 0000000000000..debab07a25bb7 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx @@ -0,0 +1,47 @@ +/*! + * 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 { Button } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; +import { LuUserRoundPen } from "react-icons/lu"; + +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 ( + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/StateFilters.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/StateFilters.tsx index 0533de4dc3a45..47dc79953b891 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/StateFilters.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/StateFilters.tsx @@ -16,101 +16,60 @@ * specific language governing permissions and limitations * under the License. */ -import { Button, ButtonGroup } from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; -import { LuUserRoundPen } from "react-icons/lu"; import { StateBadge } from "src/components/StateBadge"; +import { ButtonGroupToggle, type ButtonGroupOption } from "src/components/ui/ButtonGroupToggle"; + +type StateValue = "all" | "failed" | "queued" | "running" | "success"; type Props = { - readonly isAll: boolean; - readonly isFailed: boolean; - readonly isQueued: boolean; - readonly isRunning: boolean; - readonly isSuccess: boolean; - readonly needsReview: boolean; - readonly onStateChange: React.MouseEventHandler; + readonly onChange: (value: StateValue) => void; + readonly value: StateValue; }; -export const StateFilters = ({ - isAll, - isFailed, - isQueued, - isRunning, - isSuccess, - needsReview, - onStateChange, -}: Props) => { - const { t: translate } = useTranslation(["dags", "common", "hitl"]); +export const StateFilters = ({ onChange, value }: Props) => { + const { t: translate } = useTranslation(["dags", "common"]); + + const options: Array> = [ + { label: translate("dags:filters.paused.all"), value: "all" }, + { + label: ( + <> + + {translate("common:states.failed")} + + ), + value: "failed", + }, + { + label: ( + <> + + {translate("common:states.queued")} + + ), + value: "queued", + }, + { + label: ( + <> + + {translate("common:states.running")} + + ), + value: "running", + }, + { + label: ( + <> + + {translate("common:states.success")} + + ), + value: "success", + }, + ]; - return ( - - - - - - - - - ); + return onChange={onChange} options={options} value={value} />; }; 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 001cd0b94f8bd..f40d5829e3237 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 ( - <> + )} - + ); }; 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 39608f4a7452a..1f840cc5d40af 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(); - 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()); }); });