fix(issues): move sub-issues to content area and fix real-time refresh (#500)

1. Move sub-issues section from sidebar to main content area (below
   description), matching Linear's layout. Shows status icon, title,
   and assignee avatar for each child issue.

2. Fix real-time refresh: invalidate parent's childIssuesOptions query
   in useCreateIssue mutation (onSuccess), onIssueCreated WS handler,
   and onIssueDeleted WS handler so sub-issues list updates immediately
   without page refresh.
This commit is contained in:
Bohan Jiang 2026-04-08 16:31:49 +08:00 committed by GitHub
parent 76354cd968
commit c6ba954eb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 54 additions and 39 deletions

View file

@ -90,6 +90,10 @@ export function useCreateIssue() {
}
: old,
);
// Invalidate parent's children query so sub-issues list updates immediately
if (newIssue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });

View file

@ -17,6 +17,9 @@ export function onIssueCreated(
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
};
});
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
}
}
export function onIssueUpdated(
@ -53,18 +56,26 @@ export function onIssueDeleted(
wsId: string,
issueId: string,
) {
// Look up the issue before removing it to check for parent_issue_id
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const deleted = listData?.issues.find((i) => i.id === issueId);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const deleted = old.issues.find((i) => i.id === issueId);
const del = old.issues.find((i) => i.id === issueId);
return {
...old,
issues: old.issues.filter((i) => i.id !== issueId),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
};
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
if (deleted?.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
}
}

View file

@ -693,6 +693,43 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
/>
</div>
{/* Sub-issues — below description, like Linear */}
{(childIssues.length > 0 || parentIssue) && (
<div className="mt-6">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium">Sub-issues</span>
{childIssues.length > 0 && (
<span className="text-xs text-muted-foreground">{childIssues.length}/{childIssues.length}</span>
)}
<button
type="button"
className="ml-auto p-1 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.5 w-3.5" />
</button>
</div>
<div className="space-y-0.5">
{childIssues.map((child) => (
<Link
key={child.id}
href={`/issues/${child.id}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5 -mx-2 text-sm hover:bg-accent/50 transition-colors group"
>
<StatusIcon status={child.status} className="h-4 w-4 shrink-0" />
<span className="truncate group-hover:text-foreground">{child.title}</span>
{child.assignee_type && child.assignee_id && (
<ActorAvatar actorType={child.assignee_type} actorId={child.assignee_id} size={20} className="ml-auto shrink-0" />
)}
</Link>
))}
</div>
</div>
)}
<div className="my-8 border-t" />
{/* Activity / Comments */}
@ -1061,43 +1098,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</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