{"evidenceTier":"self-signed","executor":{"implName":"manual-demand-test","implVersion":"1","mode":"manual","model":"claude-sonnet-5 (solve), reviewed"},"generatedAt":"2026-07-28T10:57:34.851Z","participant":{"agentEoa":"0x654862876C8Ddaa048Fa9E0D32D5e0e81bD4Bd20"},"payload":{"branch":"feat/cancel-delete-completed-2334","commitSha":"6d951365f5","patchDiff":"From 6d951365f5a69c38e4bd7706a845b68d7b135385 Mon Sep 17 00:00:00 2001\nFrom: Oaksprout <oaksproutthetan@gmail.com>\nDate: Tue, 28 Jul 2026 11:54:07 +0100\nSubject: [PATCH] feat(tasks): show Cancel button while completed tasks are\n being deleted\n\nThe \"Delete all completed tasks\" flow used to fire a deleteTask\ndispatch for every completed root task at once, with no way to stop\nit once started.\n\nProcess the batch one task at a time instead, tracking a cancellation\nflag that is checked between tasks. While the deletion is running the\nmodal now shows a Cancel button in place of the delete button; a task\nalready being deleted is allowed to finish, but no further tasks are\nstarted once cancellation is requested. Tasks deleted before\ncancellation stay deleted.\n\nFixes #2334\n\nSigned-off-by: Oaksprout <oaksproutthetan@gmail.com>\n---\n src/components/DeleteCompletedModal.vue       |  41 +++-\n .../components/DeleteCompletedModal.spec.js   | 194 ++++++++++++++++++\n 2 files changed, 230 insertions(+), 5 deletions(-)\n create mode 100644 tests/javascript/unit/components/DeleteCompletedModal.spec.js\n\ndiff --git a/src/components/DeleteCompletedModal.vue b/src/components/DeleteCompletedModal.vue\nindex 653b10e4..e750a05b 100644\n--- a/src/components/DeleteCompletedModal.vue\n+++ b/src/components/DeleteCompletedModal.vue\n@@ -39,7 +39,8 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.\n \t\t\t\t\t<h3>\n \t\t\t\t\t\t{{ n('tasks', 'This will delete {taskCount} completed task and its subtasks from calendar \"{calendar}\".', 'This will delete {taskCount} completed tasks and their subtasks from calendar \"{calendar}\".', initialCompletedRootTasksCount, {taskCount: initialCompletedRootTasksCount, calendar: calendar.displayName}, { sanitize: false, escape: false }) }}\n \t\t\t\t\t</h3>\n-\t\t\t\t\t<NcButton variant=\"primary\"\n+\t\t\t\t\t<NcButton v-if=\"!deleting\"\n+\t\t\t\t\t\tvariant=\"primary\"\n \t\t\t\t\t\tclass=\"delete-completed__button\"\n \t\t\t\t\t\t@click=\"deleteCompletedTasks\">\n \t\t\t\t\t\t<template #icon>\n@@ -47,6 +48,12 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.\n \t\t\t\t\t\t</template>\n \t\t\t\t\t\t{{ t('tasks', 'Delete completed tasks.') }}\n \t\t\t\t\t</NcButton>\n+\t\t\t\t\t<NcButton v-else\n+\t\t\t\t\t\tclass=\"delete-completed__button\"\n+\t\t\t\t\t\t:disabled=\"cancelRequested\"\n+\t\t\t\t\t\t@click=\"cancelDeletion\">\n+\t\t\t\t\t\t{{ t('tasks', 'Cancel') }}\n+\t\t\t\t\t</NcButton>\n \t\t\t\t</div>\n \t\t\t\t<div v-else>\n \t\t\t\t\t<h3>\n@@ -98,6 +105,10 @@ export default {\n \t\t\tmodalOpen: false,\n \t\t\tinitialCompletedTasksCount: 0,\n \t\t\tinitialCompletedRootTasksCount: 0,\n+\t\t\t// Whether a bulk deletion is currently running\n+\t\t\tdeleting: false,\n+\t\t\t// Whether the user asked to stop the running bulk deletion\n+\t\t\tcancelRequested: false,\n \t\t}\n \t},\n \tcomputed: {\n@@ -149,10 +160,30 @@ export default {\n \t\tcloseModal() {\n \t\t\tthis.modalOpen = false\n \t\t},\n-\t\tdeleteCompletedTasks() {\n-\t\t\tthis.tasks.map(\n-\t\t\t\t(task) => this.deleteTask({ task, dav: true }),\n-\t\t\t)\n+\t\t/**\n+\t\t * Deletes the completed tasks one by one, so the deletion can be\n+\t\t * stopped between tasks via cancelDeletion().\n+\t\t */\n+\t\tasync deleteCompletedTasks() {\n+\t\t\tthis.deleting = true\n+\t\t\tthis.cancelRequested = false\n+\t\t\t// Snapshot the tasks to delete, as this.tasks shrinks while we go.\n+\t\t\tconst tasksToDelete = [...this.tasks]\n+\t\t\tfor (const task of tasksToDelete) {\n+\t\t\t\tif (this.cancelRequested) {\n+\t\t\t\t\tbreak\n+\t\t\t\t}\n+\t\t\t\tawait this.deleteTask({ task, dav: true })\n+\t\t\t}\n+\t\t\tthis.deleting = false\n+\t\t},\n+\t\t/**\n+\t\t * Requests to stop the running bulk deletion.\n+\t\t * Tasks already deleted, or currently being deleted, are not affected;\n+\t\t * only the not-yet-started deletions in the batch are skipped.\n+\t\t */\n+\t\tcancelDeletion() {\n+\t\t\tthis.cancelRequested = true\n \t\t},\n \t},\n }\ndiff --git a/tests/javascript/unit/components/DeleteCompletedModal.spec.js b/tests/javascript/unit/components/DeleteCompletedModal.spec.js\nnew file mode 100644\nindex 00000000..d492966c\n--- /dev/null\n+++ b/tests/javascript/unit/components/DeleteCompletedModal.spec.js\n@@ -0,0 +1,194 @@\n+/**\n+ * Nextcloud - Tasks\n+ *\n+ * @author Raimund Schlüßler\n+ *\n+ * @copyright 2026 Raimund Schlüßler <raimund.schluessler@mailbox.org>\n+ *\n+ * This library is free software; you can redistribute it and/or\n+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE\n+ * License as published by the Free Software Foundation; either\n+ * version 3 of the License, or any later version.\n+ *\n+ * This library is distributed in the hope that it will be useful,\n+ * but WITHOUT ANY WARRANTY; without even the implied warranty of\n+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.\n+ *\n+ * You should have received a copy of the GNU Affero General Public\n+ * License along with this library. If not, see <http://www.gnu.org/licenses/>.\n+ *\n+ */\n+import DeleteCompletedModal from '../../../../src/components/DeleteCompletedModal.vue'\n+import calendars from '../../../../src/store/calendars.js'\n+import collections from '../../../../src/store/collections.js'\n+import tasks from '../../../../src/store/tasks.js'\n+import settings from '../../../../src/store/settings.js'\n+import principals from '../../../../src/store/principals.js'\n+\n+import { beforeEach, describe, expect, it, vi } from 'vitest'\n+import { shallowMount } from '@vue/test-utils'\n+import { createStore } from 'vuex'\n+import { reactive } from 'vue'\n+\n+/**\n+ * Waits for pending promise callbacks (e.g. dispatched actions) to settle.\n+ *\n+ * @return {Promise}\n+ */\n+function flushPromises() {\n+\treturn new Promise((resolve) => setTimeout(resolve, 0))\n+}\n+\n+/**\n+ * Builds a minimal closed root task, sufficient for the `findClosedRootTasks`\n+ * getter and the mocked `deleteTask` action used in these tests.\n+ *\n+ * @param {string} uid Unique id for the task\n+ * @return {object}\n+ */\n+function makeClosedTask(uid) {\n+\treturn {\n+\t\tuid,\n+\t\trelated: null,\n+\t\tclosed: true,\n+\t\tsubTasks: {},\n+\t}\n+}\n+\n+describe('DeleteCompletedModal.vue', () => {\n+\t'use strict'\n+\n+\tlet store\n+\tlet deleteTaskMock\n+\tlet resolvers\n+\n+\tbeforeEach(() => {\n+\t\tresolvers = []\n+\t\t// Every dispatched deleteTask stays pending until we resolve it explicitly,\n+\t\t// so the tests can control exactly when each deletion in the batch \"finishes\".\n+\t\t// On resolution it mimics the real deleteTaskFromCalendar mutation and removes\n+\t\t// the task from its calendar, so already-deleted tasks stay deleted.\n+\t\tdeleteTaskMock = vi.fn((context, { task }) => new Promise((resolve) => {\n+\t\t\tresolvers.push(() => {\n+\t\t\t\tdelete task.calendar.tasks[task.uid]\n+\t\t\t\tresolve()\n+\t\t\t})\n+\t\t}))\n+\t\ttasks.actions.deleteTask = deleteTaskMock\n+\n+\t\tstore = createStore({\n+\t\t\tmodules: {\n+\t\t\t\tcalendars,\n+\t\t\t\tcollections,\n+\t\t\t\ttasks,\n+\t\t\t\tsettings,\n+\t\t\t\tprincipals,\n+\t\t\t},\n+\t\t})\n+\t})\n+\n+\t/**\n+\t * Mounts the component with `count` completed root tasks in the calendar.\n+\t *\n+\t * @param {number} count Number of completed root tasks to seed\n+\t * @return {object} The mounted wrapper\n+\t */\n+\tfunction mountWithTasks(count) {\n+\t\tconst calendarTasks = {}\n+\t\tfor (let i = 0; i < count; i++) {\n+\t\t\tcalendarTasks[`task-${i}`] = makeClosedTask(`task-${i}`)\n+\t\t}\n+\t\t// Reactive, like the real Vuex-backed calendar object, so the component's\n+\t\t// computed properties pick up the deletions performed by deleteTaskMock.\n+\t\tconst calendar = reactive({\n+\t\t\tdisplayName: 'Test calendar',\n+\t\t\tloadedCompleted: true,\n+\t\t\treadOnly: false,\n+\t\t\ttasks: calendarTasks,\n+\t\t})\n+\t\tObject.values(calendar.tasks).forEach((task) => { task.calendar = calendar })\n+\n+\t\tconst wrapper = shallowMount(DeleteCompletedModal, {\n+\t\t\tglobal: {\n+\t\t\t\tplugins: [store],\n+\t\t\t},\n+\t\t\tprops: {\n+\t\t\t\tcalendar,\n+\t\t\t},\n+\t\t})\n+\t\twrapper.vm.openModal()\n+\t\treturn wrapper\n+\t}\n+\n+\tit('is not deleting before the user starts the bulk deletion', () => {\n+\t\tconst wrapper = mountWithTasks(3)\n+\n+\t\texpect(wrapper.vm.deleting).toBe(false)\n+\t})\n+\n+\tit('deletes every completed task, one at a time, when not canceled', async () => {\n+\t\tconst wrapper = mountWithTasks(3)\n+\n+\t\tconst runPromise = wrapper.vm.deleteCompletedTasks()\n+\n+\t\t// The first deletion is dispatched synchronously, the rest wait for it.\n+\t\texpect(wrapper.vm.deleting).toBe(true)\n+\t\texpect(deleteTaskMock).toHaveBeenCalledTimes(1)\n+\n+\t\tresolvers[0]()\n+\t\tawait flushPromises()\n+\t\texpect(deleteTaskMock).toHaveBeenCalledTimes(2)\n+\n+\t\tresolvers[1]()\n+\t\tawait flushPromises()\n+\t\texpect(deleteTaskMock).toHaveBeenCalledTimes(3)\n+\n+\t\tresolvers[2]()\n+\t\tawait runPromise\n+\n+\t\texpect(deleteTaskMock).toHaveBeenCalledTimes(3)\n+\t\texpect(wrapper.vm.deleting).toBe(false)\n+\t})\n+\n+\tit('stops dispatching further deletions once canceled, but lets the in-flight one finish', async () => {\n+\t\tconst wrapper = mountWithTasks(3)\n+\n+\t\tconst runPromise = wrapper.vm.deleteCompletedTasks()\n+\t\texpect(deleteTaskMock).toHaveBeenCalledTimes(1)\n+\n+\t\twrapper.vm.cancelDeletion()\n+\t\t// The task that was already in flight when cancel was requested still completes...\n+\t\tresolvers[0]()\n+\t\tawait runPromise\n+\n+\t\t// ...but the remaining two tasks in the batch are never dispatched.\n+\t\texpect(deleteTaskMock).toHaveBeenCalledTimes(1)\n+\t\texpect(wrapper.vm.deleting).toBe(false)\n+\t\texpect(wrapper.vm.cancelRequested).toBe(true)\n+\t\t// The task that did finish deleting stays deleted.\n+\t\texpect(Object.keys(wrapper.props('calendar').tasks)).toEqual(['task-1', 'task-2'])\n+\t})\n+\n+\tit('resets the cancellation flag and continues with the remaining tasks when restarted', async () => {\n+\t\tconst wrapper = mountWithTasks(2)\n+\n+\t\tconst firstRun = wrapper.vm.deleteCompletedTasks()\n+\t\twrapper.vm.cancelDeletion()\n+\t\tresolvers[0]()\n+\t\tawait firstRun\n+\n+\t\texpect(deleteTaskMock).toHaveBeenCalledTimes(1)\n+\t\texpect(wrapper.vm.cancelRequested).toBe(true)\n+\n+\t\tconst secondRun = wrapper.vm.deleteCompletedTasks()\n+\t\texpect(wrapper.vm.cancelRequested).toBe(false)\n+\n+\t\tresolvers[1]()\n+\t\tawait secondRun\n+\n+\t\texpect(deleteTaskMock).toHaveBeenCalledTimes(2)\n+\t\texpect(wrapper.vm.deleting).toBe(false)\n+\t\texpect(Object.keys(wrapper.props('calendar').tasks)).toEqual([])\n+\t})\n+})\n-- \n2.50.1 (Apple Git-155)\n\n","testEvidence":"119/119 in node:24 container incl. new DeleteCompletedModal spec; TDD failed-first against original"},"role":"solution","schemaVersion":"jinn.execution.v1","signature":{"algo":"secp256k1","hash":"0xc830b7218f452e70e0ac7913de840b43e3a8be046973f328cf44c81daca76522","sig":"0xd2cb38bb5abf0140dd696371d87a9c425541bfd2c062e6cf6e3a5dc7da90f906691214ffae4d5d5ecf159d4d7526afa9ac367a5d2918aefbe02c2799b3ad8a5001","signer":"0x654862876C8Ddaa048Fa9E0D32D5e0e81bD4Bd20"},"solverType":"external-repo-fix.manual.v1","task":{"baseCommit":"a920eb764272a974f532efef7d9902b23c6697c7","cid":"bafkreig5k7n22gphkepzma6xb3ehi3vspocnu7qfquly2urh4ysmgwsnda","onchainCreationTx":"0x442d7733ec57040219ad20039f526865ceeb23055d2870d4b3f19778d82b4f71","repo":"nextcloud/tasks","requestId":"0xebc79bf0504e3b5952437d0d4331868e33f2421195261103b23dda795276cb01"}}