Files
kandev/apps/web/hooks/use-nest-task.ts
T
João Salavisa ce82e2a4ab fix(i18n): externalize remaining frontend copy (#2616)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-08-13 11:11:46 +01:00

137 lines
5.1 KiB
TypeScript

"use client";
import { useCallback } from "react";
import { toast } from "@/lib/toast/sonner";
import { detachTask, updateTask } from "@/lib/api";
import { useAppStoreApi } from "@/components/state-provider";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
import { t } from "@/lib/i18n";
type StoreApi = ReturnType<typeof useAppStoreApi>;
type SnapshotTask = WorkflowSnapshotData["tasks"][number];
/**
* Resolves the workflow id a task belongs to from the multi-workflow
* snapshots. Snapshot tasks carry no workflow_id themselves; the snapshot
* key is the workflow. Returns null when the task is not in any snapshot
* (the caller then skips the optimistic patch and lets the WS event
* reconcile state).
*/
export function taskWorkflowIdFromSnapshots(store: StoreApi, taskId: string): string | null {
const snapshots = store.getState().kanbanMulti?.snapshots ?? {};
return (
Object.keys(snapshots).find((wf) => snapshots[wf]?.tasks.some((t) => t.id === taskId)) ?? null
);
}
// One re-parent operation's context, threaded to the optimistic helpers.
type NestOp = {
store: StoreApi;
workflowId: string;
taskId: string;
snapshot: WorkflowSnapshotData | undefined;
original: SnapshotTask | undefined;
nextParent: string | undefined;
};
/** Shallow-clone a snapshot with `taskId`'s parent set to `parent`. */
function snapshotWithParent(
snapshot: WorkflowSnapshotData,
taskId: string,
parent: string | undefined,
): WorkflowSnapshotData {
return {
...snapshot,
tasks: snapshot.tasks.map((t) => (t.id === taskId ? { ...t, parentTaskId: parent } : t)),
};
}
/**
* Apply the optimistic parent change to the workflow snapshot. No-op when the
* multi snapshot / task row isn't available (initial-load fallback state) —
* the request is still sent and the WS event reconciles state.
*/
function applyOptimistic(op: NestOp): void {
if (!op.snapshot || !op.original) return;
op.store
.getState()
.setWorkflowSnapshot(op.workflowId, snapshotWithParent(op.snapshot, op.taskId, op.nextParent));
}
/**
* Roll the optimistic parent change back to its original value, but only when
* we actually applied one and the task still holds the optimistic value (a
* concurrent WS update may have already reconciled it).
*/
function rollbackParent(op: NestOp): void {
if (!op.snapshot || !op.original) return;
const cur = op.store.getState().kanbanMulti?.snapshots?.[op.workflowId];
const curTask = cur?.tasks.find((t) => t.id === op.taskId);
if (cur && (curTask?.parentTaskId ?? undefined) === op.nextParent) {
const restored = snapshotWithParent(cur, op.taskId, op.original.parentTaskId ?? undefined);
op.store.getState().setWorkflowSnapshot(op.workflowId, restored);
}
}
/**
* useNestTaskByDrag returns the drag-drop nesting action used by the sidebar
* and the mobile task sheet: it resolves the dragged task's workflow from the
* snapshot keys (snapshot tasks carry no workflow_id themselves) and runs the
* same composite nest operation the context menu uses. No-ops when the task
* is not in any snapshot (the WS event reconciles state either way).
*/
export function useNestTaskByDrag() {
const store = useAppStoreApi();
const nestTask = useNestTask();
return useCallback(
(taskId: string, parentTaskId: string) => {
const workflowId = taskWorkflowIdFromSnapshots(store, taskId);
if (!workflowId) return;
void nestTask(taskId, workflowId, parentTaskId);
},
[store, nestTask],
);
}
/**
* useNestTask returns a function that nests a task under a parent (or un-nests
* it when `parentId` is null). The multi-workflow snapshot may not be
* populated yet (e.g. initial /t/:id load renders the sidebar from the active
* kanban), so the optimistic snapshot patch is best-effort: the request is
* always sent and the `task.updated` WS event reconciles state either way.
*/
export function useNestTask() {
const store = useAppStoreApi();
return useCallback(
async (taskId: string, workflowId: string, parentId: string | null) => {
const nextParent = parentId ?? undefined;
const snapshot = store.getState().kanbanMulti?.snapshots?.[workflowId];
const original = snapshot?.tasks.find((t) => t.id === taskId);
const op: NestOp = { store, workflowId, taskId, snapshot, original, nextParent };
// No-op only when we can see the current parent and it already matches.
if (original && (original.parentTaskId ?? undefined) === nextParent) return;
applyOptimistic(op);
try {
if (parentId === null) {
// Un-nesting goes through the dedicated detach endpoint: unlike a
// plain parent_id clear, it also normalizes an inherit_parent
// subtask's workspace mode back to shared_group and emits the Office
// task-update event, so the promoted root isn't left marked as
// parent-inherited.
await detachTask(taskId);
} else {
await updateTask(taskId, { parent_id: parentId });
}
} catch (error) {
rollbackParent(op);
toast.error(error instanceof Error ? error.message : t("task:failedToNestTask"));
}
},
[store],
);
}