Skip to content

Commit ffa9e4b

Browse files
committed
Enhance error handling in upload route with user-facing error messages and improve community data fetching
1 parent cd40580 commit ffa9e4b

File tree

1 file changed

+192
-63
lines changed

1 file changed

+192
-63
lines changed

apps/cyberstorm-remix/app/upload/upload.tsx

Lines changed: 192 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
import "./Upload.css";
22
import {
3+
Heading,
34
NewButton,
45
NewIcon,
6+
NewLink,
57
NewSelectSearch,
68
NewSwitch,
7-
Heading,
8-
NewLink,
99
NewTable,
1010
NewTableSort,
1111
NewTag,
12+
SkeletonBox,
1213
useToast,
1314
} from "@thunderstore/cyberstorm";
1415
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
1516
import { PageHeader } from "../commonComponents/PageHeader/PageHeader";
1617
import { DnDFileInput } from "@thunderstore/react-dnd";
17-
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
18+
import {
19+
Suspense,
20+
useCallback,
21+
useEffect,
22+
useReducer,
23+
useRef,
24+
useState,
25+
} from "react";
1826
import {
1927
MultipartUpload,
2028
type IBaseUploadHandle,
@@ -27,22 +35,35 @@ import {
2735
} from "@fortawesome/pro-solid-svg-icons";
2836
import { type UserMedia } from "@thunderstore/ts-uploader/src/uploaders/types";
2937
import { DapperTs } from "@thunderstore/dapper-ts";
30-
import { type MetaFunction } from "react-router";
31-
import { useLoaderData, useOutletContext } from "react-router";
38+
import {
39+
Await,
40+
type MetaFunction,
41+
useLoaderData,
42+
useOutletContext,
43+
} from "react-router";
3244
import {
3345
type PackageSubmissionResult,
3446
type PackageSubmissionStatus,
3547
} from "@thunderstore/dapper/types";
36-
import { type PackageSubmissionRequestData } from "@thunderstore/thunderstore-api";
48+
import {
49+
type PackageSubmissionRequestData,
50+
UserFacingError,
51+
formatUserFacingError,
52+
} from "@thunderstore/thunderstore-api";
3753
import { type OutletContextShape } from "../root";
3854
import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm";
3955
import { postPackageSubmissionMetadata } from "@thunderstore/dapper-ts/src/methods/package";
4056
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
4157
import { classnames } from "@thunderstore/cyberstorm/src/utils/utils";
58+
import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError";
4259
import {
43-
getPublicEnvVariables,
44-
getSessionTools,
45-
} from "cyberstorm/security/publicEnvVariables";
60+
NimbusAwaitErrorElement,
61+
NimbusDefaultRouteErrorBoundary,
62+
} from "../../cyberstorm/utils/errors/NimbusErrorBoundary";
63+
import { getLoaderTools } from "cyberstorm/utils/getLoaderTools";
64+
65+
const getErrorMessage = (error: unknown) =>
66+
error instanceof Error ? error.message : String(error);
4667

4768
interface CommunityOption {
4869
value: string;
@@ -64,36 +85,64 @@ export const meta: MetaFunction = () => {
6485
];
6586
};
6687

88+
type CommunitiesResult = Awaited<ReturnType<DapperTs["getCommunities"]>>;
89+
90+
interface UploadContentProps {
91+
communities: CommunitiesResult;
92+
outletContext: OutletContextShape;
93+
}
94+
95+
/**
96+
* Fetches the community list for the upload form during SSR.
97+
*/
6798
export async function loader() {
68-
const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]);
69-
const dapper = new DapperTs(() => {
99+
const { dapper } = getLoaderTools();
100+
try {
101+
const communities = await dapper.getCommunities();
102+
70103
return {
71-
apiHost: publicEnvVariables.VITE_API_URL,
72-
sessionId: undefined,
104+
communities,
73105
};
74-
});
75-
return await dapper.getCommunities();
106+
} catch (error) {
107+
handleLoaderError(error);
108+
}
76109
}
77110

111+
/**
112+
* Streams the community list promise so Suspense can render progress fallbacks.
113+
*/
78114
export async function clientLoader() {
79-
// console.log("clientloader context", getSessionTools(context));
80-
const tools = getSessionTools();
81-
const dapper = new DapperTs(() => {
82-
return {
83-
apiHost: tools?.getConfig().apiHost,
84-
sessionId: tools?.getConfig().sessionId,
85-
};
86-
});
87-
return await dapper.getCommunities();
115+
const { dapper } = getLoaderTools();
116+
const communitiesPromise = dapper.getCommunities();
117+
118+
return {
119+
communities: communitiesPromise,
120+
};
88121
}
89122

123+
/**
124+
* Streams communities via Suspense and delegates UI rendering to UploadContent.
125+
*/
90126
export default function Upload() {
91-
const uploadData = useLoaderData<typeof loader | typeof clientLoader>();
92-
127+
const { communities } = useLoaderData<typeof loader | typeof clientLoader>();
93128
const outletContext = useOutletContext() as OutletContextShape;
94-
const requestConfig = outletContext.requestConfig;
95-
const currentUser = outletContext.currentUser;
96-
const dapper = outletContext.dapper;
129+
130+
return (
131+
<Suspense fallback={<UploadSkeleton />}>
132+
<Await resolve={communities} errorElement={<NimbusAwaitErrorElement />}>
133+
{(result) => (
134+
<UploadContent communities={result} outletContext={outletContext} />
135+
)}
136+
</Await>
137+
</Suspense>
138+
);
139+
}
140+
141+
/**
142+
* Renders the upload workflow once community metadata resolves.
143+
*/
144+
function UploadContent({ communities, outletContext }: UploadContentProps) {
145+
const { requestConfig, currentUser, dapper, domain } = outletContext;
97146

98147
const toast = useToast();
99148

@@ -115,13 +164,14 @@ export default function Upload() {
115164
}, [currentUser?.teams_full]);
116165

117166
// Community options
118-
const communityOptions: CommunityOption[] = [];
119-
for (const community of uploadData.results) {
120-
communityOptions.push({
121-
value: community.identifier,
122-
label: community.name,
123-
});
124-
}
167+
const communityOptions: CommunityOption[] = communities.results.map(
168+
(community) => {
169+
return {
170+
value: community.identifier,
171+
label: community.name,
172+
};
173+
}
174+
);
125175

126176
const [submissionStatus, setSubmissionStatus] =
127177
useState<PackageSubmissionStatus>();
@@ -236,17 +286,29 @@ export default function Upload() {
236286
// TODO: Add sentry logging
237287
toast.addToast({
238288
csVariant: "danger",
239-
children: `Error polling submission status: ${error.message}`,
289+
children: `Error polling submission status: ${getErrorMessage(
290+
error
291+
)}`,
240292
duration: 8000,
241293
});
242294
});
243295
}
244296
}, [submissionStatus]);
245297

246-
const retryPolling = () => {
247-
if (submissionStatus?.id) {
248-
pollSubmission(submissionStatus.id, true).then((data) => {
249-
setSubmissionStatus(data);
298+
const retryPolling = async () => {
299+
const submissionId = submissionStatus?.id;
300+
if (!submissionId) {
301+
return;
302+
}
303+
304+
try {
305+
const data = await pollSubmission(submissionId, true);
306+
setSubmissionStatus(data);
307+
} catch (error) {
308+
toast.addToast({
309+
csVariant: "danger",
310+
children: `Error polling submission status: ${getErrorMessage(error)}`,
311+
duration: 8000,
250312
});
251313
}
252314
};
@@ -282,25 +344,63 @@ export default function Upload() {
282344
});
283345

284346
useEffect(() => {
285-
for (const community of formInputs.communities) {
286-
// Skip if we already have categories for this community
287-
if (categoryOptions.some((opt) => opt.communityId === community)) {
288-
continue;
289-
}
290-
dapper.getCommunityFilters(community).then((filters) => {
291-
setCategoryOptions((prev) => [
292-
...prev,
293-
{
294-
communityId: community,
295-
categories: filters.package_categories.map((cat) => ({
296-
value: cat.slug,
297-
label: cat.name,
298-
})),
299-
},
300-
]);
301-
});
347+
const communitiesToFetch = formInputs.communities.filter(
348+
(community) =>
349+
!categoryOptions.some((opt) => opt.communityId === community)
350+
);
351+
352+
if (communitiesToFetch.length === 0) {
353+
return;
302354
}
303-
}, [formInputs.communities]);
355+
356+
let isCancelled = false;
357+
358+
const fetchFilters = async () => {
359+
await Promise.all(
360+
communitiesToFetch.map(async (community) => {
361+
try {
362+
const filters = await dapper.getCommunityFilters(community);
363+
if (isCancelled) {
364+
return;
365+
}
366+
367+
setCategoryOptions((prev) => {
368+
if (prev.some((opt) => opt.communityId === community)) {
369+
return prev;
370+
}
371+
372+
return [
373+
...prev,
374+
{
375+
communityId: community,
376+
categories: filters.package_categories.map((cat) => ({
377+
value: cat.slug,
378+
label: cat.name,
379+
})),
380+
},
381+
];
382+
});
383+
} catch (error) {
384+
if (!isCancelled) {
385+
toast.addToast({
386+
csVariant: "danger",
387+
children: `Failed to load categories: ${getErrorMessage(
388+
error
389+
)}`,
390+
duration: 8000,
391+
});
392+
}
393+
}
394+
})
395+
);
396+
};
397+
398+
fetchFilters();
399+
400+
return () => {
401+
isCancelled = true;
402+
};
403+
}, [categoryOptions, dapper, formInputs.communities, toast]);
304404

305405
type SubmitorOutput = Awaited<
306406
ReturnType<typeof postPackageSubmissionMetadata>
@@ -328,7 +428,7 @@ export default function Upload() {
328428
PackageSubmissionRequestData,
329429
Error,
330430
SubmitorOutput,
331-
Error,
431+
UserFacingError,
332432
InputErrors
333433
>({
334434
inputs: formInputs,
@@ -343,7 +443,7 @@ export default function Upload() {
343443
onSubmitError: (error) => {
344444
toast.addToast({
345445
csVariant: "danger",
346-
children: `Error occurred: ${error.message || "Unknown error"}`,
446+
children: formatUserFacingError(error),
347447
duration: 8000,
348448
});
349449
},
@@ -408,7 +508,7 @@ export default function Upload() {
408508
<p className="upload__no-teams-text">No teams available?</p>
409509
<NewLink
410510
primitiveType="link"
411-
href={`${outletContext.domain}/settings/teams/`}
511+
href={`${domain}/settings/teams/`}
412512
csVariant="cyber"
413513
rootClasses="community__item"
414514
>
@@ -559,7 +659,7 @@ export default function Upload() {
559659
</div>
560660
<div className="upload__content">
561661
{formInputs.communities.map((community) => {
562-
const communityData = uploadData.results.find(
662+
const communityData = communities.results.find(
563663
(c) => c.identifier === community
564664
);
565665
const categories =
@@ -749,6 +849,32 @@ export default function Upload() {
749849
);
750850
}
751851

852+
/**
853+
* Shows a lightweight skeleton while communities load after navigation.
854+
*/
855+
function UploadSkeleton() {
856+
return (
857+
<section className="container container--y container--full upload">
858+
{[0, 1, 2].map((index) => (
859+
<div
860+
key={index}
861+
className="container container--x container--full upload__row"
862+
style={{ marginBottom: "1rem", height: "3rem" }}
863+
>
864+
<SkeletonBox />
865+
</div>
866+
))}
867+
</section>
868+
);
869+
}
870+
871+
export function ErrorBoundary() {
872+
return <NimbusDefaultRouteErrorBoundary />;
873+
}
874+
875+
/**
876+
* Converts byte counts into a human-readable string for upload status messaging.
877+
*/
752878
function formatBytes(bytes: number, decimals = 2) {
753879
if (!+bytes) return "0 Bytes";
754880

@@ -771,6 +897,9 @@ function formatBytes(bytes: number, decimals = 2) {
771897
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
772898
}
773899

900+
/**
901+
* Displays the submission success summary once the package metadata API responds.
902+
*/
774903
const SubmissionResult = (props: {
775904
submissionStatusResult: PackageSubmissionResult;
776905
}) => {

0 commit comments

Comments
 (0)