Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1518 (2023-01-11 17:26:58)

feat: Add 'sort by status.name'
<!--- Provide a general summary of your changes in the Title above -->

# Description

Add and document new instruction `sort by status.name`.
Also ensure that `sort by status-name` etc are not recognised.

## Motivation and Context

Part of rolling out new custom task status feature.

## How has this been tested?

Adding new tests, and running existing ones.

## 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:

- [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`.
- [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 e167192e..a4abedab 100644
--- a/docs/getting-started/statuses.md
+++ b/docs/getting-started/statuses.md
@@ -159,6 +159,7 @@ We are tracking this in [issue #1486](https://github.com/obsidian-tasks-group/ob
 - `done` - matches tasks with anything except space as the status symbol
 - `not done` - matches task with a space as the status symbol
 - `status.name` text search
+- `sort by status.name`
 
 For details, see [Filters for Task Statuses]({{ site.baseurl }}{% link queries/filters.md %}#filters-for-task-statuses)
 
diff --git a/docs/queries/sorting.md b/docs/queries/sorting.md
index 33389ae8..e8d7bb58 100644
--- a/docs/queries/sorting.md
+++ b/docs/queries/sorting.md
@@ -56,10 +56,19 @@ You can sort tasks by the following properties.
 {: .released }
 `sort by happens` was introduced in Tasks 1.21.0.
 
+### Task statuses
+
+1. `status` (done or todo)
+1. `status.name` (Done, Todo, Cancelled, In Progress, Unknown etc - sorted alphabetically)
+
+{: .released }
+`sort by status.name` was introduced in Tasks X.Y.Z.
+
+For more information, including adding your own customised statuses, see [Statuses]({{ site.baseurl }}{% link getting-started/statuses.md %}).
+
 ### Other task properties
 
 1. `description` (the description of the task)
-1. `status` (done or todo)
 1. `priority` (priority of the task; "low" is below "none": [priorities]({{ site.baseurl }}{% link getting-started/priority.md %}))
 1. `urgency` ([urgency]({{ site.baseurl }}{% link advanced/urgency.md %}))
 1. `tag` (the description of the task)
diff --git a/docs/quick-reference/index.md b/docs/quick-reference/index.md
index e3ed666e..7d694bf6 100644
--- a/docs/quick-reference/index.md
+++ b/docs/quick-reference/index.md
@@ -19,7 +19,7 @@ This table summarizes the filters and other options available inside a `tasks` b
 | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | --------------------- | ---------------------- |
 | `done`<br>`not done`                                                                                                                                                                                | `sort by status`                            | `group by status`     |                        |
 | `done (before, after, on) <date>`<br>`has done date`<br>`no  done date`<br>`done date is invalid`                                                                                                   | `sort by done`                              | `group by done`       | `hide done date`       |
-| `status.name (includes, does not include) <string>`<br>`status.name (regex matches, regex does not match) /regex/i`                                                                                 |                                             |                      |                        |
+| `status.name (includes, does not include) <string>`<br>`status.name (regex matches, regex does not match) /regex/i`                                                                                 | `sort by status.name`                       |                       |                        |
 | `starts (before, after, on) <date>`<br>`has start date`<br>`no  start date`<br>`start date is invalid`                                                                                              | `sort by start`                             | `group by start`      | `hide start date`      |
 | `scheduled (before, after, on) <date>`<br>`has scheduled date`<br>`no  scheduled date`<br>`scheduled date is invalid`                                                                               | `sort by scheduled`                         | `group by scheduled`  | `hide scheduled date`  |
 | `due (before, after, on) <date>`<br>`has due date`<br>`no  due date`<br>`due date is invalid`                                                                                                       | `sort by due`                               | `group by due`        | `hide due date`        |
diff --git a/src/Query/Filter/Field.ts b/src/Query/Filter/Field.ts
index 96cf3c8a..12577673 100644
--- a/src/Query/Filter/Field.ts
+++ b/src/Query/Filter/Field.ts
@@ -1,5 +1,6 @@
 import { Sorter } from '../Sorter';
 import type { Comparator } from '../Sorter';
+import * as RegExpTools from '../../lib/RegExpTools';
 import type { FilterOrErrorMessage } from './Filter';
 
 /**
@@ -86,6 +87,7 @@ export abstract class Field {
      * @public
      *
      * @see fieldNameSingular
+     * @see fieldNameSingularEscaped
      */
     public abstract fieldName(): string;
 
@@ -94,11 +96,25 @@ export abstract class Field {
      * @public
      *
      * @see fieldName
+     * @see fieldNameSingularEscaped
      */
     public fieldNameSingular(): string {
         return this.fieldName();
     }
 
+    /**
+     * Returns the singular form of the field's name, escaped for use in regular expressions.
+     *
+     * This is needed for field names that contain `.` in, for example.
+     * @public
+     *
+     * @see fieldName
+     * @see fieldNameSingular
+     */
+    public fieldNameSingularEscaped() {
+        return RegExpTools.escapeRegExp(this.fieldNameSingular());
+    }
+
     // -----------------------------------------------------------------------------------------------------------------
     // Sorting
     // -----------------------------------------------------------------------------------------------------------------
@@ -191,7 +207,7 @@ export abstract class Field {
             throw Error(`sorterRegExp() unimplemented for ${this.fieldNameSingular()}`);
         }
 
-        return new RegExp(`^sort by ${this.fieldNameSingular()}( reverse)?`);
+        return new RegExp(`^sort by ${this.fieldNameSingularEscaped()}( reverse)?`);
     }
 
     /**
diff --git a/src/Query/Filter/StatusNameField.ts b/src/Query/Filter/StatusNameField.ts
index 99ddeab8..76a1f3af 100644
--- a/src/Query/Filter/StatusNameField.ts
+++ b/src/Query/Filter/StatusNameField.ts
@@ -13,12 +13,11 @@ export class StatusNameField extends TextField {
         return 'status.name';
     }
 
-    protected fieldPattern(): string {
-        // Guard against accidental allowing of strings like 'status-name'
-        return 'status\\.name';
-    }
-
     value(task: Task): string {
         return task.status.name;
     }
+
+    supportsSorting(): boolean {
+        return true;
+    }
 }
diff --git a/src/Query/Filter/TextField.ts b/src/Query/Filter/TextField.ts
index ae879391..92cce099 100644
--- a/src/Query/Filter/TextField.ts
+++ b/src/Query/Filter/TextField.ts
@@ -56,7 +56,7 @@ export abstract class TextField extends Field {
      * Returns a regexp pattern matching the field's name and possible aliases
      */
     protected fieldPattern(): string {
-        return this.fieldName();
+        return this.fieldNameSingularEscaped();
     }
 
     /**
diff --git a/src/Task.ts b/src/Task.ts
index 31c085e0..aae2353c 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -10,6 +10,7 @@ import { DateField } from './Query/Filter/DateField';
 import { renderTaskLine } from './TaskLineRenderer';
 import type { TaskLineRenderDetails } from './TaskLineRenderer';
 import { DateFallback } from './DateFallback';
+import * as RegExpTools from './lib/RegExpTools';
 
 /**
  * When sorting, make sure low always comes after none. This way any tasks with low will be below any exiting
@@ -707,27 +708,6 @@ export class Task {
         return true;
     }
 
-    /**
-     * Escape a string so it can be used as part of a RegExp literally.
-     * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
-     */
-    private escapeRegExp(s: string) {
-        // NOTE: = is not escaped, as doing so gives error:
-        //         Invalid regular expression: /(^|\s)hello\=world($|\s)/: Invalid escape
-        // NOTE: ! is not escaped, as doing so gives error:
-        //         Invalid regular expression: /(^|\s)hello\!world($|\s)/: Invalid escape
-        // NOTE: : is not escaped, as doing so gives error:
-        //         Invalid regular expression: /(^|\s)hello\:world($|\s)/: Invalid escape
-        //
-        // Explanation from @AnnaKornfeldSimpson in:
-        // https://github.com/esm7/obsidian-tasks/pull/18#issuecomment-1196115407
-        // From what I can tell, the three missing characters from the original regex - : ! =
-        // are all only considered to have special meanings if they directly follow
-        // a ? (all 3) or a ?< (! and =).
-        // So theoretically if the ? are all escaped, those three characters do not have to be.
-        return s.replace(/([.*+?^${}()|[\]/\\])/g, '\\$1');
-    }
-
     /**
      * Search for the global filter for the purpose of removing it from the description, but do so only
      * if it is a separate word (preceding the beginning of line or a space and followed by the end of line
@@ -739,7 +719,7 @@ export class Task {
         let description = this.description;
         if (globalFilter.length === 0) return description;
         // This matches the global filter (after escaping it) only when it's a complete word
-        const globalFilterRegex = RegExp('(^|\\s)' + this.escapeRegExp(globalFilter) + '($|\\s)', 'ug');
+        const globalFilterRegex = RegExp('(^|\\s)' + RegExpTools.escapeRegExp(globalFilter) + '($|\\s)', 'ug');
         if (this.description.search(globalFilterRegex) > -1) {
             description = description.replace(globalFilterRegex, '$1$2').replace('  ', ' ').trim();
         }
diff --git a/src/lib/RegExpTools.ts b/src/lib/RegExpTools.ts
new file mode 100644
index 00000000..5fbca0e4
--- /dev/null
+++ b/src/lib/RegExpTools.ts
@@ -0,0 +1,20 @@
+/**
+ * Escape a string so it can be used as part of a RegExp literally.
+ * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
+ */
+export function escapeRegExp(s: string) {
+    // NOTE: = is not escaped, as doing so gives error:
+    //         Invalid regular expression: /(^|\s)hello\=world($|\s)/: Invalid escape
+    // NOTE: ! is not escaped, as doing so gives error:
+    //         Invalid regular expression: /(^|\s)hello\!world($|\s)/: Invalid escape
+    // NOTE: : is not escaped, as doing so gives error:
+    //         Invalid regular expression: /(^|\s)hello\:world($|\s)/: Invalid escape
+    //
+    // Explanation from @AnnaKornfeldSimpson in:
+    // https://github.com/esm7/obsidian-tasks/pull/18#issuecomment-1196115407
+    // From what I can tell, the three missing characters from the original regex - : ! =
+    // are all only considered to have special meanings if they directly follow
+    // a ? (all 3) or a ?< (! and =).
+    // So theoretically if the ? are all escaped, those three characters do not have to be.
+    return s.replace(/([.*+?^${}()|[\]/\\])/g, '\\$1');
+}


Test patch:
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 24baa220..ac440702 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -148,6 +148,8 @@ describe('Query parsing', () => {
             'sort by start',
             'sort by status reverse',
             'sort by status',
+            'sort by status.name',
+            'sort by status.name reverse',
             'sort by tag 5',
             'sort by tag reverse 3',
             'sort by tag reverse',
diff --git a/tests/Query/Filter/StatusNameField.test.ts b/tests/Query/Filter/StatusNameField.test.ts
index c8f9a476..521edbdc 100644
--- a/tests/Query/Filter/StatusNameField.test.ts
+++ b/tests/Query/Filter/StatusNameField.test.ts
@@ -1,23 +1,35 @@
 import { StatusNameField } from '../../../src/Query/Filter/StatusNameField';
 import * as TestHelpers from '../../TestHelpers';
 import { toBeValid, toMatchTaskFromLine } from '../../CustomMatchers/CustomMatchersForFilters';
+import {
+    expectTaskComparesAfter,
+    expectTaskComparesBefore,
+    expectTaskComparesEqual,
+} from '../../CustomMatchers/CustomMatchersForSorting';
 
 expect.extend({
     toBeValid,
     toMatchTaskFromLine,
 });
 
+// Abbreviated names so that the markdown text is aligned
+const todoTask = TestHelpers.fromLine({ line: '- [ ] Xxx' });
+const inprTask = TestHelpers.fromLine({ line: '- [/] Xxx' });
+const doneTask = TestHelpers.fromLine({ line: '- [x] Xxx' });
+const cancTask = TestHelpers.fromLine({ line: '- [-] Xxx' });
+const unknTask = TestHelpers.fromLine({ line: '- [%] Xxx' });
+
 describe('status.name', () => {
     it('value', () => {
         // Arrange
         const filter = new StatusNameField();
 
         // Assert
-        expect(filter.value(TestHelpers.fromLine({ line: '- [ ] Xxx' }))).toStrictEqual('Todo');
-        expect(filter.value(TestHelpers.fromLine({ line: '- [/] Xxx' }))).toStrictEqual('In Progress');
-        expect(filter.value(TestHelpers.fromLine({ line: '- [x] Xxx' }))).toStrictEqual('Done');
-        expect(filter.value(TestHelpers.fromLine({ line: '- [-] Xxx' }))).toStrictEqual('Cancelled');
-        expect(filter.value(TestHelpers.fromLine({ line: '- [%] Xxx' }))).toStrictEqual('Unknown');
+        expect(filter.value(todoTask)).toStrictEqual('Todo');
+        expect(filter.value(inprTask)).toStrictEqual('In Progress');
+        expect(filter.value(doneTask)).toStrictEqual('Done');
+        expect(filter.value(cancTask)).toStrictEqual('Cancelled');
+        expect(filter.value(unknTask)).toStrictEqual('Unknown');
     });
 
     it('status.name includes', () => {
@@ -39,3 +51,41 @@ describe('status.name', () => {
         expect(filter).not.toBeValid();
     });
 });
+
+describe('sorting by status.name', () => {
+    it('supports Field sorting methods correctly', () => {
+        const field = new StatusNameField();
+        expect(field.supportsSorting()).toEqual(true);
+    });
+
+    it('should parse sort line correctly', () => {
+        expect(new StatusNameField().createSorterFromLine('sort by status.name reverse')).not.toBeNull();
+        expect(new StatusNameField().createSorterFromLine('sort by status-name reverse')).toBeNull();
+    });
+
+    it('sort by status.name', () => {
+        // Arrange
+        const sorter = new StatusNameField().createNormalSorter();
+
+        // Assert
+        expectTaskComparesEqual(sorter, cancTask, cancTask);
+        // Reverse of Alphabetical order by status name
+        expectTaskComparesBefore(sorter, cancTask, doneTask);
+        expectTaskComparesBefore(sorter, doneTask, inprTask);
+        expectTaskComparesBefore(sorter, inprTask, todoTask);
+        expectTaskComparesBefore(sorter, todoTask, unknTask);
+    });
+
+    it('sort by status.name reverse', () => {
+        // Arrange
+        const sorter = new StatusNameField().createReverseSorter();
+
+        // Assert
+        expectTaskComparesEqual(sorter, cancTask, cancTask);
+        // Alphabetical order by status name
+        expectTaskComparesAfter(sorter, cancTask, doneTask);
+        expectTaskComparesAfter(sorter, doneTask, inprTask);
+        expectTaskComparesAfter(sorter, inprTask, todoTask);
+        expectTaskComparesAfter(sorter, todoTask, unknTask);
+    });
+});