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 (
+
+ {opportunities.map((opportunity) => (
+ <>
+ {opportunity && (
+
+
+
+ )}
+ >
+ ))}
+
+ );
+};
+
+const NoSavedOpportunities = ({
+ noSavedCTA,
+ searchButtonText,
+}: {
+ noSavedCTA: React.ReactNode;
+ searchButtonText: string;
+}) => {
+ return (
+ <>
+
+
+
{noSavedCTA}
{" "}
+
+
{searchButtonText}
+
+
+ >
+ );
+};
+
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) {
>
),
})}
-
-
-
{t("SavedGrants.searchButton")}
-
-
+ 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);