full sonner implementation

This commit is contained in:
Oliver Bryan
2026-01-12 04:55:20 +00:00
parent cd9979f813
commit 2b0bf94134
18 changed files with 209 additions and 39 deletions

View File

@@ -53,12 +53,14 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.13.0",
"react-dom": "^19.1.0",
"react-resizable-panels": "^4.0.15",
"react-router-dom": "^7.10.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
},
@@ -617,6 +619,8 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
@@ -695,6 +699,8 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],

View File

@@ -25,12 +25,14 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.13.0",
"react-dom": "^19.1.0",
"react-resizable-panels": "^4.0.15",
"react-router-dom": "^7.10.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},

View File

@@ -214,3 +214,13 @@
height: 16px !important;
border: 1px solid white !important;
}
[data-sonner-toast] {
transition: none !important;
padding: 10px 15px 10px 15px !important;
width: max-content !important;
max-width: 90vw !important;
display: inline-flex !important;
align-items: center !important;
white-space: nowrap !important;
}

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
@@ -54,6 +55,10 @@ function AccountDialog({ trigger }: { trigger?: ReactNode }) {
setError(errorMessage);
},
});
toast.success(`Account updated successfully`, {
dismissible: false,
});
};
return (

View File

@@ -1,3 +1,4 @@
import type { UserRecord } from "@issue/shared";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -20,7 +21,7 @@ export function AddMemberDialog({
organisationId: number;
existingMembers: string[];
trigger?: React.ReactNode;
onSuccess?: () => void | Promise<void>;
onSuccess?: (user: UserRecord) => void | Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [username, setUsername] = useState("");
@@ -59,10 +60,12 @@ export function AddMemberDialog({
setSubmitting(true);
try {
let userId: number | null = null;
let userData: UserRecord;
await user.byUsername({
username,
onSuccess: (userData) => {
userId = userData.id;
onSuccess: (data: UserRecord) => {
userData = data;
userId = data.id;
},
onError: (err) => {
setError(err || "user not found");
@@ -82,7 +85,7 @@ export function AddMemberDialog({
setOpen(false);
reset();
try {
await onSuccess?.();
await onSuccess?.(userData);
} catch (actionErr) {
console.error(actionErr);
}

View File

@@ -39,7 +39,7 @@ export function CreateIssue({
members?: UserRecord[];
statuses: Record<string, string>;
trigger?: React.ReactNode;
completeAction?: (issueId: number) => void | Promise<void>;
completeAction?: (issueNumber: number) => void | Promise<void>;
}) {
const { user } = useAuthenticatedSession();
@@ -108,7 +108,7 @@ export function CreateIssue({
setOpen(false);
reset();
try {
await completeAction?.(data.id);
await completeAction?.(data.number);
} catch (actionErr) {
console.error(actionErr);
}

View File

@@ -1,4 +1,9 @@
import { ORG_DESCRIPTION_MAX_LENGTH, ORG_NAME_MAX_LENGTH, ORG_SLUG_MAX_LENGTH } from "@issue/shared";
import {
ORG_DESCRIPTION_MAX_LENGTH,
ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH,
type OrganisationRecord,
} from "@issue/shared";
import { type FormEvent, useState } from "react";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
@@ -28,7 +33,7 @@ export function CreateOrganisation({
completeAction,
}: {
trigger?: React.ReactNode;
completeAction?: (organisationId: number) => void | Promise<void>;
completeAction?: (org: OrganisationRecord) => void | Promise<void>;
}) {
const { user } = useAuthenticatedSession();
@@ -83,7 +88,7 @@ export function CreateOrganisation({
setOpen(false);
reset();
try {
await completeAction?.(data.id);
await completeAction?.(data);
} catch (actionErr) {
console.error(actionErr);
}

View File

@@ -28,7 +28,7 @@ export function CreateProject({
}: {
organisationId?: number;
trigger?: React.ReactNode;
completeAction?: (projectId: number) => void | Promise<void>;
completeAction?: (project: ProjectRecord) => void | Promise<void>;
}) {
const { user } = useAuthenticatedSession();
@@ -93,7 +93,7 @@ export function CreateProject({
setOpen(false);
reset();
try {
await completeAction?.(project.id);
await completeAction?.(project);
} catch (actionErr) {
console.error(actionErr);
}

View File

@@ -1,4 +1,4 @@
import { DEFAULT_SPRINT_COLOUR } from "@issue/shared";
import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@issue/shared";
import { type FormEvent, useMemo, useState } from "react";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
@@ -53,7 +53,7 @@ export function CreateSprint({
}: {
projectId?: number;
trigger?: React.ReactNode;
completeAction?: () => void | Promise<void>;
completeAction?: (sprint: SprintRecord) => void | Promise<void>;
}) {
const { user } = useAuthenticatedSession();
@@ -122,14 +122,14 @@ export function CreateSprint({
await sprint.create({
projectId,
name,
color: colour, // hm - always unsure which i should use
color: colour, // hm - always unsure which i should use
startDate,
endDate,
onSuccess: async () => {
onSuccess: async (data) => {
setOpen(false);
reset();
try {
await completeAction?.();
await completeAction?.(data);
} catch (actionErr) {
console.error(actionErr);
}

View File

@@ -1,6 +1,7 @@
import type { IssueResponse, ProjectResponse, SprintRecord, UserRecord } from "@issue/shared";
import { Check, Link, Trash, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display";
import { StatusSelect } from "@/components/status-select";
@@ -83,6 +84,13 @@ export function IssueDetailPane({
issueId: issueData.Issue.id,
assigneeId: newAssigneeId,
onSuccess: () => {
const user = members.find((member) => member.id === newAssigneeId);
toast.success(
`Assigned ${user?.name} to ${issueID(project.Project.key, issueData.Issue.number)}`,
{
dismissible: false,
},
);
onIssueUpdate?.();
},
onError: (error) => {
@@ -99,6 +107,13 @@ export function IssueDetailPane({
issueId: issueData.Issue.id,
status: value,
onSuccess: () => {
toast.success(
<>
{issueID(project.Project.key, issueData.Issue.number)}'s status updated to{" "}
<StatusTag status={value} colour={statuses[value]} />
</>,
{ dismissible: false },
);
onIssueUpdate?.();
},
onError: (error) => {

View File

@@ -1,4 +1,4 @@
import type { OrganisationResponse } from "@issue/shared";
import type { OrganisationRecord, OrganisationResponse } from "@issue/shared";
import { useState } from "react";
import { CreateOrganisation } from "@/components/create-organisation";
import { Button } from "@/components/ui/button";
@@ -27,7 +27,7 @@ export function OrganisationSelect({
organisations: OrganisationResponse[];
selectedOrganisation: OrganisationResponse | null;
onSelectedOrganisationChange: (organisation: OrganisationResponse | null) => void;
onCreateOrganisation?: (organisationId: number) => void | Promise<void>;
onCreateOrganisation?: (org: OrganisationRecord) => void | Promise<void>;
placeholder?: string;
contentClass?: string;
showLabel?: boolean;
@@ -79,9 +79,9 @@ export function OrganisationSelect({
Create Organisation
</Button>
}
completeAction={async (organisationId) => {
completeAction={async (org) => {
try {
await onCreateOrganisation?.(organisationId);
await onCreateOrganisation?.(org);
} catch (err) {
console.error(err);
}

View File

@@ -7,6 +7,7 @@ import {
import { ChevronDown, ChevronUp, EllipsisVertical, Plus, X } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { AddMemberDialog } from "@/components/add-member-dialog";
import { OrganisationSelect } from "@/components/organisation-select";
import { useAuthenticatedSession } from "@/components/session-provider";
@@ -26,6 +27,7 @@ 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 } from "@/lib/server";
import { capitalise } from "@/lib/utils";
function OrganisationsDialog({
trigger,
@@ -129,6 +131,10 @@ function OrganisationsDialog({
console.error(error);
},
});
toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, {
dismissible: false,
});
} catch (err) {
console.error(err);
}
@@ -162,6 +168,13 @@ function OrganisationsDialog({
console.error(error);
},
});
toast.success(
`Removed ${memberName} from ${selectedOrganisation.Organisation.name} successfully`,
{
dismissible: false,
},
);
} catch (err) {
console.error(err);
}
@@ -175,7 +188,10 @@ function OrganisationsDialog({
}
}, [selectedOrganisation]);
const updateStatuses = async (newStatuses: Record<string, string>) => {
const updateStatuses = async (
newStatuses: Record<string, string>,
statusRemoved?: { name: string; colour: string },
) => {
if (!selectedOrganisation) return;
try {
@@ -184,6 +200,18 @@ function OrganisationsDialog({
statuses: newStatuses,
onSuccess: () => {
setStatuses(newStatuses);
if (statusRemoved) {
toast.success(
<>
Removed{" "}
<StatusTag status={statusRemoved.name} colour={statusRemoved.colour} /> status
successfully
</>,
{
dismissible: false,
},
);
}
void refetchOrganisations();
},
onError: (error) => {
@@ -213,6 +241,17 @@ function OrganisationsDialog({
const newStatuses = { ...statuses };
newStatuses[trimmed] = newStatusColour;
await updateStatuses(newStatuses);
toast.success(
<>
Created <StatusTag status={newStatusName.trim()} colour={newStatusColour} /> status
successfully
</>,
{
dismissible: false,
},
);
setNewStatusName("");
setNewStatusColour(DEFAULT_STATUS_COLOUR);
setIsCreatingStatus(false);
@@ -238,6 +277,7 @@ function OrganisationsDialog({
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) => {
@@ -262,6 +302,15 @@ function OrganisationsDialog({
];
await updateStatuses(Object.fromEntries(nextStatuses.map((status) => [status, statuses[status]])));
toast.success(
<>
Moved <StatusTag status={status} colour={statuses[status]} /> from position {currentIndex + 1}{" "}
to {nextIndex + 1} successfully
</>,
{
dismissible: false,
},
);
};
const confirmRemoveStatus = async () => {
@@ -275,6 +324,7 @@ function OrganisationsDialog({
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("");
@@ -319,8 +369,11 @@ function OrganisationsDialog({
`${org?.Organisation.id}`,
);
}}
onCreateOrganisation={async (organisationId) => {
await refetchOrganisations({ selectOrganisationId: organisationId });
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"
@@ -422,7 +475,15 @@ function OrganisationsDialog({
<AddMemberDialog
organisationId={selectedOrganisation.Organisation.id}
existingMembers={members.map((m) => m.User.username)}
onSuccess={refetchMembers}
onSuccess={(user) => {
toast.success(
`${user.name} added to ${selectedOrganisation.Organisation.name} successfully`,
{
dismissible: false,
},
);
refetchMembers();
}}
trigger={
<Button variant="outline">
Add user <Plus className="size-4" />
@@ -579,8 +640,11 @@ function OrganisationsDialog({
setSelectedOrganisation(org);
localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`);
}}
onCreateOrganisation={async (organisationId) => {
await refetchOrganisations({ selectOrganisationId: organisationId });
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

@@ -1,4 +1,4 @@
import type { ProjectResponse } from "@issue/shared";
import type { ProjectRecord, ProjectResponse } from "@issue/shared";
import { useState } from "react";
import { CreateProject } from "@/components/create-project";
import { Button } from "@/components/ui/button";
@@ -28,7 +28,7 @@ export function ProjectSelect({
selectedProject: ProjectResponse | null;
organisationId: number | undefined;
onSelectedProjectChange: (project: ProjectResponse | null) => void;
onCreateProject?: (projectId: number) => void | Promise<void>;
onCreateProject?: (project: ProjectRecord) => void | Promise<void>;
placeholder?: string;
showLabel?: boolean;
label?: string;
@@ -75,9 +75,9 @@ export function ProjectSelect({
Create Project
</Button>
}
completeAction={async (projectId) => {
completeAction={async (project) => {
try {
await onCreateProject?.(projectId);
await onCreateProject?.(project);
} catch (err) {
console.error(err);
}

View File

@@ -0,0 +1,32 @@
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "0",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -1,5 +1,6 @@
import { Edit } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import Avatar from "@/components/avatar";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -43,6 +44,10 @@ export function UploadAvatar({
setUploading(false);
},
});
toast.success(`Avatar uploaded successfully`, {
dismissible: false,
});
};
return (

View File

@@ -4,6 +4,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { RequireAuth, SessionProvider } from "@/components/session-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import App from "@/pages/App";
import Font from "@/pages/Font";
import Landing from "@/pages/Landing";
@@ -44,6 +45,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
</Routes>
</SessionProvider>
</BrowserRouter>
<Toaster visibleToasts={1} duration={2000} />
</ThemeProvider>
</React.StrictMode>,
);

View File

@@ -9,6 +9,7 @@ import type {
UserRecord,
} from "@issue/shared";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import AccountDialog from "@/components/account-dialog";
import { CreateIssue } from "@/components/create-issue";
import { CreateSprint } from "@/components/create-sprint";
@@ -32,6 +33,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
import { issue, organisation, project, sprint } from "@/lib/server";
import { issueID } from "@/lib/utils";
const BREATHING_ROOM = 1;
@@ -385,8 +387,11 @@ export default function App() {
issueNumber: null,
});
}}
onCreateOrganisation={async (organisationId) => {
await refetchOrganisations({ selectOrganisationId: organisationId });
onCreateOrganisation={async (org) => {
toast.success(`Created Organisation ${org.name}`, {
dismissible: false,
});
await refetchOrganisations({ selectOrganisationId: org.id });
}}
showLabel
/>
@@ -406,10 +411,13 @@ export default function App() {
issueNumber: null,
});
}}
onCreateProject={async (projectId) => {
onCreateProject={async (project) => {
if (!selectedOrganisation) return;
toast.success(`Created Project ${project.name}`, {
dismissible: false,
});
await refetchProjects(selectedOrganisation.Organisation.id, {
selectProjectId: projectId,
selectProjectId: project.id,
});
}}
showLabel
@@ -422,16 +430,31 @@ export default function App() {
sprints={sprints}
members={members}
statuses={selectedOrganisation.Organisation.statuses}
completeAction={async () => {
completeAction={async (issueNumber) => {
if (!selectedProject) return;
toast.success(
`Created ${issueID(selectedProject.Project.key, issueNumber)}`,
{
dismissible: false,
},
);
await refetchIssues();
}}
/>
{isAdmin && (
<CreateSprint
projectId={selectedProject?.Project.id}
completeAction={async () => {
completeAction={async (sprint) => {
if (!selectedProject) return;
toast.success(
<>
Created sprint{" "}
<span style={{ color: sprint.color }}>{sprint.name}</span>
</>,
{
dismissible: false,
},
);
await refetchSprints(selectedProject?.Project.id);
}}
/>

View File

@@ -1,13 +1,11 @@
# HIGH PRIORITY
- fix colour picker alignment in new status form
- projects
- project management menu (will this be accessed from the organisations-dialog? or will it be a separate menu in the user select)
- sprints
- timeline display
- display sprints
- add toasts app-wide
- for almost every network interaction that is user prompted
- the interface feels snappy but sometimes it's hard to tell if your changes are volatile or saved
- issues
- sprint
- edit title & description