mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
full comments system
This commit is contained in:
137
packages/frontend/src/components/issue-comments.tsx
Normal file
137
packages/frontend/src/components/issue-comments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
44
packages/frontend/src/lib/query/hooks/issue-comments.ts
Normal file
44
packages/frontend/src/lib/query/hooks/issue-comments.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
19
packages/frontend/src/lib/server/issue-comment/byIssue.ts
Normal file
19
packages/frontend/src/lib/server/issue-comment/byIssue.ts
Normal 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();
|
||||
}
|
||||
29
packages/frontend/src/lib/server/issue-comment/create.ts
Normal file
29
packages/frontend/src/lib/server/issue-comment/create.ts
Normal 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;
|
||||
}
|
||||
24
packages/frontend/src/lib/server/issue-comment/delete.ts
Normal file
24
packages/frontend/src/lib/server/issue-comment/delete.ts
Normal 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();
|
||||
}
|
||||
3
packages/frontend/src/lib/server/issue-comment/index.ts
Normal file
3
packages/frontend/src/lib/server/issue-comment/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user