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: [] }),
|
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
|
||||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Link2,
|
Link2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -57,7 +58,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
|
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||||
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
||||||
import { ActorAvatar } from "@/components/common/actor-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 { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||||
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
|
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
|
||||||
import { CommentCard } from "./comment-card";
|
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 { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
||||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||||
|
import { useModalStore } from "@/features/modals";
|
||||||
import { timeAgo } from "@/shared/utils";
|
import { timeAgo } from "@/shared/utils";
|
||||||
|
|
||||||
function shortDate(date: string | null): string {
|
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,
|
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||||
} = useIssueSubscribers(id, user?.id);
|
} = 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;
|
const loading = issueLoading;
|
||||||
|
|
||||||
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
|
// 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" />
|
<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">
|
<span className="truncate text-muted-foreground">
|
||||||
{issue.identifier}
|
{issue.identifier}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -547,6 +592,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<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 */}
|
{/* Copy link */}
|
||||||
<DropdownMenuItem onClick={() => {
|
<DropdownMenuItem onClick={() => {
|
||||||
navigator.clipboard.writeText(window.location.href);
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
|
@ -1005,6 +1061,63 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
</div>}
|
</div>}
|
||||||
</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 */}
|
{/* Details section */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||||
assignee_id: assigneeId,
|
assignee_id: assigneeId,
|
||||||
due_date: dueDate || undefined,
|
due_date: dueDate || undefined,
|
||||||
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||||
|
parent_issue_id: (data?.parent_issue_id as string) || undefined,
|
||||||
});
|
});
|
||||||
clearDraft();
|
clearDraft();
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -196,7 +197,13 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<span className="text-muted-foreground">{workspaceName}</span>
|
<span className="text-muted-foreground">{workspaceName}</span>
|
||||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
<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>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Tooltip>
|
<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> {
|
async deleteIssue(id: string): Promise<void> {
|
||||||
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface UpdateIssueRequest {
|
||||||
assignee_id?: string | null;
|
assignee_id?: string | null;
|
||||||
position?: number;
|
position?: number;
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
|
parent_issue_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListIssuesParams {
|
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.Post("/reactions", h.AddIssueReaction)
|
||||||
r.Delete("/reactions", h.RemoveIssueReaction)
|
r.Delete("/reactions", h.RemoveIssueReaction)
|
||||||
r.Get("/attachments", h.ListAttachments)
|
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)
|
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 {
|
type CreateIssueRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
|
|
@ -251,6 +272,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
var parentIssueID pgtype.UUID
|
var parentIssueID pgtype.UUID
|
||||||
if req.ParentIssueID != nil {
|
if req.ParentIssueID != nil {
|
||||||
parentIssueID = parseUUID(*req.ParentIssueID)
|
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
|
var dueDate pgtype.Timestamptz
|
||||||
|
|
@ -353,6 +383,7 @@ type UpdateIssueRequest struct {
|
||||||
AssigneeID *string `json:"assignee_id"`
|
AssigneeID *string `json:"assignee_id"`
|
||||||
Position *float64 `json:"position"`
|
Position *float64 `json:"position"`
|
||||||
DueDate *string `json:"due_date"`
|
DueDate *string `json:"due_date"`
|
||||||
|
ParentIssueID *string `json:"parent_issue_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -383,10 +414,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Pre-fill nullable fields (bare sqlc.narg) with current values
|
// Pre-fill nullable fields (bare sqlc.narg) with current values
|
||||||
params := db.UpdateIssueParams{
|
params := db.UpdateIssueParams{
|
||||||
ID: prevIssue.ID,
|
ID: prevIssue.ID,
|
||||||
AssigneeType: prevIssue.AssigneeType,
|
AssigneeType: prevIssue.AssigneeType,
|
||||||
AssigneeID: prevIssue.AssigneeID,
|
AssigneeID: prevIssue.AssigneeID,
|
||||||
DueDate: prevIssue.DueDate,
|
DueDate: prevIssue.DueDate,
|
||||||
|
ParentIssueID: prevIssue.ParentIssueID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// COALESCE fields — only set when explicitly provided
|
// 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
|
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.
|
// Enforce agent visibility: private agents can only be assigned by owner/admin.
|
||||||
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
|
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
|
||||||
|
|
@ -654,10 +720,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
params := db.UpdateIssueParams{
|
params := db.UpdateIssueParams{
|
||||||
ID: prevIssue.ID,
|
ID: prevIssue.ID,
|
||||||
AssigneeType: prevIssue.AssigneeType,
|
AssigneeType: prevIssue.AssigneeType,
|
||||||
AssigneeID: prevIssue.AssigneeID,
|
AssigneeID: prevIssue.AssigneeID,
|
||||||
DueDate: prevIssue.DueDate,
|
DueDate: prevIssue.DueDate,
|
||||||
|
ParentIssueID: prevIssue.ParentIssueID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Updates.Title != nil {
|
if req.Updates.Title != nil {
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,51 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
|
||||||
return i, err
|
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
|
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
|
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
|
WHERE workspace_id = $1
|
||||||
|
|
@ -345,21 +390,23 @@ UPDATE issue SET
|
||||||
assignee_id = $7,
|
assignee_id = $7,
|
||||||
position = COALESCE($8, position),
|
position = COALESCE($8, position),
|
||||||
due_date = $9,
|
due_date = $9,
|
||||||
|
parent_issue_id = $10,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
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
|
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
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateIssueParams struct {
|
type UpdateIssueParams struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Title pgtype.Text `json:"title"`
|
Title pgtype.Text `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Status pgtype.Text `json:"status"`
|
Status pgtype.Text `json:"status"`
|
||||||
Priority pgtype.Text `json:"priority"`
|
Priority pgtype.Text `json:"priority"`
|
||||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||||
Position pgtype.Float8 `json:"position"`
|
Position pgtype.Float8 `json:"position"`
|
||||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||||
|
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
|
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.AssigneeID,
|
||||||
arg.Position,
|
arg.Position,
|
||||||
arg.DueDate,
|
arg.DueDate,
|
||||||
|
arg.ParentIssueID,
|
||||||
)
|
)
|
||||||
var i Issue
|
var i Issue
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ UPDATE issue SET
|
||||||
assignee_id = sqlc.narg('assignee_id'),
|
assignee_id = sqlc.narg('assignee_id'),
|
||||||
position = COALESCE(sqlc.narg('position'), position),
|
position = COALESCE(sqlc.narg('position'), position),
|
||||||
due_date = sqlc.narg('due_date'),
|
due_date = sqlc.narg('due_date'),
|
||||||
|
parent_issue_id = sqlc.narg('parent_issue_id'),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
@ -66,3 +67,8 @@ WHERE workspace_id = $1
|
||||||
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
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('priority')::text IS NULL OR priority = sqlc.narg('priority'))
|
||||||
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'));
|
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