feat(issues): add sub-issue support (#483)
* feat(issues): add sub-issue support
- Backend: Add ListChildIssues SQL query, add parent_issue_id to UpdateIssue,
add GET /api/issues/{id}/children endpoint
- Frontend: Display parent issue breadcrumb and link in issue detail sidebar,
show child issues list with status icons, add "Create sub-issue" action in
dropdown menu and sidebar, pass parent_issue_id through create issue modal
- Update test mocks for new API method
* fix(issues): add parent validation, cycle detection, and improve child refresh
- CreateIssue: validate parent issue exists in the same workspace
- UpdateIssue: validate parent exists, prevent self-referencing, detect
circular parent chains (up to 10 levels deep)
- Frontend: derive child issues from store when available instead of
refetching on every global issue count change
This commit is contained in:
parent
0dcaa60919
commit
a8a8ff6eca
9 changed files with 267 additions and 19 deletions
|
|
@ -166,6 +166,7 @@ vi.mock("@/shared/api", () => ({
|
|||
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
|
||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Link2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
Users,
|
||||
|
|
@ -57,7 +58,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
||||
import type { Issue, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
|
||||
import { CommentCard } from "./comment-card";
|
||||
|
|
@ -75,6 +76,7 @@ import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
|||
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
|
||||
function shortDate(date: string | null): string {
|
||||
|
|
@ -225,6 +227,38 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
} = useIssueSubscribers(id, user?.id);
|
||||
|
||||
// Sub-issue state — derive from store when possible, fetch otherwise
|
||||
const [parentIssue, setParentIssue] = useState<Issue | null>(null);
|
||||
const [childIssues, setChildIssues] = useState<Issue[]>([]);
|
||||
|
||||
// Fetch parent issue when parent_issue_id changes
|
||||
useEffect(() => {
|
||||
if (!issue?.parent_issue_id) {
|
||||
setParentIssue(null);
|
||||
return;
|
||||
}
|
||||
// Try store first, then fetch
|
||||
const storeParent = allIssues.find((i) => i.id === issue.parent_issue_id);
|
||||
if (storeParent) {
|
||||
setParentIssue(storeParent);
|
||||
} else {
|
||||
api.getIssue(issue.parent_issue_id).then(setParentIssue).catch(() => setParentIssue(null));
|
||||
}
|
||||
}, [issue?.parent_issue_id, allIssues]);
|
||||
|
||||
// Fetch child issues once, then keep in sync via store
|
||||
const childIssuesFromStore = allIssues.filter((i) => i.parent_issue_id === id);
|
||||
useEffect(() => {
|
||||
if (!issue) return;
|
||||
// If store has children, use them directly
|
||||
if (childIssuesFromStore.length > 0) {
|
||||
setChildIssues(childIssuesFromStore);
|
||||
return;
|
||||
}
|
||||
// Fetch from API (children may not be in the store yet, e.g. deep-linked)
|
||||
api.listChildIssues(issue.id).then((r) => setChildIssues(r.issues)).catch(() => setChildIssues([]));
|
||||
}, [issue?.id, childIssuesFromStore.length]);
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
|
||||
|
|
@ -377,6 +411,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
{parentIssue && (
|
||||
<>
|
||||
<Link
|
||||
href={`/issues/${parentIssue.id}`}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors truncate shrink-0"
|
||||
>
|
||||
{parentIssue.identifier}
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate text-muted-foreground">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
|
|
@ -547,6 +592,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Create sub-issue */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
useModalStore.getState().open("create-issue", {
|
||||
parent_issue_id: issue.id,
|
||||
parent_issue_identifier: issue.identifier,
|
||||
});
|
||||
}}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create sub-issue
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Copy link */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
|
|
@ -1005,6 +1061,63 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Parent issue */}
|
||||
{parentIssue && (
|
||||
<div>
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rotate-90" />
|
||||
Parent issue
|
||||
</div>
|
||||
<div className="pl-2">
|
||||
<Link
|
||||
href={`/issues/${parentIssue.id}`}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 -mx-2 text-xs hover:bg-accent/50 transition-colors group"
|
||||
>
|
||||
<StatusIcon status={parentIssue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground shrink-0">{parentIssue.identifier}</span>
|
||||
<span className="truncate group-hover:text-foreground">{parentIssue.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-issues */}
|
||||
<div>
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rotate-90" />
|
||||
Sub-issues
|
||||
{childIssues.length > 0 && (
|
||||
<span className="text-muted-foreground">{childIssues.length}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto p-0.5 rounded hover:bg-accent/60 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={() => useModalStore.getState().open("create-issue", {
|
||||
parent_issue_id: issue.id,
|
||||
parent_issue_identifier: issue.identifier,
|
||||
})}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="pl-2 space-y-0.5">
|
||||
{childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/issues/${child.id}`}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 -mx-2 text-xs hover:bg-accent/50 transition-colors group"
|
||||
>
|
||||
<StatusIcon status={child.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground shrink-0">{child.identifier}</span>
|
||||
<span className="truncate group-hover:text-foreground">{child.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
{childIssues.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground px-2">No sub-issues</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details section */}
|
||||
<div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
|||
assignee_id: assigneeId,
|
||||
due_date: dueDate || undefined,
|
||||
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||
parent_issue_id: (data?.parent_issue_id as string) || undefined,
|
||||
});
|
||||
clearDraft();
|
||||
onClose();
|
||||
|
|
@ -196,7 +197,13 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
|||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">New issue</span>
|
||||
{typeof data?.parent_issue_identifier === "string" && (
|
||||
<>
|
||||
<span className="text-muted-foreground">{data.parent_issue_identifier}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium">{data?.parent_issue_id ? "New sub-issue" : "New issue"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -196,6 +196,10 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
||||
return this.fetch(`/api/issues/${id}/children`);
|
||||
}
|
||||
|
||||
async deleteIssue(id: string): Promise<void> {
|
||||
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface UpdateIssueRequest {
|
|||
assignee_id?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
parent_issue_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ListIssuesParams {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Post("/reactions", h.AddIssueReaction)
|
||||
r.Delete("/reactions", h.RemoveIssueReaction)
|
||||
r.Get("/attachments", h.ListAttachments)
|
||||
r.Get("/children", h.ListChildIssues)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -190,6 +190,27 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
children, err := h.Queries.ListChildIssues(r.Context(), issue.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list child issues")
|
||||
return
|
||||
}
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := make([]IssueResponse, len(children))
|
||||
for i, child := range children {
|
||||
resp[i] = issueToResponse(child, prefix)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"issues": resp,
|
||||
})
|
||||
}
|
||||
|
||||
type CreateIssueRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
|
|
@ -251,6 +272,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
var parentIssueID pgtype.UUID
|
||||
if req.ParentIssueID != nil {
|
||||
parentIssueID = parseUUID(*req.ParentIssueID)
|
||||
// Validate parent exists in the same workspace.
|
||||
parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||||
ID: parentIssueID,
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil || !parent.ID.Valid {
|
||||
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var dueDate pgtype.Timestamptz
|
||||
|
|
@ -353,6 +383,7 @@ type UpdateIssueRequest struct {
|
|||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -387,6 +418,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
AssigneeType: prevIssue.AssigneeType,
|
||||
AssigneeID: prevIssue.AssigneeID,
|
||||
DueDate: prevIssue.DueDate,
|
||||
ParentIssueID: prevIssue.ParentIssueID,
|
||||
}
|
||||
|
||||
// COALESCE fields — only set when explicitly provided
|
||||
|
|
@ -432,6 +464,40 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date
|
||||
}
|
||||
}
|
||||
if _, ok := rawFields["parent_issue_id"]; ok {
|
||||
if req.ParentIssueID != nil {
|
||||
newParentID := parseUUID(*req.ParentIssueID)
|
||||
// Cannot set self as parent.
|
||||
if uuidToString(newParentID) == id {
|
||||
writeError(w, http.StatusBadRequest, "an issue cannot be its own parent")
|
||||
return
|
||||
}
|
||||
// Validate parent exists in the same workspace.
|
||||
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||||
ID: newParentID,
|
||||
WorkspaceID: prevIssue.WorkspaceID,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
||||
return
|
||||
}
|
||||
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
|
||||
cursor := newParentID
|
||||
for depth := 0; depth < 10; depth++ {
|
||||
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
|
||||
if err != nil || !ancestor.ParentIssueID.Valid {
|
||||
break
|
||||
}
|
||||
if uuidToString(ancestor.ParentIssueID) == id {
|
||||
writeError(w, http.StatusBadRequest, "circular parent relationship detected")
|
||||
return
|
||||
}
|
||||
cursor = ancestor.ParentIssueID
|
||||
}
|
||||
params.ParentIssueID = newParentID
|
||||
} else {
|
||||
params.ParentIssueID = pgtype.UUID{Valid: false} // explicit null = remove parent
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce agent visibility: private agents can only be assigned by owner/admin.
|
||||
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
|
||||
|
|
@ -658,6 +724,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
|||
AssigneeType: prevIssue.AssigneeType,
|
||||
AssigneeID: prevIssue.AssigneeID,
|
||||
DueDate: prevIssue.DueDate,
|
||||
ParentIssueID: prevIssue.ParentIssueID,
|
||||
}
|
||||
|
||||
if req.Updates.Title != nil {
|
||||
|
|
|
|||
|
|
@ -216,6 +216,51 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
|
|||
return i, err
|
||||
}
|
||||
|
||||
const listChildIssues = `-- name: ListChildIssues :many
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE parent_issue_id = $1
|
||||
ORDER BY position ASC, created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listChildIssues, parentIssueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Issue{}
|
||||
for rows.Next() {
|
||||
var i Issue
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
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, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE workspace_id = $1
|
||||
|
|
@ -345,6 +390,7 @@ UPDATE issue SET
|
|||
assignee_id = $7,
|
||||
position = COALESCE($8, position),
|
||||
due_date = $9,
|
||||
parent_issue_id = $10,
|
||||
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, position, due_date, created_at, updated_at, number
|
||||
|
|
@ -360,6 +406,7 @@ type UpdateIssueParams struct {
|
|||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
Position pgtype.Float8 `json:"position"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
|
||||
|
|
@ -373,6 +420,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
|
|||
arg.AssigneeID,
|
||||
arg.Position,
|
||||
arg.DueDate,
|
||||
arg.ParentIssueID,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ UPDATE issue SET
|
|||
assignee_id = sqlc.narg('assignee_id'),
|
||||
position = COALESCE(sqlc.narg('position'), position),
|
||||
due_date = sqlc.narg('due_date'),
|
||||
parent_issue_id = sqlc.narg('parent_issue_id'),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
|
@ -66,3 +67,8 @@ 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'));
|
||||
|
||||
-- name: ListChildIssues :many
|
||||
SELECT * FROM issue
|
||||
WHERE parent_issue_id = $1
|
||||
ORDER BY position ASC, created_at DESC;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue