Skip to content

Commit

Permalink
[Issue #2068]: Opportunity listing page (first pass) (navapbc#97)
Browse files Browse the repository at this point in the history
Fixes #2068

- Add new id-based opportunity page
- Add new `OpportunityListingAPI` class extended from `BaseAPI`
- Make `searchInputs`/`QueryParamData` in `BaseAPI` and `errors.ts`
optional params (only used for search page)
- Update sitemap to replace [id] in url with 1
- Add test coverage
  • Loading branch information
rylew1 authored and acouch committed Sep 18, 2024
1 parent 55fd596 commit 0772c9d
Show file tree
Hide file tree
Showing 11 changed files with 393 additions and 37 deletions.
89 changes: 89 additions & 0 deletions frontend/src/app/[locale]/opportunity/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
ApiResponse,
Summary,
} from "../../../../types/opportunity/opportunityResponseTypes";

import { Metadata } from "next";
import NotFound from "../../../not-found";
import OpportunityListingAPI from "../../../api/OpportunityListingAPI";
import { getTranslations } from "next-intl/server";
import { isSummary } from "../../../../utils/opportunity/isSummary";

export async function generateMetadata() {
const t = await getTranslations({ locale: "en" });
const meta: Metadata = {
title: t("OpportunityListing.page_title"),
description: t("OpportunityListing.meta_description"),
};
return meta;
}

export default async function OpportunityListing({
params,
}: {
params: { id: string };
}) {
const id = Number(params.id);

// Opportunity id needs to be a number greater than 1
if (isNaN(id) || id < 0) {
return <NotFound />;
}

const api = new OpportunityListingAPI();
let opportunity: ApiResponse;
try {
opportunity = await api.getOpportunityById(id);
} catch (error) {
console.error("Failed to fetch opportunity:", error);
return <NotFound />;
}

if (!opportunity.data) {
return <NotFound />;
}

const renderSummary = (summary: Summary) => {
return (
<>
{Object.entries(summary).map(([summaryKey, summaryValue]) => (
<tr key={summaryKey}>
<td className="word-wrap">{`summary.${summaryKey}`}</td>
<td className="word-wrap">{JSON.stringify(summaryValue)}</td>
</tr>
))}
</>
);
};

return (
<div className="grid-container">
<div className="grid-row margin-y-4">
<div className="usa-table-container">
<table className="usa-table usa-table--borderless margin-x-auto width-full maxw-desktop-lg">
<thead>
<tr>
<th>Field Name</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{Object.entries(opportunity.data).map(([key, value]) => {
if (key === "summary" && isSummary(value)) {
return renderSummary(value);
} else {
return (
<tr key={key}>
<td className="word-wrap">{key}</td>
<td className="word-wrap">{JSON.stringify(value)}</td>
</tr>
);
}
})}
</tbody>
</table>
</div>
</div>
</div>
);
}
20 changes: 9 additions & 11 deletions frontend/src/app/api/BaseApi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
// This server-only package is recommended by Next.js to ensure code is only run on the server.
// It provides a build-time error if client-side code attempts to invoke the code here.
// Since we're pulling in an API Auth Token here, this should be server only
// https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment
import "server-only";

import {
Expand Down Expand Up @@ -69,7 +65,7 @@ export default abstract class BaseApi {
basePath: string,
namespace: string,
subPath: string,
queryParamData: QueryParamData,
queryParamData?: QueryParamData,
body?: JSONRequestBody,
options: {
additionalHeaders?: HeadersDict;
Expand Down Expand Up @@ -109,7 +105,7 @@ export default abstract class BaseApi {
private async sendRequest(
url: string,
fetchOptions: RequestInit,
queryParamData: QueryParamData,
queryParamData?: QueryParamData,
) {
let response: Response;
let responseBody: SearchAPIResponse;
Expand Down Expand Up @@ -189,19 +185,21 @@ function createRequestBody(payload?: JSONRequestBody): XMLHttpRequestBodyInit {
*/
export function fetchErrorToNetworkError(
error: unknown,
searchInputs: QueryParamData,
searchInputs?: QueryParamData,
) {
// 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);
return searchInputs
? new NetworkError(error, searchInputs)
: new NetworkError(error);
}

function handleNotOkResponse(
response: SearchAPIResponse,
message: string,
status_code: number,
searchInputs: QueryParamData,
searchInputs?: QueryParamData,
) {
const { errors } = response;
if (isEmpty(errors)) {
Expand All @@ -218,7 +216,7 @@ function handleNotOkResponse(
const throwError = (
message: string,
status_code: number,
searchInputs: QueryParamData,
searchInputs?: QueryParamData,
firstError?: APIResponseError,
) => {
console.log("Throwing error: ", message, status_code, searchInputs);
Expand Down Expand Up @@ -246,9 +244,9 @@ const throwError = (
default:
throw new ApiRequestError(
error,
searchInputs,
"APIRequestError",
status_code,
searchInputs,
);
}
};
29 changes: 29 additions & 0 deletions frontend/src/app/api/OpportunityListingAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import "server-only";

import { ApiResponse } from "../../types/opportunity/opportunityResponseTypes";
import BaseApi from "./BaseApi";

export default class OpportunityListingAPI extends BaseApi {
get version(): string {
return "v1";
}

get basePath(): string {
return process.env.API_URL || "";
}

get namespace(): string {
return "opportunities";
}

async getOpportunityById(opportunityId: number): Promise<ApiResponse> {
const subPath = `${opportunityId}`;
const response = await this.request(
"GET",
this.basePath,
this.namespace,
subPath,
);
return response as ApiResponse;
}
}
56 changes: 30 additions & 26 deletions frontend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher"
*/

export class NetworkError extends Error {
constructor(error: unknown, searchInputs: QueryParamData) {
const serializedSearchInputs = convertSearchInputSetsToArrays(searchInputs);
constructor(error: unknown, searchInputs?: QueryParamData) {
const serializedSearchInputs = searchInputs
? convertSearchInputSetsToArrays(searchInputs)
: {};

const serializedData = JSON.stringify({
type: "NetworkError",
Expand All @@ -29,15 +31,17 @@ export class NetworkError extends Error {
export class BaseFrontendError extends Error {
constructor(
error: unknown,
searchInputs: QueryParamData,
type: string,
type = "BaseFrontendError",
status?: number,
searchInputs?: QueryParamData,
) {
// Sets cannot be properly serialized so convert to arrays first
const serializedSearchInputs = convertSearchInputSetsToArrays(searchInputs);
const serializedSearchInputs = searchInputs
? convertSearchInputSetsToArrays(searchInputs)
: {};

const serializedData = JSON.stringify({
type: type || "BaseFrontendError",
type,
searchInputs: serializedSearchInputs,
message: error instanceof Error ? error.message : "Unknown Error",
status,
Expand All @@ -61,29 +65,29 @@ export class BaseFrontendError extends Error {
export class ApiRequestError extends BaseFrontendError {
constructor(
error: unknown,
searchInputs: QueryParamData,
type: string,
status: number,
type = "APIRequestError",
status = 400,
searchInputs?: QueryParamData,
) {
super(error, searchInputs, type || "APIRequestError", status || 400);
super(error, type, status, searchInputs);
}
}

/**
* 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, searchInputs, "BadRequestError", 400);
constructor(error: unknown, searchInputs?: QueryParamData) {
super(error, "BadRequestError", 400, searchInputs);
}
}

/**
* An API response returned a 401 status code
*/
export class UnauthorizedError extends ApiRequestError {
constructor(error: unknown, searchInputs: QueryParamData) {
super(error, searchInputs, "UnauthorizedError", 401);
constructor(error: unknown, searchInputs?: QueryParamData) {
super(error, "UnauthorizedError", 401, searchInputs);
}
}

Expand All @@ -93,35 +97,35 @@ 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, searchInputs, "ForbiddenError", 403);
constructor(error: unknown, searchInputs?: QueryParamData) {
super(error, "ForbiddenError", 403, searchInputs);
}
}

/**
* A fetch request failed due to a 404 error
*/
export class NotFoundError extends ApiRequestError {
constructor(error: unknown, searchInputs: QueryParamData) {
super(error, searchInputs, "NotFoundError", 404);
constructor(error: unknown, searchInputs?: QueryParamData) {
super(error, "NotFoundError", 404, searchInputs);
}
}

/**
* An API response returned a 408 status code
*/
export class RequestTimeoutError extends ApiRequestError {
constructor(error: unknown, searchInputs: QueryParamData) {
super(error, searchInputs, "RequestTimeoutError", 408);
constructor(error: unknown, searchInputs?: QueryParamData) {
super(error, "RequestTimeoutError", 408, searchInputs);
}
}

/**
* An API response returned a 422 status code
*/
export class ValidationError extends ApiRequestError {
constructor(error: unknown, searchInputs: QueryParamData) {
super(error, searchInputs, "ValidationError", 422);
constructor(error: unknown, searchInputs?: QueryParamData) {
super(error, "ValidationError", 422, searchInputs);
}
}

Expand All @@ -133,17 +137,17 @@ 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, searchInputs, "InternalServerError", 500);
constructor(error: unknown, searchInputs?: QueryParamData) {
super(error, "InternalServerError", 500, searchInputs);
}
}

/**
* An API response returned a 503 status code
*/
export class ServiceUnavailableError extends ApiRequestError {
constructor(error: unknown, searchInputs: QueryParamData) {
super(error, searchInputs, "ServiceUnavailableError", 503);
constructor(error: unknown, searchInputs?: QueryParamData) {
super(error, "ServiceUnavailableError", 503, searchInputs);
}
}

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/i18n/messages/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const messages = {
alert:
"Simpler.Grants.gov is a work in progress. Thank you for your patience as we build this new website.",
},
OpportunityListing: {
page_title: "Opportunity Listing",
meta_description: "Summary details for the specific opportunity listing.",
},
Index: {
page_title: "Simpler.Grants.gov",
meta_description:
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/types/opportunity/opportunityResponseTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export interface OpportunityAssistanceListing {
assistance_listing_number: string;
program_title: string;
}

export interface Summary {
additional_info_url: string;
additional_info_url_description: string;
agency_code: string;
agency_contact_description: string;
agency_email_address: string;
agency_email_address_description: string;
agency_name: string;
agency_phone_number: string;
applicant_eligibility_description: string;
applicant_types: string[];
archive_date: string;
award_ceiling: number;
award_floor: number;
close_date: string;
close_date_description: string;
estimated_total_program_funding: number;
expected_number_of_awards: number;
fiscal_year: number;
forecasted_award_date: string;
forecasted_close_date: string;
forecasted_close_date_description: string;
forecasted_post_date: string;
forecasted_project_start_date: string;
funding_categories: string[];
funding_category_description: string;
funding_instruments: string[];
is_cost_sharing: boolean;
is_forecast: boolean;
post_date: string;
summary_description: string;
}

export interface Opportunity {
agency: string;
category: string;
category_explanation: string | null;
created_at: string;
opportunity_assistance_listings: OpportunityAssistanceListing[];
opportunity_id: number;
opportunity_number: string;
opportunity_status: string;
opportunity_title: string;
summary: Summary;
updated_at: string;
}

export interface ApiResponse {
data: Opportunity[];
message: string;
status_code: number;
}
Loading

0 comments on commit 0772c9d

Please sign in to comment.