From cc2281416e4afd8238aa7e1c75d211ea2b338ad8 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:05:38 +0800 Subject: [PATCH 1/4] feat(server): add comment CRUD endpoints and issue filter/update enhancements - Add UpdateComment and DeleteComment handlers with /api/comments/{commentId} routes - Add broadcast for comment create/update/delete WebSocket events - Support status, priority, and assignee_id filters on ListIssues - Extend UpdateIssue to handle due_date, acceptance_criteria, context_refs, repository - Properly distinguish "field not sent" vs "field sent as null" in UpdateIssue - Add corresponding SDK methods and TypeScript types Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/api-client.ts | 17 ++++- packages/types/src/api.ts | 13 ++++ packages/types/src/events.ts | 17 +++++ server/cmd/server/router.go | 6 ++ server/internal/handler/comment.go | 55 +++++++++++++- server/internal/handler/issue.go | 106 +++++++++++++++++++++++---- server/pkg/db/generated/issue.sql.go | 43 ++++++++--- server/pkg/db/queries/issue.sql | 7 ++ 8 files changed, 237 insertions(+), 27 deletions(-) diff --git a/packages/sdk/src/api-client.ts b/packages/sdk/src/api-client.ts index b64e3357..6e7a2385 100644 --- a/packages/sdk/src/api-client.ts +++ b/packages/sdk/src/api-client.ts @@ -6,6 +6,7 @@ import type { UpdateMeRequest, CreateMemberRequest, UpdateMemberRequest, + ListIssuesParams, Agent, CreateAgentRequest, UpdateAgentRequest, @@ -97,12 +98,15 @@ export class ApiClient { } // Issues - async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise { + async listIssues(params?: ListIssuesParams): Promise { const search = new URLSearchParams(); if (params?.limit) search.set("limit", String(params.limit)); if (params?.offset) search.set("offset", String(params.offset)); const wsId = params?.workspace_id ?? this.workspaceId; if (wsId) search.set("workspace_id", wsId); + if (params?.status) search.set("status", params.status); + if (params?.priority) search.set("priority", params.priority); + if (params?.assignee_id) search.set("assignee_id", params.assignee_id); return this.fetch(`/api/issues?${search}`); } @@ -142,6 +146,17 @@ export class ApiClient { }); } + async updateComment(commentId: string, content: string): Promise { + return this.fetch(`/api/comments/${commentId}`, { + method: "PUT", + body: JSON.stringify({ content }), + }); + } + + async deleteComment(commentId: string): Promise { + await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" }); + } + // Agents async listAgents(params?: { workspace_id?: string }): Promise { const search = new URLSearchParams(); diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index b9d42769..15cfdc08 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -23,6 +23,19 @@ export interface UpdateIssueRequest { assignee_type?: IssueAssigneeType | null; assignee_id?: string | null; position?: number; + due_date?: string | null; + acceptance_criteria?: string[]; + context_refs?: string[]; + repository?: { url: string; branch?: string; path?: string } | null; +} + +export interface ListIssuesParams { + limit?: number; + offset?: number; + workspace_id?: string; + status?: IssueStatus; + priority?: IssuePriority; + assignee_id?: string; } export interface ListIssuesResponse { diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 54242572..2b8702ad 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -1,12 +1,16 @@ import type { Issue } from "./issue.js"; import type { Agent } from "./agent.js"; import type { InboxItem } from "./inbox.js"; +import type { Comment } from "./comment.js"; // WebSocket event types (matching Go server protocol/events.go) export type WSEventType = | "issue:created" | "issue:updated" | "issue:deleted" + | "comment:created" + | "comment:updated" + | "comment:deleted" | "agent:status" | "task:dispatch" | "task:progress" @@ -40,3 +44,16 @@ export interface AgentStatusPayload { export interface InboxNewPayload { item: InboxItem; } + +export interface CommentCreatedPayload { + comment: Comment; +} + +export interface CommentUpdatedPayload { + comment: Comment; +} + +export interface CommentDeletedPayload { + comment_id: string; + issue_id: string; +} diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index ee40686b..078f31f5 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -109,6 +109,12 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub) chi.Router { }) }) + // Comments + r.Route("/api/comments/{commentId}", func(r chi.Router) { + r.Put("/", h.UpdateComment) + r.Delete("/", h.DeleteComment) + }) + // Agents r.Route("/api/agents", func(r chi.Router) { r.Get("/", h.ListAgents) diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 89297927..77affa1d 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -96,5 +96,58 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusCreated, commentToResponse(comment)) + resp := commentToResponse(comment) + h.broadcast("comment:created", map[string]any{"comment": resp}) + writeJSON(w, http.StatusCreated, resp) +} + +func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { + commentId := chi.URLParam(r, "commentId") + + var req struct { + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Content == "" { + writeError(w, http.StatusBadRequest, "content is required") + return + } + + comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{ + ID: parseUUID(commentId), + Content: req.Content, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update comment") + return + } + + resp := commentToResponse(comment) + h.broadcast("comment:updated", 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") + + // Get the comment first to know the issue_id for the broadcast + comment, err := h.Queries.GetComment(r.Context(), parseUUID(commentId)) + if err != nil { + writeError(w, http.StatusNotFound, "comment not found") + return + } + + if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete comment") + return + } + + h.broadcast("comment:deleted", map[string]any{ + "comment_id": commentId, + "issue_id": uuidToString(comment.IssueID), + }) + w.WriteHeader(http.StatusNoContent) } diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 0ead1c48..88b348fb 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -2,8 +2,10 @@ package handler import ( "encoding/json" + "io" "net/http" "strconv" + "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" @@ -97,10 +99,27 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } } + // Parse optional filter params + var statusFilter pgtype.Text + if s := r.URL.Query().Get("status"); s != "" { + statusFilter = pgtype.Text{String: s, Valid: true} + } + var priorityFilter pgtype.Text + if p := r.URL.Query().Get("priority"); p != "" { + priorityFilter = pgtype.Text{String: p, Valid: true} + } + var assigneeFilter pgtype.UUID + if a := r.URL.Query().Get("assignee_id"); a != "" { + assigneeFilter = parseUUID(a) + } + issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ WorkspaceID: parseUUID(workspaceID), Limit: int32(limit), Offset: int32(offset), + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -249,31 +268,52 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { } type UpdateIssueRequest struct { - Title *string `json:"title"` - Description *string `json:"description"` - Status *string `json:"status"` - Priority *string `json:"priority"` - AssigneeType *string `json:"assignee_type"` - AssigneeID *string `json:"assignee_id"` - Position *float64 `json:"position"` + Title *string `json:"title"` + Description *string `json:"description"` + Status *string `json:"status"` + Priority *string `json:"priority"` + AssigneeType *string `json:"assignee_type"` + AssigneeID *string `json:"assignee_id"` + Position *float64 `json:"position"` + DueDate *string `json:"due_date"` + AcceptanceCriteria *[]any `json:"acceptance_criteria"` + ContextRefs *[]any `json:"context_refs"` + Repository *any `json:"repository"` } func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - if _, ok := h.loadIssueForUser(w, r, id); !ok { + current, ok := h.loadIssueForUser(w, r, id) + if !ok { + return + } + + // Read body as raw bytes so we can detect which fields were explicitly sent + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read request body") return } var req UpdateIssueRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := json.Unmarshal(bodyBytes, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } + // Track which fields were explicitly present in JSON (even if null) + var rawFields map[string]json.RawMessage + json.Unmarshal(bodyBytes, &rawFields) + + // Pre-fill nullable fields (bare sqlc.narg) with current values params := db.UpdateIssueParams{ - ID: parseUUID(id), + ID: current.ID, + AssigneeType: current.AssigneeType, + AssigneeID: current.AssigneeID, + DueDate: current.DueDate, } + // COALESCE fields — only set when explicitly provided if req.Title != nil { params.Title = pgtype.Text{String: *req.Title, Valid: true} } @@ -286,15 +326,49 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { if req.Priority != nil { params.Priority = pgtype.Text{String: *req.Priority, Valid: true} } - if req.AssigneeType != nil { - params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true} - } - if req.AssigneeID != nil { - params.AssigneeID = parseUUID(*req.AssigneeID) - } if req.Position != nil { params.Position = pgtype.Float8{Float64: *req.Position, Valid: true} } + if req.AcceptanceCriteria != nil { + ac, _ := json.Marshal(*req.AcceptanceCriteria) + params.AcceptanceCriteria = ac + } + if req.ContextRefs != nil { + cr, _ := json.Marshal(*req.ContextRefs) + params.ContextRefs = cr + } + if req.Repository != nil { + repo, _ := json.Marshal(*req.Repository) + params.Repository = repo + } + + // Nullable fields — only override when explicitly present in JSON + if _, ok := rawFields["assignee_type"]; ok { + if req.AssigneeType != nil { + params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true} + } else { + params.AssigneeType = pgtype.Text{Valid: false} // explicit null = unassign + } + } + if _, ok := rawFields["assignee_id"]; ok { + if req.AssigneeID != nil { + params.AssigneeID = parseUUID(*req.AssigneeID) + } else { + params.AssigneeID = pgtype.UUID{Valid: false} // explicit null = unassign + } + } + if _, ok := rawFields["due_date"]; ok { + if req.DueDate != nil && *req.DueDate != "" { + t, err := time.Parse(time.RFC3339, *req.DueDate) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339") + return + } + params.DueDate = pgtype.Timestamptz{Time: t, Valid: true} + } else { + params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date + } + } issue, err := h.Queries.UpdateIssue(r.Context(), params) if err != nil { diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 21922612..b9185d99 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -123,6 +123,9 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) { const listIssues = `-- name: ListIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at FROM issue WHERE workspace_id = $1 + AND ($4::text IS NULL OR status = $4) + AND ($5::text IS NULL OR priority = $5) + AND ($6::uuid IS NULL OR assignee_id = $6) ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3 ` @@ -131,10 +134,20 @@ type ListIssuesParams struct { WorkspaceID pgtype.UUID `json:"workspace_id"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` } func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) { - rows, err := q.db.Query(ctx, listIssues, arg.WorkspaceID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, listIssues, + arg.WorkspaceID, + arg.Limit, + arg.Offset, + arg.Status, + arg.Priority, + arg.AssigneeID, + ) if err != nil { return nil, err } @@ -181,20 +194,28 @@ UPDATE issue SET assignee_type = $6, assignee_id = $7, position = COALESCE($8, position), + due_date = $9, + acceptance_criteria = COALESCE($10, acceptance_criteria), + context_refs = COALESCE($11, context_refs), + repository = COALESCE($12, repository), updated_at = now() WHERE id = $1 RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at ` type UpdateIssueParams struct { - ID pgtype.UUID `json:"id"` - Title pgtype.Text `json:"title"` - Description pgtype.Text `json:"description"` - Status pgtype.Text `json:"status"` - Priority pgtype.Text `json:"priority"` - AssigneeType pgtype.Text `json:"assignee_type"` - AssigneeID pgtype.UUID `json:"assignee_id"` - Position pgtype.Float8 `json:"position"` + ID pgtype.UUID `json:"id"` + Title pgtype.Text `json:"title"` + Description pgtype.Text `json:"description"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeType pgtype.Text `json:"assignee_type"` + AssigneeID pgtype.UUID `json:"assignee_id"` + Position pgtype.Float8 `json:"position"` + DueDate pgtype.Timestamptz `json:"due_date"` + AcceptanceCriteria []byte `json:"acceptance_criteria"` + ContextRefs []byte `json:"context_refs"` + Repository []byte `json:"repository"` } func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) { @@ -207,6 +228,10 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue arg.AssigneeType, arg.AssigneeID, arg.Position, + arg.DueDate, + arg.AcceptanceCriteria, + arg.ContextRefs, + arg.Repository, ) var i Issue err := row.Scan( diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index beef26b8..172ee9f2 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -1,6 +1,9 @@ -- name: ListIssues :many SELECT * FROM issue WHERE workspace_id = $1 + AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3; @@ -27,6 +30,10 @@ UPDATE issue SET assignee_type = sqlc.narg('assignee_type'), assignee_id = sqlc.narg('assignee_id'), position = COALESCE(sqlc.narg('position'), position), + due_date = sqlc.narg('due_date'), + acceptance_criteria = COALESCE(sqlc.narg('acceptance_criteria'), acceptance_criteria), + context_refs = COALESCE(sqlc.narg('context_refs'), context_refs), + repository = COALESCE(sqlc.narg('repository'), repository), updated_at = now() WHERE id = $1 RETURNING *; From 6185b7571ed2c0e09783d94ba68d977914a7a696 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:05:56 +0800 Subject: [PATCH 2/4] refactor(web): extract shared components, add tab system, and restructure issues page - Extract AppSidebar, TabBar, TabLink into dashboard _components - Add tab-store for browser-like tab navigation state - Move StatusIcon/PriorityIcon to issues/_components, config to _config - Replace inline CreateIssueForm with Dialog (status/priority selection) - Add calendar component to packages/ui - Simplify dashboard layout with SidebarProvider Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(dashboard)/_components/app-sidebar.tsx | 295 +++++++ .../app/(dashboard)/_components/tab-bar.tsx | 271 +++++++ .../app/(dashboard)/_components/tab-link.tsx | 30 + .../issues/_components/icons/index.ts | 2 + .../_components/icons/priority-icon.tsx | 57 ++ .../issues/_components/icons/status-icon.tsx | 169 ++++ .../(dashboard)/issues/_components/index.ts | 2 + .../_components/pickers/assignee-picker.tsx | 143 ++++ .../issues/_components/pickers/index.ts | 4 + .../_components/pickers/priority-picker.tsx | 49 ++ .../_components/pickers/property-picker.tsx | 133 ++++ .../_components/pickers/status-picker.tsx | 50 ++ .../app/(dashboard)/issues/_config/index.ts | 2 + .../(dashboard)/issues/_config/priority.ts | 20 + .../app/(dashboard)/issues/_config/status.ts | 32 + apps/web/app/(dashboard)/issues/page.test.tsx | 99 +-- apps/web/app/(dashboard)/issues/page.tsx | 281 ++++--- apps/web/app/(dashboard)/layout.tsx | 354 +-------- apps/web/app/layout.tsx | 2 + apps/web/lib/tab-store.tsx | 357 +++++++++ packages/ui/package.json | 17 +- packages/ui/src/components/ui/calendar.tsx | 221 ++++++ packages/ui/src/styles/globals.css | 5 + pnpm-lock.yaml | 751 +++--------------- 24 files changed, 2184 insertions(+), 1162 deletions(-) create mode 100644 apps/web/app/(dashboard)/_components/app-sidebar.tsx create mode 100644 apps/web/app/(dashboard)/_components/tab-bar.tsx create mode 100644 apps/web/app/(dashboard)/_components/tab-link.tsx create mode 100644 apps/web/app/(dashboard)/issues/_components/icons/index.ts create mode 100644 apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx create mode 100644 apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx create mode 100644 apps/web/app/(dashboard)/issues/_components/index.ts create mode 100644 apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx create mode 100644 apps/web/app/(dashboard)/issues/_components/pickers/index.ts create mode 100644 apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx create mode 100644 apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx create mode 100644 apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx create mode 100644 apps/web/app/(dashboard)/issues/_config/index.ts create mode 100644 apps/web/app/(dashboard)/issues/_config/priority.ts create mode 100644 apps/web/app/(dashboard)/issues/_config/status.ts create mode 100644 apps/web/lib/tab-store.tsx create mode 100644 packages/ui/src/components/ui/calendar.tsx diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx new file mode 100644 index 00000000..406f4713 --- /dev/null +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Inbox, + ListTodo, + Bot, + BookOpen, + ChevronDown, + Settings, + LogOut, + Plus, + Check, +} from "lucide-react"; +import { MulticaIcon } from "@multica/ui/components/multica-icon"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@multica/ui/components/ui/sidebar"; +import { useAuth } from "../../../lib/auth-context"; +import { useTabStore } from "../../../lib/tab-store"; + +const navItems = [ + { href: "/inbox", label: "Inbox", icon: Inbox, iconKey: "inbox" }, + { href: "/agents", label: "Agents", icon: Bot, iconKey: "agents" }, + { href: "/issues", label: "Issues", icon: ListTodo, iconKey: "issues" }, + { + href: "/knowledge-base", + label: "Knowledge Base", + icon: BookOpen, + iconKey: "knowledge-base", + }, +]; + +export function AppSidebar() { + const pathname = usePathname(); + const { + user, + workspace, + workspaces, + logout, + switchWorkspace, + createWorkspace, + } = useAuth(); + const { openTab } = useTabStore(); + + const [showMenu, setShowMenu] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newName, setNewName] = useState(""); + const [newSlug, setNewSlug] = useState(""); + const [creating, setCreating] = useState(false); + + const handleNameChange = (value: string) => { + setNewName(value); + setNewSlug( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + ); + }; + + const handleCreateWorkspace = async () => { + if (!newName.trim() || !newSlug.trim()) return; + setCreating(true); + try { + const ws = await createWorkspace({ + name: newName.trim(), + slug: newSlug.trim(), + }); + setShowCreateDialog(false); + setNewName(""); + setNewSlug(""); + await switchWorkspace(ws.id); + } catch (err) { + console.error("Failed to create workspace:", err); + } finally { + setCreating(false); + } + }; + + return ( + <> + + {/* Workspace Switcher */} + + + + setShowMenu(!showMenu)}> + + + {workspace?.name ?? "Multica"} + + + + + + + {showMenu && ( + <> +
setShowMenu(false)} + /> +
+
+ {user?.email} +
+
+
+ Workspaces +
+ {workspaces.map((ws) => ( + + ))} + +
+ setShowMenu(false)} + className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent" + > + + Settings + + +
+ + )} + + + {/* Navigation */} + + + + + {navItems.map((item) => { + const isActive = + pathname === item.href || + pathname.startsWith(item.href + "/"); + return ( + + } + onClick={() => + openTab(item.href, item.label, { + replace: true, + iconKey: item.iconKey, + }) + } + > + + {item.label} + + + ); + })} + + + + + + {/* User */} + + {user && ( + + + +
+ {user.name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2)} +
+ {user.name} +
+
+
+ )} +
+ + + {/* Create Workspace Dialog */} + {showCreateDialog && ( + <> +
setShowCreateDialog(false)} + /> +
+
+

+ Create workspace +

+

+ Create a new workspace for your team. +

+
+
+
+ + handleNameChange(e.target.value)} + placeholder="My Workspace" + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setNewSlug(e.target.value)} + placeholder="my-workspace" + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+
+ + +
+
+ + )} + + ); +} diff --git a/apps/web/app/(dashboard)/_components/tab-bar.tsx b/apps/web/app/(dashboard)/_components/tab-bar.tsx new file mode 100644 index 00000000..7b5d5327 --- /dev/null +++ b/apps/web/app/(dashboard)/_components/tab-bar.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useCallback, useState, useEffect, useRef } from "react"; +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + Plus, + X, + Inbox, + Bot, + ListTodo, + BookOpen, + Settings, + FileText, +} from "lucide-react"; +import { useTabStore, type Tab } from "../../../lib/tab-store"; + +// --------------------------------------------------------------------------- +// Icon lookup +// --------------------------------------------------------------------------- + +const TAB_ICONS: Record = { + inbox: Inbox, + agents: Bot, + issues: ListTodo, + "knowledge-base": BookOpen, + settings: Settings, +}; + +function TabIcon({ iconKey }: { iconKey?: string }) { + const Icon = iconKey ? TAB_ICONS[iconKey] : undefined; + if (!Icon) return ; + return ; +} + +// --------------------------------------------------------------------------- +// Context Menu +// --------------------------------------------------------------------------- + +function TabContextMenu({ + x, + y, + tabId, + onClose, +}: { + x: number; + y: number; + tabId: string; + onClose: () => void; +}) { + const { tabs, closeTab } = useTabStore(); + const menuRef = useRef(null); + const canClose = tabs.length > 1; + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleEsc); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleEsc); + }; + }, [onClose]); + + const handleClose = () => { + if (canClose) closeTab(tabId); + onClose(); + }; + + const handleCloseOthers = () => { + tabs.forEach((t) => { + if (t.id !== tabId && tabs.length > 1) closeTab(t.id); + }); + onClose(); + }; + + return ( +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// SortableTab +// --------------------------------------------------------------------------- + +function SortableTab({ + tab, + isActive, + canClose, + onContextMenu, +}: { + tab: Tab; + isActive: boolean; + canClose: boolean; + onContextMenu: (e: React.MouseEvent, tabId: string) => void; +}) { + const { activateTab, closeTab } = useTabStore(); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: tab.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const handleClick = () => { + if (!isDragging) { + activateTab(tab.id); + } + }; + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + closeTab(tab.id); + }; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// TabBar +// --------------------------------------------------------------------------- + +export function TabBar() { + const { tabs, activeTabId, reorderTabs, openTab } = useTabStore(); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + tabId: string; + } | null>(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = tabs.findIndex((t) => t.id === active.id); + const newIndex = tabs.findIndex((t) => t.id === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + reorderTabs(oldIndex, newIndex); + } + }, + [tabs, reorderTabs] + ); + + const handleNewTab = () => { + openTab("/issues", "All Issues", { replace: false, iconKey: "issues" }); + }; + + const handleContextMenu = (e: React.MouseEvent, tabId: string) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, tabId }); + }; + + return ( +
+ + t.id)} + strategy={horizontalListSortingStrategy} + > + {tabs.map((tab) => ( + 1} + onContextMenu={handleContextMenu} + /> + ))} + + + + + {contextMenu && ( + setContextMenu(null)} + /> + )} +
+ ); +} diff --git a/apps/web/app/(dashboard)/_components/tab-link.tsx b/apps/web/app/(dashboard)/_components/tab-link.tsx new file mode 100644 index 00000000..5f8a50fa --- /dev/null +++ b/apps/web/app/(dashboard)/_components/tab-link.tsx @@ -0,0 +1,30 @@ +"use client"; + +import Link from "next/link"; +import { useTabStore } from "../../../lib/tab-store"; + +export function TabLink({ + href, + title, + iconKey, + children, + ...props +}: { + href: string; + title: string; + iconKey?: string; + children: React.ReactNode; +} & Omit, "onClick" | "href">) { + const { openTab } = useTabStore(); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + openTab(href, title, { replace: false, iconKey }); + }; + + return ( + + {children} + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/icons/index.ts b/apps/web/app/(dashboard)/issues/_components/icons/index.ts new file mode 100644 index 00000000..03cb734a --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/icons/index.ts @@ -0,0 +1,2 @@ +export { StatusIcon } from "./status-icon"; +export { PriorityIcon } from "./priority-icon"; diff --git a/apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx b/apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx new file mode 100644 index 00000000..64a24752 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx @@ -0,0 +1,57 @@ +import type { IssuePriority } from "@multica/types"; +import { PRIORITY_CONFIG } from "../../_config"; + +export function PriorityIcon({ + priority, + className = "", +}: { + priority: IssuePriority; + className?: string; +}) { + const cfg = PRIORITY_CONFIG[priority]; + + // "none" — simple horizontal dashes + if (cfg.bars === 0) { + return ( + + + + ); + } + + const isUrgent = priority === "urgent"; + + return ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + {isUrgent && ( + + )} + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx b/apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx new file mode 100644 index 00000000..630b9d30 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx @@ -0,0 +1,169 @@ +import type { IssueStatus } from "@multica/types"; +import { STATUS_CONFIG } from "../../_config"; + +// --------------------------------------------------------------------------- +// Circle geometry constants (viewBox 0 0 16 16, center 8,8, radius 6) +// --------------------------------------------------------------------------- + +const CX = 8; +const CY = 8; +const R = 6; + +// --------------------------------------------------------------------------- +// Per-status SVG renderers — Linear-style icons +// --------------------------------------------------------------------------- + +/** 16 small dots arranged in a ring */ +function BacklogIcon() { + const count = 16; + const dotR = 0.65; + return ( + + {Array.from({ length: count }, (_, i) => { + const angle = (i / count) * Math.PI * 2 - Math.PI / 2; + return ( + + ); + })} + + ); +} + +/** Empty circle, solid outline */ +function TodoIcon() { + return ( + + ); +} + +/** Circle outline + right half filled (D-shape) */ +function InProgressIcon() { + return ( + <> + + + + ); +} + +/** Circle outline + 75% pie fill (bottom-left quarter empty) */ +function InReviewIcon() { + return ( + <> + + + + ); +} + +/** Solid filled circle + white checkmark */ +function DoneIcon() { + return ( + <> + + + + ); +} + +/** Circle outline + X inside */ +function CancelledIcon() { + return ( + <> + + + + ); +} + +// --------------------------------------------------------------------------- +// Renderer map +// --------------------------------------------------------------------------- + +const STATUS_RENDERERS: Record React.ReactNode> = { + backlog: BacklogIcon, + todo: TodoIcon, + in_progress: InProgressIcon, + in_review: InReviewIcon, + done: DoneIcon, + blocked: CancelledIcon, // fallback if backend sends blocked + cancelled: CancelledIcon, +}; + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- + +export function StatusIcon({ + status, + className = "h-4 w-4", +}: { + status: IssueStatus; + className?: string; +}) { + const cfg = STATUS_CONFIG[status]; + const Renderer = STATUS_RENDERERS[status]; + + return ( + + + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/index.ts b/apps/web/app/(dashboard)/issues/_components/index.ts new file mode 100644 index 00000000..8b22c442 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/index.ts @@ -0,0 +1,2 @@ +export * from "./icons"; +export * from "./pickers"; diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx b/apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx new file mode 100644 index 00000000..dee8b620 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import { Bot, UserMinus } from "lucide-react"; +import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/types"; +import { useAuth } from "../../../../../lib/auth-context"; +import { + PropertyPicker, + PickerItem, + PickerSection, + PickerEmpty, +} from "./property-picker"; + +export function AssigneePicker({ + assigneeType, + assigneeId, + onUpdate, +}: { + assigneeType: IssueAssigneeType | null; + assigneeId: string | null; + onUpdate: (updates: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const { members, agents, getActorName, getActorInitials } = useAuth(); + + const query = filter.toLowerCase(); + const filteredMembers = members.filter((m) => + m.name.toLowerCase().includes(query), + ); + const filteredAgents = agents.filter((a) => + a.name.toLowerCase().includes(query), + ); + + const isSelected = (type: string, id: string) => + assigneeType === type && assigneeId === id; + + const triggerLabel = + assigneeType && assigneeId + ? getActorName(assigneeType, assigneeId) + : "Unassigned"; + + return ( + { + setOpen(v); + if (!v) setFilter(""); + }} + width="w-52" + searchable + searchPlaceholder="Assign to..." + onSearchChange={setFilter} + trigger={ + assigneeType && assigneeId ? ( + <> +
+ {assigneeType === "agent" ? ( + + ) : ( + getActorInitials(assigneeType, assigneeId) + )} +
+ {triggerLabel} + + ) : ( + Unassigned + ) + } + > + {/* Unassigned option */} + { + onUpdate({ assignee_type: null, assignee_id: null }); + setOpen(false); + }} + > + + Unassigned + + + {/* Members */} + {filteredMembers.length > 0 && ( + + {filteredMembers.map((m) => ( + { + onUpdate({ + assignee_type: "member", + assignee_id: m.user_id, + }); + setOpen(false); + }} + > +
+ {getActorInitials("member", m.user_id)} +
+ {m.name} +
+ ))} +
+ )} + + {/* Agents */} + {filteredAgents.length > 0 && ( + + {filteredAgents.map((a) => ( + { + onUpdate({ + assignee_type: "agent", + assignee_id: a.id, + }); + setOpen(false); + }} + > +
+ +
+ {a.name} +
+ ))} +
+ )} + + {filteredMembers.length === 0 && + filteredAgents.length === 0 && + filter && } +
+ ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/index.ts b/apps/web/app/(dashboard)/issues/_components/pickers/index.ts new file mode 100644 index 00000000..4efa06f1 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/index.ts @@ -0,0 +1,4 @@ +export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker"; +export { StatusPicker } from "./status-picker"; +export { PriorityPicker } from "./priority-picker"; +export { AssigneePicker } from "./assignee-picker"; diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx b/apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx new file mode 100644 index 00000000..3c66285e --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import type { IssuePriority, UpdateIssueRequest } from "@multica/types"; +import { PRIORITY_ORDER, PRIORITY_CONFIG } from "../../_config"; +import { PriorityIcon } from "../icons"; +import { PropertyPicker, PickerItem } from "./property-picker"; + +export function PriorityPicker({ + priority, + onUpdate, +}: { + priority: IssuePriority; + onUpdate: (updates: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const cfg = PRIORITY_CONFIG[priority]; + + return ( + + + {cfg.label} + + } + > + {PRIORITY_ORDER.map((p) => { + const c = PRIORITY_CONFIG[p]; + return ( + { + onUpdate({ priority: p }); + setOpen(false); + }} + > + + {c.label} + + ); + })} + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx b/apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx new file mode 100644 index 00000000..cbff69aa --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Check } from "lucide-react"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@multica/ui/components/ui/popover"; + +// --------------------------------------------------------------------------- +// PropertyPicker — generic Popover shell with optional search +// --------------------------------------------------------------------------- + +export function PropertyPicker({ + open, + onOpenChange, + trigger, + width = "w-48", + align = "end", + searchable = false, + searchPlaceholder = "Filter...", + onSearchChange, + children, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + trigger: React.ReactNode; + width?: string; + align?: "start" | "center" | "end"; + searchable?: boolean; + searchPlaceholder?: string; + onSearchChange?: (query: string) => void; + children: React.ReactNode; +}) { + const [query, setQuery] = useState(""); + + const handleOpenChange = useCallback( + (v: boolean) => { + onOpenChange(v); + if (!v) { + setQuery(""); + onSearchChange?.(""); + } + }, + [onOpenChange, onSearchChange], + ); + + return ( + + + {trigger} + + + {searchable && ( +
+ { + setQuery(e.target.value); + onSearchChange?.(e.target.value); + }} + placeholder={searchPlaceholder} + className="w-full bg-transparent text-[13px] placeholder:text-muted-foreground outline-none" + /> +
+ )} +
{children}
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// PickerItem — single selectable row +// --------------------------------------------------------------------------- + +export function PickerItem({ + selected, + onClick, + hoverClassName, + children, +}: { + selected: boolean; + onClick: () => void; + hoverClassName?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// PickerSection — group header +// --------------------------------------------------------------------------- + +export function PickerSection({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// PickerEmpty — no results state +// --------------------------------------------------------------------------- + +export function PickerEmpty() { + return ( +
+ No results +
+ ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx b/apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx new file mode 100644 index 00000000..5354801f --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import type { IssueStatus, UpdateIssueRequest } from "@multica/types"; +import { ALL_STATUSES, STATUS_CONFIG } from "../../_config"; +import { StatusIcon } from "../icons"; +import { PropertyPicker, PickerItem } from "./property-picker"; + +export function StatusPicker({ + status, + onUpdate, +}: { + status: IssueStatus; + onUpdate: (updates: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const cfg = STATUS_CONFIG[status]; + + return ( + + + {cfg.label} + + } + > + {ALL_STATUSES.map((s) => { + const c = STATUS_CONFIG[s]; + return ( + { + onUpdate({ status: s }); + setOpen(false); + }} + > + + {c.label} + + ); + })} + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_config/index.ts b/apps/web/app/(dashboard)/issues/_config/index.ts new file mode 100644 index 00000000..60d97c53 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_config/index.ts @@ -0,0 +1,2 @@ +export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status"; +export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority"; diff --git a/apps/web/app/(dashboard)/issues/_config/priority.ts b/apps/web/app/(dashboard)/issues/_config/priority.ts new file mode 100644 index 00000000..59345ecd --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_config/priority.ts @@ -0,0 +1,20 @@ +import type { IssuePriority } from "@multica/types"; + +export const PRIORITY_ORDER: IssuePriority[] = [ + "urgent", + "high", + "medium", + "low", + "none", +]; + +export const PRIORITY_CONFIG: Record< + IssuePriority, + { label: string; bars: number; color: string } +> = { + urgent: { label: "Urgent", bars: 4, color: "text-orange-500" }, + high: { label: "High", bars: 3, color: "text-orange-400" }, + medium: { label: "Medium", bars: 2, color: "text-yellow-500" }, + low: { label: "Low", bars: 1, color: "text-blue-400" }, + none: { label: "No priority", bars: 0, color: "text-muted-foreground" }, +}; diff --git a/apps/web/app/(dashboard)/issues/_config/status.ts b/apps/web/app/(dashboard)/issues/_config/status.ts new file mode 100644 index 00000000..f00a7964 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_config/status.ts @@ -0,0 +1,32 @@ +import type { IssueStatus } from "@multica/types"; + +export const STATUS_ORDER: IssueStatus[] = [ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "cancelled", +]; + +export const ALL_STATUSES: IssueStatus[] = [ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "cancelled", +]; + +export const STATUS_CONFIG: Record< + IssueStatus, + { label: string; iconColor: string; hoverBg: string } +> = { + backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, + todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, + in_progress: { label: "In Progress", iconColor: "text-yellow-500", hoverBg: "hover:bg-yellow-500/10" }, + in_review: { label: "In Review", iconColor: "text-green-500", hoverBg: "hover:bg-green-500/10" }, + done: { label: "Done", iconColor: "text-blue-500", hoverBg: "hover:bg-blue-500/10" }, + blocked: { label: "Blocked", iconColor: "text-red-500", hoverBg: "hover:bg-red-500/10" }, + cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, +}; diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index b9ca6aeb..ca753fc9 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -49,6 +49,20 @@ vi.mock("../../../lib/ws-context", () => ({ WSProvider: ({ children }: { children: React.ReactNode }) => children, })); +// Mock tab-store +vi.mock("../../../lib/tab-store", () => ({ + useTabStore: () => ({ + updateTabTitle: vi.fn(), + activeTabId: "tab-1", + openTab: vi.fn(), + }), +})); + +// Mock tab-link to avoid TabProvider dependency +vi.mock("../_components/tab-link", () => ({ + TabLink: ({ children, href, ...props }: any) => {children}, +})); + // Mock api const mockListIssues = vi.fn(); const mockCreateIssue = vi.fn(); @@ -160,13 +174,14 @@ describe("IssuesPage", () => { render(); await waitFor(() => { - expect(screen.getByText("Backlog")).toBeInTheDocument(); + // Status labels appear in both filter dropdown and board columns + expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1); }); - expect(screen.getByText("Todo")).toBeInTheDocument(); - expect(screen.getByText("In Progress")).toBeInTheDocument(); - expect(screen.getByText("In Review")).toBeInTheDocument(); - expect(screen.getByText("Done")).toBeInTheDocument(); + expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1); }); it("switches to list view", async () => { @@ -191,7 +206,20 @@ describe("IssuesPage", () => { expect(screen.getByText("Design landing page")).toBeInTheDocument(); }); - it("shows 'New Issue' button and opens create form", async () => { + it("shows 'New Issue' button", async () => { + mockListIssues.mockResolvedValueOnce({ + issues: [], + total: 0, + } as ListIssuesResponse); + + render(); + + await waitFor(() => { + expect(screen.getByText("New Issue")).toBeInTheDocument(); + }); + }); + + it("shows create dialog when New Issue is clicked", async () => { mockListIssues.mockResolvedValueOnce({ issues: [], total: 0, @@ -206,15 +234,14 @@ describe("IssuesPage", () => { await user.click(screen.getByText("New Issue")); - // Create form should be visible - expect( - screen.getByPlaceholderText("Issue title..."), - ).toBeInTheDocument(); - expect(screen.getByText("Create")).toBeInTheDocument(); - expect(screen.getByText("Cancel")).toBeInTheDocument(); + // Dialog should open with title input + await waitFor(() => { + expect(screen.getByPlaceholderText("Issue title")).toBeInTheDocument(); + }); + expect(screen.getByText("Create Issue")).toBeInTheDocument(); }); - it("creates an issue via the form", async () => { + it("creates an issue via the dialog", async () => { mockListIssues.mockResolvedValueOnce({ issues: [], total: 0, @@ -226,7 +253,7 @@ describe("IssuesPage", () => { workspace_id: "ws-1", title: "New test issue", description: null, - status: "backlog", + status: "todo", priority: "none", assignee_type: null, assignee_id: null, @@ -246,47 +273,21 @@ describe("IssuesPage", () => { }); await user.click(screen.getByText("New Issue")); - await user.type( - screen.getByPlaceholderText("Issue title..."), - "New test issue", - ); - await user.click(screen.getByText("Create")); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Issue title")).toBeInTheDocument(); + }); + + await user.type(screen.getByPlaceholderText("Issue title"), "New test issue"); + await user.click(screen.getByText("Create Issue")); await waitFor(() => { expect(mockCreateIssue).toHaveBeenCalledWith({ title: "New test issue", + status: "todo", + priority: "none", }); }); - - // New issue should appear - await waitFor(() => { - expect(screen.getByText("New test issue")).toBeInTheDocument(); - }); - }); - - it("closes create form on Cancel", async () => { - mockListIssues.mockResolvedValueOnce({ - issues: [], - total: 0, - } as ListIssuesResponse); - - const user = userEvent.setup(); - render(); - - await waitFor(() => { - expect(screen.getByText("New Issue")).toBeInTheDocument(); - }); - - await user.click(screen.getByText("New Issue")); - expect( - screen.getByPlaceholderText("Issue title..."), - ).toBeInTheDocument(); - - await user.click(screen.getByText("Cancel")); - expect( - screen.queryByPlaceholderText("Issue title..."), - ).not.toBeInTheDocument(); - expect(screen.getByText("New Issue")).toBeInTheDocument(); }); it("handles API error gracefully", async () => { diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index 4af1b00d..4e3e5cae 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -2,19 +2,13 @@ import { useState, useCallback, useEffect } from "react"; import Link from "next/link"; +import { TabLink } from "../_components/tab-link"; +import { useTabStore } from "../../../lib/tab-store"; import { Columns3, List, Plus, Bot, - Circle, - CircleDashed, - CircleDot, - CircleCheck, - CircleX, - CircleAlert, - Eye, - Minus, } from "lucide-react"; import { DndContext, @@ -30,70 +24,21 @@ 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 } from "./_data/config"; +import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "./_config"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogTrigger, +} from "@multica/ui/components/ui/dialog"; +import { StatusIcon, PriorityIcon } from "./_components"; import { api } from "../../../lib/api"; import { useAuth } from "../../../lib/auth-context"; import { useWSEvent } from "../../../lib/ws-context"; import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types"; -// --------------------------------------------------------------------------- -// Shared icon components -// --------------------------------------------------------------------------- - -const STATUS_ICONS: Record = { - backlog: CircleDashed, - todo: Circle, - in_progress: CircleDot, - in_review: Eye, - done: CircleCheck, - blocked: CircleAlert, - cancelled: CircleX, -}; - -export function StatusIcon({ - status, - className = "h-4 w-4", -}: { - status: IssueStatus; - className?: string; -}) { - const Icon = STATUS_ICONS[status]; - const cfg = STATUS_CONFIG[status]; - return ; -} - -export function PriorityIcon({ - priority, - className = "", -}: { - priority: IssuePriority; - className?: string; -}) { - const cfg = PRIORITY_CONFIG[priority]; - if (cfg.bars === 0) { - return ; - } - return ( - - {[0, 1, 2, 3].map((i) => ( - - ))} - - ); -} - function AssigneeAvatar({ issue, size = "sm", @@ -186,16 +131,18 @@ function DraggableBoardCard({ issue }: { issue: Issue }) { {...attributes} {...listeners} className={isDragging ? "opacity-30" : ""} + onClickCapture={(e) => { + if (isDragging) e.stopPropagation(); + }} > - { - if (isDragging) e.preventDefault(); - }} + title={issue.title} + iconKey="issues" className="block transition-colors hover:opacity-80" > - +
); } @@ -330,8 +277,10 @@ function BoardView({ function ListRow({ issue }: { issue: Issue }) { return ( - @@ -346,7 +295,7 @@ function ListRow({ issue }: { issue: Issue }) { )} - + ); } @@ -383,65 +332,120 @@ function ListView({ issues }: { issues: Issue[] }) { } // --------------------------------------------------------------------------- -// Create Issue Dialog (simple inline) +// Create Issue Dialog // --------------------------------------------------------------------------- -function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) { +function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void }) { + const [open, setOpen] = useState(false); const [title, setTitle] = useState(""); - const [isOpen, setIsOpen] = useState(false); + const [description, setDescription] = useState(""); + const [status, setStatus] = useState("todo"); + const [priority, setPriority] = useState("none"); + const [submitting, setSubmitting] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const reset = () => { + setTitle(""); + setDescription(""); + setStatus("todo"); + setPriority("none"); + }; + + const handleSubmit = async () => { if (!title.trim()) return; + setSubmitting(true); try { - const issue = await api.createIssue({ title: title.trim() }); + const issue = await api.createIssue({ + title: title.trim(), + description: description.trim() || undefined, + status, + priority, + }); onCreated(issue); - setTitle(""); - setIsOpen(false); + reset(); + setOpen(false); } catch (err) { console.error("Failed to create issue:", err); + } finally { + setSubmitting(false); } }; - if (!isOpen) { - return ( - - ); - } - return ( -
- setTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") setIsOpen(false); - }} - placeholder="Issue title..." - className="rounded-md border bg-background px-2 py-1 text-xs w-48" + { setOpen(v); if (!v) reset(); }}> + + + New Issue + + } /> - - - + + + New Issue + +
+ setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }} + placeholder="Issue title" + className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring" + /> +