diff --git a/packages/frontend/src/components/account-dialog.tsx b/packages/frontend/src/components/account-dialog.tsx index a1bafe6..45f130c 100644 --- a/packages/frontend/src/components/account-dialog.tsx +++ b/packages/frontend/src/components/account-dialog.tsx @@ -50,14 +50,18 @@ function AccountDialog({ trigger }: { trigger?: ReactNode }) { setUser(data); setPassword(""); setOpen(false); - }, - onError: (errorMessage) => { - setError(errorMessage); - }, - }); - toast.success(`Account updated successfully`, { - dismissible: false, + toast.success(`Account updated successfully`, { + dismissible: false, + }); + }, + onError: (error) => { + setError(error); + + toast.error(`Error updating account: ${error}`, { + dismissible: false, + }); + }, }); }; diff --git a/packages/frontend/src/components/add-member-dialog.tsx b/packages/frontend/src/components/add-member-dialog.tsx index 3f5ee89..5752d91 100644 --- a/packages/frontend/src/components/add-member-dialog.tsx +++ b/packages/frontend/src/components/add-member-dialog.tsx @@ -1,5 +1,6 @@ import type { UserRecord } from "@issue/shared"; import { type FormEvent, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -67,9 +68,13 @@ export function AddMemberDialog({ userData = data; userId = data.id; }, - onError: (err) => { - setError(err || "user not found"); + onError: (error) => { + setError(error || "user not found"); setSubmitting(false); + + toast.error(`Error adding member: ${error}`, { + dismissible: false, + }); }, }); @@ -90,9 +95,13 @@ export function AddMemberDialog({ console.error(actionErr); } }, - onError: (err) => { - setError(err || "failed to add member"); + onError: (error) => { + setError(error || "failed to add member"); setSubmitting(false); + + toast.error(`Error adding member: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { diff --git a/packages/frontend/src/components/create-issue.tsx b/packages/frontend/src/components/create-issue.tsx index b817573..900d167 100644 --- a/packages/frontend/src/components/create-issue.tsx +++ b/packages/frontend/src/components/create-issue.tsx @@ -6,6 +6,7 @@ import { } from "@issue/shared"; import { type FormEvent, useState } from "react"; +import { toast } from "sonner"; import { useAuthenticatedSession } from "@/components/session-provider"; import { StatusSelect } from "@/components/status-select"; import StatusTag from "@/components/status-tag"; @@ -33,6 +34,7 @@ export function CreateIssue({ statuses, trigger, completeAction, + errorAction, }: { projectId?: number; sprints?: SprintRecord[]; @@ -40,6 +42,7 @@ export function CreateIssue({ statuses: Record; trigger?: React.ReactNode; completeAction?: (issueNumber: number) => void | Promise; + errorAction?: (errorMessage: string) => void | Promise; }) { const { user } = useAuthenticatedSession(); @@ -113,9 +116,13 @@ export function CreateIssue({ console.error(actionErr); } }, - onError: (message) => { - setError(message); + onError: (error) => { + setError(error); setSubmitting(false); + + toast.error(`Error creating issue: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { diff --git a/packages/frontend/src/components/create-organisation.tsx b/packages/frontend/src/components/create-organisation.tsx index 75d1b43..3041ed5 100644 --- a/packages/frontend/src/components/create-organisation.tsx +++ b/packages/frontend/src/components/create-organisation.tsx @@ -31,9 +31,11 @@ const slugify = (value: string) => export function CreateOrganisation({ trigger, completeAction, + errorAction, }: { trigger?: React.ReactNode; completeAction?: (org: OrganisationRecord) => void | Promise; + errorAction?: (errorMessage: string) => void | Promise; }) { const { user } = useAuthenticatedSession(); @@ -93,9 +95,14 @@ export function CreateOrganisation({ console.error(actionErr); } }, - onError: (err) => { + onError: async (err) => { setError(err || "failed to create organisation"); setSubmitting(false); + try { + await errorAction?.(err || "failed to create organisation"); + } catch (actionErr) { + console.error(actionErr); + } }, }); } catch (err) { diff --git a/packages/frontend/src/components/create-project.tsx b/packages/frontend/src/components/create-project.tsx index e6b2bcb..97b09ad 100644 --- a/packages/frontend/src/components/create-project.tsx +++ b/packages/frontend/src/components/create-project.tsx @@ -1,5 +1,6 @@ import { PROJECT_NAME_MAX_LENGTH, type ProjectRecord } from "@issue/shared"; import { type FormEvent, useState } from "react"; +import { toast } from "sonner"; import { useAuthenticatedSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; import { @@ -98,9 +99,13 @@ export function CreateProject({ console.error(actionErr); } }, - onError: (message) => { - setError(message); + onError: (error) => { + setError(error); setSubmitting(false); + + toast.error(`Error creating project: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { diff --git a/packages/frontend/src/components/create-sprint.tsx b/packages/frontend/src/components/create-sprint.tsx index b022b62..aa708ac 100644 --- a/packages/frontend/src/components/create-sprint.tsx +++ b/packages/frontend/src/components/create-sprint.tsx @@ -1,5 +1,6 @@ import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@issue/shared"; import { type FormEvent, useMemo, useState } from "react"; +import { toast } from "sonner"; import { useAuthenticatedSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; @@ -134,9 +135,13 @@ export function CreateSprint({ console.error(actionErr); } }, - onError: (message) => { - setError(message); + onError: (error) => { + setError(error); setSubmitting(false); + + toast.error(`Error creating sprint: ${error}`, { + dismissible: false, + }); }, }); } catch (submitError) { diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index f2a1f9e..4b0a540 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -14,6 +14,7 @@ import { SelectTrigger } from "@/components/ui/select"; import { UserSelect } from "@/components/user-select"; import { issue } from "@/lib/server"; import { issueID } from "@/lib/utils"; +import SmallSprintDisplay from "./small-sprint-display"; import { SprintSelect } from "./sprint-select"; export function IssueDetailPane({ @@ -68,10 +69,40 @@ export function IssueDetailPane({ sprintId: newSprintId, onSuccess: () => { onIssueUpdate?.(); + + toast.success( + <> + Successfully updated sprint to{" "} + {value === "unassigned" ? ( + "Unassigned" + ) : ( + s.id === newSprintId)} /> + )}{" "} + for {issueID(project.Project.key, issueData.Issue.number)} + , + { + dismissible: false, + }, + ); }, onError: (error) => { console.error("error updating sprint:", error); setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); + + toast.error( + <> + Error updating sprint to{" "} + {value === "unassigned" ? ( + "Unassigned" + ) : ( + s.id === newSprintId)} /> + )}{" "} + for {issueID(project.Project.key, issueData.Issue.number)} + , + { + dismissible: false, + }, + ); }, }); }; @@ -96,6 +127,10 @@ export function IssueDetailPane({ onError: (error) => { console.error("error updating assignee:", error); setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned"); + + toast.error(`Error updating assignee: ${error}`, { + dismissible: false, + }); }, }); }; @@ -119,6 +154,10 @@ export function IssueDetailPane({ onError: (error) => { console.error("error updating status:", error); setStatus(issueData.Issue.status); + + toast.error(`Error updating status: ${error}`, { + dismissible: false, + }); }, }); }; @@ -148,9 +187,23 @@ export function IssueDetailPane({ issueId: issueData.Issue.id, onSuccess: async () => { await onIssueDelete?.(issueData.Issue.id); + + toast.success(`Deleted issue ${issueID(project.Project.key, issueData.Issue.number)}`, { + dismissible: false, + }); }, onError: (error) => { - console.error("error deleting issue:", error); + console.error( + `error deleting issue ${issueID(project.Project.key, issueData.Issue.number)}`, + error, + ); + + toast.error( + `Error deleting issue ${issueID(project.Project.key, issueData.Issue.number)}: ${error}`, + { + dismissible: false, + }, + ); }, }); setDeleteOpen(false); diff --git a/packages/frontend/src/components/organisation-select.tsx b/packages/frontend/src/components/organisation-select.tsx index a3235af..67ffb74 100644 --- a/packages/frontend/src/components/organisation-select.tsx +++ b/packages/frontend/src/components/organisation-select.tsx @@ -1,5 +1,6 @@ import type { OrganisationRecord, OrganisationResponse } from "@issue/shared"; import { useState } from "react"; +import { toast } from "sonner"; import { CreateOrganisation } from "@/components/create-organisation"; import { Button } from "@/components/ui/button"; import { @@ -86,6 +87,11 @@ export function OrganisationSelect({ console.error(err); } }} + errorAction={async (errorMessage) => { + toast.error(`Error creating organisation: ${errorMessage}`, { + dismissible: false, + }); + }} /> diff --git a/packages/frontend/src/components/organisations-dialog.tsx b/packages/frontend/src/components/organisations-dialog.tsx index 8c35cb2..93e4d72 100644 --- a/packages/frontend/src/components/organisations-dialog.tsx +++ b/packages/frontend/src/components/organisations-dialog.tsx @@ -98,6 +98,10 @@ function OrganisationsDialog({ onError: (error) => { console.error(error); setMembers([]); + + toast.error(`Error fetching members: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { @@ -125,15 +129,23 @@ function OrganisationsDialog({ role: newRole, onSuccess: () => { closeConfirmDialog(); + + toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, { + dismissible: false, + }); + void refetchMembers(); }, onError: (error) => { console.error(error); - }, - }); - toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, { - dismissible: false, + toast.error( + `Error ${action.slice(0, -1)}ing ${memberName} to ${newRole}: ${error}`, + { + dismissible: false, + }, + ); + }, }); } catch (err) { console.error(err); @@ -162,19 +174,27 @@ function OrganisationsDialog({ 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, + }, + ); }, }); - - toast.success( - `Removed ${memberName} from ${selectedOrganisation.Organisation.name} successfully`, - { - dismissible: false, - }, - ); } catch (err) { console.error(err); } @@ -191,6 +211,8 @@ function OrganisationsDialog({ const updateStatuses = async ( newStatuses: Record, statusRemoved?: { name: string; colour: string }, + statusAdded?: { name: string; colour: string }, + statusMoved?: { name: string; colour: string; currentIndex: number; nextIndex: number }, ) => { if (!selectedOrganisation) return; @@ -200,7 +222,17 @@ function OrganisationsDialog({ statuses: newStatuses, onSuccess: () => { setStatuses(newStatuses); - if (statusRemoved) { + if (statusAdded) { + toast.success( + <> + Created {" "} + status successfully + , + { + dismissible: false, + }, + ); + } else if (statusRemoved) { toast.success( <> Removed{" "} @@ -211,11 +243,58 @@ function OrganisationsDialog({ 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, + }, + ); + } }, }); } catch (err) { @@ -240,17 +319,7 @@ function OrganisationsDialog({ } const newStatuses = { ...statuses }; newStatuses[trimmed] = newStatusColour; - await updateStatuses(newStatuses); - - toast.success( - <> - Created status - successfully - , - { - dismissible: false, - }, - ); + await updateStatuses(newStatuses, undefined, { name: trimmed, colour: newStatusColour }); setNewStatusName(""); setNewStatusColour(DEFAULT_STATUS_COLOUR); @@ -282,6 +351,16 @@ function OrganisationsDialog({ }, onError: (error) => { console.error("error checking status usage:", error); + + toast.error( + <> + Error checking status usage for{" "} + : {error} + , + { + dismissible: false, + }, + ); }, }); } catch (err) { @@ -301,15 +380,11 @@ function OrganisationsDialog({ nextStatuses[currentIndex], ]; - await updateStatuses(Object.fromEntries(nextStatuses.map((status) => [status, statuses[status]]))); - toast.success( - <> - Moved from position {currentIndex + 1}{" "} - to {nextIndex + 1} successfully - , - { - dismissible: false, - }, + await updateStatuses( + Object.fromEntries(nextStatuses.map((status) => [status, statuses[status]])), + undefined, + undefined, + { name: status, colour: statuses[status], currentIndex, nextIndex }, ); }; @@ -331,6 +406,17 @@ function OrganisationsDialog({ }, onError: (error) => { console.error("error replacing status:", error); + + toast.error( + <> + Error removing {" "} + from + {selectedOrganisation.Organisation.name}: {error}{" "} + , + { + dismissible: false, + }, + ); }, }); }; @@ -482,6 +568,7 @@ function OrganisationsDialog({ dismissible: false, }, ); + refetchMembers(); }} trigger={ diff --git a/packages/frontend/src/components/timer-display.tsx b/packages/frontend/src/components/timer-display.tsx index ac3335b..ef25fa6 100644 --- a/packages/frontend/src/components/timer-display.tsx +++ b/packages/frontend/src/components/timer-display.tsx @@ -1,5 +1,6 @@ import type { TimerState } from "@issue/shared"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { timer } from "@/lib/server"; import { formatTime } from "@/lib/utils"; @@ -24,9 +25,13 @@ export function TimerDisplay({ issueId }: { issueId: number }) { setWorkTimeMs(data?.workTimeMs ?? 0); setError(null); }, - onError: (message) => { + onError: (error) => { if (!isMounted) return; - setError(message); + setError(error); + + toast.error(`Error fetching timer data: ${error}`, { + dismissible: false, + }); }, }); @@ -42,9 +47,13 @@ export function TimerDisplay({ issueId }: { issueId: number }) { setInactiveWorkTimeMs(totalWorkTime); setError(null); }, - onError: (message) => { + onError: (error) => { if (!isMounted) return; - setError(message); + setError(error); + + toast.error(`Error fetching timer data: ${error}`, { + dismissible: false, + }); }, }); }; diff --git a/packages/frontend/src/components/upload-avatar.tsx b/packages/frontend/src/components/upload-avatar.tsx index 59d82a4..79396ae 100644 --- a/packages/frontend/src/components/upload-avatar.tsx +++ b/packages/frontend/src/components/upload-avatar.tsx @@ -38,15 +38,25 @@ export function UploadAvatar({ onSuccess: (url) => { onAvatarUploaded(url); setUploading(false); - }, - onError: (msg) => { - setError(msg); - setUploading(false); - }, - }); - toast.success(`Avatar uploaded successfully`, { - dismissible: false, + toast.success( +
+ Avatar + Avatar uploaded successfully +
, + { + dismissible: false, + }, + ); + }, + onError: (error) => { + setError(error); + setUploading(false); + + toast.error(`Error uploading avatar: ${error}`, { + dismissible: false, + }); + }, }); }; diff --git a/packages/frontend/src/pages/App.tsx b/packages/frontend/src/pages/App.tsx index 0c0ee2c..4c597b1 100644 --- a/packages/frontend/src/pages/App.tsx +++ b/packages/frontend/src/pages/App.tsx @@ -168,6 +168,12 @@ export default function App() { }, onError: (error) => { console.error("error fetching organisations:", error); + setOrganisations([]); + setSelectedOrganisation(null); + + toast.error(`Error fetching organisations: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { @@ -240,6 +246,12 @@ export default function App() { }, onError: (error) => { console.error("error fetching projects:", error); + setProjects([]); + setSelectedProject(null); + + toast.error(`Error fetching projects: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { @@ -258,6 +270,10 @@ export default function App() { onError: (error) => { console.error("error fetching members:", error); setMembers([]); + + toast.error(`Error fetching members: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { @@ -276,6 +292,10 @@ export default function App() { onError: (error) => { console.error("error fetching sprints:", error); setSprints([]); + + toast.error(`Error fetching sprints: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { @@ -324,6 +344,11 @@ export default function App() { onError: (error) => { console.error("error fetching issues:", error); setIssues([]); + setSelectedIssue(null); + + toast.error(`Error fetching issues: ${error}`, { + dismissible: false, + }); }, }); } catch (err) { @@ -413,9 +438,11 @@ export default function App() { }} onCreateProject={async (project) => { if (!selectedOrganisation) return; + toast.success(`Created Project ${project.name}`, { dismissible: false, }); + await refetchProjects(selectedOrganisation.Organisation.id, { selectProjectId: project.id, }); @@ -440,6 +467,11 @@ export default function App() { ); await refetchIssues(); }} + errorAction={async (errorMessage) => { + toast.error(`Error creating issue: ${errorMessage}`, { + dismissible: false, + }); + }} /> {isAdmin && (