mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
Speed up issues-page search
Keep issue search local to the loaded list, defer heavy result updates, and memoize the rendered list body so typing stays responsive. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b825a121cb
commit
4dea302791
1 changed files with 283 additions and 295 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
|
|
@ -68,8 +68,6 @@ const quickFilterPresets = [
|
||||||
{ label: "Backlog", statuses: ["backlog"] },
|
{ label: "Backlog", statuses: ["backlog"] },
|
||||||
{ label: "Done", statuses: ["done", "cancelled"] },
|
{ label: "Done", statuses: ["done", "cancelled"] },
|
||||||
];
|
];
|
||||||
const ISSUE_SEARCH_COMMIT_DELAY_MS = 150;
|
|
||||||
|
|
||||||
function getViewState(key: string): IssueViewState {
|
function getViewState(key: string): IssueViewState {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(key);
|
||||||
|
|
@ -144,6 +142,18 @@ function countActiveFilters(state: IssueViewState): number {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesIssueSearch(issue: Issue, normalizedSearch: string): boolean {
|
||||||
|
if (!normalizedSearch) return true;
|
||||||
|
|
||||||
|
return [
|
||||||
|
issue.identifier,
|
||||||
|
issue.title,
|
||||||
|
issue.description,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Component ── */
|
/* ── Component ── */
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
|
|
@ -175,44 +185,6 @@ interface IssuesListProps {
|
||||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssuesSearchInputProps {
|
|
||||||
initialValue: string;
|
|
||||||
onValueCommitted: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) {
|
|
||||||
const [value, setValue] = useState(initialValue);
|
|
||||||
const onValueCommittedRef = useRef(onValueCommitted);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(initialValue);
|
|
||||||
}, [initialValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onValueCommittedRef.current = onValueCommitted;
|
|
||||||
}, [onValueCommitted]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
onValueCommittedRef.current(value);
|
|
||||||
}, ISSUE_SEARCH_COMMIT_DELAY_MS);
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-48 sm:w-64 md:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
placeholder="Search issues..."
|
|
||||||
className="pl-7 text-xs sm:text-sm"
|
|
||||||
aria-label="Search issues"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IssuesList({
|
export function IssuesList({
|
||||||
issues,
|
issues,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -249,7 +221,8 @@ export function IssuesList({
|
||||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||||
const normalizedIssueSearch = issueSearch.trim();
|
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||||
|
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIssueSearch(initialSearch ?? "");
|
setIssueSearch(initialSearch ?? "");
|
||||||
|
|
@ -266,13 +239,6 @@ export function IssuesList({
|
||||||
}
|
}
|
||||||
}, [scopedKey, initialAssignees]);
|
}, [scopedKey, initialAssignees]);
|
||||||
|
|
||||||
const handleIssueSearchCommit = useCallback((nextSearch: string) => {
|
|
||||||
startTransition(() => {
|
|
||||||
setIssueSearch(nextSearch);
|
|
||||||
});
|
|
||||||
onSearchChange?.(nextSearch);
|
|
||||||
}, [onSearchChange]);
|
|
||||||
|
|
||||||
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
||||||
setViewState((prev) => {
|
setViewState((prev) => {
|
||||||
const next = { ...prev, ...patch };
|
const next = { ...prev, ...patch };
|
||||||
|
|
@ -280,27 +246,18 @@ export function IssuesList({
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [scopedKey]);
|
}, [scopedKey]);
|
||||||
|
|
||||||
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;
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
const sourceIssues = normalizedIssueSearch.length > 0
|
||||||
|
? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch))
|
||||||
|
: issues;
|
||||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||||
return sortIssues(filteredByControls, viewState);
|
return sortIssues(filteredByControls, viewState);
|
||||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
}, [issues, viewState, normalizedIssueSearch, currentUserId]);
|
||||||
|
|
||||||
const { data: labels } = useQuery({
|
const { data: labels } = useQuery({
|
||||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||||
|
|
@ -343,7 +300,7 @@ export function IssuesList({
|
||||||
}));
|
}));
|
||||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
|
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
|
||||||
|
|
||||||
const newIssueDefaults = (groupKey?: string) => {
|
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||||
const defaults: Record<string, string> = {};
|
const defaults: Record<string, string> = {};
|
||||||
if (projectId) defaults.projectId = projectId;
|
if (projectId) defaults.projectId = projectId;
|
||||||
if (groupKey) {
|
if (groupKey) {
|
||||||
|
|
@ -355,13 +312,259 @@ export function IssuesList({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return defaults;
|
return defaults;
|
||||||
};
|
}, [projectId, viewState.groupBy]);
|
||||||
|
|
||||||
const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
||||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
||||||
setAssigneePickerIssueId(null);
|
setAssigneePickerIssueId(null);
|
||||||
setAssigneeSearch("");
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -372,10 +575,19 @@ export function IssuesList({
|
||||||
<Plus className="h-4 w-4 sm:mr-1" />
|
<Plus className="h-4 w-4 sm:mr-1" />
|
||||||
<span className="hidden sm:inline">New Issue</span>
|
<span className="hidden sm:inline">New Issue</span>
|
||||||
</Button>
|
</Button>
|
||||||
<IssuesSearchInput
|
<div className="relative w-48 sm:w-64 md:w-80">
|
||||||
initialValue={initialSearch ?? ""}
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
onValueCommitted={handleIssueSearchCommit}
|
<Input
|
||||||
/>
|
value={issueSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIssueSearch(e.target.value);
|
||||||
|
onSearchChange?.(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Search issues..."
|
||||||
|
className="pl-7 text-xs sm:text-sm"
|
||||||
|
aria-label="Search issues"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||||
|
|
@ -658,231 +870,7 @@ export function IssuesList({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{viewState.viewMode === "board" ? (
|
{listContent}
|
||||||
<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>
|
|
||||||
{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>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue