diff --git a/frontend/src/app/[locale]/search/error.tsx b/frontend/src/app/[locale]/search/error.tsx index 33fc600d2..dbe1baaec 100644 --- a/frontend/src/app/[locale]/search/error.tsx +++ b/frontend/src/app/[locale]/search/error.tsx @@ -1,12 +1,14 @@ "use client"; import QueryProvider from "src/app/[locale]/search/QueryProvider"; +import { FrontendErrorDetails } from "src/types/apiResponseTypes"; import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; import { Breakpoints, ErrorProps } from "src/types/uiTypes"; import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes"; import { useTranslations } from "next-intl"; import { useEffect } from "react"; +import { Alert } from "@trussworks/react-uswds"; import ContentDisplayToggle from "src/components/ContentDisplayToggle"; import SearchBar from "src/components/search/SearchBar"; @@ -18,6 +20,7 @@ export interface ParsedError { searchInputs: ServerSideSearchParams; status: number; type: string; + details?: FrontendErrorDetails; } function isValidJSON(str: string) { @@ -73,6 +76,16 @@ export default function SearchError({ error }: ErrorProps) { console.error(error); }, [error]); + // note that the validation error will contain untranslated strings + const ErrorAlert = + parsedErrorData.details && parsedErrorData.type === "ValidationError" ? ( + + {`Error in ${parsedErrorData.details.field || "a search field"}: ${parsedErrorData.details.message || "adjust your search and try again"}`} + + ) : ( + + ); + return ( @@ -95,9 +108,7 @@ export default function SearchError({ error }: ErrorProps) { /> - - - + {ErrorAlert} diff --git a/frontend/src/components/search/SearchBar.tsx b/frontend/src/components/search/SearchBar.tsx index ba6917ac6..c12d52b4d 100644 --- a/frontend/src/components/search/SearchBar.tsx +++ b/frontend/src/components/search/SearchBar.tsx @@ -1,11 +1,12 @@ "use client"; +import clsx from "clsx"; import { QueryContext } from "src/app/[locale]/search/QueryProvider"; import { useSearchParamUpdater } from "src/hooks/useSearchParamUpdater"; import { useTranslations } from "next-intl"; -import { useContext, useEffect, useRef } from "react"; -import { Icon } from "@trussworks/react-uswds"; +import { useContext, useEffect, useRef, useState } from "react"; +import { ErrorMessage, Icon } from "@trussworks/react-uswds"; interface SearchBarProps { query: string | null | undefined; @@ -16,8 +17,16 @@ export default function SearchBar({ query }: SearchBarProps) { const { queryTerm, updateQueryTerm } = useContext(QueryContext); const { updateQueryParams, searchParams } = useSearchParamUpdater(); const t = useTranslations("Search"); + const [validationError, setValidationError] = useState(); const handleSubmit = () => { + if (queryTerm && queryTerm.length > 99) { + setValidationError(t("tooLongError")); + return; + } + if (validationError) { + setValidationError(undefined); + } updateQueryParams("", "query", queryTerm, false); }; @@ -38,7 +47,11 @@ export default function SearchBar({ query }: SearchBarProps) { }, [searchParams, updateQueryParams]); return ( - + + {validationError && {validationError}} { @@ -41,11 +44,11 @@ export class NetworkError extends Error { // Used as a base class for all !response.ok errors export class BaseFrontendError extends Error { constructor( - error: unknown, + message: string, type = "BaseFrontendError", - status?: number, - searchInputs?: QueryParamData, + details?: FrontendErrorDetails, ) { + const { searchInputs, status, ...additionalDetails } = details || {}; // Sets cannot be properly serialized so convert to arrays first const serializedSearchInputs = searchInputs ? convertSearchInputSetsToArrays(searchInputs) @@ -54,8 +57,9 @@ export class BaseFrontendError extends Error { const serializedData = JSON.stringify({ type, searchInputs: serializedSearchInputs, - message: error instanceof Error ? error.message : "Unknown Error", + message: message || "Unknown Error", status, + details: additionalDetails, }); super(serializedData); @@ -75,12 +79,12 @@ export class BaseFrontendError extends Error { */ export class ApiRequestError extends BaseFrontendError { constructor( - error: unknown, + message: string, type = "APIRequestError", status = 400, - searchInputs?: QueryParamData, + details?: FrontendErrorDetails, ) { - super(error, type, status, searchInputs); + super(message, type, { status, ...details }); } } @@ -88,8 +92,8 @@ export class ApiRequestError extends BaseFrontendError { * An API response returned a 400 status code and its JSON body didn't include any `errors` */ export class BadRequestError extends ApiRequestError { - constructor(error: unknown, searchInputs?: QueryParamData) { - super(error, "BadRequestError", 400, searchInputs); + constructor(message: string, details?: FrontendErrorDetails) { + super(message, "BadRequestError", 400, details); } } @@ -97,8 +101,8 @@ export class BadRequestError extends ApiRequestError { * An API response returned a 401 status code */ export class UnauthorizedError extends ApiRequestError { - constructor(error: unknown, searchInputs?: QueryParamData) { - super(error, "UnauthorizedError", 401, searchInputs); + constructor(message: string, details?: FrontendErrorDetails) { + super(message, "UnauthorizedError", 401, details); } } @@ -108,8 +112,8 @@ export class UnauthorizedError extends ApiRequestError { * being created, or the user hasn't consented to the data sharing agreement. */ export class ForbiddenError extends ApiRequestError { - constructor(error: unknown, searchInputs?: QueryParamData) { - super(error, "ForbiddenError", 403, searchInputs); + constructor(message: string, details?: FrontendErrorDetails) { + super(message, "ForbiddenError", 403, details); } } @@ -117,8 +121,8 @@ export class ForbiddenError extends ApiRequestError { * A fetch request failed due to a 404 error */ export class NotFoundError extends ApiRequestError { - constructor(error: unknown, searchInputs?: QueryParamData) { - super(error, "NotFoundError", 404, searchInputs); + constructor(message: string, details?: FrontendErrorDetails) { + super(message, "NotFoundError", 404, details); } } @@ -126,8 +130,8 @@ export class NotFoundError extends ApiRequestError { * An API response returned a 408 status code */ export class RequestTimeoutError extends ApiRequestError { - constructor(error: unknown, searchInputs?: QueryParamData) { - super(error, "RequestTimeoutError", 408, searchInputs); + constructor(message: string, details?: FrontendErrorDetails) { + super(message, "RequestTimeoutError", 408, details); } } @@ -135,8 +139,8 @@ export class RequestTimeoutError extends ApiRequestError { * An API response returned a 422 status code */ export class ValidationError extends ApiRequestError { - constructor(error: unknown, searchInputs?: QueryParamData) { - super(error, "ValidationError", 422, searchInputs); + constructor(message: string, details?: FrontendErrorDetails) { + super(message, "ValidationError", 422, details); } } @@ -148,8 +152,8 @@ export class ValidationError extends ApiRequestError { * An API response returned a 500 status code */ export class InternalServerError extends ApiRequestError { - constructor(error: unknown, searchInputs?: QueryParamData) { - super(error, "InternalServerError", 500, searchInputs); + constructor(message: string, details?: FrontendErrorDetails) { + super(message, "InternalServerError", 500, details); } } @@ -157,8 +161,8 @@ export class InternalServerError extends ApiRequestError { * An API response returned a 503 status code */ export class ServiceUnavailableError extends ApiRequestError { - constructor(error: unknown, searchInputs?: QueryParamData) { - super(error, "ServiceUnavailableError", 503, searchInputs); + constructor(message: string, details?: FrontendErrorDetails) { + super(message, "ServiceUnavailableError", 503, details); } } diff --git a/frontend/src/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts index 51eb9e605..cc4f7db8f 100644 --- a/frontend/src/i18n/messages/en/index.ts +++ b/frontend/src/i18n/messages/en/index.ts @@ -619,6 +619,8 @@ export const messages = { hideFilters: "Hide Filters", }, generic_error_cta: "Please try your search again.", + validationError: "Search Validation Error", + tooLongError: "Search terms must be no longer than 100 characters.", }, Maintenance: { heading: "Simpler.Grants.gov Is Currently Undergoing Maintenance", diff --git a/frontend/src/services/fetch/fetcherHelpers.ts b/frontend/src/services/fetch/fetcherHelpers.ts index d90a1dd70..3f863ef1c 100644 --- a/frontend/src/services/fetch/fetcherHelpers.ts +++ b/frontend/src/services/fetch/fetcherHelpers.ts @@ -40,6 +40,7 @@ export function getDefaultHeaders(): HeadersDict { /** * Send a request and handle the response + * @param queryParamData: note that this is only used in error handling in order to help restore original page state */ export async function sendRequest( url: string, @@ -126,6 +127,9 @@ function fetchErrorToNetworkError( : new NetworkError(error); } +// note that this will pass along filter inputs in order to maintain the state +// of the page when relaying an error, but anything passed in the body of the request, +// such as keyword search query will not be included function handleNotOkResponse( response: APIResponse, url: string, @@ -143,7 +147,7 @@ function handleNotOkResponse( } } -const throwError = ( +export const throwError = ( response: APIResponse, url: string, searchInputs?: QueryParamData, @@ -155,32 +159,33 @@ const throwError = ( searchInputs, ); - // Include just firstError for now, we can expand this - // If we need ValidationErrors to be more expanded - const error = firstError ? { message, firstError } : { message }; + const details = { + searchInputs, + ...(firstError || {}), + }; switch (status_code) { case 400: - throw new BadRequestError(error, searchInputs); + throw new BadRequestError(message, details); case 401: - throw new UnauthorizedError(error, searchInputs); + throw new UnauthorizedError(message, details); case 403: - throw new ForbiddenError(error, searchInputs); + throw new ForbiddenError(message, details); case 404: - throw new NotFoundError(error, searchInputs); + throw new NotFoundError(message, details); case 422: - throw new ValidationError(error, searchInputs); + throw new ValidationError(message, details); case 408: - throw new RequestTimeoutError(error, searchInputs); + throw new RequestTimeoutError(message, details); case 500: - throw new InternalServerError(error, searchInputs); + throw new InternalServerError(message, details); case 503: - throw new ServiceUnavailableError(error, searchInputs); + throw new ServiceUnavailableError(message, details); default: throw new ApiRequestError( - error, + message, "APIRequestError", status_code, - searchInputs, + details, ); } }; diff --git a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts index 649ba7d99..ec867170b 100644 --- a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts +++ b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts @@ -10,10 +10,15 @@ export const userFetcher: UserFetcher = async (url) => { try { response = await fetch(url, { cache: "no-store" }); } catch (e) { + const error = e as Error; console.error("User session fetch network error", e); - throw new ApiRequestError(0); // Network error + throw new ApiRequestError(error.message, "NetworkError", 0); // Network error } if (response.status === 204) return undefined; if (response.ok) return (await response.json()) as UserSession; - throw new ApiRequestError(response.status); + throw new ApiRequestError( + "Unknown error fetching user", + undefined, + response.status, + ); }; diff --git a/frontend/src/services/fetch/fetchers/fetchers.ts b/frontend/src/services/fetch/fetchers/fetchers.ts index 731adb32e..36c9fe574 100644 --- a/frontend/src/services/fetch/fetchers/fetchers.ts +++ b/frontend/src/services/fetch/fetchers/fetchers.ts @@ -33,7 +33,7 @@ export function requesterForEndpoint({ return async function ( options: { subPath?: string; - queryParamData?: QueryParamData; + queryParamData?: QueryParamData; // only used for error handling purposes body?: JSONRequestBody; additionalHeaders?: HeadersDict; } = {}, diff --git a/frontend/src/services/fetch/fetchers/searchFetcher.ts b/frontend/src/services/fetch/fetchers/searchFetcher.ts index 480cf97d6..62956dbfe 100644 --- a/frontend/src/services/fetch/fetchers/searchFetcher.ts +++ b/frontend/src/services/fetch/fetchers/searchFetcher.ts @@ -50,7 +50,10 @@ export const searchForOpportunities = async (searchInputs: QueryParamData) => { requestBody.query = query; } - const response = await fetchOpportunitySearch({ body: requestBody }); + const response = await fetchOpportunitySearch({ + body: requestBody, + queryParamData: searchInputs, + }); response.actionType = searchInputs.actionType; response.fieldChanged = searchInputs.fieldChanged; diff --git a/frontend/src/types/apiResponseTypes.ts b/frontend/src/types/apiResponseTypes.ts index 860d2bcea..635c5670f 100644 --- a/frontend/src/types/apiResponseTypes.ts +++ b/frontend/src/types/apiResponseTypes.ts @@ -1,3 +1,5 @@ +import { QueryParamData } from "src/types/search/searchRequestTypes"; + export interface PaginationInfo { order_by: string; page_offset: number; @@ -15,3 +17,11 @@ export interface APIResponse { warnings?: unknown[] | null | undefined; errors?: unknown[] | null | undefined; } + +export interface FrontendErrorDetails { + status?: number; + searchInputs?: QueryParamData; + field?: string; + message?: string; + type?: string; +} diff --git a/frontend/tests/components/search/SearchBar.test.tsx b/frontend/tests/components/search/SearchBar.test.tsx index 36f0a4b1c..58e2e7701 100644 --- a/frontend/tests/components/search/SearchBar.test.tsx +++ b/frontend/tests/components/search/SearchBar.test.tsx @@ -20,9 +20,12 @@ jest.mock("src/hooks/useSearchParamUpdater", () => ({ }), })); -describe("SearchBar", () => { - const initialQueryParams = "initial query"; +const initialQueryParams = "initial query"; +describe("SearchBar", () => { + afterEach(() => { + jest.resetAllMocks(); + }); it("should not have basic accessibility issues", async () => { const { container } = render( @@ -66,4 +69,41 @@ describe("SearchBar", () => { false, ); }); + + it("raises a validation error on submit if search term is > 99 characters, then clears error on successful search", () => { + render( + + + , + ); + + const input = screen.getByRole("searchbox"); + fireEvent.change(input, { + target: { + value: + "how long do I need to type for before I get to 100 characters? it's looking like around two sentenc-", + }, + }); + + const searchButton = screen.getByRole("button", { name: /search/i }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + fireEvent.click(searchButton); + + expect(mockUpdateQueryParams).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + fireEvent.change(input, { + target: { + value: "totally valid search terms", + }, + }); + fireEvent.click(searchButton); + expect(mockUpdateQueryParams).toHaveBeenCalledWith( + "", + "query", + "totally valid search terms", + false, + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/tests/errors.test.ts b/frontend/tests/errors.test.ts index 8b6f7c281..765b45003 100644 --- a/frontend/tests/errors.test.ts +++ b/frontend/tests/errors.test.ts @@ -2,7 +2,7 @@ import { ParsedError } from "src/app/[locale]/search/error"; import { BadRequestError } from "src/errors"; import { QueryParamData } from "src/types/search/searchRequestTypes"; -describe("BadRequestError", () => { +describe("BadRequestError (as an example of other error types)", () => { const dummySearchInputs: QueryParamData = { status: new Set(["active"]), fundingInstrument: new Set(["grant"]), @@ -15,10 +15,9 @@ describe("BadRequestError", () => { }; it("serializes search inputs and error message correctly", () => { - const error = new BadRequestError( - new Error("Test Error"), - dummySearchInputs, - ); + const error = new BadRequestError("Test Error", { + searchInputs: dummySearchInputs, + }); const errorData = JSON.parse(error.message) as ParsedError; expect(errorData.type).toEqual("BadRequestError"); @@ -29,16 +28,29 @@ describe("BadRequestError", () => { }); it("handles non-Error inputs correctly", () => { - const error = new BadRequestError("Some string error", dummySearchInputs); + const error = new BadRequestError("Some string error"); const errorData = JSON.parse(error.message) as ParsedError; - expect(errorData.message).toEqual("Unknown Error"); + expect(errorData.message).toEqual("Some string error"); }); it("sets a default message when error is not an instance of Error", () => { - const error = new BadRequestError(null, dummySearchInputs); + const error = new BadRequestError(""); const errorData = JSON.parse(error.message) as ParsedError; expect(errorData.message).toEqual("Unknown Error"); }); + + it("passes along additional error details", () => { + const error = new BadRequestError("", { + field: "fieldName", + message: "a more detailed message", + type: "a subtype", + }); + const errorData = JSON.parse(error.message) as ParsedError; + + expect(errorData.details?.field).toEqual("fieldName"); + expect(errorData.details?.message).toEqual("a more detailed message"); + expect(errorData.details?.type).toEqual("a subtype"); + }); }); diff --git a/frontend/tests/services/fetch/FetchHelpers.test.ts b/frontend/tests/services/fetch/FetcherHelpers.test.ts similarity index 78% rename from frontend/tests/services/fetch/FetchHelpers.test.ts rename to frontend/tests/services/fetch/FetcherHelpers.test.ts index d1d6da06d..12d69bd23 100644 --- a/frontend/tests/services/fetch/FetchHelpers.test.ts +++ b/frontend/tests/services/fetch/FetcherHelpers.test.ts @@ -1,11 +1,13 @@ import "server-only"; -import { ApiRequestError, NetworkError } from "src/errors"; +import { ApiRequestError, NetworkError, UnauthorizedError } from "src/errors"; import { createRequestUrl, sendRequest, + throwError, } from "src/services/fetch/fetcherHelpers"; import { QueryParamData } from "src/types/search/searchRequestTypes"; +import { wrapForExpectedError } from "src/utils/testing/commonTestUtils"; const searchInputs: QueryParamData = { status: new Set(["active"]), @@ -128,7 +130,7 @@ describe("sendRequest", () => { }; await expect(sendErrorRequest()).rejects.toThrow( - new ApiRequestError("", "APIRequestError", 0, searchInputs), + new ApiRequestError("", "APIRequestError", 0, { searchInputs }), ); }); @@ -159,3 +161,34 @@ describe("sendRequest", () => { ); }); }); + +describe("throwError", () => { + it("passes along message from response and details from first error, in error type based on status code", async () => { + const expectedError = await wrapForExpectedError(() => { + throwError( + { data: {}, message: "response message", status_code: 401 }, + "http://any.url", + undefined, + { + field: "fieldName", + type: "a subtype", + message: "a detailed message", + }, + ); + }); + expect(expectedError).toBeInstanceOf(UnauthorizedError); + expect(expectedError.message).toEqual( + JSON.stringify({ + type: "UnauthorizedError", + searchInputs: {}, + message: "response message", + status: 401, + details: { + field: "fieldName", + type: "a subtype", + message: "a detailed message", + }, + }), + ); + }); +}); diff --git a/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts b/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts index 2aa314ca5..6c34fec13 100644 --- a/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts +++ b/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts @@ -57,6 +57,18 @@ describe("searchForOpportunities", () => { }, }, }, + queryParamData: { + actionType: "fun", + agency: new Set(), + category: new Set(), + eligibility: new Set(), + fieldChanged: "baseball", + fundingInstrument: new Set(["grant", "cooperative_agreement"]), + page: 1, + query: "research", + sortby: "opportunityNumberAsc", + status: new Set(["forecasted", "posted"]), + }, }); expect(result).toEqual({