205 lines
7.2 KiB
TypeScript
205 lines
7.2 KiB
TypeScript
/**
|
|
* Project mutations. Mirrors the optimistic-patch + event-always-wins pattern
|
|
* of `useUpdateIssue` (data/mutations/issues.ts:276): apply the patch to
|
|
* both list and detail caches up-front, server response or WS event later
|
|
* overwrites with authoritative state.
|
|
*
|
|
* Cache shapes touched:
|
|
* - projectKeys.list(wsId) → `Project[]` (patch in place)
|
|
* - projectKeys.detail(wsId,id) → `Project` (replace fully)
|
|
* - projectKeys.resources(...) → `ProjectResource[]` (append / filter)
|
|
*
|
|
* No realtime-driven `project:*` updaters exist on web yet (see
|
|
* apps/mobile/CLAUDE.md realtime section) so mobile mirrors the design
|
|
* — mobile-owned ws-updaters live in `data/realtime/project-ws-updaters.ts`
|
|
* and are invoked by `use-projects-realtime.ts` + `use-project-realtime.ts`.
|
|
*/
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import type {
|
|
CreateProjectRequest,
|
|
CreateProjectResourceRequest,
|
|
Project,
|
|
ProjectResource,
|
|
UpdateProjectRequest,
|
|
} from "@multica/core/types";
|
|
import { api } from "@/data/api";
|
|
import { projectKeys } from "@/data/queries/projects";
|
|
import { useWorkspaceStore } from "@/data/workspace-store";
|
|
|
|
export function useCreateProject() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
|
|
|
|
return useMutation({
|
|
mutationFn: (body: CreateProjectRequest) => api.createProject(body),
|
|
onSuccess: (project) => {
|
|
// Seed the detail cache so the post-create navigation lands on a
|
|
// populated page (no spinner flash). The list cache gets a prepend
|
|
// — list ordering is server-driven, so a brief out-of-order render
|
|
// is acceptable and corrected by the WS `project:created` event
|
|
// (or the next refetch).
|
|
qc.setQueryData<Project>(projectKeys.detail(wsId, project.id), project);
|
|
qc.setQueryData<Project[]>(projectKeys.list(wsId), (old) =>
|
|
old ? [project, ...old.filter((p) => p.id !== project.id)] : [project],
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateProject(projectId: string) {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
|
|
|
|
return useMutation({
|
|
mutationKey: ["updateProject", projectId] as const,
|
|
mutationFn: (patch: UpdateProjectRequest) =>
|
|
api.updateProject(projectId, patch),
|
|
onMutate: async (patch) => {
|
|
const detailKey = projectKeys.detail(wsId, projectId);
|
|
const listKey = projectKeys.list(wsId);
|
|
// Cancel both — a concurrent list refetch can race-overwrite the
|
|
// optimistic patch otherwise (brief stale flash on screen).
|
|
await Promise.all([
|
|
qc.cancelQueries({ queryKey: detailKey }),
|
|
qc.cancelQueries({ queryKey: listKey }),
|
|
]);
|
|
|
|
const prevDetail = qc.getQueryData<Project>(detailKey);
|
|
const prevList = qc.getQueryData<Project[]>(listKey);
|
|
|
|
if (prevDetail) {
|
|
qc.setQueryData<Project>(detailKey, { ...prevDetail, ...patch });
|
|
}
|
|
qc.setQueryData<Project[]>(listKey, (old) =>
|
|
old
|
|
? old.map((p) => (p.id === projectId ? { ...p, ...patch } : p))
|
|
: old,
|
|
);
|
|
|
|
return { prevDetail, prevList, detailKey, listKey };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (!ctx) return;
|
|
if (ctx.prevDetail !== undefined) {
|
|
qc.setQueryData(ctx.detailKey, ctx.prevDetail);
|
|
}
|
|
if (ctx.prevList !== undefined) {
|
|
qc.setQueryData(ctx.listKey, ctx.prevList);
|
|
}
|
|
},
|
|
onSuccess: (server) => {
|
|
// Server response is authoritative — replace the optimistic merge
|
|
// so any server-side normalisation (e.g. trimmed title) wins.
|
|
qc.setQueryData<Project>(projectKeys.detail(wsId, projectId), server);
|
|
qc.setQueryData<Project[]>(projectKeys.list(wsId), (old) =>
|
|
old
|
|
? old.map((p) => (p.id === projectId ? server : p))
|
|
: old,
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteProject(projectId: string) {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
|
|
|
|
return useMutation({
|
|
mutationKey: ["deleteProject", projectId] as const,
|
|
mutationFn: () => api.deleteProject(projectId),
|
|
onMutate: async () => {
|
|
const listKey = projectKeys.list(wsId);
|
|
await qc.cancelQueries({ queryKey: listKey });
|
|
const prevList = qc.getQueryData<Project[]>(listKey);
|
|
qc.setQueryData<Project[]>(listKey, (old) =>
|
|
old ? old.filter((p) => p.id !== projectId) : old,
|
|
);
|
|
return { prevList, listKey };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (ctx?.prevList !== undefined) {
|
|
qc.setQueryData(ctx.listKey, ctx.prevList);
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
qc.removeQueries({ queryKey: projectKeys.detail(wsId, projectId) });
|
|
qc.removeQueries({ queryKey: projectKeys.resources(wsId, projectId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCreateProjectResource(projectId: string) {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
|
|
|
|
return useMutation({
|
|
mutationKey: ["createProjectResource", projectId] as const,
|
|
mutationFn: (body: CreateProjectResourceRequest) =>
|
|
api.createProjectResource(projectId, body),
|
|
onSuccess: (resource) => {
|
|
qc.setQueryData<ProjectResource[]>(
|
|
projectKeys.resources(wsId, projectId),
|
|
(old) =>
|
|
old
|
|
? [...old.filter((r) => r.id !== resource.id), resource]
|
|
: [resource],
|
|
);
|
|
// Bump the parent's resource_count so the chip on detail/list
|
|
// increments without a refetch.
|
|
const bumpCount = (p: Project): Project => ({
|
|
...p,
|
|
resource_count: p.resource_count + 1,
|
|
});
|
|
qc.setQueryData<Project>(
|
|
projectKeys.detail(wsId, projectId),
|
|
(old) => (old ? bumpCount(old) : old),
|
|
);
|
|
qc.setQueryData<Project[]>(projectKeys.list(wsId), (old) =>
|
|
old
|
|
? old.map((p) => (p.id === projectId ? bumpCount(p) : p))
|
|
: old,
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteProjectResource(projectId: string) {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
|
|
|
|
return useMutation({
|
|
mutationKey: ["deleteProjectResource", projectId] as const,
|
|
mutationFn: (resourceId: string) =>
|
|
api.deleteProjectResource(projectId, resourceId).then(() => resourceId),
|
|
onMutate: async (resourceId) => {
|
|
const key = projectKeys.resources(wsId, projectId);
|
|
await qc.cancelQueries({ queryKey: key });
|
|
const prev = qc.getQueryData<ProjectResource[]>(key);
|
|
qc.setQueryData<ProjectResource[]>(key, (old) =>
|
|
old ? old.filter((r) => r.id !== resourceId) : old,
|
|
);
|
|
return { prev, key };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (ctx?.prev !== undefined) {
|
|
qc.setQueryData(ctx.key, ctx.prev);
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
const dropCount = (p: Project): Project => ({
|
|
...p,
|
|
resource_count: Math.max(0, p.resource_count - 1),
|
|
});
|
|
qc.setQueryData<Project>(
|
|
projectKeys.detail(wsId, projectId),
|
|
(old) => (old ? dropCount(old) : old),
|
|
);
|
|
qc.setQueryData<Project[]>(projectKeys.list(wsId), (old) =>
|
|
old
|
|
? old.map((p) => (p.id === projectId ? dropCount(p) : p))
|
|
: old,
|
|
);
|
|
},
|
|
});
|
|
}
|