feat: Add expandable subtasks to project task cards

Add chevron icon to project task cards that allows users to expand and view
subtasks inline with proper indentation and visual hierarchy.

Key features:
- Chevron icon positioned next to folder icon on project tasks
- Click to expand/collapse shows subtasks below with 20px indentation
- Smooth rotation animation and hover effects
- Configurable via new "Show expandable subtasks" setting (default: enabled)
- Session-persistent expanded state via ExpandedProjectsService
- Proper DOM structure with main-row wrapper for correct layout
- Subtasks render with lighter background and visual separation
- Lazy loading - subtasks only fetch when expanded
- Graceful error handling and loading states

Technical implementation:
- New ExpandedProjectsService for state management
- Enhanced TaskCard component with main-row/subtasks layout
- Comprehensive BEM CSS styling for chevron and subtask containers
- Updated settings interface with new showExpandableSubtasks option
- Integrated with existing ProjectSubtasksService for data fetching
This commit is contained in:
Callum Alpass 2025-08-03 16:01:24 +10:00
parent dbb00317b1
commit c83cf83871
5 changed files with 367 additions and 11 deletions

View file

@ -119,6 +119,7 @@ export default class TaskNotesPlugin extends Plugin {
filterService: FilterService;
viewStateManager: ViewStateManager;
projectSubtasksService: ProjectSubtasksService;
expandedProjectsService: import('./services/ExpandedProjectsService').ExpandedProjectsService;
// Editor services
taskLinkDetectionService?: import('./services/TaskLinkDetectionService').TaskLinkDetectionService;
@ -191,6 +192,7 @@ export default class TaskNotesPlugin extends Plugin {
);
this.viewStateManager = new ViewStateManager(this.app, this);
this.projectSubtasksService = new ProjectSubtasksService(this);
this.expandedProjectsService = new (await import('./services/ExpandedProjectsService')).ExpandedProjectsService(this);
this.dragDropManager = new DragDropManager(this);
this.migrationService = new MigrationService(this.app);
this.statusBarService = new StatusBarService(this);

View file

@ -0,0 +1,62 @@
import TaskNotesPlugin from '../main';
export class ExpandedProjectsService {
private plugin: TaskNotesPlugin;
private expandedProjects: Set<string> = new Set();
constructor(plugin: TaskNotesPlugin) {
this.plugin = plugin;
}
/**
* Check if a project task is currently expanded
*/
isExpanded(taskPath: string): boolean {
return this.expandedProjects.has(taskPath);
}
/**
* Toggle the expanded state of a project task
*/
toggle(taskPath: string): boolean {
if (this.expandedProjects.has(taskPath)) {
this.expandedProjects.delete(taskPath);
return false;
} else {
this.expandedProjects.add(taskPath);
return true;
}
}
/**
* Set the expanded state of a project task
*/
setExpanded(taskPath: string, expanded: boolean): void {
if (expanded) {
this.expandedProjects.add(taskPath);
} else {
this.expandedProjects.delete(taskPath);
}
}
/**
* Get all currently expanded project paths
*/
getExpandedProjects(): string[] {
return Array.from(this.expandedProjects);
}
/**
* Clear all expanded states
*/
clearAll(): void {
this.expandedProjects.clear();
}
/**
* Collapse all expanded projects
*/
collapseAll(): void {
this.clearAll();
}
}

View file

@ -56,6 +56,7 @@ export interface TaskNotesSettings {
autoStopTimeTrackingNotification: boolean;
// Project subtasks widget settings
showProjectSubtasks: boolean;
showExpandableSubtasks: boolean;
// Overdue behavior settings
hideCompletedFromOverdue: boolean;
// ICS integration settings
@ -303,6 +304,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = {
autoStopTimeTrackingNotification: false,
// Project subtasks widget defaults
showProjectSubtasks: true,
showExpandableSubtasks: true,
// Overdue behavior defaults
hideCompletedFromOverdue: true,
// ICS integration defaults
@ -1538,6 +1540,22 @@ export class TaskNotesSettingTab extends PluginSettingTab {
this.plugin.notifyDataChanged();
});
});
// Expandable subtasks in task cards
new Setting(container)
.setName('Show expandable subtasks in task cards')
.setDesc('Add a chevron icon to project task cards that allows expanding to view subtasks inline')
.addToggle(toggle => {
toggle.toggleEl.setAttribute('aria-label', 'Show expandable subtasks in task cards');
return toggle
.setValue(this.plugin.settings.showExpandableSubtasks)
.onChange(async (value) => {
this.plugin.settings.showExpandableSubtasks = value;
await this.plugin.saveSettings();
// Refresh task views to apply the change
this.plugin.notifyDataChanged();
});
});
// Hide completed tasks from overdue
new Setting(container)
.setName('Hide completed tasks from overdue')

View file

@ -113,6 +113,9 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options:
card.dataset.key = task.path; // For DOMReconciler compatibility
card.dataset.status = effectiveStatus;
// Create main row container for horizontal layout
const mainRow = card.createEl('div', { cls: 'task-card__main-row' });
// Apply priority and status colors as CSS custom properties
const priorityConfig = plugin.priorityManager.getPriorityConfig(task.priority);
if (priorityConfig) {
@ -126,7 +129,7 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options:
// Completion checkbox (if enabled)
if (opts.showCheckbox) {
const checkbox = card.createEl('input', {
const checkbox = mainRow.createEl('input', {
type: 'checkbox',
cls: 'task-card__checkbox'
});
@ -152,7 +155,7 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options:
}
// Status indicator dot
const statusDot = card.createEl('span', { cls: 'task-card__status-dot' });
const statusDot = mainRow.createEl('span', { cls: 'task-card__status-dot' });
if (statusConfig) {
statusDot.style.borderColor = statusConfig.color;
}
@ -219,7 +222,7 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options:
// Priority indicator dot
if (task.priority && priorityConfig) {
const priorityDot = card.createEl('span', {
const priorityDot = mainRow.createEl('span', {
cls: 'task-card__priority-dot',
attr: { 'aria-label': `Priority: ${priorityConfig.label}` }
});
@ -246,7 +249,7 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options:
// Recurring task indicator
if (task.recurrence) {
const recurringIndicator = card.createEl('div', {
const recurringIndicator = mainRow.createEl('div', {
cls: 'task-card__recurring-indicator',
attr: {
'aria-label': `Recurring: ${getRecurrenceDisplayText(task.recurrence)} (click to change)`
@ -278,11 +281,17 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options:
// Project indicator (if task is used as a project)
// Create placeholder that will be updated asynchronously
const projectIndicatorPlaceholder = card.createEl('div', {
const projectIndicatorPlaceholder = mainRow.createEl('div', {
cls: 'task-card__project-indicator-placeholder',
attr: { style: 'display: none;' }
});
// Chevron for expandable subtasks (if feature is enabled)
const chevronPlaceholder = mainRow.createEl('div', {
cls: 'task-card__chevron-placeholder',
attr: { style: 'display: none;' }
});
plugin.projectSubtasksService.isTaskUsedAsProject(task.path).then((isProject: boolean) => {
if (isProject) {
projectIndicatorPlaceholder.className = 'task-card__project-indicator';
@ -303,19 +312,62 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options:
new Notice('Failed to filter project subtasks');
}
});
// Add chevron for expandable subtasks if feature is enabled
if (plugin.settings.showExpandableSubtasks) {
chevronPlaceholder.className = 'task-card__chevron';
chevronPlaceholder.removeAttribute('style');
const isExpanded = plugin.expandedProjectsService.isExpanded(task.path);
if (isExpanded) {
chevronPlaceholder.classList.add('task-card__chevron--expanded');
}
chevronPlaceholder.setAttribute('aria-label', isExpanded ? 'Collapse subtasks' : 'Expand subtasks');
setTooltip(chevronPlaceholder, isExpanded ? 'Collapse subtasks' : 'Expand subtasks', { placement: 'top' });
// Use Obsidian's built-in chevron-right icon
setIcon(chevronPlaceholder, 'chevron-right');
// Add click handler to toggle expansion
chevronPlaceholder.addEventListener('click', async (e) => {
e.stopPropagation(); // Don't trigger card click
try {
const newExpanded = plugin.expandedProjectsService.toggle(task.path);
chevronPlaceholder.classList.toggle('task-card__chevron--expanded', newExpanded);
chevronPlaceholder.setAttribute('aria-label', newExpanded ? 'Collapse subtasks' : 'Expand subtasks');
setTooltip(chevronPlaceholder, newExpanded ? 'Collapse subtasks' : 'Expand subtasks', { placement: 'top' });
// Toggle subtasks display
await toggleSubtasks(card, task, plugin, newExpanded);
} catch (error) {
console.error('Error toggling subtasks:', error);
new Notice('Failed to toggle subtasks');
}
});
// If already expanded, show subtasks
if (isExpanded) {
toggleSubtasks(card, task, plugin, true).catch(error => {
console.error('Error showing initial subtasks:', error);
});
}
}
} else {
projectIndicatorPlaceholder.remove();
chevronPlaceholder.remove();
}
}).catch((error: any) => {
console.error('Error checking if task is used as project:', error);
projectIndicatorPlaceholder.remove();
chevronPlaceholder.remove();
});
// Main content container
const contentContainer = card.createEl('div', { cls: 'task-card__content' });
const contentContainer = mainRow.createEl('div', { cls: 'task-card__content' });
// Context menu icon (appears on hover)
const contextIcon = card.createEl('div', {
const contextIcon = mainRow.createEl('div', {
cls: 'task-card__context-menu',
attr: {
'aria-label': 'Task options'
@ -813,6 +865,9 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas
element.className = cardClasses.join(' ');
element.dataset.status = effectiveStatus;
// Get the main row container
const mainRow = element.querySelector('.task-card__main-row') as HTMLElement;
// Update priority and status colors
const priorityConfig = plugin.priorityManager.getPriorityConfig(task.priority);
if (priorityConfig) {
@ -840,7 +895,7 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas
const existingPriorityDot = element.querySelector('.task-card__priority-dot') as HTMLElement;
if (task.priority && priorityConfig && !existingPriorityDot) {
// Add priority dot if task has priority but no dot exists
const priorityDot = element.createEl('span', {
const priorityDot = mainRow.createEl('span', {
cls: 'task-card__priority-dot',
attr: { 'aria-label': `Priority: ${priorityConfig.label}` }
});
@ -859,7 +914,7 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas
const existingRecurringIndicator = element.querySelector('.task-card__recurring-indicator');
if (task.recurrence && !existingRecurringIndicator) {
// Add recurring indicator if task is now recurring but didn't have one
const recurringIndicator = element.createEl('span', {
const recurringIndicator = mainRow.createEl('span', {
cls: 'task-card__recurring-indicator',
attr: { 'aria-label': `Recurring: ${getRecurrenceDisplayText(task.recurrence)}` }
});
@ -878,9 +933,10 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas
const existingPlaceholder = element.querySelector('.task-card__project-indicator-placeholder');
plugin.projectSubtasksService.isTaskUsedAsProject(task.path).then((isProject: boolean) => {
// Update project indicator
if (isProject && !existingProjectIndicator && !existingPlaceholder) {
// Add project indicator if task is now used as a project but didn't have one
const projectIndicator = element.createEl('div', {
const projectIndicator = mainRow.createEl('div', {
cls: 'task-card__project-indicator',
attr: {
'aria-label': 'This task is used as a project (click to filter subtasks)',
@ -910,6 +966,67 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas
existingProjectIndicator?.remove();
existingPlaceholder?.remove();
}
// Update chevron for expandable subtasks
const existingChevron = element.querySelector('.task-card__chevron') as HTMLElement;
const existingChevronPlaceholder = element.querySelector('.task-card__chevron-placeholder');
if (isProject && plugin.settings.showExpandableSubtasks && !existingChevron && !existingChevronPlaceholder) {
// Add chevron if task is now used as a project and feature is enabled
const chevron = mainRow.createEl('div', {
cls: 'task-card__chevron',
attr: {
'aria-label': 'Expand subtasks',
'title': 'Expand subtasks'
}
});
const isExpanded = plugin.expandedProjectsService.isExpanded(task.path);
if (isExpanded) {
chevron.classList.add('task-card__chevron--expanded');
chevron.setAttribute('aria-label', 'Collapse subtasks');
setTooltip(chevron, 'Collapse subtasks', { placement: 'top' });
} else {
setTooltip(chevron, 'Expand subtasks', { placement: 'top' });
}
setIcon(chevron, 'chevron-right');
// Add click handler to toggle expansion
chevron.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const newExpanded = plugin.expandedProjectsService.toggle(task.path);
chevron.classList.toggle('task-card__chevron--expanded', newExpanded);
chevron.setAttribute('aria-label', newExpanded ? 'Collapse subtasks' : 'Expand subtasks');
setTooltip(chevron, newExpanded ? 'Collapse subtasks' : 'Expand subtasks', { placement: 'top' });
// Toggle subtasks display
await toggleSubtasks(element, task, plugin, newExpanded);
} catch (error) {
console.error('Error toggling subtasks:', error);
new Notice('Failed to toggle subtasks');
}
});
// Insert after project indicator
const projectIndicator = element.querySelector('.task-card__project-indicator');
projectIndicator?.insertAdjacentElement('afterend', chevron);
// If already expanded, show subtasks
if (isExpanded) {
toggleSubtasks(element, task, plugin, true).catch(error => {
console.error('Error showing initial subtasks in update:', error);
});
}
} else if ((!isProject || !plugin.settings.showExpandableSubtasks) && (existingChevron || existingChevronPlaceholder)) {
// Remove chevron if task is no longer used as a project or feature is disabled
existingChevron?.remove();
existingChevronPlaceholder?.remove();
// Also remove any existing subtasks container
const subtasksContainer = element.querySelector('.task-card__subtasks');
subtasksContainer?.remove();
}
}).catch((error: any) => {
console.error('Error checking if task is used as project in update:', error);
});
@ -1260,3 +1377,84 @@ function renderProjectLinks(container: HTMLElement, projects: string[], plugin:
}
});
}
/**
* Toggle subtasks display for a project task card
*/
async function toggleSubtasks(card: HTMLElement, task: TaskInfo, plugin: TaskNotesPlugin, expanded: boolean): Promise<void> {
let subtasksContainer = card.querySelector('.task-card__subtasks') as HTMLElement;
if (expanded) {
// Show subtasks
if (!subtasksContainer) {
// Create subtasks container after the main content
subtasksContainer = document.createElement('div');
subtasksContainer.className = 'task-card__subtasks';
card.appendChild(subtasksContainer);
}
// Show loading state
subtasksContainer.innerHTML = '';
const loadingEl = subtasksContainer.createEl('div', {
cls: 'task-card__subtasks-loading',
text: 'Loading subtasks...'
});
try {
// Get the file for this task
const file = plugin.app.vault.getAbstractFileByPath(task.path);
if (!(file instanceof TFile)) {
throw new Error('Task file not found');
}
// Get subtasks
let subtasks = await plugin.projectSubtasksService.getTasksLinkedToProject(file);
// Apply current filter to subtasks if available
// For now, we'll show all subtasks to keep the implementation simple
// Future enhancement: Apply the current view's filter to subtasks
// This could be implemented by accessing the FilterService's evaluateFilterNode method
// Remove loading indicator
loadingEl.remove();
if (subtasks.length === 0) {
subtasksContainer.createEl('div', {
cls: 'task-card__subtasks-loading',
text: 'No subtasks found'
});
return;
}
// Sort subtasks
const sortedSubtasks = plugin.projectSubtasksService.sortTasks(subtasks);
// Render each subtask
for (const subtask of sortedSubtasks) {
const subtaskCard = createTaskCard(subtask, plugin, {
showDueDate: true,
showCheckbox: false,
showArchiveButton: false,
showTimeTracking: false,
showRecurringControls: true,
groupByDate: false
});
// Add subtask modifier class
subtaskCard.classList.add('task-card--subtask');
subtasksContainer.appendChild(subtaskCard);
}
} catch (error) {
console.error('Error loading subtasks:', error);
loadingEl.textContent = 'Failed to load subtasks';
}
} else {
// Hide subtasks
if (subtasksContainer) {
subtasksContainer.remove();
}
}
}

View file

@ -7,7 +7,7 @@
.tasknotes-plugin .task-card {
/* Layout & Structure */
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 0;
position: relative;
@ -58,6 +58,13 @@
TASKCARD ELEMENTS (BEM __element)
================================================================= */
.tasknotes-plugin .task-card__main-row {
display: flex;
align-items: flex-start;
gap: 0;
width: 100%;
}
.tasknotes-plugin .task-card__content {
flex: 1;
display: flex;
@ -276,6 +283,75 @@
height: 100%;
}
.tasknotes-plugin .task-card__chevron {
position: absolute;
top: 8px;
right: 62px; /* Position after project indicator */
width: 16px;
height: 16px;
color: var(--tn-text-muted);
opacity: 0.8;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-s);
padding: 2px;
cursor: pointer;
transition: all var(--tn-transition-fast);
}
.tasknotes-plugin .task-card__chevron:hover {
opacity: 1;
color: var(--interactive-accent);
background: var(--background-modifier-hover);
transform: scale(1.1);
}
.tasknotes-plugin .task-card__chevron svg {
width: 100%;
height: 100%;
transition: transform var(--tn-transition-fast);
}
.tasknotes-plugin .task-card__chevron--expanded svg {
transform: rotate(90deg);
}
.tasknotes-plugin .task-card__subtasks {
margin-left: 20px;
margin-top: 4px;
border-left: 2px solid var(--tn-border-color);
padding-left: 12px;
}
.tasknotes-plugin .task-card__subtasks .task-card {
margin-bottom: 2px;
padding: var(--tn-spacing-sm) var(--tn-spacing-md);
}
.tasknotes-plugin .task-card__subtasks .task-card:last-child {
margin-bottom: 0;
}
.tasknotes-plugin .task-card--subtask {
background: color-mix(in srgb, var(--tn-bg-secondary) 30%, transparent);
border-radius: var(--tn-radius-xs);
}
.tasknotes-plugin .task-card--subtask:hover {
background: color-mix(in srgb, var(--tn-interactive-hover) 70%, var(--tn-bg-secondary));
}
.tasknotes-plugin .task-card__subtasks-loading {
margin-left: 20px;
margin-top: 4px;
padding: var(--tn-spacing-sm);
color: var(--tn-text-muted);
font-size: var(--tn-font-size-sm);
font-style: italic;
}
.tasknotes-plugin .task-card__context-menu {
position: absolute;
top: 8px;