Skip to content

feat: Allow additional tasks states and filtering by states #675 #670

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
53d2d8e
Markdown lint fixes
sytone May 3, 2022
bea862f
Merge branch 'main' of https://github.com/schemar/obsidian-tasks
sytone May 4, 2022
f2104c9
feat: disregard the global tag
sytone May 4, 2022
1bb8f9f
feat: allow tag query to be hashless
sytone May 5, 2022
d8a3168
Merge branch 'main' of https://github.com/schemar/obsidian-tasks
sytone May 5, 2022
75eba71
test: refactor tag tests out and make them data driven
sytone May 5, 2022
d8a95b0
feat: update query language, add tests, make hash optional and query …
sytone May 5, 2022
547f776
test: add tests to validate substring
sytone May 5, 2022
9716007
test: chect to see if global tag is not found in query
sytone May 5, 2022
a3964f7
docs: update filter documentation on the tag query
sytone May 5, 2022
9c64527
feat: add ability to sort by tag instance/index
sytone May 7, 2022
f90abc4
Merge branch 'main' of https://github.com/schemar/obsidian-tasks
sytone May 7, 2022
4b88b4a
fix: resolved issues raised in PR.
sytone May 15, 2022
f02f2d2
build: add markdown lint to list command
sytone May 15, 2022
67e97c6
fix: resolve remaining comments and revert markdown lint changes
sytone May 15, 2022
b2ace95
docs: update grammar based issue and sorting description for tags
sytone May 15, 2022
3848523
Merge branch 'main' of https://github.com/schemar/obsidian-tasks
sytone May 17, 2022
788e25e
feat: Allow additional tasks states and filtering by states
sytone May 19, 2022
de6cb0f
feat: add cancelled and in progress along with minimal supported task…
sytone May 20, 2022
010f439
feat: add query capabilities for status
sytone May 20, 2022
bd31de1
build: enable minify main.js 604.9kb -> 283.8kb
sytone May 20, 2022
b08bdc8
build: move script to symlinks
sytone May 20, 2022
f58186a
fix: Update li rendering to match obsidian
sytone May 20, 2022
9d27b5d
feat: add feature flags to plugin
sytone May 21, 2022
149eb34
update flags for next menu feature only
sytone May 21, 2022
d5976ba
feat: add git attributes to force LF on windows
sytone May 22, 2022
292f684
Merge commit 'ff1216477242401139fee2031b28ebec9422334c' into sytone/i…
sytone May 22, 2022
69b8517
Merge from upstream main
sytone May 29, 2022
2b92499
Merge branch 'main' of https://github.com/schemar/obsidian-tasks into…
sytone May 29, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions scripts/Test-TasksInLocalObsidian.ps1
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
[CmdletBinding()]
param (
[Parameter(HelpMessage = 'The path to the plugins folder uner the .obsidian directory.')]
[String]
$ObsidianPluginRoot = $env:OBSIDIAN_PLUGIN_ROOT,
[Parameter(HelpMessage = 'The folder name of the plugin to copy the files to.')]
[String]
$PluginFolderName = 'obsidian-tasks-plugin'
[Parameter(HelpMessage = 'The folder name of the plugin to copy the files to.')]
[String]
$PluginFolderName = 'obsidian-tasks-plugin'
)

$repoRoot = (Resolve-Path -Path $(git rev-parse --show-toplevel)).Path
Expand Down Expand Up @@ -50,4 +46,8 @@ if ($?) {
Write-Error 'Build failed'
}

Pop-Location
Write-Error 'Build failed'
}

Pop-Location
10 changes: 5 additions & 5 deletions src/Commands/CreateOrEdit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { App, Editor, MarkdownView, View } from 'obsidian';
import { StatusRegistry } from 'StatusRegistry';
import { TaskModal } from '../TaskModal';
import { Priority, Status, Task } from '../Task';
import { Status } from '../Status';
import { Priority, Task } from '../Task';

export const createOrEdit = (
checking: boolean,
Expand Down Expand Up @@ -64,11 +66,10 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {
// Should never happen; everything in the regex is optional.
console.error('Tasks: Cannot create task on line:', line);
return new Task({
status: Status.Todo,
status: Status.TODO,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my own education, I'm curious as to why Todo -> TODO?

Asking as it increases the number of changes to review, not because I want to change it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistency in code, as these are constants I have seen they should be upper case in TS/JS. Also makes it super easy to see when reading code that these are not a normal variable.

description: '',
path,
indentation: '',
originalStatusCharacter: ' ',
priority: Priority.None,
startDate: null,
scheduledDate: null,
Expand All @@ -86,7 +87,7 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {

const indentation: string = nonTaskMatch[1];
const statusString: string = nonTaskMatch[3] ?? ' ';
const status = statusString === ' ' ? Status.Todo : Status.Done;
const status = StatusRegistry.getInstance().byIndicator(statusString);
let description: string = nonTaskMatch[4];

const blockLinkMatch = line.match(Task.blockLinkRegex);
Expand All @@ -101,7 +102,6 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {
description,
path,
indentation,
originalStatusCharacter: statusString,
blockLink,
priority: Priority.None,
startDate: null,
Expand Down
120 changes: 120 additions & 0 deletions src/Commands/SelectStatus.ts.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// import { Editor, MarkdownView, View } from 'obsidian';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see elsewhere that you've introduced a Feature Toggle, and I do like that idea. I agree that it allows working code to be committed to main, to enable testing before it's ready for prime-time.

https://martinfowler.com/articles/feature-toggles.html

Sorry if this comment is premature (I did check that the PR is ready for review) but the usual behaviour of feature toggles is to allow experimental code to be turned on or off by changing the toggle.

As it's all commented out, I'm unsure how we can test it.

Would it be possible for all the UI code to be uncommented out, and then for the code at run-time to only enable the new features if the feature toggle is turned on?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Later: I see that the feature toggle is about whether to show a pop-up menu, and this code is about adding a command.

I still would suggest the new code be activated, even if it's not called, so that if there are any future refactorings, like renaming symbols, IDEs would update the inactivated code too, preventing the commented-out code later needing to be manually updated for any changes made in between.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming in a separate PR. I added the feature capability in late in the cycle so it is skipped for the status change as the refactor is to large. However the UI will be behind the switch as it may be more problematic. All the UI code is just txt files for the moments as I think it through. This means that this PR can go in and the next one will enable that behind the feature flag.

When a feature is marked as stable we can have the settings automatically show it in the main body as well with a warning if the user has it disabled stating that it is now stable and available for use.


// import { Task } from '../Task';

// import { promptForMark } from '../ui/TaskMarkModal';
// export const selectStatus = (checking: boolean, editor: Editor, view: View) => {
// const mark = await promptForMark(this.app, this.plugin);
// if (mark) {
// this.markTaskOnLines(mark, this.getCurrentLinesFromEditor(editor));
// }

// if (checking) {
// if (!(view instanceof MarkdownView)) {
// // If we are not in a markdown view, the command shouldn't be shown.
// return false;
// }

// // The command should always trigger in a markdown view:
// // - Convert lines to list items.
// // - Convert list items to tasks.
// // - Toggle tasks' status.
// return true;
// }

// if (!(view instanceof MarkdownView)) {
// // Should never happen due to check above.
// return;
// }

// // We are certain we are in the editor due to the check above.
// const path = view.file?.path;
// if (path === undefined) {
// return;
// }

// const cursorPosition = editor.getCursor();
// const lineNumber = cursorPosition.line;
// const line = editor.getLine(lineNumber);

// const toggledLine = toggleLine({ line, path });
// editor.setLine(lineNumber, toggledLine);

// // The cursor is moved to the end of the line by default.
// // If there is text on the line, put the cursor back where it was on the line.
// if (/[^ [\]*-]/.test(toggledLine)) {
// editor.setCursor({
// line: cursorPosition.line,
// // Need to move the cursor by the distance we added to the beginning.
// ch: cursorPosition.ch + toggledLine.length - line.length,
// });
// }
// };

// const toggleLine = ({ line, path }: { line: string; path: string }): string => {
// let toggledLine: string = line;

// const task = Task.fromLine({
// line,
// path,
// sectionStart: 0, // We don't need this to toggle it here in the editor.
// sectionIndex: 0, // We don't need this to toggle it here in the editor.
// precedingHeader: null, // We don't need this to toggle it here in the editor.
// });
// if (task !== null) {
// toggledLine = toggleTask({ task });
// } else {
// // If the task is null this means that we have one of:
// // 1. a regular checklist item
// // 2. a list item
// // 3. a simple text line

// // The task regex will match checklist items.
// const regexMatch = line.match(Task.taskRegex);
// if (regexMatch !== null) {
// toggledLine = toggleChecklistItem({ regexMatch });
// } else {
// // This is not a checklist item. It is one of:
// // 1. a list item
// // 2. a simple text line

// const listItemRegex = /^([\s\t]*)([-*])/;
// if (listItemRegex.test(line)) {
// // Let's convert the list item to a checklist item.
// toggledLine = line.replace(listItemRegex, '$1$2 [ ]');
// } else {
// // Let's convert the line to a list item.
// toggledLine = line.replace(/^([\s\t]*)/, '$1- ');
// }
// }
// }

// return toggledLine;
// };

// const toggleTask = ({ task }: { task: Task }): string => {
// // Toggle a regular task.
// const toggledTasks = task.toggle();
// const serialized = toggledTasks
// .map((task: Task) => task.toFileLineString())
// .join('\n');

// return serialized;
// };

// const toggleChecklistItem = ({
// regexMatch,
// }: {
// regexMatch: RegExpMatchArray;
// }): string => {
// // It's a checklist item, let's toggle it.
// const indentation = regexMatch[1];
// const statusString = regexMatch[2].toLowerCase();
// const body = regexMatch[3];

// const toggledStatusString = statusString === ' ' ? 'x' : ' ';

// const toggledLine = `${indentation}- [${toggledStatusString}] ${body}`;

// return toggledLine;
// };
9 changes: 9 additions & 0 deletions src/Commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { App, Editor, Plugin, View } from 'obsidian';

import { createOrEdit } from './CreateOrEdit';
//import { selectStatus } from './SelectStatus';

import { toggleDone } from './ToggleDone';

Expand Down Expand Up @@ -32,5 +34,12 @@ export class Commands {
icon: 'check-in-circle',
editorCheckCallback: toggleDone,
});

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this check be activated if the feature toggle is on?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, the feature toggle is specific to the pop-up menu.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I removed it out to get a clean build and this PR in and will either add to this PR or will be in one to follow and under the flag. I have another draft PR in place using the feature flags based on this PR.

// plugin.addCommand({
// id: 'select-status-modal',
// name: 'Select Status',
// icon: 'check-in-circle',
// editorCheckCallback: selectStatus,
// });
}
}
4 changes: 2 additions & 2 deletions src/Events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ interface CacheUpdateData {
export class Events {
private obsidianEvents: ObsidianEvents;

constructor({ obsidianEents }: { obsidianEents: ObsidianEvents }) {
this.obsidianEvents = obsidianEents;
constructor({ obsidianEvents }: { obsidianEvents: ObsidianEvents }) {
this.obsidianEvents = obsidianEvents;
}

public onCacheUpdate(
Expand Down
69 changes: 69 additions & 0 deletions src/Feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
export type FeatureFlag = {
[internalName: string]: boolean;
};

/**
* @todo documentation
*
* @since {date}
*/
export class Feature {
static readonly TASK_STATUS_MENU = new Feature(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aaaahhh, this is more granular than I first thought... It's very specifically to the status menu.

I had wrongly assumed that the Feature would be 'Enable new task states UI'...
Hence my other comments about enabling the command and the GUI changes and putting them behind the one feature flag.

So that's why a bunch of other code, about settings and commands, is commented out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I added flags very late in the cycle so it is for work post the status change all up, the UI will be covered by it and the append global filter capability will behind it as well which is in a separate PR.

'TASK_STATUS_MENU',
0,
'Enables a right click menu for each task to allow you to select the task Status from the available next transition states.',
'Task Status Menu',
false,
false,
);

static get values(): Feature[] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this be used in practice?

Something simple like if (Feature.enabled(Feature.TASK_STATUS_MENU)) would be ideal...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Later: interaction with features is via the settings class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at #678

return [this.TASK_STATUS_MENU];
}

static get settingsFlags(): FeatureFlag {
const featureFlags: { [internalName: string]: boolean } = {};

Feature.values.forEach((feature) => {
featureFlags[feature.internalName] = feature.enabledByDefault;
});
return featureFlags;
}

/**
* Converts a string to its corresponding default Feature instance.
*
* @param string the string to convert to Feature
* @throws RangeError, if a string that has no corresponding Feature value was passed.
* @returns the matching Feature
*/
static fromString(string: string): Feature {
const value = (this as any)[string];
if (value) {
return value;
}

throw new RangeError(
`Illegal argument passed to fromString(): ${string} does not correspond to any available Feature ${
(this as any).prototype.constructor.name
}`,
);
}

private constructor(
public readonly internalName: string,
public readonly index: number,
public readonly description: string,
public readonly displayName: string,
public readonly enabledByDefault: boolean,
public readonly stable: boolean,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would boolean be used?

Seeing enabledByDefault, I wondered whether the counterpart boolean would be enabled?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enabled would be set by the user, this allows us to flag a feature as out of preview and stable so all features in the platform would be able to use it by default for new users and we can flag for existing users if they have it off in the configuration settings. Look at #678

) {}

/**
* Called when converting the Feature value to a string using JSON.Stringify.
* Compare to the fromString() method, which deserializes the object.
*/
public toJSON() {
return this.internalName;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is internalName?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the string name or key that is used to identify the feature. This makes sense now, seeing how Feature is used in the settings code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is the key, separate to the nicer one that users see.

}
}
47 changes: 44 additions & 3 deletions src/Query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import type { TaskGroups } from './Query/TaskGroups';
import { getSettings } from './Settings';
import { LayoutOptions } from './LayoutOptions';
import { Sort } from './Sort';
import { Priority, Status, Task } from './Task';
import { Status } from './Status';
import { Priority, Task } from './Task';
import { StatusRegistry } from './StatusRegistry';
import type { IQuery } from './IQuery';

import type { Field } from './Query/Filter/Field';
Expand Down Expand Up @@ -65,6 +67,8 @@ export class Query implements IQuery {
private readonly doneString = 'done';
private readonly notDoneString = 'not done';

private readonly statusRegexp = /^status (is not|is) (.*)/;

private readonly pathRegexp = /^path (includes|does not include) (.*)/;
private readonly descriptionRegexp =
/^description (includes|does not include) (.*)/;
Expand Down Expand Up @@ -107,12 +111,12 @@ export class Query implements IQuery {
break;
case line === this.doneString:
this._filters.push(
(task) => task.status === Status.Done,
(task) => task.status === Status.DONE,
);
break;
case line === this.notDoneString:
this._filters.push(
(task) => task.status !== Status.Done,
(task) => task.status !== Status.DONE,
);
break;
case line === this.recurringString:
Expand Down Expand Up @@ -162,6 +166,9 @@ export class Query implements IQuery {
break;
case this.parseFilter(line, new DoneDateField()):
break;
case this.statusRegexp.test(line):
this.parseStatusFilter({ line });
break;
case this.pathRegexp.test(line):
this.parsePathFilter({ line });
break;
Expand Down Expand Up @@ -330,6 +337,40 @@ export class Query implements IQuery {
}
}

/**
* Parses the status query, will fail if the status is not registered.
* Uses the RegEx: '^status (is|is not) (.*)'
*
* @private
* @param {{ line: string }} { line }
* @return {*} {void}
* @memberof Query
*/
private parseStatusFilter({ line }: { line: string }): void {
const statusMatch = line.match(this.statusRegexp);
if (statusMatch !== null) {
const filterStatus = statusMatch[2];

if (
StatusRegistry.getInstance().byIndicator(filterStatus) ===
Status.EMPTY
) {
this._error =
'status you are searching for is not registered in configuration.';
return;
}

let filter;
if (statusMatch[1] === 'is') {
filter = (task: Task) => task.status.indicator === filterStatus;
} else {
filter = (task: Task) => task.status.indicator !== filterStatus;
}

this._filters.push(filter);
}
}

private parsePathFilter({ line }: { line: string }): void {
const pathMatch = line.match(this.pathRegexp);
if (pathMatch !== null) {
Expand Down
2 changes: 1 addition & 1 deletion src/Query/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export class Group {
}

private static groupByStatus(task: Task): string {
return task.status;
return task.status.name;
}

private static groupByHeading(task: Task): string {
Expand Down
Loading