Skip to content

Commit 606600d

Browse files
authored
Merge pull request #1954 from ilandikov/feat-reverse-sorting-of-groups
feat: reverse sorting of groups
2 parents c62cf40 + 9abc135 commit 606600d

29 files changed

+251
-62
lines changed

src/Query/Filter/Field.ts

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,87 @@ export abstract class Field {
264264
return false;
265265
}
266266

267+
/**
268+
* Parse a 'group by' line and return a {@link Grouper} object.
269+
*
270+
* Returns null line does not match this field or is invalid,
271+
* or this field does not support grouping.
272+
*/
273+
public parseGroupLine(line: string): Grouper | null {
274+
if (!this.supportsGrouping()) {
275+
return null;
276+
}
277+
278+
if (!this.canCreateGrouperForLine(line)) {
279+
return null;
280+
}
281+
282+
return this.createGrouperFromLine(line);
283+
}
284+
285+
/**
286+
* Returns true if the class can parse the given 'group by' instruction line.
287+
*
288+
* Current implementation simply checks whether the class does support grouping,
289+
* and whether the line matches this.grouperRegExp().
290+
* @param line - A line from a ```tasks``` block.
291+
*
292+
* @see {@link createGrouperFromLine}
293+
*/
294+
public canCreateGrouperForLine(line: string): boolean {
295+
if (!this.supportsGrouping()) {
296+
return false;
297+
}
298+
299+
return Field.lineMatchesFilter(this.grouperRegExp(), line);
300+
}
301+
302+
/**
303+
* Parse the line, and return either a {@link Grouper} object or null.
304+
*
305+
* This default implementation works for all fields that support
306+
* the default grouping pattern of `group by <fieldName> (reverse)?`.
307+
*
308+
* Fields that offer more complicated 'group by' options can override
309+
* this method.
310+
*
311+
* @param line - A 'group by' line from a ```tasks``` block.
312+
*
313+
* @see {@link canCreateGrouperForLine}
314+
*/
315+
public createGrouperFromLine(line: string): Grouper | null {
316+
if (!this.supportsGrouping()) {
317+
return null;
318+
}
319+
320+
const match = Field.getMatch(this.grouperRegExp(), line);
321+
if (match === null) {
322+
return null;
323+
}
324+
325+
const reverse = !!match[1];
326+
return this.createGrouper(reverse);
327+
}
328+
329+
/**
330+
* Return a regular expression that will match a correctly-formed
331+
* instruction line for grouping Tasks by this field.
332+
*
333+
* Throws if this field does not support grouping.
334+
*
335+
* `match[1]` will be either `reverse` or undefined.
336+
*
337+
* Fields that offer more complicated 'group by' options can override
338+
* this method.
339+
*/
340+
protected grouperRegExp(): RegExp {
341+
if (!this.supportsGrouping()) {
342+
throw Error(`grouperRegExp() unimplemented for ${this.fieldNameSingular()}`);
343+
}
344+
345+
return new RegExp(`^group by ${this.fieldNameSingularEscaped()}( reverse)?$`);
346+
}
347+
267348
/**
268349
* Return a function to get a list of a task's group names, for use in grouping by this field's value.
269350
*
@@ -276,11 +357,29 @@ export abstract class Field {
276357

277358
/**
278359
* Create a {@link Grouper} object for grouping tasks by this field's value.
360+
* @param reverse - false for normal group order, true for reverse group order.
361+
*/
362+
public createGrouper(reverse: boolean): Grouper {
363+
return new Grouper(this.fieldNameSingular(), this.grouper(), reverse);
364+
}
365+
366+
/**
367+
* Create a {@link Grouper} object for grouping tasks by this field's value,
368+
* in the standard/normal group order for this field.
369+
*
370+
* @see {@link createReverseGrouper}
371+
*/
372+
public createNormalGrouper(): Grouper {
373+
return this.createGrouper(false);
374+
}
375+
376+
/**
377+
* Create a {@link Grouper} object for grouping tasks by this field's value,
378+
* in the reverse of the standard/normal group order for this field.
279379
*
280-
* For now, parsing of `group by` lines is currently done in {@link FilterParser.parseGrouper()}.
281-
* Later, this will probably be moved to the {@link Field} classes.
380+
* @see {@link createNormalGrouper}
282381
*/
283-
public createGrouper(): Grouper {
284-
return new Grouper(this.fieldNameSingular(), this.grouper());
382+
public createReverseGrouper(): Grouper {
383+
return this.createGrouper(true);
285384
}
286385
}

src/Query/Filter/MultiTextField.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,15 @@ export abstract class MultiTextField extends TextField {
6363
/**
6464
* This overloads {@link Field.createGrouper} to put a plural field name in the {@link Grouper.property}.
6565
*/
66-
public createGrouper(): Grouper {
67-
return new Grouper(this.fieldNamePlural(), this.grouper());
66+
public createGrouper(reverse: boolean): Grouper {
67+
return new Grouper(this.fieldNamePlural(), this.grouper(), reverse);
68+
}
69+
70+
protected grouperRegExp(): RegExp {
71+
if (!this.supportsGrouping()) {
72+
throw Error(`grouperRegExp() unimplemented for ${this.fieldNameSingular()}`);
73+
}
74+
75+
return new RegExp(`^group by ${this.fieldNamePlural()}( reverse)?$`);
6876
}
6977
}

src/Query/FilterParser.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { RecurrenceField } from './Filter/RecurrenceField';
2323
import type { FilterOrErrorMessage } from './Filter/Filter';
2424
import type { Sorter } from './Sorter';
2525
import type { Grouper } from './Grouper';
26-
import { MultiTextField } from './Filter/MultiTextField';
2726
import { FolderField } from './Filter/FolderField';
2827
import { RootField } from './Filter/RootField';
2928
import { BacklinkField } from './Filter/BacklinkField';
@@ -102,19 +101,9 @@ export function parseGrouper(line: string): Grouper | null {
102101
// See if any of the fields can parse the line.
103102
for (const creator of fieldCreators) {
104103
const field = creator();
105-
const fieldName = field.fieldNameSingular();
106-
if (field.supportsGrouping()) {
107-
if (line === `group by ${fieldName}`) {
108-
return field.createGrouper();
109-
}
110-
111-
// MultiTextField is written as a plural ('group by tags')
112-
// See also MultiTextField.createGrouper()
113-
if (field instanceof MultiTextField) {
114-
if (line === `group by ${field.fieldNamePlural()}`) {
115-
return field.createGrouper();
116-
}
117-
}
104+
const grouper = field.parseGroupLine(line);
105+
if (grouper) {
106+
return grouper;
118107
}
119108
}
120109
return null;

src/Query/Grouper.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ export class Grouper {
3434
*/
3535
public readonly grouper: GrouperFunction;
3636

37-
constructor(property: string, grouper: GrouperFunction) {
37+
public readonly reverse: boolean;
38+
39+
constructor(property: string, grouper: GrouperFunction, reverse: boolean) {
3840
this.property = property;
3941
this.grouper = grouper;
42+
this.reverse = reverse;
4043
}
4144
}

src/Query/TaskGroups.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { TaskGroup } from './TaskGroup';
1313
* @see {@link Query.grouping}
1414
*/
1515
export class TaskGroups {
16+
private _groupers: Grouper[];
1617
private _groups: TaskGroup[] = new Array<TaskGroup>();
1718
private _totalTaskCount = 0;
1819

@@ -26,6 +27,7 @@ export class TaskGroups {
2627
// Grouping doesn't change the number of tasks, and all the tasks
2728
// will be shown in at least one group.
2829
this._totalTaskCount = tasks.length;
30+
this._groupers = groups;
2931

3032
const taskGroupingTree = new TaskGroupingTree(groups, tasks);
3133
this.addTasks(taskGroupingTree);
@@ -103,9 +105,10 @@ export class TaskGroups {
103105
// In future, we will add control over the sorting of group headings,
104106
// which will likely involve adjusting this code to sort by applying a Comparator
105107
// to the first Task in each group.
108+
const grouper = this._groupers[i];
106109
const result = groupNames1[i].localeCompare(groupNames2[i], undefined, { numeric: true });
107110
if (result !== 0) {
108-
return result;
111+
return grouper.reverse ? -result : result;
109112
}
110113
}
111114
// identical if we reach here

tests/CustomMatchers/CustomMatchersForGrouping.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function toSupportGroupingWithProperty(field: Field, property: string) {
2424
};
2525
}
2626

27-
const fieldGrouper = field.createGrouper();
27+
const fieldGrouper = field.createNormalGrouper();
2828
if (fieldGrouper.property !== property) {
2929
return {
3030
message: () =>

tests/DocumentationSamples/DocsSamplesForStatuses.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,9 @@ function verifyTransitionsAsMarkdownTable(statuses: Status[]) {
208208
table.addRow(cells);
209209
}
210210

211-
showGroupNamesForAllTasks('status', new StatusField().createGrouper().grouper);
212-
showGroupNamesForAllTasks('status.type', new StatusTypeField().createGrouper().grouper);
213-
showGroupNamesForAllTasks('status.name', new StatusNameField().createGrouper().grouper);
211+
showGroupNamesForAllTasks('status', new StatusField().createNormalGrouper().grouper);
212+
showGroupNamesForAllTasks('status.type', new StatusTypeField().createNormalGrouper().grouper);
213+
showGroupNamesForAllTasks('status.name', new StatusNameField().createNormalGrouper().grouper);
214214

215215
table.verifyForDocs();
216216
}

tests/Query.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,25 +201,45 @@ describe('Query parsing', () => {
201201
// In alphabetical order, please
202202
const filters = [
203203
'group by created',
204+
'group by created reverse',
204205
'group by backlink',
206+
'group by backlink reverse',
205207
'group by done',
208+
'group by done reverse',
206209
'group by due',
210+
'group by due reverse',
207211
'group by filename',
212+
'group by filename reverse',
208213
'group by folder',
214+
'group by folder reverse',
209215
'group by happens',
216+
'group by happens reverse',
210217
'group by heading',
218+
'group by heading reverse',
211219
'group by path',
220+
'group by path reverse',
212221
'group by priority',
222+
'group by priority reverse',
213223
'group by recurrence',
224+
'group by recurrence reverse',
214225
'group by recurring',
226+
'group by recurring reverse',
215227
'group by root',
228+
'group by root reverse',
216229
'group by scheduled',
230+
'group by scheduled reverse',
217231
'group by start',
232+
'group by start reverse',
218233
'group by status',
234+
'group by status reverse',
219235
'group by status.name',
236+
'group by status.name reverse',
220237
'group by status.type',
238+
'group by status.type reverse',
221239
'group by tags',
240+
'group by tags reverse',
222241
'group by urgency',
242+
'group by urgency reverse',
223243
];
224244
test.concurrent.each<string>(filters)('recognises %j', (filter) => {
225245
// Arrange

tests/Query/Filter/BacklinkField.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('grouping by backlink', () => {
5858
'path "%s" and heading "%s" should have groups: %s',
5959
(path: string, heading: string | null, groups: string[]) => {
6060
// Arrange
61-
const grouper = new BacklinkField().createGrouper().grouper;
61+
const grouper = new BacklinkField().createNormalGrouper().grouper;
6262
const t = '- [ ] xyz';
6363

6464
// Assert

tests/Query/Filter/CreatedDateField.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('grouping by created date', () => {
9797

9898
it('group by created date', () => {
9999
// Arrange
100-
const grouper = new CreatedDateField().createGrouper();
100+
const grouper = new CreatedDateField().createNormalGrouper();
101101
const taskWithDate = new TaskBuilder().createdDate('1970-01-01').build();
102102
const taskWithoutDate = new TaskBuilder().build();
103103

0 commit comments

Comments
 (0)