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 73ff74a48480e570420784f74d8475aa412e5800 Author: LI,JHE-CHEN <[email protected]> AuthorDate: Wed Sep 10 12:08:17 2025 -0400 Add reusable FilterBar component for date, number, text input (#54895) * feat(ui): add reusable FilterBar component for date, number, text input * fix(ui): improve FilterPill focus and blur handling * refactor(ui): create useFiltersHandler hook for FilterBar * refactor(ui):Support negative number input & fix FilterPill DOM nesting * feat(ui): create centralized filter config * fix(ui): apply style feedback to FilterBar * refactor(ui): move getFilterConfig calls into useFiltersHandler & remove placeholder translation fallback * refactor: simplify DateFilter with dayjs * fix(ui): Add FilterTypes enum to avoid type casting * fix(i18n): add missing translation keys * fix(i18n): move filter translation key to free exemptions * fix(i18n): Replace dagName translation with dag ID * fix(i18n): simplify filter translations with fallbacks * fix: modify reset button translation key --------- Co-authored-by: Brent Bovenzi <[email protected]> (cherry picked from commit 2a1c68684cc0501f1a8e279bfc6ad5fe409906a4) --- .../airflow/ui/public/i18n/locales/en/common.json | 13 +- .../ui/src/components/FilterBar/FilterBar.tsx | 172 +++++++++++++++ .../ui/src/components/FilterBar/FilterPill.tsx | 161 ++++++++++++++ .../FilterBar/defaultIcons.tsx} | 15 +- .../components/FilterBar/filters/DateFilter.tsx | 54 +++++ .../components/FilterBar/filters/NumberFilter.tsx | 112 ++++++++++ .../FilterBar/filters/TextSearchFilter.tsx | 63 ++++++ .../src/{utils => components/FilterBar}/index.ts | 13 +- .../airflow/ui/src/components/FilterBar/types.ts | 53 +++++ .../ui/src/components/ui/InputWithAddon.tsx | 68 ++++++ .../src/airflow/ui/src/components/ui/index.ts | 1 + .../src/airflow/ui/src/constants/filterConfigs.tsx | 97 +++++++++ .../src/airflow/ui/src/pages/XCom/XComFilters.tsx | 236 +++++---------------- airflow-core/src/airflow/ui/src/utils/index.ts | 1 + .../src/airflow/ui/src/utils/useFiltersHandler.ts | 75 +++++++ 15 files changed, 927 insertions(+), 207 deletions(-) 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 defe64cce63..b79bb59e364 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 00000000000..b7bd83a35ba --- /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<string, FilterValue> = {}; + +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<Array<FilterState>>(() => + 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<string, FilterValue>) => { + onFiltersChange(filtersRecord); + }, 100); + + const updateFiltersRecord = useCallback( + (updatedFilters: Array<FilterState>) => { + const filtersRecord = updatedFilters.reduce<Record<string, FilterValue>>((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 <DateFilter key={filter.id} {...props} />; + case "number": + return <NumberFilter key={filter.id} {...props} />; + case "text": + return <TextSearchFilter key={filter.id} {...props} />; + default: + return undefined; + } + }; + + return ( + <HStack gap={2} wrap="wrap"> + {filters.slice(0, maxVisibleFilters).map(renderFilter)} + {availableConfigs.length > 0 && ( + <Menu.Root> + <Menu.Trigger asChild> + <Button + _hover={{ bg: "colorPalette.subtle" }} + bg="gray.muted" + borderRadius="full" + variant="outline" + > + <MdAdd /> + {translate("common:filter")} + </Button> + </Menu.Trigger> + <Menu.Content> + {availableConfigs.map((config) => ( + <Menu.Item key={config.key} onClick={() => addFilter(config)} value={config.key}> + <HStack gap={2}> + {getFilterIcon(config)} + {config.label} + </HStack> + </Menu.Item> + ))} + </Menu.Content> + </Menu.Root> + )} + {filters.length > 0 && ( + <Button borderRadius="full" colorPalette="gray" onClick={resetFilters} size="sm" variant="outline"> + <MdClear /> + {translate("admin:formActions.reset")} + </Button> + )} + </HStack> + ); +}; 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 00000000000..f8ce033a3e0 --- /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<HTMLInputElement>(null); + const blurTimeoutRef = useRef<NodeJS.Timeout | undefined>(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<string, unknown>); + } + + return child; + }); + + if (isEditing) { + return childrenWithProps; + } + + return ( + <Box + _hover={{ bg: "colorPalette.subtle" }} + as="button" + bg={hasValue ? "blue.muted" : "gray.muted"} + borderRadius="full" + color="colorPalette.fg" + colorPalette={hasValue ? "blue" : "gray"} + cursor="pointer" + display="flex" + fontSize="sm" + fontWeight="medium" + h="10" + onClick={handlePillClick} + px={4} + > + <HStack align="center" gap={1}> + {filter.config.icon ?? getDefaultFilterIcon(filter.config.type)} + <Box flex="1" px={2} py={2}> + {filter.config.label}: {displayValue} + </Box> + + <Box + _hover={{ + bg: "gray.100", + color: "gray.600", + }} + alignItems="center" + aria-label={`Remove ${filter.config.label} filter`} + bg="transparent" + borderRadius="full" + color="gray.400" + cursor="pointer" + display="flex" + h={6} + justifyContent="center" + mr={1} + onClick={(event) => { + event.stopPropagation(); + onRemove(); + }} + transition="all 0.2s" + w={6} + > + <MdClose size={16} /> + </Box> + </HStack> + </Box> + ); +}; diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts b/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx similarity index 70% copy from airflow-core/src/airflow/ui/src/utils/index.ts copy to airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx index d1badfb8f00..bb622ecbd91 100644 --- a/airflow-core/src/airflow/ui/src/utils/index.ts +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/defaultIcons.tsx @@ -16,9 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +import { MdCalendarToday, MdNumbers, MdTextFields } from "react-icons/md"; -export { capitalize } from "./capitalize"; -export { getDuration, renderDuration } from "./datetimeUtils"; -export { getMetaKey } from "./getMetaKey"; -export { useContainerWidth } from "./useContainerWidth"; -export * from "./query"; +import type { FilterConfig } from "./types"; + +export const defaultFilterIcons = { + date: <MdCalendarToday />, + number: <MdNumbers />, + text: <MdTextFields />, +} 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 00000000000..6bd7b02a8dd --- /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<HTMLInputElement>) => { + const { value } = event.target; + + onChange(value || undefined); + }; + + return ( + <FilterPill + displayValue={displayValue} + filter={filter} + hasValue={hasValue} + onChange={onChange} + onRemove={onRemove} + > + <DateTimeInput + borderRadius="full" + onChange={handleDateChange} + placeholder={filter.config.placeholder} + size="sm" + value={filter.value !== null && filter.value !== undefined ? String(filter.value) : ""} + width="200px" + /> + </FilterPill> + ); +}; 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 00000000000..d8a89895c0d --- /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 ( + <NumberInputRoot + borderRadius="full" + max={max} + min={min} + onValueChange={onValueChange} + overflow="hidden" + value={value} + width="180px" + > + <NumberInputField + borderRadius="full" + onBlur={onBlur} + onFocus={onFocus} + onKeyDown={onKeyDown} + placeholder={placeholder} + ref={ref} + /> + </NumberInputRoot> + ); +}); + +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 ( + <FilterPill + displayValue={hasValue ? String(filter.value) : ""} + filter={filter} + hasValue={hasValue} + onChange={onChange} + onRemove={onRemove} + > + <NumberInputWithRef + max={filter.config.max} + min={filter.config.min} + onValueChange={handleValueChange} + placeholder={filter.config.placeholder} + value={inputValue} + /> + </FilterPill> + ); +}; 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 00000000000..8cca8cf0853 --- /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<HTMLInputElement>(null); + + const hasValue = filter.value !== null && filter.value !== undefined && String(filter.value).trim() !== ""; + + const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { + 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 ( + <FilterPill + displayValue={hasValue ? String(filter.value) : ""} + filter={filter} + hasValue={hasValue} + onChange={onChange} + onRemove={onRemove} + > + <InputWithAddon + label={filter.config.label} + onChange={handleInputChange} + placeholder={filter.config.placeholder} + value={String(filter.value ?? "")} + /> + </FilterPill> + ); +}; diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts b/airflow-core/src/airflow/ui/src/components/FilterBar/index.ts similarity index 69% copy from airflow-core/src/airflow/ui/src/utils/index.ts copy to airflow-core/src/airflow/ui/src/components/FilterBar/index.ts index d1badfb8f00..fa3c5c7621d 100644 --- a/airflow-core/src/airflow/ui/src/utils/index.ts +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/index.ts @@ -16,9 +16,10 @@ * specific language governing permissions and limitations * under the License. */ - -export { capitalize } from "./capitalize"; -export { getDuration, renderDuration } from "./datetimeUtils"; -export { getMetaKey } from "./getMetaKey"; -export { useContainerWidth } from "./useContainerWidth"; -export * from "./query"; +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 00000000000..0b3820e5c71 --- /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<FilterConfig>; + readonly initialValues?: Record<string, FilterValue>; + readonly maxVisibleFilters?: number; + readonly onFiltersChange: (filters: Record<string, FilterValue>) => 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 00000000000..b5a49e6cdf8 --- /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<HTMLInputElement, InputWithAddonProps>((props, ref) => { + const { label, width = "220px", ...inputProps } = props; + + return ( + <Box + alignItems="center" + bg="bg" + border="1px solid" + borderColor="border" + borderRadius="full" + display="flex" + width={width} + > + <Text + bg="gray.muted" + borderLeftRadius="full" + color="colorPalette.fg" + colorPalette="gray" + fontSize="sm" + fontWeight="medium" + px={3} + py={2} + whiteSpace="nowrap" + > + {label}: + </Text> + <Input + bg="transparent" + border="none" + borderRadius="0" + flex="1" + outline="none" + ref={ref} + size="sm" + {...inputProps} + /> + </Box> + ); +}); + +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 b7695029c4e..e39d15dd09d 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 00000000000..fa2913f4a03 --- /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: <DagIcon />, + label: translate("common:dagId"), + type: FilterTypes.TEXT, + }, + [SearchParamsKeys.KEY_PATTERN]: { + icon: <MdSearch />, + label: translate("admin:columns.key"), + type: FilterTypes.TEXT, + }, + [SearchParamsKeys.LOGICAL_DATE_GTE]: { + icon: <MdDateRange />, + label: translate("common:filters.logicalDateFromLabel", "Logical date from"), // TODO: delete the fallback after the translation freeze + type: FilterTypes.DATE, + }, + [SearchParamsKeys.LOGICAL_DATE_LTE]: { + icon: <MdDateRange />, + label: translate("common:filters.logicalDateToLabel", "Logical date to"), // TODO: delete the fallback after the translation freeze + type: FilterTypes.DATE, + }, + [SearchParamsKeys.MAP_INDEX]: { + icon: <LuBrackets />, + label: translate("common:mapIndex"), + min: -1, + type: FilterTypes.NUMBER, + }, + [SearchParamsKeys.RUN_AFTER_GTE]: { + icon: <MdDateRange />, + label: translate("common:filters.runAfterFromLabel", "Run after from"), // TODO: delete the fallback after the translation freeze + type: FilterTypes.DATE, + }, + [SearchParamsKeys.RUN_AFTER_LTE]: { + icon: <MdDateRange />, + 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: <FiBarChart />, + label: translate("common:runId"), + type: FilterTypes.TEXT, + }, + [SearchParamsKeys.TASK_ID_PATTERN]: { + hotkeyDisabled: true, + icon: <TaskIcon />, + 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 c7f36cfdd1c..4b7fec38b28 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<FilterableSearchParamsKeys> => { + const keys: Array<FilterableSearchParamsKeys> = [ + 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 ( - <Box key={key} w="200px"> - <Box marginBottom={1} minHeight="1.2em"> - {type !== "search" && <Text fontSize="xs">{translate(`common:filters.${translationKey}`)}</Text>} - </Box> - {type === "search" ? ( - (() => { - const { hotkeyDisabled } = filter; + 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; + } + } + }); - return ( - <SearchBar - defaultValue={searchParams.get(key) ?? ""} - hideAdvanced - hotkeyDisabled={hotkeyDisabled} - key={`${key}-${resetKey}`} - onChange={handleFilterChange(key)} - placeHolder={translate(`common:filters.${translationKey}`)} - /> - ); - })() - ) : type === "datetime" ? ( - <DateTimeInput - key={`${key}-${resetKey}`} - onChange={(event) => handleFilterChange(key)(event.target.value)} - value={searchParams.get(key) ?? ""} - /> - ) : ( - <NumberInputRoot - key={`${key}-${resetKey}`} - min={-1} - onValueChange={(details) => handleFilterChange(key)(details.value)} - value={searchParams.get(key) ?? ""} - > - <NumberInputField placeholder={translate(`common:filters.${translationKey}`)} /> - </NumberInputRoot> - )} - </Box> - ); - }; + return values; + }, [searchParams, filterConfigs]); return ( <VStack align="start" gap={4} paddingY="4px"> - <HStack flexWrap="wrap" gap={4}> - {visibleFilters.map(renderFilterInput)} - <Box> - <Text fontSize="xs" marginBottom={1}> - - </Text> - {filterCount > 0 && ( - <Button onClick={handleResetFilters} size="md" variant="outline"> - <LuX /> - {translate("common:table.filterReset", { count: filterCount })} - </Button> - )} - </Box> - </HStack> + <FilterBar + configs={filterConfigs} + initialValues={initialValues} + onFiltersChange={handleFiltersChange} + /> </VStack> ); }; diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts b/airflow-core/src/airflow/ui/src/utils/index.ts index d1badfb8f00..c8d15c7cdb5 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 00000000000..9f4a595d85a --- /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<FilterableSearchParamsKeys>) => { + 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<string, FilterValue>) => { + 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, + }; +};
