migrated modals to mutations

This commit is contained in:
Oliver Bryan
2026-01-20 17:04:58 +00:00
parent 06bac090a2
commit 75e06f7518
7 changed files with 432 additions and 581 deletions

View File

@@ -10,10 +10,12 @@ import Icon from "@/components/ui/icon";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UploadAvatar } from "@/components/upload-avatar"; 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 }) { function AccountDialog({ trigger }: { trigger?: ReactNode }) {
const { user: currentUser, setUser } = useAuthenticatedSession(); const { user: currentUser, setUser } = useAuthenticatedSession();
const updateUser = useUpdateUser();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -45,30 +47,29 @@ function AccountDialog({ trigger }: { trigger?: ReactNode }) {
return; return;
} }
await user.update({ try {
const data = await updateUser.mutateAsync({
name: name.trim(), name: name.trim(),
password: password.trim() || undefined, password: password.trim() || undefined,
avatarURL, avatarURL,
iconPreference, iconPreference,
onSuccess: (data) => { });
setError(""); setError("");
setUser(data); setUser(data);
setPassword(""); setPassword("");
setOpen(false); setOpen(false);
toast.success(`Account updated successfully`, { toast.success("Account updated successfully", {
dismissible: false, dismissible: false,
}); });
}, } catch (err) {
onError: (err) => { const message = parseError(err as Error);
const message = parseError(err);
setError(message); setError(message);
toast.error(`Error updating account: ${message}`, { toast.error(`Error updating account: ${message}`, {
dismissible: false, dismissible: false,
}); });
}, }
});
}; };
return ( return (

View File

@@ -11,7 +11,8 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; 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({ export function AddMemberDialog({
organisationId, organisationId,
@@ -29,6 +30,7 @@ export function AddMemberDialog({
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const addMember = useAddOrganisationMember();
const reset = () => { const reset = () => {
setUsername(""); setUsername("");
@@ -60,34 +62,9 @@ export function AddMemberDialog({
setSubmitting(true); setSubmitting(true);
try { try {
let userId: number | null = null; const userData: UserRecord = await user.byUsername(username);
let userData: UserRecord; const userId = userData.id;
await user.byUsername({ await addMember.mutateAsync({ organisationId, userId, role: "member" });
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;
}
await organisation.addMember({
organisationId,
userId,
role: "member",
onSuccess: async () => {
setOpen(false); setOpen(false);
reset(); reset();
try { try {
@@ -95,21 +72,15 @@ export function AddMemberDialog({
} catch (actionErr) { } catch (actionErr) {
console.error(actionErr); console.error(actionErr);
} }
}, } catch (err) {
onError: (err) => { const message = parseError(err as Error);
const message = parseError(err); console.error(err);
setError(message || "failed to add member"); setError(message || "failed to add member");
setSubmitting(false); setSubmitting(false);
toast.error(`Error adding member: ${message}`, { toast.error(`Error adding member: ${message}`, {
dismissible: false, dismissible: false,
}); });
},
});
} catch (err) {
console.error(err);
setError("failed to add member");
setSubmitting(false);
} }
}; };

View File

@@ -18,7 +18,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; 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"; import { cn } from "@/lib/utils";
const slugify = (value: string) => const slugify = (value: string) =>
@@ -47,6 +48,8 @@ export function OrganisationModal({
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
}) { }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const createOrganisation = useCreateOrganisation();
const updateOrganisation = useUpdateOrganisation();
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
@@ -106,12 +109,12 @@ export function OrganisationModal({
setSubmitting(true); setSubmitting(true);
try { try {
if (isEdit && existingOrganisation) { if (isEdit && existingOrganisation) {
await organisation.update({ const data = await updateOrganisation.mutateAsync({
organisationId: existingOrganisation.id, id: existingOrganisation.id,
name, name,
slug, slug,
description, description,
onSuccess: async (data) => { });
setOpen(false); setOpen(false);
reset(); reset();
toast.success("Organisation updated"); toast.success("Organisation updated");
@@ -120,48 +123,33 @@ export function OrganisationModal({
} catch (actionErr) { } catch (actionErr) {
console.error(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);
}
},
});
} else { } else {
await organisation.create({ const data = await createOrganisation.mutateAsync({
name, name,
slug, slug,
description, description,
onSuccess: async (data) => { });
setOpen(false); setOpen(false);
reset(); reset();
toast.success(`Created Organisation ${data.name}`, {
dismissible: false,
});
try { try {
await completeAction?.(data); await completeAction?.(data);
} catch (actionErr) { } catch (actionErr) {
console.error(actionErr); console.error(actionErr);
} }
}, }
onError: async (err) => { } catch (err) {
const message = parseError(err); const message = parseError(err as Error);
setError(message || "failed to create organisation"); console.error(err);
setError(message || `failed to ${isEdit ? "update" : "create"} organisation`);
setSubmitting(false); setSubmitting(false);
try { try {
await errorAction?.(message || "failed to create organisation"); await errorAction?.(message || `failed to ${isEdit ? "update" : "create"} organisation`);
} catch (actionErr) { } catch (actionErr) {
console.error(actionErr); console.error(actionErr);
} }
},
});
}
} catch (err) {
console.error(err);
setError(`failed to ${isEdit ? "update" : "create"} organisation`);
setSubmitting(false);
} }
}; };

View File

@@ -1,19 +1,13 @@
import { import { DEFAULT_STATUS_COLOUR, ISSUE_STATUS_MAX_LENGTH, type SprintRecord } from "@sprint/shared";
DEFAULT_STATUS_COLOUR, import { useQueryClient } from "@tanstack/react-query";
ISSUE_STATUS_MAX_LENGTH, import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
type OrganisationMemberResponse,
type OrganisationResponse,
type ProjectRecord,
type ProjectResponse,
type SprintRecord,
} from "@sprint/shared";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddMemberDialog } from "@/components/add-member-dialog"; import { AddMemberDialog } from "@/components/add-member-dialog";
import { OrganisationModal } from "@/components/organisation-modal"; import { OrganisationModal } from "@/components/organisation-modal";
import { OrganisationSelect } from "@/components/organisation-select"; import { OrganisationSelect } from "@/components/organisation-select";
import { ProjectModal } from "@/components/project-modal"; import { ProjectModal } from "@/components/project-modal";
import { ProjectSelect } from "@/components/project-select"; import { ProjectSelect } from "@/components/project-select";
import { useSelection } from "@/components/selection-provider";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import SmallSprintDisplay from "@/components/small-sprint-display"; import SmallSprintDisplay from "@/components/small-sprint-display";
import SmallUserDisplay from "@/components/small-user-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 { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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"; import { capitalise } from "@/lib/utils";
function OrganisationsDialog({ function OrganisationsDialog({ trigger }: { trigger?: ReactNode }) {
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<void>;
projects: ProjectResponse[];
selectedProject: ProjectResponse | null;
sprints: SprintRecord[];
onSelectedProjectChange: (project: ProjectResponse | null) => void;
onCreateProject: (project: ProjectRecord) => void | Promise<void>;
onCreateSprint: (sprint: SprintRecord) => void | Promise<void>;
}) {
const { user } = useAuthenticatedSession(); 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<string, number> = { 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 [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState("info"); const [activeTab, setActiveTab] = useState("info");
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
const [statuses, setStatuses] = useState<Record<string, string>>({}); const [statuses, setStatuses] = useState<Record<string, string>>({});
const [isCreatingStatus, setIsCreatingStatus] = useState(false); const [isCreatingStatus, setIsCreatingStatus] = useState(false);
@@ -123,37 +161,6 @@ function OrganisationsDialog({
return start <= today && today <= end; 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<string, number> = { 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) => { const handleRoleChange = (memberUserId: number, memberName: string, currentRole: string) => {
if (!selectedOrganisation) return; if (!selectedOrganisation) return;
const action = currentRole === "admin" ? "demote" : "promote"; const action = currentRole === "admin" ? "demote" : "promote";
@@ -167,32 +174,23 @@ function OrganisationsDialog({
variant: action === "demote" ? "destructive" : "default", variant: action === "demote" ? "destructive" : "default",
onConfirm: async () => { onConfirm: async () => {
try { try {
await organisation.updateMemberRole({ await updateMemberRole.mutateAsync({
organisationId: selectedOrganisation.Organisation.id, organisationId: selectedOrganisation.Organisation.id,
userId: memberUserId, userId: memberUserId,
role: newRole, role: newRole,
onSuccess: () => { });
closeConfirmDialog(); closeConfirmDialog();
toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, { toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, {
dismissible: false, dismissible: false,
}); });
} catch (err) {
void refetchMembers(); console.error(err);
},
onError: (error) => {
console.error(error);
toast.error( toast.error(
`Error ${action.slice(0, -1)}ing ${memberName} to ${newRole}: ${error}`, `Error ${action.slice(0, -1)}ing ${memberName} to ${newRole}: ${String(err)}`,
{ {
dismissible: false, dismissible: false,
}, },
); );
},
});
} catch (err) {
console.error(err);
} }
}, },
}); });
@@ -213,34 +211,25 @@ function OrganisationsDialog({
variant: "destructive", variant: "destructive",
onConfirm: async () => { onConfirm: async () => {
try { try {
await organisation.removeMember({ await removeMember.mutateAsync({
organisationId: selectedOrganisation.Organisation.id, organisationId: selectedOrganisation.Organisation.id,
userId: memberUserId, userId: memberUserId,
onSuccess: () => { });
closeConfirmDialog(); closeConfirmDialog();
toast.success( toast.success(
`Removed ${memberName} from ${selectedOrganisation.Organisation.name} successfully`, `Removed ${memberName} from ${selectedOrganisation.Organisation.name} successfully`,
{ {
dismissible: false, dismissible: false,
}, },
); );
} catch (err) {
void refetchMembers(); console.error(err);
},
onError: (error) => {
console.error(error);
toast.error( toast.error(
`Error removing member from ${selectedOrganisation.Organisation.name}: ${error}`, `Error removing member from ${selectedOrganisation.Organisation.name}: ${String(err)}`,
{ {
dismissible: false, dismissible: false,
}, },
); );
},
});
} catch (err) {
console.error(err);
} }
}, },
}); });
@@ -261,16 +250,16 @@ function OrganisationsDialog({
if (!selectedOrganisation) return; if (!selectedOrganisation) return;
try { try {
await organisation.update({ await updateOrganisation.mutateAsync({
organisationId: selectedOrganisation.Organisation.id, id: selectedOrganisation.Organisation.id,
statuses: newStatuses, statuses: newStatuses,
onSuccess: () => { });
setStatuses(newStatuses); setStatuses(newStatuses);
if (statusAdded) { if (statusAdded) {
toast.success( toast.success(
<> <>
Created <StatusTag status={statusAdded.name} colour={statusAdded.colour} />{" "} Created <StatusTag status={statusAdded.name} colour={statusAdded.colour} /> status
status successfully successfully
</>, </>,
{ {
dismissible: false, dismissible: false,
@@ -279,8 +268,7 @@ function OrganisationsDialog({
} else if (statusRemoved) { } else if (statusRemoved) {
toast.success( toast.success(
<> <>
Removed{" "} Removed <StatusTag status={statusRemoved.name} colour={statusRemoved.colour} /> status
<StatusTag status={statusRemoved.name} colour={statusRemoved.colour} /> status
successfully successfully
</>, </>,
{ {
@@ -291,25 +279,22 @@ function OrganisationsDialog({
toast.success( toast.success(
<> <>
Moved <StatusTag status={statusMoved.name} colour={statusMoved.colour} /> from Moved <StatusTag status={statusMoved.name} colour={statusMoved.colour} /> from
position {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "} position
successfully {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1} successfully
</>, </>,
{ {
dismissible: false, dismissible: false,
}, },
); );
} }
void refetchOrganisations(); await invalidateOrganisations();
}, } catch (err) {
onError: (error) => { console.error("error updating statuses:", err);
console.error("error updating statuses:", error);
if (statusAdded) { if (statusAdded) {
toast.error( toast.error(
<> <>
Error adding{" "} Error adding <StatusTag status={statusAdded.name} colour={statusAdded.colour} /> to{" "}
<StatusTag status={statusAdded.name} colour={statusAdded.colour} /> to{" "} {selectedOrganisation.Organisation.name}: {String(err)}
{selectedOrganisation.Organisation.name}: {error}
</>, </>,
{ {
dismissible: false, dismissible: false,
@@ -318,9 +303,8 @@ function OrganisationsDialog({
} else if (statusRemoved) { } else if (statusRemoved) {
toast.error( toast.error(
<> <>
Error removing{" "} Error removing <StatusTag status={statusRemoved.name} colour={statusRemoved.colour} />{" "}
<StatusTag status={statusRemoved.name} colour={statusRemoved.colour} /> from{" "} from {selectedOrganisation.Organisation.name}: {String(err)}
{selectedOrganisation.Organisation.name}: {error}
</>, </>,
{ {
dismissible: false, dismissible: false,
@@ -329,20 +313,16 @@ function OrganisationsDialog({
} else if (statusMoved) { } else if (statusMoved) {
toast.error( toast.error(
<> <>
Error moving{" "} Error moving <StatusTag status={statusMoved.name} colour={statusMoved.colour} /> from
<StatusTag status={statusMoved.name} colour={statusMoved.colour} /> position
from position {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "} {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "}
{selectedOrganisation.Organisation.name}: {error} {selectedOrganisation.Organisation.name}: {String(err)}
</>, </>,
{ {
dismissible: false, dismissible: false,
}, },
); );
} }
},
});
} catch (err) {
console.error("error updating statuses:", err);
} }
}; };
@@ -374,41 +354,32 @@ function OrganisationsDialog({
const handleRemoveStatusClick = async (status: string) => { const handleRemoveStatusClick = async (status: string) => {
if (Object.keys(statuses).length <= 1 || !selectedOrganisation) return; if (Object.keys(statuses).length <= 1 || !selectedOrganisation) return;
try { try {
await issue.statusCount({ const data = await issue.statusCount(selectedOrganisation.Organisation.id, status);
organisationId: selectedOrganisation.Organisation.id, const count = data.find((item) => item.status === status)?.count ?? 0;
status,
onSuccess: (data) => {
const count = (data as { count?: number }).count ?? 0;
if (count > 0) { if (count > 0) {
setStatusToRemove(status); setStatusToRemove(status);
setIssuesUsingStatus(count); setIssuesUsingStatus(count);
const remaining = Object.keys(statuses).filter((s) => s !== status); const remaining = Object.keys(statuses).filter((item) => item !== status);
setReassignToStatus(remaining[0] || ""); setReassignToStatus(remaining[0] || "");
return; return;
} }
const nextStatuses = Object.keys(statuses).filter((s) => s !== status); const nextStatuses = Object.keys(statuses).filter((item) => item !== status);
void updateStatuses( await updateStatuses(
Object.fromEntries(nextStatuses.map((statusKey) => [statusKey, statuses[statusKey]])), Object.fromEntries(nextStatuses.map((statusKey) => [statusKey, statuses[statusKey]])),
{ name: status, colour: statuses[status] }, { name: status, colour: statuses[status] },
); );
}, } catch (err) {
onError: (error) => { console.error("error checking status usage:", err);
console.error("error checking status usage:", error);
toast.error( toast.error(
<> <>
Error checking status usage for{" "} Error checking status usage for <StatusTag status={status} colour={statuses[status]} />:{" "}
<StatusTag status={status} colour={statuses[status]} />: {error} {String(err)}
</>, </>,
{ {
dismissible: false, dismissible: false,
}, },
); );
},
});
} catch (err) {
console.error("error checking status usage:", err);
} }
}; };
@@ -435,40 +406,38 @@ function OrganisationsDialog({
const confirmRemoveStatus = async () => { const confirmRemoveStatus = async () => {
if (!statusToRemove || !reassignToStatus || !selectedOrganisation) return; if (!statusToRemove || !reassignToStatus || !selectedOrganisation) return;
await issue.replaceStatus({ try {
await replaceIssueStatus.mutateAsync({
organisationId: selectedOrganisation.Organisation.id, organisationId: selectedOrganisation.Organisation.id,
oldStatus: statusToRemove, oldStatus: statusToRemove,
newStatus: reassignToStatus, newStatus: reassignToStatus,
onSuccess: async () => { });
const newStatuses = Object.keys(statuses).filter((s) => s !== statusToRemove); const newStatuses = Object.keys(statuses).filter((item) => item !== statusToRemove);
await updateStatuses( await updateStatuses(
Object.fromEntries(newStatuses.map((status) => [status, statuses[status]])), Object.fromEntries(newStatuses.map((status) => [status, statuses[status]])),
{ name: statusToRemove, colour: statuses[statusToRemove] }, { name: statusToRemove, colour: statuses[statusToRemove] },
); );
setStatusToRemove(null); setStatusToRemove(null);
setReassignToStatus(""); setReassignToStatus("");
}, } catch (error) {
onError: (error) => {
console.error("error replacing status:", error); console.error("error replacing status:", error);
toast.error( toast.error(
<> <>
Error removing <StatusTag status={statusToRemove} colour={statuses[statusToRemove]} />{" "} Error removing <StatusTag status={statusToRemove} colour={statuses[statusToRemove]} />{" "}
from from
{selectedOrganisation.Organisation.name}: {error}{" "} {selectedOrganisation.Organisation.name}: {String(error)}
</>, </>,
{ {
dismissible: false, dismissible: false,
}, },
); );
}, }
});
}; };
useEffect(() => { useEffect(() => {
if (!open) return; if (!open || !selectedOrganisationId) return;
void refetchMembers(); void invalidateMembers();
}, [open, refetchMembers]); }, [open, invalidateMembers, selectedOrganisationId]);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -490,21 +459,6 @@ function OrganisationsDialog({
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full min-w-0"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full min-w-0">
<div className="flex flex-wrap gap-2 items-center w-full min-w-0"> <div className="flex flex-wrap gap-2 items-center w-full min-w-0">
<OrganisationSelect <OrganisationSelect
organisations={organisations}
selectedOrganisation={selectedOrganisation}
onSelectedOrganisationChange={(org) => {
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={ contentClass={
"data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25" "data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25"
} }
@@ -562,21 +516,18 @@ function OrganisationsDialog({
processingText: "Deleting...", processingText: "Deleting...",
variant: "destructive", variant: "destructive",
onConfirm: async () => { onConfirm: async () => {
await organisation.remove({ try {
organisationId: await deleteOrganisation.mutateAsync(
selectedOrganisation.Organisation.id, selectedOrganisation.Organisation.id,
onSuccess: async () => { );
closeConfirmDialog(); closeConfirmDialog();
toast.success( toast.success(
`Deleted organisation "${selectedOrganisation.Organisation.name}"`, `Deleted organisation "${selectedOrganisation.Organisation.name}"`,
); );
setSelectedOrganisation(null); await invalidateOrganisations();
await refetchOrganisations(); } catch (error) {
},
onError: (error) => {
console.error(error); console.error(error);
}, }
});
}, },
}); });
}} }}
@@ -595,9 +546,7 @@ function OrganisationsDialog({
open={editOrgOpen} open={editOrgOpen}
onOpenChange={setEditOrgOpen} onOpenChange={setEditOrgOpen}
completeAction={async () => { completeAction={async () => {
await refetchOrganisations({ await invalidateOrganisations();
selectOrganisationId: selectedOrganisation.Organisation.id,
});
}} }}
/> />
</TabsContent> </TabsContent>
@@ -683,7 +632,7 @@ function OrganisationsDialog({
}, },
); );
refetchMembers(); void invalidateMembers();
}} }}
trigger={ trigger={
<Button variant="outline"> <Button variant="outline">
@@ -699,14 +648,7 @@ function OrganisationsDialog({
<TabsContent value="projects"> <TabsContent value="projects">
<div className="border p-2 min-w-0 overflow-hidden"> <div className="border p-2 min-w-0 overflow-hidden">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<ProjectSelect <ProjectSelect showLabel />
projects={projects}
selectedProject={selectedProject}
organisationId={selectedOrganisation?.Organisation.id}
onSelectedProjectChange={onSelectedProjectChange}
onCreateProject={onCreateProject}
showLabel
/>
<div className="flex gap-3 flex-col"> <div className="flex gap-3 flex-col">
<div className="border p-2 min-w-0 overflow-hidden"> <div className="border p-2 min-w-0 overflow-hidden">
{selectedProject ? ( {selectedProject ? (
@@ -745,27 +687,20 @@ function OrganisationsDialog({
processingText: "Deleting...", processingText: "Deleting...",
variant: "destructive", variant: "destructive",
onConfirm: async () => { onConfirm: async () => {
await project.remove({ try {
projectId: await deleteProject.mutateAsync(
selectedProject selectedProject
.Project.id, .Project.id,
onSuccess: );
async () => {
closeConfirmDialog(); closeConfirmDialog();
toast.success( toast.success(
`Deleted project "${selectedProject.Project.name}"`, `Deleted project "${selectedProject.Project.name}"`,
); );
onSelectedProjectChange( await invalidateProjects();
null, await invalidateSprints();
); } catch (error) {
await refetchOrganisations(); console.error(error);
}, }
onError: (error) => {
console.error(
error,
);
},
});
}, },
}); });
}} }}
@@ -859,28 +794,20 @@ function OrganisationsDialog({
"destructive", "destructive",
onConfirm: onConfirm:
async () => { async () => {
await sprint.remove( try {
{ await deleteSprint.mutateAsync(
sprintId:
sprintItem.id, sprintItem.id,
onSuccess: );
async () => {
closeConfirmDialog(); closeConfirmDialog();
toast.success( toast.success(
`Deleted sprint "${sprintItem.name}"`, `Deleted sprint "${sprintItem.name}"`,
); );
await refetchOrganisations(); await invalidateSprints();
}, } catch (error) {
onError:
(
error,
) => {
console.error( console.error(
error, error,
); );
}, }
},
);
}, },
}); });
}} }}
@@ -902,7 +829,6 @@ function OrganisationsDialog({
{isAdmin && ( {isAdmin && (
<SprintModal <SprintModal
projectId={selectedProject?.Project.id} projectId={selectedProject?.Project.id}
completeAction={onCreateSprint}
trigger={ trigger={
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
Create sprint{" "} Create sprint{" "}
@@ -934,7 +860,7 @@ function OrganisationsDialog({
open={editProjectOpen} open={editProjectOpen}
onOpenChange={setEditProjectOpen} onOpenChange={setEditProjectOpen}
completeAction={async () => { completeAction={async () => {
await refetchOrganisations(); await invalidateProjects();
}} }}
/> />
<SprintModal <SprintModal
@@ -947,7 +873,7 @@ function OrganisationsDialog({
if (!open) setEditingSprint(null); if (!open) setEditingSprint(null);
}} }}
completeAction={async () => { completeAction={async () => {
await refetchOrganisations(); await invalidateSprints();
}} }}
/> />
</> </>
@@ -1105,18 +1031,6 @@ function OrganisationsDialog({
) : ( ) : (
<div className="flex flex-col gap-2 w-full min-w-0"> <div className="flex flex-col gap-2 w-full min-w-0">
<OrganisationSelect <OrganisationSelect
organisations={organisations}
selectedOrganisation={selectedOrganisation}
onSelectedOrganisationChange={(org) => {
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={ contentClass={
"data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25" "data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25"
} }

View File

@@ -13,7 +13,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { parseError, project } from "@/lib/server"; import { useCreateProject, useUpdateProject } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const keyify = (value: string) => const keyify = (value: string) =>
@@ -40,6 +41,8 @@ export function ProjectModal({
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
}) { }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const createProject = useCreateProject();
const updateProject = useUpdateProject();
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
@@ -106,12 +109,11 @@ export function ProjectModal({
setSubmitting(true); setSubmitting(true);
try { try {
if (isEdit && existingProject) { if (isEdit && existingProject) {
await project.update({ const proj = await updateProject.mutateAsync({
projectId: existingProject.id, id: existingProject.id,
key, key,
name, name,
onSuccess: async (data) => { });
const proj = data as ProjectRecord;
setOpen(false); setOpen(false);
reset(); reset();
toast.success("Project updated"); toast.success("Project updated");
@@ -120,52 +122,35 @@ export function ProjectModal({
} catch (actionErr) { } catch (actionErr) {
console.error(actionErr); console.error(actionErr);
} }
},
onError: (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error updating project: ${message}`, {
dismissible: false,
});
},
});
} else { } else {
if (!organisationId) { if (!organisationId) {
setError("select an organisation first"); setError("select an organisation first");
return; return;
} }
await project.create({ const proj = await createProject.mutateAsync({
key, key,
name, name,
organisationId: organisationId, organisationId,
onSuccess: async (data) => { });
const proj = data as ProjectRecord;
setOpen(false); setOpen(false);
reset(); reset();
toast.success(`Created Project ${proj.name}`, {
dismissible: false,
});
try { try {
await completeAction?.(proj); await completeAction?.(proj);
} catch (actionErr) { } catch (actionErr) {
console.error(actionErr); console.error(actionErr);
} }
},
onError: (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error creating project: ${message}`, {
dismissible: false,
});
},
});
} }
} catch (err) { } catch (err) {
const message = parseError(err as Error);
console.error(err); console.error(err);
setError(`failed to ${isEdit ? "update" : "create"} project`); setError(message || `failed to ${isEdit ? "update" : "create"} project`);
setSubmitting(false); setSubmitting(false);
toast.error(`Error ${isEdit ? "updating" : "creating"} project: ${message}`, {
dismissible: false,
});
} }
}; };

View File

@@ -16,7 +16,8 @@ import {
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { parseError, sprint } from "@/lib/server"; import { useCreateSprint, useUpdateSprint } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SPRINT_NAME_MAX_LENGTH = 64; const SPRINT_NAME_MAX_LENGTH = 64;
@@ -67,6 +68,8 @@ export function SprintModal({
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
}) { }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const createSprint = useCreateSprint();
const updateSprint = useUpdateSprint();
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
@@ -146,13 +149,13 @@ export function SprintModal({
try { try {
if (isEdit && existingSprint) { if (isEdit && existingSprint) {
await sprint.update({ const data = await updateSprint.mutateAsync({
sprintId: existingSprint.id, id: existingSprint.id,
name, name,
color: colour, color: colour,
startDate, startDate: startDate.toISOString(),
endDate, endDate: endDate.toISOString(),
onSuccess: async (data) => { });
setOpen(false); setOpen(false);
reset(); reset();
toast.success("Sprint updated"); toast.success("Sprint updated");
@@ -161,52 +164,42 @@ export function SprintModal({
} catch (actionErr) { } catch (actionErr) {
console.error(actionErr); console.error(actionErr);
} }
},
onError: (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error updating sprint: ${message}`, {
dismissible: false,
});
},
});
} else { } else {
if (!projectId) { if (!projectId) {
setError("select a project first"); setError("select a project first");
return; return;
} }
await sprint.create({ const data = await createSprint.mutateAsync({
projectId: projectId, projectId,
name, name,
color: colour, color: colour,
startDate, startDate: startDate.toISOString(),
endDate, endDate: endDate.toISOString(),
onSuccess: async (data) => { });
setOpen(false); setOpen(false);
reset(); reset();
toast.success(
<>
Created sprint <span style={{ color: data.color }}>{data.name}</span>
</>,
{
dismissible: false,
},
);
try { try {
await completeAction?.(data); await completeAction?.(data);
} catch (actionErr) { } catch (actionErr) {
console.error(actionErr); console.error(actionErr);
} }
},
onError: (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error creating sprint: ${message}`, {
dismissible: false,
});
},
});
} }
} catch (submitError) { } catch (submitError) {
const message = parseError(submitError as Error);
console.error(submitError); console.error(submitError);
setError(`failed to ${isEdit ? "update" : "create"} sprint`); setError(message || `failed to ${isEdit ? "update" : "create"} sprint`);
setSubmitting(false); setSubmitting(false);
toast.error(`Error ${isEdit ? "updating" : "creating"} sprint: ${message}`, {
dismissible: false,
});
} }
}; };

View File

@@ -4,7 +4,8 @@ import Avatar from "@/components/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { parseError, user } from "@/lib/server"; import { useUploadAvatar } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function UploadAvatar({ export function UploadAvatar({
@@ -25,6 +26,7 @@ export function UploadAvatar({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const uploadAvatar = useUploadAvatar();
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -33,9 +35,8 @@ export function UploadAvatar({
setUploading(true); setUploading(true);
setError(null); setError(null);
await user.uploadAvatar({ try {
file, const url = await uploadAvatar.mutateAsync(file);
onSuccess: (url) => {
onAvatarUploaded(url); onAvatarUploaded(url);
setUploading(false); setUploading(false);
@@ -48,17 +49,15 @@ export function UploadAvatar({
dismissible: false, dismissible: false,
}, },
); );
}, } catch (err) {
onError: (err) => { const message = parseError(err as Error);
const message = parseError(err);
setError(message); setError(message);
setUploading(false); setUploading(false);
toast.error(`Error uploading avatar: ${message}`, { toast.error(`Error uploading avatar: ${message}`, {
dismissible: false, dismissible: false,
}); });
}, }
});
}; };
return ( return (