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 ( +
+
+
+ + + + + + + + + {Object.entries(opportunity.data).map(([key, value]) => { + if (key === "summary" && isSummary(value)) { + return renderSummary(value); + } else { + return ( + + + + + ); + } + })} + +
Field NameData
{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); + }); +});