Skip to content

Commit

Permalink
[Issue #2036] Opportunity Page Design Implementation (navapbc#196)
Browse files Browse the repository at this point in the history
Fixes #2036

> What was added, updated, or removed in this PR.

> Testing instructions, background context, more in-depth details of the
implementation, and anything else you'd like to call out or ask
reviewers. Explain how the changes were verified.

> Screenshots, GIF demos, code examples or output to help show the
changes working as expected.

---------

Co-authored-by: Aaron Couch <[email protected]>
Co-authored-by: Aaron Couch <[email protected]>
  • Loading branch information
3 people committed Sep 18, 2024
1 parent ff5a432 commit 444fa7a
Show file tree
Hide file tree
Showing 30 changed files with 10,945 additions and 16,826 deletions.
26,377 changes: 9,693 additions & 16,684 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@opentelemetry/api": "^1.8.0",
"@trussworks/react-uswds": "^7.0.0",
"@uswds/uswds": "^3.6.0",
"isomorphic-dompurify": "^2.15.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"next": "^14.2.3",
Expand Down
91 changes: 42 additions & 49 deletions frontend/src/app/[locale]/opportunity/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import {
ApiResponse,
Summary,
} from "../../../../types/opportunity/opportunityResponseTypes";
OpportunityApiResponse,
Opportunity,
} from "src/types/opportunity/opportunityResponseTypes";

import BetaAlert from "src/components/BetaAlert";
import Breadcrumbs from "src/components/Breadcrumbs";
import { GridContainer } from "@trussworks/react-uswds";
import { Metadata } from "next";
import NotFound from "../../../not-found";
import { OPPORTUNITY_CRUMBS } from "src/constants/breadcrumbs";
import OpportunityAwardInfo from "src/components/opportunity/OpportunityAwardInfo";
import OpportunityDescription from "src/components/opportunity/OpportunityDescription";
import OpportunityHistory from "src/components/opportunity/OpportunityHistory";
import OpportunityIntro from "src/components/opportunity/OpportunityIntro";
import OpportunityLink from "src/components/opportunity/OpportunityLink";
import OpportunityListingAPI from "../../../api/OpportunityListingAPI";
import OpportunityStatusWidget from "src/components/opportunity/OpportunityStatusWidget";
import { getTranslations } from "next-intl/server";
import { isSummary } from "../../../../utils/opportunity/isSummary";
import withFeatureFlag from "src/hoc/search/withFeatureFlag";

export async function generateMetadata() {
const t = await getTranslations({ locale: "en" });
Expand All @@ -18,20 +28,16 @@ export async function generateMetadata() {
return meta;
}

export default async function OpportunityListing({
params,
}: {
params: { id: string };
}) {
async function OpportunityListing({ params }: { params: { id: string } }) {
const id = Number(params.id);

const breadcrumbs = Object.assign([], OPPORTUNITY_CRUMBS);
// Opportunity id needs to be a number greater than 1
if (isNaN(id) || id < 0) {
return <NotFound />;
}

const api = new OpportunityListingAPI();
let opportunity: ApiResponse;
let opportunity: OpportunityApiResponse;
try {
opportunity = await api.getOpportunityById(id);
} catch (error) {
Expand All @@ -43,47 +49,34 @@ export default async function OpportunityListing({
return <NotFound />;
}

const renderSummary = (summary: Summary) => {
return (
<>
{Object.entries(summary).map(([summaryKey, summaryValue]) => (
<tr key={summaryKey}>
<td className="word-wrap">{`summary.${summaryKey}`}</td>
<td className="word-wrap">{JSON.stringify(summaryValue)}</td>
</tr>
))}
</>
);
};
const opportunityData: Opportunity = opportunity.data;

breadcrumbs.push({
title: opportunityData.opportunity_title,
path: `/opportunity/${opportunityData.opportunity_id}/`,
});

return (
<div className="grid-container">
<div className="grid-row margin-y-4">
<div className="usa-table-container">
<table className="usa-table usa-table--borderless margin-x-auto width-full maxw-desktop-lg">
<thead>
<tr>
<th>Field Name</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{Object.entries(opportunity.data).map(([key, value]) => {
if (key === "summary" && isSummary(value)) {
return renderSummary(value);
} else {
return (
<tr key={key}>
<td className="word-wrap">{key}</td>
<td className="word-wrap">{JSON.stringify(value)}</td>
</tr>
);
}
})}
</tbody>
</table>
<div>
<BetaAlert />
<Breadcrumbs breadcrumbList={breadcrumbs} />
<OpportunityIntro opportunityData={opportunityData} />
<GridContainer>
<div className="grid-row">
<div className="desktop:grid-col-8 tablet:grid-col-12 tablet:order-1 desktop:order-first">
<OpportunityDescription opportunityData={opportunityData} />
<OpportunityLink opportunityData={opportunityData} />
</div>

<div className="desktop:grid-col-4 tablet:grid-col-12 tablet:order-0">
<OpportunityStatusWidget opportunityData={opportunityData} />
<OpportunityAwardInfo opportunityData={opportunityData} />
<OpportunityHistory opportunityData={opportunityData} />
</div>
</div>
</div>
</GridContainer>
</div>
);
}

export default withFeatureFlag(OpportunityListing, "showSearchV0");
5 changes: 3 additions & 2 deletions frontend/src/app/[locale]/process/ProcessIntro.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Grid } from "@trussworks/react-uswds";
import { useTranslations, useMessages } from "next-intl";
import { useMessages, useTranslations } from "next-intl";

import ContentLayout from "src/components/ContentLayout";
import { Grid } from "@trussworks/react-uswds";

const ProcessIntro = () => {
const t = useTranslations("Process");
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/app/[locale]/process/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { PROCESS_CRUMBS } from "src/constants/breadcrumbs";
import { getTranslations, unstable_setRequestLocale } from "next-intl/server";

import BetaAlert from "src/components/BetaAlert";

import Breadcrumbs from "src/components/Breadcrumbs";
import PageSEO from "src/components/PageSEO";
import { Metadata } from "next";
import { PROCESS_CRUMBS } from "src/constants/breadcrumbs";
import PageSEO from "src/components/PageSEO";
import ProcessIntro from "src/app/[locale]/process/ProcessIntro";
import ProcessInvolved from "src/app/[locale]/process/ProcessInvolved";
import ProcessMilestones from "src/app/[locale]/process/ProcessMilestones";
import { useTranslations } from "next-intl";
import { getTranslations, unstable_setRequestLocale } from "next-intl/server";

export async function generateMetadata() {
const t = await getTranslations({ locale: "en" });
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/app/api/BaseApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { compact, isEmpty } from "lodash";
import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher";
// TODO (#1682): replace search specific references (since this is a generic API file that any
// future page or different namespace could use)
import { SearchAPIResponse } from "../../types/search/searchResponseTypes";
import { APIResponse } from "src/types/apiResponseTypes";

export type ApiMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
export interface JSONRequestBody {
Expand Down Expand Up @@ -108,10 +108,10 @@ export default abstract class BaseApi {
queryParamData?: QueryParamData,
) {
let response: Response;
let responseBody: SearchAPIResponse;
let responseBody: APIResponse;
try {
response = await fetch(url, fetchOptions);
responseBody = (await response.json()) as SearchAPIResponse;
responseBody = (await response.json()) as APIResponse;
} catch (error) {
// API most likely down, but also possibly an error setting up or sending a request
// or parsing the response.
Expand Down Expand Up @@ -196,7 +196,7 @@ export function fetchErrorToNetworkError(
}

function handleNotOkResponse(
response: SearchAPIResponse,
response: APIResponse,
message: string,
status_code: number,
searchInputs?: QueryParamData,
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/app/api/OpportunityListingAPI.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "server-only";

import { ApiResponse } from "../../types/opportunity/opportunityResponseTypes";
import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes";
import BaseApi from "./BaseApi";

export default class OpportunityListingAPI extends BaseApi {
Expand All @@ -16,14 +16,16 @@ export default class OpportunityListingAPI extends BaseApi {
return "opportunities";
}

async getOpportunityById(opportunityId: number): Promise<ApiResponse> {
async getOpportunityById(
opportunityId: number,
): Promise<OpportunityApiResponse> {
const subPath = `${opportunityId}`;
const response = await this.request(
const response = (await this.request(
"GET",
this.basePath,
this.namespace,
subPath,
);
return response as ApiResponse;
)) as OpportunityApiResponse;
return response;
}
}
26 changes: 26 additions & 0 deletions frontend/src/components/opportunity/OpportunityAwardGridRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useTranslations } from "next-intl";
type Props = {
title: string | number;
content: string | number;
};

type TranslationKeys =
| "program_funding"
| "expected_awards"
| "award_ceiling"
| "award_floor";

const OpportunityAwardGridRow = ({ title, content }: Props) => {
const t = useTranslations("OpportunityListing.award_info");

return (
<div className="border radius-md border-base-lighter padding-x-2 ">
<p className="font-sans-sm text-bold margin-bottom-0">{content}</p>
<p className="desktop-lg:font-sans-sm margin-top-0">
{t(`${title as TranslationKeys}`)}
</p>
</div>
);
};

export default OpportunityAwardGridRow;
105 changes: 105 additions & 0 deletions frontend/src/components/opportunity/OpportunityAwardInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Grid } from "@trussworks/react-uswds";
import { Opportunity } from "src/types/opportunity/opportunityResponseTypes";
import OpportunityAwardGridRow from "./OpportunityAwardGridRow";
import { useTranslations } from "next-intl";

type Props = {
opportunityData: Opportunity;
};

type TranslationKeys =
| "cost_sharing"
| "funding_instrument"
| "opportunity_category"
| "opportunity_category_explanation"
| "funding_activity"
| "category_explanation";

const OpportunityAwardInfo = ({ opportunityData }: Props) => {
const t = useTranslations("OpportunityListing.award_info");

const formatCurrency = (number: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
}).format(number);
};

const formatSubContent = (content: boolean | string | null | string[]) => {
function formatStringReturnValue(str: string): string {
const formattedStr = str.replace(/_/g, " ");
return formattedStr.charAt(0).toUpperCase() + formattedStr.slice(1);
}
switch (typeof content) {
case "boolean":
return content ? t("yes") : t("no");
case "string":
return (
<p className="line-height-sans-1">
{formatStringReturnValue(content)}
</p>
);
case "object":
if (Array.isArray(content)) {
return content.length
? content.map((content, index) => (
<p className="line-height-sans-1" key={`contentList-${index}`}>
{formatStringReturnValue(content)}
</p>
))
: "--";
}
return "--";
default:
return "--";
}
};

const awardGridInfo = {
program_funding: formatCurrency(
opportunityData.summary.estimated_total_program_funding,
),
expected_awards: opportunityData.summary.expected_number_of_awards,
award_ceiling: formatCurrency(opportunityData.summary.award_ceiling),
award_floor: formatCurrency(opportunityData.summary.award_floor),
};

const awardSubInfo = {
cost_sharing: opportunityData.summary.is_cost_sharing,
funding_instrument: opportunityData.summary.funding_instruments,
opportunity_category: opportunityData.category,
opportunity_category_explanation: opportunityData.category_explanation,
funding_activity: opportunityData.summary.funding_categories,
category_explanation: opportunityData.summary.funding_category_description,
};

return (
<div className="usa-prose margin-top-2">
<h2>Award</h2>
<Grid row className="margin-top-2 grid-gap-2">
{Object.entries(awardGridInfo).map(([title, content], index) => (
<Grid
className="margin-bottom-2"
key={`category ${index}`}
tabletLg={{ col: 6 }}
>
<OpportunityAwardGridRow content={content} title={title} />
</Grid>
))}
</Grid>

{Object.entries(awardSubInfo).map(([title, content], index) => (
<div key={`awardInfo-${index}`}>
<p className={"text-bold"}>
{t(`${title as TranslationKeys}`)}
{":"}
</p>
<div className={"margin-top-0"}>{formatSubContent(content)}</div>
</div>
))}
</div>
);
};

export default OpportunityAwardInfo;
Loading

0 comments on commit 444fa7a

Please sign in to comment.