Skip to content

Commit 9069d46

Browse files
authored
feat: Add 'sort by heading' (#1423)
* refactor: Move FilenameField.comparator() down to TextField for re-use. Derived classes would still need to enable sorting with supportsSorting() returning true to enable this comparator. * feat: Add 'sort by heading'
1 parent 6adc3c1 commit 9069d46

File tree

7 files changed

+55
-9
lines changed

7 files changed

+55
-9
lines changed

docs/queries/sorting.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ File locations:
4040
4141
File contents:
4242

43-
None yet.
43+
1. `sort by heading` (the heading preceding the task; files with empty headings sort before other tasks)
44+
45+
> `sort by heading` was introduced in Tasks 1.21.0.
4446
4547
Task date properties:
4648

docs/quick-reference/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ This table summarizes the filters and other options available inside a `tasks` b
3131
| | | `group by root` | |
3232
| | | `group by folder` | |
3333
| `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i` | `sort by filename` | `group by filename` | |
34-
| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i` | | `group by heading` | |
34+
| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i` | `sort by heading` | `group by heading` | |
3535
| | | `group by backlink` | `hide backlink` |
3636
| `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i` | `sort by description` | | |
3737
| `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` | |

src/Query/Filter/FilenameField.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Task } from '../../Task';
2-
import type { Comparator } from '../Sorting';
32
import { TextField } from './TextField';
43

54
/** Support the 'filename' search instruction.
@@ -29,10 +28,4 @@ export class FilenameField extends TextField {
2928
supportsSorting(): boolean {
3029
return true;
3130
}
32-
33-
comparator(): Comparator {
34-
return (a: Task, b: Task) => {
35-
return this.value(a).localeCompare(this.value(b));
36-
};
37-
}
3831
}

src/Query/Filter/HeadingField.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,8 @@ export class HeadingField extends TextField {
2121
return '';
2222
}
2323
}
24+
25+
supportsSorting(): boolean {
26+
return true;
27+
}
2428
}

src/Query/Filter/TextField.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SubstringMatcher } from '../Matchers/SubstringMatcher';
33
import { RegexMatcher } from '../Matchers/RegexMatcher';
44
import type { IStringMatcher } from '../Matchers/IStringMatcher';
55
import { Explanation } from '../Explain/Explanation';
6+
import type { Comparator } from '../Sorting';
67
import { Field } from './Field';
78
import type { FilterFunction } from './Filter';
89
import { Filter, FilterOrErrorMessage } from './Filter';
@@ -89,4 +90,17 @@ export abstract class TextField extends Field {
8990
return negate ? !match : match;
9091
};
9192
}
93+
94+
/**
95+
* A default implementation of sorting, for text fields where simple locale-aware sorting is the
96+
* desired behaviour.
97+
*
98+
* Each class that wants to use this will need to override supportsSorting() to return true,
99+
* to turn on sorting.
100+
*/
101+
comparator(): Comparator {
102+
return (a: Task, b: Task) => {
103+
return this.value(a).localeCompare(this.value(b));
104+
};
105+
}
92106
}

tests/Query.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ describe('Query parsing', () => {
133133
'sort by due',
134134
'sort by filename',
135135
'sort by happens',
136+
'sort by heading',
136137
'sort by path reverse',
137138
'sort by path',
138139
'sort by priority reverse',

tests/Query/Filter/HeadingField.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
33
import { TaskBuilder } from '../../TestingTools/TaskBuilder';
44
import { testFilter } from '../../TestingTools/FilterTestHelpers';
55
import { toBeValid, toMatchTaskWithHeading } from '../../CustomMatchers/CustomMatchersForFilters';
6+
import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
67

78
function testTaskFilterForHeading(filter: FilterOrErrorMessage, precedingHeader: string | null, expected: boolean) {
89
const builder = new TaskBuilder();
@@ -64,3 +65,34 @@ describe('heading', () => {
6465
expect(filter).not.toMatchTaskWithHeading('SoMe InteResting HeaDing');
6566
});
6667
});
68+
69+
describe('sorting by heading', () => {
70+
it('supports Field sorting methods correctly', () => {
71+
const field = new HeadingField();
72+
expect(field.supportsSorting()).toEqual(true);
73+
});
74+
75+
// Helper function to create a task with a given path
76+
function with_heading(heading: string) {
77+
return new TaskBuilder().precedingHeader(heading).build();
78+
}
79+
80+
it('sort by heading', () => {
81+
// Arrange
82+
const sorter = new HeadingField().createNormalSorter();
83+
84+
// Assert
85+
CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('Heading 1'), with_heading('Heading 2'));
86+
CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading(''), with_heading('Non-empty heading')); // Empty heading comes first
87+
// Beginning with numbers
88+
CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('1 Stuff'), with_heading('2 Stuff'));
89+
CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('11 Stuff'), with_heading('9 Stuff')); // TODO want 11 to compare after 9
90+
});
91+
92+
it('sort by heading reverse', () => {
93+
// Single example just to prove reverse works.
94+
// (There's no need to repeat all the examples above)
95+
const sorter = new HeadingField().createReverseSorter();
96+
CustomMatchersForSorting.expectTaskComparesAfter(sorter, with_heading('Heading 1'), with_heading('Heading 2'));
97+
});
98+
});

0 commit comments

Comments
 (0)