Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* under the License.
*/
export enum SearchParamsKeys {
ADVANCED_SEARCH = "advanced_search",
AFTER = "after",
ASSET_EVENT_DATE_RANGE = "asset_event_date_range",
BEFORE = "before",
Expand Down
148 changes: 148 additions & 0 deletions airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*!
* 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 { act, renderHook } from "@testing-library/react";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import { afterEach, describe, expect, it } from "vitest";

import { advancedSearchKey } from "src/constants/localStorage";
import { SearchParamsKeys } from "src/constants/searchParams";
import { BaseWrapper } from "src/utils/Wrapper";

import { useAdvancedSearch } from "./useAdvancedSearch";

const createWrapper =
(initialEntries: Array<string> = ["/dags"]) =>
({ children }: PropsWithChildren) => (
<BaseWrapper>
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
</BaseWrapper>
);

const renderAdvancedSearch = (key: string, initialEntries?: Array<string>) =>
renderHook(
() => {
const [searchParams] = useSearchParams();

return { advanced: useAdvancedSearch(key), searchParams };
},
{ wrapper: createWrapper(initialEntries) },
);

afterEach(() => {
localStorage.clear();
});

describe("useAdvancedSearch reads", () => {
it("is disabled when neither the URL nor localStorage has a value", () => {
const { result } = renderAdvancedSearch("dags");

expect(result.current.advanced.enabled).toBe(false);
});

it("is enabled when the key is listed in advanced_search", () => {
const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=dags"]);

expect(result.current.advanced.enabled).toBe(true);
});

it("keeps searchbars independent via per-key values", () => {
const entries = ["/events?advanced_search=dag_id&advanced_search=run_id"];

expect(renderAdvancedSearch("dag_id", entries).result.current.advanced.enabled).toBe(true);
expect(renderAdvancedSearch("run_id", entries).result.current.advanced.enabled).toBe(true);
expect(renderAdvancedSearch("task_id", entries).result.current.advanced.enabled).toBe(false);
});

it("falls back to the stored preference when the param is absent", () => {
localStorage.setItem(advancedSearchKey("dags"), JSON.stringify(true));

const { result } = renderAdvancedSearch("dags");

expect(result.current.advanced.enabled).toBe(true);
});

it("honors an explicit off marker over a stored-on preference", () => {
localStorage.setItem(advancedSearchKey("dags"), JSON.stringify(true));

const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=-dags"]);

expect(result.current.advanced.enabled).toBe(false);
});
});

describe("useAdvancedSearch toggle", () => {
it("adds the key to advanced_search when enabled", () => {
const { result } = renderAdvancedSearch("dags");

act(() => result.current.advanced.onToggle(true));

expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["dags"]);
expect(result.current.advanced.enabled).toBe(true);
});

it("records an explicit off marker and keeps the other searchbars when disabled", () => {
const { result } = renderAdvancedSearch("dag_id", [
"/events?advanced_search=dag_id&advanced_search=run_id",
]);

act(() => result.current.advanced.onToggle(false));

expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual([
"run_id",
"-dag_id",
]);
expect(result.current.advanced.enabled).toBe(false);
});

it("writes an explicit off marker when disabled from no prior entry", () => {
const { result } = renderAdvancedSearch("dags");

act(() => result.current.advanced.onToggle(false));

expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["-dags"]);
expect(result.current.advanced.enabled).toBe(false);
});

it("flips from off to on without leaving the off marker behind", () => {
const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=-dags"]);

act(() => result.current.advanced.onToggle(true));

expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["dags"]);
expect(result.current.advanced.enabled).toBe(true);
});

it("does not duplicate the key when enabled while already present", () => {
const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=dags"]);

act(() => result.current.advanced.onToggle(true));

expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["dags"]);
});

it("persists the toggle to localStorage as well", () => {
const { result } = renderAdvancedSearch("dags");

act(() => result.current.advanced.onToggle(true));

expect(localStorage.getItem(advancedSearchKey("dags"))).toBe(JSON.stringify(true));
});
});
36 changes: 31 additions & 5 deletions airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,43 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useSearchParams } from "react-router-dom";
import { useLocalStorage } from "usehooks-ts";

import { advancedSearchKey } from "src/constants/localStorage";
import { SearchParamsKeys } from "src/constants/searchParams";

// Toggle is intentionally NOT mirrored in the URL: shared links default to the
// fast prefix-search behavior, and recipients can opt back into substring search
// per searchbar if they want it.
// The "match anywhere" (substring) toggle is mirrored in the URL so a filtered search can be shared
// and reproduced in both directions. ``advanced_search`` is a repeated param carrying each searchbar's
// explicit choice by key: ``key`` for on, ``-key`` for off
// (`?advanced_search=dag_id&advanced_search=-run_id`), keeping each searchbar independent. An explicit URL
// entry wins — a shared link reproduces the sender's on/off choices whatever the recipient's own
// preferences — and a key with no entry (e.g. landing through the nav) falls back to the per-searchbar
// localStorage preference. Toggling writes the explicit on/off entry and localStorage.
export const useAdvancedSearch = (key: string) => {
const [enabled, setEnabled] = useLocalStorage<boolean>(advancedSearchKey(key), false);
const [searchParams, setSearchParams] = useSearchParams();
const [storedEnabled, setStoredEnabled] = useLocalStorage<boolean>(advancedSearchKey(key), false);

return { enabled, onToggle: setEnabled };
const urlValues = searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH);
const enabled = urlValues.includes(key) ? true : urlValues.includes(`-${key}`) ? false : storedEnabled;

const onToggle = (nextEnabled: boolean) => {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
const retained = next
.getAll(SearchParamsKeys.ADVANCED_SEARCH)
.filter((value) => value !== key && value !== `-${key}`);

next.delete(SearchParamsKeys.ADVANCED_SEARCH);
retained.forEach((value) => next.append(SearchParamsKeys.ADVANCED_SEARCH, value));
next.append(SearchParamsKeys.ADVANCED_SEARCH, nextEnabled ? key : `-${key}`);

return next;
});
setStoredEnabled(nextEnabled);
};

return { enabled, onToggle };
};

type AdvancedSearchArgOptions<TPrefix extends string, TPattern extends string> = {
Expand Down
Loading