mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
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:
parent
6c8569156c
commit
8cdb65febb
1 changed files with 256 additions and 246 deletions
|
|
@ -219,6 +219,7 @@ export function IssuesList({
|
||||||
return getViewState(scopedKey);
|
return getViewState(scopedKey);
|
||||||
});
|
});
|
||||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||||
|
const [collapsedParents, setCollapsedParents] = useState<Set<string>>(new Set());
|
||||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||||
|
|
@ -320,251 +321,6 @@ export function IssuesList({
|
||||||
setAssigneeSearch("");
|
setAssigneeSearch("");
|
||||||
}, [onUpdateIssue]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue