Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1423 (2022-12-22 23:00:11)

feat: Add 'sort by heading'
<!--- Provide a general summary of your changes in the Title above -->

# Description

Add `sort by heading` and document it.

## Motivation and Context

It's just a gap.

## How has this been tested?

Writing new 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:

- [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/queries/sorting.md b/docs/queries/sorting.md
index 12fd22ee..5fc0c2f2 100644
--- a/docs/queries/sorting.md
+++ b/docs/queries/sorting.md
@@ -40,7 +40,9 @@ File locations:
 
 File contents:
 
-None yet.
+1. `sort by heading` (the heading preceding the task; files with empty headings sort before other tasks)
+
+> `sort by heading` was introduced in Tasks 1.21.0.
 
 Task date properties:
 
diff --git a/docs/quick-reference/index.md b/docs/quick-reference/index.md
index 57bc14cd..fbcb1008 100644
--- a/docs/quick-reference/index.md
+++ b/docs/quick-reference/index.md
@@ -31,7 +31,7 @@ This table summarizes the filters and other options available inside a `tasks` b
 |                                                                                                                                                                                                     |                                             | `group by root`       |                        |
 |                                                                                                                                                                                                     |                                             | `group by folder`     |                        |
 | `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`                                                                                     | `sort by filename`                          | `group by filename`   |                        |
-| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                         |                                             | `group by heading`    |                        |
+| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                         | `sort by heading`                           | `group by heading`    |                        |
 |                                                                                                                                                                                                     |                                             | `group by backlink`   | `hide backlink`        |
 | `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i`                                                                                 | `sort by description`                       |                       |                        |
 | `tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`<br>`tag (regex matches, regex does not match) /regex/i`<br>`tags (regex matches, regex does not match) /regex/i` | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags`       |                        |
diff --git a/src/Query/Filter/FilenameField.ts b/src/Query/Filter/FilenameField.ts
index 9939692c..88972e08 100644
--- a/src/Query/Filter/FilenameField.ts
+++ b/src/Query/Filter/FilenameField.ts
@@ -1,5 +1,4 @@
 import type { Task } from '../../Task';
-import type { Comparator } from '../Sorting';
 import { TextField } from './TextField';
 
 /** Support the 'filename' search instruction.
@@ -29,10 +28,4 @@ export class FilenameField extends TextField {
     supportsSorting(): boolean {
         return true;
     }
-
-    comparator(): Comparator {
-        return (a: Task, b: Task) => {
-            return this.value(a).localeCompare(this.value(b));
-        };
-    }
 }
diff --git a/src/Query/Filter/HeadingField.ts b/src/Query/Filter/HeadingField.ts
index 83cd961c..986c575b 100644
--- a/src/Query/Filter/HeadingField.ts
+++ b/src/Query/Filter/HeadingField.ts
@@ -21,4 +21,8 @@ export class HeadingField extends TextField {
             return '';
         }
     }
+
+    supportsSorting(): boolean {
+        return true;
+    }
 }
diff --git a/src/Query/Filter/TextField.ts b/src/Query/Filter/TextField.ts
index db93db89..d2bfd383 100644
--- a/src/Query/Filter/TextField.ts
+++ b/src/Query/Filter/TextField.ts
@@ -3,6 +3,7 @@ import { SubstringMatcher } from '../Matchers/SubstringMatcher';
 import { RegexMatcher } from '../Matchers/RegexMatcher';
 import type { IStringMatcher } from '../Matchers/IStringMatcher';
 import { Explanation } from '../Explain/Explanation';
+import type { Comparator } from '../Sorting';
 import { Field } from './Field';
 import type { FilterFunction } from './Filter';
 import { Filter, FilterOrErrorMessage } from './Filter';
@@ -89,4 +90,17 @@ export abstract class TextField extends Field {
             return negate ? !match : match;
         };
     }
+
+    /**
+     * A default implementation of sorting, for text fields where simple locale-aware sorting is the
+     * desired behaviour.
+     *
+     * Each class that wants to use this will need to override supportsSorting() to return true,
+     * to turn on sorting.
+     */
+    comparator(): Comparator {
+        return (a: Task, b: Task) => {
+            return this.value(a).localeCompare(this.value(b));
+        };
+    }
 }


Test patch:
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 5a70c3e0..266fb6e6 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -133,6 +133,7 @@ describe('Query parsing', () => {
             'sort by due',
             'sort by filename',
             'sort by happens',
+            'sort by heading',
             'sort by path reverse',
             'sort by path',
             'sort by priority reverse',
diff --git a/tests/Query/Filter/HeadingField.test.ts b/tests/Query/Filter/HeadingField.test.ts
index a9f3d782..be92ba7d 100644
--- a/tests/Query/Filter/HeadingField.test.ts
+++ b/tests/Query/Filter/HeadingField.test.ts
@@ -3,6 +3,7 @@ import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import { toBeValid, toMatchTaskWithHeading } from '../../CustomMatchers/CustomMatchersForFilters';
+import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
 
 function testTaskFilterForHeading(filter: FilterOrErrorMessage, precedingHeader: string | null, expected: boolean) {
     const builder = new TaskBuilder();
@@ -64,3 +65,34 @@ describe('heading', () => {
         expect(filter).not.toMatchTaskWithHeading('SoMe InteResting HeaDing');
     });
 });
+
+describe('sorting by heading', () => {
+    it('supports Field sorting methods correctly', () => {
+        const field = new HeadingField();
+        expect(field.supportsSorting()).toEqual(true);
+    });
+
+    // Helper function to create a task with a given path
+    function with_heading(heading: string) {
+        return new TaskBuilder().precedingHeader(heading).build();
+    }
+
+    it('sort by heading', () => {
+        // Arrange
+        const sorter = new HeadingField().createNormalSorter();
+
+        // Assert
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('Heading 1'), with_heading('Heading 2'));
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading(''), with_heading('Non-empty heading')); // Empty heading comes first
+        // Beginning with numbers
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('1 Stuff'), with_heading('2 Stuff'));
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('11 Stuff'), with_heading('9 Stuff')); // TODO want 11 to compare after 9
+    });
+
+    it('sort by heading reverse', () => {
+        // Single example just to prove reverse works.
+        // (There's no need to repeat all the examples above)
+        const sorter = new HeadingField().createReverseSorter();
+        CustomMatchersForSorting.expectTaskComparesAfter(sorter, with_heading('Heading 1'), with_heading('Heading 2'));
+    });
+});