diff --git a/documentation/frontend/development.md b/documentation/frontend/development.md index e3b886195..326752063 100644 --- a/documentation/frontend/development.md +++ b/documentation/frontend/development.md @@ -133,6 +133,25 @@ It's recommended that developers configure their code editor to auto run these t - `npm run format-check`: Check files for prettier formatting violations without fixing them - `npm run all-checks`: Runs linting, typescript check, unit testing, and creates a build - simulating locally tests that are run on PRs in Github Actions, other than e2e tests +#### Frontend coding conventions + +Some common uses and conventions are not covered in formatting and linting tools or widely agreed upon across the industry. + +##### Naming resolved and unresolved promises + +Constants that represent unresolved promises should be named `varNamePromise(s)`. + +Constants that represent resolved promises should be named `resolvedVarName(s)` + +For example: + +```javascript + +const bunnyPromises = getBunnyPromises(); +const resolvedBunnies = Promise.all(bunnyPromies); +``` + + ### 🖼️ Storybook Storybook is a [frontend workshop](https://bradfrost.com/blog/post/a-frontend-workshop-environment/) for developing and documenting pages and components in isolation. It allows you to render the same React components and files in the `src/` directory in a browser, without the need for a server or database. This allows you to develop and manually test components without having to run the entire Next.js application. diff --git a/frontend/src/app/[locale]/saved-grants/page.tsx b/frontend/src/app/[locale]/saved-grants/page.tsx index 17d3b5967..2323e2372 100644 --- a/frontend/src/app/[locale]/saved-grants/page.tsx +++ b/frontend/src/app/[locale]/saved-grants/page.tsx @@ -1,10 +1,15 @@ +import clsx from "clsx"; import { Metadata } from "next"; +import { getOpportunityDetails } from "src/services/fetch/fetchers/opportunityFetcher"; +import { fetchSavedOpportunities } from "src/services/fetch/fetchers/savedOpportunityFetcher"; import { LocalizedPageProps } from "src/types/intl"; +import { Opportunity } from "src/types/opportunity/opportunityResponseTypes"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { Button, GridContainer } from "@trussworks/react-uswds"; +import SearchResultsListItem from "src/components/search/SearchResultsListItem"; import { USWDSIcon } from "src/components/USWDSIcon"; export async function generateMetadata({ params }: LocalizedPageProps) { @@ -17,9 +22,62 @@ export async function generateMetadata({ params }: LocalizedPageProps) { return meta; } +const SavedOpportunitiesList = ({ + opportunities, +}: { + opportunities: Opportunity[]; +}) => { + return ( + + ); +}; + +const NoSavedOpportunities = ({ + noSavedCTA, + searchButtonText, +}: { + noSavedCTA: React.ReactNode; + searchButtonText: string; +}) => { + return ( + <> + +
+

{noSavedCTA}

{" "} + + + +
+ + ); +}; + export default async function SavedGrants({ params }: LocalizedPageProps) { const { locale } = await params; const t = await getTranslations({ locale }); + const savedOpportunities = await fetchSavedOpportunities(); + const opportunityPromises = savedOpportunities.map( + async (savedOpportunity) => { + const { data: opportunityData } = await getOpportunityDetails( + String(savedOpportunity.opportunity_id), + ); + return opportunityData; + }, + ); + const resolvedOpportunities = await Promise.all(opportunityPromises); return ( <> @@ -28,15 +86,17 @@ export default async function SavedGrants({ params }: LocalizedPageProps) { {t("SavedGrants.heading")} -
+
- -
-

- {t.rich("SavedGrants.noSavedCTA", { + {resolvedOpportunities.length > 0 ? ( + + ) : ( + ( <>
@@ -44,11 +104,9 @@ export default async function SavedGrants({ params }: LocalizedPageProps) { ), })} -

- - - -
+ searchButtonText={t("SavedGrants.searchButton")} + /> + )}
diff --git a/frontend/src/components/search/SearchResultsList.tsx b/frontend/src/components/search/SearchResultsList.tsx index 6823fdee3..41f64874c 100644 --- a/frontend/src/components/search/SearchResultsList.tsx +++ b/frontend/src/components/search/SearchResultsList.tsx @@ -1,8 +1,6 @@ "use server"; -import { getSession } from "src/services/auth/session"; -import { getSavedOpportunities } from "src/services/fetch/fetchers/savedOpportunityFetcher"; -import { SavedOpportunity } from "src/types/saved-opportunity/savedOpportunityResponseTypes"; +import { fetchSavedOpportunities } from "src/services/fetch/fetchers/savedOpportunityFetcher"; import { SearchAPIResponse } from "src/types/search/searchResponseTypes"; import { getTranslations } from "next-intl/server"; @@ -14,18 +12,6 @@ interface ServerPageProps { searchResults: SearchAPIResponse; } -const fetchSavedOpportunities = async (): Promise => { - const session = await getSession(); - if (!session || !session.token) { - return []; - } - const savedOpportunities = await getSavedOpportunities( - session.token, - session.user_id as string, - ); - return savedOpportunities; -}; - export default async function SearchResultsList({ searchResults, }: ServerPageProps) { diff --git a/frontend/src/components/search/SearchResultsListItem.tsx b/frontend/src/components/search/SearchResultsListItem.tsx index 6d9fff5f7..1bf55d5fe 100644 --- a/frontend/src/components/search/SearchResultsListItem.tsx +++ b/frontend/src/components/search/SearchResultsListItem.tsx @@ -112,7 +112,7 @@ export default function SearchResultsListItem({ ${opportunity?.summary?.award_ceiling?.toLocaleString() || "--"} - + {t("resultsListItem.floor")} {opportunity?.summary?.award_floor?.toLocaleString() || "--"} diff --git a/frontend/src/components/user/OpportunitySaveUserControl.tsx b/frontend/src/components/user/OpportunitySaveUserControl.tsx index 2141ab594..8f5d665f6 100644 --- a/frontend/src/components/user/OpportunitySaveUserControl.tsx +++ b/frontend/src/components/user/OpportunitySaveUserControl.tsx @@ -110,9 +110,9 @@ export const OpportunitySaveUserControl = () => { - + {t("save_button.save")} => { + try { + const session = await getSession(); + if (!session || !session.token) { + return []; + } + const savedOpportunities = await getSavedOpportunities( + session.token, + session.user_id as string, + ); + return savedOpportunities; + } catch (e) { + console.error("Error fetching saved opportunities:", e); + return []; + } +}; diff --git a/frontend/src/utils/testing/opportunityMock.ts b/frontend/src/utils/testing/opportunityMock.ts new file mode 100644 index 000000000..838af9b43 --- /dev/null +++ b/frontend/src/utils/testing/opportunityMock.ts @@ -0,0 +1,16 @@ +import { Opportunity } from "src/types/search/searchResponseTypes"; + +export const mockOpportunity: Opportunity = { + opportunity_id: 12345, + opportunity_title: "Test Opportunity", + opportunity_status: "posted", + summary: { + archive_date: "2023-01-01", + close_date: "2023-02-01", + post_date: "2023-01-15", + agency_name: "Test Agency", + award_ceiling: 50000, + award_floor: 10000, + }, + opportunity_number: "OPP-12345", +} as Opportunity; diff --git a/frontend/tests/components/search/SearchResultsList.test.tsx b/frontend/tests/components/search/SearchResultsList.test.tsx index 1cf2f9e10..782bd108f 100644 --- a/frontend/tests/components/search/SearchResultsList.test.tsx +++ b/frontend/tests/components/search/SearchResultsList.test.tsx @@ -20,7 +20,7 @@ jest.mock("src/services/auth/session", () => ({ })); jest.mock("src/services/fetch/fetchers/savedOpportunityFetcher", () => ({ - getSavedOpportunities: () => [{ opportunity_id: 1 }], + fetchSavedOpportunities: () => [{ opportunity_id: 1 }], })); const makeSearchResults = (overrides = {}) => ({ diff --git a/frontend/tests/components/search/SearchResultsListItem.test.tsx b/frontend/tests/components/search/SearchResultsListItem.test.tsx index 5e01a1787..997431f47 100644 --- a/frontend/tests/components/search/SearchResultsListItem.test.tsx +++ b/frontend/tests/components/search/SearchResultsListItem.test.tsx @@ -1,26 +1,11 @@ import { axe } from "jest-axe"; -import { Opportunity } from "src/types/search/searchResponseTypes"; +import { mockOpportunity } from "src/utils/testing/opportunityMock"; import { render, screen, waitFor } from "tests/react-utils"; import React from "react"; import SearchResultsListItem from "src/components/search/SearchResultsListItem"; -const mockOpportunity: Opportunity = { - opportunity_id: 12345, - opportunity_title: "Test Opportunity", - opportunity_status: "posted", - summary: { - archive_date: "2023-01-01", - close_date: "2023-02-01", - post_date: "2023-01-15", - agency_name: "Test Agency", - award_ceiling: 50000, - award_floor: 10000, - }, - opportunity_number: "OPP-12345", -} as Opportunity; - describe("SearchResultsListItem", () => { it("should not have basic accessibility issues", async () => { const { container } = render( diff --git a/frontend/tests/pages/saved-grants/page.test.tsx b/frontend/tests/pages/saved-grants/page.test.tsx index 7be8e13fc..cc974cdb3 100644 --- a/frontend/tests/pages/saved-grants/page.test.tsx +++ b/frontend/tests/pages/saved-grants/page.test.tsx @@ -1,12 +1,27 @@ -import { render, screen, waitFor } from "@testing-library/react"; import { axe } from "jest-axe"; import SavedGrants from "src/app/[locale]/saved-grants/page"; +import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes"; +import { SavedOpportunity } from "src/types/saved-opportunity/savedOpportunityResponseTypes"; import { localeParams, mockUseTranslations } from "src/utils/testing/intlMocks"; +import { mockOpportunity } from "src/utils/testing/opportunityMock"; +import { render, screen, waitFor } from "tests/react-utils"; jest.mock("next-intl/server", () => ({ getTranslations: () => Promise.resolve(mockUseTranslations), })); +const savedOpportunities = jest.fn().mockReturnValue([]); +const opportunity = jest.fn().mockReturnValue({ data: [] }); + +jest.mock("src/services/fetch/fetchers/opportunityFetcher", () => ({ + getOpportunityDetails: () => opportunity() as Promise, +})); + +jest.mock("src/services/fetch/fetchers/savedOpportunityFetcher", () => ({ + fetchSavedOpportunities: () => + savedOpportunities() as Promise, +})); + describe("Saved Grants page", () => { it("renders intro text for user with no saved grants", async () => { const component = await SavedGrants({ params: localeParams }); @@ -17,6 +32,18 @@ describe("Saved Grants page", () => { expect(content).toBeInTheDocument(); }); + it("renders a list of saved grants", async () => { + savedOpportunities.mockReturnValue([{ opportunity_id: 12345 }]); + opportunity.mockReturnValue({ data: mockOpportunity }); + const component = await SavedGrants({ params: localeParams }); + render(component); + + expect(screen.getByText("Test Opportunity")).toBeInTheDocument(); + expect(screen.getByText("OPP-12345")).toBeInTheDocument(); + const listItems = screen.getAllByRole("listitem"); + expect(listItems).toHaveLength(1); + }); + it("passes accessibility scan", async () => { const component = await SavedGrants({ params: localeParams }); const { container } = render(component);