fix(issues): board polish — blocked column, drag fix, comment perms, task cleanup
- Add "blocked" to STATUS_ORDER/ALL_STATUSES and board visible columns - Add min-h-[200px] to droppable columns for reliable empty-column drops - Fix card click-vs-drag conflict with pointer-events-none on Link - List view uses STATUS_ORDER from config instead of hardcoded order - Create Issue dialog: add AssigneePicker for assigning on creation - Issue detail page syncs from global useIssueStore for real-time updates - Comment UpdateComment/DeleteComment: add author-or-admin permission check - DeleteIssue: cancel running agent tasks before deletion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9127e543d5
commit
2c02aa357d
7 changed files with 125 additions and 62 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[
|
||||
{ "id": "infra-event-bus-ws", "status": "done", "name": "Infrastructure: Event Bus + WS Isolation + Global Store" },
|
||||
{ "id": "issue-board-polish", "status": "designing", "name": "Issue Board & Detail Polish" },
|
||||
{ "id": "issue-board-polish", "status": "in_progress", "name": "Issue Board & Detail Polish" },
|
||||
{ "id": "workspace-permissions", "status": "designing", "name": "Workspace & Permissions" },
|
||||
{ "id": "inbox-notifications", "status": "designing", "name": "Inbox & Notifications" }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,102 +1,99 @@
|
|||
{
|
||||
"id": "issue-board-polish",
|
||||
"name": "Issue Board & Detail Polish",
|
||||
"status": "designing",
|
||||
"status": "in_progress",
|
||||
"createdAt": "2026-03-25",
|
||||
"completedAt": null,
|
||||
"description": "Fix drag-drop data consistency bugs, complete issue detail page interactions, and polish board/list views to Linear MVP quality with consistent shadcn UI.",
|
||||
"currentState": "Board view has 5 columns (missing blocked), drag-drop has 3 data consistency bugs, issue detail title/description not editable, create dialog missing assignee/due date fields, comments lack optimistic updates and author-only edit/delete.",
|
||||
"currentState": "Core tasks done: board has 6 columns with blocked, drag/click conflict fixed, list view uses STATUS_ORDER, create dialog has assignee picker, detail page syncs from global store, comment permissions enforced, delete cancels tasks. Remaining: inline edit title/description, comment optimistic create, comment timestamp tooltip, acceptance criteria UI, loading/empty states — all deferred as UI polish.",
|
||||
"decisions": [
|
||||
"Board shows 6 columns: backlog, todo, in_progress, in_review, done, blocked. Cancelled issues visible via filter only.",
|
||||
"Drag-drop revert respects current filter params",
|
||||
"WS issue events check against current filter before adding to view",
|
||||
"AbortController used for filter change requests to prevent race conditions",
|
||||
"Issue title: click-to-edit inline input, blur/Enter saves, Escape cancels",
|
||||
"Issue description: click-to-edit textarea, blur saves",
|
||||
"Comment creation uses optimistic update (show immediately, confirm on API response)",
|
||||
"3 filter/WS bugs auto-fixed by infra migration (global store + useMemo): no AbortController or per-page WS handlers needed",
|
||||
"Issue detail syncs from useIssueStore via useEffect (Option A: minimal change, keeps local state)",
|
||||
"Comment edit/delete only by author or workspace admin (backend enforced)",
|
||||
"All loading/empty/error states use shadcn Skeleton and consistent patterns"
|
||||
"Inline edit title/description deferred to UI polish phase",
|
||||
"Comment optimistic create deferred to UI polish phase"
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task": "Fix: Board drag-drop revert respects active filters",
|
||||
"done": false,
|
||||
"scope": "When drag-drop API call fails, revert calls listIssues with current filterStatus and filterPriority params instead of bare { limit: 200 }."
|
||||
"done": true,
|
||||
"scope": "Auto-fixed by infra migration: global store + useMemo client-side filtering."
|
||||
},
|
||||
{
|
||||
"task": "Fix: WS issue events respect current filter",
|
||||
"done": false,
|
||||
"scope": "issue:created handler checks if new issue matches filterStatus/filterPriority before adding. issue:updated handler removes issue from view if it no longer matches filter."
|
||||
"done": true,
|
||||
"scope": "Auto-fixed by infra migration: global store + useMemo client-side filtering."
|
||||
},
|
||||
{
|
||||
"task": "Fix: Filter change race condition with AbortController",
|
||||
"done": false,
|
||||
"scope": "useEffect for filter changes creates AbortController, passes signal to fetch, aborts previous request on new filter change. Stale responses ignored."
|
||||
"done": true,
|
||||
"scope": "Auto-fixed by infra migration: single initial fetch, client-side filtering via useMemo."
|
||||
},
|
||||
{
|
||||
"task": "Fix: Board add blocked column, fix empty column drop target",
|
||||
"done": false,
|
||||
"scope": "visibleStatuses includes 'blocked'. All columns have min-h-[200px] for reliable drop target. Cancelled issues not shown as column but accessible via filter."
|
||||
"done": true,
|
||||
"scope": "visibleStatuses includes 'blocked'. All columns have min-h-[200px] for reliable drop target. 'blocked' added to STATUS_ORDER and ALL_STATUSES in config."
|
||||
},
|
||||
{
|
||||
"task": "Fix: Board card click vs drag conflict",
|
||||
"done": false,
|
||||
"scope": "When isDragging is true, Link inside card has pointer-events-none to prevent navigation during drag."
|
||||
"done": true,
|
||||
"scope": "Link has pointer-events-none when isDragging. Removed unreliable onClickCapture handler."
|
||||
},
|
||||
{
|
||||
"task": "Fix: List view status group order matches STATUS_ORDER",
|
||||
"done": false,
|
||||
"scope": "List view groups issues using STATUS_ORDER constant instead of hardcoded different order."
|
||||
"done": true,
|
||||
"scope": "List view uses STATUS_ORDER.filter(s => s !== 'cancelled') instead of hardcoded array."
|
||||
},
|
||||
{
|
||||
"task": "Create Issue dialog: add Assignee and Due Date fields",
|
||||
"done": false,
|
||||
"scope": "Dialog includes AssigneePicker and date picker (Calendar popover). Fields passed to api.createIssue(). Existing StatusPicker and PriorityPicker remain."
|
||||
"done": true,
|
||||
"scope": "Dialog includes AssigneePicker. assignee_type and assignee_id passed to api.createIssue(). Due date deferred (not in CreateIssueRequest)."
|
||||
},
|
||||
{
|
||||
"task": "Issue detail: inline editable title",
|
||||
"done": false,
|
||||
"scope": "Title renders as h1. Click transforms to Input. Blur or Enter calls api.updateIssue with optimistic update. Escape reverts. Empty title rejected."
|
||||
"scope": "Deferred: UI polish. Title renders as h1. Click transforms to Input. Blur or Enter saves."
|
||||
},
|
||||
{
|
||||
"task": "Issue detail: inline editable description",
|
||||
"done": false,
|
||||
"scope": "Description renders as paragraph. Click transforms to Textarea. Blur saves with optimistic update. Empty description allowed (clears field)."
|
||||
"scope": "Deferred: UI polish. Description renders as paragraph. Click transforms to Textarea."
|
||||
},
|
||||
{
|
||||
"task": "Issue detail: listen to issue:updated WS event",
|
||||
"done": false,
|
||||
"scope": "Detail page subscribes to issue:updated. When event.issue.id matches current issue, merges update into local state (unless user is actively editing that field)."
|
||||
"done": true,
|
||||
"scope": "Detail page syncs from useIssueStore via useEffect. Store updated by useRealtimeSync on WS events."
|
||||
},
|
||||
{
|
||||
"task": "Issue detail: acceptance criteria and context refs always addable",
|
||||
"done": false,
|
||||
"scope": "Show 'Add acceptance criteria' and 'Add context reference' buttons even when arrays are empty. Click reveals input field."
|
||||
"scope": "Deferred: UI polish."
|
||||
},
|
||||
{
|
||||
"task": "Comment: optimistic create",
|
||||
"done": false,
|
||||
"scope": "On submit, immediately append comment with temp ID and muted styling. On API success, replace temp with real. On API error, remove temp and show toast."
|
||||
"scope": "Deferred: UI polish."
|
||||
},
|
||||
{
|
||||
"task": "Comment: backend author-only edit/delete",
|
||||
"done": false,
|
||||
"scope": "UpdateComment and DeleteComment in comment.go load comment first, verify author_id matches request user OR user is workspace admin. Return 403 otherwise."
|
||||
"done": true,
|
||||
"scope": "UpdateComment and DeleteComment load comment, verify workspace membership, check author_id matches user OR user is owner/admin. Return 403 otherwise."
|
||||
},
|
||||
{
|
||||
"task": "Comment: hover timestamp shows full date tooltip",
|
||||
"done": false,
|
||||
"scope": "Relative time ('2h ago') wrapped in shadcn Tooltip showing full ISO-formatted date on hover."
|
||||
"scope": "Deferred: UI polish."
|
||||
},
|
||||
{
|
||||
"task": "Issue delete: cancel running tasks first",
|
||||
"done": false,
|
||||
"scope": "DeleteIssue handler calls TaskService.CancelTasksForIssue before deleting issue to prevent FK constraint errors."
|
||||
"done": true,
|
||||
"scope": "DeleteIssue calls TaskService.CancelTasksForIssue before h.Queries.DeleteIssue."
|
||||
},
|
||||
{
|
||||
"task": "UI: consistent loading/empty/error states across issues pages",
|
||||
"done": false,
|
||||
"scope": "Board empty columns show 'No issues' muted text. List empty groups hidden. Initial load uses Skeleton. Error shows toast + retry. Filter with no results shows 'No matching issues' with clear filter link."
|
||||
"scope": "Deferred: UI polish."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { api } from "@/shared/api";
|
|||
import { useAuthStore } from "@/features/auth";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { useIssueStore } from "@multica/store";
|
||||
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -291,6 +292,15 @@ export default function IssueDetailPage({
|
|||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
|
||||
// Watch the global issue store for real-time updates from other users/agents
|
||||
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
|
||||
|
||||
useEffect(() => {
|
||||
if (storeIssue) {
|
||||
setIssue(storeIssue);
|
||||
}
|
||||
}, [storeIssue]);
|
||||
|
||||
useEffect(() => {
|
||||
setIssue(null);
|
||||
setComments([]);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER, STATUS_ORDER } from "@/features/issues/config";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -43,7 +43,7 @@ import {
|
|||
SelectGroup,
|
||||
} from "@/components/ui/select";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { StatusIcon, PriorityIcon, AssigneePicker } from "@/features/issues/components";
|
||||
import { api } from "@/shared/api";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
|
||||
|
|
@ -118,13 +118,10 @@ function DraggableBoardCard({ issue }: { issue: Issue }) {
|
|||
{...attributes}
|
||||
{...listeners}
|
||||
className={isDragging ? "opacity-30" : ""}
|
||||
onClickCapture={(e) => {
|
||||
if (isDragging) e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
className="block transition-colors hover:opacity-80"
|
||||
className={`block transition-colors hover:opacity-80 ${isDragging ? "pointer-events-none" : ""}`}
|
||||
>
|
||||
<BoardCardContent issue={issue} />
|
||||
</Link>
|
||||
|
|
@ -155,7 +152,7 @@ function DroppableColumn({
|
|||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
className={`min-h-[200px] flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver ? "bg-accent/40" : ""
|
||||
}`}
|
||||
>
|
||||
|
|
@ -192,6 +189,7 @@ function BoardView({
|
|||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
|
|
@ -292,13 +290,7 @@ function ListRow({ issue }: { issue: Issue }) {
|
|||
}
|
||||
|
||||
function ListView({ issues }: { issues: Issue[] }) {
|
||||
const groupOrder: IssueStatus[] = [
|
||||
"in_review",
|
||||
"in_progress",
|
||||
"todo",
|
||||
"backlog",
|
||||
"done",
|
||||
];
|
||||
const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled");
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
|
|
@ -334,12 +326,16 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
|
|||
const [status, setStatus] = useState<IssueStatus>("todo");
|
||||
const [priority, setPriority] = useState<IssuePriority>("none");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [assigneeType, setAssigneeType] = useState<string | undefined>();
|
||||
const [assigneeId, setAssigneeId] = useState<string | undefined>();
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setStatus("todo");
|
||||
setPriority("none");
|
||||
setAssigneeType(undefined);
|
||||
setAssigneeId(undefined);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
|
|
@ -351,6 +347,8 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
|
|||
description: description.trim() || undefined,
|
||||
status,
|
||||
priority,
|
||||
assignee_type: assigneeType,
|
||||
assignee_id: assigneeId,
|
||||
});
|
||||
onCreated(issue);
|
||||
reset();
|
||||
|
|
@ -422,6 +420,15 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Assignee picker */}
|
||||
<AssigneePicker
|
||||
assigneeType={assigneeType ?? null}
|
||||
assigneeId={assigneeId ?? null}
|
||||
onUpdate={(updates) => {
|
||||
setAssigneeType(updates.assignee_type ?? undefined);
|
||||
setAssigneeId(updates.assignee_id ?? undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const STATUS_ORDER: IssueStatus[] = [
|
|||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ export const ALL_STATUSES: IssueStatus[] = [
|
|||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,37 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
commentId := chi.URLParam(r, "commentId")
|
||||
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Load comment to check ownership
|
||||
existing, err := h.Queries.GetComment(r.Context(), parseUUID(commentId))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "comment not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Load issue to get workspace
|
||||
issue, err := h.Queries.GetIssue(r.Context(), existing.IssueID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "comment not found")
|
||||
return
|
||||
}
|
||||
|
||||
member, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "comment not found")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
isAuthor := existing.AuthorType == "member" && uuidToString(existing.AuthorID) == userID
|
||||
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
||||
if !isAuthor && !isAdmin {
|
||||
writeError(w, http.StatusForbidden, "only comment author or admin can edit")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
|
@ -127,18 +158,18 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
resp := commentToResponse(comment)
|
||||
userID := requestUserID(r)
|
||||
workspaceID := ""
|
||||
if issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID); err == nil {
|
||||
workspaceID = uuidToString(issue.WorkspaceID)
|
||||
}
|
||||
h.publish(protocol.EventCommentUpdated, workspaceID, "member", userID, map[string]any{"comment": resp})
|
||||
h.publish(protocol.EventCommentUpdated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{"comment": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
commentId := chi.URLParam(r, "commentId")
|
||||
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the comment first to know the issue_id for the broadcast
|
||||
comment, err := h.Queries.GetComment(r.Context(), parseUUID(commentId))
|
||||
if err != nil {
|
||||
|
|
@ -146,17 +177,31 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Load issue to get workspace
|
||||
issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "comment not found")
|
||||
return
|
||||
}
|
||||
|
||||
member, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "comment not found")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
isAuthor := comment.AuthorType == "member" && uuidToString(comment.AuthorID) == userID
|
||||
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
||||
if !isAuthor && !isAdmin {
|
||||
writeError(w, http.StatusForbidden, "only comment author or admin can delete")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete comment")
|
||||
return
|
||||
}
|
||||
|
||||
userID := requestUserID(r)
|
||||
workspaceID := ""
|
||||
if issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID); err == nil {
|
||||
workspaceID = uuidToString(issue.WorkspaceID)
|
||||
}
|
||||
h.publish(protocol.EventCommentDeleted, workspaceID, "member", userID, map[string]any{
|
||||
h.publish(protocol.EventCommentDeleted, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
|
||||
"comment_id": commentId,
|
||||
"issue_id": uuidToString(comment.IssueID),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
|
||||
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete issue")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue