Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2403 (2023-11-07 17:07:31)

refactor: extract data and behaviour of layout parameters
# Description

Extract data and behaviour to a dictionary with layout parameters.

## Motivation and Context

Prepare further `TaskLayout.*` refactorings.

## How has this been tested?

Unit tests + new unit tests added.

## Types of changes

Internal 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)


ilandikov: @claremacrae this is what I have been able to do so far, however I really struggle to find a meaningful name for `AppleSauce` & `appleSauceDictionary`... This is not a joke... =) 
claremacrae: > @claremacrae this is what I have been able to do so far, however I really struggle to find a meaningful name for `AppleSauce` & `appleSauceDictionary`... This is not a joke... =)

Ah - I made some comments but didn't see this...


Patch:
diff --git a/src/TaskLineRenderer.ts b/src/TaskLineRenderer.ts
index 52471d69..0e863b94 100644
--- a/src/TaskLineRenderer.ts
+++ b/src/TaskLineRenderer.ts
@@ -1,13 +1,13 @@
-import { Component, MarkdownRenderer } from 'obsidian';
 import type { Moment } from 'moment';
+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 { LayoutOptions, TaskLayoutComponent } from './TaskLayout';
 import { TaskLayout } from './TaskLayout';
-import { replaceTaskWithTasks } from './File';
-import { TASK_FORMATS, getSettings } from './Config/Settings';
-import { GlobalFilter } from './Config/GlobalFilter';
-import { PriorityTools } from './lib/PriorityTools';
 
 export type TaskLineRenderDetails = {
     parentUlElement: HTMLElement;
@@ -19,16 +19,80 @@ export type TaskLineRenderDetails = {
     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: 'task-block-link',
+type AttributeValueCalculator = (component: TaskLayoutComponent, task: Task) => string;
+
+export class FieldLayoutDetail {
+    className: string;
+    attributeName: string;
+    attributeValueCalculator: AttributeValueCalculator;
+
+    public static noAttributeName = '';
+    public static noAttributeValueCalculator: AttributeValueCalculator = () => {
+        return '';
+    };
+
+    /**
+     * @param className CSS class of the component.
+     * @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;
+    }
+}
+
+const dateDataAttributeCalculator: AttributeValueCalculator = (component: TaskLayoutComponent, task: Task) => {
+    const date = task[component];
+    if (date instanceof window.moment) {
+        const attributeValue = dateToAttribute(date);
+        if (attributeValue) {
+            return attributeValue;
+        }
+    }
+
+    return '';
+};
+
+export const FieldLayouts: { [c in TaskLayoutComponent]: FieldLayoutDetail } = {
+    createdDate: new FieldLayoutDetail('task-created', 'taskCreated', dateDataAttributeCalculator),
+    dueDate: new FieldLayoutDetail('task-due', 'taskDue', dateDataAttributeCalculator),
+    startDate: new FieldLayoutDetail('task-start', 'taskStart', dateDataAttributeCalculator),
+    scheduledDate: new FieldLayoutDetail('task-scheduled', 'taskScheduled', dateDataAttributeCalculator),
+    doneDate: new FieldLayoutDetail('task-done', 'taskDone', dateDataAttributeCalculator),
+
+    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 MAX_DAY_VALUE_RANGE = 7;
@@ -153,7 +217,7 @@ async function taskToHtml(
                 addInternalClasses(component, internalSpan);
 
                 // Add the component's CSS class describing what this component is (priority, due date etc.)
-                const componentClass = getTaskComponentClass(component, task);
+                const componentClass = getTaskComponentClass(component);
                 span.classList.add(...componentClass);
 
                 // Add the component's attribute ('priority-medium', 'due-past-1d' etc.)
@@ -234,31 +298,17 @@ async function renderComponentText(
 }
 
 /**
- * The CSS class that describes what the component is, e.g. a due date or a priority, and is a value from LayoutClasses.
+ * The CSS class that describes what the component is, e.g. a due date or a priority, and is a value from FieldLayouts.
+ *
+ * **Important**: The caller is responsible for ensuring that the Task being rendered does actually have a value for this field,
+ * that is, that `task[component]` has a value. Only call this if task has this value.
  */
-function getTaskComponentClass(component: TaskLayoutComponent, task: Task) {
+function getTaskComponentClass(component: TaskLayoutComponent) {
     const componentClassContainer: string[] = [];
 
-    const componentClass = LayoutClasses[component];
-    switch (component) {
-        case 'blockLink':
-        case 'description':
-        case 'priority':
-        case 'recurrenceRule':
-            componentClassContainer.push(componentClass);
-            break;
-        case 'createdDate':
-        case 'dueDate':
-        case 'startDate':
-        case 'scheduledDate':
-        case 'doneDate': {
-            const date = task[component];
-            if (date) {
-                componentClassContainer.push(componentClass);
-            }
-            break;
-        }
-    }
+    const fieldLayoutDetail = FieldLayouts[component];
+    componentClassContainer.push(fieldLayoutDetail.className);
+
     return componentClassContainer;
 }
 
@@ -268,49 +318,15 @@ function getTaskComponentClass(component: TaskLayoutComponent, task: Task) {
 function getComponentDataAttribute(component: TaskLayoutComponent, task: Task) {
     const dataAttribute: AttributesDictionary = {};
 
-    // If a TaskLayoutComponent needs a data attribute in the task's <span>, add the data attribute name
-    // to this dictionary: key is the component, value is the data attribute name.
-    // Otherwise, just leave an empty string ('') as the value.
-    // Also add the new component to the switch-case below in this function. This is where
-    // the data attribute value shall be calculated and set in the returned dictionary.
-    const DataAttributeNames: { [c in TaskLayoutComponent]: string } = {
-        createdDate: 'taskCreated',
-        dueDate: 'taskDue',
-        startDate: 'taskStart',
-        scheduledDate: 'taskScheduled',
-        doneDate: 'taskDone',
-        priority: 'taskPriority',
-        description: '',
-        recurrenceRule: '',
-        blockLink: '',
-    };
-
-    switch (component) {
-        case 'description':
-        case 'recurrenceRule':
-        case 'blockLink':
-            break;
-        case 'priority': {
-            const attributeName = DataAttributeNames[component];
-            dataAttribute[attributeName] = PriorityTools.priorityNameUsingNormal(task.priority).toLocaleLowerCase();
-            break;
-        }
-        case 'createdDate':
-        case 'dueDate':
-        case 'startDate':
-        case 'scheduledDate':
-        case 'doneDate': {
-            const date = task[component];
-            if (date) {
-                const attributeValue = dateToAttribute(date);
-                if (attributeValue) {
-                    const attributeName = DataAttributeNames[component];
-                    dataAttribute[attributeName] = attributeValue;
-                }
-            }
-            break;
-        }
+    // If a TaskLayoutComponent needs a data attribute in the task's <span>, get the data attribute name (key) &
+    // data attribute value (value). Otherwise, just leave an empty string ('') as the value.
+    // The value is calculated based on FieldLayoutDetail.attributeValueCalculator
+    const fieldLayoutDetail = FieldLayouts[component];
+    const attributeName = fieldLayoutDetail.attributeName;
+    if (attributeName !== FieldLayoutDetail.noAttributeName) {
+        dataAttribute[attributeName] = fieldLayoutDetail.attributeValueCalculator(component, task);
     }
+
     return dataAttribute;
 }
 
@@ -410,7 +426,7 @@ function addTooltip({
     });
 }
 
-function addDateToTooltip(tooltip: HTMLDivElement, date: moment.Moment | null, signifier: string) {
+function addDateToTooltip(tooltip: HTMLDivElement, date: Moment | null, signifier: string) {
     if (date) {
         const createdDateDiv = tooltip.createDiv();
         createdDateDiv.setText(


Test patch:
diff --git a/tests/TaskLineRenderer.test.ts b/tests/TaskLineRenderer.test.ts
index 7050805d..6ca54413 100644
--- a/tests/TaskLineRenderer.test.ts
+++ b/tests/TaskLineRenderer.test.ts
@@ -2,15 +2,15 @@
  * @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 { resetSettings, updateSettings } from '../src/Config/Settings';
 import { GlobalFilter } from '../src/Config/GlobalFilter';
-import { LayoutOptions } from '../src/TaskLayout';
+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 { DateParser } from '../src/Query/DateParser';
+import { LayoutOptions } from '../src/TaskLayout';
+import type { AttributesDictionary, TextRenderer } from '../src/TaskLineRenderer';
+import { FieldLayoutDetail, FieldLayouts, renderTaskLine } from '../src/TaskLineRenderer';
 import { fromLine } from './TestHelpers';
 import { verifyWithFileExtension } from './TestingTools/ApprovalTestHelpers';
 import { TaskBuilder } from './TestingTools/TaskBuilder';
@@ -60,7 +60,7 @@ 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.classList.contains(FieldLayouts.description.className)) continue;
         if (childSpan?.textContent) components.push(childSpan.textContent);
     }
     return components;
@@ -353,19 +353,19 @@ describe('task line rendering', () => {
         await testComponentClasses(
             '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
             {},
-            LayoutClasses.priority,
+            FieldLayouts.priority.className,
             { taskPriority: 'high' },
         );
         await testComponentClasses(
             '- [ ] Full task 🔼 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
             {},
-            LayoutClasses.priority,
+            FieldLayouts.priority.className,
             { taskPriority: 'medium' },
         );
         await testComponentClasses(
             '- [ ] Full task 🔽 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
             {},
-            LayoutClasses.priority,
+            FieldLayouts.priority.className,
             { taskPriority: 'low' },
         );
     });
@@ -374,158 +374,174 @@ describe('task line rendering', () => {
         await testComponentClasses(
             '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
             {},
-            LayoutClasses.recurrenceRule,
+            FieldLayouts.recurrenceRule.className,
             {},
         );
     });
 
     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, {
+        await testComponentClasses(`- [ ] Full task ⏫ ➕ ${today}`, {}, FieldLayouts.createdDate.className, {
             taskCreated: 'today',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${today}`, {}, LayoutClasses.dueDate, { taskDue: 'today' });
-        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${today}`, {}, LayoutClasses.scheduledDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${today}`, {}, FieldLayouts.dueDate.className, {
+            taskDue: 'today',
+        });
+        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${today}`, {}, FieldLayouts.scheduledDate.className, {
             taskScheduled: 'today',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${today}`, {}, LayoutClasses.startDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${today}`, {}, FieldLayouts.startDate.className, {
             taskStart: 'today',
         });
-        await testComponentClasses(`- [x] Done task ✅ ${today}`, {}, LayoutClasses.doneDate, { taskDone: 'today' });
+        await testComponentClasses(`- [x] Done task ✅ ${today}`, {}, FieldLayouts.doneDate.className, {
+            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, {
+        await testComponentClasses(`- [ ] Full task ⏫ ➕ ${future}`, {}, FieldLayouts.createdDate.className, {
             taskCreated: 'future-1d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, LayoutClasses.dueDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, FieldLayouts.dueDate.className, {
             taskDue: 'future-1d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, LayoutClasses.scheduledDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, FieldLayouts.scheduledDate.className, {
             taskScheduled: 'future-1d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, LayoutClasses.startDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, FieldLayouts.startDate.className, {
             taskStart: 'future-1d',
         });
-        await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, LayoutClasses.doneDate, {
+        await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, FieldLayouts.doneDate.className, {
             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, {
+        await testComponentClasses(`- [ ] Full task ⏫ ➕ ${future}`, {}, FieldLayouts.createdDate.className, {
             taskCreated: 'future-7d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, LayoutClasses.dueDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, FieldLayouts.dueDate.className, {
             taskDue: 'future-7d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, LayoutClasses.scheduledDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, FieldLayouts.scheduledDate.className, {
             taskScheduled: 'future-7d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, LayoutClasses.startDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, FieldLayouts.startDate.className, {
             taskStart: 'future-7d',
         });
-        await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, LayoutClasses.doneDate, {
+        await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, FieldLayouts.doneDate.className, {
             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, {
+        await testComponentClasses(`- [ ] Full task ⏫ ➕ ${past}`, {}, FieldLayouts.createdDate.className, {
             taskCreated: 'past-1d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, LayoutClasses.dueDate, { taskDue: 'past-1d' });
-        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, LayoutClasses.scheduledDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, FieldLayouts.dueDate.className, {
+            taskDue: 'past-1d',
+        });
+        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, FieldLayouts.scheduledDate.className, {
             taskScheduled: 'past-1d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, LayoutClasses.startDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, FieldLayouts.startDate.className, {
             taskStart: 'past-1d',
         });
-        await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, LayoutClasses.doneDate, { taskDone: 'past-1d' });
+        await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, FieldLayouts.doneDate.className, {
+            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, {
+        await testComponentClasses(`- [ ] Full task ⏫ ➕ ${past}`, {}, FieldLayouts.createdDate.className, {
             taskCreated: 'past-7d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, LayoutClasses.dueDate, { taskDue: 'past-7d' });
-        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, LayoutClasses.scheduledDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, FieldLayouts.dueDate.className, {
+            taskDue: 'past-7d',
+        });
+        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, FieldLayouts.scheduledDate.className, {
             taskScheduled: 'past-7d',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, LayoutClasses.startDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, FieldLayouts.startDate.className, {
             taskStart: 'past-7d',
         });
-        await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, LayoutClasses.doneDate, { taskDone: 'past-7d' });
+        await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, FieldLayouts.doneDate.className, {
+            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, {
+        await testComponentClasses(`- [ ] Full task ⏫ ➕ ${future}`, {}, FieldLayouts.createdDate.className, {
             taskCreated: 'future-far',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, LayoutClasses.dueDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${future}`, {}, FieldLayouts.dueDate.className, {
             taskDue: 'future-far',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, LayoutClasses.scheduledDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${future}`, {}, FieldLayouts.scheduledDate.className, {
             taskScheduled: 'future-far',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, LayoutClasses.startDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${future}`, {}, FieldLayouts.startDate.className, {
             taskStart: 'future-far',
         });
-        await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, LayoutClasses.doneDate, {
+        await testComponentClasses(`- [x] Done task ✅ ${future}`, {}, FieldLayouts.doneDate.className, {
             taskDone: 'future-far',
         });
         const past = DateParser.parseDate('8 days ago').format(TaskRegularExpressions.dateFormat);
-        await testComponentClasses(`- [ ] Full task ⏫ ➕ ${past}`, {}, LayoutClasses.createdDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ ➕ ${past}`, {}, FieldLayouts.createdDate.className, {
             taskCreated: 'past-far',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, LayoutClasses.dueDate, { taskDue: 'past-far' });
-        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, LayoutClasses.scheduledDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 📅 ${past}`, {}, FieldLayouts.dueDate.className, {
+            taskDue: 'past-far',
+        });
+        await testComponentClasses(`- [ ] Full task ⏫ ⏳ ${past}`, {}, FieldLayouts.scheduledDate.className, {
             taskScheduled: 'past-far',
         });
-        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, LayoutClasses.startDate, {
+        await testComponentClasses(`- [ ] Full task ⏫ 🛫 ${past}`, {}, FieldLayouts.startDate.className, {
             taskStart: 'past-far',
         });
-        await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, LayoutClasses.doneDate, { taskDone: 'past-far' });
+        await testComponentClasses(`- [x] Done task ✅ ${past}`, {}, FieldLayouts.doneDate.className, {
+            taskDone: 'past-far',
+        });
     });
 
     it('does not add specific classes to invalid dates', async () => {
-        await testComponentClasses('- [ ] Full task ⏫ 📅 2023-02-29', {}, LayoutClasses.dueDate, {});
+        await testComponentClasses('- [ ] Full task ⏫ 📅 2023-02-29', {}, FieldLayouts.dueDate.className, {});
     });
 
     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,
+            FieldLayouts.priority.className,
             { taskPriority: 'high' },
         );
         await testHiddenComponentClasses(
             '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 ➕ 2022-07-04 🔁 every day',
             { hideCreatedDate: true },
-            LayoutClasses.createdDate,
+            FieldLayouts.createdDate.className,
             { taskCreated: 'past-far' },
         );
         await testHiddenComponentClasses(
             '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
             { hideDueDate: true },
-            LayoutClasses.dueDate,
+            FieldLayouts.dueDate.className,
             { taskDue: 'past-far' },
         );
         await testHiddenComponentClasses(
             '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
             { hideScheduledDate: true },
-            LayoutClasses.scheduledDate,
+            FieldLayouts.scheduledDate.className,
             { taskScheduled: 'past-far' },
         );
         await testHiddenComponentClasses(
             '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
             { hideStartDate: true },
-            LayoutClasses.startDate,
+            FieldLayouts.startDate.className,
             { taskStart: 'past-far' },
         );
     });
@@ -662,3 +678,21 @@ ${task.toFileLineString()}
         await renderAndVerifyHTML(minimalTask, layoutOptionsShortMode());
     });
 });
+
+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');
+    });
+});