diff --git a/plugins/tasks/views/manage/manage-panel.tsx b/plugins/tasks/views/manage/manage-panel.tsx index 0d0d544ad8..83528d4cca 100644 --- a/plugins/tasks/views/manage/manage-panel.tsx +++ b/plugins/tasks/views/manage/manage-panel.tsx @@ -440,11 +440,13 @@ function FolderRow({ rootFolders, onRename, onMove, + onDelete, }: { folder: Folder; rootFolders: Folder[]; onRename: (name: string) => Promise; onMove: (parentFolderId: string | null) => Promise; + onDelete: () => void; }) { const [renaming, setRenaming] = useState(false); const [draftName, setDraftName] = useState(folder.name); @@ -524,6 +526,15 @@ function FolderRow({ > + )} @@ -533,7 +544,9 @@ function FolderRow({ function FoldersSection() { const rpc = useTasksRpc(); const folders = useFolders(); + const projects = useProjects(); const [error, setError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); const folderList = folders.data ?? []; // The sidebar nests folders one level deep, so only roots can be parents. const rootFolders = useMemo( @@ -550,6 +563,29 @@ function FoldersSection() { } }; + // Deleting a folder only unfiles what it held: the schema's ON DELETE SET + // NULL moves its projects and subfolders to the top level. Nothing else is + // removed, so the confirmation names the move rather than warning about loss. + function describeDeleteImpact(folder: Folder): string { + const projectCount = (projects.data ?? []).filter( + (project) => project.folderId === folder.id, + ).length; + const subfolderCount = folderList.filter( + (entry) => entry.parentFolderId === folder.id, + ).length; + const moved = [ + projectCount > 0 + ? `${projectCount} project${projectCount > 1 ? "s" : ""}` + : null, + subfolderCount > 0 + ? `${subfolderCount} subfolder${subfolderCount > 1 ? "s" : ""}` + : null, + ].filter((part) => part !== null); + return moved.length === 0 + ? "The folder is empty." + : `${moved.join(" and ")} move to the top level. No tasks are deleted.`; + } + return (
{folderList.length === 0 ? ( @@ -576,6 +612,10 @@ function FoldersSection() { }), ) } + onDelete={() => { + setError(null); + setConfirmDelete(folder); + }} /> ))}
@@ -585,6 +625,22 @@ function FoldersSection() { {error}

) : null} + { + if (!open) setConfirmDelete(null); + }} + title={`Delete folder “${confirmDelete?.name ?? ""}”?`} + description={confirmDelete ? describeDeleteImpact(confirmDelete) : ""} + confirmLabel="Delete folder" + destructive + onConfirm={() => { + const target = confirmDelete; + if (target) { + void run(() => rpc.call("deleteFolder", { folderId: target.id })); + } + }} + /> ); } diff --git a/plugins/tasks/views/manage/manage.test.tsx b/plugins/tasks/views/manage/manage.test.tsx index 86beb529d3..c2177b03b8 100644 --- a/plugins/tasks/views/manage/manage.test.tsx +++ b/plugins/tasks/views/manage/manage.test.tsx @@ -750,6 +750,78 @@ describe("PresetDialog environment section", () => { }); }); +describe("Manage folders", () => { + const parentFolder = { + id: "01HZZZZZZZZZZZZZZZZZZZZZF1", + name: "bb", + parentFolderId: null, + createdAt: "2026-07-15T00:00:00.000Z", + }; + const childFolder = { + id: "01HZZZZZZZZZZZZZZZZZZZZZF2", + name: "archive", + parentFolderId: parentFolder.id, + createdAt: "2026-07-15T00:00:00.000Z", + }; + + function renderFolders(overrides: Record = {}) { + return renderSlot( + app.navPanels[0]!, + { subPath: "manage" }, + { + rpc: { + listProjects: () => ({ + projects: [{ ...project, folderId: parentFolder.id }], + }), + listFolders: () => ({ folders: [parentFolder, childFolder] }), + listPresets: () => ({ presets: [] }), + sidebarSummary: () => ({ projects: [] }), + listTasks: () => ({ tasks: [] }), + listLabels: () => ({ labels: [] }), + ...overrides, + }, + }, + ); + } + + it("deletes a folder after naming what the delete unfiles", async () => { + const deleteCalls: Array> = []; + const slot = renderFolders({ + deleteFolder: (input: Record) => { + deleteCalls.push(input); + return { deleted: true }; + }, + }); + fireEvent.mouseDown(await slot.findByRole("tab", { name: "Folders" })); + fireEvent.click( + await slot.findByRole("button", { name: "Delete folder bb" }), + ); + + // Nothing is destroyed: the schema re-parents the folder's contents. + await slot.findByText( + "1 project and 1 subfolder move to the top level. No tasks are deleted.", + ); + fireEvent.click(slot.getByRole("button", { name: "Delete folder" })); + await waitFor(() => expect(deleteCalls).toHaveLength(1)); + expect(deleteCalls[0]).toMatchObject({ folderId: parentFolder.id }); + }); + + it("surfaces a failed delete instead of silently closing", async () => { + const slot = renderFolders({ + deleteFolder: () => { + throw new Error("Folder not found"); + }, + }); + fireEvent.mouseDown(await slot.findByRole("tab", { name: "Folders" })); + fireEvent.click( + await slot.findByRole("button", { name: "Delete folder archive" }), + ); + await slot.findByText("The folder is empty."); + fireEvent.click(slot.getByRole("button", { name: "Delete folder" })); + await slot.findByRole("alert"); + }); +}); + describe("NewProjectDialog", () => { function renderEmptyState(overrides: Record = {}) { return renderSlot(