437 lines
13 KiB
TypeScript
437 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Task } from "./kanban-card";
|
|
import { TaskCreateDialog } from "./task-create-dialog";
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import type { Task as BackendTask } from "@/lib/types/http";
|
|
import type { WorkflowsState } from "@/lib/state/slices";
|
|
import { type MoveTaskError } from "@/hooks/use-drag-and-drop";
|
|
import { SwimlaneContainer } from "./kanban/swimlane-container";
|
|
import { KanbanHeader } from "./kanban/kanban-header";
|
|
import { MobileFab } from "./kanban/mobile-fab";
|
|
import { MobileSearchBar } from "./kanban/mobile-search-bar";
|
|
import { MobileTaskSheet } from "./kanban/mobile-task-sheet";
|
|
import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/domains/kanban";
|
|
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
|
|
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
|
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
|
import { HomepageCommands } from "./homepage-commands";
|
|
import { linkToTask } from "@/lib/links";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogContent,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogCancel,
|
|
AlertDialogAction,
|
|
} from "@kandev/ui/alert-dialog";
|
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
|
|
function useWorkflowSelection({
|
|
store,
|
|
userSettings,
|
|
workspaceState,
|
|
workflowsState,
|
|
commitSettings,
|
|
setActiveWorkflow,
|
|
setWorkflows,
|
|
}: {
|
|
store: ReturnType<typeof useAppStoreApi>;
|
|
userSettings: { workflowId?: string | null };
|
|
workspaceState: { activeId: string | null };
|
|
workflowsState: WorkflowsState;
|
|
commitSettings: unknown;
|
|
setActiveWorkflow: (id: string | null) => void;
|
|
setWorkflows: (workflows: WorkflowsState["items"]) => void;
|
|
}) {
|
|
const userSettingsRef = useRef(userSettings);
|
|
useEffect(() => {
|
|
userSettingsRef.current = userSettings;
|
|
});
|
|
|
|
useEffect(() => {
|
|
const workspaceId = workspaceState.activeId;
|
|
if (!workspaceId) {
|
|
if (workflowsState.items.length || workflowsState.activeId) {
|
|
setWorkflows([]);
|
|
setActiveWorkflow(null);
|
|
}
|
|
return;
|
|
}
|
|
const settings = userSettingsRef.current;
|
|
const workspaceWorkflows = workflowsState.items.filter(
|
|
(workflow: WorkflowsState["items"][number]) => workflow.workspaceId === workspaceId,
|
|
);
|
|
|
|
const hasSettingsWorkflow =
|
|
settings.workflowId &&
|
|
workspaceWorkflows.some(
|
|
(workflow: WorkflowsState["items"][number]) => workflow.id === settings.workflowId,
|
|
);
|
|
const fallbackWorkflowId = workspaceWorkflows.length === 1 ? workspaceWorkflows[0].id : null;
|
|
const desiredWorkflowId =
|
|
(hasSettingsWorkflow ? settings.workflowId : fallbackWorkflowId) ?? null;
|
|
setActiveWorkflow(desiredWorkflowId);
|
|
if (!desiredWorkflowId) {
|
|
store.getState().hydrate({
|
|
kanban: { workflowId: null, steps: [], tasks: [] },
|
|
});
|
|
}
|
|
}, [
|
|
workflowsState.activeId,
|
|
workflowsState.items,
|
|
commitSettings,
|
|
setActiveWorkflow,
|
|
setWorkflows,
|
|
store,
|
|
workspaceState.activeId,
|
|
]);
|
|
}
|
|
|
|
function useMoveErrorState(router: ReturnType<typeof useRouter>) {
|
|
const [moveError, setMoveError] = useState<MoveTaskError | null>(null);
|
|
|
|
const handleMoveError = useCallback((error: MoveTaskError) => {
|
|
setMoveError(error);
|
|
}, []);
|
|
|
|
const handleGoToTask = useCallback(() => {
|
|
if (moveError?.taskId) {
|
|
router.push(linkToTask(moveError.taskId));
|
|
}
|
|
setMoveError(null);
|
|
}, [moveError, router]);
|
|
|
|
return {
|
|
moveError,
|
|
setMoveError,
|
|
handleMoveError,
|
|
handleGoToTask,
|
|
};
|
|
}
|
|
|
|
function useKanbanBoardStore() {
|
|
const store = useAppStoreApi();
|
|
const kanbanViewMode = useAppStore((state) => state.userSettings.kanbanViewMode);
|
|
const kanban = useAppStore((state) => state.kanban);
|
|
const workspaceState = useAppStore((state) => state.workspaces);
|
|
const workflowsState = useAppStore((state) => state.workflows);
|
|
const setActiveWorkflow = useAppStore((state) => state.setActiveWorkflow);
|
|
const setWorkflows = useAppStore((state) => state.setWorkflows);
|
|
return {
|
|
store,
|
|
kanbanViewMode,
|
|
kanban,
|
|
workspaceState,
|
|
workflowsState,
|
|
setActiveWorkflow,
|
|
setWorkflows,
|
|
};
|
|
}
|
|
|
|
interface KanbanBoardProps {
|
|
onPreviewTask?: (task: Task) => void;
|
|
onOpenTask?: (task: Task) => void;
|
|
}
|
|
|
|
function useKanbanBoardHooks(
|
|
searchQuery: string,
|
|
workspaceState: ReturnType<typeof useKanbanBoardStore>["workspaceState"],
|
|
workflowsState: ReturnType<typeof useKanbanBoardStore>["workflowsState"],
|
|
) {
|
|
const {
|
|
isDialogOpen,
|
|
editingTask,
|
|
setIsDialogOpen,
|
|
setEditingTask,
|
|
handleCreate,
|
|
handleEdit,
|
|
handleDelete,
|
|
handleArchive,
|
|
handleDialogOpenChange,
|
|
handleDialogSuccess,
|
|
handleWorkspaceChange,
|
|
handleWorkflowChange,
|
|
deletingTaskId,
|
|
archivingTaskId,
|
|
} = useKanbanActions({ workspaceState, workflowsState });
|
|
const { enablePreviewOnClick, userSettings, commitSettings, activeSteps, isMounted } =
|
|
useKanbanData({
|
|
onWorkspaceChange: handleWorkspaceChange,
|
|
onWorkflowChange: handleWorkflowChange,
|
|
searchQuery,
|
|
});
|
|
return {
|
|
isDialogOpen,
|
|
editingTask,
|
|
setIsDialogOpen,
|
|
setEditingTask,
|
|
handleCreate,
|
|
handleEdit,
|
|
handleDelete,
|
|
handleArchive,
|
|
handleDialogOpenChange,
|
|
handleDialogSuccess,
|
|
deletingTaskId,
|
|
archivingTaskId,
|
|
enablePreviewOnClick,
|
|
userSettings,
|
|
commitSettings,
|
|
activeSteps,
|
|
isMounted,
|
|
};
|
|
}
|
|
|
|
function useKanbanBoardSetup(
|
|
onPreviewTask: KanbanBoardProps["onPreviewTask"],
|
|
onOpenTask: KanbanBoardProps["onOpenTask"],
|
|
) {
|
|
const router = useRouter();
|
|
const { isMobile } = useResponsiveBreakpoint();
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const {
|
|
store,
|
|
kanbanViewMode,
|
|
kanban,
|
|
workspaceState,
|
|
workflowsState,
|
|
setActiveWorkflow,
|
|
setWorkflows,
|
|
} = useKanbanBoardStore();
|
|
|
|
useAllWorkflowSnapshots(workspaceState.activeId);
|
|
useWorkspacePRs(workspaceState.activeId);
|
|
|
|
const hooks = useKanbanBoardHooks(searchQuery, workspaceState, workflowsState);
|
|
const { handleOpenTask, handleCardClick } = useKanbanNavigation({
|
|
enablePreviewOnClick: hooks.enablePreviewOnClick,
|
|
isMobile,
|
|
onPreviewTask,
|
|
onOpenTask,
|
|
});
|
|
|
|
// Mobile bottom sheet: intercept card clicks to show task info first
|
|
const [mobileSheetTask, setMobileSheetTask] = useState<Task | null>(null);
|
|
const mobileCardClick = useCallback(
|
|
(task: Task) => {
|
|
if (isMobile) {
|
|
setMobileSheetTask(task);
|
|
} else {
|
|
handleCardClick(task);
|
|
}
|
|
},
|
|
[isMobile, handleCardClick],
|
|
);
|
|
|
|
const automation = useMoveErrorState(router);
|
|
|
|
useWorkflowSelection({
|
|
store,
|
|
userSettings: hooks.userSettings,
|
|
workspaceState,
|
|
workflowsState,
|
|
commitSettings: hooks.commitSettings,
|
|
setActiveWorkflow,
|
|
setWorkflows,
|
|
});
|
|
|
|
return {
|
|
isMobile,
|
|
kanbanViewMode,
|
|
kanban,
|
|
workspaceState,
|
|
workflowsState,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
...hooks,
|
|
...automation,
|
|
handleOpenTask,
|
|
handleCardClick: mobileCardClick,
|
|
mobileSheetTask,
|
|
setMobileSheetTask,
|
|
};
|
|
}
|
|
|
|
export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) {
|
|
const s = useKanbanBoardSetup(onPreviewTask, onOpenTask);
|
|
|
|
if (!s.isMounted) {
|
|
return <div className="h-dvh w-full bg-background" />;
|
|
}
|
|
|
|
const stepOptions = s.activeSteps.map((step) => ({
|
|
id: step.id,
|
|
title: step.title,
|
|
events: step.events,
|
|
}));
|
|
|
|
return (
|
|
<div className="h-dvh w-full flex flex-col" data-testid="kanban-board">
|
|
<HomepageCommands onCreateTask={s.handleCreate} />
|
|
<KanbanHeader
|
|
onCreateTask={s.handleCreate}
|
|
workspaceId={s.workspaceState.activeId ?? undefined}
|
|
searchQuery={s.searchQuery}
|
|
onSearchChange={s.setSearchQuery}
|
|
/>
|
|
{s.isMobile && (
|
|
<MobileSearchBar searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} />
|
|
)}
|
|
<KanbanBoardDialogs
|
|
isDialogOpen={s.isDialogOpen}
|
|
handleDialogOpenChange={s.handleDialogOpenChange}
|
|
workspaceId={s.workspaceState.activeId}
|
|
workflowId={s.kanban.workflowId}
|
|
defaultStepId={s.activeSteps[0]?.id ?? null}
|
|
stepOptions={stepOptions}
|
|
editingTask={s.editingTask}
|
|
handleDialogSuccess={s.handleDialogSuccess}
|
|
moveError={s.moveError}
|
|
setMoveError={s.setMoveError}
|
|
handleGoToTask={s.handleGoToTask}
|
|
/>
|
|
<SwimlaneContainer
|
|
viewMode={s.kanbanViewMode || ""}
|
|
workflowFilter={s.workflowsState.activeId}
|
|
onPreviewTask={s.handleCardClick}
|
|
onOpenTask={s.handleOpenTask}
|
|
onEditTask={s.handleEdit}
|
|
onDeleteTask={s.handleDelete}
|
|
onArchiveTask={s.handleArchive}
|
|
onMoveError={s.handleMoveError}
|
|
deletingTaskId={s.deletingTaskId}
|
|
archivingTaskId={s.archivingTaskId}
|
|
showMaximizeButton={s.enablePreviewOnClick}
|
|
searchQuery={s.searchQuery}
|
|
selectedRepositoryIds={s.userSettings.repositoryIds}
|
|
/>
|
|
{s.isMobile && (
|
|
<>
|
|
<MobileFab onClick={s.handleCreate} />
|
|
<MobileTaskSheet
|
|
task={s.mobileSheetTask}
|
|
open={!!s.mobileSheetTask}
|
|
onOpenChange={(open) => {
|
|
if (!open) s.setMobileSheetTask(null);
|
|
}}
|
|
onGoToSession={s.handleOpenTask}
|
|
onEdit={s.handleEdit}
|
|
onDelete={s.handleDelete}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type KanbanBoardDialogsProps = {
|
|
isDialogOpen: boolean;
|
|
handleDialogOpenChange: (open: boolean) => void;
|
|
workspaceId: string | null;
|
|
workflowId: string | null;
|
|
defaultStepId: string | null;
|
|
stepOptions: Array<{
|
|
id: string;
|
|
title: string;
|
|
events?: {
|
|
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
};
|
|
}>;
|
|
editingTask: Task | null;
|
|
handleDialogSuccess: (task: BackendTask, mode: "create" | "edit") => void;
|
|
moveError: MoveTaskError | null;
|
|
setMoveError: (error: MoveTaskError | null) => void;
|
|
handleGoToTask: () => void;
|
|
};
|
|
|
|
function KanbanBoardDialogs({
|
|
isDialogOpen,
|
|
handleDialogOpenChange,
|
|
workspaceId,
|
|
workflowId,
|
|
defaultStepId,
|
|
stepOptions,
|
|
editingTask,
|
|
handleDialogSuccess,
|
|
moveError,
|
|
setMoveError,
|
|
handleGoToTask,
|
|
}: KanbanBoardDialogsProps) {
|
|
return (
|
|
<>
|
|
<TaskCreateDialog
|
|
open={isDialogOpen}
|
|
onOpenChange={handleDialogOpenChange}
|
|
workspaceId={workspaceId}
|
|
workflowId={workflowId}
|
|
defaultStepId={defaultStepId}
|
|
steps={stepOptions}
|
|
editingTask={
|
|
editingTask
|
|
? {
|
|
id: editingTask.id,
|
|
title: editingTask.title,
|
|
description: editingTask.description,
|
|
workflowStepId: editingTask.workflowStepId,
|
|
state: editingTask.state as BackendTask["state"],
|
|
repositoryId: editingTask.repositoryId,
|
|
}
|
|
: null
|
|
}
|
|
onSuccess={handleDialogSuccess}
|
|
initialValues={
|
|
editingTask
|
|
? {
|
|
title: editingTask.title,
|
|
description: editingTask.description,
|
|
state: editingTask.state as BackendTask["state"],
|
|
repositoryId: editingTask.repositoryId,
|
|
}
|
|
: undefined
|
|
}
|
|
mode={editingTask ? "edit" : "create"}
|
|
/>
|
|
<ApprovalWarningDialog
|
|
moveError={moveError}
|
|
setMoveError={setMoveError}
|
|
handleGoToTask={handleGoToTask}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ApprovalWarningDialog({
|
|
moveError,
|
|
setMoveError,
|
|
handleGoToTask,
|
|
}: {
|
|
moveError: MoveTaskError | null;
|
|
setMoveError: (error: MoveTaskError | null) => void;
|
|
handleGoToTask: () => void;
|
|
}) {
|
|
return (
|
|
<AlertDialog open={!!moveError} onOpenChange={(open) => !open && setMoveError(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="flex items-center gap-2">
|
|
<IconAlertTriangle className="h-5 w-5 text-amber-500" />
|
|
Approval Required
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>{moveError?.message}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Dismiss</AlertDialogCancel>
|
|
{moveError?.taskId && (
|
|
<AlertDialogAction onClick={handleGoToTask}>Go to Task</AlertDialogAction>
|
|
)}
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|