diff --git a/docs/advanced/styling.md b/docs/advanced/styling.md index d77006a704..f708f1ef2c 100644 --- a/docs/advanced/styling.md +++ b/docs/advanced/styling.md @@ -7,21 +7,398 @@ has_toc: false --- # Styling Tasks +{: .no_toc } -Each task entry has CSS styles that allow you to change the look and feel of how the tasks are displayed. The -following styles are available. +
+ + Table of contents + + {: .text-delta } +1. TOC +{:toc} +
+ +--- + +## Introduction + +In rendered queries and Reading View, the Tasks plugin adds detailed CSS classes and data attributes that represent many of each task's content, to allow for very extensive styling options via CSS. +Not only each component in a rendered task line is tagged with classes to differentiate it, many components also add classes and data attributes that represent the actual content of the task, so CSS rules can refer to data such as the relative due date of a task or its specific priority. + +## Basic Task Structure + +{: .released } +The following description relates to a restructuring of the rendered tasks that was introduced in Tasks X.Y.Z. + +The Tasks plugin renders a task in the following structure (this refers to query results, but the Reading View is the same except the top-most containers): + +```markdown +- Obsidian code block (div class="block-language-tasks") + - Results list (ul class="plugin-tasks-query-result") OR Reading View list (ul class="contains-task-list") + - Task (li class="task-list-item" + attributes like data-task-priority="medium" data-task-due="past-1d" + data-task="[custom_status]" + data-line="[line]") + - Task checkbox (li class="task-list-item-checkbox") + - Task content (span class="tasks-list-text") + - Task description and tags (span class="task-description") + - Internal span + - Each tag in the description is wrapped in + - Task priority (span class="task-priority" + data-task-priority attribute) + - Internal span + - Task recurrence rule (span class="task-recurring") + - Internal span + - Task created date (span class="task-created" + data-task-created attribute) + - Internal span + - ... start date, scheduled date, due date and done date in this order + - Task extras (link, edit button) (span class="task-extras") + - Tasks count (div class="tasks-count") +``` + +As can be seen above, the basic task `li` contains a checkbox and a content span. +The content span contains a list of **component** spans: description, priority, recurrence, created date, start date, scheduled date, due date and done date in this order. + +Each component span is marked with a **generic class**, which denotes the type of the component, and in some cases a **data attribute** that represents the component's content itself. + +Within each component span there is an additional "internal" span, which is the one holding the actual component text. +The reason for this additional internal span is that it allows CSS styles that closely wrap the text itself, rather than its container box, e.g. for the purpose of drawing a highlight or a box that is exactly in the size of the text. + +## Generic Classes and Data Attributes + +{: .released } +Data attributes were introduced in Tasks X.Y.Z. + +Each rendered task component (description, priority, recurrence rule etc) includes a **generic class** that denotes this type of component. +The generic classes are: + +- `task-description` +- `task-priority` +- `task-due` +- `task-created` +- `task-start` +- `task-scheduled` +- `task-done` +- `task-recurring` + +In addition to the generic classes, there are [**data attributes**](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes) that represent the content of the various task components. + +A **priority data attributes** named `data-task-priority` represents the specific priority of a class. It can be `high`, `medium`, `low` or `normal`. +The `normal` value is special: it is added as a default to a task's upper `task-list-item` even if there is no priority field set for that task. + +A **date attribute** represents a due, created, start, scheduled or done date in a format relative to the current date. +The date attributes are `data-task-due`, `data-task-created`, `data-task-start`, `data-task-scheduled` and `data-task-done` and are populated with a relative expression that denotes the number of days this field represents compared to today: + +- `data-task-due="today"` (or `data-task-start="today"`, `data-task-start="today"` etc) represents today. +- `data-task-due="future-1d"` (or `data-task-start="future-1d"`) represents 1 day in the future, i.e. tomorrow. +- `data-task-due="past-1d"` (or `data-task-start="past-1d"`) represents 1 day in the past, i.e. yesterday. +- These attributes are added up to 7 days in the future or past, e.g. `data-task-scheduled="future-7d"` or `data-task-due="past-7d"`. +- Dates that are further than 7 days in the future or past are given a `far` postfix, e.g. `data-task-scheduled="future-far"` or `data-task-due="past-far"`. + +A **tag data attribute** repeats each tag's content as a data attribute, for the purpose of applying formatting according to specific tags. +The tag `` elements are added a `data-tag-name` attribute with a *sanitized* version of the tag name, which basically means that characters that are illegal to use in HTML attributes (e.g. `&`, `"`) are replaced with dashes. + +Data attributes are added to both their corresponding components (e.g. to the due date component) and also to the complete task `li`, to make it easy for a CSS rule to style a complete task according to some property (e.g. color differently the complete task if it's due today, color a task according to a tag) or just one relevant component. + +An exception is the tag data attribute which is added only to the tag's `` element within the rendered description -- however you can still use a CSS `:has` selector to format an entire task's description according to a tag, as demonstrated in the examples below. + +{: .warning } +The CSS `:has` selector is available with Obsidian installer version 1.1.9 and newer. You can run the Obsidian command `Show debug info` to see your current installer version. + +**Tip:** [CSS wildcard selectors](https://www.geeksforgeeks.org/wildcard-selectors-and-in-css-for-classes/) are a good way to select all past dates or future dates at once -- just use `.task-due[data-task-due^="past-"]` to address all overdue tasks, for example. Examples that utilize this can be found below. + +## Hidden Components, Groups & Short Mode + +**Hidden components**, e.g. a `hide priority` line in a query, will generate the following: + +- The query container (`class="plugin-tasks-query-result"`) will include a `tasks-layout-hide...` class, e.g. `tasks-layout-hide-priority`. +- Although the priority will not be rendered in the query, the upper task element (`li class="task-list-item"`) will still be added the attribute of hidden components, e.g. `data-task-priority="high"`. + +**Short mode** will add a `tasks-layout-short-mode` class to the query container. + +**Grouping rules** will add a `data-task-group-by` attribute to the query container, e.g. `data-task-group-by="due,scheduled"`. + +## More Classes + +The following additional components have the following classes: | Class | Usage | | ------------------------------ | --------------------------------------------------------------------------------------------------------------- | -| plugin-tasks-query-result | This is applied to the UL used to hold all the tasks, each task is stored in a LI. | | plugin-tasks-query-explanation | This is applied to the PRE showing the query's explanation when the `explain` instruction is used. | -| plugin-tasks-list-item | This is applied to the LI that holds each task and the INPUT element for it. | | tasks-backlink | This is applied to the SPAN that wraps the backlink if displayed on the task. | | tasks-edit | This is applied to the SPAN that wraps the edit button/icon shown next to the task that opens the task edit UI. | | tasks-urgency | This is applied to the SPAN that wraps the urgency score if displayed on the task. | -| task-list-item-checkbox | This is applied to the INPUT element for the task. | | tasks-group-heading | This is applied to H4, H5 and H6 group headings | {: .released } `tasks-group-heading` was introduced in Tasks 1.6.0.
`plugin-tasks-query-explanation` was introduced in Tasks 1.19.0. + +## Examples + +The following examples can be used as [Obsidian CSS snippets](https://help.obsidian.md/How+to/Add+custom+styles#Use+Themes+and+or+CSS+snippets). + +**Tip:** the following examples use CSS variables (`--var(...)`) provided by Obsidian instead of concrete color codes to maximize the chance that the result will be in-line with your chosen theme. You may of course use specific colors if so you choose. + +### General Formatting + +Making tags, internal links and the recurrence rules of tasks to appear in gray: + +```css +.tasks-list-text a.tag { + color: var(--list-marker-color); +} +.tasks-backlink a.internal-link { + color: var(--list-marker-color); +} +.task-recurring { + color: var(--list-marker-color); +} +``` + +### Priority as a Checkbox Color + +The following rules remove the Tasks priority emoticon and render the tasks' checkboxes in red, orange, blue and cyan according to the tasks' priority: + +```css +.task-list-item[data-task-priority="high"] input[type=checkbox] { + box-shadow: 0px 0px 2px 2px var(--color-red); + border-color: var(--color-red); +} +.task-list-item[data-task-priority="medium"] input[type=checkbox] { + box-shadow: 0px 0px 2px 2px var(--color-orange); + border-color: var(--color-orange); +} +.task-list-item[data-task-priority="normal"] input[type=checkbox] { + box-shadow: 0px 0px 2px 2px var(--color-blue); + border-color: var(--color-blue); +} +.task-list-item[data-task-priority="low"] input[type=checkbox] { + box-shadow: 0px 0px 2px 2px var(--color-cyan); + border-color: var(--color-cyan); +} +/* This part removes the regular priority emoticon */ +span.task-priority { + display: none; +} +``` + +### Styling Tasks with Custom Statuses + +To create a green halo around the checkbox of tasks with a `/` custom status, add the following CSS snippet: + +```css +li.task-list-item[data-task="/"] .task-list-item-checkbox { + box-shadow: 0 0 10px green; +} +``` + +### Colors for Due Today and Overdue + +The following rules mark 'today' due dates as blue and past due dates as red: + +```css +/* A special color for the 'due' component if it's for today */ +.task-due[data-task-due="today"] span { + background: var(--code-property); + border-radius: 10px; + padding: 2px 8px; +} +/* A special color for overdue due dates */ +.task-due[data-task-due^="past-"] span { + background: var(--color-pink); + border-radius: 10px; + padding: 2px 8px; +} +``` + +### Highlight for a Specific Tag + +The following rule adds a green glow around `#task/atHome` tags inside the description: + +```css +a.tag[data-tag-name="#task/atHome"] { + box-shadow: 0 0 5px green; +} +``` + +The following rule adds a rounded red background to the description of a task if it contains the tag `#task/strategic`: + +```css +.task-description span:has(.tag[data-tag-name="#task/strategic"]) { + background: #ffbfcc; + border-radius: 10px; + padding: 2px 8px; +} +``` + +### Circle Checkboxes + +The following renders checkboxes as circles instead of squares: + +```css +ul > li.plugin-tasks-list-item .task-list-item-checkbox { + margin-inline-start: 0; + margin: 5px 2px; + border-radius: 50%; +} +``` + +### Grid Layout + +The following organizes the task structure into a 3-line grid, on which: + +- the description is in the first line, +- and the various components are on the second, +- the urgency, backlink and edit button are, if displayed, on the third. + +```css +ul > li.plugin-tasks-list-item { + grid-template-columns: 25px auto; + display: grid; + align-items: top; +} +span.task-description { + grid-row: 1; + grid-column: 1/10; +} +span.tasks-backlink { + grid-row: 2; + grid-column: 2; + font-size: small; +} +span.task-recurring { + grid-row: 2; + font-size: small; + width: max-content; +} +span.task-due { + grid-row: 2; + font-size: small; + width: max-content; +} +span.task-done { + grid-row: 2; + font-size: small; + width: max-content; +} +.tasks-list-text { + position: relative; + display: inline-grid; + width: max-content; + grid-column-gap: 10px; +} +span.task-extras { + grid-row: 2; + grid-column: 2; + font-size: small; +} +``` + +### Complete Example + +The following can be used as a base for a full CSS snippet: + +```css +/* I like tags to appear in gray so they won't grab too much attention */ +.tasks-list-text a.tag { + color: var(--list-marker-color); +} + +/* Set internal links to gray too instead of Obsidian's default */ +.tasks-backlink a.internal-link { + color: var(--list-marker-color); +} + +/* Paint the recurrence rule in gray so it will be less distracting */ +.task-recurring { + color: var(--list-marker-color); +} + +/* List indentation values that seem to work well for me */ +ul.contains-task-list.plugin-tasks-query-result { + padding: 0 10px; +} + +/* This seems to be needed for the task description to word-wrap correctly if they're too long */ +span.tasks-list-text { + width: auto; +} + +/* Represent tasks' priority with colorful round checkboxes instead of the priority emoticons */ +.task-list-item[data-task-priority="high"] input[type=checkbox] { + box-shadow: 0px 0px 2px 2px var(--color-red); + border-color: var(--color-red); +} +.task-list-item[data-task-priority="low"] input[type=checkbox] { + box-shadow: 0px 0px 2px 2px var(--color-blue); + border-color: var(--color-blue); +} +.task-list-item[data-task-priority="medium"] input[type=checkbox] { + box-shadow: 0px 0px 2px 2px var(--color-orange); + border-color: var(--color-orange); +} +/* This part removes the regular priority emoticon */ +span.task-priority { + display: none; +} + +/* A special color for the 'due' component if it's for today */ +.task-due[data-task-due="today"] span { + background: var(--code-property); + border-radius: 10px; + padding: 2px 8px; +} +/* A special color for overdue due dates */ +.task-due[data-task-due^="past-"] span { + background: var(--color-pink); + border-radius: 10px; + padding: 2px 8px; +} + +/* Make checkboxes a circle instead of a square */ +ul > li.plugin-tasks-list-item .task-list-item-checkbox { + margin-inline-start: 0; + margin: 5px 2px; + border-radius: 50%; +} + +/* The following section organizes the task components in a grid, so the description will be on the first row + * of each item and most components will be in the 2nd row. */ +ul > li.plugin-tasks-list-item { + grid-template-columns: 25px auto; + display: grid; + align-items: top; +} +span.task-description { + grid-row: 1; + grid-column: 1/10; +} +span.tasks-backlink { + grid-row: 2; + grid-column: 2; + font-size: small; +} +span.task-recurring { + grid-row: 2; + font-size: small; + width: max-content; +} +span.task-due { + grid-row: 2; + font-size: small; + width: max-content; +} +span.task-done { + grid-row: 2; + font-size: small; + width: max-content; +} +.tasks-list-text { + position: relative; + display: inline-grid; + width: max-content; + grid-column-gap: 10px; +} +span.task-extras { + grid-row: 2; + grid-column: 2; + font-size: small; +} +``` diff --git a/resources/sample_vaults/Tasks-Demo/.obsidian/snippets/tasks-plugin-smoke-test-query-styling.css b/resources/sample_vaults/Tasks-Demo/.obsidian/snippets/tasks-plugin-smoke-test-query-styling.css new file mode 100644 index 0000000000..79da183235 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/.obsidian/snippets/tasks-plugin-smoke-test-query-styling.css @@ -0,0 +1,13 @@ +/* This file is only for use in smoke-testing the Tasks plugin, and not intended for use by users. */ + +.block-language-tasks:has(ul.plugin-tasks-query-result[data-task-group-by="priority"]) .tasks-group-heading { + color: red; +} + +.block-language-tasks .tasks-layout-short-mode { + background-color: aqua; +} + +ul.contains-task-list.tasks-layout-hide-priority { + color: red; +} diff --git a/resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Testing the Tasks Plugin.md b/resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Testing the Tasks Plugin.md index c93d328703..d634e988da 100644 --- a/resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Testing the Tasks Plugin.md +++ b/resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Testing the Tasks Plugin.md @@ -103,6 +103,12 @@ heading includes Rendering of Task Blocks --- +### Styling of Rendered Task Blocks + +- [ ] #task **check**: Open the file [[Styling of Queries]] and follow the steps there + +--- + ### Create or edit Task modal - This text should copied in to the task Description, after following steps below diff --git a/resources/sample_vaults/Tasks-Demo/Manual Testing/Styling of Queries.md b/resources/sample_vaults/Tasks-Demo/Manual Testing/Styling of Queries.md new file mode 100644 index 0000000000..028a5450b1 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Manual Testing/Styling of Queries.md @@ -0,0 +1,46 @@ +# Styling of Queries + +> [!Warning] +> These tests require the `.has` selector. +> The CSS `:has` selector is available with **Obsidian installer version 1.1.9 and newer**. You can run the Obsidian command `Show debug info` to see your current installer version. + +--- + +To test styling of queries, follow these steps, viewing this file either in **Reading** Mode or **Live Preview** Mode: + +- [ ] **1. Open the Obsidian settings of the Demo vault and under Appearance | CSS Snippets, turn on `tasks-plugin-smoke-test-query-styling`.** + +- [ ] **2.** **Test 'group by' classes** - the following query result should have **red headings** named 'Priority 1: High' and 'Priority 4: Low'. + +```tasks +path includes Styling of Queries +group by priority +``` + +- [ ] **3. Test 'group by' classes #2** - the following should have a **black** heading named 'No due date': + +```tasks +path includes Styling of Queries +group by due +``` + +- [ ] **4. Test short mode classes** - the following should have an aqua background: + +```tasks +path includes Styling of Queries +short mode +``` + +- [ ] **5. Test 'hidden' query classes** - the following lines (except the backlinks) should be colored **red**: + +```tasks +path includes Styling of Queries +hide priority +``` + +- [ ] 6. Open the Obsidian settings of the Demo vault and under Appearance | CSS Snippets, turn **off** `tasks-plugin-smoke-test-query-styling`. + +## Tasks for Reference + +- [ ] #task Task with high priority ⏫ +- [ ] #task Task with low priority 🔽 diff --git a/src/InlineRenderer.ts b/src/InlineRenderer.ts index 977f194100..8ea225acd8 100644 --- a/src/InlineRenderer.ts +++ b/src/InlineRenderer.ts @@ -11,6 +11,10 @@ export class InlineRenderer { public markdownPostProcessor = this._markdownPostProcessor.bind(this); + /** + * This renders a file's task list when rendered in Reading View, using roughly the same pipeline + * of QueryRenderer (e.g. it removes the global filter and handles other formatting). + */ private async _markdownPostProcessor(element: HTMLElement, context: MarkdownPostProcessorContext): Promise { const { globalFilter } = getSettings(); const renderedElements = element.findAll('.task-list-item').filter((taskItem) => { diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts index 7aca89a299..365d709b6b 100644 --- a/src/QueryRenderer.ts +++ b/src/QueryRenderer.ts @@ -10,6 +10,7 @@ import { TaskModal } from './TaskModal'; import type { TasksEvents } from './TasksEvents'; import type { Task } from './Task'; import { DateFallback } from './DateFallback'; +import { TaskLayout } from './TaskLayout'; export class QueryRenderer { private readonly app: App; @@ -188,8 +189,12 @@ class QueryRenderChild extends MarkdownRenderChild { }): Promise<{ taskList: HTMLUListElement; tasksCount: number }> { const tasksCount = tasks.length; + const layout = new TaskLayout(this.query.layoutOptions); const taskList = content.createEl('ul'); taskList.addClasses(['contains-task-list', 'plugin-tasks-query-result']); + taskList.addClasses(layout.specificClasses); + const groupingAttribute = this.getGroupingAttribute(); + if (groupingAttribute && groupingAttribute.length > 0) taskList.dataset.taskGroupBy = groupingAttribute; for (let i = 0; i < tasksCount; i++) { const task = tasks[i]; const isFilenameUnique = this.isFilenameUnique({ task }); @@ -199,6 +204,7 @@ class QueryRenderChild extends MarkdownRenderChild { listIndex: i, layoutOptions: this.query.layoutOptions, isFilenameUnique, + taskLayout: layout, }); // Remove all footnotes. They don't re-appear in another document. @@ -207,16 +213,18 @@ class QueryRenderChild extends MarkdownRenderChild { const shortMode = this.query.layoutOptions.shortMode; + const extrasSpan = listItem.createSpan('task-extras'); + if (!this.query.layoutOptions.hideUrgency) { - this.addUrgency(listItem, task); + this.addUrgency(extrasSpan, task); } if (!this.query.layoutOptions.hideBacklinks) { - this.addBacklinks(listItem, task, shortMode, isFilenameUnique); + this.addBacklinks(extrasSpan, task, shortMode, isFilenameUnique); } if (!this.query.layoutOptions.hideEditButton) { - this.addEditButton(listItem, task); + this.addEditButton(extrasSpan, task); } taskList.appendChild(listItem); @@ -353,4 +361,12 @@ class QueryRenderChild extends MarkdownRenderChild { return allFilesWithSameName.length < 2; } + + private getGroupingAttribute() { + const groupingRules: string[] = []; + for (const group of this.query.grouping) { + groupingRules.push(group.property); + } + return groupingRules.join(','); + } } diff --git a/src/TaskLayout.ts b/src/TaskLayout.ts index 8d7cb75e16..078261c98d 100644 --- a/src/TaskLayout.ts +++ b/src/TaskLayout.ts @@ -46,7 +46,9 @@ export class TaskLayout { 'blockLink', ]; public layoutComponents: TaskLayoutComponent[]; + public hiddenComponents: TaskLayoutComponent[] = []; public options: LayoutOptions; + public specificClasses: string[] = []; constructor(options?: LayoutOptions, components?: TaskLayoutComponent[]) { if (options) { @@ -59,7 +61,6 @@ export class TaskLayout { } else { this.layoutComponents = this.defaultLayout; } - this.layoutComponents = this.applyOptions(this.options); } @@ -67,13 +68,17 @@ export class TaskLayout { * Return a new list of components with the given options applied. */ applyOptions(layoutOptions: LayoutOptions): TaskLayoutComponent[] { - // Remove a component from the taskComponents array if the given layoutOption criteria is met + // Remove a component from the taskComponents array if the given layoutOption criteria is met, + // and add to the layout's specific classes list the class that denotes that this component + // isn't in the layout const removeIf = ( taskComponents: TaskLayoutComponent[], shouldRemove: boolean, componentToRemove: TaskLayoutComponent, ) => { if (shouldRemove) { + this.specificClasses.push(`tasks-layout-hide-${componentToRemove}`); + this.hiddenComponents.push(componentToRemove); return taskComponents.filter((element) => element != componentToRemove); } else { return taskComponents; @@ -89,6 +94,7 @@ export class TaskLayout { newComponents = removeIf(newComponents, layoutOptions.hideScheduledDate, 'scheduledDate'); newComponents = removeIf(newComponents, layoutOptions.hideDueDate, 'dueDate'); newComponents = removeIf(newComponents, layoutOptions.hideDoneDate, 'doneDate'); + if (layoutOptions.shortMode) this.specificClasses.push('tasks-layout-short-mode'); return newComponents; } } diff --git a/src/TaskLineRenderer.ts b/src/TaskLineRenderer.ts index 9a23620886..9d59f075f7 100644 --- a/src/TaskLineRenderer.ts +++ b/src/TaskLineRenderer.ts @@ -13,8 +13,24 @@ export type TaskLineRenderDetails = { listIndex: number; layoutOptions?: LayoutOptions; isFilenameUnique?: boolean; + taskLayout?: TaskLayout; }; +export const LayoutClasses: { [c in TaskLayoutComponent]: string } = { + description: 'task-description', + priority: 'task-priority', + dueDate: 'task-due', + startDate: 'task-start', + createdDate: 'task-created', + scheduledDate: 'task-scheduled', + doneDate: 'task-done', + recurrenceRule: 'task-recurring', + blockLink: '', +}; + +const MAX_DAY_VALUE_RANGE = 7; +const DAY_VALUE_OVER_RANGE_POSTFIX = 'far'; + /** * The function used to render a Markdown task line into an existing HTML element. */ @@ -47,11 +63,11 @@ export async function renderTaskLine( // when running tests, and we want the tests to be able to create the full div and span structure, // so had to convert all of these to the equivalent but more elaborate document.createElement() and // appendChild() calls. - const textSpan = document.createElement('span'); li.appendChild(textSpan); textSpan.classList.add('tasks-list-text'); - await taskToHtml(task, renderDetails, textSpan, textRenderer); + const attributes = await taskToHtml(task, renderDetails, textSpan, textRenderer); + for (const key in attributes) li.dataset[key] = attributes[key]; // NOTE: this area is mentioned in `CONTRIBUTING.md` under "How does Tasks handle status changes". When // moving the code, remember to update that reference too. @@ -98,27 +114,59 @@ async function taskToHtml( renderDetails: TaskLineRenderDetails, parentElement: HTMLElement, textRenderer: TextRenderer, -) { - let taskAsString = ''; +): Promise { + let allAttributes: AttributesDictionary = {}; const taskLayout = new TaskLayout(renderDetails.layoutOptions); const emojiSerializer = TASK_FORMATS.tasksPluginEmoji.taskSerializer; + // Render and build classes for all the task's visible components for (const component of taskLayout.layoutComponents) { let componentString = emojiSerializer.componentToString(task, taskLayout, component); if (componentString) { if (component === 'description') componentString = removeGlobalFilterIfNeeded(componentString); - taskAsString += componentString; + // Create the text span that will hold the rendered component + const span = document.createElement('span'); + parentElement.appendChild(span); + if (span) { + // Inside that text span, we are creating another internal span, that will hold the text itself. + // This may seem redundant, and by default it indeed does nothing, but we do it to allow the CSS + // to differentiate between the container of the text and the text itself, so it will be possible + // to do things like surrouding only the text (rather than its whole placeholder) with a highlight + const internalSpan = document.createElement('span'); + span.appendChild(internalSpan); + await renderComponentText(internalSpan, componentString, component, task, textRenderer); + const [genericClasses, dataAttributes] = getComponentClassesAndData(component, task); + addInternalClasses(component, internalSpan); + // Add the generic classes that apply to what this component is (priority, due date etc) + span.classList.add(...genericClasses); + // Add the attributes to the component ('priority-medium', 'due-past-1d' etc) + for (const key in dataAttributes) span.dataset[key] = dataAttributes[key]; + allAttributes = { ...allAttributes, ...dataAttributes }; + } } } - const { debugSettings } = getSettings(); - if (debugSettings.showTaskHiddenData) { - // Add some debug output to enable hidden information in the task to be inspected. - taskAsString += `
🐛 ${task.lineNumber} . ${task.sectionStart} . ${task.sectionIndex} . '${task.originalMarkdown}'
'${task.path}' > '${task.precedingHeader}'
`; + // Now build classes for the hidden task components without rendering them + for (const component of taskLayout.hiddenComponents) { + const [_, dataAttributes] = getComponentClassesAndData(component, task); + allAttributes = { ...allAttributes, ...dataAttributes }; } - await renderComponentText(parentElement, taskAsString, 'description', task, textRenderer); + // If a task has no priority field set, its priority will not be rendered as part of the loop above and + // it will not be set a priority data attribute. + // In such a case we want the upper task LI element to mark the task has a 'normal' priority. + // So if the priority was not rendered, force it through the pipe of getting the component data for the + // priority field. + if (allAttributes.taskPriority === undefined) { + const [_, dataAttributes] = getComponentClassesAndData('priority', task); + allAttributes = { ...allAttributes, ...dataAttributes }; + } + + return allAttributes; } +/* + * Renders the given component into the given HTML span element. + */ async function renderComponentText( span: HTMLSpanElement, componentString: string, @@ -127,6 +175,11 @@ async function renderComponentText( textRenderer: TextRenderer, ) { if (component === 'description') { + const { debugSettings } = getSettings(); + if (debugSettings.showTaskHiddenData) { + // Add some debug output to enable hidden information in the task to be inspected. + componentString += `
🐛 ${task.lineNumber} . ${task.sectionStart} . ${task.sectionIndex} . '${task.originalMarkdown}'
'${task.path}' > '${task.precedingHeader}'
`; + } await textRenderer(componentString, span, task.path); // If the task is a block quote, the block quote wraps the p-tag that contains the content. @@ -160,6 +213,141 @@ async function renderComponentText( } } +export type AttributesDictionary = { [key: string]: string }; + +/** + * This function returns two lists -- genericClasses and dataAttributes -- that describe the + * given component. + * The genericClasses describe what the component is, e.g. a due date or a priority, and are one of the + * options in LayoutClasses. + * The dataAttributes describe the content of the component, e.g. `data-task-priority="medium"`, `data-task-due="past-1d"` etc. + */ +function getComponentClassesAndData(component: TaskLayoutComponent, task: Task): [string[], AttributesDictionary] { + const genericClasses: string[] = []; + const dataAttributes: AttributesDictionary = {}; + const setDateAttribute = (date: Moment, attributeName: string) => { + const dateValue = dateToAttribute(date); + if (dateValue) dataAttributes[attributeName] = dateValue; + }; + switch (component) { + case 'description': + genericClasses.push(LayoutClasses.description); + break; + case 'priority': { + let priorityValue = null; + if (task.priority === taskModule.Priority.High) priorityValue = 'high'; + else if (task.priority === taskModule.Priority.Medium) priorityValue = 'medium'; + else if (task.priority === taskModule.Priority.Low) priorityValue = 'low'; + else priorityValue = 'normal'; + dataAttributes['taskPriority'] = priorityValue; + genericClasses.push(LayoutClasses.priority); + break; + } + case 'createdDate': { + const date = task.createdDate; + if (date) { + genericClasses.push(LayoutClasses.createdDate); + setDateAttribute(date, 'taskCreated'); + } + break; + } + case 'dueDate': { + const date = task.dueDate; + if (date) { + genericClasses.push(LayoutClasses.dueDate); + setDateAttribute(date, 'taskDue'); + } + break; + } + case 'startDate': { + const date = task.startDate; + if (date) { + genericClasses.push(LayoutClasses.startDate); + setDateAttribute(date, 'taskStart'); + } + break; + } + case 'scheduledDate': { + const date = task.scheduledDate; + if (date) { + genericClasses.push(LayoutClasses.scheduledDate); + setDateAttribute(date, 'taskScheduled'); + } + break; + } + case 'doneDate': { + const date = task.doneDate; + if (date) { + genericClasses.push(LayoutClasses.doneDate); + setDateAttribute(date, 'taskDone'); + } + break; + } + case 'recurrenceRule': { + genericClasses.push(LayoutClasses.recurrenceRule); + break; + } + } + return [genericClasses, dataAttributes]; +} + +/* + * Adds internal classes for various components (right now just tags actually), meaning that we modify the existing + * rendered element to add classes inside it. + * In the case of tags, Obsidian renders a Markdown description with
elements for tags. We want to + * enable users to style these, so we modify the rendered Markdown by adding the specific tag classes for these + * elements. + */ +function addInternalClasses(component: TaskLayoutComponent, renderedComponent: HTMLSpanElement) { + if (component === 'description') { + const tags = renderedComponent.getElementsByClassName('tag'); + for (let i = 0; i < tags.length; i++) { + const tagName = tags[i].textContent; + if (tagName) { + const className = tagToAttributeValue(tagName); + const element = tags[i] as HTMLElement; + if (className) element.dataset.tagName = className; + } + } + } +} + +/** + * Translate a relative date to a CSS class: 'today', 'future-1d' (for tomorrow), 'past-1d' (for yesterday) + * etc. + * A cutoff (in days) is defined in MAX_DAY_VALUE_RANGE, from beyond that a generic 'far' postfix will be added. + * (the cutoff exists because we don't want to flood the DOM with potentially hundreds of unique classes.) + */ +function dateToAttribute(date: Moment) { + const today = window.moment().startOf('day'); + let result = ''; + const diffDays = today.diff(date, 'days'); + if (isNaN(diffDays)) return null; + if (diffDays === 0) return 'today'; + else if (diffDays > 0) result += 'past-'; + else if (diffDays < 0) result += 'future-'; + if (Math.abs(diffDays) <= MAX_DAY_VALUE_RANGE) { + result += Math.abs(diffDays).toString() + 'd'; + } else { + result += DAY_VALUE_OVER_RANGE_POSTFIX; + } + return result; +} + +/* + * Sanitize tag names so they will be valid attribute values according to the HTML spec: + * https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(double-quoted)-state + */ +function tagToAttributeValue(tag: string) { + // eslint-disable-next-line no-control-regex + const illegalChars = /["&\x00\r\n]/g; + let sanitizedTag = tag.replace(illegalChars, '-'); + // And if after sanitazation the name starts with dashes or underscores, remove them. + sanitizedTag = sanitizedTag.replace(/^[-_]+/, ''); + if (sanitizedTag.length > 0) return sanitizedTag; + else return null; +} + function addTooltip({ task, element, diff --git a/tests/Task.test.ts b/tests/Task.test.ts index d65e1db9f0..5a22aa47f0 100644 --- a/tests/Task.test.ts +++ b/tests/Task.test.ts @@ -464,6 +464,22 @@ describe('to string', () => { const expectedLine = '- [x] this is a done task #tagone #journal/daily 📅 2021-09-12 ✅ 2021-06-20'; expect(task.toFileLineString()).toStrictEqual(expectedLine); }); + + it('retains the global filter', () => { + // Arrange + const line = '- [ ] This is a task with #t as a global filter and also #t/some tags'; + + updateSettings({ globalFilter: '#t' }); + // Act + const task: Task = fromLine({ + line, + }) as Task; + + // Assert + const expectedLine = 'This is a task with #t as a global filter and also #t/some tags'; + expect(task.toString()).toStrictEqual(expectedLine); + resetSettings(); + }); }); describe('toggle done', () => { diff --git a/tests/TaskLineRenderer.test.ts b/tests/TaskLineRenderer.test.ts index 550e0aebfd..92b182ebd4 100644 --- a/tests/TaskLineRenderer.test.ts +++ b/tests/TaskLineRenderer.test.ts @@ -2,11 +2,14 @@ * @jest-environment jsdom */ import moment from 'moment'; +import { LayoutClasses, renderTaskLine } from '../src/TaskLineRenderer'; +import type { AttributesDictionary, TextRenderer } from '../src/TaskLineRenderer'; import { DebugSettings } from '../src/Config/DebugSettings'; -import { renderTaskLine } from '../src/TaskLineRenderer'; import { resetSettings, updateSettings } from '../src/Config/Settings'; import { LayoutOptions } from '../src/TaskLayout'; import type { Task } from '../src/Task'; +import { TaskRegularExpressions } from '../src/Task'; +import { DateParser } from '../src/Query/DateParser'; import { fromLine } from './TestHelpers'; jest.mock('obsidian'); @@ -16,11 +19,13 @@ window.moment = moment; * Creates a dummy 'parent element' to host a task render, renders a task inside it, * and returns it for inspection. */ -async function createMockParentAndRender(task: Task, layoutOptions?: LayoutOptions) { +async function createMockParentAndRender(task: Task, layoutOptions?: LayoutOptions, mockTextRenderer?: TextRenderer) { const parentElement = document.createElement('div'); - const mockTextRenderer = async (text: string, element: HTMLSpanElement, _path: string) => { - element.innerText = text; - }; + // Our default text renderer for this method is a simplistic flat text + if (!mockTextRenderer) + mockTextRenderer = async (text: string, element: HTMLSpanElement, _path: string) => { + element.innerText = text; + }; await renderTaskLine( task, { @@ -41,7 +46,20 @@ function getTextSpan(parentElement: HTMLElement) { function getDescriptionText(parentElement: HTMLElement) { const textSpan = getTextSpan(parentElement); - return textSpan.innerText; + return (textSpan.children[0].children[0] as HTMLElement).innerText; +} + +/* + * Returns a list of the task components that are not the description, as strings. + */ +function getOtherLayoutComponents(parentElement: HTMLElement): string[] { + const textSpan = getTextSpan(parentElement); + const components: string[] = []; + for (const childSpan of Array.from(textSpan.children)) { + if (childSpan.classList.contains(LayoutClasses.description)) continue; + if (childSpan?.textContent) components.push(childSpan.textContent); + } + return components; } describe('task line rendering', () => { @@ -73,7 +91,20 @@ describe('task line rendering', () => { const textSpan = li.children[1]; expect(textSpan.nodeName).toEqual('SPAN'); expect(textSpan.classList.contains('tasks-list-text')).toBeTruthy(); - expect((textSpan as HTMLSpanElement).innerText).toEqual('This is a simple task'); + + // Check that the text span contains a single description span + expect(textSpan.children.length).toEqual(1); + const descriptionSpan = textSpan.children[0]; + expect(descriptionSpan.nodeName).toEqual('SPAN'); + expect(descriptionSpan.className).toEqual('task-description'); + + // Check that the description span contains an internal span (see taskToHtml for an explanation why it's there) + expect(descriptionSpan.children.length).toEqual(1); + const internalDescriptionSpan = descriptionSpan.children[0]; + expect(internalDescriptionSpan.nodeName).toEqual('SPAN'); + + // Check that eventually the correct text was rendered + expect((internalDescriptionSpan as HTMLSpanElement).innerText).toEqual('This is a simple task'); }); it('hides the global filter if and only if required', async () => { @@ -98,7 +129,8 @@ describe('task line rendering', () => { const testLayoutOptions = async ( taskLine: string, layoutOptions: Partial, - expectedRender: string, + expectedDescription: string, + expectedComponents: string[], ) => { const task = fromLine({ line: taskLine, @@ -108,14 +140,17 @@ describe('task line rendering', () => { const fullLayoutOptions = { ...new LayoutOptions(), ...layoutOptions }; const parentRender = await createMockParentAndRender(task, fullLayoutOptions); const renderedDescription = getDescriptionText(parentRender); - expect(renderedDescription).toEqual(expectedRender); + const renderedComponents = getOtherLayoutComponents(parentRender); + expect(renderedDescription).toEqual(expectedDescription); + expect(renderedComponents).toEqual(expectedComponents); }; it('renders correctly with the default layout options', async () => { await testLayoutOptions( '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', {}, - 'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02', + 'Full task', + [' ⏫', ' 🔁 every day', ' 🛫 2022-07-04', ' ⏳ 2022-07-03', ' 📅 2022-07-02'], ); }); @@ -123,7 +158,8 @@ describe('task line rendering', () => { await testLayoutOptions( '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', { hidePriority: true }, - 'Full task 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02', + 'Full task', + [' 🔁 every day', ' 🛫 2022-07-04', ' ⏳ 2022-07-03', ' 📅 2022-07-02'], ); }); @@ -131,7 +167,8 @@ describe('task line rendering', () => { await testLayoutOptions( '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 ➕ 2022-07-05 🔁 every day', { hideCreatedDate: true }, - 'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02', + 'Full task', + [' ⏫', ' 🔁 every day', ' 🛫 2022-07-04', ' ⏳ 2022-07-03', ' 📅 2022-07-02'], ); }); @@ -139,7 +176,8 @@ describe('task line rendering', () => { await testLayoutOptions( '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', { hideStartDate: true }, - 'Full task ⏫ 🔁 every day ⏳ 2022-07-03 📅 2022-07-02', + 'Full task', + [' ⏫', ' 🔁 every day', ' ⏳ 2022-07-03', ' 📅 2022-07-02'], ); }); @@ -147,7 +185,8 @@ describe('task line rendering', () => { await testLayoutOptions( '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', { hideScheduledDate: true }, - 'Full task ⏫ 🔁 every day 🛫 2022-07-04 📅 2022-07-02', + 'Full task', + [' ⏫', ' 🔁 every day', ' 🛫 2022-07-04', ' 📅 2022-07-02'], ); }); @@ -155,7 +194,8 @@ describe('task line rendering', () => { await testLayoutOptions( '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', { hideDueDate: true }, - 'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03', + 'Full task', + [' ⏫', ' 🔁 every day', ' 🛫 2022-07-04', ' ⏳ 2022-07-03'], ); }); @@ -163,32 +203,49 @@ describe('task line rendering', () => { await testLayoutOptions( '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', { hideRecurrenceRule: true }, - 'Full task ⏫ 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02', + 'Full task', + [' ⏫', ' 🛫 2022-07-04', ' ⏳ 2022-07-03', ' 📅 2022-07-02'], + ); + }); + + it('marks nonexistent task priority as "normal" priority', async () => { + await testLiAttributes( + '- [ ] Full task 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + {}, + { taskPriority: 'normal' }, ); }); it('renders a done task correctly with the default layout', async () => { await testLayoutOptions( - '- [x] Full task ✅ 2022-07-05 ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + '- [x] Full task ✅ 2022-07-05 ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 ➕ 2022-07-05 🔁 every day', {}, - 'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02 ✅ 2022-07-05', + 'Full task', + [ + ' ⏫', + ' 🔁 every day', + ' ➕ 2022-07-05', + ' 🛫 2022-07-04', + ' ⏳ 2022-07-03', + ' 📅 2022-07-02', + ' ✅ 2022-07-05', + ], ); }); it('renders a done task without the done date', async () => { await testLayoutOptions( - '- [x] Full task ✅ 2022-07-05 ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + '- [x] Full task ✅ 2022-07-05 ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 ➕ 2022-07-05 🔁 every day', { hideDoneDate: true }, - 'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02', + 'Full task', + [' ⏫', ' 🔁 every day', ' ➕ 2022-07-05', ' 🛫 2022-07-04', ' ⏳ 2022-07-03', ' 📅 2022-07-02'], ); }); it('writes a placeholder message if a date is invalid', async () => { - await testLayoutOptions( - '- [ ] Task with invalid due date 📅 2023-13-02', - {}, - 'Task with invalid due date 📅 Invalid date', - ); + await testLayoutOptions('- [ ] Task with invalid due date 📅 2023-13-02', {}, 'Task with invalid due date', [ + ' 📅 Invalid date', + ]); }); it('renders debug info if requested', async () => { @@ -197,7 +254,8 @@ describe('task line rendering', () => { await testLayoutOptions( '- [ ] Task with invalid due date 📅 2023-11-02', {}, - "Task with invalid due date 📅 2023-11-02
🐛 0 . 0 . 0 . '- [ ] Task with invalid due date 📅 2023-11-02'
'a/b/c.d' > 'Previous Heading'
", + "Task with invalid due date
🐛 0 . 0 . 0 . '- [ ] Task with invalid due date 📅 2023-11-02'
'a/b/c.d' > 'Previous Heading'
", + [' 📅 2023-11-02'], ); }); @@ -205,7 +263,300 @@ describe('task line rendering', () => { await testLayoutOptions( '- [ ] Task with invalid recurrence rule 🔁 every month on the 32nd', {}, - 'Task with invalid recurrence rule 🔁 every month on the 32th', + 'Task with invalid recurrence rule', + [' 🔁 every month on the 32th'], + ); + }); + + const testComponentClasses = async ( + taskLine: string, + layoutOptions: Partial, + mainClass: string, + attributes: AttributesDictionary, + ) => { + const task = fromLine({ + line: taskLine, + }); + const fullLayoutOptions = { ...new LayoutOptions(), ...layoutOptions }; + const parentRender = await createMockParentAndRender(task, fullLayoutOptions); + + const textSpan = getTextSpan(parentRender); + let found = false; + for (const childSpan of Array.from(textSpan.children)) { + if (childSpan.classList.contains(mainClass)) { + found = true; + const spanElement = childSpan as HTMLSpanElement; + // Now verify the attributes + for (const key in attributes) { + expect(spanElement.dataset[key]).toEqual(attributes[key]); + } + } + } + expect(found).toBeTruthy(); + }; + + const testLiAttributes = async ( + taskLine: string, + layoutOptions: Partial, + attributes: AttributesDictionary, + ) => { + const task = fromLine({ + line: taskLine, + }); + const fullLayoutOptions = { ...new LayoutOptions(), ...layoutOptions }; + const parentRender = await createMockParentAndRender(task, fullLayoutOptions); + const li = parentRender.children[0] as HTMLElement; + for (const key in attributes) { + expect(li.dataset[key]).toEqual(attributes[key]); + } + }; + + const testHiddenComponentClasses = async ( + taskLine: string, + layoutOptions: Partial, + hiddenGenericClass: string, + attributes: AttributesDictionary, + ) => { + const task = fromLine({ + line: taskLine, + }); + const fullLayoutOptions = { ...new LayoutOptions(), ...layoutOptions }; + const parentRender = await createMockParentAndRender(task, fullLayoutOptions); + + const textSpan = getTextSpan(parentRender); + for (const childSpan of Array.from(textSpan.children)) { + expect(childSpan.classList.contains(hiddenGenericClass)).toBeFalsy(); + } + const li = parentRender.children[0] as HTMLElement; + // Now verify the attributes + for (const key in attributes) { + expect(li.dataset[key]).toEqual(attributes[key]); + } + }; + + it('renders priority with its correct classes', async () => { + await testComponentClasses( + '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + {}, + LayoutClasses.priority, + { taskPriority: 'high' }, + ); + await testComponentClasses( + '- [ ] Full task 🔼 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + {}, + LayoutClasses.priority, + { taskPriority: 'medium' }, + ); + await testComponentClasses( + '- [ ] Full task 🔽 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + {}, + LayoutClasses.priority, + { taskPriority: 'low' }, + ); + }); + + it('renders recurrence with its correct classes', async () => { + await testComponentClasses( + '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + {}, + LayoutClasses.recurrenceRule, + {}, + ); + }); + + it('adds a correct "today" CSS class to dates', async () => { + const today = DateParser.parseDate('today').format(TaskRegularExpressions.dateFormat); + await testComponentClasses(`- [ ] Full task ⏫ ➕ ${today}`, {}, LayoutClasses.createdDate, { + taskCreated: 'today', + }); + await testComponentClasses(`- [ ] Full task ⏫ 📅 ${today}`, {}, LayoutClasses.dueDate, { taskDue: 'today' }); + await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${today}`, {}, LayoutClasses.scheduledDate, { + taskScheduled: 'today', + }); + await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${today}`, {}, LayoutClasses.startDate, { + taskStart: 'today', + }); + await testComponentClasses(`- [x] Done task ✅ ${today}`, {}, LayoutClasses.doneDate, { taskDone: 'today' }); + }); + + it('adds a correct "future-1d" CSS class to dates', async () => { + const future = DateParser.parseDate('tomorrow').format(TaskRegularExpressions.dateFormat); + await testComponentClasses(`- [ ] Full task ⏫ ➕ ${future}`, {}, LayoutClasses.createdDate, { + taskCreated: 'future-1d', + }); + await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, LayoutClasses.dueDate, { + taskDue: 'future-1d', + }); + await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, LayoutClasses.scheduledDate, { + taskScheduled: 'future-1d', + }); + await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, LayoutClasses.startDate, { + taskStart: 'future-1d', + }); + await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, LayoutClasses.doneDate, { + taskDone: 'future-1d', + }); + }); + + it('adds a correct "future-7d" CSS class to dates', async () => { + const future = DateParser.parseDate('in 7 days').format(TaskRegularExpressions.dateFormat); + await testComponentClasses(`- [ ] Full task ⏫ ➕ ${future}`, {}, LayoutClasses.createdDate, { + taskCreated: 'future-7d', + }); + await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, LayoutClasses.dueDate, { + taskDue: 'future-7d', + }); + await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, LayoutClasses.scheduledDate, { + taskScheduled: 'future-7d', + }); + await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, LayoutClasses.startDate, { + taskStart: 'future-7d', + }); + await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, LayoutClasses.doneDate, { + taskDone: 'future-7d', + }); + }); + + it('adds a correct "past-1d" CSS class to dates', async () => { + const past = DateParser.parseDate('yesterday').format(TaskRegularExpressions.dateFormat); + await testComponentClasses(`- [ ] Full task ⏫ ➕ ${past}`, {}, LayoutClasses.createdDate, { + taskCreated: 'past-1d', + }); + await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, LayoutClasses.dueDate, { taskDue: 'past-1d' }); + await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, LayoutClasses.scheduledDate, { + taskScheduled: 'past-1d', + }); + await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, LayoutClasses.startDate, { + taskStart: 'past-1d', + }); + await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, LayoutClasses.doneDate, { taskDone: 'past-1d' }); + }); + + it('adds a correct "past-7d" CSS class to dates', async () => { + const past = DateParser.parseDate('7 days ago').format(TaskRegularExpressions.dateFormat); + await testComponentClasses(`- [ ] Full task ⏫ ➕ ${past}`, {}, LayoutClasses.createdDate, { + taskCreated: 'past-7d', + }); + await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, LayoutClasses.dueDate, { taskDue: 'past-7d' }); + await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, LayoutClasses.scheduledDate, { + taskScheduled: 'past-7d', + }); + await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, LayoutClasses.startDate, { + taskStart: 'past-7d', + }); + await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, LayoutClasses.doneDate, { taskDone: 'past-7d' }); + }); + + it('adds the classes "...future-far" and "...past-far" to dates that are further than 7 days', async () => { + const future = DateParser.parseDate('in 8 days').format(TaskRegularExpressions.dateFormat); + await testComponentClasses(`- [ ] Full task ⏫ ➕ ${future}`, {}, LayoutClasses.createdDate, { + taskCreated: 'future-far', + }); + await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, LayoutClasses.dueDate, { + taskDue: 'future-far', + }); + await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, LayoutClasses.scheduledDate, { + taskScheduled: 'future-far', + }); + await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, LayoutClasses.startDate, { + taskStart: 'future-far', + }); + await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, LayoutClasses.doneDate, { + taskDone: 'future-far', + }); + const past = DateParser.parseDate('8 days ago').format(TaskRegularExpressions.dateFormat); + await testComponentClasses(`- [ ] Full task ⏫ ➕ ${past}`, {}, LayoutClasses.createdDate, { + taskCreated: 'past-far', + }); + await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, LayoutClasses.dueDate, { taskDue: 'past-far' }); + await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, LayoutClasses.scheduledDate, { + taskScheduled: 'past-far', + }); + await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, LayoutClasses.startDate, { + taskStart: 'past-far', + }); + await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, LayoutClasses.doneDate, { taskDone: 'past-far' }); + }); + + it('does not add specific classes to invalid dates', async () => { + await testComponentClasses('- [ ] Full task ⏫ 📅 2023-02-29', {}, LayoutClasses.dueDate, {}); + }); + + it('does not render hidden components but sets their specific classes to the upper li element', async () => { + await testHiddenComponentClasses( + '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + { hidePriority: true }, + LayoutClasses.priority, + { taskPriority: 'high' }, ); + await testHiddenComponentClasses( + '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 ➕ 2022-07-04 🔁 every day', + { hideCreatedDate: true }, + LayoutClasses.createdDate, + { taskCreated: 'past-far' }, + ); + await testHiddenComponentClasses( + '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + { hideDueDate: true }, + LayoutClasses.dueDate, + { taskDue: 'past-far' }, + ); + await testHiddenComponentClasses( + '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + { hideScheduledDate: true }, + LayoutClasses.scheduledDate, + { taskScheduled: 'past-far' }, + ); + await testHiddenComponentClasses( + '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day', + { hideStartDate: true }, + LayoutClasses.startDate, + { taskStart: 'past-far' }, + ); + }); + + // Unlike the default renderer in createMockParentAndRender, this one accepts a raw HTML rather + // than a text, used for the following tests + const mockInnerHtmlRenderer = async (text: string, element: HTMLSpanElement, _path: string) => { + element.innerHTML = text; + }; + + /* + * In this test we try to imitate Obsidian's Markdown renderer more thoroughly than other tests, + * so we can verify that the rendering code adds the correct tag classes inside the rendered + * Markdown. + * Note that this test, just like the code that it tests, assumed a specific rendered structure + * by Obsidian, which is not guaranteed by the API. + */ + it('adds tag attributes inside the description span', async () => { + const taskLine = '- [ ] Class with
#someTag'; + const task = fromLine({ + line: taskLine, + }); + const parentRender = await createMockParentAndRender(task, new LayoutOptions(), mockInnerHtmlRenderer); + + const textSpan = getTextSpan(parentRender); + const descriptionSpan = textSpan.children[0].children[0] as HTMLElement; + expect(descriptionSpan.textContent).toEqual('Class with #someTag'); + const tagSpan = descriptionSpan.children[0] as HTMLSpanElement; + expect(tagSpan.textContent).toEqual('#someTag'); + expect(tagSpan.classList[0]).toEqual('tag'); + expect(tagSpan.dataset.tagName).toEqual('#someTag'); + }); + + it('sanitizes tag names when put into data attributes', async () => { + const taskLine = '- [ ] Class with #illegal"data&attribute'; + const task = fromLine({ + line: taskLine, + }); + const parentRender = await createMockParentAndRender(task, new LayoutOptions(), mockInnerHtmlRenderer); + + const textSpan = getTextSpan(parentRender); + const descriptionSpan = textSpan.children[0].children[0] as HTMLElement; + expect(descriptionSpan.textContent).toEqual('Class with #illegal"data&attribute'); + const tagSpan = descriptionSpan.children[0] as HTMLSpanElement; + expect(tagSpan.textContent).toEqual('#illegal"data&attribute'); + expect(tagSpan.classList[0]).toEqual('tag'); + expect(tagSpan.dataset.tagName).toEqual('#illegal-data-attribute'); }); });