You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ka...@apache.org on 2025/09/16 22:42:50 UTC
(airflow) 02/04: Add filter for HITL TaskInstances page (#55620)
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 <10...@users.noreply.github.com>
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