Skip to content
This repository has been archived by the owner on Sep 18, 2024. It is now read-only.

[Issue #96]: Opportunity listing page (first pass) #97

Merged
merged 7 commits into from
Jun 20, 2024
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
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({
Copy link
Member

Choose a reason for hiding this comment

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

I'm still catching up on whether or not making a page async affects ability to statically render the page. The opportunity pages are the same for everyone and rarely update so should not be rebuilt for every single request. Statically rendering seems like the easiest way to achieve that.

Copy link
Member

Choose a reason for hiding this comment

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

We can punt this consideration for #85 .

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

all queryParamData / searchInput references need to be an optional param because they're only used for search (SearchOpportunityAPI class) and not when the derived OpportunityListingAPI class is used.

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";
Copy link
Collaborator Author

@rylew1 rylew1 Jun 19, 2024

Choose a reason for hiding this comment

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

only ever run this code on the next server


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
Loading