Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2455 (2023-11-29 05:57:06)

feat: postponement usability improvements
# Description

- render postpone button after the edit button
- do not render 'Postpone' next to postpone button
- do not render the postpone button if task has DONE, CANCELLED or NON_TASK
- fix a typo in a notice

## Motivation and Context

Improve postponement rendering & usability

## How has this been tested?

Exploratory test

## Screenshots
![Снимок экрана 2023-11-29 в 12 04 43](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/93825870/9ed71dfc-aee5-4c28-9a6a-b961afe0184a)

Note the position of the rendered postpone button on the last task only, absence of the 'Postpone' label and the absence of the postpone button among the first 3 tasks with different statuses (DONE, CANCELLED or NON_TASK)

## Types of changes

- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)

## Checklist

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

- [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/QueryRenderer.ts b/src/QueryRenderer.ts
index d366346f..4071e292 100644
--- a/src/QueryRenderer.ts
+++ b/src/QueryRenderer.ts
@@ -1,6 +1,6 @@
+import type { Moment, unitOfTime } from 'moment';
 import type { EventRef, MarkdownPostProcessorContext } from 'obsidian';
 import { App, Keymap, MarkdownRenderChild, MarkdownRenderer, Menu, MenuItem, Notice, Plugin, TFile } from 'obsidian';
-import type { unitOfTime } from 'moment';
 import { State } from './Cache';
 import { GlobalFilter } from './Config/GlobalFilter';
 import { GlobalQuery } from './Config/GlobalQuery';
@@ -8,16 +8,17 @@ import { DateFallback } from './DateFallback';
 import { getTaskLineAndFile, replaceTaskWithTasks } from './File';
 
 import type { IQuery } from './IQuery';
+import { explainResults, getQueryForQueryRenderer } from './lib/QueryRendererHelper';
+import type { GroupDisplayHeading } from './Query/GroupDisplayHeading';
+import type { QueryResult } from './Query/QueryResult';
+import type { TaskGroups } from './Query/TaskGroups';
 import {
     type HappensDate,
     createPostponedTask,
-    explainResults,
     getDateFieldToPostpone,
-    getQueryForQueryRenderer,
-} from './lib/QueryRendererHelper';
-import type { GroupDisplayHeading } from './Query/GroupDisplayHeading';
-import type { QueryResult } from './Query/QueryResult';
-import type { TaskGroups } from './Query/TaskGroups';
+    postponementSuccessMessage,
+    shouldShowPostponeButton,
+} from './Scripting/Postponer';
 import type { Task } from './Task';
 import { TaskLayout } from './TaskLayout';
 import { TaskLineRenderer } from './TaskLineRenderer';
@@ -239,20 +240,15 @@ class QueryRenderChild extends MarkdownRenderChild {
             const footnotes = listItem.querySelectorAll('[data-footnote-id]');
             footnotes.forEach((footnote) => footnote.remove());
 
-            const shortMode = this.query.layoutOptions.shortMode;
-
             const extrasSpan = listItem.createSpan('task-extras');
 
-            if (!this.query.layoutOptions.hidePostponeButton) {
-                this.addPostponeButton(extrasSpan, task, shortMode);
-            }
-
             if (!this.query.layoutOptions.hideUrgency) {
                 this.addUrgency(extrasSpan, task);
             }
 
+            const shortMode = this.query.layoutOptions.shortMode;
+
             if (!this.query.layoutOptions.hideBacklinks) {
-                const shortMode = this.query.layoutOptions.shortMode;
                 this.addBacklinks(extrasSpan, task, shortMode, isFilenameUnique);
             }
 
@@ -260,6 +256,10 @@ class QueryRenderChild extends MarkdownRenderChild {
                 this.addEditButton(extrasSpan, task);
             }
 
+            if (!this.query.layoutOptions.hidePostponeButton && shouldShowPostponeButton(task)) {
+                this.addPostponeButton(extrasSpan, task, shortMode);
+            }
+
             // NEW
             // if (!this.query.layoutOptions.hideSnoozeButton) {
             //     this.addUnSnoozeButton(extrasSpan, task, shortMode);
@@ -413,8 +413,7 @@ class QueryRenderChild extends MarkdownRenderChild {
 
         const classNames = shortMode ? ['internal-button', 'internal-button-short-mode'] : ['internal-button'];
         button.addClasses(classNames);
-        const buttonText = shortMode ? ' ⏩' : ' ⏩ Postpone';
-        button.setText(buttonText);
+        button.setText(' ⏩');
 
         button.addEventListener('click', () => this.getOnClickCallback(task, button, 'days'));
 
@@ -496,19 +495,16 @@ class QueryRenderChild extends MarkdownRenderChild {
             newTasks,
         });
 
-        const postponedDateString = postponedDate?.format('DD MMM YYYY');
-        this.onPostponeSuccessCallback(button, dateTypeToUpdate, postponedDateString);
+        this.onPostponeSuccessCallback(button, dateTypeToUpdate, postponedDate);
     }
 
-    private onPostponeSuccessCallback(
-        button: HTMLButtonElement,
-        updatedDateType: HappensDate,
-        postponedDateString: string,
-    ) {
+    private onPostponeSuccessCallback(button: HTMLButtonElement, updatedDateType: HappensDate, postponedDate: Moment) {
         // Disable the button to prevent update error due to the task not being reloaded yet.
         button.disabled = true;
         button.setAttr('title', 'You can perform this action again after reloading the file.');
-        new Notice(`Task's ${updatedDateType} postponed untill ${postponedDateString}`, 5000);
+
+        const successMessage = postponementSuccessMessage(postponedDate, updatedDateType);
+        new Notice(successMessage, 5000);
         this.events.triggerRequestCacheUpdate(this.render.bind(this));
     }
 }
diff --git a/src/Scripting/Postponer.ts b/src/Scripting/Postponer.ts
new file mode 100644
index 00000000..cf44adb4
--- /dev/null
+++ b/src/Scripting/Postponer.ts
@@ -0,0 +1,49 @@
+import type { Moment, unitOfTime } from 'moment';
+import { Task } from '../Task';
+import { TasksDate } from './TasksDate';
+
+export function shouldShowPostponeButton(task: Task) {
+    return !task.isDone;
+}
+
+export type HappensDate = keyof Pick<Task, 'startDate' | 'scheduledDate' | 'dueDate'>;
+
+/**
+ * Gets a {@link HappensDate} field from a {@link Task} with the following priority: due > scheduled > start.
+ * If the task has no happens field {@link HappensDate}, null is returned.
+ *
+ * @param task
+ */
+export function getDateFieldToPostpone(task: Task): HappensDate | null {
+    if (task.dueDate) {
+        return 'dueDate';
+    }
+
+    if (task.scheduledDate) {
+        return 'scheduledDate';
+    }
+
+    if (task.startDate) {
+        return 'startDate';
+    }
+
+    return null;
+}
+
+export function createPostponedTask(
+    task: Task,
+    dateTypeToUpdate: HappensDate,
+    timeUnit: unitOfTime.DurationConstructor,
+    amount: number,
+) {
+    const dateToUpdate = task[dateTypeToUpdate] as Moment;
+    const postponedDate = new TasksDate(dateToUpdate).postpone(timeUnit, amount);
+    const newTasks = new Task({ ...task, [dateTypeToUpdate]: postponedDate });
+    return { postponedDate, newTasks };
+}
+
+export function postponementSuccessMessage(postponedDate: Moment, updatedDateType: HappensDate) {
+    // TODO all logic for invalid dates
+    const postponedDateString = postponedDate?.format('DD MMM YYYY');
+    return `Task's ${updatedDateType} postponed until ${postponedDateString}`;
+}
diff --git a/src/lib/QueryRendererHelper.ts b/src/lib/QueryRendererHelper.ts
index d8f46f3e..1221f2d8 100644
--- a/src/lib/QueryRendererHelper.ts
+++ b/src/lib/QueryRendererHelper.ts
@@ -1,9 +1,6 @@
-import type { Moment, unitOfTime } from 'moment';
 import type { GlobalFilter } from '../Config/GlobalFilter';
 import type { GlobalQuery } from '../Config/GlobalQuery';
 import { Query } from '../Query/Query';
-import { Task } from '../Task';
-import { TasksDate } from '../Scripting/TasksDate';
 
 /**
  * @summary
@@ -71,39 +68,3 @@ export function getQueryForQueryRenderer(source: string, globalQuery: GlobalQuer
 
     return globalQuery.query(path).append(tasksBlockQuery);
 }
-
-export type HappensDate = keyof Pick<Task, 'startDate' | 'scheduledDate' | 'dueDate'>;
-
-/**
- * Gets a {@link HappensDate} field from a {@link Task} with the following priority: due > scheduled > start.
- * If the task has no happens field {@link HappensDate}, null is returned.
- *
- * @param task
- */
-export function getDateFieldToPostpone(task: Task): HappensDate | null {
-    if (task.dueDate) {
-        return 'dueDate';
-    }
-
-    if (task.scheduledDate) {
-        return 'scheduledDate';
-    }
-
-    if (task.startDate) {
-        return 'startDate';
-    }
-
-    return null;
-}
-
-export function createPostponedTask(
-    task: Task,
-    dateTypeToUpdate: HappensDate,
-    timeUnit: unitOfTime.DurationConstructor,
-    amount: number,
-) {
-    const dateToUpdate = task[dateTypeToUpdate] as Moment;
-    const postponedDate = new TasksDate(dateToUpdate).postpone(timeUnit, amount);
-    const newTasks = new Task({ ...task, [dateTypeToUpdate]: postponedDate });
-    return { postponedDate, newTasks };
-}


Test patch:
diff --git a/tests/Scripting/Postponer.test.ts b/tests/Scripting/Postponer.test.ts
new file mode 100644
index 00000000..ee223444
--- /dev/null
+++ b/tests/Scripting/Postponer.test.ts
@@ -0,0 +1,101 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import {
+    type HappensDate,
+    getDateFieldToPostpone,
+    postponementSuccessMessage,
+    shouldShowPostponeButton,
+} from '../../src/Scripting/Postponer';
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
+import { StatusConfiguration, StatusType } from '../../src/StatusConfiguration';
+import { Status } from '../../src/Status';
+
+window.moment = moment;
+
+describe('postpone - date field choice', () => {
+    function checkPostponeField(taskBuilder: TaskBuilder, expected: HappensDate | null) {
+        const task = taskBuilder.build();
+        expect(getDateFieldToPostpone(task)).toEqual(expected);
+    }
+
+    function checkDoesNotPostpone(taskBuilder: TaskBuilder) {
+        checkPostponeField(taskBuilder, null);
+    }
+
+    // Since the actual date values do not affect the calculation, we use the same value for all tests,
+    // so that the field names stand out when comparing tests.
+    const date = '2023-11-26';
+
+    it('should not postpone if no happens dates on task', () => {
+        const taskBuilder = new TaskBuilder();
+        checkDoesNotPostpone(taskBuilder);
+    });
+
+    it('should not postpone created or done dates', () => {
+        const taskBuilder = new TaskBuilder().createdDate(date).doneDate(date);
+        checkDoesNotPostpone(taskBuilder);
+    });
+
+    it('should postpone due date', () => {
+        const taskBuilder = new TaskBuilder().dueDate(date);
+        checkPostponeField(taskBuilder, 'dueDate');
+    });
+
+    it('should postpone scheduled date', () => {
+        const taskBuilder = new TaskBuilder().scheduledDate(date);
+        checkPostponeField(taskBuilder, 'scheduledDate');
+    });
+
+    it('should postpone when scheduled date is inferred', () => {
+        const taskBuilder = new TaskBuilder().scheduledDate(date).scheduledDateIsInferred(true);
+        checkPostponeField(taskBuilder, 'scheduledDate');
+    });
+
+    it('should postpone start date', () => {
+        const taskBuilder = new TaskBuilder().startDate(date);
+        checkPostponeField(taskBuilder, 'startDate');
+    });
+
+    it('should postpone due date in preference to start and scheduled dates', () => {
+        const taskBuilder = new TaskBuilder().dueDate(date).scheduledDate(date).startDate(date);
+        checkPostponeField(taskBuilder, 'dueDate');
+    });
+
+    it('should postpone scheduled date in preference to start date', () => {
+        const taskBuilder = new TaskBuilder().scheduledDate(date).startDate(date);
+        checkPostponeField(taskBuilder, 'scheduledDate');
+    });
+});
+
+describe('postpone - whether to show button', () => {
+    it('should account for status type', () => {
+        function checkPostponeButtonVisibility(statusType: StatusType, expected: boolean) {
+            const status = new Status(new StatusConfiguration('p', 'Test', 'q', true, statusType));
+            const task = new TaskBuilder().status(status).build();
+            expect(shouldShowPostponeButton(task)).toEqual(expected);
+        }
+
+        // Statuses considered as done:
+        checkPostponeButtonVisibility(StatusType.TODO, true);
+        checkPostponeButtonVisibility(StatusType.IN_PROGRESS, true);
+
+        // Statuses considered as not done:
+        checkPostponeButtonVisibility(StatusType.NON_TASK, false);
+        checkPostponeButtonVisibility(StatusType.CANCELLED, false);
+        checkPostponeButtonVisibility(StatusType.DONE, false);
+    });
+});
+
+describe('postpone - postponement success message', () => {
+    it('should generate a message for a valid date', () => {
+        const message = postponementSuccessMessage(moment('2023-11-30'), 'scheduledDate');
+        expect(message).toEqual("Task's scheduledDate postponed until 30 Nov 2023");
+    });
+
+    it('should generate a message for an invalid date', () => {
+        const message = postponementSuccessMessage(moment('2023-13-30'), 'dueDate');
+        expect(message).toEqual("Task's dueDate postponed until Invalid date");
+    });
+});
diff --git a/tests/lib/QueryRendererHelper.test.ts b/tests/lib/QueryRendererHelper.test.ts
index 54f6468f..ec419e14 100644
--- a/tests/lib/QueryRendererHelper.test.ts
+++ b/tests/lib/QueryRendererHelper.test.ts
@@ -3,15 +3,9 @@
  */
 import moment from 'moment';
 import { Query } from '../../src/Query/Query';
-import {
-    type HappensDate,
-    explainResults,
-    getDateFieldToPostpone,
-    getQueryForQueryRenderer,
-} from '../../src/lib/QueryRendererHelper';
+import { explainResults, getQueryForQueryRenderer } from '../../src/lib/QueryRendererHelper';
 import { GlobalFilter } from '../../src/Config/GlobalFilter';
 import { GlobalQuery } from '../../src/Config/GlobalQuery';
-import { TaskBuilder } from '../TestingTools/TaskBuilder';
 
 window.moment = moment;
 
@@ -132,58 +126,3 @@ describe('query used for QueryRenderer', () => {
         expect(query.filePath).toEqual(filePath);
     });
 });
-
-describe('postpone - date field choice', () => {
-    function checkPostponeField(taskBuilder: TaskBuilder, expected: HappensDate | null) {
-        const task = taskBuilder.build();
-        expect(getDateFieldToPostpone(task)).toEqual(expected);
-    }
-
-    function checkDoesNotPostpone(taskBuilder: TaskBuilder) {
-        checkPostponeField(taskBuilder, null);
-    }
-
-    // Since the actual date values do not affect the calculation, we use the same value for all tests,
-    // so that the field names stand out when comparing tests.
-    const date = '2023-11-26';
-
-    it('should not postpone if no happens dates on task', () => {
-        const taskBuilder = new TaskBuilder();
-        checkDoesNotPostpone(taskBuilder);
-    });
-
-    it('should not postpone created or done dates', () => {
-        const taskBuilder = new TaskBuilder().createdDate(date).doneDate(date);
-        checkDoesNotPostpone(taskBuilder);
-    });
-
-    it('should postpone due date', () => {
-        const taskBuilder = new TaskBuilder().dueDate(date);
-        checkPostponeField(taskBuilder, 'dueDate');
-    });
-
-    it('should postpone scheduled date', () => {
-        const taskBuilder = new TaskBuilder().scheduledDate(date);
-        checkPostponeField(taskBuilder, 'scheduledDate');
-    });
-
-    it('should postpone when scheduled date is inferred', () => {
-        const taskBuilder = new TaskBuilder().scheduledDate(date).scheduledDateIsInferred(true);
-        checkPostponeField(taskBuilder, 'scheduledDate');
-    });
-
-    it('should postpone start date', () => {
-        const taskBuilder = new TaskBuilder().startDate(date);
-        checkPostponeField(taskBuilder, 'startDate');
-    });
-
-    it('should postpone due date in preference to start and scheduled dates', () => {
-        const taskBuilder = new TaskBuilder().dueDate(date).scheduledDate(date).startDate(date);
-        checkPostponeField(taskBuilder, 'dueDate');
-    });
-
-    it('should postpone scheduled date in preference to start date', () => {
-        const taskBuilder = new TaskBuilder().scheduledDate(date).startDate(date);
-        checkPostponeField(taskBuilder, 'scheduledDate');
-    });
-});