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 { 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({
try {
const data = await updateUser.mutateAsync({
name: name.trim(),
password: password.trim() || undefined,
avatarURL,
iconPreference,
onSuccess: (data) => {
});
setError("");
setUser(data);
setPassword("");
setOpen(false);
toast.success(`Account updated successfully`, {
toast.success("Account updated successfully", {
dismissible: false,
});
},
onError: (err) => {
const message = parseError(err);
} catch (err) {
const message = parseError(err as Error);
setError(message);
toast.error(`Error updating account: ${message}`, {
dismissible: false,
});
},
});
}
};
return (

View File

@@ -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<string | null>(null);
const addMember = useAddOrganisationMember();
const reset = () => {
setUsername("");
@@ -60,34 +62,9 @@ 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;
}
await organisation.addMember({
organisationId,
userId,
role: "member",
onSuccess: async () => {
const userData: UserRecord = await user.byUsername(username);
const userId = userData.id;
await addMember.mutateAsync({ organisationId, userId, role: "member" });
setOpen(false);
reset();
try {
@@ -95,21 +72,15 @@ export function AddMemberDialog({
} catch (actionErr) {
console.error(actionErr);
}
},
onError: (err) => {
const message = parseError(err);
} catch (err) {
const message = parseError(err as Error);
console.error(err);
setError(message || "failed to add member");
setSubmitting(false);
toast.error(`Error adding member: ${message}`, {
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";
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,12 +109,12 @@ 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");
@@ -120,48 +123,33 @@ export function OrganisationModal({
} 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);
}
},
});
} else {
await organisation.create({
const data = await createOrganisation.mutateAsync({
name,
slug,
description,
onSuccess: async (data) => {
});
setOpen(false);
reset();
toast.success(`Created Organisation ${data.name}`, {
dismissible: false,
});
try {
await completeAction?.(data);
} catch (actionErr) {
console.error(actionErr);
}
},
onError: async (err) => {
const message = parseError(err);
setError(message || "failed to create organisation");
}
} catch (err) {
const message = parseError(err as Error);
console.error(err);
setError(message || `failed to ${isEdit ? "update" : "create"} organisation`);
setSubmitting(false);
try {
await errorAction?.(message || "failed to create organisation");
await errorAction?.(message || `failed to ${isEdit ? "update" : "create"} organisation`);
} catch (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 {
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<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>;
}) {
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<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 [activeTab, setActiveTab] = useState("info");
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
const [statuses, setStatuses] = useState<Record<string, string>>({});
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<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) => {
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);
} catch (err) {
console.error(err);
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,
},
);
},
});
} catch (err) {
console.error(err);
}
},
});
@@ -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);
} catch (err) {
console.error(err);
toast.error(
`Error removing member from ${selectedOrganisation.Organisation.name}: ${error}`,
`Error removing member from ${selectedOrganisation.Organisation.name}: ${String(err)}`,
{
dismissible: false,
},
);
},
});
} catch (err) {
console.error(err);
}
},
});
@@ -261,16 +250,16 @@ 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 <StatusTag status={statusAdded.name} colour={statusAdded.colour} />{" "}
status successfully
Created <StatusTag status={statusAdded.name} colour={statusAdded.colour} /> status
successfully
</>,
{
dismissible: false,
@@ -279,8 +268,7 @@ function OrganisationsDialog({
} else if (statusRemoved) {
toast.success(
<>
Removed{" "}
<StatusTag status={statusRemoved.name} colour={statusRemoved.colour} /> status
Removed <StatusTag status={statusRemoved.name} colour={statusRemoved.colour} /> status
successfully
</>,
{
@@ -291,25 +279,22 @@ function OrganisationsDialog({
toast.success(
<>
Moved <StatusTag status={statusMoved.name} colour={statusMoved.colour} /> from
position {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "}
successfully
position
{statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1} successfully
</>,
{
dismissible: false,
},
);
}
void refetchOrganisations();
},
onError: (error) => {
console.error("error updating statuses:", error);
await invalidateOrganisations();
} catch (err) {
console.error("error updating statuses:", err);
if (statusAdded) {
toast.error(
<>
Error adding{" "}
<StatusTag status={statusAdded.name} colour={statusAdded.colour} /> to{" "}
{selectedOrganisation.Organisation.name}: {error}
Error adding <StatusTag status={statusAdded.name} colour={statusAdded.colour} /> to{" "}
{selectedOrganisation.Organisation.name}: {String(err)}
</>,
{
dismissible: false,
@@ -318,9 +303,8 @@ function OrganisationsDialog({
} else if (statusRemoved) {
toast.error(
<>
Error removing{" "}
<StatusTag status={statusRemoved.name} colour={statusRemoved.colour} /> from{" "}
{selectedOrganisation.Organisation.name}: {error}
Error removing <StatusTag status={statusRemoved.name} colour={statusRemoved.colour} />{" "}
from {selectedOrganisation.Organisation.name}: {String(err)}
</>,
{
dismissible: false,
@@ -329,20 +313,16 @@ function OrganisationsDialog({
} else if (statusMoved) {
toast.error(
<>
Error moving{" "}
<StatusTag status={statusMoved.name} colour={statusMoved.colour} />
from position {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "}
{selectedOrganisation.Organisation.name}: {error}
Error moving <StatusTag status={statusMoved.name} colour={statusMoved.colour} /> from
position
{statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "}
{selectedOrganisation.Organisation.name}: {String(err)}
</>,
{
dismissible: false,
},
);
}
},
});
} catch (err) {
console.error("error updating statuses:", err);
}
};
@@ -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;
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((s) => s !== status);
const remaining = Object.keys(statuses).filter((item) => item !== status);
setReassignToStatus(remaining[0] || "");
return;
}
const nextStatuses = Object.keys(statuses).filter((s) => s !== status);
void updateStatuses(
const nextStatuses = Object.keys(statuses).filter((item) => item !== status);
await updateStatuses(
Object.fromEntries(nextStatuses.map((statusKey) => [statusKey, statuses[statusKey]])),
{ name: status, colour: statuses[status] },
);
},
onError: (error) => {
console.error("error checking status usage:", error);
} catch (err) {
console.error("error checking status usage:", err);
toast.error(
<>
Error checking status usage for{" "}
<StatusTag status={status} colour={statuses[status]} />: {error}
Error checking status usage for <StatusTag status={status} colour={statuses[status]} />:{" "}
{String(err)}
</>,
{
dismissible: false,
},
);
},
});
} catch (err) {
console.error("error checking status usage:", err);
}
};
@@ -435,40 +406,38 @@ function OrganisationsDialog({
const confirmRemoveStatus = async () => {
if (!statusToRemove || !reassignToStatus || !selectedOrganisation) return;
await issue.replaceStatus({
try {
await replaceIssueStatus.mutateAsync({
organisationId: selectedOrganisation.Organisation.id,
oldStatus: statusToRemove,
newStatus: reassignToStatus,
onSuccess: async () => {
const newStatuses = Object.keys(statuses).filter((s) => s !== statusToRemove);
});
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("");
},
onError: (error) => {
} catch (error) {
console.error("error replacing status:", error);
toast.error(
<>
Error removing <StatusTag status={statusToRemove} colour={statuses[statusToRemove]} />{" "}
from
{selectedOrganisation.Organisation.name}: {error}{" "}
{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 (
<Dialog open={open} onOpenChange={setOpen}>
@@ -490,21 +459,6 @@ function OrganisationsDialog({
<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">
<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={
"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) => {
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();
}}
/>
</TabsContent>
@@ -683,7 +632,7 @@ function OrganisationsDialog({
},
);
refetchMembers();
void invalidateMembers();
}}
trigger={
<Button variant="outline">
@@ -699,14 +648,7 @@ function OrganisationsDialog({
<TabsContent value="projects">
<div className="border p-2 min-w-0 overflow-hidden">
<div className="flex flex-col gap-3">
<ProjectSelect
projects={projects}
selectedProject={selectedProject}
organisationId={selectedOrganisation?.Organisation.id}
onSelectedProjectChange={onSelectedProjectChange}
onCreateProject={onCreateProject}
showLabel
/>
<ProjectSelect showLabel />
<div className="flex gap-3 flex-col">
<div className="border p-2 min-w-0 overflow-hidden">
{selectedProject ? (
@@ -745,27 +687,20 @@ function OrganisationsDialog({
processingText: "Deleting...",
variant: "destructive",
onConfirm: async () => {
await project.remove({
projectId:
try {
await deleteProject.mutateAsync(
selectedProject
.Project.id,
onSuccess:
async () => {
);
closeConfirmDialog();
toast.success(
`Deleted project "${selectedProject.Project.name}"`,
);
onSelectedProjectChange(
null,
);
await refetchOrganisations();
},
onError: (error) => {
console.error(
error,
);
},
});
await invalidateProjects();
await invalidateSprints();
} catch (error) {
console.error(error);
}
},
});
}}
@@ -859,28 +794,20 @@ function OrganisationsDialog({
"destructive",
onConfirm:
async () => {
await sprint.remove(
{
sprintId:
try {
await deleteSprint.mutateAsync(
sprintItem.id,
onSuccess:
async () => {
);
closeConfirmDialog();
toast.success(
`Deleted sprint "${sprintItem.name}"`,
);
await refetchOrganisations();
},
onError:
(
error,
) => {
await invalidateSprints();
} catch (error) {
console.error(
error,
);
},
},
);
}
},
});
}}
@@ -902,7 +829,6 @@ function OrganisationsDialog({
{isAdmin && (
<SprintModal
projectId={selectedProject?.Project.id}
completeAction={onCreateSprint}
trigger={
<Button variant="outline" size="sm">
Create sprint{" "}
@@ -934,7 +860,7 @@ function OrganisationsDialog({
open={editProjectOpen}
onOpenChange={setEditProjectOpen}
completeAction={async () => {
await refetchOrganisations();
await invalidateProjects();
}}
/>
<SprintModal
@@ -947,7 +873,7 @@ function OrganisationsDialog({
if (!open) setEditingSprint(null);
}}
completeAction={async () => {
await refetchOrganisations();
await invalidateSprints();
}}
/>
</>
@@ -1105,18 +1031,6 @@ function OrganisationsDialog({
) : (
<div className="flex flex-col gap-2 w-full min-w-0">
<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={
"data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25"
}

View File

@@ -13,7 +13,8 @@ import {
} from "@/components/ui/dialog";
import { Field } from "@/components/ui/field";
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";
const keyify = (value: string) =>
@@ -40,6 +41,8 @@ export function ProjectModal({
onOpenChange?: (open: boolean) => void;
}) {
const { user } = useAuthenticatedSession();
const createProject = useCreateProject();
const updateProject = useUpdateProject();
const isControlled = controlledOpen !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
@@ -106,12 +109,11 @@ export function ProjectModal({
setSubmitting(true);
try {
if (isEdit && existingProject) {
await project.update({
projectId: existingProject.id,
const proj = await updateProject.mutateAsync({
id: existingProject.id,
key,
name,
onSuccess: async (data) => {
const proj = data as ProjectRecord;
});
setOpen(false);
reset();
toast.success("Project updated");
@@ -120,52 +122,35 @@ export function ProjectModal({
} catch (actionErr) {
console.error(actionErr);
}
},
onError: (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error updating project: ${message}`, {
dismissible: false,
});
},
});
} else {
if (!organisationId) {
setError("select an organisation first");
return;
}
await project.create({
const proj = await createProject.mutateAsync({
key,
name,
organisationId: organisationId,
onSuccess: async (data) => {
const proj = data as ProjectRecord;
organisationId,
});
setOpen(false);
reset();
toast.success(`Created Project ${proj.name}`, {
dismissible: false,
});
try {
await completeAction?.(proj);
} catch (actionErr) {
console.error(actionErr);
}
},
onError: (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error creating project: ${message}`, {
dismissible: false,
});
},
});
}
} catch (err) {
const message = parseError(err as Error);
console.error(err);
setError(`failed to ${isEdit ? "update" : "create"} project`);
setError(message || `failed to ${isEdit ? "update" : "create"} project`);
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 { Label } from "@/components/ui/label";
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";
const SPRINT_NAME_MAX_LENGTH = 64;
@@ -67,6 +68,8 @@ export function SprintModal({
onOpenChange?: (open: boolean) => void;
}) {
const { user } = useAuthenticatedSession();
const createSprint = useCreateSprint();
const updateSprint = useUpdateSprint();
const isControlled = controlledOpen !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
@@ -146,13 +149,13 @@ export function SprintModal({
try {
if (isEdit && existingSprint) {
await sprint.update({
sprintId: existingSprint.id,
const data = await updateSprint.mutateAsync({
id: existingSprint.id,
name,
color: colour,
startDate,
endDate,
onSuccess: async (data) => {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
setOpen(false);
reset();
toast.success("Sprint updated");
@@ -161,52 +164,42 @@ export function SprintModal({
} catch (actionErr) {
console.error(actionErr);
}
},
onError: (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error updating sprint: ${message}`, {
dismissible: false,
});
},
});
} else {
if (!projectId) {
setError("select a project first");
return;
}
await sprint.create({
projectId: projectId,
const data = await createSprint.mutateAsync({
projectId,
name,
color: colour,
startDate,
endDate,
onSuccess: async (data) => {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
setOpen(false);
reset();
toast.success(
<>
Created sprint <span style={{ color: data.color }}>{data.name}</span>
</>,
{
dismissible: false,
},
);
try {
await completeAction?.(data);
} catch (actionErr) {
console.error(actionErr);
}
},
onError: (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error creating sprint: ${message}`, {
dismissible: false,
});
},
});
}
} catch (submitError) {
const message = parseError(submitError as Error);
console.error(submitError);
setError(`failed to ${isEdit ? "update" : "create"} sprint`);
setError(message || `failed to ${isEdit ? "update" : "create"} sprint`);
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 Icon from "@/components/ui/icon";
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";
export function UploadAvatar({
@@ -25,6 +26,7 @@ export function UploadAvatar({
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showEdit, setShowEdit] = useState(false);
const uploadAvatar = useUploadAvatar();
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -33,9 +35,8 @@ export function UploadAvatar({
setUploading(true);
setError(null);
await user.uploadAvatar({
file,
onSuccess: (url) => {
try {
const url = await uploadAvatar.mutateAsync(file);
onAvatarUploaded(url);
setUploading(false);
@@ -48,17 +49,15 @@ export function UploadAvatar({
dismissible: false,
},
);
},
onError: (err) => {
const message = parseError(err);
} catch (err) {
const message = parseError(err as Error);
setError(message);
setUploading(false);
toast.error(`Error uploading avatar: ${message}`, {
dismissible: false,
});
},
});
}
};
return (