Skip to content

[Issue #3646] Better Agency display and sort in Search #3686

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-frontend-a11y.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
- name: Start API Server for search results
run: |
cd ../api
make init db-seed-local start &
make init db-seed-local populate-search-opportunities start &
cd ../frontend
# ensure the API wait script is executable
chmod +x ../api/bin/wait-for-api.sh
Expand Down
9 changes: 9 additions & 0 deletions api/src/services/opportunities_v1/search_opportunities.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"close_date": "summary.close_date",
"agency_code": "agency_code.keyword",
"agency": "agency_code.keyword",
"agency_name": "agency_name.keyword",
"top_level_agency_name": "top_level_agency_name.keyword",
"opportunity_status": "opportunity_status.keyword",
"funding_instrument": "summary.funding_instruments.keyword",
"funding_category": "summary.funding_categories.keyword",
Expand Down Expand Up @@ -101,6 +103,13 @@ def _get_sort_by(pagination: PaginationParams) -> list[tuple[str, SortDirection]
if pagination.order_by == "relevancy":
sort_by.append((_adjust_field_name("post_date"), pagination.sort_direction))

# Agency is really a two piece data point
if pagination.order_by == "agency_code":
sort_by = [
(_adjust_field_name("top_level_agency_name"), pagination.sort_direction),
(_adjust_field_name("agency_name"), pagination.sort_direction),
]

return sort_by


Expand Down
3 changes: 3 additions & 0 deletions documentation/frontend/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This [Next.js](https://nextjs.org) application can be run natively (or locally)

## Local (non-Docker)

In addition to the npm `package.json` scripts there are also several helpful make commands defined in the `Makefile` which are explained by running `make help`

### 🏗️ Development version

Run `npm install && npm run dev` to install and start the application.
Expand Down Expand Up @@ -195,6 +197,7 @@ Running authentication locally requires running the API, directing the API redir
4. Start the API (`make make db-seed-local && make populate-search-opportunities && make start`) and frontend (`npm run dev`) for development

#### Login flow

The [documentation/api/authentication.md](../api/authentication.md) details the login flow from the frontend → API → login.gov → API → frontend.

The `/api/auth/callback` route handler receives a JSON web token as query parameter, uses the `API_JWT_PUBLIC_KEY` env variable to verify that it was created by the API, sets a cookie with the token, then later uses that token to verify the user identity in `/api/auth/session` and other routes.
Expand Down
21 changes: 12 additions & 9 deletions frontend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,21 @@ release-build:
##################################################
# Local development
##################################################
build-dev: # Build the Next.js local dev server in Docker
build-dev: ## Build the Next.js local dev server in Docker
docker compose build --no-cache nextjs

build-storybook: # Build Storybook in Docker
build-storybook: ## Build Storybook in Docker
docker compose build --no-cache storybook

reinstall-deps:
reinstall-deps: ## Delete node_modules and npm install
rm -rf node_modules
npm install

dev: # Run the Next.js local dev server in Docker
dev: ## Run the Next.js local dev server in Docker
docker compose up --detach nextjs
docker compose logs --follow nextjs

storybook: # Run the Storybook local dev server in Docker
storybook: ## Run the Storybook local dev server in Docker
docker compose up --detach storybook
docker compose logs --follow storybook

Expand All @@ -62,14 +62,17 @@ stop:
# Load testing
##################################################

load-test-local: # Load test the local environment at localhost:3000
load-test-local: ## Load test the local environment at localhost:3000
artillery run -e local artillery-load-test.yml

load-test-dev: # Load test the dev environment in aws
load-test-dev: ## Load test the dev environment in aws
artillery run -e dev artillery-load-test.yml

load-test-staging: # Load test the staging environment in aws
load-test-staging: ## Load test the staging environment in aws
artillery run -e stage artillery-load-test.yml

load-test-prod: # Load test the production environment in aws. Please test responsibly
load-test-prod: ## Load test the production environment in aws. Please test responsibly
artillery run -e prod artillery-load-test.yml

help: ## Prints the help documentation and info about each command
@grep -E '^[/a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
36 changes: 33 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"react-dom": "^19.0.0",
"server-only": "^0.0.1",
"sharp": "^0.33.5",
"zod": "^3.23.8"
"zod": "^3.23.8",
"zustand": "^5.0.3"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.9.0",
Expand Down
109 changes: 55 additions & 54 deletions frontend/src/app/[locale]/search/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import QueryProvider from "src/app/[locale]/search/QueryProvider";
import { usePrevious } from "src/hooks/usePrevious";
import { useGlobalState } from "src/services/globalState/GlobalStateProvider";
import { FrontendErrorDetails } from "src/types/apiResponseTypes";
import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
import { Breakpoints, ErrorProps } from "src/types/uiTypes";
Expand All @@ -14,14 +15,21 @@ import { Alert } from "@trussworks/react-uswds";

import ContentDisplayToggle from "src/components/ContentDisplayToggle";
import SearchBar from "src/components/search/SearchBar";
import SearchFilters from "src/components/search/SearchFilters";
import { AgencyFilterAccordion } from "src/components/search/SearchFilterAccordion/AgencyFilterAccordion";
import SearchFilterAccordion from "src/components/search/SearchFilterAccordion/SearchFilterAccordion";
import {
categoryOptions,
eligibilityOptions,
fundingOptions,
} from "src/components/search/SearchFilterAccordion/SearchFilterOptions";
import SearchOpportunityStatus from "src/components/search/SearchOpportunityStatus";
import ServerErrorAlert from "src/components/ServerErrorAlert";

export interface ParsedError {
message: string;
searchInputs: ServerSideSearchParams;
status: number;
type: string;
message?: string;
searchInputs?: ServerSideSearchParams;
status?: number;
type?: string;
details?: FrontendErrorDetails;
}

Expand All @@ -34,30 +42,29 @@ function isValidJSON(str: string) {
}
}

function createBlankParsedError(): ParsedError {
return {
type: "NetworkError",
searchInputs: {
query: "",
status: "",
fundingInstrument: "",
eligibility: "",
agency: "",
category: "",
sortby: undefined,
page: "1",
actionType: "initialLoad",
},
message: "Invalid error message JSON returned",
status: -1,
};
}

// note that the SearchFilters component is not used here since that is a server component
// we work around that by including the rendered components from SearchFilters, but manually
// passing through the agency options as received from global state rather than fetching from API
export default function SearchError({ error, reset }: ErrorProps) {
const t = useTranslations("Search");
const searchParams = useSearchParams();
const previousSearchParams =
usePrevious<ReadonlyURLSearchParams>(searchParams);
useEffect(() => {
console.error(error);
}, [error]);

const parsedErrorData = isValidJSON(error.message)
? (JSON.parse(error.message) as ParsedError)
: {};

const { agencyOptions } = useGlobalState(({ agencyOptions }) => ({
agencyOptions,
}));

const convertedSearchParams = convertSearchParamsToProperTypes(
Object.fromEntries(searchParams.entries().toArray()),
);

useEffect(() => {
if (
Expand All @@ -67,35 +74,13 @@ export default function SearchError({ error, reset }: ErrorProps) {
) {
reset();
}
}, [searchParams, reset]);
}, [searchParams, reset, previousSearchParams]);

useEffect(() => {
console.error(error);
}, [error]);

// The error message is passed as an object that's been stringified.
// Parse it here.
let parsedErrorData;

if (!isValidJSON(error.message)) {
// the error likely is just a string with a non-specific Server Component error when running the built app
// "An error occurred in the Server Components render. The specific message is omitted in production builds..."
parsedErrorData = createBlankParsedError();
} else {
// Valid error thrown from server component
parsedErrorData = JSON.parse(error.message) as ParsedError;
}
const convertedSearchParams = convertSearchParamsToProperTypes(
parsedErrorData.searchInputs,
);
const { agency, category, eligibility, fundingInstrument, query, status } =
convertedSearchParams;

useEffect(() => {
console.error(error);
}, [error]);

// note that the validation error will contain untranslated strings
// and will only appear in development, prod builds will not include user facing error details
const ErrorAlert =
parsedErrorData.details && parsedErrorData.type === "ValidationError" ? (
<Alert type="error" heading={t("validationError")} headingLevel="h4">
Expand All @@ -118,12 +103,28 @@ export default function SearchError({ error, reset }: ErrorProps) {
hideCallToAction={t("filterDisplayToggle.hideFilters")}
breakpoint={Breakpoints.TABLET}
>
<SearchFilters
opportunityStatus={status}
eligibility={eligibility}
category={category}
fundingInstrument={fundingInstrument}
agency={agency}
<SearchOpportunityStatus query={status} />
<SearchFilterAccordion
filterOptions={fundingOptions}
query={fundingInstrument}
queryParamKey="fundingInstrument"
title={t("accordion.titles.funding")}
/>
<SearchFilterAccordion
filterOptions={eligibilityOptions}
query={eligibility}
queryParamKey={"eligibility"}
title={t("accordion.titles.eligibility")}
/>
<AgencyFilterAccordion
query={agency}
agencyOptions={agencyOptions}
/>
<SearchFilterAccordion
filterOptions={categoryOptions}
query={category}
queryParamKey={"category"}
title={t("accordion.titles.category")}
/>
</ContentDisplayToggle>
</div>
Expand Down
25 changes: 14 additions & 11 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import UserProvider from "src/services/auth/UserProvider";
import { GlobalStateProvider } from "src/services/globalState/GlobalStateProvider";

import { useTranslations } from "next-intl";
import { setRequestLocale } from "next-intl/server";
Expand All @@ -20,17 +21,19 @@ export default function Layout({ children, locale }: Props) {
return (
// Stick the footer to the bottom of the page
<UserProvider>
<div className="display-flex flex-column minh-viewport">
<a className="usa-skipnav" href="#main-content">
{t("Layout.skip_to_main")}
</a>
<Header locale={locale} />
<main id="main-content" className="border-top-0">
{children}
</main>
<Footer />
<GrantsIdentifier />
</div>
<GlobalStateProvider>
<div className="display-flex flex-column minh-viewport">
<a className="usa-skipnav" href="#main-content">
{t("Layout.skip_to_main")}
</a>
<Header locale={locale} />
<main id="main-content" className="border-top-0">
{children}
</main>
<Footer />
<GrantsIdentifier />
</div>
</GlobalStateProvider>
</UserProvider>
);
}
Loading
Loading