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:
@@ -8,6 +8,7 @@ import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
|||||||
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||||
import { KanbanColumn } from "./KanbanColumn";
|
import { KanbanColumn } from "./KanbanColumn";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
|
import { apiPatch } from "@/lib/api/client";
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
@@ -93,19 +94,9 @@ export function KanbanBoard({ tasks, onStatusChange }: KanbanBoardProps): React.
|
|||||||
const task = (tasks || []).find((t) => t.id === taskId);
|
const task = (tasks || []).find((t) => t.id === taskId);
|
||||||
|
|
||||||
if (task && task.status !== newStatus) {
|
if (task && task.status !== newStatus) {
|
||||||
// Call PATCH /api/tasks/:id to update status
|
// Call PATCH /api/tasks/:id to update status (using API client for CSRF protection)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
await apiPatch(`/api/tasks/${taskId}`, { status: newStatus });
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ status: newStatus }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to update task status: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally call the callback for parent component to refresh
|
// Optionally call the callback for parent component to refresh
|
||||||
if (onStatusChange) {
|
if (onStatusChange) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Upload, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
import { Upload, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
import { apiPostFormData } from "@/lib/api/client";
|
||||||
|
|
||||||
interface ImportResult {
|
interface ImportResult {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -63,17 +64,8 @@ export function ImportExportActions({
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
const response = await fetch("/api/knowledge/import", {
|
// Use API client to ensure CSRF token is included
|
||||||
method: "POST",
|
const result = await apiPostFormData<ImportResponse>("/api/knowledge/import", formData);
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = (await response.json()) as { message?: string };
|
|
||||||
throw new Error(error.message ?? "Import failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await response.json()) as ImportResponse;
|
|
||||||
setImportResult(result);
|
setImportResult(result);
|
||||||
|
|
||||||
// Notify parent component
|
// Notify parent component
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
|
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
import type { WidgetProps } from "@mosaic/shared";
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
import { apiPost } from "@/lib/api/client";
|
||||||
|
|
||||||
interface ActiveProject {
|
interface ActiveProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,14 +44,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProjects = async (): Promise<void> => {
|
const fetchProjects = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/widgets/data/active-projects", {
|
// Use API client to ensure CSRF token is included
|
||||||
method: "POST",
|
const data = await apiPost<ActiveProject[]>("/api/widgets/data/active-projects");
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as ActiveProject[];
|
|
||||||
setProjects(data);
|
setProjects(data);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch active projects:", error);
|
console.error("Failed to fetch active projects:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,14 +67,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAgentSessions = async (): Promise<void> => {
|
const fetchAgentSessions = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/widgets/data/agent-chains", {
|
// Use API client to ensure CSRF token is included
|
||||||
method: "POST",
|
const data = await apiPost<AgentSession[]>("/api/widgets/data/agent-chains");
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as AgentSession[];
|
|
||||||
setAgentSessions(data);
|
setAgentSessions(data);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch agent sessions:", error);
|
console.error("Failed to fetch agent sessions:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,26 +3,48 @@
|
|||||||
* Following TDD principles
|
* Following TDD principles
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { ActiveProjectsWidget } from "../ActiveProjectsWidget";
|
import { ActiveProjectsWidget } from "../ActiveProjectsWidget";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { clearCsrfToken } from "@/lib/api/client";
|
||||||
|
|
||||||
// Mock fetch for API calls
|
// Mock fetch for API calls
|
||||||
global.fetch = vi.fn() as typeof global.fetch;
|
global.fetch = vi.fn() as typeof global.fetch;
|
||||||
|
|
||||||
|
// Helper to create mock CSRF token response
|
||||||
|
const mockCsrfResponse = (): Response =>
|
||||||
|
({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ token: "test-csrf-token" }),
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
describe("ActiveProjectsWidget", (): void => {
|
describe("ActiveProjectsWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render loading state initially", (): void => {
|
it("should render loading state initially", (): void => {
|
||||||
vi.mocked(global.fetch).mockImplementation(
|
// First call returns CSRF token, subsequent calls never resolve (loading state)
|
||||||
() =>
|
let csrfReturned = false;
|
||||||
new Promise(() => {
|
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||||
|
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||||
|
|
||||||
|
// Return CSRF token on first request
|
||||||
|
if (urlString.includes("csrf") && !csrfReturned) {
|
||||||
|
csrfReturned = true;
|
||||||
|
return Promise.resolve(mockCsrfResponse());
|
||||||
|
}
|
||||||
|
// All other requests never resolve
|
||||||
|
return new Promise(() => {
|
||||||
// Intentionally empty - creates a never-resolving promise for loading state
|
// Intentionally empty - creates a never-resolving promise for loading state
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||||
|
|
||||||
@@ -57,6 +79,10 @@ describe("ActiveProjectsWidget", (): void => {
|
|||||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||||
|
|
||||||
|
// Return CSRF token
|
||||||
|
if (urlString.includes("csrf")) {
|
||||||
|
return Promise.resolve(mockCsrfResponse());
|
||||||
|
}
|
||||||
if (urlString.includes("active-projects")) {
|
if (urlString.includes("active-projects")) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -103,6 +129,10 @@ describe("ActiveProjectsWidget", (): void => {
|
|||||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||||
|
|
||||||
|
// Return CSRF token
|
||||||
|
if (urlString.includes("csrf")) {
|
||||||
|
return Promise.resolve(mockCsrfResponse());
|
||||||
|
}
|
||||||
if (urlString.includes("active-projects")) {
|
if (urlString.includes("active-projects")) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -127,12 +157,18 @@ describe("ActiveProjectsWidget", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle empty states", async (): Promise<void> => {
|
it("should handle empty states", async (): Promise<void> => {
|
||||||
vi.mocked(global.fetch).mockImplementation(() =>
|
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||||
Promise.resolve({
|
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||||
|
|
||||||
|
// Return CSRF token
|
||||||
|
if (urlString.includes("csrf")) {
|
||||||
|
return Promise.resolve(mockCsrfResponse());
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve([]),
|
json: () => Promise.resolve([]),
|
||||||
} as Response)
|
} as Response);
|
||||||
);
|
});
|
||||||
|
|
||||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||||
|
|
||||||
@@ -161,6 +197,10 @@ describe("ActiveProjectsWidget", (): void => {
|
|||||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||||
|
|
||||||
|
// Return CSRF token
|
||||||
|
if (urlString.includes("csrf")) {
|
||||||
|
return Promise.resolve(mockCsrfResponse());
|
||||||
|
}
|
||||||
if (urlString.includes("agent-chains")) {
|
if (urlString.includes("agent-chains")) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -207,6 +247,10 @@ describe("ActiveProjectsWidget", (): void => {
|
|||||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||||
|
|
||||||
|
// Return CSRF token
|
||||||
|
if (urlString.includes("csrf")) {
|
||||||
|
return Promise.resolve(mockCsrfResponse());
|
||||||
|
}
|
||||||
if (urlString.includes("agent-chains")) {
|
if (urlString.includes("agent-chains")) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -251,6 +295,10 @@ describe("ActiveProjectsWidget", (): void => {
|
|||||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||||
|
|
||||||
|
// Return CSRF token
|
||||||
|
if (urlString.includes("csrf")) {
|
||||||
|
return Promise.resolve(mockCsrfResponse());
|
||||||
|
}
|
||||||
if (urlString.includes("active-projects")) {
|
if (urlString.includes("active-projects")) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -3,16 +3,24 @@
|
|||||||
* Following TDD principles
|
* Following TDD principles
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
// We'll implement this hook
|
// We'll implement this hook
|
||||||
import { useLayouts, useCreateLayout, useUpdateLayout, useDeleteLayout } from "../useLayouts";
|
import { useLayouts, useCreateLayout, useUpdateLayout, useDeleteLayout } from "../useLayouts";
|
||||||
|
import { clearCsrfToken } from "@/lib/api/client";
|
||||||
|
|
||||||
global.fetch = vi.fn();
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
// Helper to create mock CSRF token response
|
||||||
|
const mockCsrfResponse = (): Response =>
|
||||||
|
({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ token: "test-csrf-token" }),
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
const createWrapper = () => {
|
const createWrapper = () => {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -29,6 +37,11 @@ const createWrapper = () => {
|
|||||||
describe("useLayouts", (): void => {
|
describe("useLayouts", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fetch layouts on mount", async (): Promise<void> => {
|
it("should fetch layouts on mount", async (): Promise<void> => {
|
||||||
@@ -82,6 +95,11 @@ describe("useLayouts", (): void => {
|
|||||||
describe("useCreateLayout", (): void => {
|
describe("useCreateLayout", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create a new layout", async (): Promise<void> => {
|
it("should create a new layout", async (): Promise<void> => {
|
||||||
@@ -92,7 +110,10 @@ describe("useCreateLayout", (): void => {
|
|||||||
layout: [],
|
layout: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
// Mock CSRF token fetch first, then the actual POST request
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce(mockCsrfResponse())
|
||||||
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => mockLayout,
|
json: () => mockLayout,
|
||||||
});
|
});
|
||||||
@@ -113,7 +134,10 @@ describe("useCreateLayout", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle creation errors", async (): Promise<void> => {
|
it("should handle creation errors", async (): Promise<void> => {
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error"));
|
// Mock CSRF token fetch succeeds but the actual POST fails
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce(mockCsrfResponse())
|
||||||
|
.mockRejectedValueOnce(new Error("API Error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useCreateLayout(), {
|
const { result } = renderHook(() => useCreateLayout(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -133,6 +157,11 @@ describe("useCreateLayout", (): void => {
|
|||||||
describe("useUpdateLayout", (): void => {
|
describe("useUpdateLayout", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update an existing layout", async (): Promise<void> => {
|
it("should update an existing layout", async (): Promise<void> => {
|
||||||
@@ -143,7 +172,10 @@ describe("useUpdateLayout", (): void => {
|
|||||||
layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }],
|
layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }],
|
||||||
};
|
};
|
||||||
|
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
// Mock CSRF token fetch first, then the actual PATCH request
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce(mockCsrfResponse())
|
||||||
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => mockLayout,
|
json: () => mockLayout,
|
||||||
});
|
});
|
||||||
@@ -165,7 +197,10 @@ describe("useUpdateLayout", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle update errors", async (): Promise<void> => {
|
it("should handle update errors", async (): Promise<void> => {
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error"));
|
// Mock CSRF token fetch succeeds but the actual PATCH fails
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce(mockCsrfResponse())
|
||||||
|
.mockRejectedValueOnce(new Error("API Error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useUpdateLayout(), {
|
const { result } = renderHook(() => useUpdateLayout(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -185,10 +220,18 @@ describe("useUpdateLayout", (): void => {
|
|||||||
describe("useDeleteLayout", (): void => {
|
describe("useDeleteLayout", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should delete a layout", async (): Promise<void> => {
|
it("should delete a layout", async (): Promise<void> => {
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
// Mock CSRF token fetch first, then the actual DELETE request
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce(mockCsrfResponse())
|
||||||
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => ({ success: true }),
|
json: () => ({ success: true }),
|
||||||
});
|
});
|
||||||
@@ -205,7 +248,10 @@ describe("useDeleteLayout", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle deletion errors", async (): Promise<void> => {
|
it("should handle deletion errors", async (): Promise<void> => {
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error"));
|
// Mock CSRF token fetch succeeds but the actual DELETE fails
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce(mockCsrfResponse())
|
||||||
|
.mockRejectedValueOnce(new Error("API Error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useDeleteLayout(), {
|
const { result } = renderHook(() => useDeleteLayout(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query";
|
import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query";
|
||||||
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
||||||
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client";
|
||||||
|
|
||||||
const LAYOUTS_KEY = ["layouts"];
|
const LAYOUTS_KEY = ["layouts"];
|
||||||
|
|
||||||
@@ -30,11 +31,7 @@ export function useLayouts(): UseQueryResult<UserLayout[]> {
|
|||||||
return useQuery<UserLayout[]>({
|
return useQuery<UserLayout[]>({
|
||||||
queryKey: LAYOUTS_KEY,
|
queryKey: LAYOUTS_KEY,
|
||||||
queryFn: async (): Promise<UserLayout[]> => {
|
queryFn: async (): Promise<UserLayout[]> => {
|
||||||
const response = await fetch("/api/layouts");
|
return apiGet<UserLayout[]>("/api/layouts");
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch layouts");
|
|
||||||
}
|
|
||||||
return response.json() as Promise<UserLayout[]>;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -46,11 +43,7 @@ export function useLayout(id: string): UseQueryResult<UserLayout> {
|
|||||||
return useQuery<UserLayout>({
|
return useQuery<UserLayout>({
|
||||||
queryKey: [...LAYOUTS_KEY, id],
|
queryKey: [...LAYOUTS_KEY, id],
|
||||||
queryFn: async (): Promise<UserLayout> => {
|
queryFn: async (): Promise<UserLayout> => {
|
||||||
const response = await fetch(`/api/layouts/${id}`);
|
return apiGet<UserLayout>(`/api/layouts/${id}`);
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch layout");
|
|
||||||
}
|
|
||||||
return response.json() as Promise<UserLayout>;
|
|
||||||
},
|
},
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
@@ -63,36 +56,20 @@ export function useDefaultLayout(): UseQueryResult<UserLayout> {
|
|||||||
return useQuery<UserLayout>({
|
return useQuery<UserLayout>({
|
||||||
queryKey: [...LAYOUTS_KEY, "default"],
|
queryKey: [...LAYOUTS_KEY, "default"],
|
||||||
queryFn: async (): Promise<UserLayout> => {
|
queryFn: async (): Promise<UserLayout> => {
|
||||||
const response = await fetch("/api/layouts/default");
|
return apiGet<UserLayout>("/api/layouts/default");
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch default layout");
|
|
||||||
}
|
|
||||||
return response.json() as Promise<UserLayout>;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new layout
|
* Create a new layout (uses API client for CSRF protection)
|
||||||
*/
|
*/
|
||||||
export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLayoutData> {
|
export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLayoutData> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: CreateLayoutData): Promise<UserLayout> => {
|
mutationFn: async (data: CreateLayoutData): Promise<UserLayout> => {
|
||||||
const response = await fetch("/api/layouts", {
|
return apiPost<UserLayout>("/api/layouts", data);
|
||||||
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>;
|
|
||||||
},
|
},
|
||||||
onSuccess: (): void => {
|
onSuccess: (): void => {
|
||||||
// Invalidate layouts cache to refetch
|
// 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> {
|
export function useUpdateLayout(): UseMutationResult<UserLayout, Error, UpdateLayoutData> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ id, ...data }: UpdateLayoutData): Promise<UserLayout> => {
|
mutationFn: async ({ id, ...data }: UpdateLayoutData): Promise<UserLayout> => {
|
||||||
const response = await fetch(`/api/layouts/${id}`, {
|
return apiPatch<UserLayout>(`/api/layouts/${id}`, data);
|
||||||
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>;
|
|
||||||
},
|
},
|
||||||
onSuccess: (_data, variables): void => {
|
onSuccess: (_data, variables): void => {
|
||||||
// Invalidate affected queries
|
// 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> {
|
export function useDeleteLayout(): UseMutationResult<void, Error, string> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (id: string): Promise<void> => {
|
mutationFn: async (id: string): Promise<void> => {
|
||||||
const response = await fetch(`/api/layouts/${id}`, {
|
await apiDelete(`/api/layouts/${id}`);
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to delete layout");
|
|
||||||
}
|
|
||||||
|
|
||||||
await response.json();
|
|
||||||
},
|
},
|
||||||
onSuccess: (): void => {
|
onSuccess: (): void => {
|
||||||
// Invalidate layouts cache to refetch
|
// Invalidate layouts cache to refetch
|
||||||
|
|||||||
@@ -214,3 +214,57 @@ export async function apiDelete<T>(endpoint: string, workspaceId?: string): Prom
|
|||||||
}
|
}
|
||||||
return apiRequest<T>(endpoint, options);
|
return apiRequest<T>(endpoint, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST request helper for FormData uploads
|
||||||
|
* Note: This does not set Content-Type header to allow browser to set multipart/form-data boundary
|
||||||
|
*/
|
||||||
|
export async function apiPostFormData<T>(
|
||||||
|
endpoint: string,
|
||||||
|
formData: FormData,
|
||||||
|
workspaceId?: string
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${API_BASE_URL}${endpoint}`;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Add workspace ID header if provided
|
||||||
|
if (workspaceId) {
|
||||||
|
headers["X-Workspace-Id"] = workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSRF token for state-changing request
|
||||||
|
const token = await ensureCsrfToken();
|
||||||
|
headers["X-CSRF-Token"] = token;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = await response.json().catch(
|
||||||
|
(): ApiError => ({
|
||||||
|
code: "UNKNOWN_ERROR",
|
||||||
|
message: response.statusText || "An unknown error occurred",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle CSRF token mismatch - refresh token and retry once
|
||||||
|
if (
|
||||||
|
response.status === 403 &&
|
||||||
|
(error.code === "CSRF_ERROR" || error.message.includes("CSRF"))
|
||||||
|
) {
|
||||||
|
// Refresh CSRF token
|
||||||
|
await fetchCsrfToken();
|
||||||
|
|
||||||
|
// Retry the request with new token (recursive call)
|
||||||
|
return apiPostFormData<T>(endpoint, formData, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user