Files
kandev/apps/web/app/tasks/tasks-page-client.tsx
T
2026-06-18 18:36:48 +01:00

479 lines
14 KiB
TypeScript

"use client";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useRouter } from "@/lib/routing/client-router";
import type { PaginationState } from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import { getColumns } from "./columns";
import { archiveTask, deleteTask, listTasksByWorkspace } from "@/lib/api";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { KanbanHeader } from "@/components/kanban/kanban-header";
import { MobileSearchBar } from "@/components/kanban/mobile-search-bar";
import { Checkbox } from "@kandev/ui/checkbox";
import { Label } from "@kandev/ui/label";
import type { Task, Workspace, Workflow, WorkflowStep, Repository } from "@/lib/types/http";
import { useToast } from "@/components/toast-provider";
import { useAppStore } from "@/components/state-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { useDebounce } from "@/hooks/use-debounce";
import { shouldSkipInitialTasksFetch } from "./tasks-page-fetch-policy";
interface TasksPageClientProps {
workspaces: Workspace[];
initialWorkspaceId?: string;
initialWorkflows: Workflow[];
initialSteps: WorkflowStep[];
initialRepositories: Repository[];
initialTasks: Task[];
initialTotal: number;
initialDataLoaded?: boolean;
}
type UseTaskOperationsParams = {
activeWorkspaceId: string | null;
activeWorkflowId: string | null;
selectedRepositoryId: string | null;
pagination: PaginationState;
debouncedQuery: string;
showArchived: boolean;
setTasks: (tasks: Task[]) => void;
setTotal: (total: number) => void;
};
function useTaskOperations({
activeWorkspaceId,
activeWorkflowId,
selectedRepositoryId,
pagination,
debouncedQuery,
showArchived,
setTasks,
setTotal,
}: UseTaskOperationsParams) {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
const fetchTasks = useCallback(async () => {
if (!activeWorkspaceId) return;
setIsLoading(true);
try {
const result = await listTasksByWorkspace(activeWorkspaceId, {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
query: debouncedQuery,
includeArchived: showArchived,
workflowId: activeWorkflowId,
repositoryId: selectedRepositoryId,
});
setTasks(result.tasks);
setTotal(result.total);
} catch (err) {
toast({
title: "Failed to load tasks",
description: err instanceof Error ? err.message : "Unknown error",
variant: "error",
});
} finally {
setIsLoading(false);
}
}, [
activeWorkspaceId,
activeWorkflowId,
selectedRepositoryId,
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
showArchived,
toast,
setTasks,
setTotal,
]);
const handleArchive = useCallback(
async (taskId: string, opts?: { cascade?: boolean }) => {
try {
await archiveTask(taskId, opts);
toast({ title: "Task archived", description: "The task has been archived successfully." });
fetchTasks();
} catch (err) {
toast({
title: "Failed to archive task",
description: err instanceof Error ? err.message : "Unknown error",
variant: "error",
});
}
},
[fetchTasks, toast],
);
const handleDelete = useCallback(
async (taskId: string, opts?: { cascade?: boolean }) => {
setDeletingTaskId(taskId);
try {
await deleteTask(taskId, opts);
fetchTasks();
} catch (err) {
toast({
title: "Failed to delete task",
description: err instanceof Error ? err.message : "Unknown error",
variant: "error",
});
} finally {
setDeletingTaskId(null);
}
},
[fetchTasks, toast],
);
return { isLoading, deletingTaskId, fetchTasks, handleArchive, handleDelete };
}
type TasksPageBodyProps = {
total: number;
showArchived: boolean;
setShowArchived: (show: boolean) => void;
columns: ReturnType<typeof getColumns>;
tasks: Task[];
pageCount: number;
pagination: PaginationState;
setPagination: (next: PaginationState | ((prev: PaginationState) => PaginationState)) => void;
isLoading: boolean;
handleRowClick: (task: Task) => void;
};
function TasksPageBody({
total,
showArchived,
setShowArchived,
columns,
tasks,
pageCount,
pagination,
setPagination,
isLoading,
handleRowClick,
}: TasksPageBodyProps) {
return (
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mx-auto max-w-5xl">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">All Tasks</h1>
<p className="text-sm text-muted-foreground">
{total} task{total !== 1 ? "s" : ""} found
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="show-archived"
checked={showArchived}
onCheckedChange={(checked) => setShowArchived(checked === true)}
/>
<Label htmlFor="show-archived" className="text-sm cursor-pointer">
Show archived
</Label>
</div>
</div>
<DataTable
columns={columns}
data={tasks}
pageCount={pageCount}
rowCount={total}
pagination={pagination}
onPaginationChange={setPagination}
isLoading={isLoading}
onRowClick={handleRowClick}
/>
</div>
</div>
);
}
type TaskCreateDialogMountProps = {
activeWorkspaceId: string | null;
defaultWorkflow: Workflow | undefined;
defaultStep: WorkflowStep | undefined;
createDialogOpen: boolean;
setCreateDialogOpen: (open: boolean) => void;
steps: WorkflowStep[];
fetchTasks: () => void;
};
function TaskCreateDialogMount({
activeWorkspaceId,
defaultWorkflow,
defaultStep,
createDialogOpen,
setCreateDialogOpen,
steps,
fetchTasks,
}: TaskCreateDialogMountProps) {
if (!activeWorkspaceId || !defaultWorkflow || !defaultStep) return null;
return (
<TaskCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
workspaceId={activeWorkspaceId}
workflowId={defaultWorkflow.id}
defaultStepId={defaultStep.id}
steps={steps
.filter((s) => s.workflow_id === defaultWorkflow.id)
.map((s) => ({ id: s.id, title: s.name, events: s.events }))}
onSuccess={() => {
setCreateDialogOpen(false);
fetchTasks();
}}
/>
);
}
function useTasksPageViewState({
initialWorkflows,
initialSteps,
initialRepositories,
initialTasks,
initialTotal,
storeRepositories,
}: {
initialWorkflows: Workflow[];
initialSteps: WorkflowStep[];
initialRepositories: Repository[];
initialTasks: Task[];
initialTotal: number;
storeRepositories: Repository[];
}) {
const [workflows] = useState(initialWorkflows);
const [steps] = useState(initialSteps);
const repositories = storeRepositories.length > 0 ? storeRepositories : initialRepositories;
const [tasks, setTasks] = useState(initialTasks);
const [total, setTotal] = useState(initialTotal);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 25 });
return {
workflows,
steps,
repositories,
tasks,
setTasks,
total,
setTotal,
createDialogOpen,
setCreateDialogOpen,
searchQuery,
setSearchQuery,
showArchived,
setShowArchived,
pagination,
setPagination,
};
}
function useTasksPageEffects({
debouncedQuery,
setPagination,
activeWorkspaceId,
fetchTasks,
pagination,
showArchived,
activeWorkflowId,
selectedRepositoryId,
initialDataLoaded = false,
}: {
debouncedQuery: string;
setPagination: (next: PaginationState | ((prev: PaginationState) => PaginationState)) => void;
activeWorkspaceId: string | null;
fetchTasks: () => void;
pagination: PaginationState;
showArchived: boolean;
activeWorkflowId: string | null;
selectedRepositoryId: string | null;
initialDataLoaded?: boolean;
}) {
const skippedInitialFetchRef = useRef(false);
useEffect(() => {
void Promise.resolve().then(() => setPagination((prev) => ({ ...prev, pageIndex: 0 })));
}, [debouncedQuery, activeWorkflowId, selectedRepositoryId, setPagination]);
useEffect(() => {
if (
shouldSkipInitialTasksFetch({
hasInitialData: initialDataLoaded,
alreadySkipped: skippedInitialFetchRef.current,
pageIndex: pagination.pageIndex,
debouncedQuery,
showArchived,
})
) {
skippedInitialFetchRef.current = true;
return;
}
if (activeWorkspaceId) fetchTasks();
}, [
activeWorkspaceId,
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
showArchived,
fetchTasks,
initialDataLoaded,
]);
}
function useTasksPageComputed({
total,
pagination,
workflows,
steps,
repositories,
handleArchive,
handleDelete,
deletingTaskId,
router,
activeWorkflowId,
}: {
total: number;
pagination: PaginationState;
workflows: Workflow[];
steps: WorkflowStep[];
repositories: Repository[];
handleArchive: (taskId: string) => Promise<void>;
handleDelete: (taskId: string) => Promise<void>;
deletingTaskId: string | null;
router: ReturnType<typeof useRouter>;
activeWorkflowId: string | null;
}) {
const pageCount = useMemo(
() => Math.ceil(total / pagination.pageSize),
[total, pagination.pageSize],
);
const columns = useMemo(
() =>
getColumns({
workflows,
steps,
repositories,
onArchive: handleArchive,
onDelete: handleDelete,
deletingTaskId,
}),
[workflows, steps, repositories, handleArchive, handleDelete, deletingTaskId],
);
const handleRowClick = useCallback(
(task: Task) => {
router.push(`/tasks/${task.id}`);
},
[router],
);
const defaultWorkflow = activeWorkflowId
? workflows.find((w) => w.id === activeWorkflowId)
: workflows[0];
const defaultStep = steps.find((s) => s.workflow_id === defaultWorkflow?.id);
return { pageCount, columns, handleRowClick, defaultWorkflow, defaultStep };
}
function useTasksPageSetup(props: TasksPageClientProps) {
const router = useRouter();
const {
activeWorkspaceId,
activeWorkflowId,
repositories: storeRepositories,
selectedRepositoryId,
} = useKanbanDisplaySettings();
const viewState = useTasksPageViewState({
initialWorkflows: props.initialWorkflows,
initialSteps: props.initialSteps,
initialRepositories: props.initialRepositories,
initialTasks: props.initialTasks,
initialTotal: props.initialTotal,
storeRepositories,
});
const debouncedQuery = useDebounce(viewState.searchQuery, 300);
const ops = useTaskOperations({
activeWorkspaceId,
activeWorkflowId,
selectedRepositoryId,
pagination: viewState.pagination,
debouncedQuery,
showArchived: viewState.showArchived,
setTasks: viewState.setTasks,
setTotal: viewState.setTotal,
});
useTasksPageEffects({
debouncedQuery,
setPagination: viewState.setPagination,
activeWorkspaceId,
fetchTasks: ops.fetchTasks,
pagination: viewState.pagination,
showArchived: viewState.showArchived,
activeWorkflowId,
selectedRepositoryId,
initialDataLoaded: props.initialDataLoaded,
});
const computed = useTasksPageComputed({
total: viewState.total,
pagination: viewState.pagination,
workflows: viewState.workflows,
steps: viewState.steps,
repositories: viewState.repositories,
handleArchive: ops.handleArchive,
handleDelete: ops.handleDelete,
deletingTaskId: ops.deletingTaskId,
router,
activeWorkflowId,
});
return { ...viewState, ...ops, ...computed, activeWorkspaceId, debouncedQuery };
}
export function TasksPageClient(props: TasksPageClientProps) {
const s = useTasksPageSetup(props);
const { isMobile } = useResponsiveBreakpoint();
const isMobileSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen);
const setMobileSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen);
// Collapse search on unmount so the global flag doesn't auto-open (and focus)
// the search bar after navigating to another route.
useEffect(() => () => setMobileSearchOpen(false), [setMobileSearchOpen]);
return (
<div className="h-screen w-full flex flex-col bg-background">
<KanbanHeader
workspaceId={s.activeWorkspaceId ?? undefined}
currentPage="tasks"
searchQuery={s.searchQuery}
onSearchChange={s.setSearchQuery}
isSearchLoading={s.isLoading && !!s.debouncedQuery}
/>
{isMobile && isMobileSearchOpen && (
<MobileSearchBar searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} />
)}
<TasksPageBody
showArchived={s.showArchived}
setShowArchived={s.setShowArchived}
columns={s.columns}
tasks={s.tasks}
total={s.total}
pageCount={s.pageCount}
pagination={s.pagination}
setPagination={s.setPagination}
isLoading={s.isLoading}
handleRowClick={s.handleRowClick}
/>
<TaskCreateDialogMount
activeWorkspaceId={s.activeWorkspaceId}
defaultWorkflow={s.defaultWorkflow}
defaultStep={s.defaultStep}
createDialogOpen={s.createDialogOpen}
setCreateDialogOpen={s.setCreateDialogOpen}
steps={s.steps}
fetchTasks={s.fetchTasks}
/>
</div>
);
}