Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2857 (2024-05-28 08:23:44)

refactor: Create `EditableTask` class
# Types of changes

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Description

- Convert `type EditableTask` to `class EditableTask`
- Move related code from EditTask modal to Editable Task class

## Motivation and Context

- Remove logic from Edit Task modal
- Encapsulate behaviour in Editable Task

## How has this been tested?

- Smoke test in demo vault
  - create task with just a description from an empty line, close modal, verify that the task has been written correctly
  - modify every field, close modal, verify that the task has been written correctly
  - remove all field data except description, close modal, verify that the task has been written correctly

## Checklist

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [ ] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).
  - hence the smoke test

## Terms

- [x] My contribution follows this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/src/ui/EditTask.svelte b/src/ui/EditTask.svelte
index 7b0bb0e3..e259969b 100644
--- a/src/ui/EditTask.svelte
+++ b/src/ui/EditTask.svelte
@@ -1,18 +1,11 @@
 <script lang="ts">
     import { onMount } from 'svelte';
-    import { parseTypedDateForSaving } from '../lib/DateTools';
-    import { Recurrence } from '../Task/Recurrence';
     import { TASK_FORMATS, getSettings } from '../Config/Settings';
-    import { GlobalFilter } from '../Config/GlobalFilter';
-    import { Status } from '../Statuses/Status';
-    import { Task } from '../Task/Task';
-    import { TasksDate } from '../Scripting/TasksDate';
-    import { addDependencyToParent, ensureTaskHasId, generateUniqueId, removeDependency } from '../Task/TaskDependency';
-    import { replaceTaskWithTasks } from '../Obsidian/File';
-    import { Priority } from '../Task/Priority';
+    import type { Status } from '../Statuses/Status';
+    import type { Task } from '../Task/Task';
     import DateEditor from './DateEditor.svelte';
-    import type { EditableTask } from './EditableTask';
     import Dependency from './Dependency.svelte';
+    import { EditableTask } from './EditableTask';
     import { labelContentWithAccessKey } from './EditTaskHelpers';
     import RecurrenceEditor from './RecurrenceEditor.svelte';
     import StatusEditor from './StatusEditor.svelte';
@@ -35,22 +28,8 @@
     } = TASK_FORMATS.tasksPluginEmoji.taskSerializer.symbols;
 
     let descriptionInput: HTMLTextAreaElement;
-    let editableTask: EditableTask = {
-        // NEW_TASK_FIELD_EDIT_REQUIRED
-        description: '',
-        status: Status.TODO,
-        priority: 'none',
-        recurrenceRule: '',
-        createdDate: '',
-        startDate: '',
-        scheduledDate: '',
-        dueDate: '',
-        doneDate: '',
-        cancelledDate: '',
-        forwardOnly: true,
-        blockedBy: [],
-        blocking: [],
-    };
+
+    let { editableTask, addGlobalFilterOnSave, originalBlocking } = EditableTask.fromTask(task, allTasks);
 
     let isDescriptionValid: boolean = true;
 
@@ -63,12 +42,9 @@
 
     let isRecurrenceValid: boolean = true;
 
-    let addGlobalFilterOnSave: boolean = false;
     let withAccessKeys: boolean = true;
     let formIsValid: boolean = true;
 
-    let originalBlocking: Task[] = [];
-
     let mountComplete = false;
 
     const priorityOptions: {
@@ -122,21 +98,6 @@
         },
     ];
 
-    async function serialiseTaskId(task: Task) {
-        if (task.id !== '') return task;
-
-        const tasksWithId = allTasks.filter((task) => task.id !== '');
-
-        const updatedTask = ensureTaskHasId(
-            task,
-            tasksWithId.map((task) => task.id),
-        );
-
-        await replaceTaskWithTasks({ originalTask: task, newTasks: updatedTask });
-
-        return updatedTask;
-    }
-
     $: accesskey = (key: string) => (withAccessKeys ? key : null);
     $: formIsValid =
         isDueDateValid &&
@@ -152,54 +113,6 @@
     onMount(() => {
         const { provideAccessKeys } = getSettings();
         withAccessKeys = provideAccessKeys;
-        const description = GlobalFilter.getInstance().removeAsWordFrom(task.description);
-        // If we're displaying to the user the description without the global filter (i.e. it was removed in the method
-        // above), or if the description did not include a global filter in the first place, we'll add the global filter
-        // when saving the task.
-        if (description != task.description || !GlobalFilter.getInstance().includedIn(task.description)) {
-            addGlobalFilterOnSave = true;
-        }
-        let priority: typeof editableTask.priority = 'none';
-        if (task.priority === Priority.Lowest) {
-            priority = 'lowest';
-        } else if (task.priority === Priority.Low) {
-            priority = 'low';
-        } else if (task.priority === Priority.Medium) {
-            priority = 'medium';
-        } else if (task.priority === Priority.High) {
-            priority = 'high';
-        } else if (task.priority === Priority.Highest) {
-            priority = 'highest';
-        }
-
-        const blockedBy: Task[] = [];
-
-        for (const taskId of task.dependsOn) {
-            const depTask = allTasks.find((cacheTask) => cacheTask.id === taskId);
-
-            if (!depTask) continue;
-
-            blockedBy.push(depTask);
-        }
-
-        originalBlocking = allTasks.filter((cacheTask) => cacheTask.dependsOn.includes(task.id));
-
-        editableTask = {
-            // NEW_TASK_FIELD_EDIT_REQUIRED
-            description,
-            status: task.status,
-            priority,
-            recurrenceRule: task.recurrence ? task.recurrence.toText() : '',
-            createdDate: new TasksDate(task.createdDate).formatAsDate(),
-            startDate: new TasksDate(task.startDate).formatAsDate(),
-            scheduledDate: new TasksDate(task.scheduledDate).formatAsDate(),
-            dueDate: new TasksDate(task.dueDate).formatAsDate(),
-            doneDate: new TasksDate(task.doneDate).formatAsDate(),
-            cancelledDate: new TasksDate(task.cancelledDate).formatAsDate(),
-            forwardOnly: true,
-            blockedBy: blockedBy,
-            blocking: originalBlocking,
-        };
 
         mountComplete = true;
 
@@ -229,106 +142,7 @@
     };
 
     const _onSubmit = async () => {
-        // NEW_TASK_FIELD_EDIT_REQUIRED
-        let description = editableTask.description.trim();
-        if (addGlobalFilterOnSave) {
-            description = GlobalFilter.getInstance().prependTo(description);
-        }
-
-        const startDate = parseTypedDateForSaving(editableTask.startDate, editableTask.forwardOnly);
-        const scheduledDate = parseTypedDateForSaving(editableTask.scheduledDate, editableTask.forwardOnly);
-        const dueDate = parseTypedDateForSaving(editableTask.dueDate, editableTask.forwardOnly);
-
-        const cancelledDate = parseTypedDateForSaving(editableTask.cancelledDate, editableTask.forwardOnly);
-        const createdDate = parseTypedDateForSaving(editableTask.createdDate, editableTask.forwardOnly);
-        const doneDate = parseTypedDateForSaving(editableTask.doneDate, editableTask.forwardOnly);
-
-        let recurrence: Recurrence | null = null;
-        if (editableTask.recurrenceRule) {
-            recurrence = Recurrence.fromText({
-                recurrenceRuleText: editableTask.recurrenceRule,
-                startDate,
-                scheduledDate,
-                dueDate,
-            });
-        }
-
-        let parsedPriority: Priority;
-        switch (editableTask.priority) {
-            case 'lowest':
-                parsedPriority = Priority.Lowest;
-                break;
-            case 'low':
-                parsedPriority = Priority.Low;
-                break;
-            case 'medium':
-                parsedPriority = Priority.Medium;
-                break;
-            case 'high':
-                parsedPriority = Priority.High;
-                break;
-            case 'highest':
-                parsedPriority = Priority.Highest;
-                break;
-            default:
-                parsedPriority = Priority.None;
-        }
-
-        let blockedByWithIds = [];
-
-        for (const depTask of editableTask.blockedBy) {
-            const newDep = await serialiseTaskId(depTask);
-            blockedByWithIds.push(newDep);
-        }
-
-        let id = task.id;
-        let removedBlocking: Task[] = [];
-        let addedBlocking: Task[] = [];
-
-        if (editableTask.blocking.toString() !== originalBlocking.toString() || editableTask.blocking.length !== 0) {
-            if (task.id === '') {
-                id = generateUniqueId(allTasks.filter((task) => task.id !== '').map((task) => task.id));
-            }
-
-            removedBlocking = originalBlocking.filter((task) => !editableTask.blocking.includes(task));
-
-            addedBlocking = editableTask.blocking.filter((task) => !originalBlocking.includes(task));
-        }
-
-        // First create an updated task, with all edits except Status:
-        const updatedTask = new Task({
-            // NEW_TASK_FIELD_EDIT_REQUIRED
-            ...task,
-            description,
-            status: task.status,
-            priority: parsedPriority,
-            recurrence,
-            startDate,
-            scheduledDate,
-            dueDate,
-            doneDate,
-            createdDate,
-            cancelledDate,
-            dependsOn: blockedByWithIds.map((task) => task.id),
-            id,
-        });
-
-        for (const blocking of removedBlocking) {
-            const newParent = removeDependency(blocking, updatedTask);
-            await replaceTaskWithTasks({ originalTask: blocking, newTasks: newParent });
-        }
-
-        for (const blocking of addedBlocking) {
-            const newParent = addDependencyToParent(blocking, updatedTask);
-            await replaceTaskWithTasks({ originalTask: blocking, newTasks: newParent });
-        }
-
-        // Then apply the new status to the updated task, in case a new recurrence
-        // needs to be created.
-        // If there is a 'done' date, use that for today's date for recurrence calculations.
-        // Otherwise, use the current date.
-        const today = doneDate ? doneDate : window.moment();
-        const newTasks = updatedTask.handleNewStatusWithRecurrenceInUsersOrder(editableTask.status, today);
+        const newTasks = await editableTask.applyEdits(task, originalBlocking, addGlobalFilterOnSave, allTasks);
         onSubmit(newTasks);
     };
 </script>
diff --git a/src/ui/EditableTask.ts b/src/ui/EditableTask.ts
index 5937a447..857dd689 100644
--- a/src/ui/EditableTask.ts
+++ b/src/ui/EditableTask.ts
@@ -1,7 +1,20 @@
+import { GlobalFilter } from '../Config/GlobalFilter';
+import { parseTypedDateForSaving } from '../lib/DateTools';
+import { replaceTaskWithTasks } from '../Obsidian/File';
 import type { Status } from '../Statuses/Status';
-import type { Task } from '../Task/Task';
+import { Priority } from '../Task/Priority';
+import { Recurrence } from '../Task/Recurrence';
+import { Task } from '../Task/Task';
+import { addDependencyToParent, ensureTaskHasId, generateUniqueId, removeDependency } from '../Task/TaskDependency';
 
-export type EditableTask = {
+type EditableTaskPriority = 'none' | 'lowest' | 'low' | 'medium' | 'high' | 'highest';
+
+/**
+ * {@link Task} objects are immutable. This class allows to create a mutable object from a {@link Task}, apply the edits,
+ * and get the resulting task(s).
+ *
+ */
+export class EditableTask {
     // NEW_TASK_FIELD_EDIT_REQUIRED
     description: string;
     status: Status;
@@ -16,4 +29,237 @@ export type EditableTask = {
     forwardOnly: boolean;
     blockedBy: Task[];
     blocking: Task[];
-};
+
+    constructor(editableTask: {
+        // NEW_TASK_FIELD_EDIT_REQUIRED
+        description: string;
+        status: Status;
+        priority: EditableTaskPriority;
+        recurrenceRule: string;
+        createdDate: string;
+        startDate: string;
+        scheduledDate: string;
+        dueDate: string;
+        doneDate: string;
+        cancelledDate: string;
+        forwardOnly: boolean;
+        blockedBy: Task[];
+        blocking: Task[];
+    }) {
+        this.description = editableTask.description;
+        this.status = editableTask.status;
+        this.priority = editableTask.priority;
+        this.recurrenceRule = editableTask.recurrenceRule;
+        this.createdDate = editableTask.createdDate;
+        this.startDate = editableTask.startDate;
+        this.scheduledDate = editableTask.scheduledDate;
+        this.dueDate = editableTask.dueDate;
+        this.doneDate = editableTask.doneDate;
+        this.cancelledDate = editableTask.cancelledDate;
+        this.forwardOnly = editableTask.forwardOnly;
+        this.blockedBy = editableTask.blockedBy;
+        this.blocking = editableTask.blocking;
+    }
+
+    /**
+     * Use this factory to create an editable task from a {@link Task} object.
+     *
+     * @param task
+     * @param allTasks
+     */
+    public static fromTask(
+        task: Task,
+        allTasks: Task[],
+    ): {
+        addGlobalFilterOnSave: boolean;
+        originalBlocking: Task[];
+        editableTask: EditableTask;
+    } {
+        const description = GlobalFilter.getInstance().removeAsWordFrom(task.description);
+        // If we're displaying to the user the description without the global filter (i.e. it was removed in the method
+        // above), or if the description did not include a global filter in the first place, we'll add the global filter
+        // when saving the task.
+        let addGlobalFilterOnSave = false;
+        if (description != task.description || !GlobalFilter.getInstance().includedIn(task.description)) {
+            addGlobalFilterOnSave = true;
+        }
+        let priority: EditableTaskPriority = 'none';
+        if (task.priority === Priority.Lowest) {
+            priority = 'lowest';
+        } else if (task.priority === Priority.Low) {
+            priority = 'low';
+        } else if (task.priority === Priority.Medium) {
+            priority = 'medium';
+        } else if (task.priority === Priority.High) {
+            priority = 'high';
+        } else if (task.priority === Priority.Highest) {
+            priority = 'highest';
+        }
+
+        const blockedBy: Task[] = [];
+
+        for (const taskId of task.dependsOn) {
+            const depTask = allTasks.find((cacheTask) => cacheTask.id === taskId);
+
+            if (!depTask) continue;
+
+            blockedBy.push(depTask);
+        }
+
+        const originalBlocking = allTasks.filter((cacheTask) => cacheTask.dependsOn.includes(task.id));
+
+        return {
+            editableTask: new EditableTask({
+                // NEW_TASK_FIELD_EDIT_REQUIRED
+                description,
+                status: task.status,
+                priority,
+                recurrenceRule: task.recurrence ? task.recurrence.toText() : '',
+                createdDate: task.created.formatAsDate(),
+                startDate: task.start.formatAsDate(),
+                scheduledDate: task.scheduled.formatAsDate(),
+                dueDate: task.due.formatAsDate(),
+                doneDate: task.done.formatAsDate(),
+                cancelledDate: task.cancelled.formatAsDate(),
+                forwardOnly: true,
+                blockedBy: blockedBy,
+                blocking: originalBlocking,
+            }),
+            addGlobalFilterOnSave,
+            originalBlocking,
+        };
+    }
+
+    /**
+     * Generates a {@link Task} object from the current {@link EditableTask}. Use this to output the new tasks after the edits.
+     *
+     * There are cases where the output of the edits is more than one task, for example, completing a {@link Task} with {@link Recurrence}.
+     *
+     * @param task
+     * @param originalBlocking
+     * @param addGlobalFilterOnSave
+     * @param allTasks
+     */
+    public async applyEdits(
+        task: Task,
+        originalBlocking: Task[],
+        addGlobalFilterOnSave: boolean,
+        allTasks: Task[],
+    ): Promise<Task[]> {
+        // NEW_TASK_FIELD_EDIT_REQUIRED
+        let description = this.description.trim();
+        if (addGlobalFilterOnSave) {
+            description = GlobalFilter.getInstance().prependTo(description);
+        }
+
+        const startDate = parseTypedDateForSaving(this.startDate, this.forwardOnly);
+        const scheduledDate = parseTypedDateForSaving(this.scheduledDate, this.forwardOnly);
+        const dueDate = parseTypedDateForSaving(this.dueDate, this.forwardOnly);
+
+        const cancelledDate = parseTypedDateForSaving(this.cancelledDate, this.forwardOnly);
+        const createdDate = parseTypedDateForSaving(this.createdDate, this.forwardOnly);
+        const doneDate = parseTypedDateForSaving(this.doneDate, this.forwardOnly);
+
+        let recurrence: Recurrence | null = null;
+        if (this.recurrenceRule) {
+            recurrence = Recurrence.fromText({
+                recurrenceRuleText: this.recurrenceRule,
+                startDate,
+                scheduledDate,
+                dueDate,
+            });
+        }
+
+        let parsedPriority: Priority;
+        switch (this.priority) {
+            case 'lowest':
+                parsedPriority = Priority.Lowest;
+                break;
+            case 'low':
+                parsedPriority = Priority.Low;
+                break;
+            case 'medium':
+                parsedPriority = Priority.Medium;
+                break;
+            case 'high':
+                parsedPriority = Priority.High;
+                break;
+            case 'highest':
+                parsedPriority = Priority.Highest;
+                break;
+            default:
+                parsedPriority = Priority.None;
+        }
+
+        const blockedByWithIds = [];
+
+        for (const depTask of this.blockedBy) {
+            const newDep = await serialiseTaskId(depTask, allTasks);
+            blockedByWithIds.push(newDep);
+        }
+
+        let id = task.id;
+        let removedBlocking: Task[] = [];
+        let addedBlocking: Task[] = [];
+
+        if (this.blocking.toString() !== originalBlocking.toString() || this.blocking.length !== 0) {
+            if (task.id === '') {
+                id = generateUniqueId(allTasks.filter((task) => task.id !== '').map((task) => task.id));
+            }
+
+            removedBlocking = originalBlocking.filter((task) => !this.blocking.includes(task));
+
+            addedBlocking = this.blocking.filter((task) => !originalBlocking.includes(task));
+        }
+
+        // First create an updated task, with all edits except Status:
+        const updatedTask = new Task({
+            // NEW_TASK_FIELD_EDIT_REQUIRED
+            ...task,
+            description,
+            status: task.status,
+            priority: parsedPriority,
+            recurrence,
+            startDate,
+            scheduledDate,
+            dueDate,
+            doneDate,
+            createdDate,
+            cancelledDate,
+            dependsOn: blockedByWithIds.map((task) => task.id),
+            id,
+        });
+
+        for (const blocking of removedBlocking) {
+            const newParent = removeDependency(blocking, updatedTask);
+            await replaceTaskWithTasks({ originalTask: blocking, newTasks: newParent });
+        }
+
+        for (const blocking of addedBlocking) {
+            const newParent = addDependencyToParent(blocking, updatedTask);
+            await replaceTaskWithTasks({ originalTask: blocking, newTasks: newParent });
+        }
+
+        // Then apply the new status to the updated task, in case a new recurrence
+        // needs to be created.
+        // If there is a 'done' date, use that for today's date for recurrence calculations.
+        // Otherwise, use the current date.
+        const today = doneDate ? doneDate : window.moment();
+        return updatedTask.handleNewStatusWithRecurrenceInUsersOrder(this.status, today);
+    }
+}
+
+async function serialiseTaskId(task: Task, allTasks: Task[]) {
+    if (task.id !== '') return task;
+
+    const tasksWithId = allTasks.filter((task) => task.id !== '');
+
+    const updatedTask = ensureTaskHasId(
+        task,
+        tasksWithId.map((task) => task.id),
+    );
+
+    await replaceTaskWithTasks({ originalTask: task, newTasks: updatedTask });
+
+    return updatedTask;
+}


Test patch:
diff --git a/tests/ui/EditTaskHelpers.test.ts b/tests/ui/EditTaskHelpers.test.ts
index a4947fc9..3c7b6228 100644
--- a/tests/ui/EditTaskHelpers.test.ts
+++ b/tests/ui/EditTaskHelpers.test.ts
@@ -4,7 +4,7 @@
 
 import moment from 'moment/moment';
 import { Status } from '../../src/Statuses/Status';
-import type { EditableTask } from '../../src/ui/EditableTask';
+import { EditableTask } from '../../src/ui/EditableTask';
 import { labelContentWithAccessKey, parseAndValidateRecurrence } from '../../src/ui/EditTaskHelpers';
 
 window.moment = moment;
@@ -49,7 +49,7 @@ describe('labelContentWithAccessKey() tests', () => {
 });
 
 describe('parseAndValidateRecurrence() tests', () => {
-    const emptyTask: EditableTask = {
+    const emptyTask = new EditableTask({
         description: '',
         status: Status.TODO,
         priority: 'none',
@@ -63,7 +63,7 @@ describe('parseAndValidateRecurrence() tests', () => {
         forwardOnly: true,
         blockedBy: [],
         blocking: [],
-    };
+    });
     const noRecurrenceRule: Partial<EditableTask> = emptyTask;
     const invalidRecurrenceRule: Partial<EditableTask> = {
         recurrenceRule: 'thisIsWrong',
@@ -89,7 +89,7 @@ describe('parseAndValidateRecurrence() tests', () => {
             expectedParsedRecurrence: string,
             expectedRecurrenceValidity: boolean,
         ) => {
-            const editableTask = { ...emptyTask, ...editableTaskFields };
+            const editableTask = new EditableTask({ ...emptyTask, ...editableTaskFields });
 
             const { parsedRecurrence, isRecurrenceValid } = parseAndValidateRecurrence(editableTask);
             expect(parsedRecurrence).toEqual(expectedParsedRecurrence);
diff --git a/tests/ui/EditableTask.test.ts b/tests/ui/EditableTask.test.ts
new file mode 100644
index 00000000..ece07b62
--- /dev/null
+++ b/tests/ui/EditableTask.test.ts
@@ -0,0 +1,249 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import type { Task } from 'Task/Task';
+import { GlobalFilter } from '../../src/Config/GlobalFilter';
+import { Status } from '../../src/Statuses/Status';
+import { EditableTask } from '../../src/ui/EditableTask';
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
+
+window.moment = moment;
+
+function testEditableTaskDescriptionAndGlobalFilterOnSave({
+    globalFilter,
+    taskDescription,
+    expectedEditableTaskDescription,
+    expectedAddGlobalFilterOnSave,
+}: {
+    globalFilter: string;
+    taskDescription: string;
+    expectedEditableTaskDescription: string;
+    expectedAddGlobalFilterOnSave: boolean;
+}) {
+    GlobalFilter.getInstance().set(globalFilter);
+    const taskWithoutGlobalFilter = new TaskBuilder().description(taskDescription).build();
+
+    const { editableTask, addGlobalFilterOnSave } = EditableTask.fromTask(taskWithoutGlobalFilter, [
+        taskWithoutGlobalFilter,
+    ]);
+
+    expect(editableTask.description).toEqual(expectedEditableTaskDescription);
+    expect(addGlobalFilterOnSave).toEqual(expectedAddGlobalFilterOnSave);
+}
+
+describe('EditableTask tests', () => {
+    beforeEach(() => {
+        GlobalFilter.getInstance().reset();
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date('2024-05-01'));
+    });
+
+    afterEach(() => {
+        jest.useRealTimers();
+    });
+
+    it('should create an editable task without dependencies', () => {
+        const taskToEdit = TaskBuilder.createFullyPopulatedTask();
+
+        const { editableTask, originalBlocking } = EditableTask.fromTask(taskToEdit, [taskToEdit]);
+
+        expect(editableTask).toMatchInlineSnapshot(`
+            EditableTask {
+              "blockedBy": [],
+              "blocking": [],
+              "cancelledDate": "2023-07-06",
+              "createdDate": "2023-07-01",
+              "description": "Do exercises #todo #health",
+              "doneDate": "2023-07-05",
+              "dueDate": "2023-07-04",
+              "forwardOnly": true,
+              "priority": "medium",
+              "recurrenceRule": "every day when done",
+              "scheduledDate": "2023-07-03",
+              "startDate": "2023-07-02",
+              "status": Status {
+                "configuration": StatusConfiguration {
+                  "availableAsCommand": true,
+                  "name": "Todo",
+                  "nextStatusSymbol": "x",
+                  "symbol": " ",
+                  "type": "TODO",
+                },
+              },
+            }
+        `);
+        expect(originalBlocking).toEqual([]);
+    });
+
+    it('should create an editable task with dependencies', () => {
+        const taskToEdit = TaskBuilder.createFullyPopulatedTask();
+        const blockingTask = new TaskBuilder().description('I am blocking the task to edit').id('123456').build();
+        const blockedTask = new TaskBuilder()
+            .description('I am blocked by the task to edit')
+            .dependsOn(['abcdef'])
+            .build();
+        const allTasks = [taskToEdit, blockingTask, blockedTask];
+
+        const { editableTask, originalBlocking } = EditableTask.fromTask(taskToEdit, allTasks);
+
+        expect(editableTask.blocking).toEqual([blockedTask]);
+        expect(editableTask.blockedBy).toEqual([blockingTask]);
+        expect(originalBlocking).toEqual([blockedTask]);
+    });
+
+    it('should remember to add global filter when it is absent in task description', () => {
+        testEditableTaskDescriptionAndGlobalFilterOnSave({
+            globalFilter: '#todo',
+            taskDescription: 'global filter is absent',
+            expectedEditableTaskDescription: 'global filter is absent',
+            expectedAddGlobalFilterOnSave: true,
+        });
+    });
+
+    it('should remember to add global filter when it is present in task description and remove it from the description', () => {
+        testEditableTaskDescriptionAndGlobalFilterOnSave({
+            globalFilter: '#important',
+            taskDescription: '#important is the global filter',
+            expectedEditableTaskDescription: 'is the global filter',
+            expectedAddGlobalFilterOnSave: true,
+        });
+    });
+
+    it('should not add global filter by default (global filter was not set)', () => {
+        testEditableTaskDescriptionAndGlobalFilterOnSave({
+            globalFilter: GlobalFilter.empty,
+            taskDescription: 'global filter has not been set',
+            expectedEditableTaskDescription: 'global filter has not been set',
+            expectedAddGlobalFilterOnSave: false,
+        });
+    });
+
+    it('should apply no edits to an empty task', async () => {
+        const task = new TaskBuilder().build();
+        const allTasks = [task];
+
+        const { editableTask, originalBlocking, addGlobalFilterOnSave } = EditableTask.fromTask(task, allTasks);
+        const appliedEdits = await editableTask.applyEdits(task, originalBlocking, addGlobalFilterOnSave, [task]);
+
+        expect(appliedEdits).toEqual([task]);
+    });
+
+    it.failing('should apply no edits to a fully populated task', async () => {
+        const task = TaskBuilder.createFullyPopulatedTask();
+        const allTasks = [task];
+
+        const { editableTask, originalBlocking, addGlobalFilterOnSave } = EditableTask.fromTask(task, allTasks);
+        const appliedEdits = await editableTask.applyEdits(task, originalBlocking, addGlobalFilterOnSave, [task]);
+
+        expect(appliedEdits).toEqual([task]);
+    });
+
+    it('should apply edit all fields in a fully populated task', async () => {
+        const task = TaskBuilder.createFullyPopulatedTask();
+        const allTasks = [task];
+
+        const { editableTask, originalBlocking, addGlobalFilterOnSave } = EditableTask.fromTask(task, allTasks);
+
+        editableTask.description = '';
+        editableTask.status = Status.TODO;
+        editableTask.priority = 'none';
+        editableTask.recurrenceRule = '';
+        editableTask.createdDate = '';
+        editableTask.startDate = '';
+        editableTask.scheduledDate = '';
+        editableTask.dueDate = '';
+        editableTask.doneDate = '';
+        editableTask.cancelledDate = '';
+        editableTask.forwardOnly = true;
+        editableTask.blockedBy = [];
+        editableTask.blocking = [];
+
+        const appliedEdits = await editableTask.applyEdits(task, originalBlocking, addGlobalFilterOnSave, allTasks);
+
+        expect(appliedEdits.length).toEqual(1);
+        expect(appliedEdits[0]).toMatchInlineSnapshot(`
+            Task {
+              "_urgency": null,
+              "blockLink": " ^dcf64c",
+              "cancelledDate": null,
+              "children": [],
+              "createdDate": null,
+              "dependsOn": [],
+              "description": "",
+              "doneDate": null,
+              "dueDate": null,
+              "id": "abcdef",
+              "indentation": "  ",
+              "listMarker": "-",
+              "originalMarkdown": "  - [ ] Do exercises #todo #health 🆔 abcdef ⛔ 123456,abc123 🔼 🔁 every day when done ➕ 2023-07-01 🛫 2023-07-02 ⏳ 2023-07-03 📅 2023-07-04 ❌ 2023-07-06 ✅ 2023-07-05 ^dcf64c",
+              "parent": null,
+              "priority": "3",
+              "recurrence": null,
+              "scheduledDate": null,
+              "scheduledDateIsInferred": false,
+              "startDate": null,
+              "status": Status {
+                "configuration": StatusConfiguration {
+                  "availableAsCommand": true,
+                  "name": "Todo",
+                  "nextStatusSymbol": "x",
+                  "symbol": " ",
+                  "type": "TODO",
+                },
+              },
+              "tags": [
+                "#todo",
+                "#health",
+              ],
+              "taskLocation": TaskLocation {
+                "_lineNumber": 17,
+                "_precedingHeader": "My Header",
+                "_sectionIndex": 3,
+                "_sectionStart": 5,
+                "_tasksFile": TasksFile {
+                  "_path": "some/folder/fileName.md",
+                },
+              },
+            }
+        `);
+    });
+
+    it('should set a date in YYYY-MM-DD format', async () => {
+        const task = new TaskBuilder().build();
+        const allTasks: Task[] = [];
+        const { editableTask, originalBlocking, addGlobalFilterOnSave } = EditableTask.fromTask(task, allTasks);
+
+        editableTask.dueDate = '2024-07-13';
+
+        const editedTasks = await editableTask.applyEdits(task, originalBlocking, addGlobalFilterOnSave, allTasks);
+        // TODO Why does this have the time 12:00?
+        //      When I edit a task in the plugin, in the modal, and then group by the following, the time is midnight,
+        //      so where is the time dropped in production code?
+        //          group by function task.due.formatAsDateAndTime()
+        //      Or have I misunderstood something?
+        //      For now, I would just like assurance that this is the same behaviour as
+        //      the code before this PR.... (I expect it is)
+        expect(editedTasks[0].dueDate).toEqualMoment(moment('2024-07-13T12:00:00.000Z'));
+    });
+
+    it('should honour the forwardOnly value', async () => {
+        const task = new TaskBuilder().build();
+        const allTasks: Task[] = [];
+        const { editableTask, originalBlocking, addGlobalFilterOnSave } = EditableTask.fromTask(task, allTasks);
+
+        jest.setSystemTime(new Date('2024-05-22')); // Wednesday 22nd May
+
+        editableTask.dueDate = 'tuesday';
+        const tuesdayBefore = moment('2024-05-28T12:00:00.000Z');
+        const tuesdayAfter = moment('2024-05-21T12:00:00.000Z');
+
+        editableTask.forwardOnly = true;
+        const tasksFutureDay = await editableTask.applyEdits(task, originalBlocking, addGlobalFilterOnSave, allTasks);
+        expect(tasksFutureDay[0].dueDate).toEqualMoment(tuesdayBefore);
+
+        editableTask.forwardOnly = false;
+        const tasksClosestDay = await editableTask.applyEdits(task, originalBlocking, addGlobalFilterOnSave, allTasks);
+        expect(tasksClosestDay[0].dueDate).toEqualMoment(tuesdayAfter);
+    });
+});