Repository: obsidian-tasks-group/obsidian-tasks
Pull request #739 (2022-06-06 21:09:37)

feat: Add 'has done date', 'has happens date', 'no done date', 'no happens date'
## Description
<!--- Describe your changes in detail -->

Add:

- `has done date`
- `no done date`
- `has happens date`
- `no happens date`

### Notes

This change was partly an experiment to see if I could easily represent the `has done date`/`no done date` filters in the `Field` class hierarchy.

My conclusion was that I can, and so in a later PR I will refactor a lot of the remaining presence/absence filters from Query out to use the recently-added Field classes.

So please don't worry about the temporary duplication of code created in this PR. It will go away soon.

## Motivation and Context

This completes the implementation of https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/618

## How has this been tested?

Via new unit tests

## Screenshots (if appropriate):

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Refactor (non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## 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`.
- [x] My change requires a change to the documentation.
- [x] I have updated the documentation accordingly.
- [x] My change has adequate Unit Test coverage.

By creating a Pull Request you agree to our [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md). For further guidance on contributing please see [contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)


claremacrae: /CC @schemar for info, in case you are interested. And only if you are interested!

Patch:
diff --git a/docs/queries/filters.md b/docs/queries/filters.md
index cc69a961..fb4caaca 100644
--- a/docs/queries/filters.md
+++ b/docs/queries/filters.md
@@ -67,8 +67,12 @@ Instead you would have two queries, one for each condition:
 
 - `done`
 - `not done`
+- `no done date`
+- `has done date`
 - `done (before|after|on) <date>`
 
+> `no done date` and `has done date` were introduced in Tasks 1.7.0.
+
 ### Priority
 
 - `priority is (above|below)? (low|none|medium|high)`
@@ -129,6 +133,13 @@ For example, `happens before tomorrow` will return all tasks that are starting,
 If a task starts today and is due in a week from today, `happens before tomorrow` will match,
 because the tasks starts before tomorrow. Only one of the dates needs to match.
 
+- `no happens date`
+  - Return tasks where _none_ of start date, scheduled date, and due date are set.
+- `has happens date`
+  - Return tasks where _any_ of start date, scheduled date, _or_ due date are set.
+
+> `no happens date` and `has happens date` were introduced in Tasks 1.7.0.
+
 ### Recurrence
 
 - `is recurring`
diff --git a/src/Query/Filter/DoneDateField.ts b/src/Query/Filter/DoneDateField.ts
index 9999641f..54a10a62 100644
--- a/src/Query/Filter/DoneDateField.ts
+++ b/src/Query/Filter/DoneDateField.ts
@@ -1,12 +1,41 @@
 import type { Moment } from 'moment';
 import type { Task } from '../../Task';
 import { DateField } from './DateField';
+import { FilterOrErrorMessage } from './Filter';
 
 /**
  * Support the 'done' search instruction.
  */
 export class DoneDateField extends DateField {
     private static readonly doneRegexp = /^done (before|after|on)? ?(.*)/;
+    private static readonly instructionForFieldPresence = 'has done date';
+    private static readonly instructionForFieldAbsence = 'no done date';
+
+    public canCreateFilterForLine(line: string): boolean {
+        if (line === DoneDateField.instructionForFieldPresence) {
+            return true;
+        }
+        if (line === DoneDateField.instructionForFieldAbsence) {
+            return true;
+        }
+        return super.canCreateFilterForLine(line);
+    }
+
+    public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
+        if (line === DoneDateField.instructionForFieldPresence) {
+            const result = new FilterOrErrorMessage();
+            result.filter = (task: Task) => this.date(task) !== null;
+            return result;
+        }
+
+        if (line === DoneDateField.instructionForFieldAbsence) {
+            const result = new FilterOrErrorMessage();
+            result.filter = (task: Task) => this.date(task) === null;
+            return result;
+        }
+
+        return super.createFilterOrErrorMessage(line);
+    }
 
     protected filterRegexp(): RegExp {
         return DoneDateField.doneRegexp;
diff --git a/src/Query/Filter/HappensDateField.ts b/src/Query/Filter/HappensDateField.ts
index 4428f227..6a3924ff 100644
--- a/src/Query/Filter/HappensDateField.ts
+++ b/src/Query/Filter/HappensDateField.ts
@@ -1,3 +1,4 @@
+import type { Moment } from 'moment';
 import type { Task } from '../../Task';
 import { DateParser } from '../DateParser';
 import { Field } from './Field';
@@ -9,9 +10,36 @@ import { FilterOrErrorMessage } from './Filter';
  */
 export class HappensDateField extends Field {
     private static readonly happensRegexp = /^happens (before|after|on)? ?(.*)/;
+    private static readonly instructionForFieldPresence = 'has happens date';
+    private static readonly instructionForFieldAbsence = 'no happens date';
+
+    public canCreateFilterForLine(line: string): boolean {
+        if (line === HappensDateField.instructionForFieldPresence) {
+            return true;
+        }
+        if (line === HappensDateField.instructionForFieldAbsence) {
+            return true;
+        }
+        return super.canCreateFilterForLine(line);
+    }
 
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
         const result = new FilterOrErrorMessage();
+
+        if (line === HappensDateField.instructionForFieldPresence) {
+            const result = new FilterOrErrorMessage();
+            result.filter = (task: Task) =>
+                this.dates(task).some((date) => date !== null);
+            return result;
+        }
+
+        if (line === HappensDateField.instructionForFieldAbsence) {
+            const result = new FilterOrErrorMessage();
+            result.filter = (task: Task) =>
+                !this.dates(task).some((date) => date !== null);
+            return result;
+        }
+
         const happensMatch = line.match(this.filterRegexp());
         if (happensMatch !== null) {
             const filterDate = DateParser.parseDate(happensMatch[2]);
@@ -20,27 +48,21 @@ export class HappensDateField extends Field {
             } else {
                 if (happensMatch[1] === 'before') {
                     result.filter = (task: Task) => {
-                        return Array.of(
-                            task.startDate,
-                            task.scheduledDate,
-                            task.dueDate,
-                        ).some((date) => date && date.isBefore(filterDate));
+                        return this.dates(task).some(
+                            (date) => date && date.isBefore(filterDate),
+                        );
                     };
                 } else if (happensMatch[1] === 'after') {
                     result.filter = (task: Task) => {
-                        return Array.of(
-                            task.startDate,
-                            task.scheduledDate,
-                            task.dueDate,
-                        ).some((date) => date && date.isAfter(filterDate));
+                        return this.dates(task).some(
+                            (date) => date && date.isAfter(filterDate),
+                        );
                     };
                 } else {
                     result.filter = (task: Task) => {
-                        return Array.of(
-                            task.startDate,
-                            task.scheduledDate,
-                            task.dueDate,
-                        ).some((date) => date && date.isSame(filterDate));
+                        return this.dates(task).some(
+                            (date) => date && date.isSame(filterDate),
+                        );
                     };
                 }
             }
@@ -54,6 +76,13 @@ export class HappensDateField extends Field {
         return HappensDateField.happensRegexp;
     }
 
+    /**
+     * Return the task's start, scheduled and due dates, any or all of which may be null.
+     */
+    protected dates(task: Task): (Moment | null)[] {
+        return Array.of(task.startDate, task.scheduledDate, task.dueDate);
+    }
+
     protected fieldName(): string {
         return 'happens';
     }


Test patch:
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index a4d754f0..3fd989ca 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -13,6 +13,39 @@ import {
 window.moment = moment;
 
 describe('Query', () => {
+    /**
+     * As more and more filters are added via the Field class, and tested
+     * outside of this test file, there is the chance that someone thinks that
+     * they have correctly added a new filter option, but forgotten to register
+     * it in the Query class.
+     *
+     * This test exists as a growing list of sample filters, and purely checks
+     * that the Query class parses them successfully.
+     *
+     * A failure here means that the Query constructor is missing code to recognise
+     * one of the supported queries/filters.
+     */
+    describe('should recognise supported filters', () => {
+        // TODO Add all other supported filters
+        // In alphabetical order, please
+        const filters = [
+            'has done date',
+            'has happens date',
+            'no done date',
+            'no happens date',
+        ];
+
+        test.concurrent.each<string>(filters)('recognises %j', (filter) => {
+            // Arrange
+            const query = new Query({ source: filter });
+
+            // Assert
+            expect(query.error).toBeUndefined();
+            expect(query.filters.length).toEqual(1);
+            expect(query.filters[0]).toBeDefined();
+        });
+    });
+
     describe('filtering', () => {
         it('filters paths case insensitive', () => {
             // Arrange
diff --git a/tests/Query/Filter/DoneDateField.test.ts b/tests/Query/Filter/DoneDateField.test.ts
new file mode 100644
index 00000000..9fa2d2e4
--- /dev/null
+++ b/tests/Query/Filter/DoneDateField.test.ts
@@ -0,0 +1,43 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import { DoneDateField } from '../../../src/Query/Filter/DoneDateField';
+import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import { testTaskFilter } from '../../TestingTools/FilterTestHelpers';
+
+window.moment = moment;
+
+function testTaskFilterForTaskWithDoneDate(
+    filter: FilterOrErrorMessage,
+    doneDate: string | null,
+    expected: boolean,
+) {
+    const builder = new TaskBuilder();
+    testTaskFilter(filter, builder.doneDate(doneDate).build(), expected);
+}
+
+describe('done date', () => {
+    it('by done date presence', () => {
+        // Arrange
+        const filter = new DoneDateField().createFilterOrErrorMessage(
+            'has done date',
+        );
+
+        // Act, Assert
+        testTaskFilterForTaskWithDoneDate(filter, null, false);
+        testTaskFilterForTaskWithDoneDate(filter, '2022-04-15', true);
+    });
+
+    it('by done date absence', () => {
+        // Arrange
+        const filter = new DoneDateField().createFilterOrErrorMessage(
+            'no done date',
+        );
+
+        // Act, Assert
+        testTaskFilterForTaskWithDoneDate(filter, null, true);
+        testTaskFilterForTaskWithDoneDate(filter, '2022-04-15', false);
+    });
+});
diff --git a/tests/Query/Filter/HappensDateField.test.ts b/tests/Query/Filter/HappensDateField.test.ts
new file mode 100644
index 00000000..5b51aae9
--- /dev/null
+++ b/tests/Query/Filter/HappensDateField.test.ts
@@ -0,0 +1,79 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import { HappensDateField } from '../../../src/Query/Filter/HappensDateField';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import { testTaskFilter } from '../../TestingTools/FilterTestHelpers';
+
+window.moment = moment;
+
+describe('happens date', () => {
+    it('by happens date presence', () => {
+        // Arrange
+        const filter = new HappensDateField().createFilterOrErrorMessage(
+            'has happens date',
+        );
+
+        // Act, Assert
+        testTaskFilter(filter, new TaskBuilder().dueDate(null).build(), false);
+
+        // scheduled, start and due all contribute to happens:
+        testTaskFilter(
+            filter,
+            new TaskBuilder().scheduledDate('2022-04-15').build(),
+            true,
+        );
+        testTaskFilter(
+            filter,
+            new TaskBuilder().startDate('2022-04-15').build(),
+            true,
+        );
+        testTaskFilter(
+            filter,
+            new TaskBuilder().dueDate('2022-04-15').build(),
+            true,
+        );
+
+        // Done date is ignored by happens
+        testTaskFilter(
+            filter,
+            new TaskBuilder().doneDate('2022-04-15').build(),
+            false,
+        );
+    });
+
+    it('by happens date absence', () => {
+        // Arrange
+        const filter = new HappensDateField().createFilterOrErrorMessage(
+            'no happens date',
+        );
+
+        // Act, Assert
+        testTaskFilter(filter, new TaskBuilder().dueDate(null).build(), true);
+
+        // scheduled, start and due all contribute to happens:
+        testTaskFilter(
+            filter,
+            new TaskBuilder().scheduledDate('2022-04-15').build(),
+            false,
+        );
+        testTaskFilter(
+            filter,
+            new TaskBuilder().startDate('2022-04-15').build(),
+            false,
+        );
+        testTaskFilter(
+            filter,
+            new TaskBuilder().dueDate('2022-04-15').build(),
+            false,
+        );
+
+        // Done date is ignored by happens
+        testTaskFilter(
+            filter,
+            new TaskBuilder().doneDate('2022-04-15').build(),
+            true,
+        );
+    });
+});