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,

Reply via email to