diff --git a/apps/web/core/issues/mutations.ts b/apps/web/core/issues/mutations.ts index fea8fc62..394e5f96 100644 --- a/apps/web/core/issues/mutations.ts +++ b/apps/web/core/issues/mutations.ts @@ -116,6 +116,17 @@ export function useUpdateIssue() { const prevList = qc.getQueryData(issueKeys.list(wsId)); const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); + // Resolve parent_issue_id from the freshest source so we can keep the + // parent's children cache in sync (used by the parent issue's + // sub-issues list). + const parentId = + prevDetail?.parent_issue_id ?? + prevList?.issues.find((i) => i.id === id)?.parent_issue_id ?? + null; + const prevChildren = parentId + ? qc.getQueryData(issueKeys.children(wsId, parentId)) + : undefined; + qc.setQueryData(issueKeys.list(wsId), (old) => old ? { @@ -129,16 +140,34 @@ export function useUpdateIssue() { qc.setQueryData(issueKeys.detail(wsId, id), (old) => old ? { ...old, ...data } : old, ); - return { prevList, prevDetail, id }; + if (parentId) { + qc.setQueryData( + issueKeys.children(wsId, parentId), + (old) => + old?.map((c) => (c.id === id ? { ...c, ...data } : c)), + ); + } + return { prevList, prevDetail, prevChildren, parentId, id }; }, onError: (_err, _vars, ctx) => { if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); if (ctx?.prevDetail) qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail); + if (ctx?.parentId && ctx.prevChildren !== undefined) { + qc.setQueryData( + issueKeys.children(wsId, ctx.parentId), + ctx.prevChildren, + ); + } }, - onSettled: (_data, _err, vars) => { + onSettled: (_data, _err, vars, ctx) => { qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) }); qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + if (ctx?.parentId) { + qc.invalidateQueries({ + queryKey: issueKeys.children(wsId, ctx.parentId), + }); + } }, }); } diff --git a/apps/web/core/issues/ws-updaters.ts b/apps/web/core/issues/ws-updaters.ts index 76cbb38e..0256a5fa 100644 --- a/apps/web/core/issues/ws-updaters.ts +++ b/apps/web/core/issues/ws-updaters.ts @@ -27,6 +27,17 @@ export function onIssueUpdated( wsId: string, issue: Partial & { id: string }, ) { + // Look up the parent before mutating list state, so we can also keep the + // parent's children cache in sync (powers the sub-issues list shown on + // the parent issue page). + const listData = qc.getQueryData(issueKeys.list(wsId)); + const detailData = qc.getQueryData(issueKeys.detail(wsId, issue.id)); + const parentId = + issue.parent_issue_id ?? + detailData?.parent_issue_id ?? + listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ?? + null; + qc.setQueryData(issueKeys.list(wsId), (old) => { if (!old) return old; const prev = old.issues.find((i) => i.id === issue.id); @@ -49,6 +60,11 @@ export function onIssueUpdated( qc.setQueryData(issueKeys.detail(wsId, issue.id), (old) => old ? { ...old, ...issue } : old, ); + if (parentId) { + qc.setQueryData(issueKeys.children(wsId, parentId), (old) => + old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)), + ); + } } export function onIssueDeleted( diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index a8f61f0f..dc851de5 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -78,6 +78,60 @@ 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"; +import { cn } from "@/lib/utils"; + +/** + * Tiny circular progress ring used in the "Sub-issue of …" line and the + * Sub-issues section header. Renders an open ring when in-progress and + * fills to a solid arc when complete. + */ +function ProgressRing({ + done, + total, + size = 12, +}: { + done: number; + total: number; + size?: number; +}) { + const stroke = 1.5; + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const ratio = total > 0 ? Math.min(done / total, 1) : 0; + const offset = circumference * (1 - ratio); + const isComplete = total > 0 && done >= total; + return ( + + ); +} function shortDate(date: string | null): string { if (!date) return "—"; @@ -238,6 +292,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo ...childIssuesOptions(wsId, id), enabled: !!issue, }); + // Parent's children — used to render the "x/y" progress next to the + // "Sub-issue of …" breadcrumb under the title. + const { data: parentChildIssues = [] } = useQuery({ + ...childIssuesOptions(wsId, parentIssueId ?? ""), + enabled: !!parentIssueId, + }); + const [subIssuesCollapsed, setSubIssuesCollapsed] = useState(false); const loading = issueLoading; @@ -663,6 +724,31 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo }} /> + {parentIssue && ( + + Sub-issue of + + {parentIssue.identifier} + + {parentIssue.title} + + {parentChildIssues.length > 0 && (() => { + const done = parentChildIssues.filter((c) => c.status === "done").length; + return ( + + + + {done}/{parentChildIssues.length} + + + ); + })()} + + )} + - {/* Sub-issues — below description, like Linear */} - {childIssues.length > 0 && ( -
- {/* Header */} -
-
- - Sub-issues -
- {childIssues.filter((c) => c.status === "done").length}/{childIssues.length} - -
- {/* List */} -
- {childIssues.map((child) => ( - 0 && (() => { + const doneCount = childIssues.filter((c) => c.status === "done").length; + return ( +
+ {/* Header */} +
+ +
+ + + {doneCount}/{childIssues.length} + +
+ + + useModalStore.getState().open("create-issue", { + parent_issue_id: issue.id, + parent_issue_identifier: issue.identifier, + }) + } + aria-label="Add sub-issue" + > + + + } + /> + Add sub-issue + +
+ + {/* List */} + {!subIssuesCollapsed && ( +
+ {childIssues.map((child) => { + const isDone = + child.status === "done" || child.status === "cancelled"; + return ( + + + + {child.identifier} + + + {child.title} + + {child.assignee_type && child.assignee_id ? ( + + ) : ( + + )} + + ); + })} +
+ )}
-
- )} + ); + })()}