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();
});