Skip to content

Commit

Permalink
refactor: Improve design of task rendering code & add tests (#1409)
Browse files Browse the repository at this point in the history
* Task rendering refactoring and tests

* More tests, fixes and documentation

* Code review fixes
  • Loading branch information
esm7 committed Dec 28, 2022
1 parent 6476cf1 commit 54682d4
Show file tree
Hide file tree
Showing 9 changed files with 585 additions and 244 deletions.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ You can toggle a task‘s status by:
The code is located as follows:

- For 1.: `./src/Commands/ToggleDone.ts`
- Numbers 2. and 4. use a checkbox created by `Task.toLi()`. There, the checkbox gets a click event handler.
- Numbers 2. and 4. use a checkbox created by `TaskLineRenderer.renderTaskLine`. There, the checkbox gets a click event handler.
- For 3.: `./src/LivePreviewExtension.ts`

Toggle behavior:
Expand Down
2 changes: 1 addition & 1 deletion src/IQuery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LayoutOptions } from './LayoutOptions';
import type { LayoutOptions } from './TaskLayout';
import type { Task } from './Task';
import type { TaskGroups } from './Query/TaskGroups';
import type { Grouping } from './Query/Query';
Expand Down
14 changes: 0 additions & 14 deletions src/LayoutOptions.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/Query/Query.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LayoutOptions } from '../LayoutOptions';
import { LayoutOptions } from '../TaskLayout';
import type { Task } from '../Task';
import type { IQuery } from '../IQuery';
import { getSettings } from '../Config/Settings';
Expand Down
11 changes: 3 additions & 8 deletions src/QueryRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ class QueryRenderChild extends MarkdownRenderChild {
return { taskList, tasksCount };
}

private addEditButton(listItem: HTMLLIElement, task: Task) {
private addEditButton(listItem: HTMLElement, task: Task) {
const editTaskPencil = listItem.createEl('a', {
cls: 'tasks-edit',
});
Expand All @@ -245,7 +245,7 @@ class QueryRenderChild extends MarkdownRenderChild {
});
}

private addUrgency(listItem: HTMLLIElement, task: Task) {
private addUrgency(listItem: HTMLElement, task: Task) {
const text = new Intl.NumberFormat().format(task.urgency);
listItem.createSpan({ text, cls: 'tasks-urgency' });
}
Expand Down Expand Up @@ -285,12 +285,7 @@ class QueryRenderChild extends MarkdownRenderChild {
await MarkdownRenderer.renderMarkdown(group.name, header, this.filePath, this);
}

private addBacklinks(
listItem: HTMLLIElement,
task: Task,
shortMode: boolean,
isFilenameUnique: boolean | undefined,
) {
private addBacklinks(listItem: HTMLElement, task: Task, shortMode: boolean, isFilenameUnique: boolean | undefined) {
const backLink = listItem.createSpan({ cls: 'tasks-backlink' });

if (!shortMode) {
Expand Down
284 changes: 65 additions & 219 deletions src/Task.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Moment } from 'moment';
import { Component, MarkdownRenderer } from 'obsidian';
import { replaceTaskWithTasks } from './File';
import { LayoutOptions } from './LayoutOptions';
import { LayoutOptions, TaskLayout } from './TaskLayout';
import type { TaskLayoutComponent } from './TaskLayout';
import { Recurrence } from './Recurrence';
import { getSettings } from './Config/Settings';
import { Urgency } from './Urgency';
import { DateField } from './Query/Filter/DateField';
import { renderTaskLine } from './TaskLineRenderer';
import type { TaskLineRenderDetails } from './TaskLineRenderer';
import { DateFallback } from './DateFallback';

/**
Expand Down Expand Up @@ -445,159 +446,78 @@ export class Task {
});
}

public async toLi({
parentUlElement,
listIndex,
layoutOptions,
isFilenameUnique,
}: {
parentUlElement: HTMLElement;
/** The nth item in this list (including non-tasks). */
listIndex: number;
layoutOptions?: LayoutOptions;
isFilenameUnique?: boolean;
}): Promise<HTMLLIElement> {
const li: HTMLLIElement = parentUlElement.createEl('li');
li.addClasses(['task-list-item', 'plugin-tasks-list-item']);

let taskAsString = this.toString(layoutOptions);
const { globalFilter, removeGlobalFilter } = getSettings();
if (removeGlobalFilter) {
taskAsString = taskAsString.replace(globalFilter, '').trim();
}

const textSpan = li.createSpan();
textSpan.addClass('tasks-list-text');

await MarkdownRenderer.renderMarkdown(taskAsString, textSpan, this.path, null as unknown as Component);

// If the task is a block quote, the block quote wraps the p-tag that contains the content.
// In that case, we need to unwrap the p-tag *inside* the surrounding block quote.
// Otherwise, we unwrap the p-tag as a direct descendant of the textSpan.
const blockQuote = textSpan.querySelector('blockquote');
const directParentOfPTag = blockQuote ?? textSpan;

// Unwrap the p-tag that was created by the MarkdownRenderer:
const pElement = directParentOfPTag.querySelector('p');
if (pElement !== null) {
while (pElement.firstChild) {
directParentOfPTag.insertBefore(pElement.firstChild, pElement);
}
pElement.remove();
}

// Remove an empty trailing p-tag that the MarkdownRenderer appends when there is a block link:
textSpan.findAll('p').forEach((pElement) => {
if (!pElement.hasChildNodes()) {
pElement.remove();
}
});

// Remove the footnote that the MarkdownRenderer appends when there is a footnote in the task:
textSpan.findAll('.footnotes').forEach((footnoteElement) => {
footnoteElement.remove();
});

const checkbox = li.createEl('input');
checkbox.addClass('task-list-item-checkbox');
checkbox.type = 'checkbox';
if (this.status !== Status.TODO) {
checkbox.checked = true;
li.addClass('is-checked');
}
checkbox.onClickEvent((event: MouseEvent) => {
event.preventDefault();
// It is required to stop propagation so that obsidian won't write the file with the
// checkbox (un)checked. Obsidian would write after us and overwrite our change.
event.stopPropagation();

// Should be re-rendered as enabled after update in file.
checkbox.disabled = true;
const toggledTasks = this.toggle();
replaceTaskWithTasks({
originalTask: this,
newTasks: toggledTasks,
});
});

li.prepend(checkbox);

// Set these to be compatible with stock obsidian lists:
li.setAttr('data-task', this.originalStatusCharacter.trim()); // Trim to ensure empty attribute for space. Same way as obsidian.
li.setAttr('data-line', listIndex);
checkbox.setAttr('data-line', listIndex);

if (layoutOptions?.shortMode) {
this.addTooltip({ element: textSpan, isFilenameUnique });
}

return li;
/**
* Create an HTML rendered List Item element (LI) for the current task.
* @param {renderTails}
*/
public async toLi(renderDetails: TaskLineRenderDetails): Promise<HTMLLIElement> {
return renderTaskLine(this, renderDetails);
}

/**
*
*
* Flatten the task as a string that includes all its components.
* @param {LayoutOptions} [layoutOptions]
* @return {*} {string}
* @memberof Task
*/
public toString(layoutOptions?: LayoutOptions): string {
layoutOptions = layoutOptions ?? new LayoutOptions();
let taskString = this.description;

if (!layoutOptions.hidePriority) {
let priority: string = '';

if (this.priority === Priority.High) {
priority = ' ' + prioritySymbols.High;
} else if (this.priority === Priority.Medium) {
priority = ' ' + prioritySymbols.Medium;
} else if (this.priority === Priority.Low) {
priority = ' ' + prioritySymbols.Low;
}

taskString += priority;
}

if (!layoutOptions.hideRecurrenceRule && this.recurrence) {
const recurrenceRule: string = layoutOptions.shortMode
? ' ' + recurrenceSymbol
: ` ${recurrenceSymbol} ${this.recurrence.toText()}`;
taskString += recurrenceRule;
}

if (!layoutOptions.hideStartDate && this.startDate) {
const startDate: string = layoutOptions.shortMode
? ' ' + startDateSymbol
: ` ${startDateSymbol} ${this.startDate.format(TaskRegularExpressions.dateFormat)}`;
taskString += startDate;
}

if (!layoutOptions.hideScheduledDate && this.scheduledDate && !this.scheduledDateIsInferred) {
const scheduledDate: string = layoutOptions.shortMode
? ' ' + scheduledDateSymbol
: ` ${scheduledDateSymbol} ${this.scheduledDate.format(TaskRegularExpressions.dateFormat)}`;
taskString += scheduledDate;
}

if (!layoutOptions.hideDueDate && this.dueDate) {
const dueDate: string = layoutOptions.shortMode
? ' ' + dueDateSymbol
: ` ${dueDateSymbol} ${this.dueDate.format(TaskRegularExpressions.dateFormat)}`;
taskString += dueDate;
const taskLayout = new TaskLayout(layoutOptions);
let taskString = '';
for (const component of taskLayout.layoutComponents) {
taskString += this.componentToString(taskLayout, component);
}
return taskString;
}

if (!layoutOptions.hideDoneDate && this.doneDate) {
const doneDate: string = layoutOptions.shortMode
? ' ' + doneDateSymbol
: ` ${doneDateSymbol} ${this.doneDate.format(TaskRegularExpressions.dateFormat)}`;
taskString += doneDate;
/**
* Renders a specific TaskLayoutComponent of the task (its description, priority, etc) as a string.
*/
public componentToString(layout: TaskLayout, component: TaskLayoutComponent) {
switch (component) {
case 'description':
return this.description;
case 'priority': {
let priority: string = '';

if (this.priority === Priority.High) {
priority = ' ' + prioritySymbols.High;
} else if (this.priority === Priority.Medium) {
priority = ' ' + prioritySymbols.Medium;
} else if (this.priority === Priority.Low) {
priority = ' ' + prioritySymbols.Low;
}
return priority;
}
case 'startDate':
if (!this.startDate) return '';
return layout.options.shortMode
? ' ' + startDateSymbol
: ` ${startDateSymbol} ${this.startDate.format(TaskRegularExpressions.dateFormat)}`;
case 'scheduledDate':
if (!this.scheduledDate || this.scheduledDateIsInferred) return '';
return layout.options.shortMode
? ' ' + scheduledDateSymbol
: ` ${scheduledDateSymbol} ${this.scheduledDate.format(TaskRegularExpressions.dateFormat)}`;
case 'doneDate':
if (!this.doneDate) return '';
return layout.options.shortMode
? ' ' + doneDateSymbol
: ` ${doneDateSymbol} ${this.doneDate.format(TaskRegularExpressions.dateFormat)}`;
case 'dueDate':
if (!this.dueDate) return '';
return layout.options.shortMode
? ' ' + dueDateSymbol
: ` ${dueDateSymbol} ${this.dueDate.format(TaskRegularExpressions.dateFormat)}`;
case 'recurrenceRule':
if (!this.recurrence) return '';
return layout.options.shortMode
? ' ' + recurrenceSymbol
: ` ${recurrenceSymbol} ${this.recurrence.toText()}`;
case 'blockLink':
return this.blockLink ?? '';
default:
throw new Error(`Don't know how to render task component of type '${component}'`);
}

const blockLink: string = this.blockLink ?? '';
taskString += blockLink;

return taskString;
}

/**
Expand Down Expand Up @@ -809,80 +729,6 @@ export class Task {
return true;
}

private addTooltip({
element,
isFilenameUnique,
}: {
element: HTMLElement;
isFilenameUnique: boolean | undefined;
}): void {
element.addEventListener('mouseenter', () => {
const tooltip = element.createDiv();
tooltip.addClasses(['tooltip', 'mod-right']);

if (this.recurrence) {
const recurrenceDiv = tooltip.createDiv();
recurrenceDiv.setText(`${recurrenceSymbol} ${this.recurrence.toText()}`);
}

if (this.startDate) {
const startDateDiv = tooltip.createDiv();
startDateDiv.setText(
Task.toTooltipDate({
signifier: startDateSymbol,
date: this.startDate,
}),
);
}

if (this.scheduledDate) {
const scheduledDateDiv = tooltip.createDiv();
scheduledDateDiv.setText(
Task.toTooltipDate({
signifier: scheduledDateSymbol,
date: this.scheduledDate,
}),
);
}

if (this.dueDate) {
const dueDateDiv = tooltip.createDiv();
dueDateDiv.setText(
Task.toTooltipDate({
signifier: dueDateSymbol,
date: this.dueDate,
}),
);
}

if (this.doneDate) {
const doneDateDiv = tooltip.createDiv();
doneDateDiv.setText(
Task.toTooltipDate({
signifier: doneDateSymbol,
date: this.doneDate,
}),
);
}

const linkText = this.getLinkText({ isFilenameUnique });
if (linkText) {
const backlinkDiv = tooltip.createDiv();
backlinkDiv.setText(`🔗 ${linkText}`);
}

element.addEventListener('mouseleave', () => {
tooltip.remove();
});
});
}

private static toTooltipDate({ signifier, date }: { signifier: string; date: Moment }): string {
return `${signifier} ${date.format(TaskRegularExpressions.dateFormat)} (${date.from(
window.moment().startOf('day'),
)})`;
}

/**
* Escape a string so it can be used as part of a RegExp literally.
* Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
Expand Down

0 comments on commit 54682d4

Please sign in to comment.