fix(#338): Route all state-changing fetch() calls through API client

- Replace raw fetch() with apiPost/apiPatch/apiDelete in:
  - ImportExportActions.tsx: POST for file imports
  - KanbanBoard.tsx: PATCH for task status updates
  - ActiveProjectsWidget.tsx: POST for widget data fetches
  - useLayouts.ts: POST/PATCH/DELETE for layout management
- Add apiPostFormData() method to API client for FormData uploads
- Ensures CSRF token is included in all state-changing requests
- Update tests to mock CSRF token fetch for API client usage

Refs #338

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-05 17:06:23 -06:00
parent 5ae07f7a84
commit 344e5df3bb
7 changed files with 198 additions and 119 deletions

View File

@@ -5,6 +5,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query";
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client";
const LAYOUTS_KEY = ["layouts"];
@@ -30,11 +31,7 @@ export function useLayouts(): UseQueryResult<UserLayout[]> {
return useQuery<UserLayout[]>({
queryKey: LAYOUTS_KEY,
queryFn: async (): Promise<UserLayout[]> => {
const response = await fetch("/api/layouts");
if (!response.ok) {
throw new Error("Failed to fetch layouts");
}
return response.json() as Promise<UserLayout[]>;
return apiGet<UserLayout[]>("/api/layouts");
},
});
}
@@ -46,11 +43,7 @@ export function useLayout(id: string): UseQueryResult<UserLayout> {
return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, id],
queryFn: async (): Promise<UserLayout> => {
const response = await fetch(`/api/layouts/${id}`);
if (!response.ok) {
throw new Error("Failed to fetch layout");
}
return response.json() as Promise<UserLayout>;
return apiGet<UserLayout>(`/api/layouts/${id}`);
},
enabled: !!id,
});
@@ -63,36 +56,20 @@ export function useDefaultLayout(): UseQueryResult<UserLayout> {
return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, "default"],
queryFn: async (): Promise<UserLayout> => {
const response = await fetch("/api/layouts/default");
if (!response.ok) {
throw new Error("Failed to fetch default layout");
}
return response.json() as Promise<UserLayout>;
return apiGet<UserLayout>("/api/layouts/default");
},
});
}
/**
* Create a new layout
* Create a new layout (uses API client for CSRF protection)
*/
export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLayoutData> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateLayoutData): Promise<UserLayout> => {
const response = await fetch("/api/layouts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Failed to create layout");
}
return response.json() as Promise<UserLayout>;
return apiPost<UserLayout>("/api/layouts", data);
},
onSuccess: (): void => {
// Invalidate layouts cache to refetch
@@ -102,26 +79,14 @@ export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLa
}
/**
* Update an existing layout
* Update an existing layout (uses API client for CSRF protection)
*/
export function useUpdateLayout(): UseMutationResult<UserLayout, Error, UpdateLayoutData> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: UpdateLayoutData): Promise<UserLayout> => {
const response = await fetch(`/api/layouts/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Failed to update layout");
}
return response.json() as Promise<UserLayout>;
return apiPatch<UserLayout>(`/api/layouts/${id}`, data);
},
onSuccess: (_data, variables): void => {
// Invalidate affected queries
@@ -132,22 +97,14 @@ export function useUpdateLayout(): UseMutationResult<UserLayout, Error, UpdateLa
}
/**
* Delete a layout
* Delete a layout (uses API client for CSRF protection)
*/
export function useDeleteLayout(): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string): Promise<void> => {
const response = await fetch(`/api/layouts/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete layout");
}
await response.json();
await apiDelete(`/api/layouts/${id}`);
},
onSuccess: (): void => {
// Invalidate layouts cache to refetch