From 0772c9db818737d04da6515c0ce3394856950b51 Mon Sep 17 00:00:00 2001
From: Ryan Lewis <93001277+rylew1@users.noreply.github.com>
Date: Thu, 20 Jun 2024 13:53:00 -0700
Subject: [PATCH] [Issue #2068]: Opportunity listing page (first pass)
(navapbc/simpler-grants-gov#97)
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
---
.../app/[locale]/opportunity/[id]/page.tsx | 89 +++++++++++++++
frontend/src/app/api/BaseApi.ts | 20 ++--
frontend/src/app/api/OpportunityListingAPI.ts | 29 +++++
frontend/src/errors.ts | 56 +++++-----
frontend/src/i18n/messages/en/index.ts | 4 +
.../opportunity/opportunityResponseTypes.ts | 57 ++++++++++
frontend/src/utils/getRoutes.ts | 1 +
frontend/src/utils/opportunity/isSummary.ts | 8 ++
.../tests/api/OpportunityListingApi.test.ts | 104 ++++++++++++++++++
frontend/tests/utils/getRoutes.test.ts | 2 +
frontend/tests/utils/isSummary.test.ts | 60 ++++++++++
11 files changed, 393 insertions(+), 37 deletions(-)
create mode 100644 frontend/src/app/[locale]/opportunity/[id]/page.tsx
create mode 100644 frontend/src/app/api/OpportunityListingAPI.ts
create mode 100644 frontend/src/types/opportunity/opportunityResponseTypes.ts
create mode 100644 frontend/src/utils/opportunity/isSummary.ts
create mode 100644 frontend/tests/api/OpportunityListingApi.test.ts
create mode 100644 frontend/tests/utils/isSummary.test.ts
diff --git a/frontend/src/app/[locale]/opportunity/[id]/page.tsx b/frontend/src/app/[locale]/opportunity/[id]/page.tsx
new file mode 100644
index 000000000..68cd03fa6
--- /dev/null
+++ b/frontend/src/app/[locale]/opportunity/[id]/page.tsx
@@ -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 ;
+ }
+
+ const api = new OpportunityListingAPI();
+ let opportunity: ApiResponse;
+ try {
+ opportunity = await api.getOpportunityById(id);
+ } catch (error) {
+ console.error("Failed to fetch opportunity:", error);
+ return ;
+ }
+
+ if (!opportunity.data) {
+ return ;
+ }
+
+ const renderSummary = (summary: Summary) => {
+ return (
+ <>
+ {Object.entries(summary).map(([summaryKey, summaryValue]) => (
+
+ {`summary.${summaryKey}`} |
+ {JSON.stringify(summaryValue)} |
+
+ ))}
+ >
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ Field Name |
+ Data |
+
+
+
+ {Object.entries(opportunity.data).map(([key, value]) => {
+ if (key === "summary" && isSummary(value)) {
+ return renderSummary(value);
+ } else {
+ return (
+
+ {key} |
+ {JSON.stringify(value)} |
+
+ );
+ }
+ })}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/api/BaseApi.ts b/frontend/src/app/api/BaseApi.ts
index 5587dc161..89ffc39f4 100644
--- a/frontend/src/app/api/BaseApi.ts
+++ b/frontend/src/app/api/BaseApi.ts
@@ -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 {
@@ -69,7 +65,7 @@ export default abstract class BaseApi {
basePath: string,
namespace: string,
subPath: string,
- queryParamData: QueryParamData,
+ queryParamData?: QueryParamData,
body?: JSONRequestBody,
options: {
additionalHeaders?: HeadersDict;
@@ -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;
@@ -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)) {
@@ -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);
@@ -246,9 +244,9 @@ const throwError = (
default:
throw new ApiRequestError(
error,
- searchInputs,
"APIRequestError",
status_code,
+ searchInputs,
);
}
};
diff --git a/frontend/src/app/api/OpportunityListingAPI.ts b/frontend/src/app/api/OpportunityListingAPI.ts
new file mode 100644
index 000000000..8f3628a1f
--- /dev/null
+++ b/frontend/src/app/api/OpportunityListingAPI.ts
@@ -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 {
+ const subPath = `${opportunityId}`;
+ const response = await this.request(
+ "GET",
+ this.basePath,
+ this.namespace,
+ subPath,
+ );
+ return response as ApiResponse;
+ }
+}
diff --git a/frontend/src/errors.ts b/frontend/src/errors.ts
index b909128b8..3fa0cef2b 100644
--- a/frontend/src/errors.ts
+++ b/frontend/src/errors.ts
@@ -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",
@@ -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,
@@ -61,11 +65,11 @@ 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);
}
}
@@ -73,8 +77,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, searchInputs, "BadRequestError", 400);
+ constructor(error: unknown, searchInputs?: QueryParamData) {
+ super(error, "BadRequestError", 400, searchInputs);
}
}
@@ -82,8 +86,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, searchInputs, "UnauthorizedError", 401);
+ constructor(error: unknown, searchInputs?: QueryParamData) {
+ super(error, "UnauthorizedError", 401, searchInputs);
}
}
@@ -93,8 +97,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, searchInputs, "ForbiddenError", 403);
+ constructor(error: unknown, searchInputs?: QueryParamData) {
+ super(error, "ForbiddenError", 403, searchInputs);
}
}
@@ -102,8 +106,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, searchInputs, "NotFoundError", 404);
+ constructor(error: unknown, searchInputs?: QueryParamData) {
+ super(error, "NotFoundError", 404, searchInputs);
}
}
@@ -111,8 +115,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, searchInputs, "RequestTimeoutError", 408);
+ constructor(error: unknown, searchInputs?: QueryParamData) {
+ super(error, "RequestTimeoutError", 408, searchInputs);
}
}
@@ -120,8 +124,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, searchInputs, "ValidationError", 422);
+ constructor(error: unknown, searchInputs?: QueryParamData) {
+ super(error, "ValidationError", 422, searchInputs);
}
}
@@ -133,8 +137,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, searchInputs, "InternalServerError", 500);
+ constructor(error: unknown, searchInputs?: QueryParamData) {
+ super(error, "InternalServerError", 500, searchInputs);
}
}
@@ -142,8 +146,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, searchInputs, "ServiceUnavailableError", 503);
+ constructor(error: unknown, searchInputs?: QueryParamData) {
+ super(error, "ServiceUnavailableError", 503, searchInputs);
}
}
diff --git a/frontend/src/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts
index 11a5f900a..563025d4f 100644
--- a/frontend/src/i18n/messages/en/index.ts
+++ b/frontend/src/i18n/messages/en/index.ts
@@ -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:
diff --git a/frontend/src/types/opportunity/opportunityResponseTypes.ts b/frontend/src/types/opportunity/opportunityResponseTypes.ts
new file mode 100644
index 000000000..4b64f0982
--- /dev/null
+++ b/frontend/src/types/opportunity/opportunityResponseTypes.ts
@@ -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;
+}
diff --git a/frontend/src/utils/getRoutes.ts b/frontend/src/utils/getRoutes.ts
index 4cc33a6f6..e77394342 100644
--- a/frontend/src/utils/getRoutes.ts
+++ b/frontend/src/utils/getRoutes.ts
@@ -32,6 +32,7 @@ export function getNextRoutes(src: string): string[] {
.replace("/page.tsx", "")
.replace(/\[locale\]/g, "")
.replace(/\\/g, "/")
+ .replace(/\[id\]/g, "1") // for id-based routes like /opportunity/[id]
: "/";
return route.replace(/\/\//g, "/");
});
diff --git a/frontend/src/utils/opportunity/isSummary.ts b/frontend/src/utils/opportunity/isSummary.ts
new file mode 100644
index 000000000..b881d14f6
--- /dev/null
+++ b/frontend/src/utils/opportunity/isSummary.ts
@@ -0,0 +1,8 @@
+import { Summary } from "../../types/opportunity/opportunityResponseTypes";
+
+export function isSummary(value: unknown): value is Summary {
+ if (typeof value === "object" && value !== null) {
+ return "additional_info_url" in value;
+ }
+ return false;
+}
diff --git a/frontend/tests/api/OpportunityListingApi.test.ts b/frontend/tests/api/OpportunityListingApi.test.ts
new file mode 100644
index 000000000..03bb71c43
--- /dev/null
+++ b/frontend/tests/api/OpportunityListingApi.test.ts
@@ -0,0 +1,104 @@
+import { ApiResponse } from "../../src/types/opportunity/opportunityResponseTypes";
+import OpportunityListingAPI from "../../src/app/api/OpportunityListingAPI";
+
+jest.mock("../../src/app/api/BaseApi");
+
+describe("OpportunityListingAPI", () => {
+ const mockedRequest = jest.fn();
+ const opportunityListingAPI = new OpportunityListingAPI();
+
+ beforeAll(() => {
+ opportunityListingAPI.request = mockedRequest;
+ });
+
+ afterEach(() => {
+ mockedRequest.mockReset();
+ });
+
+ it("should return opportunity data for a valid ID", async () => {
+ const mockResponse: ApiResponse = getValidMockResponse();
+
+ mockedRequest.mockResolvedValue(mockResponse);
+
+ const result = await opportunityListingAPI.getOpportunityById(12345);
+ console.log("results => ", result);
+ expect(mockedRequest).toHaveBeenCalledWith(
+ "GET",
+ opportunityListingAPI.basePath,
+ opportunityListingAPI.namespace,
+ "12345",
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it("should throw an error if request fails", async () => {
+ const mockError = new Error("Request failed");
+ mockedRequest.mockRejectedValue(mockError);
+
+ await expect(
+ opportunityListingAPI.getOpportunityById(12345),
+ ).rejects.toThrow("Request failed");
+ });
+});
+
+function getValidMockResponse() {
+ return {
+ data: [
+ {
+ agency: "US-ABC",
+ category: "discretionary",
+ category_explanation: null,
+ created_at: "2024-06-20T18:43:04.555Z",
+ opportunity_assistance_listings: [
+ {
+ assistance_listing_number: "43.012",
+ program_title: "Space Technology",
+ },
+ ],
+ opportunity_id: 12345,
+ opportunity_number: "ABC-123-XYZ-001",
+ opportunity_status: "posted",
+ opportunity_title: "Research into conservation techniques",
+ summary: {
+ additional_info_url: "grants.gov",
+ additional_info_url_description: "Click me for more info",
+ agency_code: "US-ABC",
+ agency_contact_description:
+ "For more information, reach out to Jane Smith at agency US-ABC",
+ agency_email_address: "fake_email@grants.gov",
+ agency_email_address_description: "Click me to email the agency",
+ agency_name: "US Alphabetical Basic Corp",
+ agency_phone_number: "123-456-7890",
+ applicant_eligibility_description:
+ "All types of domestic applicants are eligible to apply",
+ applicant_types: ["state_governments"],
+ archive_date: "2024-06-20",
+ award_ceiling: 100000,
+ award_floor: 10000,
+ close_date: "2024-06-20",
+ close_date_description: "Proposals are due earlier than usual.",
+ estimated_total_program_funding: 10000000,
+ expected_number_of_awards: 10,
+ fiscal_year: 0,
+ forecasted_award_date: "2024-06-20",
+ forecasted_close_date: "2024-06-20",
+ forecasted_close_date_description:
+ "Proposals will probably be due on this date",
+ forecasted_post_date: "2024-06-20",
+ forecasted_project_start_date: "2024-06-20",
+ funding_categories: ["recovery_act"],
+ funding_category_description: "Economic Support",
+ funding_instruments: ["cooperative_agreement"],
+ is_cost_sharing: true,
+ is_forecast: false,
+ post_date: "2024-06-20",
+ summary_description:
+ "This opportunity aims to unravel the mysteries of the universe.",
+ },
+ updated_at: "2024-06-20T18:43:04.555Z",
+ },
+ ],
+ message: "Success",
+ status_code: 200,
+ };
+}
diff --git a/frontend/tests/utils/getRoutes.test.ts b/frontend/tests/utils/getRoutes.test.ts
index c6fed8a06..7ac733495 100644
--- a/frontend/tests/utils/getRoutes.test.ts
+++ b/frontend/tests/utils/getRoutes.test.ts
@@ -12,6 +12,7 @@ jest.mock("../../src/utils/getRoutes", () => {
const mockedListPaths = listPaths as jest.MockedFunction;
+// TODO: https://github.com/navapbc/simpler-grants-gov/issues/98
describe("getNextRoutes", () => {
beforeEach(() => {
jest.resetAllMocks();
@@ -30,6 +31,7 @@ describe("getNextRoutes", () => {
"/newsletter/confirmation",
"/newsletter",
"/newsletter/unsubscribe",
+ "/opportunity/1",
"/",
"/process",
"/research",
diff --git a/frontend/tests/utils/isSummary.test.ts b/frontend/tests/utils/isSummary.test.ts
new file mode 100644
index 000000000..a154c80a7
--- /dev/null
+++ b/frontend/tests/utils/isSummary.test.ts
@@ -0,0 +1,60 @@
+import { Summary } from "../../src/types/opportunity/opportunityResponseTypes";
+import { isSummary } from "../../src/utils/opportunity/isSummary";
+
+describe("isSummary", () => {
+ it("should return true for a valid Summary object", () => {
+ const validSummary: Summary = {
+ additional_info_url: "https://example.com",
+ additional_info_url_description: "Click for more info",
+ agency_code: "AGENCY123",
+ agency_contact_description: "Contact Description",
+ agency_email_address: "contact@example.com",
+ agency_email_address_description: "Email the agency",
+ agency_name: "Agency Name",
+ agency_phone_number: "123-456-7890",
+ applicant_eligibility_description: "Eligibility Description",
+ applicant_types: ["type1", "type2"],
+ archive_date: "2024-12-31",
+ award_ceiling: 100000,
+ award_floor: 5000,
+ close_date: "2024-11-30",
+ close_date_description: "Closing Date Description",
+ estimated_total_program_funding: 1000000,
+ expected_number_of_awards: 5,
+ fiscal_year: 2024,
+ forecasted_award_date: "2024-10-01",
+ forecasted_close_date: "2024-09-30",
+ forecasted_close_date_description: "Forecasted Close Date Description",
+ forecasted_post_date: "2024-08-01",
+ forecasted_project_start_date: "2024-07-01",
+ funding_categories: ["category1"],
+ funding_category_description: "Funding Category Description",
+ funding_instruments: ["instrument1"],
+ is_cost_sharing: true,
+ is_forecast: false,
+ post_date: "2024-06-01",
+ summary_description: "This is a summary description",
+ };
+
+ expect(isSummary(validSummary)).toBe(true);
+ });
+
+ it("should return false for an invalid Summary object", () => {
+ const invalidSummary = {
+ some_other_field: "Some value",
+ };
+
+ expect(isSummary(invalidSummary)).toBe(false);
+ });
+
+ it("should return false for null or undefined", () => {
+ expect(isSummary(null)).toBe(false);
+ expect(isSummary(undefined)).toBe(false);
+ });
+
+ it("should return false for non-object types", () => {
+ expect(isSummary("string")).toBe(false);
+ expect(isSummary(123)).toBe(false);
+ expect(isSummary(true)).toBe(false);
+ });
+});