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