diff --git a/apps/cyberstorm-remix/app/c/tabs/PackageSearch/PackageSearch.tsx b/apps/cyberstorm-remix/app/c/tabs/PackageSearch/PackageSearch.tsx index ac0d7a502..7282db2e5 100644 --- a/apps/cyberstorm-remix/app/c/tabs/PackageSearch/PackageSearch.tsx +++ b/apps/cyberstorm-remix/app/c/tabs/PackageSearch/PackageSearch.tsx @@ -10,47 +10,92 @@ import { type OutletContextShape } from "~/root"; import { DapperTs } from "@thunderstore/dapper-ts"; import type { Route } from "./+types/PackageSearch"; +import { throwUserFacingPayloadResponse } from "cyberstorm/utils/errors/userFacingErrorResponse"; +import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError"; +import { createNotFoundMapping } from "cyberstorm/utils/errors/loaderMappings"; +import { NimbusDefaultRouteErrorBoundary } from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import { getLoaderTools } from "cyberstorm/utils/getLoaderTools"; +import { parseIntegerSearchParam } from "cyberstorm/utils/searchParamsUtils"; + +interface PackageSearchQuery { + ordering: string; + page: number | undefined; + search: string; + includedCategories: string[] | undefined; + excludedCategories: string[] | undefined; + section: string; + nsfw: boolean; + deprecated: boolean; +} + +const communityNotFoundMappings = [ + createNotFoundMapping( + "Community not found.", + "We could not find the requested community." + ), +]; + +function resolvePackageSearchQuery(request: Request): PackageSearchQuery { + const searchParams = new URL(request.url).searchParams; + const ordering = searchParams.get("ordering") ?? PackageOrderOptions.Updated; + const page = parseIntegerSearchParam(searchParams.get("page")); + const search = searchParams.get("search") ?? ""; + const included = searchParams.get("includedCategories"); + const excluded = searchParams.get("excludedCategories"); + const sectionParam = searchParams.get("section"); + const section = sectionParam + ? sectionParam === "all" + ? "" + : sectionParam + : ""; + const nsfw = searchParams.get("nsfw") === "true"; + const deprecated = searchParams.get("deprecated") === "true"; + + return { + ordering, + page, + search, + includedCategories: included?.split(",") ?? undefined, + excludedCategories: excluded?.split(",") ?? undefined, + section, + nsfw, + deprecated, + }; +} export async function loader({ params, request }: Route.LoaderArgs) { if (params.communityId) { - const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); - const dapper = new DapperTs(() => { + const { dapper } = getLoaderTools(); + try { + const query = resolvePackageSearchQuery(request); + return { - apiHost: publicEnvVariables.VITE_API_URL, - sessionId: undefined, + filters: await dapper.getCommunityFilters(params.communityId), + listings: await dapper.getPackageListings( + { + kind: "community", + communityId: params.communityId, + }, + query.ordering ?? "", + query.page, + query.search, + query.includedCategories, + query.excludedCategories, + query.section, + query.nsfw, + query.deprecated + ), }; - }); - const searchParams = new URL(request.url).searchParams; - const ordering = - searchParams.get("ordering") ?? PackageOrderOptions.Updated; - const page = searchParams.get("page"); - const search = searchParams.get("search"); - const includedCategories = searchParams.get("includedCategories"); - const excludedCategories = searchParams.get("excludedCategories"); - const section = searchParams.get("section"); - const nsfw = searchParams.get("nsfw"); - const deprecated = searchParams.get("deprecated"); - const filters = await dapper.getCommunityFilters(params.communityId); - - return { - filters: filters, - listings: await dapper.getPackageListings( - { - kind: "community", - communityId: params.communityId, - }, - ordering ?? "", - page === null ? undefined : Number(page), - search ?? "", - includedCategories?.split(",") ?? undefined, - excludedCategories?.split(",") ?? undefined, - section ? (section === "all" ? "" : section) : "", - nsfw === "true" ? true : false, - deprecated === "true" ? true : false - ), - }; + } catch (error) { + handleLoaderError(error, { mappings: communityNotFoundMappings }); + } } - throw new Response("Community not found", { status: 404 }); + throwUserFacingPayloadResponse({ + headline: "Community not found.", + description: "We could not find the requested community.", + category: "not_found", + status: 404, + }); } export async function clientLoader({ @@ -58,49 +103,42 @@ export async function clientLoader({ params, }: Route.ClientLoaderArgs) { if (params.communityId) { - const tools = getSessionTools(); - const dapper = new DapperTs(() => { - return { - apiHost: tools?.getConfig().apiHost, - sessionId: tools?.getConfig().sessionId, - }; - }); - const searchParams = new URL(request.url).searchParams; - const ordering = - searchParams.get("ordering") ?? PackageOrderOptions.Updated; - const page = searchParams.get("page"); - const search = searchParams.get("search"); - const includedCategories = searchParams.get("includedCategories"); - const excludedCategories = searchParams.get("excludedCategories"); - const section = searchParams.get("section"); - const nsfw = searchParams.get("nsfw"); - const deprecated = searchParams.get("deprecated"); - const filters = dapper.getCommunityFilters(params.communityId); + const { dapper } = getLoaderTools(); + const query = resolvePackageSearchQuery(request); return { - filters: filters, - listings: dapper.getPackageListings( - { - kind: "community", - communityId: params.communityId, - }, - ordering ?? "", - page === null ? undefined : Number(page), - search ?? "", - includedCategories?.split(",") ?? undefined, - excludedCategories?.split(",") ?? undefined, - section ? (section === "all" ? "" : section) : "", - nsfw === "true" ? true : false, - deprecated === "true" ? true : false - ), + filters: dapper + .getCommunityFilters(params.communityId) + .catch((error) => + handleLoaderError(error, { mappings: communityNotFoundMappings }) + ), + listings: dapper + .getPackageListings( + { + kind: "community", + communityId: params.communityId, + }, + query.ordering ?? "", + query.page, + query.search, + query.includedCategories, + query.excludedCategories, + query.section, + query.nsfw, + query.deprecated + ) + .catch((error) => + handleLoaderError(error, { mappings: communityNotFoundMappings }) + ), }; } - throw new Response("Community not found", { status: 404 }); + throwUserFacingPayloadResponse({ + headline: "Community not found.", + description: "We could not find the requested community.", + category: "not_found", + status: 404, + }); } -// function shouldRevalidate(arg: ShouldRevalidateFunctionArgs) { -// return true; // false -// } - export default function CommunityPackageSearch() { const { filters, listings } = useLoaderData< typeof loader | typeof clientLoader @@ -109,14 +147,16 @@ export default function CommunityPackageSearch() { const outletContext = useOutletContext() as OutletContextShape; return ( - <> - - + ); } + +export function ErrorBoundary() { + return ; +} diff --git a/apps/cyberstorm-remix/app/commonComponents/PackageSearch/PackageSearch.css b/apps/cyberstorm-remix/app/commonComponents/PackageSearch/PackageSearch.css index 31218c261..ecf6524a7 100644 --- a/apps/cyberstorm-remix/app/commonComponents/PackageSearch/PackageSearch.css +++ b/apps/cyberstorm-remix/app/commonComponents/PackageSearch/PackageSearch.css @@ -127,6 +127,16 @@ align-self: stretch; } + .package-search__error { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-start; + align-items: center; + align-self: stretch; + padding: 1rem; + } + .package-search__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); diff --git a/apps/cyberstorm-remix/app/commonComponents/PackageSearch/PackageSearch.tsx b/apps/cyberstorm-remix/app/commonComponents/PackageSearch/PackageSearch.tsx index 7435b42a3..3bc7541c5 100644 --- a/apps/cyberstorm-remix/app/commonComponents/PackageSearch/PackageSearch.tsx +++ b/apps/cyberstorm-remix/app/commonComponents/PackageSearch/PackageSearch.tsx @@ -1,8 +1,22 @@ import { faGhost, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { setParamsBlobValue } from "cyberstorm/utils/searchParamsUtils"; +import { + NimbusAwaitErrorElement, + NimbusErrorBoundary, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import { + parseIntegerSearchParam, + setParamsBlobValue, +} from "cyberstorm/utils/searchParamsUtils"; import { isPromise } from "cyberstorm/utils/typeChecks"; -import { Suspense, memo, useEffect, useRef, useState } from "react"; +import { + type ComponentPropsWithoutRef, + Suspense, + memo, + useEffect, + useRef, + useState, +} from "react"; import { Await, useNavigationType, useSearchParams } from "react-router"; import { useDebounce } from "use-debounce"; @@ -61,6 +75,10 @@ type SearchParamsType = { excludedCategories: string; }; +type CheckboxListItemsType = NonNullable< + ComponentPropsWithoutRef["items"] +>; + const searchParamsToBlob = ( searchParams: URLSearchParams, sections?: Section[] @@ -70,7 +88,7 @@ const searchParamsToBlob = ( const initialSection = searchParams.get("section"); const initialDeprecated = searchParams.get("deprecated"); const initialNsfw = searchParams.get("nsfw"); - const initialPage = searchParams.get("page"); + const initialPage = parseIntegerSearchParam(searchParams.get("page")); const initialIncludedCategories = searchParams.get("includedCategories"); const initialExcludedCategories = searchParams.get("excludedCategories"); @@ -101,12 +119,7 @@ const searchParamsToBlob = ( : initialNsfw === "false" ? false : false, - page: - initialPage && - !Number.isNaN(Number.parseInt(initialPage)) && - Number.isSafeInteger(Number.parseInt(initialPage)) - ? Number.parseInt(initialPage) - : 1, + page: initialPage ?? 1, includedCategories: initialIncludedCategories !== null ? initialIncludedCategories : "", excludedCategories: @@ -149,7 +162,7 @@ const compareSearchParamBlobs = ( /** * Component for filtering and rendering a PackageList */ -export function PackageSearch(props: Props) { +function PackageSearchContent(props: Props) { const { listings, filters, config, currentUser, dapper } = props; const navigationType = useNavigationType(); @@ -165,10 +178,15 @@ export function PackageSearch(props: Props) { const [categories, setCategories] = useState( possibleFilters?.package_categories - .sort((a, b) => a.slug.localeCompare(b.slug)) - .map((c) => ({ ...c, selection: "off" })) + ? possibleFilters.package_categories + .slice() + .sort((a, b) => a.slug.localeCompare(b.slug)) + .map((c) => ({ ...c, selection: "off" })) + : undefined ); + const [filtersError, setFiltersError] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); const initialParams = searchParamsToBlob(searchParams, sortedSections); @@ -177,39 +195,82 @@ export function PackageSearch(props: Props) { useState(initialParams); const [currentPage, setCurrentPage] = useState( - searchParams.get("page") ? Number(searchParams.get("page")) : 1 + parseIntegerSearchParam(searchParams.get("page")) ?? 1 ); const categoriesRef = useRef< undefined | Awaited>["package_categories"] >(undefined); + const applyResolvedFilters = (resolvedFilters?: CommunityFilters | null) => { + const sections = Array.isArray(resolvedFilters?.sections) + ? resolvedFilters?.sections + : []; + + const orderedSections = sections + .slice() + .sort((a, b) => b.priority - a.priority); + setSortedSections(orderedSections); + if (orderedSections.length > 0) { + setSearchParamsBlob((prev) => + prev.section + ? prev + : { + ...prev, + section: orderedSections[0].uuid, + } + ); + } + + const rawCategories = Array.isArray(resolvedFilters?.package_categories) + ? resolvedFilters?.package_categories + : []; + + if (categoriesRef.current !== rawCategories) { + const nextCategories: CategorySelection[] = rawCategories + .slice() + .sort((a, b) => a.slug.localeCompare(b.slug)) + .map((c) => ({ ...c, selection: "off" })); + setCategories(nextCategories); + categoriesRef.current = rawCategories; + } + }; + useEffect(() => { - if (isPromise(filters)) { - // On mount, resolve filters promise and set sections and categories states - filters.then((resolvedFilters) => { - // Set sorted sections - setSortedSections( - resolvedFilters.sections.sort((a, b) => b.priority - a.priority) - ); - if (sortedSections && sortedSections.length !== 0) { - setSearchParamsBlob((prev) => ({ - ...prev, - section: sortedSections[0].uuid, - })); + if (!filters) { + return; + } + + if (!isPromise(filters)) { + applyResolvedFilters(filters); + setFiltersError(null); + return; + } + + let isCancelled = false; + + const resolveFilters = async () => { + setFiltersError(null); + try { + const resolvedFilters = await filters; + if (isCancelled) { + return; } - if (resolvedFilters.package_categories !== categoriesRef.current) { - // Set current "initial" categories - const categories: CategorySelection[] = - resolvedFilters.package_categories - .sort((a, b) => a.slug.localeCompare(b.slug)) - .map((c) => ({ ...c, selection: "off" })); - setCategories(categories); - categoriesRef.current = resolvedFilters.package_categories; + applyResolvedFilters(resolvedFilters); + } catch (error) { + if (!isCancelled) { + console.error("Failed to resolve package filters", error); + setFiltersError(error); } - }); - } - }, []); + } + }; + + resolveFilters(); + + return () => { + isCancelled = true; + }; + }, [filters]); // Categories start @@ -235,17 +296,19 @@ export function PackageSearch(props: Props) { ); }; - const filtersCategoriesItems = parsedCategories.map((c) => { - return { - state: c.selection, - setStateFunc: (v: boolean | TRISTATE) => - updateCatSelection( - c.id, - typeof v === "string" ? v : v ? "include" : "off" - ), - label: c.name, - }; - }); + const filtersCategoriesItems: CheckboxListItemsType = parsedCategories.map( + (c) => { + return { + state: c.selection, + setStateFunc: (v: boolean | TRISTATE) => + updateCatSelection( + c.id, + typeof v === "string" ? v : v ? "include" : "off" + ), + label: c.name, + }; + } + ); // Categories end // Start updating page @@ -307,9 +370,7 @@ export function PackageSearch(props: Props) { searchParams.get("includedCategories") ?? ""; const oldExcludedCategories = searchParams.get("excludedCategories") ?? ""; - const oldPage = searchParams.get("page") - ? Number(searchParams.get("page")) - : 1; + const oldPage = parseIntegerSearchParam(searchParams.get("page")) ?? 1; // Search if (oldSearch !== debouncedSearchParamsBlob.search) { @@ -477,36 +538,22 @@ export function PackageSearch(props: Props) {
- {sortedSections && sortedSections.length > 0 ? ( - - - - ) : null} - {categories && categories.length > 0 ? ( - - - - ) : null} + + + +
- - setParamsBlobCategories(setSearchParamsBlob, searchParamsBlob, v) - } - rootClasses="package-search__tags" - clearAll={clearAll(setSearchParamsBlob, searchParamsBlob)} +
@@ -605,7 +639,10 @@ export function PackageSearch(props: Props) {
}> - + } + > {(resolvedValue) => (
}> - + } + > {(resolvedValue) => ( <> {resolvedValue.results.length > 0 ? ( @@ -698,7 +738,10 @@ export function PackageSearch(props: Props) {
}> - + } + > {(resolvedValue) => ( + + + ); +} + PackageSearch.displayName = "PackageSearch"; // Start setters @@ -779,6 +838,115 @@ const clearAll = }); // End setters +interface SectionsFilterSectionProps { + sections?: CommunityFilters["sections"]; + filtersError: unknown; + searchParamsBlob: SearchParamsType; + setSearchParamsBlob: (v: SearchParamsType) => void; +} + +/** + * Renders the sections filter menu or throws when the sections promise rejects. + */ +function SectionsFilterSection(props: SectionsFilterSectionProps) { + const { sections, filtersError, searchParamsBlob, setSearchParamsBlob } = + props; + + if (filtersError) { + throw filtersError instanceof Error + ? filtersError + : new Error("Failed to load section filters"); + } + + if (!sections || sections.length === 0) { + return null; + } + + const radioSections = [ + ...sections, + { uuid: "all", name: "All", slug: "all", priority: -999999999 }, + ]; + + const selectedSection = + searchParamsBlob.section === "" + ? sections[0]?.uuid + : searchParamsBlob.section; + + return ( + + + + ); +} + +interface CategoriesFilterSectionProps { + categories?: CategorySelection[]; + filtersError: unknown; + items: CheckboxListItemsType; +} + +/** + * Renders the categories filter menu and throws when filter resolution fails so + * the surrounding boundary can surface a localized fallback. + */ +function CategoriesFilterSection(props: CategoriesFilterSectionProps) { + const { categories, filtersError, items } = props; + + if (filtersError) { + throw filtersError instanceof Error + ? filtersError + : new Error("Failed to load category filters"); + } + + if (!categories || categories.length === 0) { + return null; + } + + return ( + + + + ); +} + +interface CategoryTagCloudSectionProps { + parsedCategories: CategorySelection[]; + searchParamsBlob: SearchParamsType; + setSearchParamsBlob: (v: SearchParamsType) => void; +} + +/** + * Wraps the category tag cloud, throwing when the filter promise rejects. + */ +function CategoryTagCloudSection(props: CategoryTagCloudSectionProps) { + const { parsedCategories, searchParamsBlob, setSearchParamsBlob } = props; + + return ( + + setParamsBlobCategories(setSearchParamsBlob, searchParamsBlob, v) + } + rootClasses="package-search__tags" + clearAll={clearAll(setSearchParamsBlob, searchParamsBlob)} + /> + ); +} + const PackageSearchPackagesSkeleton = memo( function PackageSearchPackagesSkeleton() { return (