diff --git a/packages/frontend/src/components/account-dialog.tsx b/packages/frontend/src/components/account-dialog.tsx index 07a4465..e66d7d6 100644 --- a/packages/frontend/src/components/account-dialog.tsx +++ b/packages/frontend/src/components/account-dialog.tsx @@ -10,10 +10,12 @@ import Icon from "@/components/ui/icon"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { UploadAvatar } from "@/components/upload-avatar"; -import { parseError, user } from "@/lib/server"; +import { useUpdateUser } from "@/lib/query/hooks"; +import { parseError } from "@/lib/server"; function AccountDialog({ trigger }: { trigger?: ReactNode }) { const { user: currentUser, setUser } = useAuthenticatedSession(); + const updateUser = useUpdateUser(); const [open, setOpen] = useState(false); const [name, setName] = useState(""); @@ -45,30 +47,29 @@ function AccountDialog({ trigger }: { trigger?: ReactNode }) { return; } - await user.update({ - name: name.trim(), - password: password.trim() || undefined, - avatarURL, - iconPreference, - onSuccess: (data) => { - setError(""); - setUser(data); - setPassword(""); - setOpen(false); + try { + const data = await updateUser.mutateAsync({ + name: name.trim(), + password: password.trim() || undefined, + avatarURL, + iconPreference, + }); + setError(""); + setUser(data); + setPassword(""); + setOpen(false); - toast.success(`Account updated successfully`, { - dismissible: false, - }); - }, - onError: (err) => { - const message = parseError(err); - setError(message); + toast.success("Account updated successfully", { + dismissible: false, + }); + } catch (err) { + const message = parseError(err as Error); + setError(message); - toast.error(`Error updating account: ${message}`, { - dismissible: false, - }); - }, - }); + toast.error(`Error updating account: ${message}`, { + dismissible: false, + }); + } }; return ( diff --git a/packages/frontend/src/components/add-member-dialog.tsx b/packages/frontend/src/components/add-member-dialog.tsx index 87149b9..dc17917 100644 --- a/packages/frontend/src/components/add-member-dialog.tsx +++ b/packages/frontend/src/components/add-member-dialog.tsx @@ -11,7 +11,8 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Field } from "@/components/ui/field"; -import { organisation, parseError, user } from "@/lib/server"; +import { useAddOrganisationMember } from "@/lib/query/hooks"; +import { parseError, user } from "@/lib/server"; export function AddMemberDialog({ organisationId, @@ -29,6 +30,7 @@ export function AddMemberDialog({ const [submitAttempted, setSubmitAttempted] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const addMember = useAddOrganisationMember(); const reset = () => { setUsername(""); @@ -60,56 +62,25 @@ export function AddMemberDialog({ setSubmitting(true); try { - let userId: number | null = null; - let userData: UserRecord; - await user.byUsername({ - username, - onSuccess: (data: UserRecord) => { - userData = data; - userId = data.id; - }, - onError: (err) => { - const message = parseError(err); - setError(message || "user not found"); - setSubmitting(false); - - toast.error(`Error adding member: ${message}`, { - dismissible: false, - }); - }, - }); - - if (!userId) { - return; + const userData: UserRecord = await user.byUsername(username); + const userId = userData.id; + await addMember.mutateAsync({ organisationId, userId, role: "member" }); + setOpen(false); + reset(); + try { + await onSuccess?.(userData); + } catch (actionErr) { + console.error(actionErr); } - - await organisation.addMember({ - organisationId, - userId, - role: "member", - onSuccess: async () => { - setOpen(false); - reset(); - try { - await onSuccess?.(userData); - } catch (actionErr) { - console.error(actionErr); - } - }, - onError: (err) => { - const message = parseError(err); - setError(message || "failed to add member"); - setSubmitting(false); - - toast.error(`Error adding member: ${message}`, { - dismissible: false, - }); - }, - }); } catch (err) { + const message = parseError(err as Error); console.error(err); - setError("failed to add member"); + setError(message || "failed to add member"); setSubmitting(false); + + toast.error(`Error adding member: ${message}`, { + dismissible: false, + }); } }; diff --git a/packages/frontend/src/components/organisation-modal.tsx b/packages/frontend/src/components/organisation-modal.tsx index 3f80e29..356814d 100644 --- a/packages/frontend/src/components/organisation-modal.tsx +++ b/packages/frontend/src/components/organisation-modal.tsx @@ -18,7 +18,8 @@ import { } from "@/components/ui/dialog"; import { Field } from "@/components/ui/field"; import { Label } from "@/components/ui/label"; -import { organisation, parseError } from "@/lib/server"; +import { useCreateOrganisation, useUpdateOrganisation } from "@/lib/query/hooks"; +import { parseError } from "@/lib/server"; import { cn } from "@/lib/utils"; const slugify = (value: string) => @@ -47,6 +48,8 @@ export function OrganisationModal({ onOpenChange?: (open: boolean) => void; }) { const { user } = useAuthenticatedSession(); + const createOrganisation = useCreateOrganisation(); + const updateOrganisation = useUpdateOrganisation(); const isControlled = controlledOpen !== undefined; const [internalOpen, setInternalOpen] = useState(false); @@ -106,62 +109,47 @@ export function OrganisationModal({ setSubmitting(true); try { if (isEdit && existingOrganisation) { - await organisation.update({ - organisationId: existingOrganisation.id, + const data = await updateOrganisation.mutateAsync({ + id: existingOrganisation.id, name, slug, description, - onSuccess: async (data) => { - setOpen(false); - reset(); - toast.success("Organisation updated"); - try { - await completeAction?.(data); - } catch (actionErr) { - console.error(actionErr); - } - }, - onError: async (err) => { - const message = parseError(err); - setError(message || "failed to update organisation"); - setSubmitting(false); - try { - await errorAction?.(message || "failed to update organisation"); - } catch (actionErr) { - console.error(actionErr); - } - }, }); + setOpen(false); + reset(); + toast.success("Organisation updated"); + try { + await completeAction?.(data); + } catch (actionErr) { + console.error(actionErr); + } } else { - await organisation.create({ + const data = await createOrganisation.mutateAsync({ name, slug, description, - onSuccess: async (data) => { - setOpen(false); - reset(); - try { - await completeAction?.(data); - } catch (actionErr) { - console.error(actionErr); - } - }, - onError: async (err) => { - const message = parseError(err); - setError(message || "failed to create organisation"); - setSubmitting(false); - try { - await errorAction?.(message || "failed to create organisation"); - } catch (actionErr) { - console.error(actionErr); - } - }, }); + setOpen(false); + reset(); + toast.success(`Created Organisation ${data.name}`, { + dismissible: false, + }); + try { + await completeAction?.(data); + } catch (actionErr) { + console.error(actionErr); + } } } catch (err) { + const message = parseError(err as Error); console.error(err); - setError(`failed to ${isEdit ? "update" : "create"} organisation`); + setError(message || `failed to ${isEdit ? "update" : "create"} organisation`); setSubmitting(false); + try { + await errorAction?.(message || `failed to ${isEdit ? "update" : "create"} organisation`); + } catch (actionErr) { + console.error(actionErr); + } } }; diff --git a/packages/frontend/src/components/organisations-dialog.tsx b/packages/frontend/src/components/organisations-dialog.tsx index dcfa912..2b5201a 100644 --- a/packages/frontend/src/components/organisations-dialog.tsx +++ b/packages/frontend/src/components/organisations-dialog.tsx @@ -1,19 +1,13 @@ -import { - DEFAULT_STATUS_COLOUR, - ISSUE_STATUS_MAX_LENGTH, - type OrganisationMemberResponse, - type OrganisationResponse, - type ProjectRecord, - type ProjectResponse, - type SprintRecord, -} from "@sprint/shared"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { DEFAULT_STATUS_COLOUR, ISSUE_STATUS_MAX_LENGTH, type SprintRecord } from "@sprint/shared"; +import { useQueryClient } from "@tanstack/react-query"; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { AddMemberDialog } from "@/components/add-member-dialog"; import { OrganisationModal } from "@/components/organisation-modal"; import { OrganisationSelect } from "@/components/organisation-select"; import { ProjectModal } from "@/components/project-modal"; import { ProjectSelect } from "@/components/project-select"; +import { useSelection } from "@/components/selection-provider"; import { useAuthenticatedSession } from "@/components/session-provider"; import SmallSprintDisplay from "@/components/small-sprint-display"; import SmallUserDisplay from "@/components/small-user-display"; @@ -34,39 +28,83 @@ import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { issue, organisation, project, sprint } from "@/lib/server"; +import { + useDeleteOrganisation, + useDeleteProject, + useDeleteSprint, + useOrganisationMembers, + useOrganisations, + useProjects, + useRemoveOrganisationMember, + useReplaceIssueStatus, + useSprints, + useUpdateOrganisation, + useUpdateOrganisationMemberRole, +} from "@/lib/query/hooks"; +import { queryKeys } from "@/lib/query/keys"; +import { issue } from "@/lib/server"; import { capitalise } from "@/lib/utils"; -function OrganisationsDialog({ - trigger, - organisations, - selectedOrganisation, - setSelectedOrganisation, - refetchOrganisations, - projects, - selectedProject, - sprints, - onSelectedProjectChange, - onCreateProject, - onCreateSprint, -}: { - trigger?: ReactNode; - organisations: OrganisationResponse[]; - selectedOrganisation: OrganisationResponse | null; - setSelectedOrganisation: (organisation: OrganisationResponse | null) => void; - refetchOrganisations: (options?: { selectOrganisationId?: number }) => Promise; - projects: ProjectResponse[]; - selectedProject: ProjectResponse | null; - sprints: SprintRecord[]; - onSelectedProjectChange: (project: ProjectResponse | null) => void; - onCreateProject: (project: ProjectRecord) => void | Promise; - onCreateSprint: (sprint: SprintRecord) => void | Promise; -}) { +function OrganisationsDialog({ trigger }: { trigger?: ReactNode }) { const { user } = useAuthenticatedSession(); + const queryClient = useQueryClient(); + const { selectedOrganisationId, selectedProjectId } = useSelection(); + const { data: organisationsData = [] } = useOrganisations(); + const { data: projectsData = [] } = useProjects(selectedOrganisationId); + const { data: sprints = [] } = useSprints(selectedProjectId); + const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId); + const updateOrganisation = useUpdateOrganisation(); + const updateMemberRole = useUpdateOrganisationMemberRole(); + const removeMember = useRemoveOrganisationMember(); + const deleteOrganisation = useDeleteOrganisation(); + const deleteProject = useDeleteProject(); + const deleteSprint = useDeleteSprint(); + const replaceIssueStatus = useReplaceIssueStatus(); + + const organisations = useMemo( + () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), + [organisationsData], + ); + const projects = useMemo( + () => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)), + [projectsData], + ); + const selectedOrganisation = useMemo( + () => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null, + [organisations, selectedOrganisationId], + ); + const selectedProject = useMemo( + () => projects.find((proj) => proj.Project.id === selectedProjectId) ?? null, + [projects, selectedProjectId], + ); + + const invalidateOrganisations = () => + queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); + const invalidateProjects = () => + queryClient.invalidateQueries({ + queryKey: queryKeys.projects.byOrganisation(selectedOrganisationId ?? 0), + }); + const invalidateMembers = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: queryKeys.organisations.members(selectedOrganisationId ?? 0), + }), + [queryClient, selectedOrganisationId], + ); + const invalidateSprints = () => + queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(selectedProjectId ?? 0) }); + const members = useMemo(() => { + const roleOrder: Record = { owner: 0, admin: 1, member: 2 }; + return [...membersData].sort((a, b) => { + const roleA = roleOrder[a.OrganisationMember.role] ?? 3; + const roleB = roleOrder[b.OrganisationMember.role] ?? 3; + if (roleA !== roleB) return roleA - roleB; + return a.User.name.localeCompare(b.User.name); + }); + }, [membersData]); const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState("info"); - const [members, setMembers] = useState([]); const [statuses, setStatuses] = useState>({}); const [isCreatingStatus, setIsCreatingStatus] = useState(false); @@ -123,37 +161,6 @@ function OrganisationsDialog({ return start <= today && today <= end; }; - const refetchMembers = useCallback(async () => { - if (!selectedOrganisation) return; - try { - await organisation.members({ - organisationId: selectedOrganisation.Organisation.id, - onSuccess: (data) => { - const members = data as OrganisationMemberResponse[]; - const roleOrder: Record = { owner: 0, admin: 1, member: 2 }; - members.sort((a, b) => { - const roleA = roleOrder[a.OrganisationMember.role] ?? 3; - const roleB = roleOrder[b.OrganisationMember.role] ?? 3; - if (roleA !== roleB) return roleA - roleB; - return a.User.name.localeCompare(b.User.name); - }); - setMembers(members); - }, - onError: (error) => { - console.error(error); - setMembers([]); - - toast.error(`Error fetching members: ${error}`, { - dismissible: false, - }); - }, - }); - } catch (err) { - console.error("error fetching members:", err); - setMembers([]); - } - }, [selectedOrganisation]); - const handleRoleChange = (memberUserId: number, memberName: string, currentRole: string) => { if (!selectedOrganisation) return; const action = currentRole === "admin" ? "demote" : "promote"; @@ -167,32 +174,23 @@ function OrganisationsDialog({ variant: action === "demote" ? "destructive" : "default", onConfirm: async () => { try { - await organisation.updateMemberRole({ + await updateMemberRole.mutateAsync({ organisationId: selectedOrganisation.Organisation.id, userId: memberUserId, role: newRole, - onSuccess: () => { - closeConfirmDialog(); - - toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, { - dismissible: false, - }); - - void refetchMembers(); - }, - onError: (error) => { - console.error(error); - - toast.error( - `Error ${action.slice(0, -1)}ing ${memberName} to ${newRole}: ${error}`, - { - dismissible: false, - }, - ); - }, + }); + closeConfirmDialog(); + toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, { + dismissible: false, }); } catch (err) { console.error(err); + toast.error( + `Error ${action.slice(0, -1)}ing ${memberName} to ${newRole}: ${String(err)}`, + { + dismissible: false, + }, + ); } }, }); @@ -213,34 +211,25 @@ function OrganisationsDialog({ variant: "destructive", onConfirm: async () => { try { - await organisation.removeMember({ + await removeMember.mutateAsync({ organisationId: selectedOrganisation.Organisation.id, userId: memberUserId, - onSuccess: () => { - closeConfirmDialog(); - - toast.success( - `Removed ${memberName} from ${selectedOrganisation.Organisation.name} successfully`, - { - dismissible: false, - }, - ); - - void refetchMembers(); - }, - onError: (error) => { - console.error(error); - - toast.error( - `Error removing member from ${selectedOrganisation.Organisation.name}: ${error}`, - { - dismissible: false, - }, - ); - }, }); + closeConfirmDialog(); + toast.success( + `Removed ${memberName} from ${selectedOrganisation.Organisation.name} successfully`, + { + dismissible: false, + }, + ); } catch (err) { console.error(err); + toast.error( + `Error removing member from ${selectedOrganisation.Organisation.name}: ${String(err)}`, + { + dismissible: false, + }, + ); } }, }); @@ -261,88 +250,79 @@ function OrganisationsDialog({ if (!selectedOrganisation) return; try { - await organisation.update({ - organisationId: selectedOrganisation.Organisation.id, + await updateOrganisation.mutateAsync({ + id: selectedOrganisation.Organisation.id, statuses: newStatuses, - onSuccess: () => { - setStatuses(newStatuses); - if (statusAdded) { - toast.success( - <> - Created {" "} - status successfully - , - { - dismissible: false, - }, - ); - } else if (statusRemoved) { - toast.success( - <> - Removed{" "} - status - successfully - , - { - dismissible: false, - }, - ); - } else if (statusMoved) { - toast.success( - <> - Moved from - position {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "} - successfully - , - { - dismissible: false, - }, - ); - } - void refetchOrganisations(); - }, - onError: (error) => { - console.error("error updating statuses:", error); - - if (statusAdded) { - toast.error( - <> - Error adding{" "} - to{" "} - {selectedOrganisation.Organisation.name}: {error} - , - { - dismissible: false, - }, - ); - } else if (statusRemoved) { - toast.error( - <> - Error removing{" "} - from{" "} - {selectedOrganisation.Organisation.name}: {error} - , - { - dismissible: false, - }, - ); - } else if (statusMoved) { - toast.error( - <> - Error moving{" "} - - from position {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "} - {selectedOrganisation.Organisation.name}: {error} - , - { - dismissible: false, - }, - ); - } - }, }); + setStatuses(newStatuses); + if (statusAdded) { + toast.success( + <> + Created status + successfully + , + { + dismissible: false, + }, + ); + } else if (statusRemoved) { + toast.success( + <> + Removed status + successfully + , + { + dismissible: false, + }, + ); + } else if (statusMoved) { + toast.success( + <> + Moved from + position + {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1} successfully + , + { + dismissible: false, + }, + ); + } + await invalidateOrganisations(); } catch (err) { console.error("error updating statuses:", err); + if (statusAdded) { + toast.error( + <> + Error adding to{" "} + {selectedOrganisation.Organisation.name}: {String(err)} + , + { + dismissible: false, + }, + ); + } else if (statusRemoved) { + toast.error( + <> + Error removing {" "} + from {selectedOrganisation.Organisation.name}: {String(err)} + , + { + dismissible: false, + }, + ); + } else if (statusMoved) { + toast.error( + <> + Error moving from + position + {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "} + {selectedOrganisation.Organisation.name}: {String(err)} + , + { + dismissible: false, + }, + ); + } } }; @@ -374,41 +354,32 @@ function OrganisationsDialog({ const handleRemoveStatusClick = async (status: string) => { if (Object.keys(statuses).length <= 1 || !selectedOrganisation) return; try { - await issue.statusCount({ - organisationId: selectedOrganisation.Organisation.id, - status, - onSuccess: (data) => { - const count = (data as { count?: number }).count ?? 0; - if (count > 0) { - setStatusToRemove(status); - setIssuesUsingStatus(count); - const remaining = Object.keys(statuses).filter((s) => s !== status); - setReassignToStatus(remaining[0] || ""); - return; - } + const data = await issue.statusCount(selectedOrganisation.Organisation.id, status); + const count = data.find((item) => item.status === status)?.count ?? 0; + if (count > 0) { + setStatusToRemove(status); + setIssuesUsingStatus(count); + const remaining = Object.keys(statuses).filter((item) => item !== status); + setReassignToStatus(remaining[0] || ""); + return; + } - const nextStatuses = Object.keys(statuses).filter((s) => s !== status); - void updateStatuses( - Object.fromEntries(nextStatuses.map((statusKey) => [statusKey, statuses[statusKey]])), - { name: status, colour: statuses[status] }, - ); - }, - onError: (error) => { - console.error("error checking status usage:", error); - - toast.error( - <> - Error checking status usage for{" "} - : {error} - , - { - dismissible: false, - }, - ); - }, - }); + const nextStatuses = Object.keys(statuses).filter((item) => item !== status); + await updateStatuses( + Object.fromEntries(nextStatuses.map((statusKey) => [statusKey, statuses[statusKey]])), + { name: status, colour: statuses[status] }, + ); } catch (err) { console.error("error checking status usage:", err); + toast.error( + <> + Error checking status usage for :{" "} + {String(err)} + , + { + dismissible: false, + }, + ); } }; @@ -435,40 +406,38 @@ function OrganisationsDialog({ const confirmRemoveStatus = async () => { if (!statusToRemove || !reassignToStatus || !selectedOrganisation) return; - await issue.replaceStatus({ - organisationId: selectedOrganisation.Organisation.id, - oldStatus: statusToRemove, - newStatus: reassignToStatus, - onSuccess: async () => { - const newStatuses = Object.keys(statuses).filter((s) => s !== statusToRemove); - await updateStatuses( - Object.fromEntries(newStatuses.map((status) => [status, statuses[status]])), - { name: statusToRemove, colour: statuses[statusToRemove] }, - ); - setStatusToRemove(null); - setReassignToStatus(""); - }, - onError: (error) => { - console.error("error replacing status:", error); - - toast.error( - <> - Error removing {" "} - from - {selectedOrganisation.Organisation.name}: {error}{" "} - , - { - dismissible: false, - }, - ); - }, - }); + try { + await replaceIssueStatus.mutateAsync({ + organisationId: selectedOrganisation.Organisation.id, + oldStatus: statusToRemove, + newStatus: reassignToStatus, + }); + const newStatuses = Object.keys(statuses).filter((item) => item !== statusToRemove); + await updateStatuses( + Object.fromEntries(newStatuses.map((status) => [status, statuses[status]])), + { name: statusToRemove, colour: statuses[statusToRemove] }, + ); + setStatusToRemove(null); + setReassignToStatus(""); + } catch (error) { + console.error("error replacing status:", error); + toast.error( + <> + Error removing {" "} + from + {selectedOrganisation.Organisation.name}: {String(error)} + , + { + dismissible: false, + }, + ); + } }; useEffect(() => { - if (!open) return; - void refetchMembers(); - }, [open, refetchMembers]); + if (!open || !selectedOrganisationId) return; + void invalidateMembers(); + }, [open, invalidateMembers, selectedOrganisationId]); return ( @@ -490,21 +459,6 @@ function OrganisationsDialog({
{ - setSelectedOrganisation(org); - localStorage.setItem( - "selectedOrganisationId", - `${org?.Organisation.id}`, - ); - }} - onCreateOrganisation={async (org) => { - toast.success(`Created Organisation ${org.name}`, { - dismissible: false, - }); - await refetchOrganisations({ selectOrganisationId: org.id }); - }} contentClass={ "data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25" } @@ -562,21 +516,18 @@ function OrganisationsDialog({ processingText: "Deleting...", variant: "destructive", onConfirm: async () => { - await organisation.remove({ - organisationId: + try { + await deleteOrganisation.mutateAsync( selectedOrganisation.Organisation.id, - onSuccess: async () => { - closeConfirmDialog(); - toast.success( - `Deleted organisation "${selectedOrganisation.Organisation.name}"`, - ); - setSelectedOrganisation(null); - await refetchOrganisations(); - }, - onError: (error) => { - console.error(error); - }, - }); + ); + closeConfirmDialog(); + toast.success( + `Deleted organisation "${selectedOrganisation.Organisation.name}"`, + ); + await invalidateOrganisations(); + } catch (error) { + console.error(error); + } }, }); }} @@ -595,9 +546,7 @@ function OrganisationsDialog({ open={editOrgOpen} onOpenChange={setEditOrgOpen} completeAction={async () => { - await refetchOrganisations({ - selectOrganisationId: selectedOrganisation.Organisation.id, - }); + await invalidateOrganisations(); }} /> @@ -683,7 +632,7 @@ function OrganisationsDialog({ }, ); - refetchMembers(); + void invalidateMembers(); }} trigger={