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:
parent
68d052625c
commit
bd6731525e
3 changed files with 229 additions and 39 deletions
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue