multica/apps/web/features/issues/components/issues-page.tsx
2026-04-08 11:30:53 +08:00

332 lines
12 KiB
TypeScript

"use client";
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { ChevronRight, ListTodo } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types";
import { Skeleton } from "@/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store";
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
import { filterIssues } from "@/features/issues/utils/filter";
import {
filterIssuesBySearch,
getSearchConstrainedStatuses,
parseIssueSearch,
} from "@/features/issues/utils/search";
import { ALL_STATUSES, BOARD_STATUSES } from "@/features/issues/config";
import { useWorkspaceStore } from "@/features/workspace";
import { WorkspaceAvatar } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { issueListOptions } from "@core/issues/queries";
import { useUpdateIssue } from "@core/issues/mutations";
import { api } from "@/shared/api";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { IssuesHeader } from "./issues-header";
import { BoardView } from "./board-view";
import { ListView } from "./list-view";
import { BatchActionToolbar } from "./batch-action-toolbar";
function mergeIssuesById(base: Issue[] | null, live: Issue[]): Issue[] {
const byId = new Map<string, Issue>();
for (const issue of base ?? []) {
byId.set(issue.id, issue);
}
for (const issue of live) {
byId.set(issue.id, issue);
}
return Array.from(byId.values());
}
export function IssuesPage() {
const wsId = useWorkspaceId();
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
const searchParams = useSearchParams();
const urlSearchQuery = searchParams.get("q") ?? "";
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters);
const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee);
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
const [searchInputValue, setSearchInputValue] = useState(urlSearchQuery);
const [searchQuery, setSearchQueryState] = useState(urlSearchQuery);
const isSearchComposingRef = useRef(false);
const [searchPool, setSearchPool] = useState<Issue[] | null>(null);
const [searchPoolWorkspaceId, setSearchPoolWorkspaceId] = useState<string | null>(null);
const [searchLoading, setSearchLoading] = useState(false);
const deferredSearchQuery = useDeferredValue(searchQuery);
const hasActiveSearch = deferredSearchQuery.trim().length > 0;
const hasActiveFilters =
statusFilters.length > 0 ||
priorityFilters.length > 0 ||
assigneeFilters.length > 0 ||
includeNoAssignee ||
creatorFilters.length > 0;
const replaceSearchUrl = useCallback((nextQuery: string) => {
const params = new URLSearchParams(window.location.search);
if (nextQuery.trim()) {
params.set("q", nextQuery);
} else {
params.delete("q");
}
const next = params.toString();
window.history.replaceState(null, "", next ? `/issues?${next}` : "/issues");
}, []);
const applySearchQuery = useCallback((nextQuery: string) => {
setSearchQueryState(nextQuery);
replaceSearchUrl(nextQuery);
}, [replaceSearchUrl]);
const handleSearchInputChange = useCallback((nextQuery: string) => {
setSearchInputValue(nextQuery);
if (!isSearchComposingRef.current) {
applySearchQuery(nextQuery);
}
}, [applySearchQuery]);
const handleSearchCompositionStart = useCallback(() => {
isSearchComposingRef.current = true;
}, []);
const handleSearchCompositionEnd = useCallback((nextQuery: string) => {
isSearchComposingRef.current = false;
setSearchInputValue(nextQuery);
applySearchQuery(nextQuery);
}, [applySearchQuery]);
useEffect(() => {
initFilterWorkspaceSync();
}, []);
useEffect(() => {
setSearchInputValue(urlSearchQuery);
setSearchQueryState(urlSearchQuery);
}, [urlSearchQuery]);
useEffect(() => {
setSearchPool(null);
setSearchPoolWorkspaceId(null);
}, [workspace?.id]);
useEffect(() => {
if (!hasActiveSearch || !workspace?.id) return;
if (searchPool && searchPoolWorkspaceId === workspace.id) return;
let cancelled = false;
setSearchLoading(true);
api.listIssues({ all: true })
.then((res) => {
if (cancelled) return;
setSearchPool(res.issues);
setSearchPoolWorkspaceId(workspace.id);
})
.catch((error) => {
if (cancelled) return;
console.error(error);
toast.error("Failed to search all issues");
})
.finally(() => {
if (!cancelled) setSearchLoading(false);
});
return () => {
cancelled = true;
};
}, [hasActiveSearch, searchPool, searchPoolWorkspaceId, workspace?.id]);
useEffect(() => {
useIssueSelectionStore.getState().clear();
}, [viewMode, scope, deferredSearchQuery]);
const parsedSearch = useMemo(
() => parseIssueSearch(deferredSearchQuery, { members, agents }),
[agents, deferredSearchQuery, members],
);
const searchableIssues = useMemo(() => {
if (!hasActiveSearch) return allIssues;
if (searchPoolWorkspaceId !== workspace?.id) return allIssues;
return mergeIssuesById(searchPool, allIssues);
}, [allIssues, hasActiveSearch, searchPool, searchPoolWorkspaceId, workspace?.id]);
// Scope pre-filter: narrow by assignee type
const scopedIssues = useMemo(() => {
if (scope === "members")
return searchableIssues.filter((i) => i.assignee_type === "member");
if (scope === "agents")
return searchableIssues.filter((i) => i.assignee_type === "agent");
return searchableIssues;
}, [scope, searchableIssues]);
const filteredIssues = useMemo(
() =>
filterIssues(scopedIssues, {
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
}),
[
assigneeFilters,
creatorFilters,
includeNoAssignee,
priorityFilters,
scopedIssues,
statusFilters,
],
);
const issues = useMemo(
() => filterIssuesBySearch(filteredIssues, parsedSearch, { members, agents }),
[agents, filteredIssues, members, parsedSearch],
);
const visibleStatuses = useMemo(() => {
const explicitSearchStatuses = getSearchConstrainedStatuses(parsedSearch);
const explicitStatuses =
statusFilters.length > 0 || explicitSearchStatuses
? ALL_STATUSES.filter((status) => {
if (statusFilters.length > 0 && !statusFilters.includes(status)) {
return false;
}
if (explicitSearchStatuses && !explicitSearchStatuses.includes(status)) {
return false;
}
return true;
})
: null;
if (explicitStatuses) return explicitStatuses;
if (hasActiveSearch) {
const resultStatuses = new Set(issues.map((issue) => issue.status));
return ALL_STATUSES.filter((status) => resultStatuses.has(status));
}
return BOARD_STATUSES;
}, [hasActiveSearch, issues, parsedSearch, statusFilters]);
const hiddenStatuses = useMemo(() => {
if (hasActiveSearch) return [];
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
}, [hasActiveSearch, visibleStatuses]);
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
// Auto-switch to manual sort so drag ordering is preserved
const viewState = useIssueViewStore.getState();
if (viewState.sortBy !== "position") {
viewState.setSortBy("position");
viewState.setSortDirection("asc");
}
const updates: Partial<{ status: IssueStatus; position: number }> = {
status: newStatus,
};
if (newPosition !== undefined) updates.position = newPosition;
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error("Failed to move issue") },
);
},
[updateIssueMutation],
);
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
</div>
);
}
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header 1: Workspace breadcrumb */}
<div className="flex h-12 shrink-0 items-center gap-1.5 border-b px-4">
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
<span className="text-sm text-muted-foreground">
{workspace?.name ?? "Workspace"}
</span>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium">Issues</span>
</div>
{/* Header 2: Scope tabs + filters */}
<IssuesHeader
scopedIssues={scopedIssues}
searchQuery={searchInputValue}
searchLoading={searchLoading}
resultCount={issues.length}
onSearchQueryChange={handleSearchInputChange}
onSearchCompositionStart={handleSearchCompositionStart}
onSearchCompositionEnd={handleSearchCompositionEnd}
/>
{/* Content: scrollable */}
<ViewStoreProvider store={useIssueViewStore}>
{scopedIssues.length === 0 && !hasActiveSearch && !hasActiveFilters ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">No issues yet</p>
<p className="text-xs">Create an issue to get started.</p>
</div>
) : issues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">No issues match this search</p>
<p className="text-xs">
Try `#123`, `status:done`, `assignee:alice`, or looser keywords.
</p>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={issues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
)}
{viewMode === "list" && <BatchActionToolbar />}
</ViewStoreProvider>
</div>
);
}