From dfd3e7428073aa5d03ddaddc68c4466019223d5a Mon Sep 17 00:00:00 2001 From: doug-s-nava <92806979+doug-s-nava@users.noreply.github.com> Date: Fri, 25 Oct 2024 14:56:32 -0400 Subject: [PATCH] [Issue #2273] create opportunity call to action linking to legacy (#2543) * Adds a CTA box on the opportunity detail page with link to legacy site * upgrades the testing-library package to fix bug with querying for paragraph role --- frontend/package-lock.json | 49 ++++++++++----- frontend/package.json | 4 +- .../app/[locale]/opportunity/[id]/page.tsx | 2 + .../opportunity/OpportunityAwardGridRow.tsx | 2 +- .../components/opportunity/OpportunityCTA.tsx | 50 +++++++++++++++ .../opportunity/OpportunityStatusWidget.tsx | 2 +- frontend/src/constants/environments.ts | 4 ++ frontend/src/i18n/messages/en/index.ts | 6 ++ .../search/searchfetcher/MockSearchFetcher.ts | 2 +- .../opportunity/opportunityResponseTypes.ts | 4 +- .../src/types/search/searchResponseTypes.ts | 6 +- .../tests/api/OpportunityListingApi.test.ts | 4 +- .../opportunity/OpportunityCTA.test.tsx | 62 +++++++++++++++++++ .../OpportunityStatusWidget.test.tsx | 46 ++++++++------ 14 files changed, 200 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/opportunity/OpportunityCTA.tsx create mode 100644 frontend/tests/components/opportunity/OpportunityCTA.test.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7b78dfaf2..82ee3807e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,9 +32,9 @@ "@storybook/addon-essentials": "^7.1.0", "@storybook/nextjs": "^7.1.0", "@storybook/react": "^7.1.0", - "@testing-library/dom": "^9.0.1", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.4.3", "@types/jest-axe": "^3.5.5", "@types/js-cookie": "^3.0.6", @@ -8240,22 +8240,23 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", + "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { @@ -8273,6 +8274,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/@testing-library/dom/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8382,21 +8393,31 @@ } }, "node_modules/@testing-library/react": { - "version": "14.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.1.tgz", + "integrity": "sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@testing-library/user-event": { diff --git a/frontend/package.json b/frontend/package.json index 9c41ec3c6..4f143ba18 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,9 +49,9 @@ "@storybook/addon-essentials": "^7.1.0", "@storybook/nextjs": "^7.1.0", "@storybook/react": "^7.1.0", - "@testing-library/dom": "^9.0.1", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.4.3", "@types/jest-axe": "^3.5.5", "@types/js-cookie": "^3.0.6", diff --git a/frontend/src/app/[locale]/opportunity/[id]/page.tsx b/frontend/src/app/[locale]/opportunity/[id]/page.tsx index c89a4fb76..cbfae16f5 100644 --- a/frontend/src/app/[locale]/opportunity/[id]/page.tsx +++ b/frontend/src/app/[locale]/opportunity/[id]/page.tsx @@ -12,6 +12,7 @@ import { GridContainer } from "@trussworks/react-uswds"; import BetaAlert from "src/components/BetaAlert"; import Breadcrumbs from "src/components/Breadcrumbs"; import OpportunityAwardInfo from "src/components/opportunity/OpportunityAwardInfo"; +import OpportunityCTA from "src/components/opportunity/OpportunityCTA"; import OpportunityDescription from "src/components/opportunity/OpportunityDescription"; import OpportunityHistory from "src/components/opportunity/OpportunityHistory"; import OpportunityIntro from "src/components/opportunity/OpportunityIntro"; @@ -120,6 +121,7 @@ async function OpportunityListing({ params }: { params: { id: string } }) {
+
diff --git a/frontend/src/components/opportunity/OpportunityAwardGridRow.tsx b/frontend/src/components/opportunity/OpportunityAwardGridRow.tsx index 9f38a5ea9..d7235e826 100644 --- a/frontend/src/components/opportunity/OpportunityAwardGridRow.tsx +++ b/frontend/src/components/opportunity/OpportunityAwardGridRow.tsx @@ -18,7 +18,7 @@ const OpportunityAwardGridRow = ({ title, content }: Props) => { const t = useTranslations("OpportunityListing.award_info"); return ( -
+

{content || defaultContentByType(title)}

diff --git a/frontend/src/components/opportunity/OpportunityCTA.tsx b/frontend/src/components/opportunity/OpportunityCTA.tsx new file mode 100644 index 000000000..787d82d08 --- /dev/null +++ b/frontend/src/components/opportunity/OpportunityCTA.tsx @@ -0,0 +1,50 @@ +import { environment } from "src/constants/environments"; + +import { useTranslations } from "next-intl"; +import { ReactNode } from "react"; +import { Button } from "@trussworks/react-uswds"; + +import { USWDSIcon } from "src/components/USWDSIcon"; + +export const OpportunityContentBox = ({ + title, + content, +}: { + title?: string | ReactNode; + content: string | ReactNode; +}) => { + return ( +
+ {title &&

{title}

} +

{content}

+
+ ); +}; + +const OpportunityCTA = ({ id }: { id: number }) => { + const t = useTranslations("OpportunityListing.cta"); + const legacyOpportunityURL = `${environment.LEGACY_HOST}/search-results-detail/${id}`; + + const content = ( + <> + {t("apply_content")} + + + + + ); + + return ( +
+ +
+ ); +}; + +export default OpportunityCTA; diff --git a/frontend/src/components/opportunity/OpportunityStatusWidget.tsx b/frontend/src/components/opportunity/OpportunityStatusWidget.tsx index 00cd128a1..f6ad2fcad 100644 --- a/frontend/src/components/opportunity/OpportunityStatusWidget.tsx +++ b/frontend/src/components/opportunity/OpportunityStatusWidget.tsx @@ -56,7 +56,7 @@ const OpportunityStatusWidget = ({ opportunityData }: Props) => { return (

- {t("forecasted")} + {t("forecasted")}

); diff --git a/frontend/src/constants/environments.ts b/frontend/src/constants/environments.ts index b4a0c5656..bf9847e87 100644 --- a/frontend/src/constants/environments.ts +++ b/frontend/src/constants/environments.ts @@ -35,6 +35,10 @@ export const PUBLIC_ENV = PUBLIC_ENV_VARS_BY_ENV[CURRENT_ENV]; // home for all interpreted server side environment variables export const environment: { [key: string]: string } = { + LEGACY_HOST: + NODE_ENV === "production" + ? "https://grants.gov" + : "https://test.grants.gov", NEXT_PUBLIC_BASE_PATH: NEXT_PUBLIC_BASE_PATH ?? "", USE_SEARCH_MOCK_DATA, SENDY_API_URL: SENDY_API_URL || "", diff --git a/frontend/src/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts index e48dd247f..eb7dbe1d2 100644 --- a/frontend/src/i18n/messages/en/index.ts +++ b/frontend/src/i18n/messages/en/index.ts @@ -52,6 +52,12 @@ export const messages = { closing_warn: "Electronically submitted applications must be submitted no later than 5:00 p.m., ET, on the listed application due date.", }, + cta: { + apply_title: "Application process", + apply_content: + "This site is a work in progress. Go to www.grants.gov to apply, track application status, and subscribe to updates.", + button_content: "View on Grants.gov", + }, }, Index: { page_title: "Simpler.Grants.gov", diff --git a/frontend/src/services/search/searchfetcher/MockSearchFetcher.ts b/frontend/src/services/search/searchfetcher/MockSearchFetcher.ts index b72c2c826..a75fbc214 100644 --- a/frontend/src/services/search/searchfetcher/MockSearchFetcher.ts +++ b/frontend/src/services/search/searchfetcher/MockSearchFetcher.ts @@ -9,6 +9,6 @@ export class MockSearchFetcher extends SearchFetcher { async fetchOpportunities(): Promise { // simulate delay await new Promise((resolve) => setTimeout(resolve, 750)); - return mockData; + return mockData as SearchAPIResponse; } } diff --git a/frontend/src/types/opportunity/opportunityResponseTypes.ts b/frontend/src/types/opportunity/opportunityResponseTypes.ts index 42aba8cea..30e5f778f 100644 --- a/frontend/src/types/opportunity/opportunityResponseTypes.ts +++ b/frontend/src/types/opportunity/opportunityResponseTypes.ts @@ -1,3 +1,5 @@ +export type OpportunityStatus = "archived" | "closed" | "posted" | "forecasted"; + export interface OpportunityAssistanceListing { assistance_listing_number: string; program_title: string; @@ -47,7 +49,7 @@ export interface Opportunity { opportunity_assistance_listings: OpportunityAssistanceListing[]; opportunity_id: number; opportunity_number: string; - opportunity_status: string; + opportunity_status: OpportunityStatus; opportunity_title: string; summary: Summary; updated_at: string; diff --git a/frontend/src/types/search/searchResponseTypes.ts b/frontend/src/types/search/searchResponseTypes.ts index 9d2a84c59..269b50b59 100644 --- a/frontend/src/types/search/searchResponseTypes.ts +++ b/frontend/src/types/search/searchResponseTypes.ts @@ -1,6 +1,6 @@ import { PaginationInfo } from "src/types/apiResponseTypes"; - -import { SearchFetcherActionType } from "./searchRequestTypes"; +import { OpportunityStatus } from "src/types/opportunity/opportunityResponseTypes"; +import { SearchFetcherActionType } from "src/types/search/searchRequestTypes"; export interface AssistanceListing { assistance_listing_number: string; @@ -49,7 +49,7 @@ export interface Opportunity { opportunity_assistance_listings: AssistanceListing[]; opportunity_id: number; opportunity_number: string; - opportunity_status: string; + opportunity_status: OpportunityStatus; opportunity_title: string; summary: Summary; updated_at: string; diff --git a/frontend/tests/api/OpportunityListingApi.test.ts b/frontend/tests/api/OpportunityListingApi.test.ts index a50861467..55acf3f81 100644 --- a/frontend/tests/api/OpportunityListingApi.test.ts +++ b/frontend/tests/api/OpportunityListingApi.test.ts @@ -16,7 +16,7 @@ describe("OpportunityListingAPI", () => { }); it("should return opportunity data for a valid ID", async () => { - const mockResponse: OpportunityApiResponse = getValidMockResponse(); + const mockResponse = getValidMockResponse(); mockedRequest.mockResolvedValue(mockResponse); @@ -41,7 +41,7 @@ describe("OpportunityListingAPI", () => { }); }); -function getValidMockResponse() { +function getValidMockResponse(): OpportunityApiResponse { return { data: { agency: "US-ABC", diff --git a/frontend/tests/components/opportunity/OpportunityCTA.test.tsx b/frontend/tests/components/opportunity/OpportunityCTA.test.tsx new file mode 100644 index 000000000..bb851011a --- /dev/null +++ b/frontend/tests/components/opportunity/OpportunityCTA.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import { useTranslationsMock } from "src/utils/testing/intlMocks"; + +import OpportunityCTA, { + OpportunityContentBox, +} from "src/components/opportunity/OpportunityCTA"; + +jest.mock("next-intl", () => ({ + useTranslations: () => useTranslationsMock(), +})); + +describe("OpportunityCTA", () => { + it("renders the expected content and title", () => { + render(); + + expect(screen.getByText("apply_title")).toBeInTheDocument(); + expect(screen.getByText("apply_content")).toBeInTheDocument(); + }); + + it("renders a link that links out to the opportunity detail on grants.gov", () => { + render(); + + const link = screen.getByRole("link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute( + "href", + "https://test.grants.gov/search-results-detail/1", + ); + }); +}); + +describe("OpportunityContentBox", () => { + it("displays title if one is provided", () => { + render(); + expect(screen.getByText("fun title")).toBeInTheDocument(); + }); + it("does not displays title if one is not provided", () => { + render(); + expect(screen.getAllByRole("paragraph")).toHaveLength(1); + }); + it("displays content as string or React children", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("fun content")).toBeInTheDocument(); + + rerender( + + Some Stuff + + + } + />, + ); + + expect(screen.getByText("Some Stuff")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/components/opportunity/OpportunityStatusWidget.test.tsx b/frontend/tests/components/opportunity/OpportunityStatusWidget.test.tsx index 407d2e642..dacd91c0b 100644 --- a/frontend/tests/components/opportunity/OpportunityStatusWidget.test.tsx +++ b/frontend/tests/components/opportunity/OpportunityStatusWidget.test.tsx @@ -25,6 +25,13 @@ jest.mock("next-intl", () => ({ }), })); +const createMockOpportunityData = ( + overrides: Partial, +): Opportunity => ({ + ...mockOpportunityData, + ...overrides, +}); + const mockOpportunityData: Opportunity = { opportunity_status: "posted", summary: { @@ -45,36 +52,39 @@ describe("OpportunityStatusWidget", () => { }); it("renders 'Closed' status tag correctly", () => { - const closedData = { - ...mockOpportunityData, - opportunity_status: "closed", - }; - - render(); + render( + , + ); expect(screen.getByText("Closed:")).toBeInTheDocument(); expect(screen.getByText("2024-12-01")).toBeInTheDocument(); }); it("renders 'Archived' status tag correctly", () => { - const archivedData = { - ...mockOpportunityData, - opportunity_status: "archived", - }; - - render(); + render( + , + ); expect(screen.getByText("Archived:")).toBeInTheDocument(); expect(screen.getByText("2025-01-01")).toBeInTheDocument(); }); it("renders 'Forecasted' status tag correctly", () => { - const forecastedData = { - ...mockOpportunityData, - opportunity_status: "forecasted", - }; - - render(); + render( + , + ); expect(screen.getByText("Forecasted")).toBeInTheDocument(); });