forked from farhoodlabs/paperclip
feat: show sub-tasks indented under parent in issue list with collapse/expand
Sub-tasks are now grouped under their parent issue in the list view. Parent issues with children show a chevron to collapse/expand their subtasks. Child issues are visually indented to indicate hierarchy. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+256
-246
@@ -219,6 +219,7 @@ export function IssuesList({
|
||||
return getViewState(scopedKey);
|
||||
});
|
||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||
const [collapsedParents, setCollapsedParents] = useState<Set<string>>(new Set());
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||
@@ -320,251 +321,6 @@ export function IssuesList({
|
||||
setAssigneeSearch("");
|
||||
}, [onUpdateIssue]);
|
||||
|
||||
const listContent = useMemo(() => {
|
||||
if (viewState.viewMode === "board") {
|
||||
return (
|
||||
<KanbanBoard
|
||||
issues={filtered}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
onUpdateIssue={onUpdateIssue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return groupedContent.map((group) => (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={!viewState.collapsedGroups.includes(group.key)}
|
||||
onOpenChange={(open) => {
|
||||
updateView({
|
||||
collapsedGroups: open
|
||||
? viewState.collapsedGroups.filter((k) => k !== group.key)
|
||||
: [...viewState.collapsedGroups, group.key],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{group.label && (
|
||||
<div className="flex items-center py-1.5 pl-1 pr-3">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => openNewIssue(newIssueDefaults(group.key))}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
desktopLeadingSpacer
|
||||
mobileLeading={(
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
desktopMetaLeading={(
|
||||
<>
|
||||
<span
|
||||
className="hidden shrink-0 sm:inline-flex"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||
/>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{liveIssueIds?.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
mobileMeta={timeAgo(issue.updatedAt)}
|
||||
desktopTrailing={(
|
||||
<>
|
||||
{(issue.labels ?? []).length > 0 && (
|
||||
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
|
||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
color: pickTextColorForPillBg(label.color, 0.12),
|
||||
backgroundColor: `${label.color}1f`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{(issue.labels ?? []).length - 3}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Popover
|
||||
open={assigneePickerIssueId === issue.id}
|
||||
onOpenChange={(open) => {
|
||||
setAssigneePickerIssueId(open ? issue.id : null);
|
||||
if (!open) setAssigneeSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||
>
|
||||
<input
|
||||
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Search assignees..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, null);
|
||||
}}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, currentUserId);
|
||||
}}
|
||||
>
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>Me</span>
|
||||
</button>
|
||||
)}
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
return agent.name
|
||||
.toLowerCase()
|
||||
.includes(assigneeSearch.toLowerCase());
|
||||
})
|
||||
.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeAgentId === agent.id && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
trailingMeta={formatDate(issue.createdAt)}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
));
|
||||
}, [
|
||||
agents,
|
||||
agentName,
|
||||
assigneePickerIssueId,
|
||||
assigneeSearch,
|
||||
assignIssue,
|
||||
currentUserId,
|
||||
filtered,
|
||||
groupedContent,
|
||||
issueLinkState,
|
||||
liveIssueIds,
|
||||
newIssueDefaults,
|
||||
onUpdateIssue,
|
||||
openNewIssue,
|
||||
updateView,
|
||||
viewState.collapsedGroups,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -870,7 +626,261 @@ export function IssuesList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{listContent}
|
||||
{viewState.viewMode === "board" ? (
|
||||
<KanbanBoard
|
||||
issues={filtered}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
onUpdateIssue={onUpdateIssue}
|
||||
/>
|
||||
) : (
|
||||
groupedContent.map((group) => (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={!viewState.collapsedGroups.includes(group.key)}
|
||||
onOpenChange={(open) => {
|
||||
updateView({
|
||||
collapsedGroups: open
|
||||
? viewState.collapsedGroups.filter((k) => k !== group.key)
|
||||
: [...viewState.collapsedGroups, group.key],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{group.label && (
|
||||
<div className="flex items-center py-1.5 pl-1 pr-3">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => openNewIssue(newIssueDefaults(group.key))}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{(() => {
|
||||
const itemIds = new Set(group.items.map((i) => i.id));
|
||||
const roots = group.items.filter((i) => !i.parentId || !itemIds.has(i.parentId));
|
||||
const childMap = new Map<string, Issue[]>();
|
||||
for (const item of group.items) {
|
||||
if (item.parentId && itemIds.has(item.parentId)) {
|
||||
const arr = childMap.get(item.parentId) ?? [];
|
||||
arr.push(item);
|
||||
childMap.set(item.parentId, arr);
|
||||
}
|
||||
}
|
||||
|
||||
const renderIssueRow = (issue: Issue, isChild: boolean) => {
|
||||
const children = childMap.get(issue.id) ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
const isExpanded = !collapsedParents.has(issue.id);
|
||||
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCollapsedParents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(issue.id)) next.delete(issue.id); else next.add(issue.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={issue.id}>
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
className={isChild ? "pl-6" : undefined}
|
||||
mobileLeading={
|
||||
hasChildren ? (
|
||||
<button type="button" onClick={toggleCollapse}>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
desktopMetaLeading={(
|
||||
<>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden shrink-0 items-center sm:inline-flex"
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="hidden w-3.5 shrink-0 sm:block" />
|
||||
)}
|
||||
<span
|
||||
className="hidden shrink-0 sm:inline-flex"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{liveIssueIds?.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
mobileMeta={timeAgo(issue.updatedAt)}
|
||||
desktopTrailing={(
|
||||
<>
|
||||
{(issue.labels ?? []).length > 0 && (
|
||||
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
|
||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
color: pickTextColorForPillBg(label.color, 0.12),
|
||||
backgroundColor: `${label.color}1f`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{(issue.labels ?? []).length - 3}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Popover
|
||||
open={assigneePickerIssueId === issue.id}
|
||||
onOpenChange={(open) => {
|
||||
setAssigneePickerIssueId(open ? issue.id : null);
|
||||
if (!open) setAssigneeSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||
>
|
||||
<input
|
||||
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Search assignees..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, null);
|
||||
}}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, currentUserId);
|
||||
}}
|
||||
>
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>Me</span>
|
||||
</button>
|
||||
)}
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
||||
})
|
||||
.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeAgentId === agent.id && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
trailingMeta={formatDate(issue.createdAt)}
|
||||
/>
|
||||
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, true))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return roots.map((issue) => renderIssueRow(issue, false));
|
||||
})()}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user