diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx index 66644b57cbcef..849047dde072f 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx @@ -27,6 +27,7 @@ import { Menu } from "src/components/ui"; import { getDefaultFilterIcon } from "./defaultIcons"; import { DateFilter } from "./filters/DateFilter"; import { NumberFilter } from "./filters/NumberFilter"; +import { SelectFilter } from "./filters/SelectFilter"; import { TextSearchFilter } from "./filters/TextSearchFilter"; import type { FilterBarProps, FilterConfig, FilterState, FilterValue } from "./types"; @@ -126,6 +127,8 @@ export const FilterBar = ({ return ; case "number": return ; + case "select": + return ; case "text": return ; default: diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterPill.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterPill.tsx index f8ce033a3e07f..ef789617f203b 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterPill.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterPill.tsx @@ -26,7 +26,7 @@ import type { FilterState, FilterValue } from "./types"; type FilterPillProps = { readonly children: React.ReactNode; - readonly displayValue: string; + readonly displayValue: React.ReactNode | string; readonly filter: FilterState; readonly hasValue: boolean; readonly onChange: (value: FilterValue) => void; @@ -127,7 +127,7 @@ export const FilterPill = ({ > {filter.config.icon ?? getDefaultFilterIcon(filter.config.type)} - + {filter.config.label}: {displayValue} diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx index bb622ecbd919a..338b89422b789 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { MdCalendarToday, MdNumbers, MdTextFields } from "react-icons/md"; +import { MdCalendarToday, MdNumbers, MdTextFields, MdArrowDropDown } from "react-icons/md"; import type { FilterConfig } from "./types"; export const defaultFilterIcons = { date: , number: , + select: , text: , } as const; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx new file mode 100644 index 0000000000000..79374b27c23a4 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx @@ -0,0 +1,108 @@ +/*! + * 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 { createListCollection, Box, Text } from "@chakra-ui/react"; + +import { Select } from "src/components/ui"; + +import { FilterPill } from "../FilterPill"; +import type { FilterPluginProps, FilterConfig } from "../types"; + +type SelectOption = { + label: string; + value: string; +}; + +type SelectFilterConfig = { + options: Array; +}; + +export const SelectFilter = ({ filter, onChange, onRemove }: FilterPluginProps) => { + const config = filter.config as FilterConfig & SelectFilterConfig; + + const handleValueChange = ({ value }: { value: Array }) => { + const [newValue] = value; + + onChange(newValue); + + // Trigger blur to close the editing mode after selection + setTimeout(() => { + const activeElement = document.activeElement as HTMLElement; + + activeElement.blur(); + }, 0); + }; + + const hasValue = filter.value !== null && filter.value !== undefined && filter.value !== ""; + const displayValue = config.options.find((option) => option.value === String(filter.value))?.label; + + return ( + + + + {filter.config.label}: + + + + + + + {config.options.map((option) => ( + + {option.label} + + ))} + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts index 0b3820e5c7107..0f997c90671b6 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts @@ -28,9 +28,10 @@ export type FilterConfig = { readonly label: string; readonly max?: number; readonly min?: number; + readonly options?: Array<{ label: React.ReactNode | string; value: string }>; readonly placeholder?: string; readonly required?: boolean; - readonly type: "date" | "number" | "text"; + readonly type: "date" | "number" | "select" | "text"; }; export type FilterState = { diff --git a/airflow-core/src/airflow/ui/src/components/ui/Select/Trigger.tsx b/airflow-core/src/airflow/ui/src/components/ui/Select/Trigger.tsx index 9a6390d1e72e5..ce5f47f201662 100644 --- a/airflow-core/src/airflow/ui/src/components/ui/Select/Trigger.tsx +++ b/airflow-core/src/airflow/ui/src/components/ui/Select/Trigger.tsx @@ -24,14 +24,17 @@ import { CloseButton } from "../CloseButton"; type Props = { readonly clearable?: boolean; readonly isActive?: boolean; + readonly triggerProps?: ChakraSelect.TriggerProps; } & ChakraSelect.ControlProps; export const Trigger = forwardRef((props, ref) => { - const { children, clearable, isActive, ...rest } = props; + const { children, clearable, isActive, triggerProps, ...rest } = props; return ( - {children} + + {children} + {clearable ? ( diff --git a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx index 0dc09b1fd55c9..8c83a75b84dc5 100644 --- a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx +++ b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx @@ -24,17 +24,19 @@ import { MdDateRange, MdSearch } from "react-icons/md"; import { DagIcon } from "src/assets/DagIcon"; import { TaskIcon } from "src/assets/TaskIcon"; import type { FilterConfig } from "src/components/FilterBar"; +import { StateBadge } from "src/components/StateBadge"; import { SearchParamsKeys } from "./searchParams"; export enum FilterTypes { DATE = "date", NUMBER = "number", + SELECT = "select", TEXT = "text", } export const useFilterConfigs = () => { - const { t: translate } = useTranslation(["browse", "common", "admin"]); + const { t: translate } = useTranslation(["browse", "common", "admin", "hitl"]); const filterConfigMap = { [SearchParamsKeys.AFTER]: { @@ -89,6 +91,22 @@ export const useFilterConfigs = () => { min: -1, type: FilterTypes.NUMBER, }, + [SearchParamsKeys.RESPONSE_RECEIVED]: { + icon: , + label: translate("hitl:requiredActionState"), + options: [ + { label: translate("hitl:filters.response.all"), value: "all" }, + { + label: {translate("hitl:filters.response.pending")}, + value: "false", + }, + { + label: {translate("hitl:filters.response.received")}, + value: "true", + }, + ], + type: FilterTypes.SELECT, + }, [SearchParamsKeys.RUN_AFTER_GTE]: { icon: , label: translate("common:filters.runAfterFrom"), diff --git a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLFilters.tsx b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLFilters.tsx new file mode 100644 index 0000000000000..84aec7292c81e --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLFilters.tsx @@ -0,0 +1,84 @@ +/*! + * 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 { VStack } from "@chakra-ui/react"; +import { useMemo } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; + +import { FilterBar, type FilterValue } from "src/components/FilterBar"; +import { SearchParamsKeys } from "src/constants/searchParams"; +import { useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils"; + +export const HITLFilters = ({ onResponseChange }: { readonly onResponseChange: () => void }) => { + const { dagId = "~", taskId = "~" } = useParams(); + const [urlSearchParams] = useSearchParams(); + const responseReceived = urlSearchParams.get(SearchParamsKeys.RESPONSE_RECEIVED); + + const searchParamKeys = useMemo((): Array => { + const keys: Array = []; + + if (dagId === "~") { + keys.push(SearchParamsKeys.DAG_DISPLAY_NAME_PATTERN); + } + + if (taskId === "~") { + keys.push(SearchParamsKeys.TASK_ID_PATTERN); + } + + keys.push(SearchParamsKeys.RESPONSE_RECEIVED); + + return keys; + }, [dagId, taskId]); + + const { filterConfigs, handleFiltersChange, searchParams } = useFiltersHandler(searchParamKeys); + + const initialValues = useMemo(() => { + const values: Record = {}; + + filterConfigs.forEach((config) => { + const value = searchParams.get(config.key); + + if (value !== null && value !== "") { + if (config.type === "number") { + const parsedValue = Number(value); + + values[config.key] = isNaN(parsedValue) ? value : parsedValue; + } else { + values[config.key] = value; + } + } + }); + + values[SearchParamsKeys.RESPONSE_RECEIVED] = responseReceived; + + return values; + }, [filterConfigs, responseReceived, searchParams]); + + return ( + + { + onResponseChange(); + handleFiltersChange(filters); + }} + /> + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx index 65fb4a33db92f..376c2abf37f29 100644 --- a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx +++ b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Heading, Link, createListCollection } from "@chakra-ui/react"; +import { Heading, Link, VStack } from "@chakra-ui/react"; import type { ColumnDef } from "@tanstack/react-table"; import type { TFunction } from "i18next"; import { useCallback } from "react"; @@ -31,15 +31,20 @@ import { ErrorAlert } from "src/components/ErrorAlert"; import { StateBadge } from "src/components/StateBadge"; import Time from "src/components/Time"; import { TruncatedText } from "src/components/TruncatedText"; -import { Select } from "src/components/ui"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { getHITLState } from "src/utils/hitl"; import { getTaskInstanceLink } from "src/utils/links"; +import { HITLFilters } from "./HITLFilters"; + type TaskInstanceRow = { row: { original: HITLDetail } }; -const { OFFSET: OFFSET_PARAM, RESPONSE_RECEIVED: RESPONSE_RECEIVED_PARAM }: SearchParamsKeysType = - SearchParamsKeys; +const { + DAG_DISPLAY_NAME_PATTERN, + OFFSET: OFFSET_PARAM, + RESPONSE_RECEIVED: RESPONSE_RECEIVED_PARAM, + TASK_ID_PATTERN, +}: SearchParamsKeysType = SearchParamsKeys; const taskInstanceColumns = ({ dagId, @@ -122,71 +127,46 @@ export const HITLTaskInstances = () => { const [sort] = sorting; const responseReceived = searchParams.get(RESPONSE_RECEIVED_PARAM); + const dagIdPattern = searchParams.get(DAG_DISPLAY_NAME_PATTERN) ?? undefined; + const taskIdPattern = searchParams.get(TASK_ID_PATTERN) ?? undefined; + const filterResponseReceived = searchParams.get(RESPONSE_RECEIVED_PARAM) ?? undefined; + + // Use the filter value if available, otherwise fall back to the old responseReceived param + const effectiveResponseReceived = filterResponseReceived ?? responseReceived; + const { data, error, isLoading } = useTaskInstanceServiceGetHitlDetails({ dagId: dagId ?? "~", + dagIdPattern, dagRunId: runId ?? "~", limit: pagination.pageSize, offset: pagination.pageIndex * pagination.pageSize, orderBy: sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : [], - responseReceived: Boolean(responseReceived) ? responseReceived === "true" : undefined, - state: responseReceived === "false" ? ["deferred"] : undefined, + responseReceived: + Boolean(effectiveResponseReceived) && effectiveResponseReceived !== "all" + ? effectiveResponseReceived === "true" + : undefined, + state: effectiveResponseReceived === "false" ? ["deferred"] : undefined, taskId, + taskIdPattern, }); - const enabledOptions = createListCollection({ - items: [ - { label: translate("filters.response.all"), value: "all" }, - { label: translate("filters.response.pending"), value: "false" }, - { label: translate("filters.response.received"), value: "true" }, - ], - }); - - const handleResponseChange = useCallback( - ({ value }: { value: Array }) => { - const [val] = value; - - if (val === undefined || val === "all") { - searchParams.delete(RESPONSE_RECEIVED_PARAM); - } else { - searchParams.set(RESPONSE_RECEIVED_PARAM, val); - } - setTableURLState({ - pagination: { ...pagination, pageIndex: 0 }, - sorting, - }); - searchParams.delete(OFFSET_PARAM); - setSearchParams(searchParams); - }, - [searchParams, setSearchParams, pagination, sorting, setTableURLState], - ); + const handleResponseChange = useCallback(() => { + setTableURLState({ + pagination: { ...pagination, pageIndex: 0 }, + sorting, + }); + searchParams.delete(OFFSET_PARAM); + setSearchParams(searchParams); + }, [pagination, searchParams, setSearchParams, setTableURLState, sorting]); return ( - + {!Boolean(dagId) && !Boolean(runId) && !Boolean(taskId) ? ( {data?.total_entries} {translate("requiredAction", { count: data?.total_entries })} ) : undefined} - - - {translate("requiredActionState")} - - - - - {enabledOptions.items.map((option) => ( - - {option.label} - - ))} - - - + { onStateChange={setTableURLState} total={data?.total_entries} /> - + ); }; diff --git a/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts index 241a6e9287a4d..ec1bcf710f2cd 100644 --- a/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts +++ b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts @@ -35,6 +35,7 @@ export type FilterableSearchParamsKeys = | SearchParamsKeys.LOGICAL_DATE_GTE | SearchParamsKeys.LOGICAL_DATE_LTE | SearchParamsKeys.MAP_INDEX + | SearchParamsKeys.RESPONSE_RECEIVED | SearchParamsKeys.RUN_AFTER_GTE | SearchParamsKeys.RUN_AFTER_LTE | SearchParamsKeys.RUN_ID