ce82e2a4ab
Co-authored-by: Kandev Agent <agent@kandev.dev>
137 lines
5.1 KiB
TypeScript
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],
|
|
);
|
|
}
|