Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1547 (2023-01-18 05:47:31)

feat: Add 'status.type' filter and 'sort by status.type'
<!--- Provide a general summary of your changes in the Title above -->

# Description

Add 'status.type' filter and 'sort by status.type' instructions.

## Motivation and Context

Missing functionality

## How has this been tested?

With 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: -->

Changes visible to users:

- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)

Internal changes:

- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## 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](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#maintaining-the-tests).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md) 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://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)
- [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/docs/getting-started/statuses.md b/docs/getting-started/statuses.md
index 13436611..bddea0c1 100644
--- a/docs/getting-started/statuses.md
+++ b/docs/getting-started/statuses.md
@@ -26,13 +26,15 @@ has_toc: false
   - the single character in the `[]` at the start of the task.
 - `status name`
   - a name for the status.
+  - this is flexible: for custom statuses, you can use any name you wish.
   - is searchable with `status.name`, for example `status.name includes todo`.
 - `next status symbol`
   - the status symbol to use when the task is toggled.
 - `status type`
   - one of `TODO`, `IN_PROGRESS`, `DONE`, `CANCELLED`, `NON_TASK`.
-  - these do not yet have any effect on search results.
   - by assigning one of these types to your custom statuses, you can control some aspects of Tasks' behaviour for all task lines that use that particular custom status.
+  - is searchable with `status.type`, for example `status.type includes IN_PROGRESS`.
+  - you can have any number of custom statuses with the same status type, and then search them conveniently with `status.type`
 
 ### Status Types
 
@@ -49,9 +51,14 @@ The tasks shown are purely examples for context. The `~` column is just an arbit
 | Matches `done` | no | no | YES | YES | no |
 | Matches `not done` | YES | YES | no | no | no |
 | Matches `status.name includes todo` | YES | no | no | no | no |
+| Matches `status.type includes TODO` | YES | no | no | no | no |
 | Matches `status.name includes in progress` | no | YES | no | no | no |
+| Matches `status.type includes IN_PROGRESS` | no | YES | no | no | no |
 | Matches `status.name includes done` | no | no | YES | no | no |
+| Matches `status.type includes DONE` | no | no | YES | no | no |
 | Matches `status.name includes cancelled` | no | no | no | YES | no |
+| Matches `status.type includes CANCELLED` | no | no | no | YES | no |
+| Matches `status.type includes NON_TASK` | no | no | no | no | YES |
 | Name for `group by status` | Todo | Done | Done | Done | Done |
 
 <!-- placeholder to force blank line after table --> <!-- endInclude -->
@@ -199,6 +206,8 @@ We are tracking this in [issue #1486](https://github.com/obsidian-tasks-group/ob
 - `not done` - matches tasks with status types `TODO` and `IN_PROGRESS`
 - `status.name` text search
 - `sort by status.name`
+- `status.type` text search
+- `sort by status.type`
 
 For details, see [Filters for Task Statuses]({{ site.baseurl }}{% link queries/filters.md %}#filters-for-task-statuses)
 
diff --git a/docs/queries/filters.md b/docs/queries/filters.md
index 8d70b501..16242e77 100644
--- a/docs/queries/filters.md
+++ b/docs/queries/filters.md
@@ -205,6 +205,8 @@ because the tasks starts before tomorrow. Only one of the dates needs to match.
 
 ### Status Name
 
+- This searches the names given to your custom statuses.
+- For example, perhaps you might have named `[!]` as `Important`, and so this field would search then text `Important` for all tasks with that status symbol.
 - `status.name (includes|does not include) <string>`
   - Matches case-insensitive (disregards capitalization).
 - `status.name (regex matches|regex does not match) /<JavaScript-style Regex>/`
@@ -216,6 +218,27 @@ because the tasks starts before tomorrow. Only one of the dates needs to match.
 
 For more information, including adding your own customised statuses, see [Statuses]({{ site.baseurl }}{% link getting-started/statuses.md %}).
 
+### Status Type
+
+- This searches the types you have given to your custom statuses.
+- The following status types are available:
+  - `TODO`
+  - `IN_PROGRESS`
+  - `DONE`
+  - `CANCELLED`
+  - `NON_TASK`
+- This search is efficient if you wish to find all tasks that are `IN_PROGRESS`, and you have set up your statuses to have `[/]`, `[d]` and perhaps several other all treated as `IN_PROGRESS`.
+- `status.type (includes|does not include) <string>`
+  - Matches case-insensitive (disregards capitalization).
+- `status.type (regex matches|regex does not match) /<JavaScript-style Regex>/`
+  - Does regular expression match (case-sensitive by default).
+  - Essential reading: [Regular Expression Searches]({{ site.baseurl }}{% link queries/regular-expressions.md %}).
+
+{: .released }
+`status.type` text searching was introduced in Tasks X.Y.Z.
+
+For more information, including adding your own customised statuses, see [Statuses]({{ site.baseurl }}{% link getting-started/statuses.md %}).
+
 ### Status Examples
 
 Find any tasks that have status symbols you have not yet added to your Tasks settings:
diff --git a/docs/quick-reference/index.md b/docs/quick-reference/index.md
index 7d694bf6..367598ef 100644
--- a/docs/quick-reference/index.md
+++ b/docs/quick-reference/index.md
@@ -20,6 +20,7 @@ This table summarizes the filters and other options available inside a `tasks` b
 | `done`<br>`not done`                                                                                                                                                                                | `sort by status`                            | `group by status`     |                        |
 | `done (before, after, on) <date>`<br>`has done date`<br>`no  done date`<br>`done date is invalid`                                                                                                   | `sort by done`                              | `group by done`       | `hide done date`       |
 | `status.name (includes, does not include) <string>`<br>`status.name (regex matches, regex does not match) /regex/i`                                                                                 | `sort by status.name`                       |                       |                        |
+| `status.type (includes, does not include) <string>`<br>`status.type (regex matches, regex does not match) /regex/i`                                                                                 | `sort by status.type`                       |                       |                        |
 | `starts (before, after, on) <date>`<br>`has start date`<br>`no  start date`<br>`start date is invalid`                                                                                              | `sort by start`                             | `group by start`      | `hide start date`      |
 | `scheduled (before, after, on) <date>`<br>`has scheduled date`<br>`no  scheduled date`<br>`scheduled date is invalid`                                                                               | `sort by scheduled`                         | `group by scheduled`  | `hide scheduled date`  |
 | `due (before, after, on) <date>`<br>`has due date`<br>`no  due date`<br>`due date is invalid`                                                                                                       | `sort by due`                               | `group by due`        | `hide due date`        |
diff --git a/src/Query/Filter/StatusTypeField.ts b/src/Query/Filter/StatusTypeField.ts
new file mode 100644
index 00000000..a52d4f71
--- /dev/null
+++ b/src/Query/Filter/StatusTypeField.ts
@@ -0,0 +1,23 @@
+import type { Task } from '../../Task';
+import { TextField } from './TextField';
+
+/**
+ * A ${@link Field} implementation for searching status.type
+ */
+export class StatusTypeField extends TextField {
+    constructor() {
+        super();
+    }
+
+    public fieldName(): string {
+        return 'status.type';
+    }
+
+    value(task: Task): string {
+        return task.status.type;
+    }
+
+    supportsSorting(): boolean {
+        return true;
+    }
+}
diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts
index ab0609a2..2b328a7d 100644
--- a/src/Query/FilterParser.ts
+++ b/src/Query/FilterParser.ts
@@ -15,6 +15,7 @@ import { BooleanField } from './Filter/BooleanField';
 import { FilenameField } from './Filter/FilenameField';
 import { UrgencyField } from './Filter/UrgencyField';
 import { StatusNameField } from './Filter/StatusNameField';
+import { StatusTypeField } from './Filter/StatusTypeField';
 
 import { RecurrenceField } from './Filter/RecurrenceField';
 import type { FilterOrErrorMessage } from './Filter/Filter';
@@ -22,6 +23,7 @@ import type { Sorter } from './Sorter';
 
 const fieldCreators = [
     () => new StatusNameField(), // status.name is before status, to avoid ambiguity
+    () => new StatusTypeField(), // status.type is before status, to avoid ambiguity
     () => new StatusField(),
     () => new RecurringField(),
     () => new PriorityField(),


Test patch:
diff --git a/tests/DocsSamplesForStatuses.test.Status_Transitions status-types.approved.md b/tests/DocsSamplesForStatuses.test.Status_Transitions status-types.approved.md
index 33c7bb35..ba3bceb0 100644
--- a/tests/DocsSamplesForStatuses.test.Status_Transitions status-types.approved.md
+++ b/tests/DocsSamplesForStatuses.test.Status_Transitions status-types.approved.md
@@ -6,9 +6,14 @@
 | Matches `done` | no | no | YES | YES | no |
 | Matches `not done` | YES | YES | no | no | no |
 | Matches `status.name includes todo` | YES | no | no | no | no |
+| Matches `status.type includes TODO` | YES | no | no | no | no |
 | Matches `status.name includes in progress` | no | YES | no | no | no |
+| Matches `status.type includes IN_PROGRESS` | no | YES | no | no | no |
 | Matches `status.name includes done` | no | no | YES | no | no |
+| Matches `status.type includes DONE` | no | no | YES | no | no |
 | Matches `status.name includes cancelled` | no | no | no | YES | no |
+| Matches `status.type includes CANCELLED` | no | no | no | YES | no |
+| Matches `status.type includes NON_TASK` | no | no | no | no | YES |
 | Name for `group by status` | Todo | Done | Done | Done | Done |
 
 
diff --git a/tests/DocsSamplesForStatuses.test.ts b/tests/DocsSamplesForStatuses.test.ts
index 9f7a6f5b..0368c602 100644
--- a/tests/DocsSamplesForStatuses.test.ts
+++ b/tests/DocsSamplesForStatuses.test.ts
@@ -145,9 +145,14 @@ function verifyTransitionsAsMarkdownTable(statuses: Status[]) {
     filterAllStatuses(FilterParser.parseFilter('done')!);
     filterAllStatuses(FilterParser.parseFilter('not done')!);
     filterAllStatuses(FilterParser.parseFilter('status.name includes todo')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type includes TODO')!);
     filterAllStatuses(FilterParser.parseFilter('status.name includes in progress')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type includes IN_PROGRESS')!);
     filterAllStatuses(FilterParser.parseFilter('status.name includes done')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type includes DONE')!);
     filterAllStatuses(FilterParser.parseFilter('status.name includes cancelled')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type includes CANCELLED')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type includes NON_TASK')!);
 
     {
         const cells: string[] = ['Name for `group by status`'];
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 82df97c7..4e63b7cd 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -69,6 +69,7 @@ describe('Query parsing', () => {
         'starts before 2021-12-27',
         'starts on 2021-12-27',
         'status.name includes cancelled',
+        'status.type includes IN_PROGRESS',
         'tag does not include #sometag',
         'tag does not include sometag',
         'tag includes #sometag',
@@ -150,6 +151,8 @@ describe('Query parsing', () => {
             'sort by status',
             'sort by status.name',
             'sort by status.name reverse',
+            'sort by status.type',
+            'sort by status.type reverse',
             'sort by tag 5',
             'sort by tag reverse 3',
             'sort by tag reverse',
diff --git a/tests/Query/Filter/StatusTypeField.test.ts b/tests/Query/Filter/StatusTypeField.test.ts
new file mode 100644
index 00000000..b643a3d7
--- /dev/null
+++ b/tests/Query/Filter/StatusTypeField.test.ts
@@ -0,0 +1,110 @@
+import { StatusTypeField } from '../../../src/Query/Filter/StatusTypeField';
+import * as TestHelpers from '../../TestHelpers';
+import { toBeValid, toMatchTask, toMatchTaskFromLine } from '../../CustomMatchers/CustomMatchersForFilters';
+import {
+    expectTaskComparesAfter,
+    expectTaskComparesBefore,
+    expectTaskComparesEqual,
+} from '../../CustomMatchers/CustomMatchersForSorting';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import { StatusType } from '../../../src/StatusConfiguration';
+import { Status } from '../../../src/Status';
+
+expect.extend({
+    toBeValid,
+    toMatchTask,
+    toMatchTaskFromLine,
+});
+
+// Abbreviated names so that the markdown text is aligned
+const todoTask = TestHelpers.fromLine({ line: '- [ ] Xxx' });
+const inprTask = TestHelpers.fromLine({ line: '- [/] Xxx' });
+const doneTask = TestHelpers.fromLine({ line: '- [x] Xxx' });
+const cancTask = TestHelpers.fromLine({ line: '- [-] Xxx' });
+const unknTask = TestHelpers.fromLine({ line: '- [%] Xxx' });
+const non_Task = new TaskBuilder().statusValues('^', 'non-task', 'x', false, StatusType.NON_TASK).build();
+const emptTask = new TaskBuilder().status(Status.makeEmpty()).build();
+
+describe('status.name', () => {
+    it('value', () => {
+        // Arrange
+        const filter = new StatusTypeField();
+
+        // Assert
+        expect(filter.value(cancTask)).toStrictEqual('CANCELLED');
+        expect(filter.value(doneTask)).toStrictEqual('DONE');
+        expect(filter.value(inprTask)).toStrictEqual('IN_PROGRESS');
+        expect(filter.value(non_Task)).toStrictEqual('NON_TASK');
+        expect(filter.value(todoTask)).toStrictEqual('TODO');
+        expect(filter.value(unknTask)).toStrictEqual('TODO');
+    });
+
+    it('status.type includes', () => {
+        // Arrange
+        const filter = new StatusTypeField().createFilterOrErrorMessage('status.type includes IN_PROGRESS');
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).toMatchTask(inprTask);
+        expect(filter).not.toMatchTask(todoTask);
+    });
+
+    it('status-name is not valid', () => {
+        // Arrange
+        const filter = new StatusTypeField().createFilterOrErrorMessage('status-type includes NON_TASK');
+
+        // Assert
+        // Check that the '.' in status.name is interpreted exactly as a dot.
+        expect(filter).not.toBeValid();
+    });
+});
+
+describe('sorting by status.name', () => {
+    it('supports Field sorting methods correctly', () => {
+        const field = new StatusTypeField();
+        expect(field.supportsSorting()).toEqual(true);
+    });
+
+    it('should parse sort line correctly', () => {
+        expect(new StatusTypeField().createSorterFromLine('sort by status.type reverse')).not.toBeNull();
+        expect(new StatusTypeField().createSorterFromLine('sort by status-type reverse')).toBeNull();
+    });
+
+    it('sort by status.name', () => {
+        // Arrange
+        const sorter = new StatusTypeField().createNormalSorter();
+
+        // Assert
+        expectTaskComparesEqual(sorter, cancTask, cancTask);
+        expectTaskComparesEqual(sorter, todoTask, unknTask); // Unknown treated as TODO
+
+        // Alphabetical order by status name
+        expectTaskComparesBefore(sorter, cancTask, doneTask);
+        expectTaskComparesBefore(sorter, doneTask, inprTask);
+        expectTaskComparesBefore(sorter, inprTask, non_Task);
+        expectTaskComparesBefore(sorter, non_Task, todoTask);
+
+        // Users won't see empty tasks, but test them anyway
+        expectTaskComparesBefore(sorter, doneTask, emptTask);
+        expectTaskComparesBefore(sorter, emptTask, inprTask);
+    });
+
+    it('sort by status.name reverse', () => {
+        // Arrange
+        const sorter = new StatusTypeField().createReverseSorter();
+
+        // Assert
+        expectTaskComparesEqual(sorter, cancTask, cancTask);
+        expectTaskComparesEqual(sorter, todoTask, unknTask); // Unknown treated as TODO
+
+        // Reverse of Alphabetical order by status name
+        expectTaskComparesAfter(sorter, cancTask, doneTask);
+        expectTaskComparesAfter(sorter, doneTask, inprTask);
+        expectTaskComparesAfter(sorter, inprTask, non_Task);
+        expectTaskComparesAfter(sorter, non_Task, todoTask);
+
+        // Users won't see empty tasks, but test them anyway
+        expectTaskComparesAfter(sorter, doneTask, emptTask);
+        expectTaskComparesAfter(sorter, emptTask, inprTask);
+    });
+});