feat(core/issues): add TanStack Query layer and rewrite hooks (Phase 1, Commits 1-4)
- Add getQueryClient() singleton for non-React contexts (WS handlers, Zustand) - Create issue query key factory + 5 queryOptions - Create 11 mutation hooks with optimistic updates and rollback - Create WS cache updaters + dual-write in use-realtime-sync - Rewrite useIssueTimeline, useIssueReactions, useIssueSubscribers to TQ (return types unchanged, consumers unaffected) - Add QueryClientProvider wrapper to issue detail tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2be9f6cd2f
commit
7560f7be85
12 changed files with 894 additions and 318 deletions
|
|
@ -2,6 +2,7 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, Comment, TimelineEntry } from "@/shared/types";
|
||||
|
||||
// Mock next/navigation
|
||||
|
|
@ -235,14 +236,26 @@ const mockTimeline: TimelineEntry[] = [
|
|||
|
||||
import IssueDetailPage from "./page";
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// React 19 use(Promise) needs the promise to resolve within act + Suspense
|
||||
async function renderPage(id = "issue-1") {
|
||||
const queryClient = createTestQueryClient();
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return result!;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export { createQueryClient } from "./query-client";
|
||||
export { createQueryClient, getQueryClient, setQueryClient } from "./query-client";
|
||||
export { QueryProvider } from "./provider";
|
||||
export { useWorkspaceId } from "./hooks";
|
||||
|
|
|
|||
28
apps/web/core/issues/index.ts
Normal file
28
apps/web/core/issues/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export {
|
||||
issueKeys,
|
||||
issueListOptions,
|
||||
issueDetailOptions,
|
||||
issueTimelineOptions,
|
||||
issueReactionsOptions,
|
||||
issueSubscribersOptions,
|
||||
} from "./queries";
|
||||
|
||||
export {
|
||||
useCreateIssue,
|
||||
useUpdateIssue,
|
||||
useDeleteIssue,
|
||||
useBatchUpdateIssues,
|
||||
useBatchDeleteIssues,
|
||||
useCreateComment,
|
||||
useUpdateComment,
|
||||
useDeleteComment,
|
||||
useToggleCommentReaction,
|
||||
useToggleIssueReaction,
|
||||
useToggleIssueSubscriber,
|
||||
} from "./mutations";
|
||||
|
||||
export {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
onIssueDeleted,
|
||||
} from "./ws-updaters";
|
||||
471
apps/web/core/issues/mutations.ts
Normal file
471
apps/web/core/issues/mutations.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
import { issueKeys } from "./queries";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import type { Issue, IssueReaction } from "@/shared/types";
|
||||
import type {
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
} from "@/shared/types";
|
||||
import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useCreateIssue() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
|
||||
onSuccess: (newIssue) => {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? { ...old, issues: [...old.issues, newIssue], total: old.total + 1 }
|
||||
: old,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateIssue() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
|
||||
api.updateIssue(id, data),
|
||||
onMutate: async ({ id, ...data }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === id ? { ...i, ...data } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
|
||||
old ? { ...old, ...data } : old,
|
||||
);
|
||||
return { prevList, prevDetail, id };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevDetail)
|
||||
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteIssue() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteIssue(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== id),
|
||||
total: old.total - 1,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBatchUpdateIssues() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
ids,
|
||||
updates,
|
||||
}: {
|
||||
ids: string[];
|
||||
updates: UpdateIssueRequest;
|
||||
}) => api.batchUpdateIssues(ids, updates),
|
||||
onMutate: async ({ ids, updates }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
ids.includes(i.id) ? { ...i, ...updates } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBatchDeleteIssues() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => !ids.includes(i.id)),
|
||||
total: old.total - ids.length,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _ids, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comments / Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useCreateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
content,
|
||||
type,
|
||||
parentId,
|
||||
attachmentIds,
|
||||
}: {
|
||||
content: string;
|
||||
type?: string;
|
||||
parentId?: string;
|
||||
attachmentIds?: string[];
|
||||
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
|
||||
onSuccess: (comment) => {
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const entry: TimelineEntry = {
|
||||
type: "comment",
|
||||
id: comment.id,
|
||||
actor_type: comment.author_type,
|
||||
actor_id: comment.author_id,
|
||||
content: comment.content,
|
||||
parent_id: comment.parent_id,
|
||||
comment_type: comment.type,
|
||||
reactions: comment.reactions ?? [],
|
||||
attachments: comment.attachments ?? [],
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
};
|
||||
if (old.some((e) => e.id === comment.id)) return old;
|
||||
return [...old, entry];
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
|
||||
api.updateComment(commentId, content),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (commentId: string) => api.deleteComment(commentId),
|
||||
onMutate: async (commentId) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||
|
||||
// Cascade: collect all child comment IDs
|
||||
const toRemove = new Set<string>([commentId]);
|
||||
if (prev) {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && toRemove.has(e.parent_id) && !toRemove.has(e.id)) {
|
||||
toRemove.add(e.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => old?.filter((e) => !toRemove.has(e.id)),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleCommentReaction(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
commentId,
|
||||
emoji,
|
||||
existing,
|
||||
}: {
|
||||
commentId: string;
|
||||
emoji: string;
|
||||
existing: Reaction | undefined;
|
||||
}) => {
|
||||
if (existing) {
|
||||
await api.removeReaction(commentId, emoji);
|
||||
return null;
|
||||
}
|
||||
return api.addReaction(commentId, emoji);
|
||||
},
|
||||
onMutate: async ({ commentId, emoji, existing }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||
|
||||
if (existing) {
|
||||
// Remove
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) =>
|
||||
e.id === commentId
|
||||
? {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).filter(
|
||||
(r) => r.id !== existing.id,
|
||||
),
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Add temp
|
||||
const tempReaction: Reaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
comment_id: commentId,
|
||||
actor_type: "",
|
||||
actor_id: "",
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) =>
|
||||
e.id === commentId
|
||||
? { ...e, reactions: [...(e.reactions ?? []), tempReaction] }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onSuccess: (reaction, { commentId }) => {
|
||||
if (reaction) {
|
||||
// Replace temp with real
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) =>
|
||||
e.id === commentId
|
||||
? {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).map((r) =>
|
||||
r.id.startsWith("temp-") && r.emoji === reaction.emoji
|
||||
? reaction
|
||||
: r,
|
||||
),
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue-level Reactions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useToggleIssueReaction(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
emoji,
|
||||
existing,
|
||||
}: {
|
||||
emoji: string;
|
||||
existing: IssueReaction | undefined;
|
||||
}) => {
|
||||
if (existing) {
|
||||
await api.removeIssueReaction(issueId, emoji);
|
||||
return null;
|
||||
}
|
||||
return api.addIssueReaction(issueId, emoji);
|
||||
},
|
||||
onMutate: async ({ emoji, existing }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
const prev = qc.getQueryData<IssueReaction[]>(issueKeys.reactions(issueId));
|
||||
|
||||
if (existing) {
|
||||
qc.setQueryData<IssueReaction[]>(
|
||||
issueKeys.reactions(issueId),
|
||||
(old) => old?.filter((r) => r.id !== existing.id),
|
||||
);
|
||||
} else {
|
||||
const temp: IssueReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
issue_id: issueId,
|
||||
actor_type: "",
|
||||
actor_id: "",
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<IssueReaction[]>(
|
||||
issueKeys.reactions(issueId),
|
||||
(old) => [...(old ?? []), temp],
|
||||
);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onSuccess: (reaction) => {
|
||||
if (reaction) {
|
||||
qc.setQueryData<IssueReaction[]>(
|
||||
issueKeys.reactions(issueId),
|
||||
(old) =>
|
||||
old?.map((r) =>
|
||||
r.id.startsWith("temp-") && r.emoji === reaction.emoji
|
||||
? reaction
|
||||
: r,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData(issueKeys.reactions(issueId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue Subscribers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useToggleIssueSubscriber(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
userType,
|
||||
subscribed,
|
||||
}: {
|
||||
userId: string;
|
||||
userType: "member" | "agent";
|
||||
subscribed: boolean;
|
||||
}) => {
|
||||
if (subscribed) {
|
||||
await api.unsubscribeFromIssue(issueId, userId, userType);
|
||||
} else {
|
||||
await api.subscribeToIssue(issueId, userId, userType);
|
||||
}
|
||||
},
|
||||
onMutate: async ({ userId, userType, subscribed }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
const prev = qc.getQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
);
|
||||
|
||||
if (subscribed) {
|
||||
qc.setQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
(old) =>
|
||||
old?.filter(
|
||||
(s) => !(s.user_id === userId && s.user_type === userType),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const temp: IssueSubscriber = {
|
||||
issue_id: issueId,
|
||||
user_type: userType,
|
||||
user_id: userId,
|
||||
reason: "manual",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
(old) => {
|
||||
if (
|
||||
old?.some(
|
||||
(s) => s.user_id === userId && s.user_type === userType,
|
||||
)
|
||||
)
|
||||
return old;
|
||||
return [...(old ?? []), temp];
|
||||
},
|
||||
);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData(issueKeys.subscribers(issueId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
52
apps/web/core/issues/queries.ts
Normal file
52
apps/web/core/issues/queries.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
export const issueKeys = {
|
||||
all: (wsId: string) => ["issues", wsId] as const,
|
||||
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
||||
detail: (wsId: string, id: string) =>
|
||||
[...issueKeys.all(wsId), "detail", id] as const,
|
||||
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
};
|
||||
|
||||
export function issueListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.list(wsId),
|
||||
queryFn: () => api.listIssues({ limit: 200 }),
|
||||
select: (data) => data.issues,
|
||||
});
|
||||
}
|
||||
|
||||
export function issueDetailOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.detail(wsId, id),
|
||||
queryFn: () => api.getIssue(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function issueTimelineOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.timeline(issueId),
|
||||
queryFn: () => api.listTimeline(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
export function issueReactionsOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.reactions(issueId),
|
||||
queryFn: async () => {
|
||||
const issue = await api.getIssue(issueId);
|
||||
return issue.reactions ?? [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function issueSubscribersOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.subscribers(issueId),
|
||||
queryFn: () => api.listIssueSubscribers(issueId),
|
||||
});
|
||||
}
|
||||
56
apps/web/core/issues/ws-updaters.ts
Normal file
56
apps/web/core/issues/ws-updaters.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { issueKeys } from "./queries";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import type { ListIssuesResponse } from "@/shared/types";
|
||||
|
||||
export function onIssueCreated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Issue,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old && !old.issues.some((i) => i.id === issue.id)
|
||||
? { ...old, issues: [...old.issues, issue], total: old.total + 1 }
|
||||
: old,
|
||||
);
|
||||
}
|
||||
|
||||
export function onIssueUpdated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
) {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === issue.id ? { ...i, ...issue } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
}
|
||||
|
||||
export function onIssueDeleted(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== issueId),
|
||||
total: old.total - 1,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
}
|
||||
|
|
@ -3,11 +3,15 @@
|
|||
import { useState } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { createQueryClient } from "./query-client";
|
||||
import { createQueryClient, setQueryClient } from "./query-client";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(createQueryClient);
|
||||
const [queryClient] = useState(() => {
|
||||
const client = createQueryClient();
|
||||
setQueryClient(client);
|
||||
return client;
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
let _queryClient: QueryClient | null = null;
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -16,3 +18,14 @@ export function createQueryClient(): QueryClient {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Called by QueryProvider on mount to register the singleton. */
|
||||
export function setQueryClient(client: QueryClient) {
|
||||
_queryClient = client;
|
||||
}
|
||||
|
||||
/** Access QueryClient outside React tree (WS handlers, Zustand actions). */
|
||||
export function getQueryClient(): QueryClient {
|
||||
if (!_queryClient) throw new Error("QueryClient not initialized");
|
||||
return _queryClient;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { IssueReaction } from "@/shared/types";
|
||||
import type {
|
||||
IssueReactionAddedPayload,
|
||||
IssueReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { issueReactionsOptions, issueKeys } from "@core/issues/queries";
|
||||
import { useToggleIssueReaction } from "@core/issues/mutations";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueReactions(issueId: string, userId?: string) {
|
||||
const [reactions, setReactions] = useState<IssueReaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const qc = useQueryClient();
|
||||
const { data: reactions = [], isLoading: loading } = useQuery(
|
||||
issueReactionsOptions(issueId),
|
||||
);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
setReactions([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.getIssue(issueId)
|
||||
.then((iss) => setReactions(iss.reactions ?? []))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load reactions");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
const toggleMutation = useToggleIssueReaction(issueId);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error);
|
||||
}, [issueId]),
|
||||
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
}, [qc, issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
|
@ -43,13 +34,18 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
|||
(payload: unknown) => {
|
||||
const { reaction, issue_id } = payload as IssueReactionAddedPayload;
|
||||
if (issue_id !== issueId) return;
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
||||
setReactions((prev) => {
|
||||
if (prev.some((r) => r.id === reaction.id)) return prev;
|
||||
return [...prev, reaction];
|
||||
});
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId)
|
||||
return;
|
||||
qc.setQueryData<IssueReaction[]>(
|
||||
issueKeys.reactions(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((r) => r.id === reaction.id)) return old;
|
||||
return [...old, reaction];
|
||||
},
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
[qc, issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -60,13 +56,20 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
|||
const p = payload as IssueReactionRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||
setReactions((prev) =>
|
||||
prev.filter(
|
||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
||||
),
|
||||
qc.setQueryData<IssueReaction[]>(
|
||||
issueKeys.reactions(issueId),
|
||||
(old) =>
|
||||
old?.filter(
|
||||
(r) =>
|
||||
!(
|
||||
r.emoji === p.emoji &&
|
||||
r.actor_type === p.actor_type &&
|
||||
r.actor_id === p.actor_id
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
[qc, issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -76,36 +79,14 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
|||
async (emoji: string) => {
|
||||
if (!userId) return;
|
||||
const existing = reactions.find(
|
||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
||||
(r) =>
|
||||
r.emoji === emoji &&
|
||||
r.actor_type === "member" &&
|
||||
r.actor_id === userId,
|
||||
);
|
||||
if (existing) {
|
||||
setReactions((prev) => prev.filter((r) => r.id !== existing.id));
|
||||
try {
|
||||
await api.removeIssueReaction(issueId, emoji);
|
||||
} catch {
|
||||
setReactions((prev) => [...prev, existing]);
|
||||
toast.error("Failed to remove reaction");
|
||||
}
|
||||
} else {
|
||||
const temp: IssueReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
issue_id: issueId,
|
||||
actor_type: "member",
|
||||
actor_id: userId,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setReactions((prev) => [...prev, temp]);
|
||||
try {
|
||||
const reaction = await api.addIssueReaction(issueId, emoji);
|
||||
setReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r)));
|
||||
} catch {
|
||||
setReactions((prev) => prev.filter((r) => r.id !== temp.id));
|
||||
toast.error("Failed to add reaction");
|
||||
}
|
||||
}
|
||||
toggleMutation.mutate({ emoji, existing });
|
||||
},
|
||||
[issueId, userId, reactions],
|
||||
[userId, reactions, toggleMutation],
|
||||
);
|
||||
|
||||
return { reactions, loading, toggleReaction };
|
||||
|
|
|
|||
|
|
@ -1,38 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { IssueSubscriber } from "@/shared/types";
|
||||
import type {
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { issueSubscribersOptions, issueKeys } from "@core/issues/queries";
|
||||
import { useToggleIssueSubscriber } from "@core/issues/mutations";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const qc = useQueryClient();
|
||||
const { data: subscribers = [], isLoading: loading } = useQuery(
|
||||
issueSubscribersOptions(issueId),
|
||||
);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
setSubscribers([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.listIssueSubscribers(issueId)
|
||||
.then((subs) => setSubscribers(subs))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load subscribers");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
const toggleMutation = useToggleIssueSubscriber(issueId);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error);
|
||||
}, [issueId]),
|
||||
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
}, [qc, issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
|
@ -43,21 +34,31 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
|||
(payload: unknown) => {
|
||||
const p = payload as SubscriberAddedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setSubscribers((prev) => {
|
||||
if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
issue_id: p.issue_id,
|
||||
user_type: p.user_type as "member" | "agent",
|
||||
user_id: p.user_id,
|
||||
reason: p.reason as IssueSubscriber["reason"],
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
qc.setQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (
|
||||
old.some(
|
||||
(s) =>
|
||||
s.user_id === p.user_id && s.user_type === p.user_type,
|
||||
)
|
||||
)
|
||||
return old;
|
||||
return [
|
||||
...old,
|
||||
{
|
||||
issue_id: p.issue_id,
|
||||
user_type: p.user_type as "member" | "agent",
|
||||
user_id: p.user_id,
|
||||
reason: p.reason as IssueSubscriber["reason"],
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
},
|
||||
[issueId],
|
||||
[qc, issueId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -67,11 +68,16 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
|||
(payload: unknown) => {
|
||||
const p = payload as SubscriberRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)),
|
||||
qc.setQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
(old) =>
|
||||
old?.filter(
|
||||
(s) =>
|
||||
!(s.user_id === p.user_id && s.user_type === p.user_type),
|
||||
),
|
||||
);
|
||||
},
|
||||
[issueId],
|
||||
[qc, issueId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -82,50 +88,29 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
|||
);
|
||||
|
||||
const toggleSubscriber = useCallback(
|
||||
async (subUserId: string, userType: "member" | "agent", currentlySubscribed: boolean) => {
|
||||
if (currentlySubscribed) {
|
||||
// Optimistic remove + rollback on error
|
||||
const removed = subscribers.find(
|
||||
(s) => s.user_id === subUserId && s.user_type === userType,
|
||||
);
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType)),
|
||||
);
|
||||
try {
|
||||
await api.unsubscribeFromIssue(issueId, subUserId, userType);
|
||||
} catch {
|
||||
if (removed) setSubscribers((prev) => [...prev, removed]);
|
||||
toast.error("Failed to update subscriber");
|
||||
}
|
||||
} else {
|
||||
// Optimistic add
|
||||
const tempSub: IssueSubscriber = {
|
||||
issue_id: issueId,
|
||||
user_type: userType,
|
||||
user_id: subUserId,
|
||||
reason: "manual" as const,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setSubscribers((prev) => {
|
||||
if (prev.some((s) => s.user_id === subUserId && s.user_type === userType)) return prev;
|
||||
return [...prev, tempSub];
|
||||
});
|
||||
try {
|
||||
await api.subscribeToIssue(issueId, subUserId, userType);
|
||||
} catch {
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType && s.reason === "manual")),
|
||||
);
|
||||
toast.error("Failed to update subscriber");
|
||||
}
|
||||
}
|
||||
async (
|
||||
subUserId: string,
|
||||
userType: "member" | "agent",
|
||||
currentlySubscribed: boolean,
|
||||
) => {
|
||||
toggleMutation.mutate({
|
||||
userId: subUserId,
|
||||
userType,
|
||||
subscribed: currentlySubscribed,
|
||||
});
|
||||
},
|
||||
[issueId, subscribers],
|
||||
[toggleMutation],
|
||||
);
|
||||
|
||||
const toggleSubscribe = useCallback(() => {
|
||||
if (userId) toggleSubscriber(userId, "member", isSubscribed);
|
||||
}, [userId, isSubscribed, toggleSubscriber]);
|
||||
|
||||
return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber };
|
||||
return {
|
||||
subscribers,
|
||||
loading,
|
||||
isSubscribed,
|
||||
toggleSubscribe,
|
||||
toggleSubscriber,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Comment, TimelineEntry } from "@/shared/types";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Comment, TimelineEntry, Reaction } from "@/shared/types";
|
||||
import type {
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
|
|
@ -10,7 +11,13 @@ import type {
|
|||
ReactionAddedPayload,
|
||||
ReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { issueTimelineOptions, issueKeys } from "@core/issues/queries";
|
||||
import {
|
||||
useCreateComment,
|
||||
useUpdateComment,
|
||||
useDeleteComment,
|
||||
useToggleCommentReaction,
|
||||
} from "@core/issues/mutations";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -30,29 +37,22 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
|
|||
}
|
||||
|
||||
export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
||||
const qc = useQueryClient();
|
||||
const { data: timeline = [], isLoading: loading } = useQuery(
|
||||
issueTimelineOptions(issueId),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Initial fetch + reset on id change
|
||||
useEffect(() => {
|
||||
setTimeline([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.listTimeline(issueId)
|
||||
.then((entries) => setTimeline(entries))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load activity");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
const createCommentMutation = useCreateComment(issueId);
|
||||
const updateCommentMutation = useUpdateComment(issueId);
|
||||
const deleteCommentMutation = useDeleteComment(issueId);
|
||||
const toggleReactionMutation = useToggleCommentReaction(issueId);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.listTimeline(issueId).then(setTimeline).catch(console.error);
|
||||
}, [issueId]),
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
}, [qc, issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
|
@ -63,13 +63,21 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
|||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentCreatedPayload;
|
||||
if (comment.issue_id !== issueId) return;
|
||||
if (comment.author_type === "member" && comment.author_id === userId) return;
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
if (
|
||||
comment.author_type === "member" &&
|
||||
comment.author_id === userId
|
||||
)
|
||||
return;
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((e) => e.id === comment.id)) return old;
|
||||
return [...old, commentToTimelineEntry(comment)];
|
||||
},
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
[qc, issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -79,12 +87,16 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
|||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentUpdatedPayload;
|
||||
if (comment.issue_id === issueId) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)),
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) =>
|
||||
e.id === comment.id ? commentToTimelineEntry(comment) : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[issueId],
|
||||
[qc, issueId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -94,23 +106,31 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
|||
(payload: unknown) => {
|
||||
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
||||
if (issue_id === issueId) {
|
||||
setTimeline((prev) => {
|
||||
const idsToRemove = new Set<string>([comment_id]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const idsToRemove = new Set<string>([comment_id]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of old) {
|
||||
if (
|
||||
e.parent_id &&
|
||||
idsToRemove.has(e.parent_id) &&
|
||||
!idsToRemove.has(e.id)
|
||||
) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
||||
});
|
||||
return old.filter((e) => !idsToRemove.has(e.id));
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[issueId],
|
||||
[qc, issueId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -122,12 +142,16 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
|||
if (p.issue_id !== issueId) return;
|
||||
const entry = p.entry;
|
||||
if (!entry || !entry.id) return;
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === entry.id)) return prev;
|
||||
return [...prev, entry];
|
||||
});
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((e) => e.id === entry.id)) return old;
|
||||
return [...old, entry];
|
||||
},
|
||||
);
|
||||
},
|
||||
[issueId],
|
||||
[qc, issueId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -137,17 +161,23 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
|||
(payload: unknown) => {
|
||||
const { reaction, issue_id } = payload as ReactionAddedPayload;
|
||||
if (issue_id !== issueId) return;
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== reaction.comment_id) return e;
|
||||
const existing = e.reactions ?? [];
|
||||
if (existing.some((r) => r.id === reaction.id)) return e;
|
||||
return { ...e, reactions: [...existing, reaction] };
|
||||
}),
|
||||
if (
|
||||
reaction.actor_type === "member" &&
|
||||
reaction.actor_id === userId
|
||||
)
|
||||
return;
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) => {
|
||||
if (e.id !== reaction.comment_id) return e;
|
||||
const existing = e.reactions ?? [];
|
||||
if (existing.some((r) => r.id === reaction.id)) return e;
|
||||
return { ...e, reactions: [...existing, reaction] };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
[qc, issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -158,19 +188,26 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
|||
const p = payload as ReactionRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== p.comment_id) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).filter(
|
||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
||||
),
|
||||
};
|
||||
}),
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) => {
|
||||
if (e.id !== p.comment_id) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).filter(
|
||||
(r) =>
|
||||
!(
|
||||
r.emoji === p.emoji &&
|
||||
r.actor_type === p.actor_type &&
|
||||
r.actor_id === p.actor_id
|
||||
),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
[qc, issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -181,10 +218,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
|||
if (!content.trim() || submitting || !userId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
await createCommentMutation.mutateAsync({
|
||||
content,
|
||||
attachmentIds,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to send comment");
|
||||
|
|
@ -192,147 +228,61 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
|||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[issueId, userId],
|
||||
[userId, submitting, createCommentMutation],
|
||||
);
|
||||
|
||||
const submitReply = useCallback(
|
||||
async (parentId: string, content: string, attachmentIds?: string[]) => {
|
||||
if (!content.trim() || !userId) return;
|
||||
try {
|
||||
const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
await createCommentMutation.mutateAsync({
|
||||
content,
|
||||
type: "comment",
|
||||
parentId,
|
||||
attachmentIds,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to send reply");
|
||||
}
|
||||
},
|
||||
[issueId, userId],
|
||||
[userId, createCommentMutation],
|
||||
);
|
||||
|
||||
const editComment = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
// Optimistic: update content immediately
|
||||
let prevContent: string | undefined;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
prevContent = e.content;
|
||||
return { ...e, content, updated_at: new Date().toISOString() };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const updated = await api.updateComment(commentId, content);
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)),
|
||||
);
|
||||
await updateCommentMutation.mutateAsync({ commentId, content });
|
||||
} catch {
|
||||
// Rollback
|
||||
if (prevContent !== undefined) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)),
|
||||
);
|
||||
}
|
||||
toast.error("Failed to update comment");
|
||||
}
|
||||
},
|
||||
[],
|
||||
[updateCommentMutation],
|
||||
);
|
||||
|
||||
const deleteComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
// Capture entries for rollback
|
||||
let removedEntries: TimelineEntry[] = [];
|
||||
setTimeline((prev) => {
|
||||
const idsToRemove = new Set<string>([commentId]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
removedEntries = prev.filter((e) => idsToRemove.has(e.id));
|
||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
||||
});
|
||||
try {
|
||||
await api.deleteComment(commentId);
|
||||
await deleteCommentMutation.mutateAsync(commentId);
|
||||
} catch {
|
||||
// Rollback: re-add removed entries
|
||||
setTimeline((prev) => [...prev, ...removedEntries]);
|
||||
toast.error("Failed to delete comment");
|
||||
}
|
||||
},
|
||||
[],
|
||||
[deleteCommentMutation],
|
||||
);
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (commentId: string, emoji: string) => {
|
||||
if (!userId) return;
|
||||
const entry = timeline.find((e) => e.id === commentId);
|
||||
const existing = (entry?.reactions ?? []).find(
|
||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
||||
const existing: Reaction | undefined = (entry?.reactions ?? []).find(
|
||||
(r) =>
|
||||
r.emoji === emoji &&
|
||||
r.actor_type === "member" &&
|
||||
r.actor_id === userId,
|
||||
);
|
||||
if (existing) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await api.removeReaction(commentId, emoji);
|
||||
} catch {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: [...(e.reactions ?? []), existing] };
|
||||
}),
|
||||
);
|
||||
toast.error("Failed to remove reaction");
|
||||
}
|
||||
} else {
|
||||
const tempReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
comment_id: commentId,
|
||||
actor_type: "member",
|
||||
actor_id: userId,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: [...(e.reactions ?? []), tempReaction] };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const reaction = await api.addReaction(commentId, emoji);
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) };
|
||||
}),
|
||||
);
|
||||
toast.error("Failed to add reaction");
|
||||
}
|
||||
}
|
||||
toggleReactionMutation.mutate({ commentId, emoji, existing });
|
||||
},
|
||||
[userId, timeline],
|
||||
[userId, timeline, toggleReactionMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ import { useWorkspaceStore } from "@/features/workspace";
|
|||
import { useAuthStore } from "@/features/auth";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { api } from "@/shared/api";
|
||||
import { getQueryClient } from "@core/query-client";
|
||||
import { issueKeys } from "@core/issues/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
onIssueDeleted,
|
||||
} from "@core/issues/ws-updaters";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
|
|
@ -96,16 +103,27 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
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);
|
||||
});
|
||||
|
||||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||
const { issue } = p as IssueCreatedPayload;
|
||||
if (issue) useIssueStore.getState().addIssue(issue);
|
||||
if (!issue) return;
|
||||
useIssueStore.getState().addIssue(issue);
|
||||
// Dual-write: TanStack Query cache
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueCreated(getQueryClient(), wsId, issue);
|
||||
});
|
||||
|
||||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
|
||||
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);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
|
|
@ -167,6 +185,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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue