Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1460 (2023-01-02 19:26:56)

feat: Add & document 'In Progress' + 'Cancelled'
<!--- Provide a general summary of your changes in the Title above -->

# Description

This adds support and docs for 'In Progress' + 'Cancelled' statuses.

Notes:

- Users will need to install a Theme or Snippet for the new statuses to be visually distinguishable.
- The new documentation that has been added contains a machine-generated markdown table of supported statuses, created by using ApprovalTests.
- This process does need to be better documented for contributors, and automated (to avoid needing to run MarkdownSnippets by hand).
- For now, search for MarkdownSnippets in CONTRIBUTING.md 

Credit: Some of this PR is a copy of some code and tests in @sytone's fork https://github.com/sytone/obsidian-tasks-x on its `main-tasks-sql` branch.

The revision copied from is:
https://github.com/sytone/obsidian-tasks-x/commit/2c0b659457cc80806ff18585c955496c76861b87

Co-Authored-By: Sytone <1399443+sytone@users.noreply.github.com>

## Motivation and Context

Want to get on with offering customisation of statuses - and that needs 'In Progress' + 'Cancelled' to be added first.

## How has this been tested?

Via unit tests.

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content)

Internal changes:

and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [x] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## 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`.
- [x] My change requires a change to the documentation.
- [x] I have [updated the documentation](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#maintaining-the-tests).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md) 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://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)
- [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/docs/getting-started/statuses.md b/docs/getting-started/statuses.md
index 9a29f41c..eee6a93f 100644
--- a/docs/getting-started/statuses.md
+++ b/docs/getting-started/statuses.md
@@ -36,6 +36,25 @@ The convention in markdown is:
 
 ### Custom Markdown task statuses
 
+- `> Introduced in Tasks X.Y.Z`
+
+Tasks supports custom task statuses.
+
+This table shows the statues provided by default:
+
+<!-- include: DocsSamplesForStatuses.test.DefaultStatuses_markdown-table.approved.md -->
+| Status Character    | Status Name | Next Status Character |
+| ------------------- | ----------- | --------------------- |
+| `space` | Todo | `x` |
+| `/` | In Progress | `x` |
+| `x` | Done | `space` |
+| `-` | Cancelled | `space` |
+<!-- endInclude -->
+
+Note that `Todo` is followed by `Done`, in order to preserve compatibility with earlier Tasks releases.
+
+It will soon be possible to edit these custom statues, and enable `Todo` -> `In Progress` -> `Done`.
+
 ## Credit: Sytone and the 'Tasks SQL Powered' plugin
 
 This plugin's implementation of reading, searching and editing custom statuses was entirely made possible by the work of [Sytone](https://github.com/sytone) and his fork of Tasks called ['Tasks SQL Powered'](https://github.com/sytone/obsidian-tasks-x).
diff --git a/package.json b/package.json
index 19147dd9..c08496f2 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "build": "node esbuild.config.mjs production",
     "build:dev": "node esbuild.config.mjs development",
     "lint": "eslint ./src --fix && eslint ./tests --fix && tsc --noEmit --pretty && svelte-check",
-    "lint:markdown": "markdownlint-cli2-fix \"**/*.md\" \"#node_modules\"",
+    "lint:markdown": "markdownlint-cli2-fix \"**/*.md\" \"#node_modules\"  \"#tests\"",
     "test": "jest --ci",
     "test:dev": "jest --watch",
     "deploy:local": "pwsh -ExecutionPolicy Unrestricted -NoProfile -File ./scripts/Test-TasksInLocalObsidian.ps1"
diff --git a/src/Status.ts b/src/Status.ts
index e433d91f..d24e452b 100644
--- a/src/Status.ts
+++ b/src/Status.ts
@@ -90,6 +90,24 @@ export class Status {
      */
     public static TODO: Status = new Status(new StatusConfiguration(' ', 'Todo', 'x', true));
 
+    /**
+     * The default Cancelled status. Goes to Todo when toggled.
+     *
+     * @static
+     * @type {Status}
+     * @memberof Status
+     */
+    public static CANCELLED: Status = new Status(new StatusConfiguration('-', 'Cancelled', ' ', true));
+
+    /**
+     * The default In Progress status. Goes to Done when toggled.
+     *
+     * @static
+     * @type {Status}
+     * @memberof Status
+     */
+    public static IN_PROGRESS: Status = new Status(new StatusConfiguration('/', 'In Progress', 'x', true));
+
     /**
      * The configuration stored in the data.json file.
      *
diff --git a/src/StatusRegistry.ts b/src/StatusRegistry.ts
index 70858ce4..c3db5498 100644
--- a/src/StatusRegistry.ts
+++ b/src/StatusRegistry.ts
@@ -21,6 +21,17 @@ export class StatusRegistry {
         this.addDefaultStatusTypes();
     }
 
+    /**
+     * Returns all the registered statuses minus the empty status.
+     *
+     * @readonly
+     * @type {Status[]}
+     * @memberof StatusRegistry
+     */
+    public get registeredStatuses(): Status[] {
+        return this._registeredStatuses.filter(({ indicator }) => indicator !== Status.EMPTY.indicator);
+    }
+
     /**
      * The static method that controls the access to the StatusRegistry instance.
      *
@@ -68,6 +79,21 @@ export class StatusRegistry {
         return Status.EMPTY;
     }
 
+    /**
+     * Returns the registered status by the name assigned by the user.
+     *
+     * @param {string} nameToFind
+     * @return {*}  {Status}
+     * @memberof StatusRegistry
+     */
+    public byName(nameToFind: string): Status {
+        if (this._registeredStatuses.filter(({ name }) => name === nameToFind).length > 0) {
+            return this._registeredStatuses.filter(({ name }) => name === nameToFind)[0];
+        }
+
+        return Status.EMPTY;
+    }
+
     /**
      * Resets the array os Status types to be empty.
      *
@@ -130,7 +156,7 @@ export class StatusRegistry {
      * @memberof StatusRegistry
      */
     private addDefaultStatusTypes(): void {
-        const defaultStatuses = [Status.TODO, Status.DONE, Status.EMPTY];
+        const defaultStatuses = [Status.TODO, Status.IN_PROGRESS, Status.DONE, Status.CANCELLED, Status.EMPTY];
 
         defaultStatuses.forEach((status) => {
             this.add(status);


Test patch:
diff --git a/tests/DocsSamplesForStatuses.test.DefaultStatuses_markdown-table.approved.md b/tests/DocsSamplesForStatuses.test.DefaultStatuses_markdown-table.approved.md
new file mode 100644
index 00000000..6978e49c
--- /dev/null
+++ b/tests/DocsSamplesForStatuses.test.DefaultStatuses_markdown-table.approved.md
@@ -0,0 +1,6 @@
+| Status Character    | Status Name | Next Status Character |
+| ------------------- | ----------- | --------------------- |
+| `space` | Todo | `x` |
+| `/` | In Progress | `x` |
+| `x` | Done | `space` |
+| `-` | Cancelled | `space` |
diff --git a/tests/DocsSamplesForStatuses.test.ts b/tests/DocsSamplesForStatuses.test.ts
new file mode 100644
index 00000000..8306e8d3
--- /dev/null
+++ b/tests/DocsSamplesForStatuses.test.ts
@@ -0,0 +1,28 @@
+import { Options } from 'approvals/lib/Core/Options';
+import { verify } from 'approvals/lib/Providers/Jest/JestApprovals';
+
+import { StatusRegistry } from '../src/StatusRegistry';
+
+function getPrintableIndicator(indicator: string) {
+    const result = indicator !== ' ' ? indicator : 'space';
+    return '`' + result + '`';
+}
+
+describe('DefaultStatuses', () => {
+    it('markdown-table', () => {
+        // This "test" writes out a markdown representation of the default task statuses,
+        // for embedding in the user docs.
+        const instance = StatusRegistry.getInstance();
+        let commandsTable = '';
+        commandsTable += '| Status Character    | Status Name | Next Status Character |\n';
+        commandsTable += '| ------------------- | ----------- | --------------------- |\n';
+        for (const status of instance.registeredStatuses) {
+            const statusCharacter = getPrintableIndicator(status.indicator);
+            const nextStatusCharacter = getPrintableIndicator(status.nextStatusIndicator);
+            commandsTable += `| ${statusCharacter} | ${status.name} | ${nextStatusCharacter} |\n`;
+        }
+        let options = new Options();
+        options = options.forFile().withFileExtention('md');
+        verify(commandsTable, options);
+    });
+});
diff --git a/tests/StatusRegistry.test.ts b/tests/StatusRegistry.test.ts
index 6bc3bb80..639eca5c 100644
--- a/tests/StatusRegistry.test.ts
+++ b/tests/StatusRegistry.test.ts
@@ -33,11 +33,76 @@ describe('StatusRegistry', () => {
         expect(statusRegistry.byIndicator('x').indicator).toEqual(Status.DONE.indicator);
         expect(statusRegistry.byIndicator('').indicator).toEqual(Status.EMPTY.indicator);
         expect(statusRegistry.byIndicator(' ').indicator).toEqual(Status.TODO.indicator);
+        expect(statusRegistry.byIndicator('-').indicator).toEqual(Status.CANCELLED.indicator);
+        expect(statusRegistry.byIndicator('/').indicator).toEqual(Status.IN_PROGRESS.indicator);
 
         // Detect unrecognised indicator:
         expect(statusRegistry.byIndicator('?').indicator).toEqual(Status.EMPTY.indicator);
     });
 
+    it('should allow task to toggle through standard transitions', () => {
+        // Arrange
+        const statusRegistry = StatusRegistry.getInstance();
+        statusRegistry.clearStatuses();
+        const line = '- [ ] this is a task starting at A';
+        const path = 'file.md';
+        const sectionStart = 1337;
+        const sectionIndex = 1209;
+        const precedingHeader = 'Eloquent Section';
+        const task = Task.fromLine({
+            line,
+            path,
+            sectionStart,
+            sectionIndex,
+            precedingHeader,
+            fallbackDate: null,
+        });
+
+        // Act
+
+        // Assert
+        expect(task).not.toBeNull();
+        expect(task!.status.indicator).toEqual(Status.TODO.indicator);
+
+        // In Tasks, TODO toggles to DONE, for consistency with earlier releases.
+        // const toggledInProgress = task?.toggle()[0];
+        // expect(toggledInProgress?.status.indicator).toEqual(Status.IN_PROGRESS.indicator);
+
+        const toggledDone = task?.toggle()[0];
+        expect(toggledDone?.status.indicator).toEqual(Status.DONE.indicator);
+
+        const toggledTodo = toggledDone?.toggle()[0];
+        expect(toggledTodo?.status.indicator).toEqual(Status.TODO.indicator);
+    });
+
+    it('should allow task to toggle from cancelled to todo', () => {
+        // Arrange
+        const statusRegistry = StatusRegistry.getInstance();
+        statusRegistry.clearStatuses();
+        const line = '- [-] This is a cancelled task';
+        const path = 'file.md';
+        const sectionStart = 1337;
+        const sectionIndex = 1209;
+        const precedingHeader = 'Eloquent Section';
+        const task = Task.fromLine({
+            line,
+            path,
+            sectionStart,
+            sectionIndex,
+            precedingHeader,
+            fallbackDate: null,
+        });
+
+        // Act
+
+        // Assert
+        expect(task).not.toBeNull();
+        expect(task!.status.indicator).toEqual(Status.CANCELLED.indicator);
+
+        const toggledTodo = task?.toggle()[0];
+        expect(toggledTodo?.status.indicator).toEqual(Status.TODO.indicator);
+    });
+
     it('should allow lookup of next status for a status', () => {
         // Arrange
         const statusRegistry = StatusRegistry.getInstance();