diff --git a/_features/_index.json b/_features/_index.json index 97537da5..1159fee9 100644 --- a/_features/_index.json +++ b/_features/_index.json @@ -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" } ] diff --git a/_features/issue-board-polish.json b/_features/issue-board-polish.json index 33d3ea9a..9db3d64d 100644 --- a/_features/issue-board-polish.json +++ b/_features/issue-board-polish.json @@ -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." } ] } diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index dd813534..2d1e651c 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -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(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([]); diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index 455fe81e..40a5c5ed 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -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(); - }} > @@ -155,7 +152,7 @@ function DroppableColumn({
@@ -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 (
@@ -334,12 +326,16 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void }) const [status, setStatus] = useState("todo"); const [priority, setPriority] = useState("none"); const [submitting, setSubmitting] = useState(false); + const [assigneeType, setAssigneeType] = useState(); + const [assigneeId, setAssigneeId] = useState(); 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 }) ))} + {/* Assignee picker */} + { + setAssigneeType(updates.assignee_type ?? undefined); + setAssigneeId(updates.assignee_id ?? undefined); + }} + />
diff --git a/apps/web/features/issues/config/status.ts b/apps/web/features/issues/config/status.ts index 9e0fa809..dfbc384e 100644 --- a/apps/web/features/issues/config/status.ts +++ b/apps/web/features/issues/config/status.ts @@ -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", ]; diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index da9feed1..1ef56a8b 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -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), }) diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 24b5cb78..f37b2e05 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -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")