- Store previous state before PATCH request - Apply optimistic update immediately on drag - Rollback UI to original position on API error - Show error toast notification on failure - Add comprehensive tests for optimistic updates and rollback Refs #338 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, within, waitFor, act } from "@testing-library/react";
|
|
import { KanbanBoard } from "./KanbanBoard";
|
|
import type { Task } from "@mosaic/shared";
|
|
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
|
import type { ToastContextValue } from "@mosaic/ui";
|
|
|
|
// Mock fetch globally
|
|
global.fetch = vi.fn();
|
|
|
|
// Mock useToast hook from @mosaic/ui
|
|
const mockShowToast = vi.fn();
|
|
vi.mock("@mosaic/ui", () => ({
|
|
useToast: (): ToastContextValue => ({
|
|
showToast: mockShowToast,
|
|
removeToast: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// Mock the api client's apiPatch function
|
|
const mockApiPatch = vi.fn<(endpoint: string, data: unknown) => Promise<unknown>>();
|
|
vi.mock("@/lib/api/client", () => ({
|
|
apiPatch: (endpoint: string, data: unknown): Promise<unknown> => mockApiPatch(endpoint, data),
|
|
}));
|
|
|
|
// Store drag event handlers for testing
|
|
type DragEventHandler = (event: {
|
|
active: { id: string };
|
|
over: { id: string } | null;
|
|
}) => Promise<void> | void;
|
|
let capturedOnDragEnd: DragEventHandler | null = null;
|
|
|
|
// Mock @dnd-kit modules
|
|
vi.mock("@dnd-kit/core", async () => {
|
|
const actual = await vi.importActual("@dnd-kit/core");
|
|
return {
|
|
...actual,
|
|
DndContext: ({
|
|
children,
|
|
onDragEnd,
|
|
}: {
|
|
children: React.ReactNode;
|
|
onDragEnd?: DragEventHandler;
|
|
}): React.JSX.Element => {
|
|
// Capture the event handler for testing
|
|
capturedOnDragEnd = onDragEnd ?? null;
|
|
return <div data-testid="dnd-context">{children}</div>;
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@dnd-kit/sortable", () => ({
|
|
SortableContext: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
|
<div data-testid="sortable-context">{children}</div>
|
|
),
|
|
verticalListSortingStrategy: {},
|
|
useSortable: (): object => ({
|
|
attributes: {},
|
|
listeners: {},
|
|
setNodeRef: (): void => {},
|
|
transform: null,
|
|
transition: null,
|
|
}),
|
|
}));
|
|
|
|
const mockTasks: Task[] = [
|
|
{
|
|
id: "task-1",
|
|
title: "Design homepage",
|
|
description: "Create wireframes for the new homepage",
|
|
status: TaskStatus.NOT_STARTED,
|
|
priority: TaskPriority.HIGH,
|
|
dueDate: new Date("2026-02-01"),
|
|
assigneeId: "user-1",
|
|
creatorId: "user-1",
|
|
workspaceId: "workspace-1",
|
|
projectId: null,
|
|
parentId: null,
|
|
sortOrder: 0,
|
|
metadata: {},
|
|
completedAt: null,
|
|
createdAt: new Date("2026-01-28"),
|
|
updatedAt: new Date("2026-01-28"),
|
|
},
|
|
{
|
|
id: "task-2",
|
|
title: "Implement authentication",
|
|
description: "Add OAuth support",
|
|
status: TaskStatus.IN_PROGRESS,
|
|
priority: TaskPriority.HIGH,
|
|
dueDate: new Date("2026-01-30"),
|
|
assigneeId: "user-2",
|
|
creatorId: "user-1",
|
|
workspaceId: "workspace-1",
|
|
projectId: null,
|
|
parentId: null,
|
|
sortOrder: 1,
|
|
metadata: {},
|
|
completedAt: null,
|
|
createdAt: new Date("2026-01-28"),
|
|
updatedAt: new Date("2026-01-28"),
|
|
},
|
|
{
|
|
id: "task-3",
|
|
title: "Write unit tests",
|
|
description: "Achieve 85% coverage",
|
|
status: TaskStatus.PAUSED,
|
|
priority: TaskPriority.MEDIUM,
|
|
dueDate: new Date("2026-02-05"),
|
|
assigneeId: "user-3",
|
|
creatorId: "user-1",
|
|
workspaceId: "workspace-1",
|
|
projectId: null,
|
|
parentId: null,
|
|
sortOrder: 2,
|
|
metadata: {},
|
|
completedAt: null,
|
|
createdAt: new Date("2026-01-28"),
|
|
updatedAt: new Date("2026-01-28"),
|
|
},
|
|
{
|
|
id: "task-4",
|
|
title: "Deploy to production",
|
|
description: "Set up CI/CD pipeline",
|
|
status: TaskStatus.COMPLETED,
|
|
priority: TaskPriority.LOW,
|
|
dueDate: new Date("2026-01-25"),
|
|
assigneeId: "user-1",
|
|
creatorId: "user-1",
|
|
workspaceId: "workspace-1",
|
|
projectId: null,
|
|
parentId: null,
|
|
sortOrder: 3,
|
|
metadata: {},
|
|
completedAt: new Date("2026-01-25"),
|
|
createdAt: new Date("2026-01-20"),
|
|
updatedAt: new Date("2026-01-25"),
|
|
},
|
|
];
|
|
|
|
describe("KanbanBoard", (): void => {
|
|
const mockOnStatusChange = vi.fn();
|
|
|
|
beforeEach((): void => {
|
|
vi.clearAllMocks();
|
|
mockShowToast.mockClear();
|
|
mockApiPatch.mockClear();
|
|
// Default: apiPatch succeeds
|
|
mockApiPatch.mockResolvedValue({});
|
|
// Also set up fetch mock for other tests that may use it
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok: true,
|
|
json: (): Promise<object> => Promise.resolve({}),
|
|
} as Response);
|
|
});
|
|
|
|
describe("Rendering", (): void => {
|
|
it("should render all four status columns with spec names", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Spec requires: todo, in_progress, review, done
|
|
expect(screen.getByText("To Do")).toBeInTheDocument();
|
|
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
|
expect(screen.getByText("Review")).toBeInTheDocument();
|
|
expect(screen.getByText("Done")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should organize tasks by status into correct columns", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
const todoColumn = screen.getByTestId("column-NOT_STARTED");
|
|
const inProgressColumn = screen.getByTestId("column-IN_PROGRESS");
|
|
const reviewColumn = screen.getByTestId("column-PAUSED");
|
|
const doneColumn = screen.getByTestId("column-COMPLETED");
|
|
|
|
expect(within(todoColumn).getByText("Design homepage")).toBeInTheDocument();
|
|
expect(within(inProgressColumn).getByText("Implement authentication")).toBeInTheDocument();
|
|
expect(within(reviewColumn).getByText("Write unit tests")).toBeInTheDocument();
|
|
expect(within(doneColumn).getByText("Deploy to production")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render empty state when no tasks provided", (): void => {
|
|
render(<KanbanBoard tasks={[]} onStatusChange={mockOnStatusChange} />);
|
|
|
|
expect(screen.getByText("To Do")).toBeInTheDocument();
|
|
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
|
expect(screen.getByText("Review")).toBeInTheDocument();
|
|
expect(screen.getByText("Done")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should handle undefined tasks array gracefully", (): void => {
|
|
// @ts-expect-error Testing error case
|
|
render(<KanbanBoard tasks={undefined} onStatusChange={mockOnStatusChange} />);
|
|
|
|
expect(screen.getByText("To Do")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Task Cards", (): void => {
|
|
it("should display task title on each card", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
expect(screen.getByText("Design homepage")).toBeInTheDocument();
|
|
expect(screen.getByText("Implement authentication")).toBeInTheDocument();
|
|
expect(screen.getByText("Write unit tests")).toBeInTheDocument();
|
|
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should display task priority badge", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
const highPriorityElements = screen.getAllByText("High");
|
|
const mediumPriorityElements = screen.getAllByText("Medium");
|
|
const lowPriorityElements = screen.getAllByText("Low");
|
|
|
|
expect(highPriorityElements.length).toBeGreaterThan(0);
|
|
expect(mediumPriorityElements.length).toBeGreaterThan(0);
|
|
expect(lowPriorityElements.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should display due date when available", (): void => {
|
|
// Use tasks with future due dates to ensure they render
|
|
const tasksWithDates: Task[] = [
|
|
{
|
|
...mockTasks[0]!,
|
|
dueDate: new Date("2026-03-15"),
|
|
},
|
|
{
|
|
...mockTasks[1]!,
|
|
dueDate: new Date("2026-04-20"),
|
|
},
|
|
];
|
|
|
|
render(<KanbanBoard tasks={tasksWithDates} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Check that calendar icons are present for tasks with due dates
|
|
const calendarIcons = screen.getAllByLabelText("Due date");
|
|
expect(calendarIcons.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("should display assignee avatar when assignee data is provided", (): void => {
|
|
const tasksWithAssignee: Task[] = [
|
|
{
|
|
...mockTasks[0]!,
|
|
// Task type uses assigneeId, not assignee object
|
|
assigneeId: "user-john",
|
|
},
|
|
];
|
|
|
|
render(<KanbanBoard tasks={tasksWithAssignee} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Check that the component renders the board successfully
|
|
expect(screen.getByTestId("kanban-grid")).toBeInTheDocument();
|
|
// Check that the task title is rendered
|
|
expect(screen.getByText("Design homepage")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Drag and Drop", (): void => {
|
|
it("should initialize DndContext for drag-and-drop", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
expect(screen.getByTestId("dnd-context")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should have droppable columns", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
const columns = screen.getAllByTestId(/^column-/);
|
|
expect(columns.length).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("Status Update API Call", (): void => {
|
|
it("should call PATCH /api/tasks/:id when status changes", (): void => {
|
|
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ status: TaskStatus.IN_PROGRESS }),
|
|
} as unknown as Response);
|
|
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Simulate drag end by calling the component's internal method
|
|
// In a real test, we'd simulate actual drag-and-drop events
|
|
// For now, we'll test the fetch call directly
|
|
|
|
// This is a simplified test - full E2E would use Playwright
|
|
expect(screen.getByTestId("dnd-context")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should handle API errors gracefully", (): void => {
|
|
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
statusText: "Internal Server Error",
|
|
} as Response);
|
|
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Component should still render even if API fails
|
|
expect(screen.getByText("To Do")).toBeInTheDocument();
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("Optimistic Updates and Rollback", (): void => {
|
|
it("should apply optimistic update immediately on drag", async (): Promise<void> => {
|
|
// apiPatch is already mocked to succeed in beforeEach
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Verify initial state - task-1 is in NOT_STARTED column
|
|
const todoColumn = screen.getByTestId("column-NOT_STARTED");
|
|
expect(within(todoColumn).getByText("Design homepage")).toBeInTheDocument();
|
|
|
|
// Trigger drag end event to move task-1 to IN_PROGRESS and wait for completion
|
|
await act(async () => {
|
|
if (capturedOnDragEnd) {
|
|
const result = capturedOnDragEnd({
|
|
active: { id: "task-1" },
|
|
over: { id: TaskStatus.IN_PROGRESS },
|
|
});
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
}
|
|
});
|
|
|
|
// After the drag completes, task should be in the new column (optimistic update persisted)
|
|
const inProgressColumn = screen.getByTestId("column-IN_PROGRESS");
|
|
expect(within(inProgressColumn).getByText("Design homepage")).toBeInTheDocument();
|
|
|
|
// Verify the task is NOT in the original column anymore
|
|
const todoColumnAfter = screen.getByTestId("column-NOT_STARTED");
|
|
expect(within(todoColumnAfter).queryByText("Design homepage")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should persist update when API call succeeds", async (): Promise<void> => {
|
|
// apiPatch is already mocked to succeed in beforeEach
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Trigger drag end event
|
|
await act(async () => {
|
|
if (capturedOnDragEnd) {
|
|
const result = capturedOnDragEnd({
|
|
active: { id: "task-1" },
|
|
over: { id: TaskStatus.IN_PROGRESS },
|
|
});
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wait for API call to complete
|
|
await waitFor(() => {
|
|
expect(mockApiPatch).toHaveBeenCalledWith("/api/tasks/task-1", {
|
|
status: TaskStatus.IN_PROGRESS,
|
|
});
|
|
});
|
|
|
|
// Verify task is in the new column after API success
|
|
const inProgressColumn = screen.getByTestId("column-IN_PROGRESS");
|
|
expect(within(inProgressColumn).getByText("Design homepage")).toBeInTheDocument();
|
|
|
|
// Verify callback was called
|
|
expect(mockOnStatusChange).toHaveBeenCalledWith("task-1", TaskStatus.IN_PROGRESS);
|
|
});
|
|
|
|
it("should rollback to original position when API call fails", async (): Promise<void> => {
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
// Mock API failure
|
|
mockApiPatch.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Verify initial state - task-1 is in NOT_STARTED column
|
|
const todoColumnBefore = screen.getByTestId("column-NOT_STARTED");
|
|
expect(within(todoColumnBefore).getByText("Design homepage")).toBeInTheDocument();
|
|
|
|
// Trigger drag end event
|
|
await act(async () => {
|
|
if (capturedOnDragEnd) {
|
|
const result = capturedOnDragEnd({
|
|
active: { id: "task-1" },
|
|
over: { id: TaskStatus.IN_PROGRESS },
|
|
});
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wait for rollback to occur
|
|
await waitFor(() => {
|
|
// After rollback, task should be back in original column
|
|
const todoColumnAfter = screen.getByTestId("column-NOT_STARTED");
|
|
expect(within(todoColumnAfter).getByText("Design homepage")).toBeInTheDocument();
|
|
});
|
|
|
|
// Verify callback was NOT called due to error
|
|
expect(mockOnStatusChange).not.toHaveBeenCalled();
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it("should show error toast notification when API call fails", async (): Promise<void> => {
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
// Mock API failure
|
|
mockApiPatch.mockRejectedValueOnce(new Error("Server error"));
|
|
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Trigger drag end event
|
|
await act(async () => {
|
|
if (capturedOnDragEnd) {
|
|
const result = capturedOnDragEnd({
|
|
active: { id: "task-1" },
|
|
over: { id: TaskStatus.IN_PROGRESS },
|
|
});
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wait for error handling
|
|
await waitFor(() => {
|
|
// Verify showToast was called with error message
|
|
expect(mockShowToast).toHaveBeenCalledWith(
|
|
"Unable to update task status. Please try again.",
|
|
"error"
|
|
);
|
|
});
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it("should not make API call when dropping on same column", async (): Promise<void> => {
|
|
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
|
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Trigger drag end event with same status
|
|
await act(async () => {
|
|
if (capturedOnDragEnd) {
|
|
const result = capturedOnDragEnd({
|
|
active: { id: "task-1" },
|
|
over: { id: TaskStatus.NOT_STARTED }, // Same as task's current status
|
|
});
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
}
|
|
});
|
|
|
|
// No API call should be made
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
// No callback should be called
|
|
expect(mockOnStatusChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle drag cancel (no drop target)", async (): Promise<void> => {
|
|
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
|
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
// Trigger drag end event with no drop target
|
|
await act(async () => {
|
|
if (capturedOnDragEnd) {
|
|
const result = capturedOnDragEnd({
|
|
active: { id: "task-1" },
|
|
over: null,
|
|
});
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Task should remain in original column
|
|
const todoColumn = screen.getByTestId("column-NOT_STARTED");
|
|
expect(within(todoColumn).getByText("Design homepage")).toBeInTheDocument();
|
|
|
|
// No API call should be made
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Accessibility", (): void => {
|
|
it("should have proper heading hierarchy", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
const h3Headings = screen.getAllByRole("heading", { level: 3 });
|
|
expect(h3Headings.length).toBe(4);
|
|
});
|
|
|
|
it("should have keyboard-navigable task cards", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
const taskCards = screen.getAllByRole("article");
|
|
expect(taskCards.length).toBe(mockTasks.length);
|
|
});
|
|
|
|
it("should announce column changes to screen readers", (): void => {
|
|
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
|
|
|
|
const columns = screen.getAllByRole("region");
|
|
expect(columns.length).toBeGreaterThan(0);
|
|
columns.forEach((column) => {
|
|
expect(column).toHaveAttribute("aria-label");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Responsive Design", (): void => {
|
|
it("should apply responsive grid classes", (): void => {
|
|
const { container } = render(
|
|
<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />
|
|
);
|
|
|
|
const boardGrid = container.querySelector('[data-testid="kanban-grid"]');
|
|
expect(boardGrid).toBeInTheDocument();
|
|
const className = boardGrid?.className ?? "";
|
|
expect(className).toMatch(/grid/);
|
|
});
|
|
});
|
|
});
|