diff --git a/apps/cyberstorm-remix/app/p/team/Team.tsx b/apps/cyberstorm-remix/app/p/team/Team.tsx index 4fef9da6e..7d01ea5bb 100644 --- a/apps/cyberstorm-remix/app/p/team/Team.tsx +++ b/apps/cyberstorm-remix/app/p/team/Team.tsx @@ -1,60 +1,76 @@ -import { - getPublicEnvVariables, - getSessionTools, -} from "cyberstorm/security/publicEnvVariables"; -import { useLoaderData, useOutletContext } from "react-router"; +import { Await, useLoaderData, useOutletContext } from "react-router"; +import "./Team.css"; import { PackageSearch } from "~/commonComponents/PackageSearch/PackageSearch"; -import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; - -import { DapperTs } from "@thunderstore/dapper-ts"; - import { PackageOrderOptions } from "../../commonComponents/PackageSearch/components/PackageOrder"; import { type OutletContextShape } from "../../root"; +import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; import type { Route } from "./+types/Team"; -import "./Team.css"; +import { throwUserFacingPayloadResponse } from "cyberstorm/utils/errors/userFacingErrorResponse"; +import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError"; +import { createNotFoundMapping } from "cyberstorm/utils/errors/loaderMappings"; +import { SkeletonBox } from "@thunderstore/cyberstorm"; +import { Suspense } from "react"; +import { + NimbusAwaitErrorElement, + NimbusDefaultRouteErrorBoundary, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import { getLoaderTools } from "cyberstorm/utils/getLoaderTools"; +import { parseIntegerSearchParam } from "cyberstorm/utils/searchParamsUtils"; + +const teamNotFoundMappings = [ + createNotFoundMapping( + "Team not found.", + "We could not find the requested team.", + 404 + ), +]; export async function loader({ params, request }: Route.LoaderArgs) { if (params.communityId && params.namespaceId) { - const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); - const dapper = new DapperTs(() => { - return { - apiHost: publicEnvVariables.VITE_API_URL, - sessionId: undefined, - }; - }); + const { dapper } = getLoaderTools(); const searchParams = new URL(request.url).searchParams; const ordering = searchParams.get("ordering") ?? PackageOrderOptions.Updated; - const page = searchParams.get("page"); + const page = parseIntegerSearchParam(searchParams.get("page")); const search = searchParams.get("search"); const includedCategories = searchParams.get("includedCategories"); const excludedCategories = searchParams.get("excludedCategories"); const section = searchParams.get("section"); const nsfw = searchParams.get("nsfw"); const deprecated = searchParams.get("deprecated"); - const filters = await dapper.getCommunityFilters(params.communityId); - - return { - teamId: params.namespaceId, - filters: filters, - listings: await dapper.getPackageListings( + try { + const filters = await dapper.getCommunityFilters(params.communityId); + const listings = await dapper.getPackageListings( { kind: "namespace", communityId: params.communityId, namespaceId: params.namespaceId, }, ordering ?? "", - page === null ? undefined : Number(page), + page, search ?? "", includedCategories?.split(",") ?? undefined, excludedCategories?.split(",") ?? undefined, section ? (section === "all" ? "" : section) : "", - nsfw === "true" ? true : false, - deprecated === "true" ? true : false - ), - }; + nsfw === "true", + deprecated === "true" + ); + const dataPromise = Promise.all([filters, listings]); + + return { + teamId: params.namespaceId, + filtersAndListings: await dataPromise, + }; + } catch (error) { + handleLoaderError(error, { mappings: teamNotFoundMappings }); + } } - throw new Response("Community not found", { status: 404 }); + throwUserFacingPayloadResponse({ + headline: "Community not found.", + description: "We could not find the requested community.", + category: "not_found", + status: 404, + }); } export async function clientLoader({ @@ -62,70 +78,88 @@ export async function clientLoader({ params, }: Route.ClientLoaderArgs) { if (params.communityId && params.namespaceId) { - const tools = getSessionTools(); - const dapper = new DapperTs(() => { - return { - apiHost: tools?.getConfig().apiHost, - sessionId: tools?.getConfig().sessionId, - }; - }); + const { dapper } = getLoaderTools(); const searchParams = new URL(request.url).searchParams; const ordering = searchParams.get("ordering") ?? PackageOrderOptions.Updated; - const page = searchParams.get("page"); + const page = parseIntegerSearchParam(searchParams.get("page")); const search = searchParams.get("search"); const includedCategories = searchParams.get("includedCategories"); const excludedCategories = searchParams.get("excludedCategories"); const section = searchParams.get("section"); const nsfw = searchParams.get("nsfw"); const deprecated = searchParams.get("deprecated"); - const filters = dapper.getCommunityFilters(params.communityId); - return { - teamId: params.namespaceId, - filters: filters, - listings: dapper.getPackageListings( + const filters = dapper + .getCommunityFilters(params.communityId) + .catch((error) => + handleLoaderError(error, { mappings: teamNotFoundMappings }) + ); + const listings = dapper + .getPackageListings( { kind: "namespace", communityId: params.communityId, namespaceId: params.namespaceId, }, ordering ?? "", - page === null ? undefined : Number(page), + page, search ?? "", includedCategories?.split(",") ?? undefined, excludedCategories?.split(",") ?? undefined, section ? (section === "all" ? "" : section) : "", - nsfw === "true" ? true : false, - deprecated === "true" ? true : false - ), - }; + nsfw === "true", + deprecated === "true" + ) + .catch((error) => + handleLoaderError(error, { mappings: teamNotFoundMappings }) + ); + const dataPromise = Promise.all([filters, listings]); + + return { teamId: params.namespaceId, filtersAndListings: dataPromise }; } - throw new Response("Community not found", { status: 404 }); + throwUserFacingPayloadResponse({ + headline: "Community not found.", + description: "We could not find the requested community.", + category: "not_found", + status: 404, + }); } +/** + * Displays the team package listing and delegates streaming data to PackageSearch. + */ export default function Team() { - const { filters, listings, teamId } = useLoaderData< - typeof loader | typeof clientLoader - >(); + const data = useLoaderData(); const outletContext = useOutletContext() as OutletContextShape; return ( - <> -
- - Mods uploaded by {teamId} - - <> - - -
- +
+ + Mods uploaded by {data.teamId} + + }> + } + > + {([filters, listings]) => { + return ( + + ); + }} + + +
); } + +export function ErrorBoundary() { + return ; +} diff --git a/apps/cyberstorm-remix/app/settings/teams/Teams.tsx b/apps/cyberstorm-remix/app/settings/teams/Teams.tsx index 294278851..af5802034 100644 --- a/apps/cyberstorm-remix/app/settings/teams/Teams.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/Teams.tsx @@ -1,6 +1,11 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; +import { + NimbusErrorBoundary, + NimbusErrorBoundaryFallback, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import type { NimbusErrorBoundaryFallbackProps } from "cyberstorm/utils/errors/NimbusErrorBoundary"; import { useReducer, useState } from "react"; import type { MetaFunction } from "react-router"; import { useOutletContext, useRevalidator } from "react-router"; @@ -21,6 +26,7 @@ import { type RequestConfig, type TeamCreateRequestData, UserFacingError, + formatUserFacingError, teamCreate, } from "@thunderstore/thunderstore-api"; import { @@ -66,7 +72,10 @@ export default function Teams() { const currentUser = outletContext.currentUser; return ( - <> + reset()} + > Teams @@ -81,7 +90,6 @@ export default function Teams() { {currentUser?.teams_full?.length ? ( Teams} - // csModifiers={["alignLastColumnRight"]} headers={[ { value: "Team Name", disableSort: false }, { value: "Role", disableSort: false }, @@ -125,7 +133,28 @@ export default function Teams() { - + + ); +} + +/** + * Presents fallback messaging when the teams settings view crashes. + */ +function TeamsSettingsFallback(props: NimbusErrorBoundaryFallbackProps) { + const { + title = "Teams failed to load", + description = "Reload the teams tab or return to settings.", + retryLabel = "Reload", + ...rest + } = props; + + return ( + ); } @@ -179,7 +208,7 @@ function CreateTeamForm(props: { config: () => RequestConfig }) { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: formatUserFacingError(error), duration: 8000, }); }, diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx index 390db4800..cff63cece 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx @@ -1,6 +1,6 @@ -import { type OutletContextShape } from "app/root"; -import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders"; -import { Suspense } from "react"; +import { faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Suspense, useReducer, useState } from "react"; import { Await, useLoaderData, @@ -8,10 +8,42 @@ import { useRevalidator, } from "react-router"; -import { MemberAddForm } from "./MemberAddForm"; +import { + Modal, + NewButton, + NewIcon, + NewSelect, + NewTextInput, + SkeletonBox, + useToast, +} from "@thunderstore/cyberstorm"; +import { + type RequestConfig, + teamAddMember, + type TeamAddMemberRequestData, + teamRemoveMember, + UserFacingError, + formatUserFacingError, +} from "@thunderstore/thunderstore-api"; +import { ApiAction } from "@thunderstore/ts-api-react-actions"; + +import { type OutletContextShape } from "app/root"; +import { makeTeamSettingsTabLoader } from "cyberstorm/utils/getLoaderTools"; +import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; import "./Members.css"; +import type { DapperTs } from "@thunderstore/dapper-ts"; +import { + NimbusAwaitErrorElement, + NimbusDefaultRouteErrorBoundary, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import { MemberAddForm } from "./MemberAddForm"; import { MembersTable } from "./MembersTable"; +const roleOptions = [ + { label: "Owner", value: "owner" }, + { label: "Member", value: "member" }, +]; + export const clientLoader = makeTeamSettingsTabLoader( async (dapper, teamName) => ({ members: dapper.getTeamMembers(teamName), @@ -21,6 +53,35 @@ export const clientLoader = makeTeamSettingsTabLoader( export default function Members() { const { teamName, members } = useLoaderData(); const outletContext = useOutletContext() as OutletContextShape; + return ( + }> + }> + {(result) => ( + + )} + + + ); +} + +interface MembersContentProps { + teamName: string; + members: Awaited>; + outletContext: OutletContextShape; +} + +/** + * Displays the team members table once the loader promise settles. + */ +function MembersContent({ + teamName, + members, + outletContext, +}: MembersContentProps) { const revalidator = useRevalidator(); async function teamMemberRevalidate() { @@ -57,3 +118,231 @@ export default function Members() { ); } + +/** + * Displays a table placeholder while members load on the client. + */ +function MembersSkeleton() { + return ( +
+ +
+ ); +} + +export function ErrorBoundary() { + return ; +} + +function AddTeamMemberForm(props: { + teamName: string; + updateTrigger: () => Promise; + config: () => RequestConfig; +}) { + const toast = useToast(); + const [open, setOpen] = useState(false); + + function formFieldUpdateAction( + state: TeamAddMemberRequestData, + action: { + field: keyof TeamAddMemberRequestData; + value: TeamAddMemberRequestData[keyof TeamAddMemberRequestData]; + } + ) { + return { + ...state, + [action.field]: action.value, + }; + } + + const [formInputs, updateFormFieldState] = useReducer(formFieldUpdateAction, { + username: "", + role: "member", + }); + + type SubmitorOutput = Awaited>; + + async function submitor(data: typeof formInputs): Promise { + return await teamAddMember({ + config: props.config, + params: { team_name: props.teamName }, + queryParams: {}, + data: { username: data.username, role: data.role }, + }); + } + + type InputErrors = { + [key in keyof typeof formInputs]?: string | string[]; + }; + + const strongForm = useStrongForm< + typeof formInputs, + TeamAddMemberRequestData, + Error, + SubmitorOutput, + UserFacingError, + InputErrors + >({ + inputs: formInputs, + submitor, + onSubmitSuccess: () => { + props.updateTrigger(); + toast.addToast({ + csVariant: "success", + children: `Team member added`, + duration: 4000, + }); + setOpen(false); + }, + onSubmitError: (error) => { + toast.addToast({ + csVariant: "danger", + children: formatUserFacingError(error), + duration: 8000, + }); + }, + }); + + return ( + + Add Member + + + + + } + > + +
+ Enter the username of the user you wish to add to the team{" "} + {props.teamName}. +
+
+
+ + { + updateFormFieldState({ + field: "username", + value: e.target.value, + }); + }} + rootClasses="add-member-form__username-input" + id="username" + /> +
+
+ + { + updateFormFieldState({ field: "role", value: value }); + }} + id="role" + /> +
+
+
+ + + Add member + + +
+ ); +} + +AddTeamMemberForm.displayName = "AddTeamMemberForm"; + +function RemoveTeamMemberForm(props: { + indexKey?: string; + userName: string; + teamName: string; + updateTrigger: () => Promise; + config: () => RequestConfig; +}) { + const toast = useToast(); + const [open, setOpen] = useState(false); + + const kickMemberAction = ApiAction({ + endpoint: teamRemoveMember, + onSubmitSuccess: () => { + void props.updateTrigger(); + setOpen(false); + toast.addToast({ + csVariant: "success", + children: `Team member removed`, + duration: 4000, + }); + }, + onSubmitError: (error) => { + toast.addToast({ + csVariant: "danger", + children: formatUserFacingError(error), + duration: 8000, + }); + }, + }); + + return ( + + + + + Kick + + } + > + +
+ You are about to kick member{" "} + + {props.userName} + + . +
+
+ + + Cancel + + + kickMemberAction({ + config: props.config, + params: { team_name: props.teamName, username: props.userName }, + queryParams: {}, + data: {}, + }) + } + > + Kick member + + +
+ ); +} + +RemoveTeamMemberForm.displayName = "RemoveTeamMemberForm"; diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx index 61c8c053e..63be7cbf4 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx @@ -1,6 +1,10 @@ import { type OutletContextShape } from "app/root"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders"; +import { + NimbusAwaitErrorElement, + NimbusDefaultRouteErrorBoundary, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; import { Suspense, useReducer } from "react"; import { Await, @@ -9,11 +13,17 @@ import { useRevalidator, } from "react-router"; -import { NewButton, NewTextInput, useToast } from "@thunderstore/cyberstorm"; +import { + NewButton, + NewTextInput, + SkeletonBox, + useToast, +} from "@thunderstore/cyberstorm"; import { type TeamDetails, type TeamDetailsEditRequestData, UserFacingError, + formatUserFacingError, teamDetailsEdit, } from "@thunderstore/thunderstore-api"; @@ -47,6 +57,27 @@ export default function Profile() { function ProfileForm(props: { team: TeamDetails }) { const { team } = props; const outletContext = useOutletContext() as OutletContextShape; + + return ( + }> + }> + {(result) => ( + + )} + + + ); +} + +interface ProfileContentProps { + team: TeamDetails; + outletContext: OutletContextShape; +} + +/** + * Renders the team profile editing form once Suspense resolves the team data. + */ +function ProfileContent({ team, outletContext }: ProfileContentProps) { const revalidator = useRevalidator(); const toast = useToast(); @@ -103,7 +134,7 @@ function ProfileForm(props: { team: TeamDetails }) { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: formatUserFacingError(error), duration: 8000, }); }, @@ -141,3 +172,18 @@ function ProfileForm(props: { team: TeamDetails }) { } ProfileForm.displayName = "ProfileForm"; + +/** + * Displays a minimal skeleton while team profile data loads. + */ +function ProfileSkeleton() { + return ( +
+ +
+ ); +} + +export function ErrorBoundary() { + return ; +} diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx index a0a60f207..93aeb1a22 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx @@ -3,6 +3,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { type OutletContextShape } from "app/root"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders"; +import { + NimbusAwaitErrorElement, + NimbusDefaultRouteErrorBoundary, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; import { isTeamOwner } from "cyberstorm/utils/permissions"; import { Suspense, useReducer, useState } from "react"; import { @@ -19,7 +23,9 @@ import { NewButton, NewIcon, NewTextInput, + SkeletonBox, } from "@thunderstore/cyberstorm"; +import type { DapperTs } from "@thunderstore/dapper-ts"; import { type TeamServiceAccountAddRequestData, UserFacingError, @@ -37,6 +43,39 @@ export const clientLoader = makeTeamSettingsTabLoader( export default function ServiceAccounts() { const { teamName, serviceAccounts } = useLoaderData(); + const outletContext = useOutletContext() as OutletContextShape; + + return ( + }> + } + > + {(result) => ( + + )} + + + ); +} + +interface ServiceAccountsContentProps { + teamName: string; + serviceAccounts: Awaited>; + outletContext: OutletContextShape; +} + +/** + * Renders the service accounts table after Suspense resolves the data. + */ +function ServiceAccountsContent({ + teamName, + serviceAccounts, +}: ServiceAccountsContentProps) { const revalidator = useRevalidator(); async function serviceAccountRevalidate() { @@ -74,6 +113,21 @@ export default function ServiceAccounts() { ); } +/** + * Displays a placeholder skeleton while service accounts load. + */ +function ServiceAccountsSkeleton() { + return ( +
+ +
+ ); +} + +export function ErrorBoundary() { + return ; +} + function AddServiceAccountForm(props: { teamName: string; serviceAccountRevalidate: () => Promise; @@ -142,7 +196,7 @@ function AddServiceAccountForm(props: { >({ inputs: formInputs, submitor, - onSubmitSuccess: (result) => { + onSubmitSuccess: (result: SubmitorOutput) => { onSuccess(result); setError(null); // Refresh the service accounts list to show the newly created account diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx index 7909b1f02..b1247084d 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx @@ -25,6 +25,7 @@ import { type RequestConfig, type TeamDisbandRequestData, UserFacingError, + formatUserFacingError, teamDisband, teamRemoveMember, } from "@thunderstore/thunderstore-api"; @@ -153,7 +154,7 @@ function LeaveTeamForm(props: { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: formatUserFacingError(error), duration: 8000, }); }, @@ -275,7 +276,7 @@ function DisbandTeamForm(props: { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: formatUserFacingError(error), duration: 8000, }); }, diff --git a/apps/cyberstorm-remix/app/settings/teams/team/teamSettings.tsx b/apps/cyberstorm-remix/app/settings/teams/team/teamSettings.tsx index 1ecd81496..972a5df97 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/teamSettings.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/teamSettings.tsx @@ -1,29 +1,39 @@ import { PageHeader } from "app/commonComponents/PageHeader/PageHeader"; import { type OutletContextShape } from "app/root"; -import { - type MetaFunction, - Outlet, - useLocation, - useOutletContext, - useParams, -} from "react-router"; +import { NimbusDefaultRouteErrorBoundary } from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import { Outlet, useLocation, useOutletContext, useParams } from "react-router"; import { NewLink, Tabs } from "@thunderstore/cyberstorm"; import "./teamSettings.css"; -export const meta: MetaFunction = ({ params }) => { - return [ - { title: `Team ${params.namespaceId ?? ""} settings` }, - { name: "description", content: "Manage team related settings" }, - ]; -}; - -export default function Community() { - const teamName = useParams().namespaceId ?? ""; +export default function TeamSettingsRoute() { const location = useLocation(); const outletContext = useOutletContext() as OutletContextShape; + const teamName = useParams().namespaceId ?? ""; + return ( + + ); +} + +interface TeamSettingsContentProps { + teamName: string; + locationPathname: string; + outletContext: OutletContextShape; +} + +/** + * Displays the team settings tabs once loader data resolves on the client. + */ +function TeamSettingsContent({ + teamName, + outletContext, +}: TeamSettingsContentProps) { const parts = location.pathname.split("/"); const currentTab = parts.length === 4 ? parts[3] : "profile"; @@ -90,3 +100,7 @@ export default function Community() { ); } + +export function ErrorBoundary() { + return ; +}