This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 745bdbfb9bc UI: Add match-anywhere toggle to the tags filter (#73616)
745bdbfb9bc is described below
commit 745bdbfb9bcb263fdadf578772900730bdd0f769
Author: krisztiansala <[email protected]>
AuthorDate: Wed Oct 21 14:42:44 2026 +0300
UI: Add match-anywhere toggle to the tags filter (#73616)
* UI: Add match-anywhere toggle to the tags filter
Follow-up to #66015. The Dags list Tags pill queries ``/dagTags`` with
``tag_name_prefix_pattern`` on every keystroke, so tag search only matches
from the start of the name. Wire the existing advanced-search toggle into
``MultiSelectPill`` and set ``supportsAdvancedSearch`` on the tags config so
users can opt back into substring (``tag_name_pattern``) matching, persisted
per-filter in localStorage like the other searchbars.
* Add newsfragment for tags match-anywhere toggle
* Drop PR cross-reference from newsfragment
* Address review: remove newsfragment, fix pill border with match-mode
toggle
- Drop newsfragments/73616.feature.rst per review
- Add roundedRight option to AdvancedSearchToggle addon variant so it
doesn't keep a rounded corner when the match-mode toggle follows it
- Fix prettier formatting in TagsFilter.test.tsx
---------
Co-authored-by: krisztiansala <[email protected]>
---
.../ui/src/components/AdvancedSearchToggle.tsx | 6 +-
.../FilterBar/filters/MultiSelectPill.tsx | 15 +++
.../FilterBar/filters/TagsFilter.test.tsx | 143 +++++++++++++++++++++
.../components/FilterBar/filters/TagsFilter.tsx | 3 +
.../airflow/ui/src/components/FilterBar/types.ts | 4 +-
.../src/airflow/ui/src/constants/filterConfigs.tsx | 1 +
.../airflow/ui/src/queries/useDagTagsInfinite.ts | 15 ++-
7 files changed, 182 insertions(+), 5 deletions(-)
diff --git
a/airflow-core/src/airflow/ui/src/components/AdvancedSearchToggle.tsx
b/airflow-core/src/airflow/ui/src/components/AdvancedSearchToggle.tsx
index 0380126bb14..bded2026233 100644
--- a/airflow-core/src/airflow/ui/src/components/AdvancedSearchToggle.tsx
+++ b/airflow-core/src/airflow/ui/src/components/AdvancedSearchToggle.tsx
@@ -25,6 +25,9 @@ import { IconButton, Tooltip } from "src/system-components";
export type AdvancedSearchToggleProps = {
readonly enabled: boolean;
readonly onToggle: (enabled: boolean) => void;
+ // For the "addon" variant: whether this toggle is the last addon, owning
the pill's
+ // rounded right corner. False when another addon (e.g. the match-mode
toggle) follows.
+ readonly roundedRight?: boolean;
readonly size?: "2xs" | "sm" | "xs";
// "standalone" → free-floating IconButton next to a SearchBar.
// "addon" → flush right addon inside a rounded pill input, mirroring
the label on the left.
@@ -34,6 +37,7 @@ export type AdvancedSearchToggleProps = {
export const AdvancedSearchToggle = ({
enabled,
onToggle,
+ roundedRight = true,
size = "sm",
variant = "standalone",
}: AdvancedSearchToggleProps) => {
@@ -59,7 +63,7 @@ export const AdvancedSearchToggle = ({
aria-pressed={enabled}
as="button"
bg={enabled ? "colorPalette.solid" : "gray.muted"}
- borderRightRadius="full"
+ borderRightRadius={roundedRight ? "full" : undefined}
color={enabled ? "colorPalette.contrast" : "colorPalette.fg"}
colorPalette={enabled ? "brand" : "gray"}
cursor="pointer"
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx
index c3c2be9cc58..21d2b2dd489 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx
@@ -20,9 +20,12 @@ import { useEffect, useRef } from "react";
import { Box, HStack, Text } from "@chakra-ui/react";
import { CreatableSelect, Select as ReactSelect, type SelectInstance } from
"chakra-react-select";
+import { LuRegex } from "react-icons/lu";
+import { AdvancedSearchToggle } from "src/components/AdvancedSearchToggle";
import { MatchModeToggle } from "src/components/MatchModeToggle";
+import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
import { useMatchMode } from "src/hooks/useMatchMode";
import { FilterPill } from "../FilterPill";
@@ -60,9 +63,12 @@ export const MultiSelectPill = ({
options,
}: Props) => {
const { mode, setMode } = useMatchMode(filter.config.matchModeKey);
+ const advanced = useAdvancedSearch(filter.config.key);
const values = Array.isArray(filter.value) ? filter.value : [];
const SelectComponent = filter.config.isCreatable === true ? CreatableSelect
: ReactSelect;
const showMatchMode = filter.config.matchModeKey !== undefined &&
values.length >= 2;
+ const showAdvancedToggle = filter.config.supportsAdvancedSearch === true;
+ const isAdvanced = showAdvancedToggle && advanced.enabled;
const selectRef = useRef<SelectInstance<SelectOption, true> | null>(null);
// The editor only mounts when editing begins, so the caret belongs in the
select from the
@@ -80,6 +86,7 @@ export const MultiSelectPill = ({
// Each value is its own node so the collapsed chip stays queryable by
value.
displayValue={
<HStack display="inline-flex" gap={1}>
+ {isAdvanced ? <LuRegex aria-label="match anywhere" /> : undefined}
{mode === "all" && values.length >= 2 ? (
<Box as="span" color="fg.muted" fontSize="xs">
{`(${mode})`}
@@ -170,6 +177,14 @@ export const MultiSelectPill = ({
value={values.map((value) => ({ label: value, value }))}
/>
</Box>
+ {showAdvancedToggle ? (
+ <AdvancedSearchToggle
+ enabled={advanced.enabled}
+ onToggle={advanced.onToggle}
+ roundedRight={!showMatchMode}
+ variant="addon"
+ />
+ ) : undefined}
{showMatchMode ? <MatchModeToggle mode={mode} onModeChange={setMode}
/> : undefined}
</Box>
)}
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.test.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.test.tsx
new file mode 100644
index 00000000000..f01de5bfd27
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.test.tsx
@@ -0,0 +1,143 @@
+/*!
+ * 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 { PropsWithChildren } from "react";
+
+import "@testing-library/jest-dom";
+import { cleanup, fireEvent, render, screen, waitFor, within } from
"@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { DagService } from "openapi/requests/services.gen";
+import type * as ServicesGen from "openapi/requests/services.gen";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { FilterBar } from "../FilterBar";
+import type { FilterConfig } from "../types";
+import { TagsFilter } from "./TagsFilter";
+
+vi.mock("openapi/requests/services.gen", async (importOriginal) => {
+ const mod = await importOriginal<typeof ServicesGen>();
+
+ return {
+ ...mod,
+ DagService: { ...mod.DagService, getDagTags: vi.fn() },
+ };
+});
+
+const wrapper = ({ children }: PropsWithChildren) => (
+ <BaseWrapper>
+ <MemoryRouter initialEntries={["/dags"]}>{children}</MemoryRouter>
+ </BaseWrapper>
+);
+
+const tagsConfig: FilterConfig = {
+ EditorComponent: TagsFilter,
+ key: "tags",
+ label: "Tags",
+ matchModeKey: "tags_match_mode",
+ supportsAdvancedSearch: true,
+ type: "multiselect",
+};
+
+const openTagsEditor = async () => {
+ fireEvent.click(screen.getByTestId("add-filter-button"));
+ fireEvent.click(await screen.findByTestId("add-filter-tags"));
+};
+
+const typeIntoSelect = (value: string) => {
+ const input = document.querySelector('input[id^="react-select"]');
+
+ expect(input).not.toBeNull();
+ fireEvent.change(input as Element, { target: { value } });
+};
+
+const getDagTagsMock = vi.mocked(DagService.getDagTags);
+
+afterEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ cleanup();
+});
+
+describe("TagsFilter advanced search", () => {
+ it("searches tags by prefix by default", async () => {
+ getDagTagsMock.mockResolvedValue({ tags: ["alpha"], total_entries: 1 });
+ render(<FilterBar configs={[tagsConfig]} onFiltersChange={vi.fn()} />, {
wrapper });
+
+ await openTagsEditor();
+ typeIntoSelect("alp");
+
+ await waitFor(() =>
+ expect(getDagTagsMock).toHaveBeenCalledWith(expect.objectContaining({
tagNamePrefixPattern: "alp" })),
+ );
+ expect(getDagTagsMock).not.toHaveBeenCalledWith(expect.objectContaining({
tagNamePattern: "alp" }));
+ });
+
+ it("switches to substring matching when the match-anywhere toggle is
enabled", async () => {
+ getDagTagsMock.mockResolvedValue({ tags: ["alpha"], total_entries: 1 });
+ render(<FilterBar configs={[tagsConfig]} onFiltersChange={vi.fn()} />, {
wrapper });
+
+ await openTagsEditor();
+
+ const toggle = await screen.findByTestId("advanced-search-toggle");
+
+ expect(toggle.getAttribute("aria-pressed")).toBe("false");
+ fireEvent.click(toggle);
+
+ await waitFor(() =>
expect(localStorage.getItem("advanced_search-tags")).toBe("true"));
+
+ getDagTagsMock.mockClear();
+ typeIntoSelect("pha");
+
+ await waitFor(() =>
+ expect(getDagTagsMock).toHaveBeenCalledWith(expect.objectContaining({
tagNamePattern: "pha" })),
+ );
+ expect(getDagTagsMock).not.toHaveBeenCalledWith(expect.objectContaining({
tagNamePrefixPattern: "pha" }));
+ });
+
+ it("shows a regex indicator on the collapsed pill while substring matching
is enabled", () => {
+ getDagTagsMock.mockResolvedValue({ tags: ["alpha"], total_entries: 1 });
+ localStorage.setItem("advanced_search-tags", "true");
+ render(
+ <FilterBar configs={[tagsConfig]} initialValues={{ tags: ["alpha"] }}
onFiltersChange={vi.fn()} />,
+ { wrapper },
+ );
+
+ const pill = screen.getByTestId("tags-pill");
+
+ expect(within(pill).getByLabelText("match anywhere")).toBeInTheDocument();
+ });
+
+ it("does not render the toggle without supportsAdvancedSearch", async () => {
+ const plainConfig: FilterConfig = {
+ key: "teams",
+ label: "Teams",
+ options: [{ label: "alpha", value: "alpha" }],
+ type: "multiselect",
+ };
+
+ render(<FilterBar configs={[plainConfig]} onFiltersChange={vi.fn()} />, {
wrapper });
+
+ fireEvent.click(screen.getByTestId("add-filter-button"));
+ fireEvent.click(await screen.findByTestId("add-filter-teams"));
+
+
expect(screen.queryByTestId("advanced-search-toggle")).not.toBeInTheDocument();
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.tsx
index 49743fc0726..e1ec373b279 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.tsx
@@ -20,6 +20,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
+import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
import { useDagTagsInfinite } from "src/queries/useDagTagsInfinite";
import type { FilterPluginProps } from "../types";
@@ -32,8 +33,10 @@ import { MultiSelectPill } from "./MultiSelectPill";
export const TagsFilter = ({ filter, onChange, onRemove }: FilterPluginProps)
=> {
const { t: translate } = useTranslation();
const [pattern, setPattern] = useState("");
+ const advanced = useAdvancedSearch(filter.config.key);
const { data, fetchNextPage, fetchPreviousPage } = useDagTagsInfinite({
+ advancedSearch: advanced.enabled,
limit: 10,
orderBy: ["name"],
tagNamePattern: pattern,
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 de312b352d6..9712e959d95 100644
--- a/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts
+++ b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts
@@ -59,9 +59,9 @@ export type FilterConfig = {
readonly options?: Array<{ label: ReactNode | string; value: string }>;
readonly placeholder?: string;
readonly startKey?: string;
- // Set on text filters whose API endpoint exposes both ``*_pattern``
(substring)
+ // Set on filters whose API endpoint exposes both ``*_pattern`` (substring)
// and ``*_prefix_pattern`` (prefix) variants. The pill renders a toggle that
- // controls which one the consuming page uses, via ``useAdvancedSearch``.
+ // controls which one the consuming query uses, via ``useAdvancedSearch``.
readonly supportsAdvancedSearch?: boolean;
readonly toSearchParams?: (value: FilterValue) => Record<string, string |
undefined>;
readonly type: "boolean" | "date" | "daterange" | "multiselect" | "number" |
"select" | "text";
diff --git a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
index 969abe1b089..93ef46495d1 100644
--- a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
+++ b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
@@ -512,6 +512,7 @@ export const useFilterConfigs = () => {
label: translate("common:dagDetails.tags"),
matchModeKey: SearchParamsKeys.TAGS_MATCH_MODE,
placeholder: translate("common:table.tagPlaceholder"),
+ supportsAdvancedSearch: true,
type: FilterTypes.MULTISELECT,
},
[SearchParamsKeys.TASK_ID]: {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDagTagsInfinite.ts
b/airflow-core/src/airflow/ui/src/queries/useDagTagsInfinite.ts
index 74b4ffbcfc0..46d2d07013c 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDagTagsInfinite.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDagTagsInfinite.ts
@@ -24,10 +24,12 @@ import type { DAGTagCollectionResponse } from
"openapi/requests/types.gen";
export const useDagTagsInfinite = <TError = unknown>(
{
+ advancedSearch = false,
limit,
orderBy,
tagNamePattern,
}: {
+ advancedSearch?: boolean;
limit?: number;
orderBy?: Array<string>;
tagNamePattern?: string;
@@ -57,9 +59,18 @@ export const useDagTagsInfinite = <TError = unknown>(
) => (firstPageParam > 0 ? -firstPage.tags.length + firstPageParam :
undefined),
initialPageParam: 0,
queryFn: ({ pageParam }: { pageParam: number }) =>
- DagService.getDagTags({ limit, offset: pageParam, orderBy,
tagNamePrefixPattern: tagNamePattern }),
+ DagService.getDagTags({
+ limit,
+ offset: pageParam,
+ orderBy,
+ ...(advancedSearch ? { tagNamePattern } : { tagNamePrefixPattern:
tagNamePattern }),
+ }),
queryKey: UseDagServiceGetDagTagsKeyFn(
- { limit, orderBy, tagNamePrefixPattern: tagNamePattern },
+ {
+ limit,
+ orderBy,
+ ...(advancedSearch ? { tagNamePattern } : { tagNamePrefixPattern:
tagNamePattern }),
+ },
queryKey,
),
...options,