Files
kandev/apps/web/components/kanban-board.tsx
T
2026-01-16 01:59:00 +00:00

432 lines
13 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import { Column } from './kanban-column';
import { Task } from './kanban-card';
import { TaskCreateDialog } from './task-create-dialog';
import { useRouter } from 'next/navigation';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import type { Task as BackendTask } from '@/lib/types/http';
import { filterTasksByRepositories } from '@/lib/kanban/filters';
import { useUserDisplaySettings } from '@/hooks/use-user-display-settings';
import { useBoards } from '@/hooks/use-boards';
import { useBoardSnapshot } from '@/hooks/use-board-snapshot';
import { useTaskActions } from '@/hooks/use-task-actions';
import { KanbanBoardHeader } from './kanban-board-header';
import { KanbanBoardGrid } from './kanban-board-grid';
import { getWebSocketClient } from '@/lib/ws/connection';
interface KanbanBoardProps {
onPreviewTask?: (task: Task) => void;
onOpenTask?: (task: Task, sessionId: string) => void;
}
export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [isMovingTask, setIsMovingTask] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [taskSessionAvailability, setTaskSessionAvailability] = useState<Record<string, boolean>>({});
const router = useRouter();
const kanban = useAppStore((state) => state.kanban);
const workspaceState = useAppStore((state) => state.workspaces);
const setActiveWorkspace = useAppStore((state) => state.setActiveWorkspace);
const boardsState = useAppStore((state) => state.boards);
const setActiveBoard = useAppStore((state) => state.setActiveBoard);
const setBoards = useAppStore((state) => state.setBoards);
const store = useAppStoreApi();
const { moveTaskById, deleteTaskById } = useTaskActions();
useBoards(workspaceState.activeId, true);
useBoardSnapshot(boardsState.activeId);
const {
settings: userSettings,
commitSettings,
repositories,
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryIds,
} = useUserDisplaySettings({
workspaceId: workspaceState.activeId,
boardId: boardsState.activeId,
onWorkspaceChange: (nextWorkspaceId) => {
setActiveWorkspace(nextWorkspaceId);
if (nextWorkspaceId) {
router.push(`/?workspaceId=${nextWorkspaceId}`);
} else {
router.push('/');
}
},
onBoardChange: (nextBoardId) => {
setActiveBoard(nextBoardId);
if (nextBoardId) {
const workspaceId = boardsState.items.find((board) => board.id === nextBoardId)?.workspaceId;
const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : '';
router.push(`/?boardId=${nextBoardId}${workspaceParam}`);
}
},
});
const isMounted = useSyncExternalStore(
() => () => { },
() => true,
() => false
);
const backendColumns = useMemo<Column[]>(
() =>
[...kanban.columns]
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.map((column) => ({
id: column.id,
title: column.title,
color: column.color || 'bg-neutral-400',
})),
[kanban.columns]
);
const backendTasks = useMemo<Task[]>(
() =>
kanban.tasks.map((task) => ({
id: task.id,
title: task.title,
columnId: task.columnId,
state: task.state,
description: task.description,
position: task.position,
repositoryId: task.repositoryId,
})),
[kanban.tasks]
);
const activeColumns = kanban.boardId ? backendColumns : [];
const visibleTasks = useMemo(
() => filterTasksByRepositories(backendTasks, selectedRepositoryIds),
[backendTasks, selectedRepositoryIds]
);
const visibleTasksWithSessions = useMemo(
() => visibleTasks.map((task) => ({
...task,
hasSession: taskSessionAvailability[task.id],
})),
[visibleTasks, taskSessionAvailability]
);
const activeTask = useMemo(
() => visibleTasksWithSessions.find((task) => task.id === activeTaskId) ?? null,
[visibleTasksWithSessions, activeTaskId]
);
const handleDragStart = (event: DragStartEvent) => {
setActiveTaskId(event.active.id as string);
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveTaskId(null);
if (!over) return;
const taskId = active.id as string;
const newStatus = over.id as string;
if (!kanban.boardId || isMovingTask) {
return;
}
const targetTasks = kanban.tasks
.filter((task) => task.columnId === newStatus && task.id !== taskId)
.sort((a, b) => a.position - b.position);
const nextPosition = targetTasks.length;
store.getState().hydrate({
kanban: {
...kanban,
tasks: kanban.tasks.map((task) =>
task.id === taskId
? { ...task, columnId: newStatus, position: nextPosition }
: task
),
},
});
try {
setIsMovingTask(true);
await moveTaskById(taskId, {
board_id: kanban.boardId,
column_id: newStatus,
position: nextPosition,
});
} catch {
// Ignore move errors for now; WS updates or next snapshot will correct.
} finally {
setIsMovingTask(false);
}
};
const handleDragCancel = () => {
setActiveTaskId(null);
};
const handleDialogSuccess = (task: BackendTask, mode: 'create' | 'edit') => {
if (mode === 'create') {
store.getState().hydrate({
kanban: {
...kanban,
tasks: [
...kanban.tasks,
{
id: task.id,
columnId: task.column_id,
title: task.title,
description: task.description ?? undefined,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
},
],
},
});
return;
}
store.getState().hydrate({
kanban: {
...kanban,
tasks: kanban.tasks.map((item) =>
item.id === task.id
? {
...item,
title: task.title,
description: task.description ?? undefined,
columnId: task.column_id ?? item.columnId,
position: task.position ?? item.position,
state: task.state ?? item.state,
repositoryId: task.repositories?.[0]?.repository_id ?? item.repositoryId,
}
: item
),
},
});
};
const fetchLatestSessionId = async (taskId: string) => {
const client = getWebSocketClient();
if (!client) return null;
try {
const response = await client.request<{ sessions: Array<{ id: string }> }>(
'task.session.list',
{ task_id: taskId },
10000
);
setTaskSessionAvailability((prev) => ({
...prev,
[taskId]: response.sessions.length > 0,
}));
return response.sessions[0]?.id ?? null;
} catch (error) {
console.error('Failed to load task sessions:', error);
return null;
}
};
const handleOpenTask = async (task: Task) => {
const latestSessionId = await fetchLatestSessionId(task.id);
if (!latestSessionId) {
setEditingTask(task);
setIsDialogOpen(true);
return;
}
if (onOpenTask) {
onOpenTask(task, latestSessionId);
} else {
router.push(`/task/${task.id}/${latestSessionId}`);
}
};
const handlePreviewTask = async (task: Task) => {
const latestSessionId = await fetchLatestSessionId(task.id);
if (!latestSessionId) {
setEditingTask(task);
setIsDialogOpen(true);
return;
}
if (onPreviewTask) {
onPreviewTask(task);
} else {
// Fallback to opening full page if no preview handler
handleOpenTask(task);
}
};
const handleEditTask = (task: Task) => {
setEditingTask(task);
setIsDialogOpen(true);
};
const handleDeleteTask = async (task: Task) => {
if (!kanban.boardId) return;
store.getState().hydrate({
kanban: {
...kanban,
tasks: kanban.tasks.filter((item) => item.id !== task.id),
},
});
try {
await deleteTaskById(task.id);
} catch {
// Ignore delete errors for now.
}
};
useEffect(() => {
const workspaceId = workspaceState.activeId;
if (!workspaceId) {
if (boardsState.items.length || boardsState.activeId) {
setBoards([]);
setActiveBoard(null);
}
return;
}
const workspaceBoards = boardsState.items.filter((board) => board.workspaceId === workspaceId);
const desiredBoardId =
(userSettings.boardId && workspaceBoards.some((board) => board.id === userSettings.boardId)
? userSettings.boardId
: workspaceBoards[0]?.id) ?? null;
setActiveBoard(desiredBoardId);
if (userSettings.loaded && desiredBoardId !== userSettings.boardId) {
commitSettings({
workspaceId,
boardId: desiredBoardId,
repositoryIds: userSettings.repositoryIds,
});
}
if (!desiredBoardId) {
store.getState().hydrate({
kanban: { boardId: null, columns: [], tasks: [] },
});
}
}, [
boardsState.activeId,
boardsState.items,
commitSettings,
setActiveBoard,
setBoards,
store,
userSettings.boardId,
userSettings.loaded,
userSettings.repositoryIds,
workspaceState.activeId,
]);
if (!isMounted) {
return <div className="h-screen w-full bg-background" />;
}
return (
<div className="h-screen w-full flex flex-col bg-background">
<KanbanBoardHeader
workspaces={workspaceState.items}
boards={boardsState.items}
activeWorkspaceId={workspaceState.activeId}
activeBoardId={boardsState.activeId}
repositories={repositories}
repositoriesLoading={repositoriesLoading}
allRepositoriesSelected={allRepositoriesSelected}
selectedRepositoryId={userSettings.repositoryIds[0] ?? null}
onWorkspaceChange={(nextWorkspaceId) => {
setActiveWorkspace(nextWorkspaceId);
if (nextWorkspaceId) {
router.push(`/?workspaceId=${nextWorkspaceId}`);
} else {
router.push('/');
}
commitSettings({
workspaceId: nextWorkspaceId,
boardId: null,
repositoryIds: [],
});
}}
onBoardChange={(nextBoardId) => {
setActiveBoard(nextBoardId);
if (nextBoardId) {
const workspaceId = boardsState.items.find((board) => board.id === nextBoardId)?.workspaceId;
const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : '';
router.push(`/?boardId=${nextBoardId}${workspaceParam}`);
}
commitSettings({
workspaceId: userSettings.workspaceId,
boardId: nextBoardId,
repositoryIds: userSettings.repositoryIds,
});
}}
onRepositoryChange={(value) => {
if (value === 'all') {
commitSettings({
workspaceId: userSettings.workspaceId,
boardId: userSettings.boardId,
repositoryIds: [],
});
return;
}
commitSettings({
workspaceId: userSettings.workspaceId,
boardId: userSettings.boardId,
repositoryIds: [value],
});
}}
onAddTask={() => {
setEditingTask(null);
setIsDialogOpen(true);
}}
/>
<TaskCreateDialog
key={isDialogOpen ? 'open' : 'closed'}
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingTask(null);
}
}}
workspaceId={workspaceState.activeId}
boardId={kanban.boardId}
defaultColumnId={activeColumns[0]?.id ?? null}
columns={activeColumns.map((column) => ({ id: column.id, title: column.title }))}
editingTask={
editingTask
? {
id: editingTask.id,
title: editingTask.title,
description: editingTask.description,
columnId: editingTask.columnId,
state: editingTask.state as BackendTask['state'],
}
: null
}
onSuccess={handleDialogSuccess}
initialValues={
editingTask
? {
title: editingTask.title,
description: editingTask.description,
state: editingTask.state as BackendTask['state'],
}
: undefined
}
submitLabel={editingTask ? 'Update' : 'Create'}
/>
<KanbanBoardGrid
columns={activeColumns}
tasks={visibleTasksWithSessions}
onPreviewTask={handlePreviewTask}
onOpenTask={handleOpenTask}
onEditTask={handleEditTask}
onDeleteTask={handleDeleteTask}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
activeTask={activeTask}
/>
</div>
);
}