Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1871 (2023-04-11 22:10:43)

refactor: existing *Field grouping migration to new grouping
<!--- Provide a general summary of your changes in the Title above -->

# Description

## Motivation and Context

Refactor the existing `*Field` classes to use the new grouping abstraction. Continuation of #1870

Part of #920 

## How has this been tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->

## 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:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [ ] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))
- [ ] **Contributing Guidelines** (prefix: `contrib` - any improvements to documentation content **for contributors** - see [Contributing to Tasks](https://publish.obsidian.md/tasks-contributing/))

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## 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`.
- [ ] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) 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://publish.obsidian.md/tasks-contributing)
- [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/src/Query/Filter/FilenameField.ts b/src/Query/Filter/FilenameField.ts
index 88972e08..7fe90a19 100644
--- a/src/Query/Filter/FilenameField.ts
+++ b/src/Query/Filter/FilenameField.ts
@@ -1,4 +1,5 @@
 import type { Task } from '../../Task';
+import type { GrouperFunction } from '../Grouper';
 import { TextField } from './TextField';
 
 /** Support the 'filename' search instruction.
@@ -28,4 +29,21 @@ export class FilenameField extends TextField {
     supportsSorting(): boolean {
         return true;
     }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            // Note current limitation: Tasks from different notes with the
+            // same name will be grouped together, even though they are in
+            // different files and their links will look different.
+            const filename = task.filename;
+            if (filename === null) {
+                return ['Unknown Location'];
+            }
+            return ['[[' + filename + ']]'];
+        };
+    }
 }
diff --git a/src/Query/Filter/HeadingField.ts b/src/Query/Filter/HeadingField.ts
index 986c575b..57d76ee5 100644
--- a/src/Query/Filter/HeadingField.ts
+++ b/src/Query/Filter/HeadingField.ts
@@ -1,3 +1,4 @@
+import type { GrouperFunction } from '../Grouper';
 import type { Task } from '../../Task';
 import { TextField } from './TextField';
 
@@ -25,4 +26,17 @@ export class HeadingField extends TextField {
     supportsSorting(): boolean {
         return true;
     }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            if (task.precedingHeader === null || task.precedingHeader.length === 0) {
+                return ['(No heading)'];
+            }
+            return [task.precedingHeader];
+        };
+    }
 }
diff --git a/src/Query/Filter/MultiTextField.ts b/src/Query/Filter/MultiTextField.ts
index e75fa774..a6ba8d2e 100644
--- a/src/Query/Filter/MultiTextField.ts
+++ b/src/Query/Filter/MultiTextField.ts
@@ -1,5 +1,6 @@
 import type { Task } from '../../Task';
 import type { IStringMatcher } from '../Matchers/IStringMatcher';
+import { Grouper } from '../Grouper';
 import { TextField } from './TextField';
 import type { FilterFunction } from './Filter';
 
@@ -18,7 +19,7 @@ export abstract class MultiTextField extends TextField {
      * Returns the plural form of the field's name.
      * If not overridden, returns the singular form appended with an "s".
      */
-    protected fieldNamePlural(): string {
+    public fieldNamePlural(): string {
         return this.fieldNameSingular() + 's';
     }
 
@@ -58,4 +59,11 @@ export abstract class MultiTextField extends TextField {
             return negate ? !match : match;
         };
     }
+
+    /**
+     * This overloads {@link Field.createGrouper} to put a plural field name in the {@link Grouper.property}.
+     */
+    public createGrouper(): Grouper {
+        return new Grouper(this.fieldNamePlural(), this.grouper());
+    }
 }
diff --git a/src/Query/Filter/PathField.ts b/src/Query/Filter/PathField.ts
index 0db74d6e..7434a7db 100644
--- a/src/Query/Filter/PathField.ts
+++ b/src/Query/Filter/PathField.ts
@@ -1,4 +1,6 @@
+import { Group } from '../Group';
 import type { Task } from '../../Task';
+import type { GrouperFunction } from '../Grouper';
 import { TextField } from './TextField';
 
 /** Support the 'path' search instruction.
@@ -24,4 +26,16 @@ export class PathField extends TextField {
     public supportsSorting(): boolean {
         return true;
     }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            // Does this need to be made stricter?
+            // Is there a better way of getting the file name?
+            return [Group.escapeMarkdownCharacters(task.path.replace('.md', ''))];
+        };
+    }
 }
diff --git a/src/Query/Filter/PriorityField.ts b/src/Query/Filter/PriorityField.ts
index 344c056e..bf14ea59 100644
--- a/src/Query/Filter/PriorityField.ts
+++ b/src/Query/Filter/PriorityField.ts
@@ -1,6 +1,7 @@
 import { Priority, Task } from '../../Task';
 import { Explanation } from '../Explain/Explanation';
 import type { Comparator } from '../Sorter';
+import type { GrouperFunction } from '../Grouper';
 import { Field } from './Field';
 import { Filter, FilterOrErrorMessage } from './Filter';
 
@@ -79,4 +80,29 @@ export class PriorityField extends Field {
             return a.priority.localeCompare(b.priority);
         };
     }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            let priorityName = 'ERROR';
+            switch (task.priority) {
+                case Priority.High:
+                    priorityName = 'High';
+                    break;
+                case Priority.Medium:
+                    priorityName = 'Medium';
+                    break;
+                case Priority.None:
+                    priorityName = 'None';
+                    break;
+                case Priority.Low:
+                    priorityName = 'Low';
+                    break;
+            }
+            return [`Priority ${task.priority}: ${priorityName}`];
+        };
+    }
 }
diff --git a/src/Query/Filter/RecurrenceField.ts b/src/Query/Filter/RecurrenceField.ts
index 02b03a58..83b18ed8 100644
--- a/src/Query/Filter/RecurrenceField.ts
+++ b/src/Query/Filter/RecurrenceField.ts
@@ -1,4 +1,5 @@
 import type { Task } from '../../Task';
+import type { GrouperFunction } from '../Grouper';
 import { TextField } from './TextField';
 
 export class RecurrenceField extends TextField {
@@ -13,4 +14,18 @@ export class RecurrenceField extends TextField {
             return '';
         }
     }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            if (task.recurrence !== null) {
+                return [task.recurrence!.toText()];
+            } else {
+                return ['None'];
+            }
+        };
+    }
 }
diff --git a/src/Query/Filter/RecurringField.ts b/src/Query/Filter/RecurringField.ts
index 2684e16c..548180dd 100644
--- a/src/Query/Filter/RecurringField.ts
+++ b/src/Query/Filter/RecurringField.ts
@@ -1,3 +1,5 @@
+import type { Task } from '../../Task';
+import type { GrouperFunction } from '../Grouper';
 import { FilterInstructionsBasedField } from './FilterInstructionsBasedField';
 
 export class RecurringField extends FilterInstructionsBasedField {
@@ -10,4 +12,18 @@ export class RecurringField extends FilterInstructionsBasedField {
     public fieldName(): string {
         return 'recurring';
     }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            if (task.recurrence !== null) {
+                return ['Recurring'];
+            } else {
+                return ['Not Recurring'];
+            }
+        };
+    }
 }
diff --git a/src/Query/Filter/StatusField.ts b/src/Query/Filter/StatusField.ts
index 7c4aadb0..d7b9f66c 100644
--- a/src/Query/Filter/StatusField.ts
+++ b/src/Query/Filter/StatusField.ts
@@ -1,6 +1,7 @@
 import type { Task } from '../../Task';
 import type { Comparator } from '../Sorter';
 import { StatusType } from '../../StatusConfiguration';
+import type { GrouperFunction } from '../Grouper';
 import { FilterInstructionsBasedField } from './FilterInstructionsBasedField';
 
 export class StatusField extends FilterInstructionsBasedField {
@@ -64,4 +65,17 @@ export class StatusField extends FilterInstructionsBasedField {
             return 'Done';
         }
     }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            // Backwards-compatibility note: In Tasks 1.22.0 and earlier, the only
+            // names used by 'group by status' were 'Todo' and 'Done' - and
+            // any character other than a space was considered to be 'Done'.
+            return [StatusField.oldStatusName(task)];
+        };
+    }
 }
diff --git a/src/Query/Filter/TagsField.ts b/src/Query/Filter/TagsField.ts
index f6ceba44..fa926e0c 100644
--- a/src/Query/Filter/TagsField.ts
+++ b/src/Query/Filter/TagsField.ts
@@ -1,6 +1,7 @@
 import type { Task } from '../../Task';
 import type { Comparator } from '../Sorter';
 import { Sorter } from '../Sorter';
+import type { GrouperFunction } from '../Grouper';
 import type { FilterOrErrorMessage } from './Filter';
 import { FilterInstructions } from './FilterInstructions';
 import { MultiTextField } from './MultiTextField';
@@ -121,4 +122,21 @@ export class TagsField extends MultiTextField {
             return tagA.localeCompare(tagB, undefined, { numeric: true });
         };
     }
+
+    // -----------------------------------------------------------------------------------------------------------------
+    // Grouping
+    // -----------------------------------------------------------------------------------------------------------------
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            if (task.tags.length == 0) {
+                return ['(No tags)'];
+            }
+            return task.tags;
+        };
+    }
 }
diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts
index 0c952b5a..21839504 100644
--- a/src/Query/FilterParser.ts
+++ b/src/Query/FilterParser.ts
@@ -22,6 +22,7 @@ import { RecurrenceField } from './Filter/RecurrenceField';
 import type { FilterOrErrorMessage } from './Filter/Filter';
 import type { Sorter } from './Sorter';
 import type { Grouper } from './Grouper';
+import { MultiTextField } from './Filter/MultiTextField';
 
 const fieldCreators = [
     () => new StatusNameField(), // status.name is before status, to avoid ambiguity
@@ -87,10 +88,18 @@ export function parseGrouper(line: string): Grouper | null {
     for (const creator of fieldCreators) {
         const field = creator();
         const fieldName = field.fieldNameSingular();
-        if (line === `group by ${fieldName}`) {
-            if (field.supportsGrouping()) {
+        if (field.supportsGrouping()) {
+            if (line === `group by ${fieldName}`) {
                 return field.createGrouper();
             }
+
+            // MultiTextField is written as a plural ('group by tags')
+            // See also MultiTextField.createGrouper()
+            if (field instanceof MultiTextField) {
+                if (line === `group by ${field.fieldNamePlural()}`) {
+                    return field.createGrouper();
+                }
+            }
         }
     }
     return null;
diff --git a/src/Query/Group.ts b/src/Query/Group.ts
index 3ad6dd5f..9f869c3d 100644
--- a/src/Query/Group.ts
+++ b/src/Query/Group.ts
@@ -1,8 +1,8 @@
 import type { Task } from '../Task';
-import { Priority } from '../Task';
 import { TaskGroups } from './TaskGroups';
 import { Grouper } from './Grouper';
 import type { GrouperFunction, GroupingProperty } from './Grouper';
+import { HeadingField } from './Filter/HeadingField';
 
 /**
  * Implementation of the 'group by' instruction.
@@ -39,64 +39,15 @@ export class Group {
 
     private static groupers: Record<GroupingProperty, GrouperFunction> = {
         backlink: Group.groupByBacklink,
-        filename: Group.groupByFileName,
         folder: Group.groupByFolder,
-        heading: Group.groupByHeading,
-        path: Group.groupByPath,
-        priority: Group.groupByPriority,
-        recurrence: Group.groupByRecurrence,
-        recurring: Group.groupByRecurring,
         root: Group.groupByRoot,
-        status: Group.groupByStatus,
-        tags: Group.groupByTags,
     };
 
-    private static escapeMarkdownCharacters(filename: string) {
+    public static escapeMarkdownCharacters(filename: string) {
         // https://wilsonmar.github.io/markdown-text-for-github-from-html/#special-characters
         return filename.replace(/\\/g, '\\\\').replace(/_/g, '\\_');
     }
 
-    private static groupByPriority(task: Task): string[] {
-        let priorityName = 'ERROR';
-        switch (task.priority) {
-            case Priority.High:
-                priorityName = 'High';
-                break;
-            case Priority.Medium:
-                priorityName = 'Medium';
-                break;
-            case Priority.None:
-                priorityName = 'None';
-                break;
-            case Priority.Low:
-                priorityName = 'Low';
-                break;
-        }
-        return [`Priority ${task.priority}: ${priorityName}`];
-    }
-
-    private static groupByRecurrence(task: Task): string[] {
-        if (task.recurrence !== null) {
-            return [task.recurrence!.toText()];
-        } else {
-            return ['None'];
-        }
-    }
-
-    private static groupByRecurring(task: Task): string[] {
-        if (task.recurrence !== null) {
-            return ['Recurring'];
-        } else {
-            return ['Not Recurring'];
-        }
-    }
-
-    private static groupByPath(task: Task): string[] {
-        // Does this need to be made stricter?
-        // Is there a better way of getting the file name?
-        return [Group.escapeMarkdownCharacters(task.path.replace('.md', ''))];
-    }
-
     private static groupByFolder(task: Task): string[] {
         const path = task.path;
         const fileNameWithExtension = task.filename + '.md';
@@ -107,17 +58,6 @@ export class Group {
         return [Group.escapeMarkdownCharacters(folder)];
     }
 
-    private static groupByFileName(task: Task): string[] {
-        // Note current limitation: Tasks from different notes with the
-        // same name will be grouped together, even though they are in
-        // different files and their links will look different.
-        const filename = task.filename;
-        if (filename === null) {
-            return ['Unknown Location'];
-        }
-        return ['[[' + filename + ']]'];
-    }
-
     private static groupByRoot(task: Task): string[] {
         const path = task.path.replace(/\\/g, '/');
         const separatorIndex = path.indexOf('/');
@@ -145,7 +85,8 @@ export class Group {
         }
 
         // Markdown characters in the heading must NOT be escaped.
-        const headingComponent = Group.groupByHeading(task)[0];
+        const headingGrouper = new HeadingField().createGrouper().grouper;
+        const headingComponent = headingGrouper(task)[0];
 
         if (filenameComponent === headingComponent) {
             return [filenameComponent];
@@ -153,29 +94,4 @@ export class Group {
             return [`${filenameComponent} > ${headingComponent}`];
         }
     }
-
-    private static groupByStatus(task: Task): string[] {
-        // Backwards-compatibility note: In Tasks 1.22.0 and earlier, the only
-        // names used by 'group by status' were 'Todo' and 'Done' - and
-        // any character other than a space was considered to be 'Done'.
-        if (task.status.symbol === ' ') {
-            return ['Todo'];
-        } else {
-            return ['Done'];
-        }
-    }
-
-    private static groupByHeading(task: Task): string[] {
-        if (task.precedingHeader === null || task.precedingHeader.length === 0) {
-            return ['(No heading)'];
-        }
-        return [task.precedingHeader];
-    }
-
-    private static groupByTags(task: Task): string[] {
-        if (task.tags.length == 0) {
-            return ['(No tags)'];
-        }
-        return task.tags;
-    }
 }
diff --git a/src/Query/Grouper.ts b/src/Query/Grouper.ts
index 80371084..53b52044 100644
--- a/src/Query/Grouper.ts
+++ b/src/Query/Grouper.ts
@@ -5,18 +5,7 @@ import type { Task } from '../Task';
  */
 export type GrouperFunction = (task: Task) => string[];
 
-export type GroupingProperty =
-    | 'backlink'
-    | 'filename'
-    | 'folder'
-    | 'heading'
-    | 'path'
-    | 'priority'
-    | 'recurrence'
-    | 'recurring'
-    | 'root'
-    | 'status'
-    | 'tags';
+export type GroupingProperty = 'backlink' | 'folder' | 'root';
 
 export class Grouper {
     public readonly property: string;
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index 592fcc53..3a9d192d 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -22,8 +22,7 @@ export class Query implements IQuery {
     private _sorting: Sorter[] = [];
     private _grouping: Grouper[] = [];
 
-    private readonly groupByRegexp =
-        /^group by (backlink|filename|folder|heading|path|priority|recurrence|recurring|root|status|tags)/;
+    private readonly groupByRegexp = /^group by (backlink|folder|root)/;
 
     private readonly hideOptionsRegexp =
         /^(hide|show) (task count|backlink|priority|created date|start date|scheduled date|done date|due date|recurrence rule|edit button|urgency)/;


Test patch:
diff --git a/tests/DocsSamplesForStatuses.test.ts b/tests/DocsSamplesForStatuses.test.ts
index 5b88844b..49620afc 100644
--- a/tests/DocsSamplesForStatuses.test.ts
+++ b/tests/DocsSamplesForStatuses.test.ts
@@ -1,11 +1,11 @@
 import { verify } from 'approvals/lib/Providers/Jest/JestApprovals';
 
+import { StatusField } from '../src/Query/Filter/StatusField';
 import { Status } from '../src/Status';
 import type { Task } from '../src/Task';
 import { StatusConfiguration, StatusType } from '../src/StatusConfiguration';
 import type { FilterOrErrorMessage } from '../src/Query/Filter/Filter';
 import * as FilterParser from '../src/Query/FilterParser';
-import { Group } from '../src/Query/Group';
 import { StatusNameField } from '../src/Query/Filter/StatusNameField';
 import { StatusTypeField } from '../src/Query/Filter/StatusTypeField';
 import type { StatusCollection, StatusCollectionEntry } from '../src/StatusCollection';
@@ -203,7 +203,7 @@ function verifyTransitionsAsMarkdownTable(statuses: Status[]) {
         table.addRow(cells);
     }
 
-    showGroupNamesForAllTasks('status', Group.grouperForProperty('status'));
+    showGroupNamesForAllTasks('status', new StatusField().createGrouper().grouper);
     showGroupNamesForAllTasks('status.type', new StatusTypeField().createGrouper().grouper);
     showGroupNamesForAllTasks('status.name', new StatusNameField().createGrouper().grouper);
 
diff --git a/tests/Group.test.ts b/tests/Group.test.ts
index 31dc42dd..827c43ba 100644
--- a/tests/Group.test.ts
+++ b/tests/Group.test.ts
@@ -2,10 +2,13 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
+import { FilenameField } from '../src/Query/Filter/FilenameField';
 import { Group } from '../src/Query/Group';
 import type { Grouper } from '../src/Query/Grouper';
 import type { GroupingProperty } from '../src/Query/Grouper';
 import type { Task } from '../src/Task';
+import { PathField } from '../src/Query/Filter/PathField';
+import { TagsField } from '../src/Query/Filter/TagsField';
 import { fromLine } from './TestHelpers';
 
 window.moment = moment;
@@ -24,8 +27,7 @@ describe('Grouping tasks', () => {
         const inputs = [a, b, c];
 
         // Act
-        const groupBy: GroupingProperty = 'path';
-        const grouping = [Group.fromGroupingProperty(groupBy)];
+        const grouping = [new PathField().createGrouper()];
         const groups = Group.by(grouping, inputs);
 
         // Assert
@@ -79,8 +81,7 @@ describe('Grouping tasks', () => {
     it('groups empty task list correctly', () => {
         // Arrange
         const inputs: Task[] = [];
-        const group_by: GroupingProperty = 'path';
-        const grouping = [Group.fromGroupingProperty(group_by)];
+        const grouping = [new PathField().createGrouper()];
 
         // Act
         const groups = Group.by(grouping, inputs);
@@ -106,8 +107,7 @@ describe('Grouping tasks', () => {
         });
         const inputs = [a, b, c];
 
-        const group_by: GroupingProperty = 'path';
-        const grouping = [Group.fromGroupingProperty(group_by)];
+        const grouping = [new PathField().createGrouper()];
         const groups = Group.by(grouping, inputs);
         expect(groups.toString()).toMatchInlineSnapshot(`
             "
@@ -146,8 +146,7 @@ describe('Grouping tasks', () => {
         });
         const inputs = [a, b, c];
 
-        const group_by: GroupingProperty = 'tags';
-        const grouping = [Group.fromGroupingProperty(group_by)];
+        const grouping = [new TagsField().createGrouper()];
         const groups = Group.by(grouping, inputs);
         expect(groups.toString()).toMatchInlineSnapshot(`
             "
@@ -186,7 +185,7 @@ describe('Grouping tasks', () => {
         });
         const tasks = [t1, t2, t3];
 
-        const grouping: Grouper[] = [Group.fromGroupingProperty('folder'), Group.fromGroupingProperty('filename')];
+        const grouping: Grouper[] = [Group.fromGroupingProperty('folder'), new FilenameField().createGrouper()];
 
         // Act
         const groups = Group.by(grouping, tasks);
@@ -270,21 +269,6 @@ describe('Group names', () => {
             precedingHeading: 'heading _italic text_',
         },
 
-        // -----------------------------------------------------------
-        // group by filename
-        {
-            groupBy: 'filename',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['[[c]]'],
-            path: 'a/b/c.md',
-        },
-        {
-            groupBy: 'filename',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['[[_c_]]'], // underscores in links shall not be escaped
-            path: 'a/b/_c_.md',
-        },
-
         // -----------------------------------------------------------
         // group by folder
         {
@@ -307,113 +291,6 @@ describe('Group names', () => {
             path: 'a.md',
         },
 
-        // -----------------------------------------------------------
-        // group by heading
-        {
-            groupBy: 'heading',
-            taskLine: '- [ ] xxx',
-            expectedGroupNames: ['(No heading)'],
-            precedingHeading: null,
-        },
-        {
-            groupBy: 'heading',
-            taskLine: '- [ ] xxx',
-            expectedGroupNames: ['(No heading)'],
-            precedingHeading: '',
-        },
-        {
-            groupBy: 'heading',
-            taskLine: '- [ ] xxx',
-            expectedGroupNames: ['heading'],
-            precedingHeading: 'heading',
-        },
-        {
-            groupBy: 'heading',
-            taskLine: '- [ ] xxx',
-            expectedGroupNames: ['heading _italic text_'], // underscores in headings are NOT escaped - will be rendered
-            precedingHeading: 'heading _italic text_',
-        },
-
-        // -----------------------------------------------------------
-        // group by path
-        {
-            groupBy: 'path',
-            taskLine: '- [ ] a',
-            path: 'a/b/c.md',
-            expectedGroupNames: ['a/b/c'], // the file extension is removed
-        },
-        {
-            groupBy: 'path',
-            taskLine: '- [ ] a',
-            path: '_a_/b/_c_.md',
-            expectedGroupNames: ['\\_a\\_/b/\\_c\\_'], // underscores in paths are escaped
-        },
-        {
-            groupBy: 'path',
-            taskLine: '- [ ] a',
-            path: 'a\\b\\c.md',
-            expectedGroupNames: ['a\\\\b\\\\c'], // backslashes are escaped. (this artificial example is to test escaping)
-        },
-
-        // -----------------------------------------------------------
-        // group by priority
-        {
-            groupBy: 'priority',
-            taskLine: '- [ ] a ⏫',
-            expectedGroupNames: ['Priority 1: High'],
-        },
-        {
-            groupBy: 'priority',
-            taskLine: '- [ ] a 🔼',
-            expectedGroupNames: ['Priority 2: Medium'],
-        },
-        {
-            groupBy: 'priority',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['Priority 3: None'],
-        },
-        {
-            groupBy: 'priority',
-            taskLine: '- [ ] a 🔽',
-            expectedGroupNames: ['Priority 4: Low'],
-        },
-
-        // -----------------------------------------------------------
-        // group by recurrence
-        {
-            groupBy: 'recurrence',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['None'],
-        },
-        {
-            groupBy: 'recurrence',
-            taskLine: '- [ ] a 🔁 every Sunday',
-            expectedGroupNames: ['every week on Sunday'],
-        },
-        {
-            groupBy: 'recurrence',
-            taskLine: '- [ ] a 🔁 every Sunday when done',
-            expectedGroupNames: ['every week on Sunday when done'],
-        },
-        {
-            groupBy: 'recurrence',
-            taskLine: '- [ ] a 🔁 every 6 months on the 2nd Wednesday',
-            expectedGroupNames: ['every 6 months on the 2nd Wednesday'],
-        },
-
-        // -----------------------------------------------------------
-        // group by recurring
-        {
-            groupBy: 'recurring',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['Not Recurring'],
-        },
-        {
-            groupBy: 'recurring',
-            taskLine: '- [ ] a 🔁 every Sunday',
-            expectedGroupNames: ['Recurring'],
-        },
-
         // -----------------------------------------------------------
         // group by root
         {
@@ -441,58 +318,6 @@ describe('Group names', () => {
             expectedGroupNames: ['/'],
             path: 'a.md',
         },
-
-        // -----------------------------------------------------------
-        // group by status
-        {
-            groupBy: 'status',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['Todo'],
-        },
-        {
-            groupBy: 'status',
-            taskLine: '- [x] a',
-            expectedGroupNames: ['Done'],
-        },
-        {
-            groupBy: 'status',
-            taskLine: '- [X] a',
-            expectedGroupNames: ['Done'],
-        },
-        {
-            groupBy: 'status',
-            taskLine: '- [/] a',
-            expectedGroupNames: ['Done'],
-        },
-        {
-            groupBy: 'status',
-            taskLine: '- [-] a',
-            expectedGroupNames: ['Done'],
-        },
-        {
-            groupBy: 'status',
-            taskLine: '- [!] a',
-            expectedGroupNames: ['Done'],
-        },
-
-        // -----------------------------------------------------------
-        // group by tags
-        {
-            groupBy: 'tags',
-            taskLine: '- [ ] a #tag1',
-            expectedGroupNames: ['#tag1'],
-        },
-        {
-            groupBy: 'tags',
-            taskLine: '- [ ] a #tag1 #tag2',
-            expectedGroupNames: ['#tag1', '#tag2'],
-        },
-        {
-            groupBy: 'tags',
-            taskLine: '- [x] a',
-            expectedGroupNames: ['(No tags)'],
-        },
-
         // -----------------------------------------------------------
     ];
 
diff --git a/tests/Query/Filter/FilenameField.test.ts b/tests/Query/Filter/FilenameField.test.ts
index 3558b403..693d29f6 100644
--- a/tests/Query/Filter/FilenameField.test.ts
+++ b/tests/Query/Filter/FilenameField.test.ts
@@ -1,6 +1,7 @@
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { FilenameField } from '../../../src/Query/Filter/FilenameField';
 import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
+import { fromLine } from '../../TestHelpers';
 
 describe('filename', () => {
     it('should provide access to the file name with extension', () => {
@@ -101,3 +102,22 @@ describe('sorting by filename', () => {
         CustomMatchersForSorting.expectTaskComparesAfter(sorter, with_path('a/b.md'), with_path('c/d.md'));
     });
 });
+
+describe('grouping by filename', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new FilenameField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+
+    it.each([
+        ['- [ ] a', 'a/b/c.md', ['[[c]]']],
+        // underscores in links shall not be escaped
+        ['- [ ] a', 'a/b/_c_.md', ['[[_c_]]']],
+    ])('task "%s" with path "%s" should have groups: %s', (taskLine: string, path: string, groups: string[]) => {
+        // Arrange
+        const grouper = new FilenameField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
+    });
+});
diff --git a/tests/Query/Filter/HeadingField.test.ts b/tests/Query/Filter/HeadingField.test.ts
index 67bcf496..a91c6dfa 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 * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
+import { fromLine } from '../../TestHelpers';
 
 function testTaskFilterForHeading(filter: FilterOrErrorMessage, precedingHeader: string | null, expected: boolean) {
     const builder = new TaskBuilder();
@@ -87,3 +88,27 @@ describe('sorting by heading', () => {
         CustomMatchersForSorting.expectTaskComparesAfter(sorter, with_heading('Heading 1'), with_heading('Heading 2'));
     });
 });
+
+describe('grouping by heading', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new HeadingField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+
+    it.each([
+        ['- [ ] xxx', null, ['(No heading)']],
+        ['- [ ] xxx', '', ['(No heading)']],
+        ['- [ ] xxx', 'heading', ['heading']],
+        // underscores in headings are NOT escaped - will be rendered
+        ['- [ ] xxx', 'heading _italic text_', ['heading _italic text_']],
+    ])(
+        'task "%s" with header "%s" should have groups: %s',
+        (taskLine: string, header: string | null, groups: string[]) => {
+            // Arrange
+            const grouper = new HeadingField().createGrouper().grouper;
+
+            // Assert
+            expect(grouper(fromLine({ line: taskLine, precedingHeader: header }))).toEqual(groups);
+        },
+    );
+});
diff --git a/tests/Query/Filter/PathField.test.ts b/tests/Query/Filter/PathField.test.ts
index fad2415d..2eb83f5e 100644
--- a/tests/Query/Filter/PathField.test.ts
+++ b/tests/Query/Filter/PathField.test.ts
@@ -143,3 +143,25 @@ describe('sorting by path', () => {
         expectTaskComparesAfter(sorter, with_path('a/b.md'), with_path('c/d.md'));
     });
 });
+
+describe('grouping by path', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new PathField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+
+    it.each([
+        // the file extension is removed
+        ['- [ ] a', 'a/b/c.md', ['a/b/c']],
+        // underscores in paths are escaped
+        ['- [ ] a', '_a_/b/_c_.md', ['\\_a\\_/b/\\_c\\_']],
+        // backslashes are escaped. (this artificial example is to test escaping)
+        ['- [ ] a', 'a\\b\\c.md', ['a\\\\b\\\\c']],
+    ])('task "%s" with path "%s" should have groups: %s', (taskLine: string, path: string, groups: string[]) => {
+        // Arrange
+        const grouper = new PathField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
+    });
+});
diff --git a/tests/Query/Filter/PriorityField.test.ts b/tests/Query/Filter/PriorityField.test.ts
index 93113a93..bc5fa2a7 100644
--- a/tests/Query/Filter/PriorityField.test.ts
+++ b/tests/Query/Filter/PriorityField.test.ts
@@ -2,6 +2,7 @@ import { Priority } from '../../../src/Task';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import { PriorityField } from '../../../src/Query/Filter/PriorityField';
+import { fromLine } from '../../TestHelpers';
 
 import {
     expectTaskComparesAfter,
@@ -155,3 +156,23 @@ describe('sorting by priority', () => {
         expectTaskComparesAfter(sorter, with_priority(Priority.High), with_priority(Priority.Medium));
     });
 });
+
+describe('grouping by priority', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new PriorityField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+
+    it.each([
+        ['- [ ] a ⏫', ['Priority 1: High']],
+        ['- [ ] a 🔼', ['Priority 2: Medium']],
+        ['- [ ] a', ['Priority 3: None']],
+        ['- [ ] a 🔽', ['Priority 4: Low']],
+    ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
+        // Arrange
+        const grouper = new PriorityField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
+    });
+});
diff --git a/tests/Query/Filter/RecurrenceField.test.ts b/tests/Query/Filter/RecurrenceField.test.ts
index 5f9cdf5d..91b7efa0 100644
--- a/tests/Query/Filter/RecurrenceField.test.ts
+++ b/tests/Query/Filter/RecurrenceField.test.ts
@@ -6,6 +6,7 @@ import moment from 'moment';
 import { RecurrenceField } from '../../../src/Query/Filter/RecurrenceField';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { RecurrenceBuilder } from '../../TestingTools/RecurrenceBuilder';
+import { fromLine } from '../../TestHelpers';
 
 window.moment = moment;
 
@@ -74,3 +75,23 @@ describe('recurrence', () => {
         expect(filter).toMatchTask(with_recurrence('every month on the last'));
     });
 });
+
+describe('grouping by recurrence', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new RecurrenceField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+
+    it.each([
+        ['- [ ] a', ['None']],
+        ['- [ ] a 🔁 every Sunday', ['every week on Sunday']],
+        ['- [ ] a 🔁 every Sunday when done', ['every week on Sunday when done']],
+        ['- [ ] a 🔁 every 6 months on the 2nd Wednesday', ['every 6 months on the 2nd Wednesday']],
+    ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
+        // Arrange
+        const grouper = new RecurrenceField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
+    });
+});
diff --git a/tests/Query/Filter/RecurringField.test.ts b/tests/Query/Filter/RecurringField.test.ts
index 96109e60..68c285ca 100644
--- a/tests/Query/Filter/RecurringField.test.ts
+++ b/tests/Query/Filter/RecurringField.test.ts
@@ -40,3 +40,21 @@ describe('recurring', () => {
         testRecurringFilter(filter, invalid, true);
     });
 });
+
+describe('grouping by recurring', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new RecurringField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+
+    it.each([
+        ['- [ ] a', ['Not Recurring']],
+        ['- [ ] a 🔁 every Sunday', ['Recurring']],
+    ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
+        // Arrange
+        const grouper = new RecurringField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
+    });
+});
diff --git a/tests/Query/Filter/StatusField.test.ts b/tests/Query/Filter/StatusField.test.ts
index 3319cf1b..86fb50b6 100644
--- a/tests/Query/Filter/StatusField.test.ts
+++ b/tests/Query/Filter/StatusField.test.ts
@@ -8,6 +8,7 @@ import {
     expectTaskComparesEqual,
 } from '../../CustomMatchers/CustomMatchersForSorting';
 import { StatusConfiguration, StatusType } from '../../../src/StatusConfiguration';
+import { fromLine } from '../../TestHelpers';
 
 describe('status', () => {
     it('done', () => {
@@ -80,3 +81,25 @@ describe('sorting by status', () => {
         expectTaskComparesEqual(sorter, doneTask, doneTask);
     });
 });
+
+describe('grouping by status', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new StatusField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+
+    it.each([
+        ['- [ ] a', ['Todo']],
+        ['- [x] a', ['Done']],
+        ['- [X] a', ['Done']],
+        ['- [/] a', ['Done']],
+        ['- [-] a', ['Done']],
+        ['- [!] a', ['Done']],
+    ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
+        // Arrange
+        const grouper = new StatusField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
+    });
+});
diff --git a/tests/Query/Filter/TagsField.test.ts b/tests/Query/Filter/TagsField.test.ts
index 22970a13..481305ea 100644
--- a/tests/Query/Filter/TagsField.test.ts
+++ b/tests/Query/Filter/TagsField.test.ts
@@ -627,3 +627,29 @@ describe('Sort by tags', () => {
         ).toEqual(correctExpectedOrder);
     });
 });
+
+describe('grouping by tag', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new TagsField();
+        const fieldGrouper = field.createGrouper();
+        expect(field.supportsGrouping()).toEqual(true);
+        expect(fieldGrouper.property).toEqual('tags');
+    });
+
+    it.each([
+        ['- [ ] a #tag1', ['#tag1']],
+        ['- [ ] a #tag1 #tag2', ['#tag1', '#tag2']],
+        ['- [x] a', ['(No tags)']],
+    ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
+        // Arrange
+        const grouper = new TagsField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
+    });
+
+    it('supports grouping methods correctly', () => {
+        const field = new TagsField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+});