Skip to content

Commit dab4b00

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

File tree

1 file changed

+207
-80
lines changed

1 file changed

+207
-80
lines changed

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

Lines changed: 207 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,4 @@
11
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
2-
import {
3-
faArrowUpRight,
4-
faFileZip,
5-
faTreasureChest,
6-
faUsers,
7-
} from "@fortawesome/pro-solid-svg-icons";
8-
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
9-
import {
10-
getPublicEnvVariables,
11-
getSessionTools,
12-
} from "cyberstorm/security/publicEnvVariables";
13-
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";
17-
182
import {
193
Heading,
204
NewButton,
@@ -25,26 +9,61 @@ import {
259
NewTable,
2610
NewTableSort,
2711
NewTag,
12+
SkeletonBox,
2813
useToast,
2914
} from "@thunderstore/cyberstorm";
30-
import { classnames } from "@thunderstore/cyberstorm/src/utils/utils";
15+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
16+
import { PageHeader } from "../commonComponents/PageHeader/PageHeader";
17+
import { DnDFileInput } from "@thunderstore/react-dnd";
18+
import {
19+
Suspense,
20+
useCallback,
21+
useEffect,
22+
useReducer,
23+
useRef,
24+
useState,
25+
} from "react";
26+
import {
27+
MultipartUpload,
28+
type IBaseUploadHandle,
29+
} from "@thunderstore/ts-uploader";
30+
import {
31+
faFileZip,
32+
faTreasureChest,
33+
faUsers,
34+
faArrowUpRight,
35+
} from "@fortawesome/pro-solid-svg-icons";
36+
import { type UserMedia } from "@thunderstore/ts-uploader/src/uploaders/types";
3137
import { DapperTs } from "@thunderstore/dapper-ts";
32-
import { postPackageSubmissionMetadata } from "@thunderstore/dapper-ts/src/methods/package";
38+
import {
39+
Await,
40+
type MetaFunction,
41+
useLoaderData,
42+
useOutletContext,
43+
} from "react-router";
3344
import {
3445
type PackageSubmissionResult,
3546
type PackageSubmissionStatus,
3647
} from "@thunderstore/dapper/types";
37-
import { DnDFileInput } from "@thunderstore/react-dnd";
38-
import { type PackageSubmissionRequestData } from "@thunderstore/thunderstore-api";
3948
import {
40-
type IBaseUploadHandle,
41-
MultipartUpload,
42-
} from "@thunderstore/ts-uploader";
43-
import { type UserMedia } from "@thunderstore/ts-uploader/src/uploaders/types";
44-
45-
import { PageHeader } from "../commonComponents/PageHeader/PageHeader";
49+
type PackageSubmissionRequestData,
50+
UserFacingError,
51+
formatUserFacingError,
52+
} from "@thunderstore/thunderstore-api";
4653
import { type OutletContextShape } from "../root";
47-
import "./Upload.css";
54+
import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm";
55+
import { postPackageSubmissionMetadata } from "@thunderstore/dapper-ts/src/methods/package";
56+
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
57+
import { classnames } from "@thunderstore/cyberstorm/src/utils/utils";
58+
import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError";
59+
import {
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);
4867

4968
interface CommunityOption {
5069
value: string;
@@ -66,36 +85,64 @@ export const meta: MetaFunction = () => {
6685
];
6786
};
6887

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+
*/
6998
export async function loader() {
70-
const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]);
71-
const dapper = new DapperTs(() => {
99+
const { dapper } = getLoaderTools();
100+
try {
101+
const communities = await dapper.getCommunities();
102+
72103
return {
73-
apiHost: publicEnvVariables.VITE_API_URL,
74-
sessionId: undefined,
104+
communities,
75105
};
76-
});
77-
return await dapper.getCommunities();
106+
} catch (error) {
107+
handleLoaderError(error);
108+
}
78109
}
79110

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

123+
/**
124+
* Streams communities via Suspense and delegates UI rendering to UploadContent.
125+
*/
92126
export default function Upload() {
93-
const uploadData = useLoaderData<typeof loader | typeof clientLoader>();
94-
127+
const { communities } = useLoaderData<typeof loader | typeof clientLoader>();
95128
const outletContext = useOutletContext() as OutletContextShape;
96-
const requestConfig = outletContext.requestConfig;
97-
const currentUser = outletContext.currentUser;
98-
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;
99146

100147
const toast = useToast();
101148

@@ -117,13 +164,14 @@ export default function Upload() {
117164
}, [currentUser?.teams_full]);
118165

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

128176
const [submissionStatus, setSubmissionStatus] =
129177
useState<PackageSubmissionStatus>();
@@ -238,17 +286,29 @@ export default function Upload() {
238286
// TODO: Add sentry logging
239287
toast.addToast({
240288
csVariant: "danger",
241-
children: `Error polling submission status: ${error.message}`,
289+
children: `Error polling submission status: ${getErrorMessage(
290+
error
291+
)}`,
242292
duration: 8000,
243293
});
244294
});
245295
}
246296
}, [submissionStatus]);
247297

248-
const retryPolling = () => {
249-
if (submissionStatus?.id) {
250-
pollSubmission(submissionStatus.id, true).then((data) => {
251-
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,
252312
});
253313
}
254314
};
@@ -284,25 +344,63 @@ export default function Upload() {
284344
});
285345

286346
useEffect(() => {
287-
for (const community of formInputs.communities) {
288-
// Skip if we already have categories for this community
289-
if (categoryOptions.some((opt) => opt.communityId === community)) {
290-
continue;
291-
}
292-
dapper.getCommunityFilters(community).then((filters) => {
293-
setCategoryOptions((prev) => [
294-
...prev,
295-
{
296-
communityId: community,
297-
categories: filters.package_categories.map((cat) => ({
298-
value: cat.slug,
299-
label: cat.name,
300-
})),
301-
},
302-
]);
303-
});
347+
const communitiesToFetch = formInputs.communities.filter(
348+
(community) =>
349+
!categoryOptions.some((opt) => opt.communityId === community)
350+
);
351+
352+
if (communitiesToFetch.length === 0) {
353+
return;
304354
}
305-
}, [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]);
306404

307405
type SubmitorOutput = Awaited<
308406
ReturnType<typeof postPackageSubmissionMetadata>
@@ -330,7 +428,7 @@ export default function Upload() {
330428
PackageSubmissionRequestData,
331429
Error,
332430
SubmitorOutput,
333-
Error,
431+
UserFacingError,
334432
InputErrors
335433
>({
336434
inputs: formInputs,
@@ -345,7 +443,7 @@ export default function Upload() {
345443
onSubmitError: (error) => {
346444
toast.addToast({
347445
csVariant: "danger",
348-
children: `Error occurred: ${error.message || "Unknown error"}`,
446+
children: formatUserFacingError(error),
349447
duration: 8000,
350448
});
351449
},
@@ -562,7 +660,7 @@ export default function Upload() {
562660
</div>
563661
<div className="upload__content">
564662
{formInputs.communities.map((community) => {
565-
const communityData = uploadData.results.find(
663+
const communityData = communities.results.find(
566664
(c) => c.identifier === community
567665
);
568666
const categories =
@@ -752,6 +850,32 @@ export default function Upload() {
752850
);
753851
}
754852

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

@@ -774,6 +898,9 @@ function formatBytes(bytes: number, decimals = 2) {
774898
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
775899
}
776900

901+
/**
902+
* Displays the submission success summary once the package metadata API responds.
903+
*/
777904
const SubmissionResult = (props: {
778905
submissionStatusResult: PackageSubmissionResult;
779906
}) => {

0 commit comments

Comments
 (0)