Skip to content

Commit

Permalink
[Issue #1502]: Setup search error management (#1681)
Browse files Browse the repository at this point in the history
## Summary
Fixes #1502

## Changes proposed
- Add Error boundary page that shows alert
    - add Error classes (400/500 and generic) are defined in `src/errors.ts`
- Update tests
  • Loading branch information
rylew1 authored Apr 16, 2024
1 parent 32f4946 commit 4810991
Show file tree
Hide file tree
Showing 12 changed files with 561 additions and 55 deletions.
143 changes: 110 additions & 33 deletions frontend/src/app/api/BaseApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,35 @@
// https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment
import "server-only";

import {
ApiRequestError,
BadRequestError,
ForbiddenError,
InternalServerError,
NetworkError,
NotFoundError,
RequestTimeoutError,
ServiceUnavailableError,
UnauthorizedError,
ValidationError,
} from "src/errors";
import { compact, isEmpty } from "lodash";

// TODO (#1682): replace search specific references (since this is a generic API file that any
// future page or different namespace could use)
import { SearchAPIResponse } from "../../types/search/searchResponseTypes";
import { compact } from "lodash";
import { SearchFetcherProps } from "src/services/search/searchfetcher/SearchFetcher";

export type ApiMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
export interface JSONRequestBody {
[key: string]: unknown;
}

// TODO: keep for reference on generic response type

// export interface ApiResponseBody<TResponseData> {
// message: string;
// data: TResponseData;
// status_code: number;

// errors?: unknown[]; // TODO: define error and warning Issue type
// warnings?: unknown[];
// }
interface APIResponseError {
field: string;
message: string;
type: string;
}

export interface HeadersDict {
[header: string]: string;
Expand Down Expand Up @@ -58,6 +69,8 @@ export default abstract class BaseApi {
basePath: string,
namespace: string,
subPath: string,

searchInputs: SearchFetcherProps,
body?: JSONRequestBody,
options: {
additionalHeaders?: HeadersDict;
Expand All @@ -78,45 +91,42 @@ export default abstract class BaseApi {
};

headers["Content-Type"] = "application/json";
const response = await this.sendRequest(url, {
body: method === "GET" || !body ? null : createRequestBody(body),
headers,
method,
});
const response = await this.sendRequest(
url,
{
body: method === "GET" || !body ? null : createRequestBody(body),
headers,
method,
},
searchInputs,
);

return response;
}

/**
* Send a request and handle the response
*/
private async sendRequest(url: string, fetchOptions: RequestInit) {
private async sendRequest(
url: string,
fetchOptions: RequestInit,
searchInputs: SearchFetcherProps,
) {
let response: Response;
let responseBody: SearchAPIResponse;
try {
response = await fetch(url, fetchOptions);
responseBody = (await response.json()) as SearchAPIResponse;
} catch (error) {
console.log("Network Error encountered => ", error);
throw new Error("Network request failed");
// TODO: Error management
// throw fetchErrorToNetworkError(error);
// API most likely down, but also possibly an error setting up or sending a request
// or parsing the response.
throw fetchErrorToNetworkError(error, searchInputs);
}

const { data, message, pagination_info, status_code, errors, warnings } =
const { data, message, pagination_info, status_code, warnings } =
responseBody;
if (!response.ok) {
console.log(
"Not OK Response => ",
response,
errors,
this.namespace,
data,
);

throw new Error("Not OK response received");
// TODO: Error management
// handleNotOkResponse(response, errors, this.namespace, data);
handleNotOkResponse(responseBody, message, status_code, searchInputs);
}

return {
Expand Down Expand Up @@ -174,3 +184,70 @@ function createRequestBody(payload?: JSONRequestBody): XMLHttpRequestBodyInit {

return JSON.stringify(payload);
}

/**
* Handle request errors
*/
export function fetchErrorToNetworkError(
error: unknown,
searchInputs: SearchFetcherProps,
) {
// Request failed to send or something failed while parsing the response
// Log the JS error to support troubleshooting
console.error(error);
return new NetworkError(error, searchInputs);
}

function handleNotOkResponse(
response: SearchAPIResponse,
message: string,
status_code: number,
searchInputs: SearchFetcherProps,
) {
const { errors } = response;
if (isEmpty(errors)) {
// No detailed errors provided, throw generic error based on status code
throwError(message, status_code, searchInputs);
} else {
if (errors) {
const firstError = errors[0] as APIResponseError;
throwError(message, status_code, searchInputs, firstError);
}
}
}

const throwError = (
message: string,
status_code: number,
searchInputs: SearchFetcherProps,
firstError?: APIResponseError,
) => {
// Include just firstError for now, we can expand this
// If we need ValidationErrors to be more expanded
const error = firstError ? { message, firstError } : { message };
switch (status_code) {
case 400:
throw new BadRequestError(error, searchInputs);
case 401:
throw new UnauthorizedError(error, searchInputs);
case 403:
throw new ForbiddenError(error, searchInputs);
case 404:
throw new NotFoundError(error, searchInputs);
case 422:
throw new ValidationError(error, searchInputs);
case 408:
throw new RequestTimeoutError(error, searchInputs);
case 500:
throw new InternalServerError(error, searchInputs);
case 503:
throw new ServiceUnavailableError(error, searchInputs);
default:
throw new ApiRequestError(
error,
searchInputs,
"APIRequestError",
status_code,
);
}
};
1 change: 1 addition & 0 deletions frontend/src/app/api/SearchOpportunityAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default class SearchOpportunityAPI extends BaseApi {
this.basePath,
this.namespace,
subPath,
searchInputs,
requestBody,
);

Expand Down
15 changes: 9 additions & 6 deletions frontend/src/app/search/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ export function SearchForm({
<div className="tablet:grid-col-8">
<SearchResultsHeader
formRef={formRef}
searchResultsLength={searchResults.pagination_info.total_records}
searchResultsLength={
searchResults?.pagination_info?.total_records
}
initialQueryParams={sortbyQueryParams}
/>
<div className="usa-prose">
{searchResults.data.length >= 1 ? (
{searchResults?.data.length >= 1 ? (
<SearchPagination
totalPages={searchResults.pagination_info.total_pages}
totalPages={searchResults?.pagination_info?.total_pages}
page={page}
handlePageChange={handlePageChange}
showHiddenInput={true}
Expand All @@ -93,12 +95,13 @@ export function SearchForm({
) : null}

<SearchResultsList
searchResults={searchResults.data}
searchResults={searchResults?.data}
maxPaginationError={maxPaginationError}
errors={searchResults.errors}
/>
{searchResults.data.length >= 1 ? (
{searchResults?.data?.length >= 1 ? (
<SearchPagination
totalPages={searchResults.pagination_info.total_pages}
totalPages={searchResults?.pagination_info?.total_pages}
page={page}
handlePageChange={handlePageChange}
/>
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/app/search/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"use client"; // Error components must be Client Components

import {
PaginationInfo,
SearchAPIResponse,
} from "src/types/search/searchResponseTypes";

import PageSEO from "src/components/PageSEO";
import SearchCallToAction from "src/components/search/SearchCallToAction";
import { SearchFetcherProps } from "src/services/search/searchfetcher/SearchFetcher";
import { SearchForm } from "src/app/search/SearchForm";
import { useEffect } from "react";

interface ErrorProps {
// Next's error boundary also includes a reset function as a prop for retries,
// but it was not needed as users can retry with new inputs in the normal page flow.
error: Error & { digest?: string };
}

export interface ParsedError {
message: string;
searchInputs: SearchFetcherProps;
status: number;
type: string;
}

export default function Error({ error }: ErrorProps) {
// The error message is passed as an object that's been stringified.
// Parse it here.
const parsedErrorData = JSON.parse(error.message) as ParsedError;

const pagination_info = getErrorPaginationInfo();
const initialSearchResults: SearchAPIResponse = getErrorInitialSearchResults(
parsedErrorData,
pagination_info,
);

// The error message search inputs had to be converted to arrays in order to be stringified,
// convert those back to sets as we do in non-error flow.
const convertedSearchParams = convertSearchInputArraysToSets(
parsedErrorData.searchInputs,
);

useEffect(() => {
console.error(error);
}, [error]);

return (
<>
<PageSEO
title="Search Funding Opportunities"
description="Try out our experimental search page."
/>
<SearchCallToAction />
<SearchForm
initialSearchResults={initialSearchResults}
requestURLQueryParams={convertedSearchParams}
/>
</>
);
}

/*
* Generate empty response data to render the full page on an error
* which otherwise may not have any data.
*/
function getErrorInitialSearchResults(
parsedError: ParsedError,
pagination_info: PaginationInfo,
) {
return {
errors: [{ ...parsedError }],
data: [],
pagination_info,
status_code: parsedError.status,
message: parsedError.message,
};
}

// There will be no pagination shown on an error
// so the values here just need to be valid for the page to
// load without error
function getErrorPaginationInfo() {
return {
order_by: "opportunity_id",
page_offset: 0,
page_size: 25,
sort_direction: "ascending",
total_pages: 1,
total_records: 0,
};
}

function convertSearchInputArraysToSets(
searchInputs: SearchFetcherProps,
): SearchFetcherProps {
return {
...searchInputs,
status: new Set(searchInputs.status || []),
fundingInstrument: new Set(searchInputs.fundingInstrument || []),
eligibility: new Set(searchInputs.eligibility || []),
agency: new Set(searchInputs.agency || []),
category: new Set(searchInputs.category || []),
};
}
2 changes: 0 additions & 2 deletions frontend/src/components/search/SearchCallToAction.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import "server-only";

import Breadcrumbs from "../../components/Breadcrumbs";
import { GridContainer } from "@trussworks/react-uswds";
import React from "react";
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/search/SearchResultsList.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
"use client";

import Loading from "../../app/search/loading";
import SearchErrorAlert from "src/components/search/error/SearchErrorAlert";
import { SearchResponseData } from "../../types/search/searchResponseTypes";
import { useFormStatus } from "react-dom";
import SearchResultsListItem from "./SearchResultsListItem";
import { useFormStatus } from "react-dom";

interface SearchResultsListProps {
searchResults: SearchResponseData;
maxPaginationError: boolean;
errors?: unknown[] | null | undefined; // If passed in, there's been an issue with the fetch call
}

const SearchResultsList: React.FC<SearchResultsListProps> = ({
searchResults,
maxPaginationError,
errors,
}) => {
const { pending } = useFormStatus();

if (pending) {
return <Loading />;
}

if (searchResults.length === 0) {
if (errors) {
return <SearchErrorAlert />;
}

if (searchResults?.length === 0) {
return (
<div>
<h2>Your search did not return any results.</h2>
Expand All @@ -45,7 +52,7 @@ const SearchResultsList: React.FC<SearchResultsListProps> = ({
}
</h4>
)}
{searchResults.map((opportunity) => (
{searchResults?.map((opportunity) => (
<li key={opportunity?.opportunity_id}>
<SearchResultsListItem opportunity={opportunity} />
</li>
Expand Down
Loading

0 comments on commit 4810991

Please sign in to comment.