diff --git a/src/components/DeleteCompletedModal.vue b/src/components/DeleteCompletedModal.vue
index 653b10e4f..e750a05b0 100644
--- a/src/components/DeleteCompletedModal.vue
+++ b/src/components/DeleteCompletedModal.vue
@@ -39,7 +39,8 @@ License along with this library. If not, see .
{{ 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 }) }}
-
@@ -47,6 +48,12 @@ License along with this library. If not, see .
{{ t('tasks', 'Delete completed tasks.') }}
+
+ {{ t('tasks', 'Cancel') }}
+
@@ -98,6 +105,10 @@ export default {
modalOpen: false,
initialCompletedTasksCount: 0,
initialCompletedRootTasksCount: 0,
+ // Whether a bulk deletion is currently running
+ deleting: false,
+ // Whether the user asked to stop the running bulk deletion
+ cancelRequested: false,
}
},
computed: {
@@ -149,10 +160,30 @@ export default {
closeModal() {
this.modalOpen = false
},
- deleteCompletedTasks() {
- this.tasks.map(
- (task) => this.deleteTask({ task, dav: true }),
- )
+ /**
+ * Deletes the completed tasks one by one, so the deletion can be
+ * stopped between tasks via cancelDeletion().
+ */
+ async deleteCompletedTasks() {
+ this.deleting = true
+ this.cancelRequested = false
+ // Snapshot the tasks to delete, as this.tasks shrinks while we go.
+ const tasksToDelete = [...this.tasks]
+ for (const task of tasksToDelete) {
+ if (this.cancelRequested) {
+ break
+ }
+ await this.deleteTask({ task, dav: true })
+ }
+ this.deleting = false
+ },
+ /**
+ * Requests to stop the running bulk deletion.
+ * Tasks already deleted, or currently being deleted, are not affected;
+ * only the not-yet-started deletions in the batch are skipped.
+ */
+ cancelDeletion() {
+ this.cancelRequested = true
},
},
}
diff --git a/tests/javascript/unit/components/DeleteCompletedModal.spec.js b/tests/javascript/unit/components/DeleteCompletedModal.spec.js
new file mode 100644
index 000000000..d492966c0
--- /dev/null
+++ b/tests/javascript/unit/components/DeleteCompletedModal.spec.js
@@ -0,0 +1,194 @@
+/**
+ * Nextcloud - Tasks
+ *
+ * @author Raimund Schlüßler
+ *
+ * @copyright 2026 Raimund Schlüßler
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this library. If not, see .
+ *
+ */
+import DeleteCompletedModal from '../../../../src/components/DeleteCompletedModal.vue'
+import calendars from '../../../../src/store/calendars.js'
+import collections from '../../../../src/store/collections.js'
+import tasks from '../../../../src/store/tasks.js'
+import settings from '../../../../src/store/settings.js'
+import principals from '../../../../src/store/principals.js'
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+import { createStore } from 'vuex'
+import { reactive } from 'vue'
+
+/**
+ * Waits for pending promise callbacks (e.g. dispatched actions) to settle.
+ *
+ * @return {Promise}
+ */
+function flushPromises() {
+ return new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+/**
+ * Builds a minimal closed root task, sufficient for the `findClosedRootTasks`
+ * getter and the mocked `deleteTask` action used in these tests.
+ *
+ * @param {string} uid Unique id for the task
+ * @return {object}
+ */
+function makeClosedTask(uid) {
+ return {
+ uid,
+ related: null,
+ closed: true,
+ subTasks: {},
+ }
+}
+
+describe('DeleteCompletedModal.vue', () => {
+ 'use strict'
+
+ let store
+ let deleteTaskMock
+ let resolvers
+
+ beforeEach(() => {
+ resolvers = []
+ // Every dispatched deleteTask stays pending until we resolve it explicitly,
+ // so the tests can control exactly when each deletion in the batch "finishes".
+ // On resolution it mimics the real deleteTaskFromCalendar mutation and removes
+ // the task from its calendar, so already-deleted tasks stay deleted.
+ deleteTaskMock = vi.fn((context, { task }) => new Promise((resolve) => {
+ resolvers.push(() => {
+ delete task.calendar.tasks[task.uid]
+ resolve()
+ })
+ }))
+ tasks.actions.deleteTask = deleteTaskMock
+
+ store = createStore({
+ modules: {
+ calendars,
+ collections,
+ tasks,
+ settings,
+ principals,
+ },
+ })
+ })
+
+ /**
+ * Mounts the component with `count` completed root tasks in the calendar.
+ *
+ * @param {number} count Number of completed root tasks to seed
+ * @return {object} The mounted wrapper
+ */
+ function mountWithTasks(count) {
+ const calendarTasks = {}
+ for (let i = 0; i < count; i++) {
+ calendarTasks[`task-${i}`] = makeClosedTask(`task-${i}`)
+ }
+ // Reactive, like the real Vuex-backed calendar object, so the component's
+ // computed properties pick up the deletions performed by deleteTaskMock.
+ const calendar = reactive({
+ displayName: 'Test calendar',
+ loadedCompleted: true,
+ readOnly: false,
+ tasks: calendarTasks,
+ })
+ Object.values(calendar.tasks).forEach((task) => { task.calendar = calendar })
+
+ const wrapper = shallowMount(DeleteCompletedModal, {
+ global: {
+ plugins: [store],
+ },
+ props: {
+ calendar,
+ },
+ })
+ wrapper.vm.openModal()
+ return wrapper
+ }
+
+ it('is not deleting before the user starts the bulk deletion', () => {
+ const wrapper = mountWithTasks(3)
+
+ expect(wrapper.vm.deleting).toBe(false)
+ })
+
+ it('deletes every completed task, one at a time, when not canceled', async () => {
+ const wrapper = mountWithTasks(3)
+
+ const runPromise = wrapper.vm.deleteCompletedTasks()
+
+ // The first deletion is dispatched synchronously, the rest wait for it.
+ expect(wrapper.vm.deleting).toBe(true)
+ expect(deleteTaskMock).toHaveBeenCalledTimes(1)
+
+ resolvers[0]()
+ await flushPromises()
+ expect(deleteTaskMock).toHaveBeenCalledTimes(2)
+
+ resolvers[1]()
+ await flushPromises()
+ expect(deleteTaskMock).toHaveBeenCalledTimes(3)
+
+ resolvers[2]()
+ await runPromise
+
+ expect(deleteTaskMock).toHaveBeenCalledTimes(3)
+ expect(wrapper.vm.deleting).toBe(false)
+ })
+
+ it('stops dispatching further deletions once canceled, but lets the in-flight one finish', async () => {
+ const wrapper = mountWithTasks(3)
+
+ const runPromise = wrapper.vm.deleteCompletedTasks()
+ expect(deleteTaskMock).toHaveBeenCalledTimes(1)
+
+ wrapper.vm.cancelDeletion()
+ // The task that was already in flight when cancel was requested still completes...
+ resolvers[0]()
+ await runPromise
+
+ // ...but the remaining two tasks in the batch are never dispatched.
+ expect(deleteTaskMock).toHaveBeenCalledTimes(1)
+ expect(wrapper.vm.deleting).toBe(false)
+ expect(wrapper.vm.cancelRequested).toBe(true)
+ // The task that did finish deleting stays deleted.
+ expect(Object.keys(wrapper.props('calendar').tasks)).toEqual(['task-1', 'task-2'])
+ })
+
+ it('resets the cancellation flag and continues with the remaining tasks when restarted', async () => {
+ const wrapper = mountWithTasks(2)
+
+ const firstRun = wrapper.vm.deleteCompletedTasks()
+ wrapper.vm.cancelDeletion()
+ resolvers[0]()
+ await firstRun
+
+ expect(deleteTaskMock).toHaveBeenCalledTimes(1)
+ expect(wrapper.vm.cancelRequested).toBe(true)
+
+ const secondRun = wrapper.vm.deleteCompletedTasks()
+ expect(wrapper.vm.cancelRequested).toBe(false)
+
+ resolvers[1]()
+ await secondRun
+
+ expect(deleteTaskMock).toHaveBeenCalledTimes(2)
+ expect(wrapper.vm.deleting).toBe(false)
+ expect(Object.keys(wrapper.props('calendar').tasks)).toEqual([])
+ })
+})