Skip to content

Commit

Permalink
[Issue #3607] Add saved tag on search results (#3929)
Browse files Browse the repository at this point in the history
  • Loading branch information
acouch authored Feb 21, 2025
1 parent f586071 commit a12b574
Show file tree
Hide file tree
Showing 12 changed files with 385 additions and 67 deletions.
90 changes: 90 additions & 0 deletions frontend/src/components/search/SearchResultListItemStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import clsx from "clsx";
import { formatDate } from "src/utils/dateUtil";

interface StatusItemProps {
description: string;
showDate?: boolean;
date?: string | null;
tag?: boolean;
orange?: boolean;
}

const StatusItem = ({
description,
date = null,
orange = false,
showDate = true,
tag = false,
}: StatusItemProps) => {
return (
<span
className="
tablet:padding-x-1
tablet:margin-left-neg-1
tablet:margin-right-1
tablet:border-base-lighter"
>
<span
className={clsx("display-flex", {
"usa-tag margin-right-2 tablet:margin-0": tag,
"bg-accent-warm-dark": orange,
})}
>
<strong>{description}</strong>
{showDate && (
<span className="text-no-uppercase">
{date ? formatDate(date) : "--"}
</span>
)}
</span>
</span>
);
};

interface SearchResultListItemStatusProps {
status: string | null;
archivedString: string;
closedString: string;
forecastedString: string;
postedString: string;
archiveDate: string | null;
closedDate: string | null;
}

const SearchResultListItemStatus = ({
status,
archiveDate,
closedDate,
archivedString,
closedString,
forecastedString,
postedString,
}: SearchResultListItemStatusProps) => {
return (
<>
{status === "archived" && (
<StatusItem date={archiveDate} description={archivedString} />
)}
{(status === "archived" || status === "closed") && closedDate && (
<StatusItem description={closedString} date={closedDate} />
)}
{status === "posted" && (
<StatusItem
description={postedString}
date={closedDate}
orange={true}
tag={true}
/>
)}
{status === "forecasted" && (
<StatusItem
description={forecastedString}
showDate={false}
tag={true}
/>
)}
</>
);
};

export default SearchResultListItemStatus;
24 changes: 23 additions & 1 deletion frontend/src/components/search/SearchResultsList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"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 { SearchAPIResponse } from "src/types/search/searchResponseTypes";

import { getTranslations } from "next-intl/server";
Expand All @@ -11,11 +14,27 @@ interface ServerPageProps {
searchResults: SearchAPIResponse;
}

const fetchSavedOpportunities = async (): Promise<SavedOpportunity[]> => {
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) {
const t = await getTranslations("Search");

const savedOpportunities = await fetchSavedOpportunities();
const savedOpportunityIds = savedOpportunities.map(
(opportunity) => opportunity.opportunity_id,
);
if (searchResults.status_code !== 200) {
return <ServerErrorAlert callToAction={t("generic_error_cta")} />;
}
Expand All @@ -38,7 +57,10 @@ export default async function SearchResultsList({
<ul className="usa-list--unstyled">
{searchResults.data.map((opportunity) => (
<li key={opportunity?.opportunity_id}>
<SearchResultsListItem opportunity={opportunity} />
<SearchResultsListItem
opportunity={opportunity}
saved={savedOpportunityIds.includes(opportunity?.opportunity_id)}
/>
</li>
))}
</ul>
Expand Down
104 changes: 42 additions & 62 deletions frontend/src/components/search/SearchResultsListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,23 @@ import { AgencyNamyLookup } from "src/utils/search/generateAgencyNameLookup";
import { useTranslations } from "next-intl";
import Link from "next/link";

import { USWDSIcon } from "src/components/USWDSIcon";
import SearchResultListItemStatus from "./SearchResultListItemStatus";

interface SearchResultsListItemProps {
opportunity: Opportunity;
agencyNameLookup?: AgencyNamyLookup;
saved?: boolean;
}

export default function SearchResultsListItem({
opportunity,
agencyNameLookup,
saved = false,
}: SearchResultsListItemProps) {
const t = useTranslations("Search");

const metadataBorderClasses = `
display-block
tablet:display-inline-block
tablet:border-left-1px
tablet:padding-x-1
Expand Down Expand Up @@ -52,87 +56,63 @@ export default function SearchResultsListItem({
</Link>
</h2>
</div>
<div className="grid-col tablet:order-1 overflow-hidden font-body-xs">
{opportunity.opportunity_status === "archived" && (
<span className={metadataBorderClasses}>
<strong>{t("resultsListItem.status.archived")}</strong>
{opportunity?.summary?.archive_date
? formatDate(opportunity?.summary?.archive_date)
: "--"}
</span>
)}
{(opportunity?.opportunity_status === "archived" ||
opportunity?.opportunity_status === "closed") &&
opportunity?.summary?.close_date && (
<span className={metadataBorderClasses}>
<strong>{t("resultsListItem.status.closed")}</strong>
{opportunity?.summary?.close_date
? formatDate(opportunity?.summary?.close_date)
: "--"}
</span>
)}
{opportunity?.opportunity_status === "posted" && (
<span className={metadataBorderClasses}>
<span className="usa-tag bg-accent-warm-dark">
<strong>{t("resultsListItem.status.posted")}</strong>
<span className="text-no-uppercase">
{opportunity?.summary?.close_date
? formatDate(opportunity?.summary?.close_date)
: "--"}
</span>
</span>
</span>
)}
{opportunity?.opportunity_status === "forecasted" && (
<span className={metadataBorderClasses}>
<span className="usa-tag">
<strong>{t("resultsListItem.status.forecasted")}</strong>
</span>
</span>
)}
<span className={metadataBorderClasses}>
<div className="font-body-xs display-flex flex-wrap">
<SearchResultListItemStatus
archiveDate={opportunity?.summary?.archive_date}
archivedString={t("resultsListItem.status.archived")}
closedDate={opportunity?.summary?.close_date}
closedString={t("resultsListItem.status.closed")}
forecastedString={t("resultsListItem.status.forecasted")}
postedString={t("resultsListItem.status.posted")}
status={opportunity?.opportunity_status}
/>
<span
className={`${metadataBorderClasses} tablet:order-0 order-2`}
>
<strong>{t("resultsListItem.summary.posted")}</strong>
{opportunity?.summary?.post_date
? formatDate(opportunity?.summary?.post_date)
: "--"}
</span>
{saved && (
<span className="padding-x-105 padding-y-2px bg-base-lighter display-flex flex-align-center font-sans-3xs radius-sm">
<USWDSIcon
name="star"
className="text-accent-warm-dark button-icon-md padding-right-05"
/>
{t("opportunitySaved")}
</span>
)}
<div className="width-full tablet:width-auto" />
</div>
<div className="grid-col tablet:order-2 overflow-hidden font-body-xs">
<span className={metadataBorderClasses}>
<strong>{t("resultsListItem.summary.agency")}</strong>
{opportunity?.top_level_agency_name &&
opportunity?.agency_name &&
opportunity?.top_level_agency_name !== opportunity?.agency_name
? `${opportunity?.top_level_agency_name} - ${opportunity?.agency_name}`
: opportunity?.agency_name ||
(agencyNameLookup && opportunity?.summary?.agency_code
? // Use same exact label we're using for the agency filter list
agencyNameLookup[opportunity?.summary?.agency_code]
: "--")}
</span>
<strong>{t("resultsListItem.summary.agency")}</strong>
{opportunity?.top_level_agency_name &&
opportunity?.agency_name &&
opportunity?.top_level_agency_name !== opportunity?.agency_name
? `${opportunity?.top_level_agency_name} - ${opportunity?.agency_name}`
: opportunity?.agency_name ||
(agencyNameLookup && opportunity?.summary?.agency_code
? // Use same exact label we're using for the agency filter list
agencyNameLookup[opportunity?.summary?.agency_code]
: "--")}
</div>
<div className="grid-col tablet:order-3 overflow-hidden font-body-xs">
<span className={metadataBorderClasses}>
<strong>{t("resultsListItem.opportunity_number")}</strong>
{opportunity?.opportunity_number}
</span>
<strong>{t("resultsListItem.opportunity_number")}</strong>
{opportunity?.opportunity_number}
</div>
</div>
</div>
<div className="desktop:grid-col-auto">
<div className="overflow-hidden font-body-xs">
{/* TODO: Better way to format as a dollar amounts */}
<span
className={`${metadataBorderClasses} desktop:display-block text-right desktop:margin-right-0 desktop:padding-right-0`}
>
<span className="desktop:display-block text-right desktop:margin-right-0 desktop:padding-right-0">
<strong>{t("resultsListItem.award_ceiling")}</strong>
<span className="desktop:display-block desktop:font-sans-lg text-ls-neg-3 text-right">
${opportunity?.summary?.award_ceiling?.toLocaleString() || "--"}
</span>
</span>
<span
className={`${metadataBorderClasses} desktop:display-block text-right desktop:margin-right-0 desktop:padding-right-0`}
>
<span className="margin-left-3 desktop:display-block text-right desktop:margin-right-0 desktop:padding-right-0">
<strong>{t("resultsListItem.floor")}</strong>
{opportunity?.summary?.award_floor?.toLocaleString() || "--"}
</span>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/messages/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@ export const messages = {
callToAction: {
title: "Search funding opportunities",
},
opportunitySaved: "Saved",
opportunityStatus: {
title: "Opportunity status",
label: {
Expand Down
15 changes: 11 additions & 4 deletions frontend/src/services/fetch/fetchers/savedOpportunityFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,10 @@ export const handleSavedOpportunity = async (
});
};

export const getSavedOpportunity = async (
export const getSavedOpportunities = async (
token: string,
userId: string,
opportunityId: number,
): Promise<SavedOpportunity | null> => {
): Promise<SavedOpportunity[]> => {
const ssgToken = {
"X-SGG-Token": token,
};
Expand All @@ -55,7 +54,15 @@ export const getSavedOpportunity = async (
body,
});
const json = (await resp.json()) as { data: [] };
const savedOpportunities = json.data;
return json.data;
};

export const getSavedOpportunity = async (
token: string,
userId: string,
opportunityId: number,
): Promise<SavedOpportunity | null> => {
const savedOpportunities = await getSavedOpportunities(token, userId);
const savedOpportunity = savedOpportunities.find(
(savedOpportunity: { opportunity_id: number }) =>
savedOpportunity.opportunity_id === opportunityId,
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/styles/_uswds-theme-custom-styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ i.e.
margin: 0 units(0.5) 0 0;
}

.button-icon-md {
transform: scale(1.75);
margin: 0 units(0.5) 0 0;
}

.icon-active {
color: color("orange-40v");
}
Expand Down
Loading

0 comments on commit a12b574

Please sign in to comment.