mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
Merge pull request #2733 from davison/feature/issue-management
Issue list and issue properties panel: improved UI
This commit is contained in:
commit
89ad6767c7
7 changed files with 535 additions and 262 deletions
|
|
@ -44,6 +44,7 @@ interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
|
childIssues?: Issue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
|
@ -117,7 +118,7 @@ function PropertyPicker({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
export function IssueProperties({ issue, onUpdate, inline, childIssues }: IssuePropertiesProps) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
|
|
@ -560,17 +561,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
{projectContent}
|
{projectContent}
|
||||||
</PropertyPicker>
|
</PropertyPicker>
|
||||||
|
|
||||||
{issue.parentId && (
|
|
||||||
<PropertyRow label="Parent">
|
|
||||||
<Link
|
|
||||||
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
|
||||||
className="text-sm hover:underline"
|
|
||||||
>
|
|
||||||
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
|
||||||
</Link>
|
|
||||||
</PropertyRow>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{issue.requestDepth > 0 && (
|
{issue.requestDepth > 0 && (
|
||||||
<PropertyRow label="Depth">
|
<PropertyRow label="Depth">
|
||||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||||
|
|
@ -615,6 +605,52 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(issue.parentId || (childIssues && childIssues.length > 0)) && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{issue.parentId && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">Parent task</p>
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
{issue.ancestors?.[0] != null && (
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<StatusIcon status={issue.ancestors[0].status} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{childIssues && childIssues.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">Sub-tasks</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{childIssues.map((child) => (
|
||||||
|
<div key={child.id} className="flex items-start gap-1.5">
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<StatusIcon status={child.status} />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/issues/${child.identifier ?? child.id}`}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{child.title}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,4 +136,42 @@ describe("IssueRow", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders titleSuffix inline after the issue title", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const issue = createIssue({ title: "Parent task" });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<IssueRow
|
||||||
|
issue={issue}
|
||||||
|
titleSuffix={<span data-testid="suffix">(3 sub-tasks)</span>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleEl = container.querySelector(".line-clamp-2, .truncate");
|
||||||
|
expect(titleEl?.textContent).toContain("Parent task");
|
||||||
|
expect(titleEl?.textContent).toContain("(3 sub-tasks)");
|
||||||
|
expect(container.querySelector('[data-testid="suffix"]')).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without error when titleSuffix is omitted", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<IssueRow issue={createIssue()} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleEl = container.querySelector(".line-clamp-2, .truncate");
|
||||||
|
expect(titleEl?.textContent).toContain("Inbox item");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ interface IssueRowProps {
|
||||||
mobileMeta?: ReactNode;
|
mobileMeta?: ReactNode;
|
||||||
desktopTrailing?: ReactNode;
|
desktopTrailing?: ReactNode;
|
||||||
trailingMeta?: ReactNode;
|
trailingMeta?: ReactNode;
|
||||||
|
titleSuffix?: ReactNode;
|
||||||
unreadState?: UnreadState | null;
|
unreadState?: UnreadState | null;
|
||||||
onMarkRead?: () => void;
|
onMarkRead?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
|
|
@ -35,6 +36,7 @@ export function IssueRow({
|
||||||
mobileMeta,
|
mobileMeta,
|
||||||
desktopTrailing,
|
desktopTrailing,
|
||||||
trailingMeta,
|
trailingMeta,
|
||||||
|
titleSuffix,
|
||||||
unreadState = null,
|
unreadState = null,
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
|
|
@ -63,7 +65,7 @@ export function IssueRow({
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||||
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
|
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
|
||||||
{issue.title}
|
{issue.title}{titleSuffix}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||||
{desktopLeadingSpacer ? (
|
{desktopLeadingSpacer ? (
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||||
import { KanbanBoard } from "./KanbanBoard";
|
import { KanbanBoard } from "./KanbanBoard";
|
||||||
|
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
|
||||||
/* ── Helpers ── */
|
/* ── Helpers ── */
|
||||||
|
|
@ -47,6 +48,7 @@ export type IssueViewState = {
|
||||||
groupBy: "status" | "priority" | "assignee" | "none";
|
groupBy: "status" | "priority" | "assignee" | "none";
|
||||||
viewMode: "list" | "board";
|
viewMode: "list" | "board";
|
||||||
collapsedGroups: string[];
|
collapsedGroups: string[];
|
||||||
|
collapsedParents: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultViewState: IssueViewState = {
|
const defaultViewState: IssueViewState = {
|
||||||
|
|
@ -60,6 +62,7 @@ const defaultViewState: IssueViewState = {
|
||||||
groupBy: "none",
|
groupBy: "none",
|
||||||
viewMode: "list",
|
viewMode: "list",
|
||||||
collapsedGroups: [],
|
collapsedGroups: [],
|
||||||
|
collapsedParents: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const quickFilterPresets = [
|
const quickFilterPresets = [
|
||||||
|
|
@ -246,6 +249,29 @@ export function IssuesList({
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [scopedKey]);
|
}, [scopedKey]);
|
||||||
|
|
||||||
|
// Prune stale IDs from collapsedParents whenever the issue list changes.
|
||||||
|
// Deleted or reassigned issues leave orphan IDs in localStorage; this keeps
|
||||||
|
// the stored array bounded to only current parent IDs.
|
||||||
|
useEffect(() => {
|
||||||
|
const parentIds = new Set(issues.map((i) => i.parentId).filter(Boolean) as string[]);
|
||||||
|
const pruned = viewState.collapsedParents.filter((id) => parentIds.has(id));
|
||||||
|
if (pruned.length !== viewState.collapsedParents.length) {
|
||||||
|
updateView({ collapsedParents: pruned });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [issues]);
|
||||||
|
|
||||||
|
const { data: searchedIssues = [] } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||||
|
searchFilters ?? {},
|
||||||
|
],
|
||||||
|
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
|
||||||
|
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
});
|
||||||
|
|
||||||
const agentName = useCallback((id: string | null) => {
|
const agentName = useCallback((id: string | null) => {
|
||||||
if (!id || !agents) return null;
|
if (!id || !agents) return null;
|
||||||
return agents.find((a) => a.id === id)?.name ?? null;
|
return agents.find((a) => a.id === id)?.name ?? null;
|
||||||
|
|
@ -320,251 +346,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 +651,257 @@ 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 { roots, childMap } = buildIssueTree(group.items);
|
||||||
|
|
||||||
|
const renderIssueRow = (issue: Issue, depth: number) => {
|
||||||
|
const children = childMap.get(issue.id) ?? [];
|
||||||
|
const hasChildren = children.length > 0;
|
||||||
|
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
||||||
|
const isExpanded = !viewState.collapsedParents.includes(issue.id);
|
||||||
|
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateView({
|
||||||
|
collapsedParents: isExpanded
|
||||||
|
? [...viewState.collapsedParents, issue.id]
|
||||||
|
: viewState.collapsedParents.filter((id) => id !== issue.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={issue.id} style={depth > 0 ? { paddingLeft: `${depth * 16}px` } : undefined}>
|
||||||
|
<IssueRow
|
||||||
|
issue={issue}
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
|
titleSuffix={hasChildren && !isExpanded ? (
|
||||||
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||||
|
({totalDescendants} sub-task{totalDescendants !== 1 ? "s" : ""})
|
||||||
|
</span>
|
||||||
|
) : 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, depth + 1))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return roots.map((issue) => renderIssueRow(issue, 0));
|
||||||
|
})()}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
130
ui/src/lib/issue-tree.test.ts
Normal file
130
ui/src/lib/issue-tree.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { buildIssueTree, countDescendants } from "./issue-tree";
|
||||||
|
|
||||||
|
function makeIssue(id: string, parentId: string | null = null): Issue {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
identifier: id.toUpperCase(),
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId,
|
||||||
|
title: `Issue ${id}`,
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
issueNumber: 1,
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
myLastTouchAt: null,
|
||||||
|
lastExternalCommentAt: null,
|
||||||
|
isUnreadForMe: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildIssueTree", () => {
|
||||||
|
it("returns all items as roots when no parent-child relationships exist", () => {
|
||||||
|
const items = [makeIssue("a"), makeIssue("b"), makeIssue("c")];
|
||||||
|
const { roots, childMap } = buildIssueTree(items);
|
||||||
|
expect(roots.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(childMap.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places children under their parent and excludes them from roots", () => {
|
||||||
|
const parent = makeIssue("parent");
|
||||||
|
const child1 = makeIssue("child1", "parent");
|
||||||
|
const child2 = makeIssue("child2", "parent");
|
||||||
|
const { roots, childMap } = buildIssueTree([parent, child1, child2]);
|
||||||
|
expect(roots.map((r) => r.id)).toEqual(["parent"]);
|
||||||
|
expect(childMap.get("parent")?.map((c) => c.id)).toEqual(["child1", "child2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple levels of nesting", () => {
|
||||||
|
const grandparent = makeIssue("gp");
|
||||||
|
const parent = makeIssue("p", "gp");
|
||||||
|
const child = makeIssue("c", "p");
|
||||||
|
const { roots, childMap } = buildIssueTree([grandparent, parent, child]);
|
||||||
|
expect(roots.map((r) => r.id)).toEqual(["gp"]);
|
||||||
|
expect(childMap.get("gp")?.map((i) => i.id)).toEqual(["p"]);
|
||||||
|
expect(childMap.get("p")?.map((i) => i.id)).toEqual(["c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes orphaned sub-tasks (parent not in list) to root level", () => {
|
||||||
|
// child references a parent that is not in the items array (e.g. filtered out)
|
||||||
|
const child = makeIssue("child", "missing-parent");
|
||||||
|
const unrelated = makeIssue("unrelated");
|
||||||
|
const { roots, childMap } = buildIssueTree([child, unrelated]);
|
||||||
|
expect(roots.map((r) => r.id)).toEqual(["child", "unrelated"]);
|
||||||
|
expect(childMap.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty roots and empty childMap for an empty list", () => {
|
||||||
|
const { roots, childMap } = buildIssueTree([]);
|
||||||
|
expect(roots).toEqual([]);
|
||||||
|
expect(childMap.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves list order within roots and within children", () => {
|
||||||
|
const p1 = makeIssue("p1");
|
||||||
|
const p2 = makeIssue("p2");
|
||||||
|
const c1 = makeIssue("c1", "p1");
|
||||||
|
const c2 = makeIssue("c2", "p1");
|
||||||
|
const { roots, childMap } = buildIssueTree([p1, c1, p2, c2]);
|
||||||
|
expect(roots.map((r) => r.id)).toEqual(["p1", "p2"]);
|
||||||
|
expect(childMap.get("p1")?.map((c) => c.id)).toEqual(["c1", "c2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("countDescendants", () => {
|
||||||
|
it("returns 0 for a leaf node", () => {
|
||||||
|
const { childMap } = buildIssueTree([makeIssue("a")]);
|
||||||
|
expect(countDescendants("a", childMap)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns direct child count for a single-level parent", () => {
|
||||||
|
const { childMap } = buildIssueTree([
|
||||||
|
makeIssue("p"),
|
||||||
|
makeIssue("c1", "p"),
|
||||||
|
makeIssue("c2", "p"),
|
||||||
|
]);
|
||||||
|
expect(countDescendants("p", childMap)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts all descendants across multiple levels", () => {
|
||||||
|
// P → C → G1, G2 (P has 3 total descendants: C, G1, G2)
|
||||||
|
const { childMap } = buildIssueTree([
|
||||||
|
makeIssue("p"),
|
||||||
|
makeIssue("c", "p"),
|
||||||
|
makeIssue("g1", "c"),
|
||||||
|
makeIssue("g2", "c"),
|
||||||
|
]);
|
||||||
|
expect(countDescendants("p", childMap)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for an id not in the childMap", () => {
|
||||||
|
const { childMap } = buildIssueTree([makeIssue("a"), makeIssue("b")]);
|
||||||
|
expect(countDescendants("nonexistent", childMap)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
ui/src/lib/issue-tree.ts
Normal file
36
ui/src/lib/issue-tree.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export interface IssueTree {
|
||||||
|
roots: Issue[];
|
||||||
|
childMap: Map<string, Issue[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a parent→children tree from a flat list of issues.
|
||||||
|
*
|
||||||
|
* - `roots` contains issues whose parent is absent from the list (or have no
|
||||||
|
* parent at all), so orphaned sub-tasks are always visible at root level.
|
||||||
|
* - `childMap` maps each parent id to its direct children in list order.
|
||||||
|
*/
|
||||||
|
export function buildIssueTree(items: Issue[]): IssueTree {
|
||||||
|
const itemIds = new Set(items.map((i) => i.id));
|
||||||
|
const roots = items.filter((i) => !i.parentId || !itemIds.has(i.parentId));
|
||||||
|
const childMap = new Map<string, Issue[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.parentId && itemIds.has(item.parentId)) {
|
||||||
|
const arr = childMap.get(item.parentId) ?? [];
|
||||||
|
arr.push(item);
|
||||||
|
childMap.set(item.parentId, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { roots, childMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of descendants (all depths) of `id` in `childMap`.
|
||||||
|
* Used to accurately label collapsed parent badges like "(3 sub-tasks)".
|
||||||
|
*/
|
||||||
|
export function countDescendants(id: string, childMap: Map<string, Issue[]>): number {
|
||||||
|
const children = childMap.get(id) ?? [];
|
||||||
|
return children.reduce((sum, c) => sum + 1 + countDescendants(c.id, childMap), 0);
|
||||||
|
}
|
||||||
|
|
@ -985,11 +985,11 @@ export function IssueDetail() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue) {
|
if (issue) {
|
||||||
openPanel(
|
openPanel(
|
||||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
|
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} childIssues={childIssues} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [issue, childIssues]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const inboxQuickArchiveArmedRef = useRef(false);
|
const inboxQuickArchiveArmedRef = useRef(false);
|
||||||
const canQuickArchiveFromInbox =
|
const canQuickArchiveFromInbox =
|
||||||
|
|
@ -1699,7 +1699,7 @@ export function IssueDetail() {
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="flex-1 overflow-y-auto">
|
<ScrollArea className="flex-1 overflow-y-auto">
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
|
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline childIssues={childIssues} />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue