Skip to content

Commit bd556b5

Browse files
committed
Enhance error handling and user feedback across team settings routes with Suspense and Nimbus error boundaries
1 parent cd40580 commit bd556b5

File tree

7 files changed

+360
-123
lines changed

7 files changed

+360
-123
lines changed
Lines changed: 100 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,165 @@
1-
import { useLoaderData, useOutletContext } from "react-router";
1+
import { Await, useLoaderData, useOutletContext } from "react-router";
22
import "./Team.css";
33
import { PackageSearch } from "~/commonComponents/PackageSearch/PackageSearch";
4-
import { DapperTs } from "@thunderstore/dapper-ts";
54
import { PackageOrderOptions } from "../../commonComponents/PackageSearch/components/PackageOrder";
65
import { type OutletContextShape } from "../../root";
76
import { PageHeader } from "~/commonComponents/PageHeader/PageHeader";
8-
import {
9-
getPublicEnvVariables,
10-
getSessionTools,
11-
} from "cyberstorm/security/publicEnvVariables";
127
import type { Route } from "./+types/Team";
8+
import { throwUserFacingPayloadResponse } from "cyberstorm/utils/errors/userFacingErrorResponse";
9+
import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError";
10+
import { createNotFoundMapping } from "cyberstorm/utils/errors/loaderMappings";
11+
import { SkeletonBox } from "@thunderstore/cyberstorm";
12+
import { Suspense } from "react";
13+
import {
14+
NimbusAwaitErrorElement,
15+
NimbusDefaultRouteErrorBoundary,
16+
} from "cyberstorm/utils/errors/NimbusErrorBoundary";
17+
import { getLoaderTools } from "cyberstorm/utils/getLoaderTools";
18+
import { parseIntegerSearchParam } from "cyberstorm/utils/searchParamsUtils";
19+
20+
const teamNotFoundMappings = [
21+
createNotFoundMapping(
22+
"Team not found.",
23+
"We could not find the requested team.",
24+
404
25+
),
26+
];
1327

1428
export async function loader({ params, request }: Route.LoaderArgs) {
1529
if (params.communityId && params.namespaceId) {
16-
const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]);
17-
const dapper = new DapperTs(() => {
18-
return {
19-
apiHost: publicEnvVariables.VITE_API_URL,
20-
sessionId: undefined,
21-
};
22-
});
30+
const { dapper } = getLoaderTools();
2331
const searchParams = new URL(request.url).searchParams;
2432
const ordering =
2533
searchParams.get("ordering") ?? PackageOrderOptions.Updated;
26-
const page = searchParams.get("page");
34+
const page = parseIntegerSearchParam(searchParams.get("page"));
2735
const search = searchParams.get("search");
2836
const includedCategories = searchParams.get("includedCategories");
2937
const excludedCategories = searchParams.get("excludedCategories");
3038
const section = searchParams.get("section");
3139
const nsfw = searchParams.get("nsfw");
3240
const deprecated = searchParams.get("deprecated");
33-
const filters = await dapper.getCommunityFilters(params.communityId);
34-
35-
return {
36-
teamId: params.namespaceId,
37-
filters: filters,
38-
listings: await dapper.getPackageListings(
41+
try {
42+
const filters = await dapper.getCommunityFilters(params.communityId);
43+
const listings = await dapper.getPackageListings(
3944
{
4045
kind: "namespace",
4146
communityId: params.communityId,
4247
namespaceId: params.namespaceId,
4348
},
4449
ordering ?? "",
45-
page === null ? undefined : Number(page),
50+
page,
4651
search ?? "",
4752
includedCategories?.split(",") ?? undefined,
4853
excludedCategories?.split(",") ?? undefined,
4954
section ? (section === "all" ? "" : section) : "",
50-
nsfw === "true" ? true : false,
51-
deprecated === "true" ? true : false
52-
),
53-
};
55+
nsfw === "true",
56+
deprecated === "true"
57+
);
58+
const dataPromise = Promise.all([filters, listings]);
59+
60+
return {
61+
teamId: params.namespaceId,
62+
filtersAndListings: await dataPromise,
63+
};
64+
} catch (error) {
65+
handleLoaderError(error, { mappings: teamNotFoundMappings });
66+
}
5467
}
55-
throw new Response("Community not found", { status: 404 });
68+
throwUserFacingPayloadResponse({
69+
headline: "Community not found.",
70+
description: "We could not find the requested community.",
71+
category: "not_found",
72+
status: 404,
73+
});
5674
}
5775

5876
export async function clientLoader({
5977
request,
6078
params,
6179
}: Route.ClientLoaderArgs) {
6280
if (params.communityId && params.namespaceId) {
63-
const tools = getSessionTools();
64-
const dapper = new DapperTs(() => {
65-
return {
66-
apiHost: tools?.getConfig().apiHost,
67-
sessionId: tools?.getConfig().sessionId,
68-
};
69-
});
81+
const { dapper } = getLoaderTools();
7082
const searchParams = new URL(request.url).searchParams;
7183
const ordering =
7284
searchParams.get("ordering") ?? PackageOrderOptions.Updated;
73-
const page = searchParams.get("page");
85+
const page = parseIntegerSearchParam(searchParams.get("page"));
7486
const search = searchParams.get("search");
7587
const includedCategories = searchParams.get("includedCategories");
7688
const excludedCategories = searchParams.get("excludedCategories");
7789
const section = searchParams.get("section");
7890
const nsfw = searchParams.get("nsfw");
7991
const deprecated = searchParams.get("deprecated");
80-
const filters = dapper.getCommunityFilters(params.communityId);
81-
return {
82-
teamId: params.namespaceId,
83-
filters: filters,
84-
listings: dapper.getPackageListings(
92+
const filters = dapper
93+
.getCommunityFilters(params.communityId)
94+
.catch((error) =>
95+
handleLoaderError(error, { mappings: teamNotFoundMappings })
96+
);
97+
const listings = dapper
98+
.getPackageListings(
8599
{
86100
kind: "namespace",
87101
communityId: params.communityId,
88102
namespaceId: params.namespaceId,
89103
},
90104
ordering ?? "",
91-
page === null ? undefined : Number(page),
105+
page,
92106
search ?? "",
93107
includedCategories?.split(",") ?? undefined,
94108
excludedCategories?.split(",") ?? undefined,
95109
section ? (section === "all" ? "" : section) : "",
96-
nsfw === "true" ? true : false,
97-
deprecated === "true" ? true : false
98-
),
99-
};
110+
nsfw === "true",
111+
deprecated === "true"
112+
)
113+
.catch((error) =>
114+
handleLoaderError(error, { mappings: teamNotFoundMappings })
115+
);
116+
const dataPromise = Promise.all([filters, listings]);
117+
118+
return { teamId: params.namespaceId, filtersAndListings: dataPromise };
100119
}
101-
throw new Response("Community not found", { status: 404 });
120+
throwUserFacingPayloadResponse({
121+
headline: "Community not found.",
122+
description: "We could not find the requested community.",
123+
category: "not_found",
124+
status: 404,
125+
});
102126
}
103127

128+
/**
129+
* Displays the team package listing and delegates streaming data to PackageSearch.
130+
*/
104131
export default function Team() {
105-
const { filters, listings, teamId } = useLoaderData<
106-
typeof loader | typeof clientLoader
107-
>();
132+
const data = useLoaderData<typeof loader | typeof clientLoader>();
108133

109134
const outletContext = useOutletContext() as OutletContextShape;
110135

111136
return (
112-
<>
113-
<section className="team">
114-
<PageHeader headingLevel="1" headingSize="3">
115-
Mods uploaded by {teamId}
116-
</PageHeader>
117-
<>
118-
<PackageSearch
119-
listings={listings}
120-
filters={filters}
121-
config={outletContext.requestConfig}
122-
currentUser={outletContext.currentUser}
123-
dapper={outletContext.dapper}
124-
/>
125-
</>
126-
</section>
127-
</>
137+
<section className="team">
138+
<PageHeader headingLevel="1" headingSize="3">
139+
Mods uploaded by {data.teamId}
140+
</PageHeader>
141+
<Suspense fallback={<SkeletonBox />}>
142+
<Await
143+
resolve={data.filtersAndListings}
144+
errorElement={<NimbusAwaitErrorElement />}
145+
>
146+
{([filters, listings]) => {
147+
return (
148+
<PackageSearch
149+
listings={listings}
150+
filters={filters}
151+
config={outletContext.requestConfig}
152+
currentUser={outletContext.currentUser}
153+
dapper={outletContext.dapper}
154+
/>
155+
);
156+
}}
157+
</Await>
158+
</Suspense>
159+
</section>
128160
);
129161
}
162+
163+
export function ErrorBoundary() {
164+
return <NimbusDefaultRouteErrorBoundary />;
165+
}

apps/cyberstorm-remix/app/settings/teams/Teams.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
type RequestConfig,
2020
teamCreate,
2121
type TeamCreateRequestData,
22+
UserFacingError,
23+
formatUserFacingError,
2224
} from "@thunderstore/thunderstore-api";
2325
import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm";
2426
import { postTeamCreate } from "@thunderstore/dapper-ts/src/methods/team";
@@ -28,6 +30,11 @@ import {
2830
setSessionStale,
2931
SESSION_STORAGE_KEY,
3032
} from "@thunderstore/ts-api-react/src/SessionContext";
33+
import {
34+
NimbusErrorBoundary,
35+
NimbusErrorBoundaryFallback,
36+
} from "cyberstorm/utils/errors/NimbusErrorBoundary";
37+
import type { NimbusErrorBoundaryFallbackProps } from "cyberstorm/utils/errors/NimbusErrorBoundary";
3138

3239
export const meta: MetaFunction<
3340
unknown,
@@ -63,7 +70,10 @@ export default function Teams() {
6370
const currentUser = outletContext.currentUser;
6471

6572
return (
66-
<>
73+
<NimbusErrorBoundary
74+
fallback={TeamsSettingsFallback}
75+
onRetry={({ reset }) => reset()}
76+
>
6777
<PageHeader headingLevel="1" headingSize="2">
6878
Teams
6979
</PageHeader>
@@ -78,7 +88,6 @@ export default function Teams() {
7888
{currentUser?.teams_full?.length ? (
7989
<NewTable
8090
titleRowContent={<Heading csLevel="3">Teams</Heading>}
81-
// csModifiers={["alignLastColumnRight"]}
8291
headers={[
8392
{ value: "Team Name", disableSort: false },
8493
{ value: "Role", disableSort: false },
@@ -122,7 +131,28 @@ export default function Teams() {
122131
</div>
123132
</div>
124133
</section>
125-
</>
134+
</NimbusErrorBoundary>
135+
);
136+
}
137+
138+
/**
139+
* Presents fallback messaging when the teams settings view crashes.
140+
*/
141+
function TeamsSettingsFallback(props: NimbusErrorBoundaryFallbackProps) {
142+
const {
143+
title = "Teams failed to load",
144+
description = "Reload the teams tab or return to settings.",
145+
retryLabel = "Reload",
146+
...rest
147+
} = props;
148+
149+
return (
150+
<NimbusErrorBoundaryFallback
151+
{...rest}
152+
title={title}
153+
description={description}
154+
retryLabel={retryLabel}
155+
/>
126156
);
127157
}
128158

@@ -159,7 +189,7 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
159189
TeamCreateRequestData,
160190
Error,
161191
SubmitorOutput,
162-
Error,
192+
UserFacingError,
163193
InputErrors
164194
>({
165195
inputs: formInputs,
@@ -176,7 +206,7 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
176206
onSubmitError: (error) => {
177207
toast.addToast({
178208
csVariant: "danger",
179-
children: `Error occurred: ${error.message || "Unknown error"}`,
209+
children: formatUserFacingError(error),
180210
duration: 8000,
181211
});
182212
},

0 commit comments

Comments
 (0)