diff --git a/docs/Queries/Grouping.md b/docs/Queries/Grouping.md index c14f7af461..72c33231f5 100644 --- a/docs/Queries/Grouping.md +++ b/docs/Queries/Grouping.md @@ -77,14 +77,12 @@ For more information, including adding your own customised statuses, see [[Statu 1. `priority` - The priority of the task, namely one of: - - `Priority 1: High` - - `Priority 2: Medium` - - `Priority 3: None` - - `Priority 4: Low` + - `High` + - `Medium` + - `None` + - `Low` 1. `urgency` ([[Urgency|urgency]]) - - Currently, the groups run from the lowest urgency to highest. - - You can reverse this with `group by urgency reverse`. - - In a future release, the default group order will become from the highest urgency to lowest. + - From the highest urgency to lowest. 1. `recurring` - Whether the task is recurring: either `Recurring` or `Not Recurring`. 1. `recurrence` diff --git a/src/Query/Filter/DescriptionLengthGroupingField.ts b/src/Query/Filter/DescriptionLengthGroupingField.ts new file mode 100644 index 0000000000..39e016e2eb --- /dev/null +++ b/src/Query/Filter/DescriptionLengthGroupingField.ts @@ -0,0 +1,43 @@ +import type { Task } from 'Task'; +import type { GrouperFunction } from 'Query/Grouper'; +import { Field } from './Field'; +import { FilterOrErrorMessage } from './Filter'; + +/** This is a class for test purposes of a Field that supports grouping but not sorting + */ +export class DescriptionLengthGroupingfield extends Field { + protected filterRegExp(): RegExp | null { + throw new Error('No filtering for description length field'); + } + public fieldName(): string { + return 'description length'; + } + + public value(task: Task): number { + return task.description.length; + } + + public createFilterOrErrorMessage(line: string): FilterOrErrorMessage { + return FilterOrErrorMessage.fromError(line, 'description length field does not support filtering'); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Sorting + // ----------------------------------------------------------------------------------------------------------------- + + // Doesn't support sorting by default as in Field.ts + + // ----------------------------------------------------------------------------------------------------------------- + // Grouping + // ----------------------------------------------------------------------------------------------------------------- + + public supportsGrouping(): boolean { + return true; + } + + public grouper(): GrouperFunction { + return (task: Task) => { + return [this.value(task).toString()]; + }; + } +} diff --git a/src/Query/Filter/Field.ts b/src/Query/Filter/Field.ts index b342583189..ee363e1fec 100644 --- a/src/Query/Filter/Field.ts +++ b/src/Query/Filter/Field.ts @@ -1,3 +1,4 @@ +import type { Task } from 'Task'; import { Sorter } from '../Sorter'; import type { Comparator } from '../Sorter'; import * as RegExpTools from '../../lib/RegExpTools'; @@ -360,7 +361,13 @@ export abstract class Field { * @param reverse - false for normal group order, true for reverse group order. */ public createGrouper(reverse: boolean): Grouper { - return new Grouper(this.fieldNameSingular(), this.grouper(), reverse); + let defaultOrFieldComparator = this.defaultGroupComparator; + + if (this.supportsSorting()) { + defaultOrFieldComparator = this.comparator(); + } + + return new Grouper(this.fieldNameSingular(), this.grouper(), reverse, defaultOrFieldComparator); } /** @@ -382,4 +389,17 @@ export abstract class Field { public createReverseGrouper(): Grouper { return this.createGrouper(true); } + + private defaultGroupComparator: Comparator = (a: Task, b: Task) => { + const groupNamesA = this.grouper()(a); + const groupNamesB = this.grouper()(b); + + for (let i = 0; i < groupNamesA.length; i++) { + // The containers are guaranteed to be identical sizes since we are calling the same grouper + return groupNamesA[i].localeCompare(groupNamesB[i], undefined, { numeric: true }); + } + + // identical if we reach here + return 0; + }; } diff --git a/src/Query/Filter/MultiTextField.ts b/src/Query/Filter/MultiTextField.ts index ef6a0d70df..1115d52fd4 100644 --- a/src/Query/Filter/MultiTextField.ts +++ b/src/Query/Filter/MultiTextField.ts @@ -64,7 +64,7 @@ export abstract class MultiTextField extends TextField { * This overloads {@link Field.createGrouper} to put a plural field name in the {@link Grouper.property}. */ public createGrouper(reverse: boolean): Grouper { - return new Grouper(this.fieldNamePlural(), this.grouper(), reverse); + return new Grouper(this.fieldNamePlural(), this.grouper(), reverse, this.comparator()); } protected grouperRegExp(): RegExp { diff --git a/src/Query/Filter/PriorityField.ts b/src/Query/Filter/PriorityField.ts index bf14ea596c..af0dbfa21f 100644 --- a/src/Query/Filter/PriorityField.ts +++ b/src/Query/Filter/PriorityField.ts @@ -87,22 +87,18 @@ export class PriorityField extends Field { public grouper(): GrouperFunction { return (task: Task) => { - let priorityName = 'ERROR'; switch (task.priority) { case Priority.High: - priorityName = 'High'; - break; + return ['High']; case Priority.Medium: - priorityName = 'Medium'; - break; + return ['Medium']; case Priority.None: - priorityName = 'None'; - break; + return ['None']; case Priority.Low: - priorityName = 'Low'; - break; + return ['Low']; + default: + return ['ERROR']; } - return [`Priority ${task.priority}: ${priorityName}`]; }; } } diff --git a/src/Query/Grouper.ts b/src/Query/Grouper.ts index 8df4050e66..40c4c1e550 100644 --- a/src/Query/Grouper.ts +++ b/src/Query/Grouper.ts @@ -1,4 +1,5 @@ import type { Task } from '../Task'; +import type { Comparator } from './Sorter'; /** * A group-naming function, that takes a Task object and returns zero or more @@ -36,9 +37,12 @@ export class Grouper { public readonly reverse: boolean; - constructor(property: string, grouper: GrouperFunction, reverse: boolean) { + public readonly groupComparator: Comparator; + + constructor(property: string, grouper: GrouperFunction, reverse: boolean, groupComparator: Comparator) { this.property = property; this.grouper = grouper; this.reverse = reverse; + this.groupComparator = groupComparator; } } diff --git a/src/Query/TaskGroups.ts b/src/Query/TaskGroups.ts index 92ba9f9af8..6ffe742dca 100644 --- a/src/Query/TaskGroups.ts +++ b/src/Query/TaskGroups.ts @@ -105,18 +105,10 @@ export class TaskGroups { private sortTaskGroups() { const compareFn = (group1: TaskGroup, group2: TaskGroup) => { - // Compare two TaskGroup objects, sorting them by the group names at each grouping level. - const groupNames1 = group1.groups; - const groupNames2 = group2.groups; - // The containers are guaranteed to be identical sizes, - // they have one value for each 'group by' line in the query. - for (let i = 0; i < groupNames1.length; i++) { - // For now, we only have one sort option: sort by the names of the groups. - // In future, we will add control over the sorting of group headings, - // which will likely involve adjusting this code to sort by applying a Comparator - // to the first Task in each group. + // Compare two TaskGroup objects, sorting them by first task in each group. + for (let i = 0; i < this._groupers.length; i++) { const grouper = this._groupers[i]; - const result = groupNames1[i].localeCompare(groupNames2[i], undefined, { numeric: true }); + const result = grouper.groupComparator(group1.tasks[0], group2.tasks[0]); if (result !== 0) { return grouper.reverse ? -result : result; } diff --git a/tests/Query/Filter/DescriptionLengthGroupingField.test.ts b/tests/Query/Filter/DescriptionLengthGroupingField.test.ts new file mode 100644 index 0000000000..c10327b113 --- /dev/null +++ b/tests/Query/Filter/DescriptionLengthGroupingField.test.ts @@ -0,0 +1,53 @@ +import { DescriptionLengthGroupingfield } from '../../../src/Query/Filter/DescriptionLengthGroupingField'; +import { fromLine } from '../../TestHelpers'; +import { TaskGroups } from '../../../src/Query/TaskGroups'; + +describe('test a Field class that supports grouping without sorting', () => { + it('should create the grouper', () => { + const grouper = new DescriptionLengthGroupingfield().createNormalGrouper(); + expect(grouper).toBeDefined(); + }); + + it('should group in default (alphabetical) order', () => { + const tasks = [ + fromLine({ line: '- [ ] descrip' }), + fromLine({ line: '- [ ] desc' }), + fromLine({ line: '- [ ] description' }), + fromLine({ line: '- [ ] d' }), + ]; + const grouper = [new DescriptionLengthGroupingfield().createNormalGrouper()]; + const groups = new TaskGroups(grouper, tasks); + + expect(groups.toString()).toMatchInlineSnapshot(` + "Groupers (if any): + - description length + + Group names: [1] + #### [description length] 1 + - [ ] d + + --- + + Group names: [4] + #### [description length] 4 + - [ ] desc + + --- + + Group names: [7] + #### [description length] 7 + - [ ] descrip + + --- + + Group names: [11] + #### [description length] 11 + - [ ] description + + --- + + 4 tasks + " + `); + }); +}); diff --git a/tests/Query/Filter/PriorityField.test.ts b/tests/Query/Filter/PriorityField.test.ts index d909fb3991..ca008b379b 100644 --- a/tests/Query/Filter/PriorityField.test.ts +++ b/tests/Query/Filter/PriorityField.test.ts @@ -3,7 +3,7 @@ import { TaskBuilder } from '../../TestingTools/TaskBuilder'; import { testFilter } from '../../TestingTools/FilterTestHelpers'; import { PriorityField } from '../../../src/Query/Filter/PriorityField'; import { fromLine } from '../../TestHelpers'; - +import { TaskGroups } from '../../../src/Query/TaskGroups'; import { expectTaskComparesAfter, expectTaskComparesBefore, @@ -163,10 +163,10 @@ describe('grouping by priority', () => { }); it.each([ - ['- [ ] a ⏫', ['Priority 1: High']], - ['- [ ] a 🔼', ['Priority 2: Medium']], - ['- [ ] a', ['Priority 3: None']], - ['- [ ] a 🔽', ['Priority 4: Low']], + ['- [ ] a ⏫', ['High']], + ['- [ ] a 🔼', ['Medium']], + ['- [ ] a', ['None']], + ['- [ ] a 🔽', ['Low']], ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => { // Arrange const grouper = new PriorityField().createNormalGrouper().grouper; @@ -174,4 +174,50 @@ describe('grouping by priority', () => { // Assert expect(grouper(fromLine({ line: taskLine }))).toEqual(groups); }); + + it('should sort groups according to priority meaning', () => { + // Arrange + const tasks = [ + fromLine({ line: '- [ ] a 🔽' }), + fromLine({ line: '- [ ] a ⏫' }), + fromLine({ line: '- [ ] a' }), + fromLine({ line: '- [ ] a 🔼' }), + ]; + + const grouper = [new PriorityField().createNormalGrouper()]; + const groups = new TaskGroups(grouper, tasks); + + // Assert + expect(groups.toString()).toMatchInlineSnapshot(` + "Groupers (if any): + - priority + + Group names: [High] + #### [priority] High + - [ ] a ⏫ + + --- + + Group names: [Medium] + #### [priority] Medium + - [ ] a 🔼 + + --- + + Group names: [None] + #### [priority] None + - [ ] a + + --- + + Group names: [Low] + #### [priority] Low + - [ ] a 🔽 + + --- + + 4 tasks + " + `); + }); }); diff --git a/tests/Query/Filter/UrgencyField.test.ts b/tests/Query/Filter/UrgencyField.test.ts index d079bc5034..620bd3b182 100644 --- a/tests/Query/Filter/UrgencyField.test.ts +++ b/tests/Query/Filter/UrgencyField.test.ts @@ -12,6 +12,7 @@ import { expectTaskComparesEqual, } from '../../CustomMatchers/CustomMatchersForSorting'; import { fromLine } from '../../TestHelpers'; +import { TaskGroups } from '../../../src/Query/TaskGroups'; window.moment = moment; @@ -78,6 +79,15 @@ describe('sorting by urgency', () => { }); describe('grouping by urgency', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2023, 5 - 1, 22)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + it('supports grouping methods correctly', () => { expect(new UrgencyField()).toSupportGroupingWithProperty('urgency'); }); @@ -96,4 +106,36 @@ describe('grouping by urgency', () => { // Assert expect(grouper(fromLine({ line: taskLine }))).toEqual(groups); }); + + it('should sort groups from more urgent to less urgent', () => { + // Arrange + const tasks = [ + new TaskBuilder().description('task1').dueDate('2023-05-22').build(), + new TaskBuilder().description('task2').dueDate('2023-05-21').build(), + ]; + + const grouper = [new UrgencyField().createNormalGrouper()]; + const groups = new TaskGroups(grouper, tasks); + + // Assert + expect(groups.toString()).toMatchInlineSnapshot(` + "Groupers (if any): + - urgency + + Group names: [11.21] + #### [urgency] 11.21 + - [ ] task2 📅 2023-05-21 + + --- + + Group names: [10.75] + #### [urgency] 10.75 + - [ ] task1 📅 2023-05-22 + + --- + + 2 tasks + " + `); + }); });