refactor(issues): migrate all consumers to TanStack Query (Phase 1, Commits 5-10)
- Migrate issue-detail.tsx: useQuery for issue data, useUpdateIssue/useDeleteIssue
- Migrate issues-page.tsx, my-issues-page.tsx, board-card.tsx: useQuery for list
- Migrate batch-action-toolbar.tsx, create-issue.tsx: mutation hooks
- Migrate edge consumers: mention-suggestion, mention-view, agents page, issue-mention-card
- Remove Zustand writes from WS sync (TQ cache is now sole source of truth)
- Remove useIssueStore.fetch() dependency from workspace store
- Gut useIssueStore to client-only: { activeIssueId, setActiveIssue }
- Update test wrappers with QueryClientProvider
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7560f7be85
commit
1ad057fb0f
14 changed files with 116 additions and 190 deletions
|
|
@ -76,7 +76,9 @@ import { api } from "@/shared/api";
|
|||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
|
|
@ -1056,7 +1058,8 @@ function TriggersTab({
|
|||
function TasksTab({ agent }: { agent: Agent }) {
|
||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const issues = useIssueStore((s) => s.issues);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@/shared/types";
|
||||
|
||||
// Mock next/navigation
|
||||
|
|
@ -61,10 +62,11 @@ vi.mock("sonner", () => ({
|
|||
|
||||
// Mock api
|
||||
const mockUpdateIssue = vi.fn();
|
||||
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
|
||||
|
||||
vi.mock("@/shared/api", () => ({
|
||||
api: {
|
||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
||||
listIssues: (...args: any[]) => mockListIssues(...args),
|
||||
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
|
||||
},
|
||||
}));
|
||||
|
|
@ -282,6 +284,11 @@ const mockIssues: Issue[] = [
|
|||
|
||||
import IssuesPage from "./page";
|
||||
|
||||
function renderWithQuery(ui: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("IssuesPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -302,17 +309,18 @@ describe("IssuesPage", () => {
|
|||
it("shows loading state initially", () => {
|
||||
mockStoreState.loading = true;
|
||||
mockStoreState.issues = [];
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders issues in board view after loading", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
mockListIssues.mockResolvedValue({ issues: mockIssues, total: mockIssues.length });
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
await screen.findByText("Implement auth");
|
||||
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -320,43 +328,46 @@ describe("IssuesPage", () => {
|
|||
it("renders board columns", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
mockListIssues.mockResolvedValue({ issues: mockIssues, total: mockIssues.length });
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
|
||||
await screen.findByText("Backlog");
|
||||
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows workspace breadcrumb", () => {
|
||||
it("shows workspace breadcrumb", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||
await screen.findByText("Issues");
|
||||
});
|
||||
|
||||
it("shows scope buttons", () => {
|
||||
it("shows scope buttons", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("All")).toBeInTheDocument();
|
||||
await screen.findByText("All");
|
||||
expect(screen.getByText("Members")).toBeInTheDocument();
|
||||
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows filter and display icon buttons", () => {
|
||||
it("shows filter and display icon buttons", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
mockListIssues.mockResolvedValue({ issues: mockIssues, total: mockIssues.length });
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
// Filter and Display are now icon-only buttons, verify they render as buttons
|
||||
// Wait for query to resolve and component to render past loading state
|
||||
await screen.findByText("Implement auth");
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
|
@ -365,7 +376,7 @@ describe("IssuesPage", () => {
|
|||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
// Should still render the board/list view, not a "no issues" message
|
||||
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import {
|
|||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { getQueryClient } from "@core/query-client";
|
||||
import { issueKeys } from "@core/issues/queries";
|
||||
import type { Issue, ListIssuesResponse } from "@/shared/types";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -217,7 +219,10 @@ export function createMentionSuggestion(): Omit<
|
|||
return {
|
||||
items: ({ query }) => {
|
||||
const { members, agents } = useWorkspaceStore.getState();
|
||||
const { issues } = useIssueStore.getState();
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
const issues: Issue[] = wsId
|
||||
? getQueryClient().getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
|
||||
: [];
|
||||
const q = query.toLowerCase();
|
||||
|
||||
// Show "All members" option when query is empty or matches "all"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@
|
|||
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
|
||||
export function MentionView({ node }: NodeViewProps) {
|
||||
|
|
@ -48,7 +50,9 @@ function IssueMention({
|
|||
issueId: string;
|
||||
fallbackLabel?: string;
|
||||
}) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@ import {
|
|||
} from "@/components/ui/popover";
|
||||
import type { UpdateIssueRequest } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { api } from "@/shared/api";
|
||||
import { useBatchUpdateIssues, useBatchDeleteIssues } from "@core/issues/mutations";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
import { AssigneePicker } from "./pickers";
|
||||
|
|
@ -37,46 +36,31 @@ export function BatchActionToolbar() {
|
|||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const batchUpdate = useBatchUpdateIssues();
|
||||
const batchDelete = useBatchDeleteIssues();
|
||||
const loading = batchUpdate.isPending || batchDelete.isPending;
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
const ids = Array.from(selectedIds);
|
||||
|
||||
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.batchUpdateIssues(ids, updates);
|
||||
for (const id of ids) {
|
||||
useIssueStore.getState().updateIssue(id, updates);
|
||||
}
|
||||
await batchUpdate.mutateAsync({ ids, updates });
|
||||
toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`);
|
||||
} catch {
|
||||
toast.error("Failed to update issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.batchDeleteIssues(ids);
|
||||
for (const id of ids) {
|
||||
useIssueStore.getState().removeIssue(id);
|
||||
}
|
||||
await batchDelete.mutateAsync(ids);
|
||||
clear();
|
||||
toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`);
|
||||
} catch {
|
||||
toast.error("Failed to delete issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import { toast } from "sonner";
|
|||
import type { Issue, UpdateIssueRequest } from "@/shared/types";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { api } from "@/shared/api";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useUpdateIssue } from "@core/issues/mutations";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
|
||||
import { PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
|
|
@ -46,16 +45,15 @@ export const BoardCardContent = memo(function BoardCardContent({
|
|||
const storeProperties = useViewStore((s) => s.cardProperties);
|
||||
const priorityCfg = PRIORITY_CONFIG[issue.priority];
|
||||
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
const prev = { ...issue };
|
||||
useIssueStore.getState().updateIssue(issue.id, updates);
|
||||
api.updateIssue(issue.id, updates).catch(() => {
|
||||
useIssueStore.getState().updateIssue(issue.id, prev);
|
||||
toast.error("Failed to update issue");
|
||||
});
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issue.id, ...updates },
|
||||
{ onError: () => toast.error("Failed to update issue") },
|
||||
);
|
||||
},
|
||||
[issue],
|
||||
[issue.id, updateIssueMutation],
|
||||
);
|
||||
|
||||
const showPriority = storeProperties.priority;
|
||||
|
|
|
|||
|
|
@ -63,10 +63,12 @@ import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent
|
|||
import { CommentCard } from "./comment-card";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
|
||||
import { api } from "@/shared/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { issueListOptions, issueDetailOptions } from "@core/issues/queries";
|
||||
import { useUpdateIssue, useDeleteIssue } from "@core/issues/mutations";
|
||||
import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
|
||||
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
||||
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
||||
|
|
@ -179,8 +181,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
|
||||
|
||||
// Issue navigation
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
// Issue navigation — read from TQ list cache
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
const currentIndex = allIssues.findIndex((i) => i.id === id);
|
||||
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
||||
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
||||
|
|
@ -200,38 +203,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const didHighlightRef = useRef<string | null>(null);
|
||||
|
||||
// Single source of truth: read issue directly from global store
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
|
||||
const [issueLoading, setIssueLoading] = useState(!issue);
|
||||
|
||||
// If issue isn't in the store yet, fetch and upsert it.
|
||||
// loadedIdRef tracks which issue was already loaded — if it disappears
|
||||
// from the store (workspace switch clears all issues), skip refetch.
|
||||
const loadedIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (issue) {
|
||||
loadedIdRef.current = id;
|
||||
setIssueLoading(false);
|
||||
return;
|
||||
}
|
||||
// Issue was loaded for this id but vanished → store cleared (workspace switch)
|
||||
if (loadedIdRef.current === id) {
|
||||
loadedIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Issue not in store → fetch it
|
||||
setIssueLoading(true);
|
||||
api
|
||||
.getIssue(id)
|
||||
.then((iss) => {
|
||||
useIssueStore.getState().addIssue(iss);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load issue");
|
||||
})
|
||||
.finally(() => setIssueLoading(false));
|
||||
}, [id, !!issue]);
|
||||
// Issue data from TQ — uses detail query, seeded from list cache if available
|
||||
const { data: issue = null, isLoading: issueLoading } = useQuery({
|
||||
...issueDetailOptions(wsId, id),
|
||||
initialData: () => allIssues.find((i) => i.id === id),
|
||||
});
|
||||
|
||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||
const {
|
||||
|
|
@ -283,18 +259,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
// Issue field updates — write directly to the global store (single source of truth)
|
||||
// Issue field updates via TQ mutation (optimistic update + rollback in mutation hook)
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleUpdateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
if (!issue) return;
|
||||
const prev = { ...issue };
|
||||
useIssueStore.getState().updateIssue(id, updates);
|
||||
api.updateIssue(id, updates).catch(() => {
|
||||
useIssueStore.getState().updateIssue(id, prev);
|
||||
toast.error("Failed to update issue");
|
||||
});
|
||||
updateIssueMutation.mutate(
|
||||
{ id, ...updates },
|
||||
{ onError: () => toast.error("Failed to update issue") },
|
||||
);
|
||||
},
|
||||
[issue, id],
|
||||
[issue, id, updateIssueMutation],
|
||||
);
|
||||
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
|
|
@ -303,11 +278,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
[uploadWithToast, id],
|
||||
);
|
||||
|
||||
const deleteIssueMutation = useDeleteIssue();
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteIssue(issue!.id);
|
||||
useIssueStore.getState().removeIssue(issue!.id);
|
||||
await deleteIssueMutation.mutateAsync(issue!.id);
|
||||
toast.success("Issue deleted");
|
||||
if (onDelete) onDelete();
|
||||
else router.push("/issues");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
interface IssueMentionCardProps {
|
||||
|
|
@ -11,7 +13,9 @@ interface IssueMentionCardProps {
|
|||
}
|
||||
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { toast } from "sonner";
|
|||
import { ChevronRight, ListTodo } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
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";
|
||||
|
|
@ -13,7 +13,9 @@ import { filterIssues } from "@/features/issues/utils/filter";
|
|||
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { useUpdateIssue } from "@core/issues/mutations";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { IssuesHeader } from "./issues-header";
|
||||
import { BoardView } from "./board-view";
|
||||
|
|
@ -21,8 +23,8 @@ import { ListView } from "./list-view";
|
|||
import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||
|
||||
export function IssuesPage() {
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const scope = useIssuesScopeStore((s) => s.scope);
|
||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||
|
|
@ -64,6 +66,7 @@ export function IssuesPage() {
|
|||
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
||||
}, [visibleStatuses]);
|
||||
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
// Auto-switch to manual sort so drag ordering is preserved
|
||||
|
|
@ -78,16 +81,12 @@ export function IssuesPage() {
|
|||
};
|
||||
if (newPosition !== undefined) updates.position = newPosition;
|
||||
|
||||
useIssueStore.getState().updateIssue(issueId, updates);
|
||||
|
||||
api.updateIssue(issueId, updates).catch(() => {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
});
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error("Failed to move issue") },
|
||||
);
|
||||
},
|
||||
[]
|
||||
[updateIssueMutation],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
|
|
|||
|
|
@ -1,57 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("issue-store");
|
||||
|
||||
interface IssueState {
|
||||
issues: Issue[];
|
||||
loading: boolean;
|
||||
interface IssueClientState {
|
||||
activeIssueId: string | null;
|
||||
fetch: () => Promise<void>;
|
||||
setIssues: (issues: Issue[]) => void;
|
||||
addIssue: (issue: Issue) => void;
|
||||
updateIssue: (id: string, updates: Partial<Issue>) => void;
|
||||
removeIssue: (id: string) => void;
|
||||
setActiveIssue: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useIssueStore = create<IssueState>((set, get) => ({
|
||||
issues: [],
|
||||
loading: true,
|
||||
export const useIssueStore = create<IssueClientState>((set) => ({
|
||||
activeIssueId: null,
|
||||
|
||||
fetch: async () => {
|
||||
logger.debug("fetch start");
|
||||
const isInitialLoad = get().issues.length === 0;
|
||||
if (isInitialLoad) set({ loading: true });
|
||||
try {
|
||||
const res = await api.listIssues({ limit: 200 });
|
||||
logger.info("fetched", res.issues.length, "issues");
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load issues");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setIssues: (issues) => set({ issues }),
|
||||
addIssue: (issue) =>
|
||||
set((s) => ({
|
||||
issues: s.issues.some((i) => i.id === issue.id)
|
||||
? s.issues
|
||||
: [...s.issues, issue],
|
||||
})),
|
||||
updateIssue: (id, updates) =>
|
||||
set((s) => ({
|
||||
issues: s.issues.map((i) => (i.id === id ? { ...i, ...updates } : i)),
|
||||
})),
|
||||
removeIssue: (id) =>
|
||||
set((s) => ({ issues: s.issues.filter((i) => i.id !== id) })),
|
||||
setActiveIssue: (id) => set({ activeIssueId: id }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -30,9 +30,8 @@ import { TitleEditor } from "@/features/editor";
|
|||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
|
||||
import { api } from "@/shared/api";
|
||||
import { useCreateIssue } from "@core/issues/mutations";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
|
|
@ -125,11 +124,12 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
|||
};
|
||||
const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); };
|
||||
|
||||
const createIssueMutation = useCreateIssue();
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const issue = await api.createIssue({
|
||||
const issue = await createIssueMutation.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||||
status,
|
||||
|
|
@ -139,7 +139,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
|||
due_date: dueDate || undefined,
|
||||
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||
});
|
||||
useIssueStore.getState().addIssue(issue);
|
||||
clearDraft();
|
||||
onClose();
|
||||
toast.custom((t) => (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { IssueStatus } from "@/shared/types";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||
|
|
@ -17,7 +17,9 @@ import { BoardView } from "@/features/issues/components/board-view";
|
|||
import { ListView } from "@/features/issues/components/list-view";
|
||||
import { BatchActionToolbar } from "@/features/issues/components/batch-action-toolbar";
|
||||
import { registerViewStoreForWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { useUpdateIssue } from "@core/issues/mutations";
|
||||
import { myIssuesViewStore } from "../stores/my-issues-view-store";
|
||||
import { MyIssuesHeader } from "./my-issues-header";
|
||||
|
||||
|
|
@ -25,8 +27,8 @@ export function MyIssuesPage() {
|
|||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||
|
||||
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
||||
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
||||
|
|
@ -105,6 +107,7 @@ export function MyIssuesPage() {
|
|||
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
||||
}, [visibleStatuses]);
|
||||
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
const viewState = myIssuesViewStore.getState();
|
||||
|
|
@ -118,16 +121,12 @@ export function MyIssuesPage() {
|
|||
};
|
||||
if (newPosition !== undefined) updates.position = newPosition;
|
||||
|
||||
useIssueStore.getState().updateIssue(issueId, updates);
|
||||
|
||||
api.updateIssue(issueId, updates).catch(() => {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
});
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error("Failed to move issue") },
|
||||
);
|
||||
},
|
||||
[],
|
||||
[updateIssueMutation],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useEffect } from "react";
|
||||
import type { WSClient } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
|
|
@ -99,11 +98,9 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
if (!issue?.id) return;
|
||||
useIssueStore.getState().updateIssue(issue.id, issue);
|
||||
if (issue.status) {
|
||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
||||
}
|
||||
// Dual-write: TanStack Query cache
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueUpdated(getQueryClient(), wsId, issue);
|
||||
});
|
||||
|
|
@ -111,8 +108,6 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||
const { issue } = p as IssueCreatedPayload;
|
||||
if (!issue) return;
|
||||
useIssueStore.getState().addIssue(issue);
|
||||
// Dual-write: TanStack Query cache
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueCreated(getQueryClient(), wsId, issue);
|
||||
});
|
||||
|
|
@ -120,8 +115,6 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
if (!issue_id) return;
|
||||
useIssueStore.getState().removeIssue(issue_id);
|
||||
// Dual-write: TanStack Query cache
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueDeleted(getQueryClient(), wsId, issue_id);
|
||||
});
|
||||
|
|
@ -185,13 +178,11 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
const unsub = ws.onReconnect(async () => {
|
||||
logger.info("reconnected, refetching all data");
|
||||
try {
|
||||
// Dual-write: invalidate TanStack Query caches
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) {
|
||||
getQueryClient().invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
}
|
||||
await Promise.all([
|
||||
useIssueStore.getState().fetch(),
|
||||
useInboxStore.getState().fetch(),
|
||||
useWorkspaceStore.getState().refreshAgents(),
|
||||
useWorkspaceStore.getState().refreshMembers(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { create } from "zustand";
|
||||
import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -88,7 +87,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
return [] as Agent[];
|
||||
}),
|
||||
api.listSkills().catch(() => [] as Skill[]),
|
||||
useIssueStore.getState().fetch().catch(() => {}),
|
||||
useInboxStore.getState().fetch().catch(() => {}),
|
||||
]);
|
||||
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
|
|
@ -110,8 +108,8 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
api.setWorkspaceId(ws.id);
|
||||
localStorage.setItem("multica_workspace_id", ws.id);
|
||||
|
||||
// Clear ALL stale data across every store before hydrating.
|
||||
useIssueStore.getState().setIssues([]);
|
||||
// Clear stale data across stores before hydrating.
|
||||
// Issue cache is managed by TanStack Query (keyed by wsId, auto-refetches).
|
||||
useInboxStore.getState().setItems([]);
|
||||
useRuntimeStore.getState().setRuntimes([]);
|
||||
set({ workspace: ws, members: [], agents: [], skills: [] });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue