diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index defe64cce63c1..b79bb59e36471 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -99,18 +99,7 @@ "any": "Any", "or": "OR" }, - "filters": { - "dagDisplayNamePlaceholder": "Filter by Dag", - "keyPlaceholder": "Filter by XCom key", - "logicalDateFromPlaceholder": "Logical Date From", - "logicalDateToPlaceholder": "Logical Date To", - "mapIndexPlaceholder": "Filter by Map Index", - "runAfterFromPlaceholder": "Run After From", - "runAfterToPlaceholder": "Run After To", - "runIdPlaceholder": "Filter by Run ID", - "taskIdPlaceholder": "Filter by Task ID", - "triggeringUserPlaceholder": "Filter by triggering user" - }, + "filter": "Filter", "logicalDate": "Logical Date", "logout": "Logout", "logoutConfirmation": "You are about to logout from the application.", diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx new file mode 100644 index 0000000000000..b7bd83a35ba7e --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx @@ -0,0 +1,172 @@ +/*! + * 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, HStack } from "@chakra-ui/react"; +import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { MdAdd, MdClear } from "react-icons/md"; +import { useDebouncedCallback } from "use-debounce"; + +import { Menu } from "src/components/ui"; + +import { getDefaultFilterIcon } from "./defaultIcons"; +import { DateFilter } from "./filters/DateFilter"; +import { NumberFilter } from "./filters/NumberFilter"; +import { TextSearchFilter } from "./filters/TextSearchFilter"; +import type { FilterBarProps, FilterConfig, FilterState, FilterValue } from "./types"; + +const defaultInitialValues: Record = {}; + +const getFilterIcon = (config: FilterConfig) => config.icon ?? getDefaultFilterIcon(config.type); + +export const FilterBar = ({ + configs, + initialValues = defaultInitialValues, + maxVisibleFilters = 10, + onFiltersChange, +}: FilterBarProps) => { + const { t: translate } = useTranslation(["admin", "common"]); + const [filters, setFilters] = useState>(() => + Object.entries(initialValues) + .filter(([, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => { + const config = configs.find((con) => con.key === key); + + if (!config) { + throw new Error(`Filter config not found for key: ${key}`); + } + + return { + config, + id: `${key}-${Date.now()}`, + value, + }; + }), + ); + + const debouncedOnFiltersChange = useDebouncedCallback((filtersRecord: Record) => { + onFiltersChange(filtersRecord); + }, 100); + + const updateFiltersRecord = useCallback( + (updatedFilters: Array) => { + const filtersRecord = updatedFilters.reduce>((accumulator, filter) => { + if (filter.value !== null && filter.value !== undefined && filter.value !== "") { + accumulator[filter.config.key] = filter.value; + } + + return accumulator; + }, {}); + + debouncedOnFiltersChange(filtersRecord); + }, + [debouncedOnFiltersChange], + ); + + const addFilter = (config: FilterConfig) => { + const newFilter: FilterState = { + config, + id: `${config.key}-${Date.now()}`, + value: config.defaultValue ?? "", + }; + + const updatedFilters = [...filters, newFilter]; + + setFilters(updatedFilters); + updateFiltersRecord(updatedFilters); + }; + + const updateFilter = (id: string, value: FilterValue) => { + const updatedFilters = filters.map((filter) => (filter.id === id ? { ...filter, value } : filter)); + + setFilters(updatedFilters); + updateFiltersRecord(updatedFilters); + }; + + const removeFilter = (id: string) => { + const updatedFilters = filters.filter((filter) => filter.id !== id); + + setFilters(updatedFilters); + updateFiltersRecord(updatedFilters); + }; + + const resetFilters = () => { + setFilters([]); + onFiltersChange({}); + }; + + const availableConfigs = configs.filter( + (config) => !filters.some((filter) => filter.config.key === config.key), + ); + + const renderFilter = (filter: FilterState) => { + const props = { + filter, + onChange: (value: FilterValue) => updateFilter(filter.id, value), + onRemove: () => removeFilter(filter.id), + }; + + switch (filter.config.type) { + case "date": + return ; + case "number": + return ; + case "text": + return ; + default: + return undefined; + } + }; + + return ( + + {filters.slice(0, maxVisibleFilters).map(renderFilter)} + {availableConfigs.length > 0 && ( + + + + + + {availableConfigs.map((config) => ( + addFilter(config)} value={config.key}> + + {getFilterIcon(config)} + {config.label} + + + ))} + + + )} + {filters.length > 0 && ( + + )} + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterPill.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterPill.tsx new file mode 100644 index 0000000000000..f8ce033a3e07f --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterPill.tsx @@ -0,0 +1,161 @@ +/*! + * 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 { Box, HStack } from "@chakra-ui/react"; +import React from "react"; +import { useEffect, useRef, useState } from "react"; +import { MdClose } from "react-icons/md"; + +import { getDefaultFilterIcon } from "./defaultIcons"; +import type { FilterState, FilterValue } from "./types"; + +type FilterPillProps = { + readonly children: React.ReactNode; + readonly displayValue: string; + readonly filter: FilterState; + readonly hasValue: boolean; + readonly onChange: (value: FilterValue) => void; + readonly onRemove: () => void; +}; + +export const FilterPill = ({ + children, + displayValue, + filter, + hasValue, + onChange, + onRemove, +}: FilterPillProps) => { + const isEmpty = filter.value === null || filter.value === undefined || String(filter.value).trim() === ""; + const [isEditing, setIsEditing] = useState(isEmpty); + const inputRef = useRef(null); + const blurTimeoutRef = useRef(undefined); + + const handlePillClick = () => setIsEditing(true); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === "Escape") { + setIsEditing(false); + } + }; + + const handleBlur = () => { + blurTimeoutRef.current = setTimeout(() => setIsEditing(false), 150); + }; + + const handleFocus = () => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = undefined; + } + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + const input = inputRef.current; + const focusInput = () => { + input.focus(); + try { + input.select(); + } catch { + // NumberInputField doesn't support select() + } + }; + + requestAnimationFrame(focusInput); + } + }, [isEditing]); + + useEffect( + () => () => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + } + }, + [], + ); + + const childrenWithProps = React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + onBlur: handleBlur, + onChange, + onFocus: handleFocus, + onKeyDown: handleKeyDown, + ref: inputRef, + ...child.props, + } as Record); + } + + return child; + }); + + if (isEditing) { + return childrenWithProps; + } + + return ( + + + {filter.config.icon ?? getDefaultFilterIcon(filter.config.type)} + + {filter.config.label}: {displayValue} + + + { + event.stopPropagation(); + onRemove(); + }} + transition="all 0.2s" + w={6} + > + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx new file mode 100644 index 0000000000000..bb622ecbd919a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx @@ -0,0 +1,29 @@ +/*! + * 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 { MdCalendarToday, MdNumbers, MdTextFields } from "react-icons/md"; + +import type { FilterConfig } from "./types"; + +export const defaultFilterIcons = { + date: , + number: , + text: , +} as const; + +export const getDefaultFilterIcon = (type: FilterConfig["type"]) => defaultFilterIcons[type]; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/DateFilter.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/DateFilter.tsx new file mode 100644 index 0000000000000..6bd7b02a8ddfd --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/DateFilter.tsx @@ -0,0 +1,54 @@ +/*! + * 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 dayjs from "dayjs"; + +import { DateTimeInput } from "src/components/DateTimeInput"; + +import { FilterPill } from "../FilterPill"; +import type { FilterPluginProps } from "../types"; + +export const DateFilter = ({ filter, onChange, onRemove }: FilterPluginProps) => { + const hasValue = filter.value !== null && filter.value !== undefined && String(filter.value).trim() !== ""; + const displayValue = hasValue ? dayjs(String(filter.value)).format("MMM DD, YYYY, hh:mm A") : ""; + + const handleDateChange = (event: React.ChangeEvent) => { + const { value } = event.target; + + onChange(value || undefined); + }; + + return ( + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/NumberFilter.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/NumberFilter.tsx new file mode 100644 index 0000000000000..d8a89895c0d3f --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/NumberFilter.tsx @@ -0,0 +1,112 @@ +/*! + * 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 { useState, useEffect, forwardRef } from "react"; + +import { NumberInputField, NumberInputRoot } from "src/components/ui/NumberInput"; + +import { FilterPill } from "../FilterPill"; +import type { FilterPluginProps } from "../types"; + +const NumberInputWithRef = forwardRef< + HTMLInputElement, + { + readonly max?: number; + readonly min?: number; + readonly onBlur?: () => void; + readonly onFocus?: () => void; + readonly onKeyDown?: (event: React.KeyboardEvent) => void; + readonly onValueChange: (details: { value: string }) => void; + readonly placeholder?: string; + readonly value: string; + } +>((props, ref) => { + const { max, min, onBlur, onFocus, onKeyDown, onValueChange, placeholder, value } = props; + + return ( + + + + ); +}); + +NumberInputWithRef.displayName = "NumberInputWithRef"; + +export const NumberFilter = ({ filter, onChange, onRemove }: FilterPluginProps) => { + const hasValue = filter.value !== null && filter.value !== undefined && filter.value !== ""; + + const [inputValue, setInputValue] = useState(filter.value?.toString() ?? ""); + + useEffect(() => { + setInputValue(filter.value?.toString() ?? ""); + }, [filter.value]); + + const handleValueChange = ({ value }: { value: string }) => { + setInputValue(value); + + if (value === "") { + onChange(undefined); + + return; + } + + // Allow user to input negative sign for negative number + if (value === "-") { + return; + } + + const parsedValue = Number(value); + + if (!isNaN(parsedValue)) { + onChange(parsedValue); + } + }; + + return ( + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx new file mode 100644 index 0000000000000..8cca8cf08532e --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx @@ -0,0 +1,63 @@ +/*! + * 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 { useRef } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { InputWithAddon } from "../../ui"; +import { FilterPill } from "../FilterPill"; +import type { FilterPluginProps } from "../types"; + +export const TextSearchFilter = ({ filter, onChange, onRemove }: FilterPluginProps) => { + const inputRef = useRef(null); + + const hasValue = filter.value !== null && filter.value !== undefined && String(filter.value).trim() !== ""; + + const handleInputChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + + onChange(newValue || undefined); + }; + + useHotkeys( + "mod+k", + () => { + if (!filter.config.hotkeyDisabled) { + inputRef.current?.focus(); + } + }, + { enabled: !filter.config.hotkeyDisabled, preventDefault: true }, + ); + + return ( + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/index.ts b/airflow-core/src/airflow/ui/src/components/FilterBar/index.ts new file mode 100644 index 0000000000000..fa3c5c7621da0 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/index.ts @@ -0,0 +1,25 @@ +/*! + * 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. + */ +export { FilterBar } from "./FilterBar"; +export { FilterPill } from "./FilterPill"; +export { defaultFilterIcons, getDefaultFilterIcon } from "./defaultIcons"; +export { DateFilter } from "./filters/DateFilter"; +export { NumberFilter } from "./filters/NumberFilter"; +export { TextSearchFilter } from "./filters/TextSearchFilter"; +export type * from "./types"; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts new file mode 100644 index 0000000000000..0b3820e5c7107 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts @@ -0,0 +1,53 @@ +/*! + * 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 React from "react"; + +export type FilterValue = Date | number | string | null | undefined; + +export type FilterConfig = { + readonly defaultValue?: FilterValue; + readonly hotkeyDisabled?: boolean; + readonly icon?: React.ReactNode; + readonly key: string; + readonly label: string; + readonly max?: number; + readonly min?: number; + readonly placeholder?: string; + readonly required?: boolean; + readonly type: "date" | "number" | "text"; +}; + +export type FilterState = { + readonly config: FilterConfig; + readonly id: string; + readonly value: FilterValue; +}; + +export type FilterBarProps = { + readonly configs: Array; + readonly initialValues?: Record; + readonly maxVisibleFilters?: number; + readonly onFiltersChange: (filters: Record) => void; +}; + +export type FilterPluginProps = { + readonly filter: FilterState; + readonly onChange: (value: FilterValue) => void; + readonly onRemove: () => void; +}; diff --git a/airflow-core/src/airflow/ui/src/components/ui/InputWithAddon.tsx b/airflow-core/src/airflow/ui/src/components/ui/InputWithAddon.tsx new file mode 100644 index 0000000000000..b5a49e6cdf82d --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/ui/InputWithAddon.tsx @@ -0,0 +1,68 @@ +/*! + * 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 { InputProps } from "@chakra-ui/react"; +import { Box, Input, Text } from "@chakra-ui/react"; +import * as React from "react"; + +export type InputWithAddonProps = { + readonly label: string; + readonly width?: string; +} & InputProps; + +export const InputWithAddon = React.forwardRef((props, ref) => { + const { label, width = "220px", ...inputProps } = props; + + return ( + + + {label}: + + + + ); +}); + +InputWithAddon.displayName = "InputWithAddon"; 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 b7695029c4ea8..e39d15dd09d49 100644 --- a/airflow-core/src/airflow/ui/src/components/ui/index.ts +++ b/airflow-core/src/airflow/ui/src/components/ui/index.ts @@ -35,3 +35,4 @@ export * from "./Clipboard"; export * from "./Popover"; export * from "./Checkbox"; export * from "./ResetButton"; +export * from "./InputWithAddon"; diff --git a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx new file mode 100644 index 0000000000000..fa2913f4a03d2 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx @@ -0,0 +1,97 @@ +/*! + * 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 { useTranslation } from "react-i18next"; +import { FiBarChart } from "react-icons/fi"; +import { LuBrackets } from "react-icons/lu"; +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 { SearchParamsKeys } from "./searchParams"; + +export enum FilterTypes { + DATE = "date", + NUMBER = "number", + TEXT = "text", +} + +export const useFilterConfigs = () => { + const { t: translate } = useTranslation(["browse", "common", "admin"]); + + const filterConfigMap = { + [SearchParamsKeys.DAG_DISPLAY_NAME_PATTERN]: { + hotkeyDisabled: true, + icon: , + label: translate("common:dagId"), + type: FilterTypes.TEXT, + }, + [SearchParamsKeys.KEY_PATTERN]: { + icon: , + label: translate("admin:columns.key"), + type: FilterTypes.TEXT, + }, + [SearchParamsKeys.LOGICAL_DATE_GTE]: { + icon: , + label: translate("common:filters.logicalDateFromLabel", "Logical date from"), // TODO: delete the fallback after the translation freeze + type: FilterTypes.DATE, + }, + [SearchParamsKeys.LOGICAL_DATE_LTE]: { + icon: , + label: translate("common:filters.logicalDateToLabel", "Logical date to"), // TODO: delete the fallback after the translation freeze + type: FilterTypes.DATE, + }, + [SearchParamsKeys.MAP_INDEX]: { + icon: , + label: translate("common:mapIndex"), + min: -1, + type: FilterTypes.NUMBER, + }, + [SearchParamsKeys.RUN_AFTER_GTE]: { + icon: , + label: translate("common:filters.runAfterFromLabel", "Run after from"), // TODO: delete the fallback after the translation freeze + type: FilterTypes.DATE, + }, + [SearchParamsKeys.RUN_AFTER_LTE]: { + icon: , + label: translate("common:filters.runAfterToLabel", "Run after to"), // TODO: delete the fallback after the translation freeze + type: FilterTypes.DATE, + }, + [SearchParamsKeys.RUN_ID_PATTERN]: { + hotkeyDisabled: true, + icon: , + label: translate("common:runId"), + type: FilterTypes.TEXT, + }, + [SearchParamsKeys.TASK_ID_PATTERN]: { + hotkeyDisabled: true, + icon: , + label: translate("common:taskId"), + type: FilterTypes.TEXT, + }, + }; + + const getFilterConfig = (key: keyof typeof filterConfigMap): FilterConfig => ({ + key, + ...filterConfigMap[key], + }); + + return { getFilterConfig }; +}; diff --git a/airflow-core/src/airflow/ui/src/pages/XCom/XComFilters.tsx b/airflow-core/src/airflow/ui/src/pages/XCom/XComFilters.tsx index c7f36cfdd1c9f..4b7fec38b288b 100644 --- a/airflow-core/src/airflow/ui/src/pages/XCom/XComFilters.tsx +++ b/airflow-core/src/airflow/ui/src/pages/XCom/XComFilters.tsx @@ -16,206 +16,74 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Button, HStack, Text, VStack } from "@chakra-ui/react"; -import { useCallback, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { LuX } from "react-icons/lu"; -import { useSearchParams, useParams } from "react-router-dom"; +import { VStack } from "@chakra-ui/react"; +import { useMemo } from "react"; +import { useParams } from "react-router-dom"; -import { useTableURLState } from "src/components/DataTable/useTableUrlState"; -import { DateTimeInput } from "src/components/DateTimeInput"; -import { SearchBar } from "src/components/SearchBar"; -import { NumberInputField, NumberInputRoot } from "src/components/ui/NumberInput"; +import { FilterBar, type FilterValue } from "src/components/FilterBar"; import { SearchParamsKeys } from "src/constants/searchParams"; - -const FILTERS = [ - { - hotkeyDisabled: false, - key: SearchParamsKeys.KEY_PATTERN, - translationKey: "keyPlaceholder", - type: "search", - }, - { - hotkeyDisabled: true, - key: SearchParamsKeys.DAG_DISPLAY_NAME_PATTERN, - translationKey: "dagDisplayNamePlaceholder", - type: "search", - }, - { - hotkeyDisabled: true, - key: SearchParamsKeys.RUN_ID_PATTERN, - translationKey: "runIdPlaceholder", - type: "search", - }, - { - hotkeyDisabled: true, - key: SearchParamsKeys.TASK_ID_PATTERN, - translationKey: "taskIdPlaceholder", - type: "search", - }, - { - hotkeyDisabled: true, - key: SearchParamsKeys.MAP_INDEX, - translationKey: "mapIndexPlaceholder", - type: "number", - }, - { - key: SearchParamsKeys.LOGICAL_DATE_GTE, - translationKey: "logicalDateFromPlaceholder", - type: "datetime", - }, - { - key: SearchParamsKeys.LOGICAL_DATE_LTE, - translationKey: "logicalDateToPlaceholder", - type: "datetime", - }, - { - key: SearchParamsKeys.RUN_AFTER_GTE, - translationKey: "runAfterFromPlaceholder", - type: "datetime", - }, - { - key: SearchParamsKeys.RUN_AFTER_LTE, - translationKey: "runAfterToPlaceholder", - type: "datetime", - }, -] as const satisfies ReadonlyArray<{ - readonly hotkeyDisabled?: boolean; - readonly key: string; - readonly translationKey: string; - readonly type: "datetime" | "number" | "search"; -}>; +import { useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils"; export const XComFilters = () => { - const [searchParams, setSearchParams] = useSearchParams(); const { dagId = "~", mapIndex = "-1", runId = "~", taskId = "~" } = useParams(); - const { setTableURLState, tableURLState } = useTableURLState(); - const { pagination, sorting } = tableURLState; - const { t: translate } = useTranslation(["browse", "common"]); - const [resetKey, setResetKey] = useState(0); - const visibleFilters = useMemo( - () => - FILTERS.filter((filter) => { - switch (filter.key) { - case SearchParamsKeys.DAG_DISPLAY_NAME_PATTERN: - return dagId === "~"; - case SearchParamsKeys.KEY_PATTERN: - case SearchParamsKeys.LOGICAL_DATE_GTE: - case SearchParamsKeys.LOGICAL_DATE_LTE: - case SearchParamsKeys.RUN_AFTER_GTE: - case SearchParamsKeys.RUN_AFTER_LTE: - return true; - case SearchParamsKeys.MAP_INDEX: - return mapIndex === "-1"; - case SearchParamsKeys.RUN_ID_PATTERN: - return runId === "~"; - case SearchParamsKeys.TASK_ID_PATTERN: - return taskId === "~"; - default: - return true; - } - }), - [dagId, mapIndex, runId, taskId], - ); + const searchParamKeys = useMemo((): Array => { + const keys: Array = [ + SearchParamsKeys.KEY_PATTERN, + SearchParamsKeys.LOGICAL_DATE_GTE, + SearchParamsKeys.LOGICAL_DATE_LTE, + SearchParamsKeys.RUN_AFTER_GTE, + SearchParamsKeys.RUN_AFTER_LTE, + ]; - const handleFilterChange = useCallback( - (paramKey: string) => (value: string) => { - if (value === "") { - searchParams.delete(paramKey); - } else { - searchParams.set(paramKey, value); - } - setTableURLState({ - pagination: { ...pagination, pageIndex: 0 }, - sorting, - }); - setSearchParams(searchParams); - }, - [pagination, searchParams, setSearchParams, setTableURLState, sorting], - ); + if (dagId === "~") { + keys.push(SearchParamsKeys.DAG_DISPLAY_NAME_PATTERN); + } - const filterCount = useMemo( - () => - visibleFilters.filter((filter) => { - const value = searchParams.get(filter.key); + if (runId === "~") { + keys.push(SearchParamsKeys.RUN_ID_PATTERN); + } - return value !== null && value !== ""; - }).length, - [searchParams, visibleFilters], - ); + if (taskId === "~") { + keys.push(SearchParamsKeys.TASK_ID_PATTERN); + } - const handleResetFilters = useCallback(() => { - visibleFilters.forEach((filter) => { - searchParams.delete(filter.key); - }); - setTableURLState({ - pagination: { ...pagination, pageIndex: 0 }, - sorting, - }); - setSearchParams(searchParams); - setResetKey((prev) => prev + 1); - }, [pagination, searchParams, setSearchParams, setTableURLState, sorting, visibleFilters]); + if (mapIndex === "-1") { + keys.push(SearchParamsKeys.MAP_INDEX); + } - const renderFilterInput = (filter: (typeof FILTERS)[number]) => { - const { key, translationKey, type } = filter; + return keys; + }, [dagId, mapIndex, runId, taskId]); - return ( - - - {type !== "search" && {translate(`common:filters.${translationKey}`)}} - - {type === "search" ? ( - (() => { - const { hotkeyDisabled } = filter; + 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; + } + } + }); - return ( - - ); - })() - ) : type === "datetime" ? ( - handleFilterChange(key)(event.target.value)} - value={searchParams.get(key) ?? ""} - /> - ) : ( - handleFilterChange(key)(details.value)} - value={searchParams.get(key) ?? ""} - > - - - )} - - ); - }; + return values; + }, [searchParams, filterConfigs]); return ( - - {visibleFilters.map(renderFilterInput)} - - -   - - {filterCount > 0 && ( - - )} - - + ); }; diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts b/airflow-core/src/airflow/ui/src/utils/index.ts index d1badfb8f0033..c8d15c7cdb597 100644 --- a/airflow-core/src/airflow/ui/src/utils/index.ts +++ b/airflow-core/src/airflow/ui/src/utils/index.ts @@ -21,4 +21,5 @@ export { capitalize } from "./capitalize"; export { getDuration, renderDuration } from "./datetimeUtils"; export { getMetaKey } from "./getMetaKey"; export { useContainerWidth } from "./useContainerWidth"; +export { useFiltersHandler, type FilterableSearchParamsKeys } from "./useFiltersHandler"; export * from "./query"; diff --git a/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts new file mode 100644 index 0000000000000..9f4a595d85a9a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts @@ -0,0 +1,75 @@ +/*! + * 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 { useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { useTableURLState } from "src/components/DataTable/useTableUrlState"; +import type { FilterValue } from "src/components/FilterBar"; +import { useFilterConfigs } from "src/constants/filterConfigs"; +import type { SearchParamsKeys } from "src/constants/searchParams"; + +export type FilterableSearchParamsKeys = + | SearchParamsKeys.DAG_DISPLAY_NAME_PATTERN + | SearchParamsKeys.KEY_PATTERN + | SearchParamsKeys.LOGICAL_DATE_GTE + | SearchParamsKeys.LOGICAL_DATE_LTE + | SearchParamsKeys.MAP_INDEX + | SearchParamsKeys.RUN_AFTER_GTE + | SearchParamsKeys.RUN_AFTER_LTE + | SearchParamsKeys.RUN_ID_PATTERN + | SearchParamsKeys.TASK_ID_PATTERN; + +export const useFiltersHandler = (searchParamKeys: Array) => { + const { getFilterConfig } = useFilterConfigs(); + + const filterConfigs = useMemo( + () => searchParamKeys.map((key) => getFilterConfig(key)), + [searchParamKeys, getFilterConfig], + ); + const [searchParams, setSearchParams] = useSearchParams(); + const { setTableURLState, tableURLState } = useTableURLState(); + const { pagination, sorting } = tableURLState; + + const handleFiltersChange = useCallback( + (filters: Record) => { + filterConfigs.forEach((config) => { + const value = filters[config.key]; + + if (value === null || value === undefined || value === "") { + searchParams.delete(config.key); + } else { + searchParams.set(config.key, String(value)); + } + }); + + setTableURLState({ + pagination: { ...pagination, pageIndex: 0 }, + sorting, + }); + setSearchParams(searchParams); + }, + [filterConfigs, pagination, searchParams, setSearchParams, setTableURLState, sorting], + ); + + return { + filterConfigs, + handleFiltersChange, + searchParams, + }; +};