fix(issues): polish sub-issue UI and sync parent children cache (#506)

- Add Linear-style "Sub-issue of …" breadcrumb under the title with a
  parent progress ring
- Refresh sub-issues section: progress ring badge, identifier column,
  bordered list, collapse toggle, dashed assignee placeholder
- useUpdateIssue + onIssueUpdated WS handler now also patch and
  invalidate the parent's children query so sub-issue status/assignee
  changes show up on the parent page without a refresh
This commit is contained in:
Bohan Jiang 2026-04-08 17:04:55 +08:00 committed by GitHub
parent 68d052625c
commit bd6731525e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 229 additions and 39 deletions

View file

@ -116,6 +116,17 @@ export function useUpdateIssue() {
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const prevDetail = qc.getQueryData<Issue>(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<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
@ -129,16 +140,34 @@ export function useUpdateIssue() {
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
old ? { ...old, ...data } : old,
);
return { prevList, prevDetail, id };
if (parentId) {
qc.setQueryData<Issue[]>(
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),
});
}
},
});
}

View file

@ -27,6 +27,17 @@ export function onIssueUpdated(
wsId: string,
issue: Partial<Issue> & { 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<ListIssuesResponse>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(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<ListIssuesResponse>(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<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
}
}
export function onIssueDeleted(

View file

@ -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 (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={isComplete ? "text-info" : "text-primary"}
aria-hidden="true"
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
);
}
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 && (
<Link
href={`/issues/${parentIssue.id}`}
className="mt-2 inline-flex max-w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors group/parent"
>
<span className="font-medium shrink-0">Sub-issue of</span>
<StatusIcon status={parentIssue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="tabular-nums shrink-0">{parentIssue.identifier}</span>
<span className="truncate group-hover/parent:text-foreground">
{parentIssue.title}
</span>
{parentChildIssues.length > 0 && (() => {
const done = parentChildIssues.filter((c) => c.status === "done").length;
return (
<span className="ml-1 inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5 shrink-0">
<ProgressRing done={done} total={parentChildIssues.length} size={11} />
<span className="tabular-nums text-[10.5px] font-medium">
{done}/{parentChildIssues.length}
</span>
</span>
);
})()}
</Link>
)}
<ContentEditor
ref={descEditorRef}
key={id}
@ -693,45 +779,104 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
/>
</div>
{/* Sub-issues — below description, like Linear */}
{childIssues.length > 0 && (
<div className="mt-8">
{/* Header */}
<div className="flex items-center gap-3 mb-1">
<div className="flex items-center gap-2">
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium">Sub-issues</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{childIssues.filter((c) => c.status === "done").length}/{childIssues.length}</span>
<button
type="button"
className="ml-auto p-1.5 rounded-md 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-4 w-4" />
</button>
</div>
{/* List */}
<div className="ml-[7px] border-l border-border">
{childIssues.map((child) => (
<Link
key={child.id}
href={`/issues/${child.id}`}
className="flex items-center gap-3 pl-5 pr-2 py-2.5 hover:bg-accent/40 transition-colors group"
{/* Sub-issues — Linear-style */}
{childIssues.length > 0 && (() => {
const doneCount = childIssues.filter((c) => c.status === "done").length;
return (
<div className="mt-10">
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<button
type="button"
onClick={() => setSubIssuesCollapsed((v) => !v)}
className="flex items-center gap-1.5 text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
>
<StatusIcon status={child.status} className="h-[18px] w-[18px] shrink-0" />
<span className="text-sm truncate group-hover:text-foreground">{child.title}</span>
{child.assignee_type && child.assignee_id && (
<ActorAvatar actorType={child.assignee_type} actorId={child.assignee_id} size={24} className="ml-auto shrink-0" />
)}
</Link>
))}
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform",
subIssuesCollapsed && "-rotate-90",
)}
/>
<span>Sub-issues</span>
</button>
<div className="inline-flex items-center gap-1.5 rounded-full bg-muted/60 px-2 py-0.5">
<ProgressRing done={doneCount} total={childIssues.length} size={11} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{doneCount}/{childIssues.length}
</span>
</div>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() =>
useModalStore.getState().open("create-issue", {
parent_issue_id: issue.id,
parent_issue_identifier: issue.identifier,
})
}
aria-label="Add sub-issue"
>
<Plus className="h-4 w-4" />
</button>
}
/>
<TooltipContent side="bottom">Add sub-issue</TooltipContent>
</Tooltip>
</div>
{/* List */}
{!subIssuesCollapsed && (
<div className="overflow-hidden rounded-lg border bg-card/30 divide-y divide-border/60">
{childIssues.map((child) => {
const isDone =
child.status === "done" || child.status === "cancelled";
return (
<Link
key={child.id}
href={`/issues/${child.id}`}
className="flex items-center gap-2.5 px-3 py-2 hover:bg-accent/50 transition-colors group/row"
>
<StatusIcon
status={child.status}
className="h-[15px] w-[15px] shrink-0"
/>
<span className="text-[11px] text-muted-foreground tabular-nums font-medium shrink-0">
{child.identifier}
</span>
<span
className={cn(
"text-sm truncate flex-1",
isDone
? "text-muted-foreground"
: "group-hover/row:text-foreground",
)}
>
{child.title}
</span>
{child.assignee_type && child.assignee_id ? (
<ActorAvatar
actorType={child.assignee_type}
actorId={child.assignee_id}
size={20}
className="shrink-0"
/>
) : (
<span
aria-hidden
className="h-5 w-5 rounded-full border border-dashed border-muted-foreground/30 shrink-0"
/>
)}
</Link>
);
})}
</div>
)}
</div>
</div>
)}
);
})()}
<div className="my-8 border-t" />