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
203 changes: 203 additions & 0 deletions client/src/app/pages/search/components/SearchMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
type MockedFunction,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";

import { MemoryRouter } from "react-router-dom";

import { FILTER_TEXT_CATEGORY_KEY } from "@app/Constants";
import { SbomSearchContext } from "@app/pages/sbom-list/sbom-context";
import { useFetchAdvisories } from "@app/queries/advisories";
import { useFetchPackages } from "@app/queries/packages";
import { useFetchSBOMs } from "@app/queries/sboms";
import { useFetchVulnerabilities } from "@app/queries/vulnerabilities";

import { SearchMenu } from "./SearchMenu";

vi.mock("@app/queries/advisories");
vi.mock("@app/queries/packages");
vi.mock("@app/queries/sboms");
vi.mock("@app/queries/vulnerabilities");

const mockedUseFetchAdvisories = useFetchAdvisories as MockedFunction<
typeof useFetchAdvisories
>;
const mockedUseFetchPackages = useFetchPackages as MockedFunction<
typeof useFetchPackages
>;
const mockedUseFetchSBOMs = useFetchSBOMs as MockedFunction<
typeof useFetchSBOMs
>;
const mockedUseFetchVulnerabilities = useFetchVulnerabilities as MockedFunction<
typeof useFetchVulnerabilities
>;

const emptyFetchResult = {
isFetching: false,
result: { data: [], total: 0, params: {} },
fetchError: null,
refetch: vi.fn(),
};

const getDisableSearchFlags = () => ({
advisories: mockedUseFetchAdvisories.mock.calls.at(-1)?.[2],
packages: mockedUseFetchPackages.mock.calls.at(-1)?.[1],
sboms: mockedUseFetchSBOMs.mock.calls.at(-1)?.[3],
vulnerabilities: mockedUseFetchVulnerabilities.mock.calls.at(-1)?.[1],
});

const allAutocompleteDisabled = (
flags: ReturnType<typeof getDisableSearchFlags>,
) =>
flags.advisories === true &&
flags.packages === true &&
flags.sboms === true &&
flags.vulnerabilities === true;

const allAutocompleteEnabled = (
flags: ReturnType<typeof getDisableSearchFlags>,
) =>
flags.advisories === false &&
flags.packages === false &&
flags.sboms === false &&
flags.vulnerabilities === false;

const renderSearchMenu = ({
filterValues = {},
onChangeSearch = vi.fn(),
}: {
filterValues?: Record<string, string[]>;
onChangeSearch?: (searchValue: string | undefined) => void;
} = {}) => {
const setFilterValues = vi.fn();

const contextValue = {
tableControls: {
filterState: {
filterValues,
setFilterValues,
},
},
} as unknown as React.ContextType<typeof SbomSearchContext>;

return {
onChangeSearch,
setFilterValues,
...render(
<MemoryRouter>
<SbomSearchContext.Provider value={contextValue}>
<SearchMenu onChangeSearch={onChangeSearch} />
</SbomSearchContext.Provider>
</MemoryRouter>,
),
};
};

describe("SearchMenu autocomplete fetch gating (TC-2443)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseFetchAdvisories.mockReturnValue(emptyFetchResult);
mockedUseFetchPackages.mockReturnValue(emptyFetchResult);
mockedUseFetchSBOMs.mockReturnValue(emptyFetchResult);
mockedUseFetchVulnerabilities.mockReturnValue(emptyFetchResult);
});

it("disables autocomplete fetches on initial mount with empty input", () => {
renderSearchMenu();

expect(allAutocompleteDisabled(getDisableSearchFlags())).toBe(true);
});

it("enables autocomplete fetches when typing a non-empty query", async () => {
renderSearchMenu();

fireEvent.change(screen.getByRole("textbox", { name: "Search input" }), {
target: { value: "some" },
});

await waitFor(() => {
expect(allAutocompleteEnabled(getDisableSearchFlags())).toBe(true);
});
});

it("disables autocomplete fetches for whitespace-only input while dirty", () => {
renderSearchMenu();

fireEvent.change(screen.getByRole("textbox", { name: "Search input" }), {
target: { value: " " },
});

expect(allAutocompleteDisabled(getDisableSearchFlags())).toBe(true);
});

it("disables autocomplete fetches after submit", () => {
const onChangeSearch = vi.fn();
renderSearchMenu({ onChangeSearch });

const input = screen.getByRole("textbox", { name: "Search input" });
fireEvent.change(input, { target: { value: "some" } });
fireEvent.click(screen.getByRole("button", { name: "Search" }));

expect(onChangeSearch).toHaveBeenCalledWith("some");
expect(allAutocompleteDisabled(getDisableSearchFlags())).toBe(true);
});

it("disables autocomplete fetches after clear", () => {
const onChangeSearch = vi.fn();
renderSearchMenu({
filterValues: { [FILTER_TEXT_CATEGORY_KEY]: ["previous"] },
onChangeSearch,
});

fireEvent.change(screen.getByRole("textbox", { name: "Search input" }), {
target: { value: "previous" },
});
fireEvent.click(screen.getByRole("button", { name: "Reset" }));

expect(onChangeSearch).toHaveBeenCalledWith("");
expect(allAutocompleteDisabled(getDisableSearchFlags())).toBe(true);
});

it("does not flash stale filter text into the input while clearing filters", () => {
const onChangeSearch = vi.fn();
const { rerender } = renderSearchMenu({
filterValues: { [FILTER_TEXT_CATEGORY_KEY]: ["something"] },
onChangeSearch,
});

const input = screen.getByRole("textbox", { name: "Search input" });
fireEvent.change(input, { target: { value: "something" } });
fireEvent.click(screen.getByRole("button", { name: "Search" }));

fireEvent.click(screen.getByRole("button", { name: "Reset" }));

const contextWithStaleFilter = {
tableControls: {
filterState: {
filterValues: { [FILTER_TEXT_CATEGORY_KEY]: ["something"] },
setFilterValues: vi.fn(),
},
},
} as unknown as React.ContextType<typeof SbomSearchContext>;

rerender(
<MemoryRouter>
<SbomSearchContext.Provider value={contextWithStaleFilter}>
<SearchMenu onChangeSearch={onChangeSearch} />
</SbomSearchContext.Provider>
</MemoryRouter>,
);

expect(input).toHaveValue("");

fireEvent.click(screen.getByRole("button", { name: "Search" }));

expect(input).toHaveValue("");
});
});
36 changes: 31 additions & 5 deletions client/src/app/pages/search/components/SearchMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,30 @@ export const SearchMenu: React.FC<ISearchMenu> = ({ onChangeSearch }) => {
React.useContext(SbomSearchContext);

// Search value
// Stable string primitive — the filterState object is recreated every render
// (no useMemo in the useUrlParams → useFilterState → context chain), but the
// extracted string only changes when the URL filter param actually changes.
const appliedSearchValue =
sbomTableControls.filterState.filterValues[FILTER_TEXT_CATEGORY_KEY]?.[0] ??
"";

const [searchValue, setSearchValue] = React.useState("");
const [isSearchValueDirty, setIsSearchValueDirty] = React.useState(false);
const submittedSearchValueRef = React.useRef<string | null>(null);

React.useEffect(() => {
const searchValueFromTableControls =
sbomTableControls.filterState.filterValues[FILTER_TEXT_CATEGORY_KEY]?.[0];
setSearchValue(searchValueFromTableControls ?? "");
}, [sbomTableControls.filterState]);
if (isSearchValueDirty) return;

if (submittedSearchValueRef.current !== null) {
if (appliedSearchValue === submittedSearchValueRef.current) {
submittedSearchValueRef.current = null;
setSearchValue(appliedSearchValue);
}
return;
}

setSearchValue(appliedSearchValue);
}, [appliedSearchValue, isSearchValueDirty]);

// Debounce Search value
const [debouncedSearchValue, setDebouncedSearchValue] = useDebounceValue(
Expand All @@ -185,10 +201,13 @@ export const SearchMenu: React.FC<ISearchMenu> = ({ onChangeSearch }) => {
setDebouncedSearchValue(searchValue);
}, [setDebouncedSearchValue, searchValue]);

const disableAutocomplete =
!isSearchValueDirty || debouncedSearchValue.trim() === "";

// Fetch all entities
const { isFetching, list: entityList } = useAllEntities(
debouncedSearchValue,
!isSearchValueDirty,
disableAutocomplete,
);

const [isAutocompleteOpen, setIsAutocompleteOpen] =
Expand Down Expand Up @@ -217,9 +236,16 @@ export const SearchMenu: React.FC<ISearchMenu> = ({ onChangeSearch }) => {

const onClearSearchValue = () => {
setSearchValue("");
setIsSearchValueDirty(false);
setIsAutocompleteOpen(false);
submittedSearchValueRef.current = "";
onChangeSearch("");
};

const onSubmitInput = () => {
setIsSearchValueDirty(false);
setIsAutocompleteOpen(false);
submittedSearchValueRef.current = searchValue;
onChangeSearch(searchValue);
};

Expand Down
Loading