Merge pull request #10 from abhi1693/jarvis/task-conversations
Task comments: reply threading + show reply target
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""add reply_to_comment_id to task_comments
|
||||||
|
|
||||||
|
Revision ID: 9d3d9b9c1a23
|
||||||
|
Revises: 157587037601
|
||||||
|
Create Date: 2026-02-02 08:15:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "9d3d9b9c1a23"
|
||||||
|
down_revision = "157587037601"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("task_comments", sa.Column("reply_to_comment_id", sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_task_comments_reply_to_comment_id",
|
||||||
|
"task_comments",
|
||||||
|
"task_comments",
|
||||||
|
["reply_to_comment_id"],
|
||||||
|
["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_task_comments_reply_to_comment_id", "task_comments", type_="foreignkey")
|
||||||
|
op.drop_column("task_comments", "reply_to_comment_id")
|
||||||
@@ -89,6 +89,12 @@ def create_task_comment(payload: TaskCommentCreate, session: Session = Depends(g
|
|||||||
if payload.author_employee_id is None:
|
if payload.author_employee_id is None:
|
||||||
payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id})
|
payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id})
|
||||||
c = TaskComment(**payload.model_dump())
|
c = TaskComment(**payload.model_dump())
|
||||||
|
|
||||||
|
# Validate reply target (must exist + belong to same task)
|
||||||
|
if c.reply_to_comment_id is not None:
|
||||||
|
parent = session.get(TaskComment, c.reply_to_comment_id)
|
||||||
|
if parent is None or parent.task_id != c.task_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid reply_to_comment_id")
|
||||||
session.add(c)
|
session.add(c)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(c)
|
session.refresh(c)
|
||||||
|
|||||||
@@ -30,5 +30,9 @@ class TaskComment(SQLModel, table=True):
|
|||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
task_id: int = Field(foreign_key="tasks.id", index=True)
|
task_id: int = Field(foreign_key="tasks.id", index=True)
|
||||||
author_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
author_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||||
|
|
||||||
|
# Optional reply threading
|
||||||
|
reply_to_comment_id: int | None = Field(default=None, foreign_key="task_comments.id")
|
||||||
|
|
||||||
body: str
|
body: str
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ class TaskUpdate(SQLModel):
|
|||||||
class TaskCommentCreate(SQLModel):
|
class TaskCommentCreate(SQLModel):
|
||||||
task_id: int
|
task_id: int
|
||||||
author_employee_id: int | None = None
|
author_employee_id: int | None = None
|
||||||
|
reply_to_comment_id: int | None = None
|
||||||
body: str
|
body: str
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export default function ProjectDetailPage() {
|
|||||||
const [reviewerId, setReviewerId] = useState<string>("");
|
const [reviewerId, setReviewerId] = useState<string>("");
|
||||||
|
|
||||||
const [commentTaskId, setCommentTaskId] = useState<number | null>(null);
|
const [commentTaskId, setCommentTaskId] = useState<number | null>(null);
|
||||||
|
const [replyToCommentId, setReplyToCommentId] = useState<number | null>(null);
|
||||||
const [commentBody, setCommentBody] = useState("");
|
const [commentBody, setCommentBody] = useState("");
|
||||||
|
|
||||||
const comments = useListTaskCommentsTaskCommentsGet(
|
const comments = useListTaskCommentsTaskCommentsGet(
|
||||||
@@ -94,6 +95,7 @@ export default function ProjectDetailPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
comments.refetch();
|
comments.refetch();
|
||||||
setCommentBody("");
|
setCommentBody("");
|
||||||
|
setReplyToCommentId(null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -113,6 +115,11 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
const projectMembers = memberList;
|
const projectMembers = memberList;
|
||||||
|
|
||||||
|
const commentById = new Map<number, (typeof commentList)[number]>();
|
||||||
|
for (const c of commentList) {
|
||||||
|
if (c.id != null) commentById.set(Number(c.id), c);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl p-6">
|
<main className="mx-auto max-w-6xl p-6">
|
||||||
{!Number.isFinite(projectId) ? (
|
{!Number.isFinite(projectId) ? (
|
||||||
@@ -258,7 +265,7 @@ export default function ProjectDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => setCommentTaskId(Number(t.id))}>
|
<Button variant="outline" size="sm" onClick={() => { setCommentTaskId(Number(t.id)); setReplyToCommentId(null); }}>
|
||||||
Comments
|
Comments
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}>
|
<Button variant="destructive" size="sm" onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}>
|
||||||
@@ -284,6 +291,19 @@ export default function ProjectDetailPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{addComment.error ? <div className="text-sm text-destructive">{(addComment.error as Error).message}</div> : null}
|
{addComment.error ? <div className="text-sm text-destructive">{(addComment.error as Error).message}</div> : null}
|
||||||
|
{replyToCommentId ? (
|
||||||
|
<div className="rounded-md border bg-muted/40 p-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-xs text-muted-foreground">Replying to comment #{replyToCommentId}</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(null)}>
|
||||||
|
Cancel reply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{commentById.get(replyToCommentId)?.body ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Write a comment"
|
placeholder="Write a comment"
|
||||||
value={commentBody}
|
value={commentBody}
|
||||||
@@ -297,6 +317,7 @@ export default function ProjectDetailPage() {
|
|||||||
task_id: Number(commentTaskId),
|
task_id: Number(commentTaskId),
|
||||||
author_employee_id: getActorEmployeeId(),
|
author_employee_id: getActorEmployeeId(),
|
||||||
body: commentBody,
|
body: commentBody,
|
||||||
|
reply_to_comment_id: replyToCommentId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -307,9 +328,21 @@ export default function ProjectDetailPage() {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{commentList.map((c) => (
|
{commentList.map((c) => (
|
||||||
<li key={String(c.id)} className="rounded-md border p-2 text-sm">
|
<li key={String(c.id)} className="rounded-md border p-2 text-sm">
|
||||||
<div className="font-medium">{employeeName(c.author_employee_id)}</div>
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="text-xs text-muted-foreground">{(c.created_at ? new Date(c.created_at).toLocaleString() : "—")}</div>
|
<div>
|
||||||
<div className="mt-1">{c.body}</div>
|
<div className="font-medium">{employeeName(c.author_employee_id)}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{(c.created_at ? new Date(c.created_at).toLocaleString() : "—")}</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(Number(c.id))}>
|
||||||
|
Reply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(c.reply_to_comment_id ? (
|
||||||
|
<div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs">
|
||||||
|
<div className="text-muted-foreground">Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
) : null)}
|
||||||
|
<div className="mt-2">{c.body}</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{commentList.length === 0 ? (
|
{commentList.length === 0 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user