FN-7380: Close task detail on delete
Close task detail hosts immediately after delete confirmation. - Wire the embedded list split-detail host to clear selected task state when detail content requests close. - Keep task-detail delete close idempotent across initial delete failures and follow-up prompts. - Add regression coverage for split-detail closing and optimistic delete failure handling. - Add a patch changeset for the operator-visible delete detail behavior. Files changed: .changeset/fn-7380-task-detail-delete-close.md | 7 ++++ packages/dashboard/app/components/ListView.tsx | 15 +++++++ .../dashboard/app/components/TaskDetailModal.tsx | 4 +- .../app/components/__tests__/ListView.test.tsx | 22 ++++++++++ .../components/__tests__/TaskDetailModal.test.tsx | 49 ++++++++++++++++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7380 Fusion-Task-Lineage: 0b7fa676-cf89-4116-aa64-c10840b6fb07 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7380-task-detail-delete-close.md
Normal file
7
.changeset/fn-7380-task-detail-delete-close.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Close task details immediately after confirming task deletion.
|
||||
category: fix
|
||||
dev: Updates shared task-detail delete close behavior and split-detail host wiring so detail shells close before delete requests settle.
|
||||
@@ -1846,6 +1846,20 @@ export function ListView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeEmbeddedTaskDetail = useCallback(() => {
|
||||
/*
|
||||
FNXC:TaskDetailDelete 2026-07-01-09:46:
|
||||
List split-detail is an embedded TaskDetailContent host, so optimistic delete close must clear the selected task synchronously and remove the persisted selection before the delete request settles. Clear any pending detail fetch so a delayed response cannot resurrect the closed split panel.
|
||||
*/
|
||||
detailFetchTargetRef.current = null;
|
||||
if (detailFetchTimerRef.current) {
|
||||
clearTimeout(detailFetchTimerRef.current);
|
||||
detailFetchTimerRef.current = null;
|
||||
}
|
||||
setSelectedTaskId(null);
|
||||
setSelectedTaskSnapshot(null);
|
||||
}, []);
|
||||
|
||||
const handleEmbeddedOpenDetail = useCallback((nextTask: Task | TaskDetail) => {
|
||||
setSelectedTaskId(nextTask.id);
|
||||
setSelectedTaskSnapshot(nextTask);
|
||||
@@ -2855,6 +2869,7 @@ export function ListView({
|
||||
projectId={projectId}
|
||||
tasks={tasks}
|
||||
embedded
|
||||
onRequestClose={closeEmbeddedTaskDetail}
|
||||
onOpenDetail={handleEmbeddedOpenDetail}
|
||||
onMoveTask={onMoveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
|
||||
@@ -1957,8 +1957,8 @@ export function TaskDetailContent({
|
||||
return;
|
||||
}
|
||||
/*
|
||||
FNXC:TaskDetailDelete 2026-06-23-10:55:
|
||||
Task detail hosts must close optimistically after the operator completes every required delete prompt and before the server delete request settles. Keep async success/error toasts attached to the delete promise so conflict handling and failure reporting continue after the modal, embedded panel, or floating host is gone.
|
||||
FNXC:TaskDetailDelete 2026-07-01-09:40:
|
||||
Task detail hosts must close optimistically after the operator completes every required delete prompt and before each server delete request starts. Keep this helper idempotent so dependency/lineage retries preserve async prompts and toasts without reopening or repeatedly closing the modal, main panel, list split, or right-dock host.
|
||||
*/
|
||||
requestClose();
|
||||
deleteCloseRequested = true;
|
||||
|
||||
@@ -161,12 +161,15 @@ vi.mock("../TaskDetailModal", () => ({
|
||||
TaskDetailContent: ({
|
||||
task,
|
||||
onOpenDetail,
|
||||
onRequestClose,
|
||||
}: {
|
||||
task: Task | TaskDetail;
|
||||
onOpenDetail?: (task: Task | TaskDetail) => void;
|
||||
onRequestClose?: () => void;
|
||||
}) => (
|
||||
<div data-testid="task-detail-content">
|
||||
<span>{task.id}</span>
|
||||
<button type="button" onClick={() => onRequestClose?.()}>Close detail</button>
|
||||
{(task.dependencies ?? []).map((dependencyId) => (
|
||||
<button
|
||||
key={dependencyId}
|
||||
@@ -1454,6 +1457,25 @@ describe("ListView", () => {
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clears the desktop split-detail shell when embedded detail requests close", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
fireEvent.click(screen.getByText("FN-001").closest("tr")!);
|
||||
expect(await screen.findByTestId("task-detail-content")).toHaveTextContent("FN-001");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close detail" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("task-detail-content")).toBeNull();
|
||||
expect(screen.getByText("Select a task to view details")).toBeInTheDocument();
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBeNull();
|
||||
});
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reloads persisted sidebar width when projectId changes", () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1000);
|
||||
|
||||
@@ -1019,6 +1019,55 @@ describe("TaskDetailModal delete affordance", () => {
|
||||
pendingRetry.resolve(makeTask());
|
||||
});
|
||||
|
||||
it("reports a delete failure after optimistic close without reopening or reclosing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const pendingDelete = createDeferred<ReturnType<typeof makeTask>>();
|
||||
const onDeleteTask = vi.fn(() => pendingDelete.promise);
|
||||
const addToast = vi.fn();
|
||||
const { onClose } = renderClosingTaskDetailModal({ onDeleteTask, addToast });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Delete task" }));
|
||||
|
||||
await waitFor(() => expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: false }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
pendingDelete.reject(new Error("delete failed"));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("delete failed", "error"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not reclose or retry when the force-delete prompt is cancelled after a conflict", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteTask = vi.fn().mockRejectedValueOnce(dependencyConflictError(["FN-200"]));
|
||||
const onRequestClose = vi.fn();
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
render(
|
||||
<TaskDetailContent
|
||||
initialTab="definition"
|
||||
embedded
|
||||
task={makeTask({ column: "triage" })}
|
||||
onRequestClose={onRequestClose}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Delete task" }));
|
||||
|
||||
await waitFor(() => expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Force Delete Task",
|
||||
})));
|
||||
expect(onDeleteTask).toHaveBeenCalledTimes(1);
|
||||
expect(onRequestClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the dialog open when the delete confirmation is cancelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteTask = vi.fn(async () => makeTask());
|
||||
|
||||
Reference in New Issue
Block a user