Files
stack/apps/web/src/hooks/useLayouts.ts
Jason Woltje 344e5df3bb 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>
2026-02-05 17:06:23 -06:00

141 lines
3.7 KiB
TypeScript

/**
* React Query hooks for layout management
*/
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"];
interface CreateLayoutData {
name: string;
layout: WidgetPlacement[];
isDefault?: boolean;
metadata?: Record<string, unknown>;
}
interface UpdateLayoutData {
id: string;
name?: string;
layout?: WidgetPlacement[];
isDefault?: boolean;
metadata?: Record<string, unknown>;
}
/**
* Fetch all layouts for the current user
*/
export function useLayouts(): UseQueryResult<UserLayout[]> {
return useQuery<UserLayout[]>({
queryKey: LAYOUTS_KEY,
queryFn: async (): Promise<UserLayout[]> => {
return apiGet<UserLayout[]>("/api/layouts");
},
});
}
/**
* Fetch a single layout by ID
*/
export function useLayout(id: string): UseQueryResult<UserLayout> {
return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, id],
queryFn: async (): Promise<UserLayout> => {
return apiGet<UserLayout>(`/api/layouts/${id}`);
},
enabled: !!id,
});
}
/**
* Fetch the default layout
*/
export function useDefaultLayout(): UseQueryResult<UserLayout> {
return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, "default"],
queryFn: async (): Promise<UserLayout> => {
return apiGet<UserLayout>("/api/layouts/default");
},
});
}
/**
* 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> => {
return apiPost<UserLayout>("/api/layouts", data);
},
onSuccess: (): void => {
// Invalidate layouts cache to refetch
void queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
},
});
}
/**
* 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> => {
return apiPatch<UserLayout>(`/api/layouts/${id}`, data);
},
onSuccess: (_data, variables): void => {
// Invalidate affected queries
void queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
void queryClient.invalidateQueries({ queryKey: [...LAYOUTS_KEY, variables.id] });
},
});
}
/**
* 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> => {
await apiDelete(`/api/layouts/${id}`);
},
onSuccess: (): void => {
// Invalidate layouts cache to refetch
void queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
},
});
}
interface UseSaveLayoutReturn {
saveLayout: (layout: WidgetPlacement[]) => void;
isSaving: boolean;
error: Error | null;
}
/**
* Helper hook to save layout changes with debouncing
*/
export function useSaveLayout(layoutId: string): UseSaveLayoutReturn {
const updateLayout = useUpdateLayout();
const saveLayout = (layout: WidgetPlacement[]): void => {
updateLayout.mutate({
id: layoutId,
layout,
});
};
return {
saveLayout,
isSaving: updateLayout.isPending,
error: updateLayout.error,
};
}