Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2436 (2023-11-21 19:40:45)

refactor: create TaskFieldRenderer.ts
<!--- Provide a general summary of your changes in the Title above -->

# Description

Extract `FieldLayoutDetail` and `FieldLayouts` classes from `TaskLineRenderer.ts` to a separate file.

## Motivation and Context

Separate concerns is `TaskLineRenderer.ts`.

## How has this been tested?

Unit tests.

## Types of changes

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)

## Checklist

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [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/src/TaskFieldRenderer.ts b/src/TaskFieldRenderer.ts
new file mode 100644
index 00000000..b2b15895
--- /dev/null
+++ b/src/TaskFieldRenderer.ts
@@ -0,0 +1,153 @@
+import type { Moment } from 'moment';
+import { PriorityTools } from './lib/PriorityTools';
+import type { Task } from './Task';
+import type { TaskLayoutComponent } from './TaskLayout';
+
+export type AttributesDictionary = { [key: string]: string };
+
+export class FieldLayouts {
+    private details = FieldLayoutDetails;
+
+    constructor() {}
+
+    /**
+     * Searches for the component among the {@link FieldLayoutDetails} and gets its data attribute
+     * in a given task. The data attribute shall be added in the task's `<span>`.
+     * For example, a task with medium priority and done yesterday will have
+     * `data-task-priority="medium" data-task-due="past-1d" ` in its data attributes.
+     *
+     * If the data attribute is absent in the task, an empty {@link AttributesDictionary} is returned.
+     *
+     * For detailed calculation see {@link FieldLayoutDetail.dataAttribute}.
+     *
+     * @param component the component of the task for which the data attribute has to be generated.
+     * @param task the task from which the data shall be taken
+     */
+    public dataAttribute(component: TaskLayoutComponent, task: Task) {
+        return this.details[component].dataAttribute(component, task);
+    }
+}
+
+type AttributeValueCalculator = (component: TaskLayoutComponent, task: Task) => string;
+
+export class FieldLayoutDetail {
+    readonly className: string;
+    readonly attributeName: string;
+    readonly attributeValueCalculator: AttributeValueCalculator;
+
+    public static noAttributeName = '';
+    public static noAttributeValueCalculator: AttributeValueCalculator = () => {
+        return '';
+    };
+    public static dateAttributeCalculator = (component: TaskLayoutComponent, task: Task) => {
+        /**
+         * 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.)
+         */
+        const MAX_DAY_VALUE_RANGE = 7;
+        const DAY_VALUE_OVER_RANGE_POSTFIX = 'far';
+
+        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;
+        }
+
+        const date = task[component];
+
+        if (date instanceof window.moment) {
+            const attributeValue = dateToAttribute(date);
+            if (attributeValue) {
+                return attributeValue;
+            }
+        }
+        return '';
+    };
+
+    /**
+     * @param className CSS class that describes what the component is, e.g. a due date or a priority.
+     *
+     * @param attributeName if the component needs data attribute (`data-key="value"`) this is the key.
+     * Otherwise, set this to {@link FieldLayoutDetail.noAttributeName}.
+     *
+     * @param attributeValueCalculator And this is the value.
+     * Set to {@link FieldLayoutDetail.noAttributeValueCalculator} if shall be empty.
+     *
+     * There is a relation between {@link attributeName} and {@link attributeValueCalculator}.
+     * For a component to have the data attribute, both need to be set to values other than
+     * {@link FieldLayoutDetail.noAttributeName} and {@link FieldLayoutDetail.noAttributeValueCalculator} respectively.
+     * This means that having an empty data attribute (`data-key=""`) is not supported.
+     */
+    constructor(className: string, attributeName: string, attributeValueCalculator: AttributeValueCalculator) {
+        // If className is empty, `span.classList.add(...componentClass);` will fail in runtime.
+        if (className === '') {
+            throw Error('Developer note: CSS class cannot be an empty string, please specify one.');
+        }
+        this.className = className;
+        this.attributeName = attributeName;
+        this.attributeValueCalculator = attributeValueCalculator;
+    }
+
+    /**
+     * Shall be called only by {@link FieldLayouts}. Use that class if you need the data attributes.
+     *
+     * @returns the data attribute, associated to with a task's component, added in the task's `<span>`.
+     * For example, a task with medium priority and done yesterday will have
+     * `data-task-priority="medium" data-task-due="past-1d" ` in its data attributes.
+     *
+     * Calculation of the value is done with {@link FieldLayoutDetail.attributeValueCalculator}.
+     *
+     * @param component the component of the task for which the data attribute has to be generated.
+     * @param task the task from which the data shall be taken
+     */
+    public dataAttribute(component: TaskLayoutComponent, task: Task) {
+        const dataAttribute: AttributesDictionary = {};
+
+        if (this.attributeName !== FieldLayoutDetail.noAttributeName) {
+            dataAttribute[this.attributeName] = this.attributeValueCalculator(component, task);
+        }
+
+        return dataAttribute;
+    }
+}
+
+export const FieldLayoutDetails: { [c in TaskLayoutComponent]: FieldLayoutDetail } = {
+    createdDate: new FieldLayoutDetail('task-created', 'taskCreated', FieldLayoutDetail.dateAttributeCalculator),
+    dueDate: new FieldLayoutDetail('task-due', 'taskDue', FieldLayoutDetail.dateAttributeCalculator),
+    startDate: new FieldLayoutDetail('task-start', 'taskStart', FieldLayoutDetail.dateAttributeCalculator),
+    scheduledDate: new FieldLayoutDetail('task-scheduled', 'taskScheduled', FieldLayoutDetail.dateAttributeCalculator),
+    doneDate: new FieldLayoutDetail('task-done', 'taskDone', FieldLayoutDetail.dateAttributeCalculator),
+
+    description: new FieldLayoutDetail(
+        'task-description',
+        FieldLayoutDetail.noAttributeName,
+        FieldLayoutDetail.noAttributeValueCalculator,
+    ),
+    recurrenceRule: new FieldLayoutDetail(
+        'task-recurring',
+        FieldLayoutDetail.noAttributeName,
+        FieldLayoutDetail.noAttributeValueCalculator,
+    ),
+
+    priority: new FieldLayoutDetail('task-priority', 'taskPriority', (_component, task) => {
+        return PriorityTools.priorityNameUsingNormal(task.priority).toLocaleLowerCase();
+    }),
+
+    blockLink: new FieldLayoutDetail(
+        'task-block-link',
+        FieldLayoutDetail.noAttributeName,
+        FieldLayoutDetail.noAttributeValueCalculator,
+    ),
+};
diff --git a/src/TaskLineRenderer.ts b/src/TaskLineRenderer.ts
index 23dd1476..99debdf0 100644
--- a/src/TaskLineRenderer.ts
+++ b/src/TaskLineRenderer.ts
@@ -3,161 +3,13 @@ import { Component, MarkdownRenderer } from 'obsidian';
 import { GlobalFilter } from './Config/GlobalFilter';
 import { TASK_FORMATS, getSettings } from './Config/Settings';
 import { replaceTaskWithTasks } from './File';
-import { PriorityTools } from './lib/PriorityTools';
 import type { Task } from './Task';
 import * as taskModule from './Task';
+import { type AttributesDictionary, FieldLayoutDetails, FieldLayouts } from './TaskFieldRenderer';
 import type { LayoutOptions, TaskLayoutComponent } from './TaskLayout';
 import { TaskLayout } from './TaskLayout';
 
-export type AttributesDictionary = { [key: string]: string };
-
-export class FieldLayoutsContainer {
-    private details = FieldLayoutDetails;
-
-    constructor() {}
-
-    /**
-     * Searches for the component among the {@link FieldLayoutDetails} and gets its data attribute
-     * in a given task. The data attribute shall be added in the task's `<span>`.
-     * For example, a task with medium priority and done yesterday will have
-     * `data-task-priority="medium" data-task-due="past-1d" ` in its data attributes.
-     *
-     * If the data attribute is absent in the task, an empty {@link AttributesDictionary} is returned.
-     *
-     * For detailed calculation see {@link FieldLayoutDetail.dataAttribute}.
-     *
-     * @param component the component of the task for which the data attribute has to be generated.
-     * @param task the task from which the data shall be taken
-     */
-    public dataAttribute(component: TaskLayoutComponent, task: Task) {
-        return this.details[component].dataAttribute(component, task);
-    }
-}
-
-type AttributeValueCalculator = (component: TaskLayoutComponent, task: Task) => string;
-
-export class FieldLayoutDetail {
-    readonly className: string;
-    readonly attributeName: string;
-    readonly attributeValueCalculator: AttributeValueCalculator;
-
-    public static noAttributeName = '';
-    public static noAttributeValueCalculator: AttributeValueCalculator = () => {
-        return '';
-    };
-    public static dateAttributeCalculator = (component: TaskLayoutComponent, task: Task) => {
-        /**
-         * 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.)
-         */
-        const MAX_DAY_VALUE_RANGE = 7;
-        const DAY_VALUE_OVER_RANGE_POSTFIX = 'far';
-        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;
-        }
-
-        const date = task[component];
-
-        if (date instanceof window.moment) {
-            const attributeValue = dateToAttribute(date);
-            if (attributeValue) {
-                return attributeValue;
-            }
-        }
-        return '';
-    };
-
-    /**
-     * @param className CSS class that describes what the component is, e.g. a due date or a priority.
-     *
-     * @param attributeName if the component needs data attribute (`data-key="value"`) this is the key.
-     * Otherwise, set this to {@link FieldLayoutDetail.noAttributeName}.
-     *
-     * @param attributeValueCalculator And this is the value.
-     * Set to {@link FieldLayoutDetail.noAttributeValueCalculator} if shall be empty.
-     *
-     * There is a relation between {@link attributeName} and {@link attributeValueCalculator}.
-     * For a component to have the data attribute, both need to be set to values other than
-     * {@link FieldLayoutDetail.noAttributeName} and {@link FieldLayoutDetail.noAttributeValueCalculator} respectively.
-     * This means that having an empty data attribute (`data-key=""`) is not supported.
-     */
-    constructor(className: string, attributeName: string, attributeValueCalculator: AttributeValueCalculator) {
-        // If className is empty, `span.classList.add(...componentClass);` will fail in runtime.
-        if (className === '') {
-            throw Error('Developer note: CSS class cannot be an empty string, please specify one.');
-        }
-        this.className = className;
-        this.attributeName = attributeName;
-        this.attributeValueCalculator = attributeValueCalculator;
-    }
-
-    /**
-     * Shall be called only by {@link FieldLayoutsContainer}. Use that class if you need the data attributes.
-     *
-     * @returns the data attribute, associated to with a task's component, added in the task's `<span>`.
-     * For example, a task with medium priority and done yesterday will have
-     * `data-task-priority="medium" data-task-due="past-1d" ` in its data attributes.
-     *
-     * Calculation of the value is done with {@link FieldLayoutDetail.attributeValueCalculator}.
-     *
-     * @param component the component of the task for which the data attribute has to be generated.
-     * @param task the task from which the data shall be taken
-     */
-    public dataAttribute(component: TaskLayoutComponent, task: Task) {
-        const dataAttribute: AttributesDictionary = {};
-
-        if (this.attributeName !== FieldLayoutDetail.noAttributeName) {
-            dataAttribute[this.attributeName] = this.attributeValueCalculator(component, task);
-        }
-
-        return dataAttribute;
-    }
-}
-
-export const FieldLayoutDetails: { [c in TaskLayoutComponent]: FieldLayoutDetail } = {
-    createdDate: new FieldLayoutDetail('task-created', 'taskCreated', FieldLayoutDetail.dateAttributeCalculator),
-    dueDate: new FieldLayoutDetail('task-due', 'taskDue', FieldLayoutDetail.dateAttributeCalculator),
-    startDate: new FieldLayoutDetail('task-start', 'taskStart', FieldLayoutDetail.dateAttributeCalculator),
-    scheduledDate: new FieldLayoutDetail('task-scheduled', 'taskScheduled', FieldLayoutDetail.dateAttributeCalculator),
-    doneDate: new FieldLayoutDetail('task-done', 'taskDone', FieldLayoutDetail.dateAttributeCalculator),
-
-    description: new FieldLayoutDetail(
-        'task-description',
-        FieldLayoutDetail.noAttributeName,
-        FieldLayoutDetail.noAttributeValueCalculator,
-    ),
-    recurrenceRule: new FieldLayoutDetail(
-        'task-recurring',
-        FieldLayoutDetail.noAttributeName,
-        FieldLayoutDetail.noAttributeValueCalculator,
-    ),
-
-    priority: new FieldLayoutDetail('task-priority', 'taskPriority', (_component, task) => {
-        return PriorityTools.priorityNameUsingNormal(task.priority).toLocaleLowerCase();
-    }),
-
-    blockLink: new FieldLayoutDetail(
-        'task-block-link',
-        FieldLayoutDetail.noAttributeName,
-        FieldLayoutDetail.noAttributeValueCalculator,
-    ),
-};
-
-const FieldLayouts = new FieldLayoutsContainer();
+const fieldLayouts = new FieldLayouts();
 
 /**
  * The function used to render a Markdown task line into an existing HTML element.
@@ -322,7 +174,7 @@ export class TaskLineRenderer {
                     }
 
                     // Add the component's attribute ('priority-medium', 'due-past-1d' etc.)
-                    const componentDataAttribute = FieldLayouts.dataAttribute(component, task);
+                    const componentDataAttribute = fieldLayouts.dataAttribute(component, task);
                     for (const key in componentDataAttribute) span.dataset[key] = componentDataAttribute[key];
                     allAttributes = { ...allAttributes, ...componentDataAttribute };
                 }
@@ -331,7 +183,7 @@ export class TaskLineRenderer {
 
         // Now build classes for the hidden task components without rendering them
         for (const component of taskLayout.hiddenTaskLayoutComponents) {
-            const hiddenComponentDataAttribute = FieldLayouts.dataAttribute(component, task);
+            const hiddenComponentDataAttribute = fieldLayouts.dataAttribute(component, task);
             allAttributes = { ...allAttributes, ...hiddenComponentDataAttribute };
         }
 
@@ -341,7 +193,7 @@ export class TaskLineRenderer {
         // 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 priorityDataAttribute = FieldLayouts.dataAttribute('priority', task);
+            const priorityDataAttribute = fieldLayouts.dataAttribute('priority', task);
             allAttributes = { ...allAttributes, ...priorityDataAttribute };
         }
 


Test patch:
diff --git a/tests/TaskFieldRenderer.test.ts b/tests/TaskFieldRenderer.test.ts
new file mode 100644
index 00000000..4ffdeb43
--- /dev/null
+++ b/tests/TaskFieldRenderer.test.ts
@@ -0,0 +1,76 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import { FieldLayoutDetail, FieldLayouts } from '../src/TaskFieldRenderer';
+import { TaskBuilder } from './TestingTools/TaskBuilder';
+
+window.moment = moment;
+
+describe('Field Layouts Container tests', () => {
+    beforeEach(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date('2023-11-19'));
+    });
+
+    afterEach(() => {
+        jest.useRealTimers();
+    });
+
+    it('should get the data attribute of an existing component (date)', () => {
+        const container = new FieldLayouts();
+        const task = new TaskBuilder().dueDate('2023-11-20').build();
+
+        const dueDateDataAttribute = container.dataAttribute('dueDate', task);
+
+        expect(Object.keys(dueDateDataAttribute).length).toEqual(1);
+        expect(dueDateDataAttribute['taskDue']).toEqual('future-1d');
+    });
+
+    it('should get the data attribute of an existing component (not date)', () => {
+        const container = new FieldLayouts();
+        const task = TaskBuilder.createFullyPopulatedTask();
+
+        const dueDateDataAttribute = container.dataAttribute('priority', task);
+
+        expect(Object.keys(dueDateDataAttribute).length).toEqual(1);
+        expect(dueDateDataAttribute['taskPriority']).toEqual('medium');
+    });
+
+    it('should return empty data attributes dictionary for a missing component', () => {
+        const container = new FieldLayouts();
+        const task = new TaskBuilder().build();
+
+        const dueDateDataAttribute = container.dataAttribute('recurrenceRule', task);
+
+        expect(Object.keys(dueDateDataAttribute).length).toEqual(0);
+    });
+});
+
+describe('Field Layout Detail tests', () => {
+    it('should supply a class name and a data attribute name', () => {
+        const fieldLayoutDetail = new FieldLayoutDetail('stuff', 'taskAttribute', () => {
+            return '';
+        });
+        expect(fieldLayoutDetail.className).toEqual('stuff');
+        expect(fieldLayoutDetail.attributeName).toEqual('taskAttribute');
+    });
+
+    it('should calculate data attribute value', () => {
+        const fieldLayoutDetail = new FieldLayoutDetail('foo', 'bar', () => {
+            return 'someValue';
+        });
+        const attributeValue = fieldLayoutDetail.attributeValueCalculator('createdDate', new TaskBuilder().build());
+        expect(attributeValue).toEqual('someValue');
+    });
+
+    it('should return a data attribute', () => {
+        const fieldLayoutDetail = new FieldLayoutDetail('dataAttributeTest', 'aKey', () => {
+            return 'aValue';
+        });
+        const dataAttribute = fieldLayoutDetail.dataAttribute('description', new TaskBuilder().build());
+
+        expect(Object.keys(dataAttribute).length).toEqual(1);
+        expect(dataAttribute['aKey']).toEqual('aValue');
+    });
+});
diff --git a/tests/TaskLineRenderer.test.ts b/tests/TaskLineRenderer.test.ts
index 44453840..c03137ca 100644
--- a/tests/TaskLineRenderer.test.ts
+++ b/tests/TaskLineRenderer.test.ts
@@ -8,14 +8,10 @@ import { resetSettings, updateSettings } from '../src/Config/Settings';
 import { DateParser } from '../src/Query/DateParser';
 import type { Task } from '../src/Task';
 import { TaskRegularExpressions } from '../src/Task';
+import { type AttributesDictionary, FieldLayoutDetails } from '../src/TaskFieldRenderer';
 import { LayoutOptions } from '../src/TaskLayout';
-import type { AttributesDictionary, TextRenderer } from '../src/TaskLineRenderer';
-import {
-    FieldLayoutDetail,
-    FieldLayoutDetails,
-    FieldLayoutsContainer,
-    TaskLineRenderer,
-} from '../src/TaskLineRenderer';
+import type { TextRenderer } from '../src/TaskLineRenderer';
+import { TaskLineRenderer } from '../src/TaskLineRenderer';
 import { fromLine } from './TestHelpers';
 import { verifyWithFileExtension } from './TestingTools/ApprovalTestHelpers';
 import { TaskBuilder } from './TestingTools/TaskBuilder';
@@ -679,71 +675,3 @@ ${task.toFileLineString()}
         await renderAndVerifyHTML(minimalTask, layoutOptionsShortMode());
     });
 });
-
-describe('Field Layouts Container tests', () => {
-    beforeEach(() => {
-        jest.useFakeTimers();
-        jest.setSystemTime(new Date('2023-11-19'));
-    });
-
-    afterEach(() => {
-        jest.useRealTimers();
-    });
-
-    it('should get the data attribute of an existing component (date)', () => {
-        const container = new FieldLayoutsContainer();
-        const task = new TaskBuilder().dueDate('2023-11-20').build();
-
-        const dueDateDataAttribute = container.dataAttribute('dueDate', task);
-
-        expect(Object.keys(dueDateDataAttribute).length).toEqual(1);
-        expect(dueDateDataAttribute['taskDue']).toEqual('future-1d');
-    });
-
-    it('should get the data attribute of an existing component (not date)', () => {
-        const container = new FieldLayoutsContainer();
-        const task = TaskBuilder.createFullyPopulatedTask();
-
-        const dueDateDataAttribute = container.dataAttribute('priority', task);
-
-        expect(Object.keys(dueDateDataAttribute).length).toEqual(1);
-        expect(dueDateDataAttribute['taskPriority']).toEqual('medium');
-    });
-
-    it('should return empty data attributes dictionary for a missing component', () => {
-        const container = new FieldLayoutsContainer();
-        const task = new TaskBuilder().build();
-
-        const dueDateDataAttribute = container.dataAttribute('recurrenceRule', task);
-
-        expect(Object.keys(dueDateDataAttribute).length).toEqual(0);
-    });
-});
-
-describe('Field Layout Detail tests', () => {
-    it('should supply a class name and a data attribute name', () => {
-        const fieldLayoutDetail = new FieldLayoutDetail('stuff', 'taskAttribute', () => {
-            return '';
-        });
-        expect(fieldLayoutDetail.className).toEqual('stuff');
-        expect(fieldLayoutDetail.attributeName).toEqual('taskAttribute');
-    });
-
-    it('should calculate data attribute value', () => {
-        const fieldLayoutDetail = new FieldLayoutDetail('foo', 'bar', () => {
-            return 'someValue';
-        });
-        const attributeValue = fieldLayoutDetail.attributeValueCalculator('createdDate', new TaskBuilder().build());
-        expect(attributeValue).toEqual('someValue');
-    });
-
-    it('should return a data attribute', () => {
-        const fieldLayoutDetail = new FieldLayoutDetail('dataAttributeTest', 'aKey', () => {
-            return 'aValue';
-        });
-        const dataAttribute = fieldLayoutDetail.dataAttribute('description', new TaskBuilder().build());
-
-        expect(Object.keys(dataAttribute).length).toEqual(1);
-        expect(dataAttribute['aKey']).toEqual('aValue');
-    });
-});