This is an automated email from the ASF dual-hosted git repository. kaxilnaik pushed a commit to branch v3-1-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 410f49fa0e5b81f59e26c8b10f98b654077ca79b Author: Guan Ming(Wesley) Chiu <[email protected]> AuthorDate: Wed Sep 17 02:07:58 2025 +0800 Add filter for HITL TaskInstances page (#55620) * Add select filter support in FilterBar * Support React.Element for select options (cherry picked from commit 8e91ac5e59c9baa04e24cd4696d39f180d116a77) --- .../ui/src/components/FilterBar/FilterBar.tsx | 3 + .../ui/src/components/FilterBar/FilterPill.tsx | 4 +- .../ui/src/components/FilterBar/defaultIcons.tsx | 3 +- .../components/FilterBar/filters/SelectFilter.tsx | 108 +++++++++++++++++++++ .../airflow/ui/src/components/FilterBar/types.ts | 3 +- .../ui/src/components/ui/Select/Trigger.tsx | 7 +- .../src/airflow/ui/src/constants/filterConfigs.tsx | 20 +++- .../ui/src/pages/HITLTaskInstances/HITLFilters.tsx | 84 ++++++++++++++++ .../pages/HITLTaskInstances/HITLTaskInstances.tsx | 88 +++++++---------- .../src/airflow/ui/src/utils/useFiltersHandler.ts | 1 + 10 files changed, 260 insertions(+), 61 deletions(-) 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 66644b57cbc..849047dde07 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 <DateFilter key={filter.id} {...props} />; case "number": return <NumberFilter key={filter.id} {...props} />; + case "select": + return <SelectFilter key={filter.id} {...props} />; case "text": return <TextSearchFilter key={filter.id} {...props} />; 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 f8ce033a3e0..ef789617f20 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 = ({ > <HStack align="center" gap={1}> {filter.config.icon ?? getDefaultFilterIcon(filter.config.type)} - <Box flex="1" px={2} py={2}> + <Box alignItems="center" display="flex" flex="1" gap={2} px={2}> {filter.config.label}: {displayValue} </Box> 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 bb622ecbd91..338b89422b7 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: <MdCalendarToday />, number: <MdNumbers />, + select: <MdArrowDropDown />, text: <MdTextFields />, } 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 00000000000..79374b27c23 --- /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<SelectOption>; +}; + +export const SelectFilter = ({ filter, onChange, onRemove }: FilterPluginProps) => { + const config = filter.config as FilterConfig & SelectFilterConfig; + + const handleValueChange = ({ value }: { value: Array<string> }) => { + 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 ( + <FilterPill + displayValue={displayValue ?? ""} + filter={filter} + hasValue={hasValue} + onChange={onChange} + onRemove={onRemove} + > + <Box + alignItems="center" + bg="bg" + border="0.5px solid" + borderColor="border" + borderRadius="full" + display="flex" + h="full" + overflow="hidden" + width="330px" + > + <Text + alignItems="center" + bg="gray.muted" + borderLeftRadius="full" + display="flex" + fontSize="sm" + fontWeight="medium" + h="full" + px={4} + py={2} + whiteSpace="nowrap" + > + {filter.config.label}: + </Text> + <Select.Root + border="none" + collection={createListCollection({ items: config.options })} + h="full" + onValueChange={handleValueChange} + value={hasValue ? [String(filter.value)] : []} + > + <Select.Trigger triggerProps={{ border: "none" }}> + <Select.ValueText placeholder={filter.config.placeholder} /> + </Select.Trigger> + <Select.Content> + {config.options.map((option) => ( + <Select.Item item={option} key={option.value}> + {option.label} + </Select.Item> + ))} + </Select.Content> + </Select.Root> + </Box> + </FilterPill> + ); +}; 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 0b3820e5c71..0f997c90671 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 9a6390d1e72..ce5f47f2016 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<HTMLButtonElement, Props>((props, ref) => { - const { children, clearable, isActive, ...rest } = props; + const { children, clearable, isActive, triggerProps, ...rest } = props; return ( <ChakraSelect.Control {...rest}> - <ChakraSelect.Trigger ref={ref}>{children}</ChakraSelect.Trigger> + <ChakraSelect.Trigger ref={ref} {...triggerProps}> + {children} + </ChakraSelect.Trigger> <ChakraSelect.IndicatorGroup _rtl={{ bottom: 0, left: 0, right: "auto", top: 0 }}> {clearable ? ( <ChakraSelect.ClearTrigger asChild> diff --git a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx index 0dc09b1fd55..8c83a75b84d 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: <FiUser />, + label: translate("hitl:requiredActionState"), + options: [ + { label: translate("hitl:filters.response.all"), value: "all" }, + { + label: <StateBadge state="deferred">{translate("hitl:filters.response.pending")}</StateBadge>, + value: "false", + }, + { + label: <StateBadge state="success">{translate("hitl:filters.response.received")}</StateBadge>, + value: "true", + }, + ], + type: FilterTypes.SELECT, + }, [SearchParamsKeys.RUN_AFTER_GTE]: { icon: <MdDateRange />, 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 00000000000..84aec7292c8 --- /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<FilterableSearchParamsKeys> => { + const keys: Array<FilterableSearchParamsKeys> = []; + + 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<string, FilterValue> = {}; + + 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 ( + <VStack align="start" pt={2}> + <FilterBar + configs={filterConfigs} + initialValues={initialValues} + onFiltersChange={(filters) => { + onResponseChange(); + handleFiltersChange(filters); + }} + /> + </VStack> + ); +}; 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 65fb4a33db9..376c2abf37f 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<string> }) => { - 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 ( - <Box> + <VStack align="start"> {!Boolean(dagId) && !Boolean(runId) && !Boolean(taskId) ? ( <Heading size="md"> {data?.total_entries} {translate("requiredAction", { count: data?.total_entries })} </Heading> ) : undefined} - <Box mt={3}> - <Select.Root - collection={enabledOptions} - maxW="250px" - onValueChange={handleResponseChange} - value={[responseReceived ?? "all"]} - > - <Select.Label fontSize="xs">{translate("requiredActionState")}</Select.Label> - <Select.Trigger isActive={Boolean(responseReceived)}> - <Select.ValueText /> - </Select.Trigger> - <Select.Content> - {enabledOptions.items.map((option) => ( - <Select.Item item={option} key={option.label}> - {option.label} - </Select.Item> - ))} - </Select.Content> - </Select.Root> - </Box> + <HITLFilters onResponseChange={handleResponseChange} /> <DataTable columns={taskInstanceColumns({ dagId, @@ -202,6 +182,6 @@ export const HITLTaskInstances = () => { onStateChange={setTableURLState} total={data?.total_entries} /> - </Box> + </VStack> ); }; diff --git a/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts index 241a6e9287a..ec1bcf710f2 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
