Skip to content

Commit f33adae

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

File tree

7 files changed

+565
-95
lines changed

7 files changed

+565
-95
lines changed
Lines changed: 102 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,131 +1,165 @@
1-
import {
2-
getPublicEnvVariables,
3-
getSessionTools,
4-
} from "cyberstorm/security/publicEnvVariables";
5-
import { useLoaderData, useOutletContext } from "react-router";
1+
import { Await, useLoaderData, useOutletContext } from "react-router";
2+
import "./Team.css";
63
import { PackageSearch } from "~/commonComponents/PackageSearch/PackageSearch";
7-
import { PageHeader } from "~/commonComponents/PageHeader/PageHeader";
8-
9-
import { DapperTs } from "@thunderstore/dapper-ts";
10-
114
import { PackageOrderOptions } from "../../commonComponents/PackageSearch/components/PackageOrder";
125
import { type OutletContextShape } from "../../root";
6+
import { PageHeader } from "~/commonComponents/PageHeader/PageHeader";
137
import type { Route } from "./+types/Team";
14-
import "./Team.css";
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+
];
1527

1628
export async function loader({ params, request }: Route.LoaderArgs) {
1729
if (params.communityId && params.namespaceId) {
18-
const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]);
19-
const dapper = new DapperTs(() => {
20-
return {
21-
apiHost: publicEnvVariables.VITE_API_URL,
22-
sessionId: undefined,
23-
};
24-
});
30+
const { dapper } = getLoaderTools();
2531
const searchParams = new URL(request.url).searchParams;
2632
const ordering =
2733
searchParams.get("ordering") ?? PackageOrderOptions.Updated;
28-
const page = searchParams.get("page");
34+
const page = parseIntegerSearchParam(searchParams.get("page"));
2935
const search = searchParams.get("search");
3036
const includedCategories = searchParams.get("includedCategories");
3137
const excludedCategories = searchParams.get("excludedCategories");
3238
const section = searchParams.get("section");
3339
const nsfw = searchParams.get("nsfw");
3440
const deprecated = searchParams.get("deprecated");
35-
const filters = await dapper.getCommunityFilters(params.communityId);
36-
37-
return {
38-
teamId: params.namespaceId,
39-
filters: filters,
40-
listings: await dapper.getPackageListings(
41+
try {
42+
const filters = await dapper.getCommunityFilters(params.communityId);
43+
const listings = await dapper.getPackageListings(
4144
{
4245
kind: "namespace",
4346
communityId: params.communityId,
4447
namespaceId: params.namespaceId,
4548
},
4649
ordering ?? "",
47-
page === null ? undefined : Number(page),
50+
page,
4851
search ?? "",
4952
includedCategories?.split(",") ?? undefined,
5053
excludedCategories?.split(",") ?? undefined,
5154
section ? (section === "all" ? "" : section) : "",
52-
nsfw === "true" ? true : false,
53-
deprecated === "true" ? true : false
54-
),
55-
};
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+
}
5667
}
57-
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+
});
5874
}
5975

6076
export async function clientLoader({
6177
request,
6278
params,
6379
}: Route.ClientLoaderArgs) {
6480
if (params.communityId && params.namespaceId) {
65-
const tools = getSessionTools();
66-
const dapper = new DapperTs(() => {
67-
return {
68-
apiHost: tools?.getConfig().apiHost,
69-
sessionId: tools?.getConfig().sessionId,
70-
};
71-
});
81+
const { dapper } = getLoaderTools();
7282
const searchParams = new URL(request.url).searchParams;
7383
const ordering =
7484
searchParams.get("ordering") ?? PackageOrderOptions.Updated;
75-
const page = searchParams.get("page");
85+
const page = parseIntegerSearchParam(searchParams.get("page"));
7686
const search = searchParams.get("search");
7787
const includedCategories = searchParams.get("includedCategories");
7888
const excludedCategories = searchParams.get("excludedCategories");
7989
const section = searchParams.get("section");
8090
const nsfw = searchParams.get("nsfw");
8191
const deprecated = searchParams.get("deprecated");
82-
const filters = dapper.getCommunityFilters(params.communityId);
83-
return {
84-
teamId: params.namespaceId,
85-
filters: filters,
86-
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(
8799
{
88100
kind: "namespace",
89101
communityId: params.communityId,
90102
namespaceId: params.namespaceId,
91103
},
92104
ordering ?? "",
93-
page === null ? undefined : Number(page),
105+
page,
94106
search ?? "",
95107
includedCategories?.split(",") ?? undefined,
96108
excludedCategories?.split(",") ?? undefined,
97109
section ? (section === "all" ? "" : section) : "",
98-
nsfw === "true" ? true : false,
99-
deprecated === "true" ? true : false
100-
),
101-
};
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 };
102119
}
103-
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+
});
104126
}
105127

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

111134
const outletContext = useOutletContext() as OutletContextShape;
112135

113136
return (
114-
<>
115-
<section className="team">
116-
<PageHeader headingLevel="1" headingSize="3">
117-
Mods uploaded by {teamId}
118-
</PageHeader>
119-
<>
120-
<PackageSearch
121-
listings={listings}
122-
filters={filters}
123-
config={outletContext.requestConfig}
124-
currentUser={outletContext.currentUser}
125-
dapper={outletContext.dapper}
126-
/>
127-
</>
128-
</section>
129-
</>
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>
130160
);
131161
}
162+
163+
export function ErrorBoundary() {
164+
return <NimbusDefaultRouteErrorBoundary />;
165+
}

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,19 @@ import { postTeamCreate } from "@thunderstore/dapper-ts/src/methods/team";
2020
import {
2121
type RequestConfig,
2222
type TeamCreateRequestData,
23-
teamCreate,
23+
UserFacingError,
24+
formatUserFacingError,
2425
} from "@thunderstore/thunderstore-api";
2526
import { NamespacedStorageManager } from "@thunderstore/ts-api-react";
2627
import {
2728
SESSION_STORAGE_KEY,
2829
setSessionStale,
2930
} from "@thunderstore/ts-api-react/src/SessionContext";
31+
import {
32+
NimbusErrorBoundary,
33+
NimbusErrorBoundaryFallback,
34+
} from "cyberstorm/utils/errors/NimbusErrorBoundary";
35+
import type { NimbusErrorBoundaryFallbackProps } from "cyberstorm/utils/errors/NimbusErrorBoundary";
3036

3137
import { type OutletContextShape, type RootLoadersType } from "../../root";
3238
import "./Teams.css";
@@ -65,7 +71,10 @@ export default function Teams() {
6571
const currentUser = outletContext.currentUser;
6672

6773
return (
68-
<>
74+
<NimbusErrorBoundary
75+
fallback={TeamsSettingsFallback}
76+
onRetry={({ reset }) => reset()}
77+
>
6978
<PageHeader headingLevel="1" headingSize="2">
7079
Teams
7180
</PageHeader>
@@ -80,7 +89,6 @@ export default function Teams() {
8089
{currentUser?.teams_full?.length ? (
8190
<NewTable
8291
titleRowContent={<Heading csLevel="3">Teams</Heading>}
83-
// csModifiers={["alignLastColumnRight"]}
8492
headers={[
8593
{ value: "Team Name", disableSort: false },
8694
{ value: "Role", disableSort: false },
@@ -124,7 +132,28 @@ export default function Teams() {
124132
</div>
125133
</div>
126134
</section>
127-
</>
135+
</NimbusErrorBoundary>
136+
);
137+
}
138+
139+
/**
140+
* Presents fallback messaging when the teams settings view crashes.
141+
*/
142+
function TeamsSettingsFallback(props: NimbusErrorBoundaryFallbackProps) {
143+
const {
144+
title = "Teams failed to load",
145+
description = "Reload the teams tab or return to settings.",
146+
retryLabel = "Reload",
147+
...rest
148+
} = props;
149+
150+
return (
151+
<NimbusErrorBoundaryFallback
152+
{...rest}
153+
title={title}
154+
description={description}
155+
retryLabel={retryLabel}
156+
/>
128157
);
129158
}
130159

@@ -161,7 +190,7 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
161190
TeamCreateRequestData,
162191
Error,
163192
SubmitorOutput,
164-
Error,
193+
UserFacingError,
165194
InputErrors
166195
>({
167196
inputs: formInputs,
@@ -178,7 +207,7 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
178207
onSubmitError: (error) => {
179208
toast.addToast({
180209
csVariant: "danger",
181-
children: `Error occurred: ${error.message || "Unknown error"}`,
210+
children: formatUserFacingError(error),
182211
duration: 8000,
183212
});
184213
},

0 commit comments

Comments
 (0)