Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2495 (2023-12-11 14:10:02)

internal: Add Priorities menu (not yet used anywhere in the plugin)
<!--- Provide a general summary of your changes in the Title above -->

# Description

Implement a Priorities context menu for editing tasks.

**Note: it is not yet exposed to users.**

### Code Design

Editing instruction classes:

- Introduce new interface `TaskEditingInstruction`: An instruction interface, for editing a Task object.
- Add `SetPriority` which implements `TaskEditingInstruction`
- Add function `allPriorityInstructions()` to provide all the available priority editing instructions

Menu classes:

- Push some code from `StatusMenu` down to new class `TaskEditingMenu`
- Introduce new class `PriorityMenu`. It is very simple, as most of the work is done by `allPriorityInstructions()` and `SetPriority`.

### Usage

Here is how it might added to the Pencil button in QueryRenderer... the example is taken from the jsdocs:

```diff
 import type { TasksEvents } from './TasksEvents';
+import { PriorityMenu } from './ui/Menus/PriorityMenu';
 
 export class QueryRenderer {
     private readonly app: App;
@@ -295,6 +296,12 @@
             });
             taskModal.open();
         });
+
+        editTaskPencil.addEventListener('contextmenu', (ev: MouseEvent) => {
+            const menu = new PriorityMenu(task);
+            menu.showAtPosition({ x: ev.clientX, y: ev.clientY });
+        });
+        editTaskPencil.setAttribute('title', 'Right-click for options');
     }
 
     private addUrgency(listItem: HTMLElement, task: Task) {
```

## Motivation and Context

I am exploring adding abstractions for how to make it easier to add new facilities for editing tasks.

This is working towards a general abstraction for editing tasks via:

- right-click context menus
- later, also adding Obsidian Commands for editing individual task properties

## How has this been tested?

- Lots of new tests
- Lots of manual editing

## Screenshots (if appropriate)

This is what it would look light, if it were attached to the context menu of the Edit Task button in Tasks search results:

![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/4840096/a8712454-19aa-400e-a689-830b7210d33c)


## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

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)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [ ] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow 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/EditInstructions/PriorityInstructions.ts b/src/ui/EditInstructions/PriorityInstructions.ts
new file mode 100644
index 00000000..285acce5
--- /dev/null
+++ b/src/ui/EditInstructions/PriorityInstructions.ts
@@ -0,0 +1,56 @@
+import { Priority, Task } from '../../Task';
+import { PriorityTools } from '../../lib/PriorityTools';
+import type { TaskEditingInstruction } from './TaskEditingInstruction';
+
+/**
+ * An instruction class, for editing a {@link Task} object's {@link Priority}.
+ */
+export class SetPriority implements TaskEditingInstruction {
+    readonly newPriority: Priority;
+
+    constructor(priority: Priority) {
+        this.newPriority = priority;
+    }
+
+    public apply(task: Task): Task[] {
+        if (this.isCheckedForTask(task)) {
+            // Unchanged: return the input task:
+            return [task];
+        } else {
+            return [
+                new Task({
+                    ...task,
+                    priority: this.newPriority,
+                }),
+            ];
+        }
+    }
+
+    public instructionDisplayName(): string {
+        return `Priority: ${PriorityTools.priorityNameUsingNormal(this.newPriority)}`;
+    }
+
+    public isCheckedForTask(task: Task) {
+        return task.priority === this.newPriority;
+    }
+}
+
+/**
+ * Return all the available instructions for editing task priorities.
+ * @todo Add instructions for increasing and decreasing the priority.
+ */
+export function allPriorityInstructions() {
+    const allPriorities = [
+        Priority.Highest,
+        Priority.High,
+        Priority.Medium,
+        Priority.None,
+        Priority.Low,
+        Priority.Lowest,
+    ];
+    const instructions = [];
+    for (const priority of allPriorities) {
+        instructions.push(new SetPriority(priority));
+    }
+    return instructions;
+}
diff --git a/src/ui/EditInstructions/TaskEditingInstruction.ts b/src/ui/EditInstructions/TaskEditingInstruction.ts
new file mode 100644
index 00000000..036b7841
--- /dev/null
+++ b/src/ui/EditInstructions/TaskEditingInstruction.ts
@@ -0,0 +1,31 @@
+import type { Task } from '../../Task';
+
+/**
+ * An instruction interface, for editing a {@link Task} object.
+ */
+export interface TaskEditingInstruction {
+    /**
+     * Apply the edit to a copy of the given task.
+     *
+     * @note {@link Task} objects are immutable, so any edits are returned in a new task or tasks.
+     * @param task
+     * @returns An array of 0 or more tasks:
+     *
+     *            - Typically, this contains a single value, containing an edited copy of {@link task}.
+     *            - An extra task would indicate that a new occurrence has been created.
+     *            - An empty array would indicate to delete the task.
+     *            - If no edit was made, the returned value will be an array of one element, which is the input task.
+     */
+    apply(task: Task): Task[];
+
+    /**
+     * Return the text to use for this instruction, in menus (and eventually, Obsidian commands)
+     */
+    instructionDisplayName(): string;
+
+    /**
+     * Should a checkmark be shown on this instruction, for the given task.
+     * @param task
+     */
+    isCheckedForTask(task: Task): boolean;
+}
diff --git a/src/ui/Menus/PriorityMenu.ts b/src/ui/Menus/PriorityMenu.ts
new file mode 100644
index 00000000..a503944a
--- /dev/null
+++ b/src/ui/Menus/PriorityMenu.ts
@@ -0,0 +1,42 @@
+import type { MenuItem } from 'obsidian';
+import type { Task } from '../../Task';
+import { allPriorityInstructions } from '../EditInstructions/PriorityInstructions';
+import type { TaskEditingInstruction } from '../EditInstructions/TaskEditingInstruction';
+import { TaskEditingMenu, type TaskSaver, defaultTaskSaver } from './TaskEditingMenu';
+
+/**
+ * A Menu of options for editing the status of a Task object.
+ *
+ * @example
+ *     editTaskPencil.addEventListener('contextmenu', (ev: MouseEvent) => {
+ *         const menu = new PriorityMenu(task);
+ *         menu.showAtPosition({ x: ev.clientX, y: ev.clientY });
+ *     });
+ *     editTaskPencil.setAttribute('title', 'Right-click for options');
+ */
+export class PriorityMenu extends TaskEditingMenu {
+    /**
+     * Constructor, which sets up the menu items.
+     * @param task - the Task to be edited.
+     * @param taskSaver - an optional {@link TaskSaver} function. For details, see {@link TaskEditingMenu}.
+     */
+    constructor(task: Task, taskSaver: TaskSaver = defaultTaskSaver) {
+        super(taskSaver);
+
+        const getMenuItemCallback = (task: Task, item: MenuItem, instruction: TaskEditingInstruction) => {
+            item.setTitle(instruction.instructionDisplayName())
+                .setChecked(instruction.isCheckedForTask(task))
+                .onClick(async () => {
+                    const newTask = instruction.apply(task);
+                    const hasEdits = newTask.length !== 1 || !Object.is(newTask[0], task);
+                    if (hasEdits) {
+                        await this.taskSaver(task, newTask);
+                    }
+                });
+        };
+
+        for (const instruction of allPriorityInstructions()) {
+            this.addItem((item) => getMenuItemCallback(task, item, instruction));
+        }
+    }
+}
diff --git a/src/ui/Menus/StatusMenu.ts b/src/ui/Menus/StatusMenu.ts
index eab9f1e4..f192168e 100644
--- a/src/ui/Menus/StatusMenu.ts
+++ b/src/ui/Menus/StatusMenu.ts
@@ -1,26 +1,8 @@
-import { Menu, MenuItem } from 'obsidian';
+import type { MenuItem } from 'obsidian';
 import type { StatusRegistry } from '../../StatusRegistry';
-import { replaceTaskWithTasks } from '../../File';
 import type { Task } from '../../Task';
 import { StatusSettings } from '../../Config/StatusSettings';
-
-/**
- * A function for replacing one task with zero or more new tasks.
- * @see {@link defaultTaskSaver}
- */
-type TaskSaver = (originalTask: Task, newTasks: Task | Task[]) => Promise<void>;
-
-/**
- * A default implementation of {@link TaskSaver} that calls {@link replaceTaskWithTasks}
- * @param originalTask
- * @param newTasks
- */
-async function defaultTaskSaver(originalTask: Task, newTasks: Task | Task[]) {
-    await replaceTaskWithTasks({
-        originalTask,
-        newTasks,
-    });
-}
+import { TaskEditingMenu, type TaskSaver, defaultTaskSaver } from './TaskEditingMenu';
 
 /**
  * A Menu of options for editing the status of a Task object.
@@ -32,23 +14,19 @@ async function defaultTaskSaver(originalTask: Task, newTasks: Task | Task[]) {
  *     });
  *     checkbox.setAttribute('title', 'Right-click for options');
  */
-export class StatusMenu extends Menu {
+export class StatusMenu extends TaskEditingMenu {
     private statusRegistry: StatusRegistry;
-    private readonly taskSaver: TaskSaver;
 
     /**
      * Constructor, which sets up the menu items.
      * @param statusRegistry - the statuses to be shown in the menu.
      * @param task - the Task to be edited.
-     * @param taskSaver - an optional {@link TaskSaver} function. If not supplied, {@link replaceTaskWithTasks}
-     *                    will be used to update the file containing the Task.
-     *                    An alternative implementation can be used in tests.
+     * @param taskSaver - an optional {@link TaskSaver} function. For details, see {@link TaskEditingMenu}.
      */
     constructor(statusRegistry: StatusRegistry, task: Task, taskSaver: TaskSaver = defaultTaskSaver) {
-        super();
+        super(taskSaver);
 
         this.statusRegistry = statusRegistry;
-        this.taskSaver = taskSaver;
 
         const commonTitle = 'Change status to:';
 
diff --git a/src/ui/Menus/TaskEditingMenu.ts b/src/ui/Menus/TaskEditingMenu.ts
new file mode 100644
index 00000000..8d8421de
--- /dev/null
+++ b/src/ui/Menus/TaskEditingMenu.ts
@@ -0,0 +1,42 @@
+import { Menu } from 'obsidian';
+import type { Task } from '../../Task';
+import { replaceTaskWithTasks } from '../../File';
+
+/**
+ * A function for replacing one task with zero or more new tasks.
+ * @see {@link defaultTaskSaver}
+ */
+export type TaskSaver = (originalTask: Task, newTasks: Task | Task[]) => Promise<void>;
+
+/**
+ * A default implementation of {@link TaskSaver} that calls {@link replaceTaskWithTasks}
+ * @param originalTask
+ * @param newTasks
+ */
+export async function defaultTaskSaver(originalTask: Task, newTasks: Task | Task[]) {
+    await replaceTaskWithTasks({
+        originalTask,
+        newTasks,
+    });
+}
+
+/**
+ * Base class for Menus that offer editing one or more properties of a Task object.
+ *
+ * A {@link TaskSaver} function must be supplied, in order for any edits to be saved.
+ * Derived classes should default to using {@link defaultTaskSaver}, but allow
+ * alternative implementations to be used in tests.
+ */
+export class TaskEditingMenu extends Menu {
+    protected readonly taskSaver: TaskSaver;
+
+    /**
+     * Constructor, which sets up the menu items.
+     * @param taskSaver - a {@link TaskSaver} function, for saving any edits.
+     */
+    constructor(taskSaver: TaskSaver) {
+        super();
+
+        this.taskSaver = taskSaver;
+    }
+}


Test patch:
diff --git a/tests/ui/EditInstructions/PriorityInstructions.test.ts b/tests/ui/EditInstructions/PriorityInstructions.test.ts
new file mode 100644
index 00000000..cf79cccd
--- /dev/null
+++ b/tests/ui/EditInstructions/PriorityInstructions.test.ts
@@ -0,0 +1,56 @@
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import { Priority } from '../../../src/Task';
+import { SetPriority, allPriorityInstructions } from '../../../src/ui/EditInstructions/PriorityInstructions';
+
+describe('SetPriority', () => {
+    const lowPriorityTask = new TaskBuilder().priority(Priority.Low).build();
+    const normalPriorityTask = new TaskBuilder().priority(Priority.None).build();
+    const highPriorityTask = new TaskBuilder().priority(Priority.High).build();
+
+    it('should provide information to set up a menu item for setting priority', () => {
+        // Arrange
+        const instruction = new SetPriority(Priority.None);
+
+        // Assert
+        expect(instruction.instructionDisplayName()).toEqual('Priority: Normal');
+        expect(instruction.isCheckedForTask(highPriorityTask)).toEqual(false);
+        expect(instruction.isCheckedForTask(normalPriorityTask)).toEqual(true);
+    });
+
+    it('should edit priority', () => {
+        // Arrange
+        const instruction = new SetPriority(Priority.High);
+
+        // Act
+        const newTasks = instruction.apply(lowPriorityTask);
+
+        // Assert
+        expect(newTasks.length).toEqual(1);
+        expect(newTasks[0].priority).toEqual(Priority.High);
+    });
+
+    it('should not edit task if already has chosen priority', () => {
+        // Arrange
+        const instruction = new SetPriority(Priority.High);
+
+        // Act
+        const newTasks = instruction.apply(highPriorityTask);
+
+        // Assert
+        expect(newTasks.length).toEqual(1);
+        // Expect it is the same object
+        expect(Object.is(newTasks[0], highPriorityTask)).toBe(true);
+    });
+});
+
+describe('All Priority Instructions', () => {
+    it('should supply all priority instructions', () => {
+        // Arrange
+        const allInstructions = allPriorityInstructions();
+
+        // Assert
+        expect(allInstructions.length).toBe(6);
+        expect(allInstructions[0].newPriority).toBe(Priority.Highest);
+        expect(allInstructions[5].newPriority).toBe(Priority.Lowest);
+    });
+});
diff --git a/tests/ui/Menus/PriorityMenu.test.ts b/tests/ui/Menus/PriorityMenu.test.ts
new file mode 100644
index 00000000..bdc0fee5
--- /dev/null
+++ b/tests/ui/Menus/PriorityMenu.test.ts
@@ -0,0 +1,89 @@
+import { PriorityMenu } from '../../../src/ui/Menus/PriorityMenu';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import type { MenuItem } from '../../__mocks__/obsidian';
+import type { Task } from '../../../src/Task';
+import { Priority } from '../../../src/Task';
+
+export {};
+
+function menuToString(menu: PriorityMenu) {
+    // @ts-expect-error TS2339: Property 'items' does not exist on type 'PriorityMenu'.
+    const items: MenuItem[] = menu.items;
+    return '\n' + items.map((item) => `${item.checked ? 'x' : ' '} ${item.title}`).join('\n');
+}
+
+describe('PriorityMenu', () => {
+    let taskBeingOverwritten: Task | undefined;
+    let tasksBeingSaved: Task[] | undefined;
+
+    async function testableTaskSaver(originalTask: Task, newTasks: Task | Task[]) {
+        taskBeingOverwritten = originalTask;
+        tasksBeingSaved = Array.isArray(newTasks) ? newTasks : [newTasks];
+    }
+
+    beforeEach(() => {
+        taskBeingOverwritten = undefined;
+        tasksBeingSaved = undefined;
+    });
+
+    it('should show checkmark against the current task priority', () => {
+        // Arrange
+        const task = new TaskBuilder().build();
+
+        // Act
+        const menu = new PriorityMenu(task);
+
+        // Assert
+        const itemsAsText = menuToString(menu);
+        expect(itemsAsText).toMatchInlineSnapshot(`
+            "
+              Priority: Highest
+              Priority: High
+              Priority: Medium
+            x Priority: Normal
+              Priority: Low
+              Priority: Lowest"
+        `);
+    });
+
+    it('should modify task, if different priority selected', () => {
+        // Arrange
+        const task = new TaskBuilder().build();
+        const menu = new PriorityMenu(task, testableTaskSaver);
+
+        // Act
+        // @ts-expect-error TS2339: Property 'items' does not exist on type 'PriorityMenu'.
+        const todoItem = menu.items[0];
+        expect(todoItem.title).toEqual('Priority: Highest');
+        todoItem.callback();
+
+        // Assert
+        expect(taskBeingOverwritten).not.toBeUndefined();
+        expect(Object.is(task, taskBeingOverwritten)).toEqual(true);
+        expect(taskBeingOverwritten!.priority).toEqual(Priority.None);
+
+        expect(tasksBeingSaved).not.toBeUndefined();
+        expect(tasksBeingSaved!.length).toEqual(1);
+        expect(tasksBeingSaved![0].priority).toEqual(Priority.Highest);
+    });
+
+    it('should not modify task, if current priority selected', () => {
+        // Arrange
+        const task = new TaskBuilder().priority(Priority.Highest).build();
+
+        // Act
+        const menu = new PriorityMenu(task, testableTaskSaver);
+
+        // Act
+        // @ts-expect-error TS2339: Property 'items' does not exist on type 'PriorityMenu'.
+        const todoItem = menu.items[0];
+        expect(todoItem.title).toEqual('Priority: Highest');
+        todoItem.callback();
+
+        // Assert
+        // testableTaskSaver() should never have been called, so the values
+        // it saves should still be undefined:
+        expect(taskBeingOverwritten).toBeUndefined();
+        expect(tasksBeingSaved).toBeUndefined();
+    });
+});