full comments system

This commit is contained in:
Oliver Bryan
2026-01-21 19:10:28 +00:00
parent 0d2195cab4
commit 8f87fc8acf
28 changed files with 1451 additions and 7 deletions

View File

@@ -0,0 +1,137 @@
import type { IssueCommentResponse } from "@sprint/shared";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button";
import { Textarea } from "@/components/ui/textarea";
import { useCreateIssueComment, useDeleteIssueComment, useIssueComments } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils";
const formatTimestamp = (value?: string | Date | null) => {
if (!value) return "";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
export function IssueComments({ issueId, className }: { issueId: number; className?: string }) {
const { user } = useSession();
const { data = [], isLoading } = useIssueComments(issueId);
const createComment = useCreateIssueComment();
const deleteComment = useDeleteIssueComment();
const [body, setBody] = useState("");
const [deletingId, setDeletingId] = useState<number | null>(null);
const sortedComments = useMemo(() => {
return [...data].sort((a, b) => {
const aDate =
a.Comment.createdAt instanceof Date ? a.Comment.createdAt : new Date(a.Comment.createdAt ?? 0);
const bDate =
b.Comment.createdAt instanceof Date ? b.Comment.createdAt : new Date(b.Comment.createdAt ?? 0);
return aDate.getTime() - bDate.getTime();
});
}, [data]);
const handleSubmit = async () => {
const trimmed = body.trim();
if (!trimmed) return;
try {
await createComment.mutateAsync({
issueId,
body: trimmed,
});
setBody("");
} catch (error) {
toast.error(`Error adding comment: ${parseError(error as Error)}`);
}
};
const handleDelete = async (comment: IssueCommentResponse) => {
setDeletingId(comment.Comment.id);
try {
await deleteComment.mutateAsync({ id: comment.Comment.id });
} catch (error) {
toast.error(`Error deleting comment: ${parseError(error as Error)}`);
} finally {
setDeletingId(null);
}
};
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="flex items-center justify-between">
<span className="text-sm font-600">Comments</span>
<span className="text-xs text-muted-foreground">{sortedComments.length}</span>
</div>
<div className="flex gap-2">
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
void handleSubmit();
}
}}
placeholder="Leave a comment..."
className="text-sm resize-none !bg-background h-"
disabled={createComment.isPending}
/>
<Button
size="lg"
onClick={handleSubmit}
disabled={createComment.isPending || body.trim() === ""}
className="px-4"
>
{createComment.isPending ? "Posting..." : "Post comment"}
</Button>
</div>
<div className="flex flex-col gap-2">
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading comments...</div>
) : sortedComments.length === 0 ? (
<div className="text-sm text-muted-foreground">No comments yet.</div>
) : (
sortedComments.map((comment) => {
const isAuthor = user?.id === comment.Comment.userId;
const timestamp = formatTimestamp(comment.Comment.createdAt);
return (
<div key={comment.Comment.id} className="border border-border/60 bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
<SmallUserDisplay user={comment.User} className="text-sm" />
{timestamp ? (
<span className="text-[11px] text-muted-foreground">{timestamp}</span>
) : null}
</div>
{isAuthor ? (
<IconButton
variant="ghost"
onClick={() => handleDelete(comment)}
disabled={deletingId === comment.Comment.id}
title="Delete comment"
>
<Icon icon="trash" className="size-4" />
</IconButton>
) : null}
</div>
<p className="text-sm whitespace-pre-wrap pt-2">{comment.Comment.body}</p>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { IssueResponse, SprintRecord, UserRecord } from "@sprint/shared";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { IssueComments } from "@/components/issue-comments";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useSession } from "@/components/session-provider";
import SmallSprintDisplay from "@/components/small-sprint-display";
@@ -317,7 +318,7 @@ export function IssueDetails({
</div>
)}
<div className="flex flex-col w-full p-2 py-2 gap-2">
<div className="flex flex-col w-full p-2 py-2 gap-2 max-h-[75vh] overflow-y-scroll">
<div className="flex gap-2">
<StatusSelect
statuses={statuses}
@@ -373,7 +374,7 @@ export function IssueDetails({
}}
placeholder="Add a description..."
disabled={isSavingDescription}
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background"
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background min-h-fit"
/>
) : (
<Button
@@ -388,7 +389,6 @@ export function IssueDetails({
Add description
</Button>
)}
<div className="flex items-center gap-2">
<span className="text-sm">Sprint:</span>
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
@@ -423,6 +423,8 @@ export function IssueDetails({
</div>
)}
<IssueComments issueId={issueData.Issue.id} className="pt-2" />
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}

View File

@@ -27,7 +27,7 @@ export function IssuesTable({
params.set("o", selectedOrganisation.Organisation.slug.toLowerCase());
params.set("p", selectedProject.Project.key.toLowerCase());
params.set("i", issueNumber.toString());
return `/app?${params.toString()}`;
return `/issues?${params.toString()}`;
};
const handleLinkClick = (e: React.MouseEvent) => {

View File

@@ -56,7 +56,7 @@ export default function LogInForm() {
const data = await res.json();
setCsrfToken(data.csrfToken);
setUser(data.user);
const next = searchParams.get("next") || "/app";
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
}
// unauthorized
@@ -94,7 +94,7 @@ export default function LogInForm() {
const data = await res.json();
setCsrfToken(data.csrfToken);
setUser(data.user);
const next = searchParams.get("next") || "/app";
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
}
// bad request (probably a bad user input)

View File

@@ -1,4 +1,5 @@
export * from "@/lib/query/hooks/derived";
export * from "@/lib/query/hooks/issue-comments";
export * from "@/lib/query/hooks/issues";
export * from "@/lib/query/hooks/organisations";
export * from "@/lib/query/hooks/projects";

View File

@@ -0,0 +1,44 @@
import type {
IssueCommentCreateRequest,
IssueCommentDeleteRequest,
IssueCommentRecord,
IssueCommentResponse,
SuccessResponse,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { issueComment } from "@/lib/server";
export function useIssueComments(issueId?: number | null) {
return useQuery<IssueCommentResponse[]>({
queryKey: queryKeys.issueComments.byIssue(issueId ?? 0),
queryFn: () => issueComment.byIssue(issueId ?? 0),
enabled: Boolean(issueId),
});
}
export function useCreateIssueComment() {
const queryClient = useQueryClient();
return useMutation<IssueCommentRecord, Error, IssueCommentCreateRequest>({
mutationKey: ["issue-comments", "create"],
mutationFn: issueComment.create,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.issueComments.byIssue(variables.issueId),
});
},
});
}
export function useDeleteIssueComment() {
const queryClient = useQueryClient();
return useMutation<SuccessResponse, Error, IssueCommentDeleteRequest>({
mutationKey: ["issue-comments", "delete"],
mutationFn: issueComment.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issueComments.all });
},
});
}

View File

@@ -16,6 +16,10 @@ export const queryKeys = {
statusCount: (organisationId: number, status: string) =>
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
},
issueComments: {
all: ["issue-comments"] as const,
byIssue: (issueId: number) => [...queryKeys.issueComments.all, "by-issue", issueId] as const,
},
sprints: {
all: ["sprints"] as const,
byProject: (projectId: number) => [...queryKeys.sprints.all, "by-project", projectId] as const,

View File

@@ -1,6 +1,7 @@
import type { ApiError } from "@sprint/shared";
export * as issue from "@/lib/server/issue";
export * as issueComment from "@/lib/server/issue-comment";
export * as organisation from "@/lib/server/organisation";
export * as project from "@/lib/server/project";
export * as sprint from "@/lib/server/sprint";

View File

@@ -0,0 +1,19 @@
import type { IssueCommentResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byIssue(issueId: number): Promise<IssueCommentResponse[]> {
const url = new URL(`${getServerURL()}/issue-comments/by-issue`);
url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue comments (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -0,0 +1,29 @@
import type { IssueCommentCreateRequest, IssueCommentRecord } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function create(request: IssueCommentCreateRequest): Promise<IssueCommentRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue-comment/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to create comment (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as IssueCommentRecord;
if (!data.id) {
throw new Error(`failed to create comment (${res.status})`);
}
return data;
}

View File

@@ -0,0 +1,24 @@
import type { IssueCommentDeleteRequest, SuccessResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function remove(request: IssueCommentDeleteRequest): Promise<SuccessResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue-comment/delete`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete comment (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -0,0 +1,3 @@
export { byIssue } from "@/lib/server/issue-comment/byIssue";
export { create } from "@/lib/server/issue-comment/create";
export { remove as delete } from "@/lib/server/issue-comment/delete";

View File

@@ -7,8 +7,8 @@ import { SelectionProvider } from "@/components/selection-provider";
import { RequireAuth, SessionProvider } from "@/components/session-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import Issues from "@/pages/Issues";
import Font from "@/pages/Font";
import Issues from "@/pages/Issues";
import Landing from "@/pages/Landing";
import Login from "@/pages/Login";
import NotFound from "@/pages/NotFound";