Files
kandev/apps/web/hooks/use-sidebar-multi-select.ts
T
2026-09-06 15:19:25 +01:00

195 lines
6.7 KiB
TypeScript

"use client";
import { useCallback, useEffect, useReducer, useState, type Dispatch } from "react";
import {
INITIAL_STATE,
multiSelectReducer,
useTaskMultiSelectStore,
} from "./use-task-multi-select";
import {
useTaskActions,
useArchiveAndSwitchTask,
type TaskActionOptions,
} from "./use-task-actions";
import { useTaskRemoval } from "./use-task-removal";
import { useTaskWorkflowMove } from "./use-task-workflow-move";
import { useToast } from "@/components/toast-provider";
import { useAppStoreApi } from "@/components/state-provider";
import { useTranslation } from "react-i18next";
type BulkOpts = TaskActionOptions;
/**
* Bulk archive/delete/move that act on an explicit id list. Archive and delete
* share one flow: remove each task, routing the currently-open task through the
* switch-aware path last so the view doesn't strand on removed content; keep any
* failed ids selected for retry and surface the failure via toast.
*/
function useSidebarBulkActions(
dispatch: Dispatch<{ type: "set_selected"; ids: Set<string> }>,
clearSelection: () => void,
) {
const store = useAppStoreApi();
const { archiveTaskById, deleteTaskById } = useTaskActions();
const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true });
const { removeTaskFromBoard } = useTaskRemoval({ store, useLayoutSwitch: true });
const { getWorkflowIdForTask, removeTasksFromStore } = useTaskMultiSelectStore();
const moveTasks = useTaskWorkflowMove();
const { toast } = useToast();
const { t } = useTranslation();
const [isArchiving, setIsArchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const runBulkRemoval = useCallback(
async (
ids: string[],
per: (id: string) => Promise<void>,
handleActive: (id: string) => Promise<void>,
setBusy: (v: boolean) => void,
failureKey: string,
) => {
if (ids.length === 0) return;
const activeId = store.getState().tasks.activeTaskId;
const activeInSet = activeId != null && ids.includes(activeId);
const restIds = activeInSet ? ids.filter((id) => id !== activeId) : ids;
setBusy(true);
try {
const results = await Promise.allSettled(restIds.map((id) => per(id)));
const failed = restIds.filter((_, i) => results[i].status === "rejected");
const succeeded = restIds.filter((_, i) => results[i].status === "fulfilled");
if (succeeded.length > 0) removeTasksFromStore(new Set(succeeded));
if (activeInSet) {
try {
await handleActive(activeId!);
} catch {
failed.push(activeId!);
}
}
if (failed.length > 0) {
// Keep the failed ids selected so the user can retry, and surface it.
dispatch({ type: "set_selected", ids: new Set(failed) });
toast({
title: t(failureKey, { count: failed.length }),
variant: "error",
});
return;
}
clearSelection();
} finally {
setBusy(false);
}
},
[store, removeTasksFromStore, dispatch, toast, clearSelection, t],
);
const bulkArchive = useCallback(
(ids: string[], opts?: BulkOpts) =>
runBulkRemoval(
ids,
(id) => archiveTaskById(id, opts),
(id) => archiveAndSwitch(id, opts),
setIsArchiving,
"sidebar:bulkArchiveFailed",
),
[runBulkRemoval, archiveTaskById, archiveAndSwitch],
);
const bulkDelete = useCallback(
(ids: string[], opts?: BulkOpts) =>
runBulkRemoval(
ids,
(id) => deleteTaskById(id, opts),
async (id) => {
const { activeTaskId, activeSessionId } = store.getState().tasks;
await deleteTaskById(id, opts);
await removeTaskFromBoard(id, {
wasActiveTaskId: activeTaskId,
wasActiveSessionId: activeSessionId,
});
},
setIsDeleting,
"sidebar:bulkDeleteFailed",
),
[runBulkRemoval, deleteTaskById, removeTaskFromBoard, store],
);
const bulkMove = useCallback(
async (ids: string[], targetWorkflowId: string, targetStepId: string) => {
if (ids.length === 0) return;
try {
const destination = ids.every((taskId) => getWorkflowIdForTask(taskId) === targetWorkflowId)
? "step"
: "workflow";
await moveTasks(ids, targetWorkflowId, targetStepId, destination);
clearSelection();
} catch {
// useTaskWorkflowMove already toasts the failure; keep the rows selected
// for retry and swallow the rejection so it isn't unhandled at the
// fire-and-forget call site.
}
},
[moveTasks, clearSelection, getWorkflowIdForTask],
);
return { bulkArchive, bulkDelete, bulkMove, isArchiving, isDeleting };
}
/**
* Sidebar task multi-select. Reuses the shared selection reducer (toggle +
* shift-range with an anchor) from `use-task-multi-select`, and exposes the
* selection state plus bulk archive/delete/move used by the right-click menu.
*
* Unlike the kanban hook this spans all workflows in a workspace, so it resets
* on workspace change and the caller passes the target workflow for moves.
*/
export function useSidebarMultiSelect(workspaceId: string | null) {
const [state, dispatch] = useReducer(multiSelectReducer, INITIAL_STATE);
const { selectedIds } = state;
// Selection is ephemeral per workspace; drop it when the workspace changes.
useEffect(() => {
dispatch({ type: "reset" });
}, [workspaceId]);
const toggleSelect = useCallback(
(taskId: string) => dispatch({ type: "toggle_select", taskId }),
[],
);
const selectRange = useCallback(
(taskId: string, orderedIds: string[]) =>
dispatch({ type: "select_range", taskId, orderedIds }),
[],
);
const clearSelection = useCallback(() => dispatch({ type: "set_selected", ids: new Set() }), []);
// Drop any selected ids that are no longer visible (collapsed group / filtered
// out) so `isSelecting` doesn't stay latched on hidden rows. No-ops (and avoids
// a render loop) when every selected id is still visible.
const pruneToVisible = useCallback(
(visibleIds: string[]) => {
const visible = new Set(visibleIds);
const next = new Set([...selectedIds].filter((id) => visible.has(id)));
if (next.size !== selectedIds.size) dispatch({ type: "set_selected", ids: next });
},
[selectedIds],
);
const bulk = useSidebarBulkActions(dispatch, clearSelection);
return {
selectedIds,
isSelecting: selectedIds.size > 0,
isArchiving: bulk.isArchiving,
isDeleting: bulk.isDeleting,
toggleSelect,
selectRange,
clearSelection,
pruneToVisible,
bulkArchive: bulk.bulkArchive,
bulkDelete: bulk.bulkDelete,
bulkMove: bulk.bulkMove,
};
}