Skip to content
Merged
24 changes: 24 additions & 0 deletions app/api/search/facets/[facet]/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { CollectionsApi } from "@/src/utils/apiClients/apiClients";

export const GET = async (
request: NextRequest,
{ params }: { params: { facet: string } }
) => {
const searchParams = request.nextUrl.searchParams;
const q = searchParams.get("q") || "";
const filters = searchParams.get("filters") || "";
try {
const response = await CollectionsApi.getFacetOptions(
params.facet,
q,
filters
);
return NextResponse.json(response, { status: 200 });
} catch (error: any) {
return NextResponse.json(
{ error: error?.message || "Unknown error" },
{ status: 500 }
);
}
Comment on lines +4 to +23
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are Jest API tests for other route handlers under __tests__/api/*, but this new /api/search/facets/[facet] route doesn’t have coverage. Please add tests for the 200 path (returns facet options) and an error path (upstream failure → 500) to keep API handler behavior stable.

Copilot uses AI. Check for mistakes.
};
5 changes: 4 additions & 1 deletion app/src/components/search/filters/selectFilter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
DEFAULT_SEARCH_SORT,
DEFAULT_SEARCH_TERM,
} from "@/src/config/constants";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { AvailableFilter } from "@/src/types/AvailableFilterType";

const mockAvailableFilter: AvailableFilter = {
Expand All @@ -30,6 +30,9 @@ jest.mock("next/navigation", () => ({
push: jest.fn(),
})),
usePathname: jest.fn(),
useSearchParams: jest.fn(() => ({
toString: jest.fn(),
})),
}));

let mockManager = new GeneralSearchManager({
Expand Down
3 changes: 3 additions & 0 deletions app/src/components/search/filters/selectFilterGrid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ jest.mock("next/navigation", () => ({
push: jest.fn(),
})),
usePathname: jest.fn(),
useSearchParams: jest.fn(() => ({
toString: jest.fn(),
})),
}));

const manager = new GeneralSearchManager({
Expand Down
24 changes: 24 additions & 0 deletions app/src/components/search/filters/selectFilterModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jest.mock("next/navigation", () => ({
push: jest.fn(),
})),
usePathname: jest.fn(),
useSearchParams: jest.fn(() => ({
toString: jest.fn(),
})),
}));

describe("SelectFilterModal", () => {
Expand All @@ -45,6 +48,27 @@ describe("SelectFilterModal", () => {
],
};

beforeEach(() => {
global.fetch = jest.fn().mockImplementation((url: string) => {
const urlObj = new URL(url, "http://localhost");
const query = urlObj.searchParams.get("q") || "";
const options = mockFilter.options;
const facets = query
? options.filter((option) =>
option.name.toLowerCase().includes(query.toLowerCase())
)
: options;
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ facets }),
});
});
});

afterEach(() => {
jest.restoreAllMocks();
});

let mockManager = new GeneralSearchManager({
initialPage: 1,
initialSort: DEFAULT_SEARCH_SORT,
Expand Down
60 changes: 55 additions & 5 deletions app/src/components/search/filters/selectFilterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from "@nypl/design-system-react-components";
import DCSearchBar from "../dcSearchBar";
import { headerBreakpoints } from "@/src/utils/breakpoints";
import { usePathname, useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SearchManager } from "@/src/utils/searchManager/searchManager";
import {
AvailableFilter,
Expand Down Expand Up @@ -61,6 +61,9 @@ const SelectFilterModal = forwardRef<HTMLButtonElement, SelectFilterModalProps>(
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [facetOptions, setFacetOptions] = useState<AvailableFilterOption[]>(
filter.options || []
);

// Whether modal closing should focus on open or closed dropdown.
const [focusOutside, setFocusOutside] = useState(false);
Expand All @@ -73,16 +76,17 @@ const SelectFilterModal = forwardRef<HTMLButtonElement, SelectFilterModalProps>(

const { push } = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const updateURL = async (queryString) => {
push(`${pathname}?${queryString}`);
};

const filteredOptions = filter.options.filter((option) =>
// Client-side filtering of fetched facet options by modal search text.
const filteredOptions = facetOptions.filter((option) =>
option.name.toLowerCase().includes(searchText.toLowerCase())
);

const pageCount = Math.ceil(filteredOptions.length / itemsPerPage);

const pageCount = Math.ceil(filteredOptions.length / itemsPerPage) || 1;
const currentOptions = filteredOptions.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
Expand All @@ -94,10 +98,42 @@ const SelectFilterModal = forwardRef<HTMLButtonElement, SelectFilterModalProps>(
}
}, [filteredOptions.length]);

// Reset page when search text changes.
useEffect(() => {
setCurrentPage(1);
}, [searchText]);

// Fetch all facet options scoped to the current DC search params.
// Pagination and text filtering are handled client-side.
const queryString = searchParams.toString();
useEffect(() => {
let mounted = true;

const fetchOptions = async () => {
try {
const parsedOptions = await fetchFacetOptions(
filter.name,
queryString
);
if (mounted) {
setFacetOptions(parsedOptions);
}
} catch (e) {
if (mounted) {
setFacetOptions([]);
}
}
};

if (isOpen) {
fetchOptions();
}

return () => {
mounted = false;
};
}, [isOpen, filter.name, queryString]);

const handleOpen = () => {
modalOnOpen();
parentOnOpen();
Expand Down Expand Up @@ -232,7 +268,7 @@ const SelectFilterModal = forwardRef<HTMLButtonElement, SelectFilterModalProps>(
showHelperInvalidText={false}
onChange={(newSelection: string) => {
selected =
filter.options.find(
filteredOptions.find(
(option) => option.name === newSelection
) || null;
setModalCurrent(selected);
Expand Down Expand Up @@ -324,5 +360,19 @@ const SelectFilterModal = forwardRef<HTMLButtonElement, SelectFilterModalProps>(
}
);

const fetchFacetOptions = async (
facet: string,
queryString: string
): Promise<AvailableFilterOption[]> => {
const response = await fetch(
`/api/search/facets/${encodeURIComponent(facet)}?${queryString}`
);
if (!response.ok) {
throw new Error("Failed to fetch facet options");
}
const res = await response.json();
return res?.facets ?? [];
};

SelectFilterModal.displayName = "SelectFilterModal";
export default SelectFilterModal;
15 changes: 15 additions & 0 deletions app/src/utils/apiClients/apiClients.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ export class CollectionsApi {
return response;
}

/**
* Fetches facet options for a single facet from the collections API, taking
* the `q` keyword string and already applied `filters`
*/
static async getFacetOptions(facet: string, q: string, filters: string) {
const filterString = filterStringToCollectionApiFilterString(filters);
const filterURL = filters.length > 0 ? `&${filterString}` : "";
const query = `?q=${encodeURIComponent(q)}${filterURL}`;
const apiUrl = `${process.env.COLLECTIONS_API_URL}/search/facets/${facet}${query}`;
const response = await fetchApi({
apiUrl: apiUrl,
});
return response;
}

static async getCollectionData(uuid: string) {
let apiUrl = `${process.env.COLLECTIONS_API_URL}/collections/${uuid}`;
const response = await fetchApi({
Expand Down
4 changes: 1 addition & 3 deletions app/src/utils/fetchApi/fetchApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import logger from "logger";

/**
* Makes a GET or POST request to Repo/Collections API and returns the response.
* Times out at 10 seconds to prevent 504 crash.
Expand Down Expand Up @@ -67,7 +65,7 @@ export const fetchApi = async ({
next,
})) as Response;
} catch (error) {
logger.error(error);
console.error(error);
throw new Error(error.message);
}
if (response.ok) {
Expand Down
Loading
Loading