Skip to content

Commit 9623c29

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

File tree

1 file changed

+196
-71
lines changed

1 file changed

+196
-71
lines changed

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

Lines changed: 196 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,34 @@
1-
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
21
import {
32
faArrowUpRight,
3+
faCheckCircle,
44
faFileZip,
55
faTreasureChest,
66
faUsers,
77
} from "@fortawesome/pro-solid-svg-icons";
88
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
9-
import {
10-
getPublicEnvVariables,
11-
getSessionTools,
12-
} from "cyberstorm/security/publicEnvVariables";
139
import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm";
14-
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
15-
import { type MetaFunction } from "react-router";
16-
import { useLoaderData, useOutletContext } from "react-router";
10+
import {
11+
NimbusAwaitErrorElement,
12+
NimbusDefaultRouteErrorBoundary,
13+
} from "cyberstorm/utils/errors/NimbusErrorBoundary";
14+
import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError";
15+
import { getLoaderTools } from "cyberstorm/utils/getLoaderTools";
16+
import {
17+
Suspense,
18+
useCallback,
19+
useEffect,
20+
useReducer,
21+
useRef,
22+
useState,
23+
} from "react";
24+
import {
25+
Await,
26+
type MetaFunction,
27+
useLoaderData,
28+
useOutletContext,
29+
} from "react-router";
30+
import { PageHeader } from "~/commonComponents/PageHeader/PageHeader";
31+
import type { OutletContextShape } from "~/root";
1732

1833
import {
1934
Heading,
@@ -25,31 +40,33 @@ import {
2540
NewTable,
2641
NewTableSort,
2742
NewTag,
43+
SkeletonBox,
2844
classnames,
2945
useToast,
3046
} from "@thunderstore/cyberstorm";
31-
import {
32-
type PackageSubmissionResult,
33-
type PackageSubmissionStatus,
47+
import type {
48+
PackageSubmissionResult,
49+
PackageSubmissionStatus,
3450
} from "@thunderstore/dapper";
3551
import {
3652
DapperTs,
3753
postPackageSubmissionMetadata,
3854
} from "@thunderstore/dapper-ts";
3955
import { DnDFileInput } from "@thunderstore/react-dnd";
40-
import {
41-
type PackageSubmissionRequestData,
42-
UserFacingError,
43-
} from "@thunderstore/thunderstore-api";
4456
import {
4557
type IBaseUploadHandle,
4658
MultipartUpload,
47-
type UserMedia,
4859
} from "@thunderstore/ts-uploader";
4960

50-
import { PageHeader } from "../commonComponents/PageHeader/PageHeader";
51-
import { type OutletContextShape } from "../root";
52-
import "./Upload.css";
61+
import {
62+
type PackageSubmissionRequestData,
63+
UserFacingError,
64+
type UserMedia,
65+
formatUserFacingError,
66+
} from "../../../../packages/thunderstore-api/src";
67+
68+
const getErrorMessage = (error: unknown) =>
69+
error instanceof Error ? error.message : String(error);
5370

5471
interface CommunityOption {
5572
value: string;
@@ -71,36 +88,64 @@ export const meta: MetaFunction = () => {
7188
];
7289
};
7390

91+
type CommunitiesResult = Awaited<ReturnType<DapperTs["getCommunities"]>>;
92+
93+
interface UploadContentProps {
94+
communities: CommunitiesResult;
95+
outletContext: OutletContextShape;
96+
}
97+
98+
/**
99+
* Fetches the community list for the upload form during SSR.
100+
*/
74101
export async function loader() {
75-
const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]);
76-
const dapper = new DapperTs(() => {
102+
const { dapper } = getLoaderTools();
103+
try {
104+
const communities = await dapper.getCommunities();
105+
77106
return {
78-
apiHost: publicEnvVariables.VITE_API_URL,
79-
sessionId: undefined,
107+
communities,
80108
};
81-
});
82-
return await dapper.getCommunities();
109+
} catch (error) {
110+
handleLoaderError(error);
111+
}
83112
}
84113

114+
/**
115+
* Streams the community list promise so Suspense can render progress fallbacks.
116+
*/
85117
export async function clientLoader() {
86-
// console.log("clientloader context", getSessionTools(context));
87-
const tools = getSessionTools();
88-
const dapper = new DapperTs(() => {
89-
return {
90-
apiHost: tools?.getConfig().apiHost,
91-
sessionId: tools?.getConfig().sessionId,
92-
};
93-
});
94-
return await dapper.getCommunities();
118+
const { dapper } = getLoaderTools();
119+
const communitiesPromise = dapper.getCommunities();
120+
121+
return {
122+
communities: communitiesPromise,
123+
};
95124
}
96125

126+
/**
127+
* Streams communities via Suspense and delegates UI rendering to UploadContent.
128+
*/
97129
export default function Upload() {
98-
const uploadData = useLoaderData<typeof loader | typeof clientLoader>();
99-
130+
const { communities } = useLoaderData<typeof loader | typeof clientLoader>();
100131
const outletContext = useOutletContext() as OutletContextShape;
101-
const requestConfig = outletContext.requestConfig;
102-
const currentUser = outletContext.currentUser;
103-
const dapper = outletContext.dapper;
132+
133+
return (
134+
<Suspense fallback={<UploadSkeleton />}>
135+
<Await resolve={communities} errorElement={<NimbusAwaitErrorElement />}>
136+
{(result) => (
137+
<UploadContent communities={result} outletContext={outletContext} />
138+
)}
139+
</Await>
140+
</Suspense>
141+
);
142+
}
143+
144+
/**
145+
* Renders the upload workflow once community metadata resolves.
146+
*/
147+
function UploadContent({ communities, outletContext }: UploadContentProps) {
148+
const { requestConfig, currentUser, dapper } = outletContext;
104149

105150
const toast = useToast();
106151

@@ -122,13 +167,14 @@ export default function Upload() {
122167
}, [currentUser?.teams_full]);
123168

124169
// Community options
125-
const communityOptions: CommunityOption[] = [];
126-
for (const community of uploadData.results) {
127-
communityOptions.push({
128-
value: community.identifier,
129-
label: community.name,
130-
});
131-
}
170+
const communityOptions: CommunityOption[] = communities.results.map(
171+
(community) => {
172+
return {
173+
value: community.identifier,
174+
label: community.name,
175+
};
176+
}
177+
);
132178

133179
const [submissionStatus, setSubmissionStatus] =
134180
useState<PackageSubmissionStatus>();
@@ -243,17 +289,29 @@ export default function Upload() {
243289
// TODO: Add sentry logging
244290
toast.addToast({
245291
csVariant: "danger",
246-
children: `Error polling submission status: ${error.message}`,
292+
children: `Error polling submission status: ${getErrorMessage(
293+
error
294+
)}`,
247295
duration: 8000,
248296
});
249297
});
250298
}
251299
}, [submissionStatus]);
252300

253-
const retryPolling = () => {
254-
if (submissionStatus?.id) {
255-
pollSubmission(submissionStatus.id, true).then((data) => {
256-
setSubmissionStatus(data);
301+
const retryPolling = async () => {
302+
const submissionId = submissionStatus?.id;
303+
if (!submissionId) {
304+
return;
305+
}
306+
307+
try {
308+
const data = await pollSubmission(submissionId, true);
309+
setSubmissionStatus(data);
310+
} catch (error) {
311+
toast.addToast({
312+
csVariant: "danger",
313+
children: `Error polling submission status: ${getErrorMessage(error)}`,
314+
duration: 8000,
257315
});
258316
}
259317
};
@@ -289,25 +347,63 @@ export default function Upload() {
289347
});
290348

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

312408
type SubmitorOutput = Awaited<
313409
ReturnType<typeof postPackageSubmissionMetadata>
@@ -350,7 +446,7 @@ export default function Upload() {
350446
onSubmitError: (error) => {
351447
toast.addToast({
352448
csVariant: "danger",
353-
children: `Error occurred: ${error.message || "Unknown error"}`,
449+
children: formatUserFacingError(error),
354450
duration: 8000,
355451
});
356452
},
@@ -567,7 +663,7 @@ export default function Upload() {
567663
</div>
568664
<div className="upload__content">
569665
{formInputs.communities.map((community) => {
570-
const communityData = uploadData.results.find(
666+
const communityData = communities.results.find(
571667
(c) => c.identifier === community
572668
);
573669
const categories =
@@ -757,6 +853,32 @@ export default function Upload() {
757853
);
758854
}
759855

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

@@ -779,6 +901,9 @@ function formatBytes(bytes: number, decimals = 2) {
779901
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
780902
}
781903

904+
/**
905+
* Displays the submission success summary once the package metadata API responds.
906+
*/
782907
const SubmissionResult = (props: {
783908
submissionStatusResult: PackageSubmissionResult;
784909
}) => {

0 commit comments

Comments
 (0)