diff --git a/apps/cyberstorm-remix/app/p/packageEdit.tsx b/apps/cyberstorm-remix/app/p/packageEdit.tsx index 518a36d8a..1612fecc3 100644 --- a/apps/cyberstorm-remix/app/p/packageEdit.tsx +++ b/apps/cyberstorm-remix/app/p/packageEdit.tsx @@ -1,13 +1,21 @@ import { faBan, faCheck } from "@fortawesome/pro-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - getPublicEnvVariables, - getSessionTools, -} from "cyberstorm/security/publicEnvVariables"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; -import { useReducer } from "react"; +import { + NimbusAwaitErrorElement, + NimbusDefaultRouteErrorBoundary, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError"; +import { throwUserFacingPayloadResponse } from "cyberstorm/utils/errors/userFacingErrorResponse"; +import { getLoaderTools } from "cyberstorm/utils/getLoaderTools"; +import { Suspense, useReducer } from "react"; import type { LoaderFunctionArgs, MetaFunction } from "react-router"; -import { useLoaderData, useOutletContext, useRevalidator } from "react-router"; +import { + Await, + useLoaderData, + useOutletContext, + useRevalidator, +} from "react-router"; import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; import { type OutletContextShape } from "~/root"; @@ -17,12 +25,12 @@ import { NewIcon, NewSelectSearch, NewTag, + SkeletonBox, formatToDisplayName, useToast, } from "@thunderstore/cyberstorm"; -import { DapperTs } from "@thunderstore/dapper-ts"; +import type { DapperTsInterface } from "@thunderstore/dapper-ts"; import { - ApiError, type PackageListingUpdateRequestData, UserFacingError, packageDeprecate, @@ -37,7 +45,7 @@ export const meta: MetaFunction = ({ data }) => { return [ { title: data - ? `${formatToDisplayName(data.listing.name)} - Edit package` + ? `${formatToDisplayName(data[0].name)} - Edit package` : "Edit package", }, ]; @@ -46,94 +54,155 @@ export const meta: MetaFunction = ({ data }) => { export async function loader({ params }: LoaderFunctionArgs) { if (params.communityId && params.namespaceId && params.packageId) { try { - const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); - const dapper = new DapperTs(() => { - return { - apiHost: publicEnvVariables.VITE_API_URL, - sessionId: undefined, - }; - }); - return { - community: await dapper.getCommunity(params.communityId), - communityFilters: await dapper.getCommunityFilters(params.communityId), - listing: await dapper.getPackageListingDetails( + const { dapper } = getLoaderTools(); + const resolvedData = await Promise.all([ + dapper.getCommunity(params.communityId), + dapper.getCommunityFilters(params.communityId), + dapper.getPackageListingDetails( params.communityId, params.namespaceId, params.packageId ), - team: await dapper.getTeamDetails(params.namespaceId), - filters: await dapper.getCommunityFilters(params.communityId), - permissions: undefined, - }; + dapper.getTeamDetails(params.namespaceId), + undefined, + ]); + + return resolvedData; } catch (error) { - if (error instanceof ApiError) { - throw new Response("Package not found", { status: 404 }); - } else { - // REMIX TODO: Add sentry - throw error; - } + handleLoaderError(error); } } - throw new Response("Package not found", { status: 404 }); + throwUserFacingPayloadResponse({ + headline: "Package not found.", + description: "We could not find the requested package.", + category: "not_found", + status: 404, + }); } // TODO: Needs to check if package is available for the logged in user export async function clientLoader({ params }: LoaderFunctionArgs) { if (params.communityId && params.namespaceId && params.packageId) { try { - const tools = getSessionTools(); - const dapper = new DapperTs(() => { - return { - apiHost: tools?.getConfig().apiHost, - sessionId: tools?.getConfig().sessionId, - }; - }); + const { dapper } = getLoaderTools(); - const permissions = await dapper.getPackagePermissions( + const permissionsPromise = dapper.getPackagePermissions( params.communityId, params.namespaceId, params.packageId ); + const permissions = await permissionsPromise; + if (!permissions?.permissions.can_manage) { - throw new Response("Unauthorized", { status: 403 }); + throwUserFacingPayloadResponse({ + headline: "You do not have permission to edit this package.", + description: "Sign in with a team member account to continue.", + category: "auth", + status: 403, + }); } - return { - community: await dapper.getCommunity(params.communityId), - communityFilters: await dapper.getCommunityFilters(params.communityId), - listing: await dapper.getPackageListingDetails( + const promises = Promise.all([ + dapper.getCommunity(params.communityId), + dapper.getCommunityFilters(params.communityId), + dapper.getPackageListingDetails( params.communityId, params.namespaceId, params.packageId ), - team: await dapper.getTeamDetails(params.namespaceId), - filters: await dapper.getCommunityFilters(params.communityId), - permissions: permissions, - }; + dapper.getTeamDetails(params.namespaceId), + permissionsPromise, + ]); + + return promises; } catch (error) { - if (error instanceof ApiError) { - throw new Response("Package not found", { status: 404 }); - } else { - throw error; - } + handleLoaderError(error); } } - throw new Response("Package not found", { status: 404 }); + throwUserFacingPayloadResponse({ + headline: "Package not found.", + description: "We could not find the requested package.", + category: "not_found", + status: 404, + }); } clientLoader.hydrate = true; +/** + * Renders the package edit page and defers loading states to Suspense/Await. + */ export default function PackageListing() { - const { community, listing, filters, permissions } = useLoaderData< - typeof loader | typeof clientLoader - >(); - + const loaderData = useLoaderData(); const outletContext = useOutletContext() as OutletContextShape; const config = outletContext.requestConfig; const toast = useToast(); const revalidator = useRevalidator(); + return ( + <> + + Edit package + +
+ }> + } + > + {(resolvedData) => { + const data = { + community: resolvedData[0], + filters: resolvedData[1], + listing: resolvedData[2], + team: resolvedData[3], + permissions: resolvedData[4], + }; + + return ( + + ); + }} + + +
+ + ); +} + +type PackageEditResolvedData = { + community: Awaited>; + listing: Awaited>; + filters: Awaited>; + permissions: + | Awaited> + | undefined; +}; + +type PackageEditContentProps = { + data: PackageEditResolvedData; + config: OutletContextShape["requestConfig"]; + toast: ReturnType; + revalidator: ReturnType; +}; + +/** + * Provides the interactive package edit form once all dependencies resolve. + */ +function PackageEditContent({ + data, + config, + toast, + revalidator, +}: PackageEditContentProps) { + const { community, listing, filters, permissions } = data; + const deprecateToggleAction = ApiAction({ endpoint: packageDeprecate, onSubmitSuccess: () => { @@ -149,7 +218,9 @@ export default function PackageListing() { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: error.description + ? `${error.headline} ${error.description}` + : error.headline, duration: 8000, }); }, @@ -168,7 +239,9 @@ export default function PackageListing() { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: error.description + ? `${error.headline} ${error.description}` + : error.headline, duration: 8000, }); }, @@ -188,7 +261,7 @@ export default function PackageListing() { } const [formInputs, updateFormFieldState] = useReducer(formFieldUpdateAction, { - categories: listing.categories.map((c) => c.slug), + categories: listing.categories.map((category) => category.slug), }); type SubmitorOutput = Awaited>; @@ -210,7 +283,7 @@ export default function PackageListing() { [key in keyof typeof formInputs]?: string | string[]; }; - const strongForm = useStrongForm< + const { submit: submitPackageUpdate } = useStrongForm< typeof formInputs, PackageListingUpdateRequestData, Error, @@ -230,177 +303,199 @@ export default function PackageListing() { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: error.description + ? `${error.headline} ${error.description}` + : error.headline, duration: 8000, }); }, }); return ( - <> - - Edit package - -
-
- {permissions?.permissions.can_unlist ? ( - <> -
-
-
Listed
-
- Control if the package is listed on Thunderstore. (in any - community) -
-
-
-
- - When you unlist the package, this page too will become - unavailable. - -
- - unlistAction({ - config: config, - params: { - community: community.identifier, - namespace: listing.namespace, - package: listing.name, - }, - queryParams: {}, - data: { unlist: "unlist" }, - useSession: true, - }) - } - csSize="medium" - csVariant="danger" - > - Unlist - -
-
-
- - ) : null} - {permissions?.permissions.can_manage_deprecation ? ( - <> -
-
-
Status
-
- Control the status of your package. -
-
-
-
- - - - - {listing.is_deprecated ? "Deprecated" : "Active"} - - - {listing.is_deprecated - ? "This package is marked as deprecated and is not listed on Thunderstore." - : "This package is marked as active and is listed on Thunderstore."} - -
- - deprecateToggleAction({ - config: config, - params: { - namespace: listing.namespace, - package: listing.name, - }, - queryParams: {}, - data: { deprecate: !listing.is_deprecated }, - useSession: true, - }) - } - csSize="medium" - csVariant={listing.is_deprecated ? "success" : "warning"} - > - {listing.is_deprecated ? "Undeprecate" : "Deprecate"} - -
-
-
- - ) : null} +
+ {permissions?.permissions.can_unlist ? ( + <>
-
Categories
+
Listed
- Select descriptive categories to help people discover your - package. + Control if the package is listed on Thunderstore. (in any + community)
- ({ - value: category.slug, - label: category.name, - }))} - onChange={(val) => { - updateFormFieldState({ - field: "categories", - value: val ? val.map((v) => v.value) : [], - }); - }} - value={formInputs.categories.map((categoryId) => ({ - value: categoryId, - label: - filters.package_categories.find( - (c) => c.slug === categoryId - )?.name || "", - }))} - /> +
+ + When you unlist the package, this page too will become + unavailable. + +
+ + unlistAction({ + config: config, + params: { + community: community.identifier, + namespace: listing.namespace, + package: listing.name, + }, + queryParams: {}, + data: { unlist: "unlist" }, + useSession: true, + }) + } + csSize="medium" + csVariant="danger" + > + Unlist +
+ + ) : null} + {permissions?.permissions.can_manage_deprecation ? ( + <>
-
Save changes
+
Status
- Your changes will take effect after hitting “Save”. + Control the status of your package.
-
- - Cancel - +
+
+ + + + + {listing.is_deprecated ? "Deprecated" : "Active"} + + + {listing.is_deprecated + ? "This package is marked as deprecated and is not listed on Thunderstore." + : "This package is marked as active and is listed on Thunderstore."} + +
{ - strongForm.submit(); - }} - rootClasses="package-edit__save-button" + onClick={() => + deprecateToggleAction({ + config: config, + params: { + namespace: listing.namespace, + package: listing.name, + }, + queryParams: {}, + data: { deprecate: !listing.is_deprecated }, + useSession: true, + }) + } + csSize="medium" + csVariant={listing.is_deprecated ? "success" : "warning"} > - Save changes + {listing.is_deprecated ? "Undeprecate" : "Deprecate"}
-
+
+ + ) : null} +
+
+
Categories
+
+ Select descriptive categories to help people discover your package. +
+
+
+ ({ + value: category.slug, + label: category.name, + }))} + onChange={(val) => { + updateFormFieldState({ + field: "categories", + value: val ? val.map((v) => v.value) : [], + }); + }} + value={formInputs.categories.map((categoryId) => ({ + value: categoryId, + label: + filters.package_categories.find( + (category) => category.slug === categoryId + )?.name || "", + }))} + /> +
- +
+
+
+
Save changes
+
+ Your changes will take effect after hitting “Save”. +
+
+
+ + Cancel + + { + submitPackageUpdate(); + }} + rootClasses="package-edit__save-button" + > + Save changes + +
+
+
); } + +/** + * Displays a basic skeleton layout while package edit data streams in. + */ +function PackageEditSkeleton() { + return ( +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ ); +} + +export function ErrorBoundary() { + return ; +}