Skip to content

Commit

Permalink
feat: Add 'sort by heading' (#1423)
Browse files Browse the repository at this point in the history
* 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'
  • Loading branch information
claremacrae committed Dec 22, 2022
1 parent 6adc3c1 commit 9069d46
Show file tree
Hide file tree
Showing 7 changed files with 55 additions and 9 deletions.
4 changes: 3 additions & 1 deletion docs/queries/sorting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/quick-reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | |
Expand Down
7 changes: 0 additions & 7 deletions src/Query/Filter/FilenameField.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Task } from '../../Task';
import type { Comparator } from '../Sorting';
import { TextField } from './TextField';

/** Support the 'filename' search instruction.
Expand Down Expand Up @@ -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));
};
}
}
4 changes: 4 additions & 0 deletions src/Query/Filter/HeadingField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@ export class HeadingField extends TextField {
return '';
}
}

supportsSorting(): boolean {
return true;
}
}
14 changes: 14 additions & 0 deletions src/Query/Filter/TextField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
};
}
}
1 change: 1 addition & 0 deletions tests/Query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
32 changes: 32 additions & 0 deletions tests/Query/Filter/HeadingField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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'));
});
});

0 comments on commit 9069d46

Please sign in to comment.