feat: add collapsible groups to subtask widget

- Extract reusable GroupingUtils for shared group functionality
- Implement collapsible group headers with toggle buttons in subtask widget
- Add state persistence using SUBTASK_WIDGET_VIEW_TYPE identifier
- Wire expand/collapse all functionality via FilterBar events
- Apply consistent CSS styling matching TaskListView groups
- Refactor TaskListView to use GroupingUtils for consistency

Ensures subtask widget groups behave identically to TaskListView with
proper state persistence, accessibility, and visual consistency.
This commit is contained in:
renatomen 2025-08-16 13:45:35 +00:00
parent a1abe71a8c
commit edf29a252e
7 changed files with 424 additions and 85 deletions

1
.gitignore vendored
View file

@ -52,3 +52,4 @@ external/tasknotes-e2e/.e2e/
C/
.copy-files.local
.augment

View file

@ -1,12 +1,13 @@
import { Decoration, DecorationSet, EditorView, PluginSpec, PluginValue, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view';
import { Extension, RangeSetBuilder, StateEffect } from '@codemirror/state';
import { TFile, editorLivePreviewField, editorInfoField, EventRef } from 'obsidian';
import { TFile, editorLivePreviewField, editorInfoField, EventRef, setIcon } from 'obsidian';
import TaskNotesPlugin from '../main';
import { TaskInfo, EVENT_DATA_CHANGED, EVENT_TASK_UPDATED, EVENT_TASK_DELETED, FilterQuery } from '../types';
import { TaskInfo, EVENT_DATA_CHANGED, EVENT_TASK_UPDATED, EVENT_TASK_DELETED, FilterQuery, SUBTASK_WIDGET_VIEW_TYPE } from '../types';
import { createTaskCard } from '../ui/TaskCard';
import { ProjectSubtasksService } from '../services/ProjectSubtasksService';
import { FilterBar } from '../ui/FilterBar';
import { FilterService } from '../services/FilterService';
import { GroupingUtils } from '../utils/GroupingUtils';
// Define a state effect for project subtasks updates
const projectSubtasksUpdateEffect = StateEffect.define<{ forceUpdate?: boolean }>();
@ -23,7 +24,7 @@ class ProjectSubtasksWidget extends WidgetType {
constructor(private plugin: TaskNotesPlugin, private tasks: TaskInfo[], private notePath: string, private version: number = 0) {
super();
// Create note-specific view type identifier
this.viewType = `project-subtasks:${notePath}`;
this.viewType = `${SUBTASK_WIDGET_VIEW_TYPE}:${notePath}`;
// Initialize with ungrouped tasks
this.groupedTasks.set('all', [...tasks]);
@ -218,7 +219,38 @@ class ProjectSubtasksWidget extends WidgetType {
this.filterBar.updateSavedViews(updatedViews);
}
});
// Wire expand/collapse all functionality
this.filterBar.on('expandAllGroups', () => {
const key = this.currentQuery.groupKey || 'none';
GroupingUtils.expandAllGroups(this.viewType, key, this.plugin);
// Update DOM
if (this.taskListContainer) {
this.taskListContainer.querySelectorAll('.task-group').forEach(section => {
section.classList.remove('is-collapsed');
const list = (section as HTMLElement).querySelector('.task-cards') as HTMLElement | null;
if (list) list.style.display = '';
});
}
});
this.filterBar.on('collapseAllGroups', () => {
const key = this.currentQuery.groupKey || 'none';
const groupNames: string[] = [];
if (this.taskListContainer) {
this.taskListContainer.querySelectorAll('.task-group').forEach(section => {
const name = (section as HTMLElement).dataset.group;
if (name) {
groupNames.push(name);
section.classList.add('is-collapsed');
const list = (section as HTMLElement).querySelector('.task-cards') as HTMLElement | null;
if (list) list.style.display = 'none';
}
});
}
GroupingUtils.collapseAllGroups(this.viewType, key, groupNames, this.plugin);
});
} catch (error) {
console.error('Error initializing filter bar for subtasks:', error);
}
@ -282,25 +314,85 @@ class ProjectSubtasksWidget extends WidgetType {
taskListContainer.appendChild(taskCard);
});
} else {
// Render grouped tasks with group headers
// Render grouped tasks with collapsible group headers
for (const [groupKey, tasks] of this.groupedTasks.entries()) {
if (tasks.length === 0) continue;
// Create group header
const groupHeader = taskListContainer.createEl('div', {
cls: 'project-note-subtasks__group-header'
// Create group section
const groupSection = taskListContainer.createEl('div', {
cls: 'project-note-subtasks__group-section task-group'
});
groupSection.setAttribute('data-group', groupKey);
const groupingKey = this.currentQuery.groupKey || 'none';
const collapsedInitially = GroupingUtils.isGroupCollapsed(this.viewType, groupingKey, groupKey, this.plugin);
// Create group header with toggle functionality
const groupHeader = groupSection.createEl('div', {
cls: 'project-note-subtasks__group-header task-group-header'
});
// Create toggle button first
const toggleBtn = groupHeader.createEl('button', {
cls: 'task-group-toggle',
attr: { 'aria-label': 'Toggle group' }
});
try {
setIcon(toggleBtn, 'chevron-right');
} catch (_) {}
const svg = toggleBtn.querySelector('svg');
if (svg) {
svg.classList.add('chevron');
svg.setAttr('width', '16');
svg.setAttr('height', '16');
} else {
toggleBtn.textContent = '▸';
toggleBtn.addClass('chevron-text');
}
// Add group title
groupHeader.createEl('h4', {
cls: 'project-note-subtasks__group-title',
text: this.getGroupDisplayName(groupKey, tasks.length)
text: GroupingUtils.getGroupDisplayName(groupKey, tasks.length, this.plugin)
});
// Create group container
const groupContainer = taskListContainer.createEl('div', {
cls: 'project-note-subtasks__group'
const groupContainer = groupSection.createEl('div', {
cls: 'project-note-subtasks__group task-cards'
});
// Apply initial collapsed state
if (collapsedInitially) {
groupSection.classList.add('is-collapsed');
groupContainer.style.display = 'none';
}
// Add click handlers for expand/collapse
groupHeader.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('a')) return; // Ignore link clicks
const willCollapse = !groupSection.classList.contains('is-collapsed');
GroupingUtils.setGroupCollapsed(this.viewType, groupingKey, groupKey, willCollapse, this.plugin);
groupSection.classList.toggle('is-collapsed', willCollapse);
groupContainer.style.display = willCollapse ? 'none' : '';
toggleBtn.setAttribute('aria-expanded', String(!willCollapse));
});
toggleBtn.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const willCollapse = !groupSection.classList.contains('is-collapsed');
GroupingUtils.setGroupCollapsed(this.viewType, groupingKey, groupKey, willCollapse, this.plugin);
groupSection.classList.toggle('is-collapsed', willCollapse);
groupContainer.style.display = willCollapse ? 'none' : '';
toggleBtn.setAttribute('aria-expanded', String(!willCollapse));
});
// Set initial ARIA state
toggleBtn.setAttribute('aria-expanded', String(!collapsedInitially));
// Render tasks in this group
tasks.forEach(task => {
const taskCard = createTaskCard(task, this.plugin, {
@ -311,7 +403,7 @@ class ProjectSubtasksWidget extends WidgetType {
showRecurringControls: true,
groupByDate: false
});
taskCard.classList.add('project-note-subtasks__task');
groupContainer.appendChild(taskCard);
});
@ -326,26 +418,7 @@ class ProjectSubtasksWidget extends WidgetType {
}
private getGroupDisplayName(groupKey: string, taskCount: number): string {
// Handle different group types with user-friendly names
switch (groupKey) {
case 'none':
case 'all':
return `All Tasks (${taskCount})`;
case 'No Status':
return `No Status (${taskCount})`;
case 'No Priority':
return `No Priority (${taskCount})`;
case 'No Context':
return `No Context (${taskCount})`;
case 'No Project':
return `No Project (${taskCount})`;
case 'No Due Date':
return `No Due Date (${taskCount})`;
case 'No Scheduled Date':
return `No Scheduled Date (${taskCount})`;
default:
return `${groupKey} (${taskCount})`;
}
return GroupingUtils.getGroupDisplayName(groupKey, taskCount, this.plugin);
}
private createNewSubtask(): void {

View file

@ -7,6 +7,7 @@ export const AGENDA_VIEW_TYPE = 'tasknotes-agenda-view';
export const POMODORO_VIEW_TYPE = 'tasknotes-pomodoro-view';
export const POMODORO_STATS_VIEW_TYPE = 'tasknotes-pomodoro-stats-view';
export const KANBAN_VIEW_TYPE = 'tasknotes-kanban-view';
export const SUBTASK_WIDGET_VIEW_TYPE = 'tasknotes-subtask-widget-view';
// Event types
export const EVENT_DATE_SELECTED = 'date-selected';

103
src/utils/GroupingUtils.ts Normal file
View file

@ -0,0 +1,103 @@
import TaskNotesPlugin from '../main';
/**
* Utility functions for task grouping functionality
* Shared between TaskListView and subtask widget
*/
export class GroupingUtils {
/**
* Format group name for display with proper labels
*/
static formatGroupName(groupName: string, plugin: TaskNotesPlugin): string {
// Check if it's a priority value
const priorityConfig = plugin.priorityManager.getPriorityConfig(groupName);
if (priorityConfig) {
return `${priorityConfig.label} priority`;
}
// Check if it's a status value
const statusConfig = plugin.statusManager.getStatusConfig(groupName);
if (statusConfig) {
return statusConfig.label;
}
switch (groupName) {
case 'all':
return 'All tasks';
case 'no-status':
return 'No status assigned';
case 'No Status':
return 'No Status';
case 'No Priority':
return 'No Priority';
case 'No Context':
return 'No Context';
case 'No Project':
return 'No Project';
case 'No Due Date':
return 'No Due Date';
case 'No Scheduled Date':
return 'No Scheduled Date';
default:
return groupName;
}
}
/**
* Get group display name with task count
*/
static getGroupDisplayName(groupKey: string, taskCount: number, plugin: TaskNotesPlugin): string {
const formattedName = GroupingUtils.formatGroupName(groupKey, plugin);
return `${formattedName} (${taskCount})`;
}
/**
* Check if a group should be collapsed initially
*/
static isGroupCollapsed(viewType: string, groupingKey: string, groupName: string, plugin: TaskNotesPlugin): boolean {
try {
const prefs = plugin.viewStateManager.getViewPreferences<any>(viewType) || {};
const collapsed = prefs.collapsedGroups || {};
return !!collapsed?.[groupingKey]?.[groupName];
} catch {
return false;
}
}
/**
* Set the collapsed state for a group
*/
static setGroupCollapsed(viewType: string, groupingKey: string, groupName: string, collapsed: boolean, plugin: TaskNotesPlugin): void {
const prefs = plugin.viewStateManager.getViewPreferences<any>(viewType) || {};
const next = { ...prefs };
if (!next.collapsedGroups) next.collapsedGroups = {};
if (!next.collapsedGroups[groupingKey]) next.collapsedGroups[groupingKey] = {};
next.collapsedGroups[groupingKey][groupName] = collapsed;
plugin.viewStateManager.setViewPreferences(viewType, next);
}
/**
* Expand all groups for a specific view and grouping key
*/
static expandAllGroups(viewType: string, groupingKey: string, plugin: TaskNotesPlugin): void {
const prefs = plugin.viewStateManager.getViewPreferences<any>(viewType) || {};
const next = { ...(prefs.collapsedGroups || {}) } as Record<string, Record<string, boolean>>;
next[groupingKey] = {};
plugin.viewStateManager.setViewPreferences(viewType, { ...prefs, collapsedGroups: next });
}
/**
* Collapse all groups for a specific view and grouping key
*/
static collapseAllGroups(viewType: string, groupingKey: string, groupNames: string[], plugin: TaskNotesPlugin): void {
const prefs = plugin.viewStateManager.getViewPreferences<any>(viewType) || {};
const next = { ...(prefs.collapsedGroups || {}) } as Record<string, Record<string, boolean>>;
const collapsed: Record<string, boolean> = {};
groupNames.forEach(name => {
collapsed[name] = true;
});
next[groupingKey] = collapsed;
plugin.viewStateManager.setViewPreferences(viewType, { ...prefs, collapsedGroups: next });
}
}

View file

@ -1,8 +1,8 @@
import { TFile, ItemView, WorkspaceLeaf, EventRef, Notice, setIcon } from 'obsidian';
import TaskNotesPlugin from '../main';
import {
TASK_LIST_VIEW_TYPE,
TaskInfo,
import {
TASK_LIST_VIEW_TYPE,
TaskInfo,
EVENT_DATA_CHANGED,
EVENT_TASK_UPDATED,
FilterQuery,
@ -12,6 +12,7 @@ import {
import { perfMonitor } from '../utils/PerformanceMonitor';
import { createTaskCard, updateTaskCard, refreshParentTaskSubtasks } from '../ui/TaskCard';
import { FilterBar } from '../ui/FilterBar';
import { GroupingUtils } from '../utils/GroupingUtils';
export class TaskListView extends ItemView {
plugin: TaskNotesPlugin;
@ -270,10 +271,7 @@ export class TaskListView extends ItemView {
// Wire expand/collapse all (as in preview-all)
this.filterBar.on('expandAllGroups', () => {
const key = this.currentQuery.groupKey || 'none';
const prefs = this.plugin.viewStateManager.getViewPreferences<any>(TASK_LIST_VIEW_TYPE) || {};
const next = { ...(prefs.collapsedGroups || {}) } as Record<string, Record<string, boolean>>;
next[key] = {};
this.plugin.viewStateManager.setViewPreferences(TASK_LIST_VIEW_TYPE, { ...prefs, collapsedGroups: next });
GroupingUtils.expandAllGroups(TASK_LIST_VIEW_TYPE, key, this.plugin);
// Update DOM
this.contentEl.querySelectorAll('.task-group').forEach(section => {
section.classList.remove('is-collapsed');
@ -283,18 +281,17 @@ export class TaskListView extends ItemView {
});
this.filterBar.on('collapseAllGroups', () => {
const key = this.currentQuery.groupKey || 'none';
const prefs = this.plugin.viewStateManager.getViewPreferences<any>(TASK_LIST_VIEW_TYPE) || {};
const next = { ...(prefs.collapsedGroups || {}) } as Record<string, Record<string, boolean>>;
const collapsed: Record<string, boolean> = {};
const groupNames: string[] = [];
this.contentEl.querySelectorAll('.task-group').forEach(section => {
const name = (section as HTMLElement).dataset.group;
if (name) collapsed[name] = true;
section.classList.add('is-collapsed');
const list = (section as HTMLElement).querySelector('.task-cards') as HTMLElement | null;
if (list) list.style.display = 'none';
if (name) {
groupNames.push(name);
section.classList.add('is-collapsed');
const list = (section as HTMLElement).querySelector('.task-cards') as HTMLElement | null;
if (list) list.style.display = 'none';
}
});
next[key] = collapsed;
this.plugin.viewStateManager.setViewPreferences(TASK_LIST_VIEW_TYPE, { ...prefs, collapsedGroups: next });
GroupingUtils.collapseAllGroups(TASK_LIST_VIEW_TYPE, key, groupNames, this.plugin);
});
// Get saved views for the FilterBar
@ -566,22 +563,11 @@ export class TaskListView extends ItemView {
// Persist and restore collapsed state per grouping key and group name
private isGroupCollapsed(groupingKey: string, groupName: string): boolean {
try {
const prefs = this.plugin.viewStateManager.getViewPreferences<any>(TASK_LIST_VIEW_TYPE) || {};
const collapsed = prefs.collapsedGroups || {};
return !!collapsed?.[groupingKey]?.[groupName];
} catch {
return false;
}
return GroupingUtils.isGroupCollapsed(TASK_LIST_VIEW_TYPE, groupingKey, groupName, this.plugin);
}
private setGroupCollapsed(groupingKey: string, groupName: string, collapsed: boolean): void {
const prefs = this.plugin.viewStateManager.getViewPreferences<any>(TASK_LIST_VIEW_TYPE) || {};
const next = { ...prefs };
if (!next.collapsedGroups) next.collapsedGroups = {};
if (!next.collapsedGroups[groupingKey]) next.collapsedGroups[groupingKey] = {};
next.collapsedGroups[groupingKey][groupName] = collapsed;
this.plugin.viewStateManager.setViewPreferences(TASK_LIST_VIEW_TYPE, next);
GroupingUtils.setGroupCollapsed(TASK_LIST_VIEW_TYPE, groupingKey, groupName, collapsed, this.plugin);
}
/**
@ -650,26 +636,7 @@ export class TaskListView extends ItemView {
* Format group name for display
*/
private formatGroupName(groupName: string): string {
// Check if it's a priority value
const priorityConfig = this.plugin.priorityManager.getPriorityConfig(groupName);
if (priorityConfig) {
return `${priorityConfig.label} priority`;
}
// Check if it's a status value
const statusConfig = this.plugin.statusManager.getStatusConfig(groupName);
if (statusConfig) {
return statusConfig.label;
}
switch (groupName) {
case 'all':
return 'All tasks';
case 'no-status':
return 'No status assigned';
default:
return groupName;
}
return GroupingUtils.formatGroupName(groupName, this.plugin);
}

View file

@ -203,6 +203,11 @@
.tasknotes-plugin .project-note-subtasks__group-header {
margin: 1em 0 0.5em 0;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--tn-spacing-xs, 0.5em);
}
.tasknotes-plugin .project-note-subtasks__group-header:first-child {
@ -218,6 +223,7 @@
margin: 0;
padding: 0.3em 0;
border-bottom: 1px solid var(--background-modifier-border);
flex: 1;
}
.tasknotes-plugin .project-note-subtasks__group {
@ -227,6 +233,55 @@
margin-bottom: 0.5em;
}
/* Collapsible group functionality */
.tasknotes-plugin .project-note-subtasks__group-section {
margin-bottom: 1em;
}
.tasknotes-plugin .project-note-subtasks__group-section.is-collapsed .task-cards {
display: none;
}
/* Toggle button styles (match TaskListView) */
.tasknotes-plugin .task-group-toggle {
background: none;
border: none;
padding: 0.2em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-s);
transition: all 0.2s ease;
}
.tasknotes-plugin .task-group-toggle:hover {
background-color: var(--background-modifier-hover);
}
.tasknotes-plugin .task-group-toggle svg,
.tasknotes-plugin .task-group-toggle .chevron,
.tasknotes-plugin .task-group-toggle .chevron path {
color: var(--text-normal);
transition: transform 0.2s ease;
}
.tasknotes-plugin .task-group-toggle svg,
.tasknotes-plugin .task-group-toggle .chevron path {
stroke: currentColor;
}
/* Chevron rotation for expanded state */
.tasknotes-plugin .task-group:not(.is-collapsed) .task-group-toggle svg,
.tasknotes-plugin .task-group:not(.is-collapsed) .task-group-toggle .chevron {
transform: rotate(90deg);
}
.tasknotes-plugin .task-group.is-collapsed .task-group-toggle svg,
.tasknotes-plugin .task-group.is-collapsed .task-group-toggle .chevron {
transform: rotate(0deg);
}
.tasknotes-plugin .project-note-subtasks__task {
/* Task card wrapper */
margin-bottom: 0.3em;

View file

@ -0,0 +1,139 @@
import { GroupingUtils } from '../../../src/utils/GroupingUtils';
import { TASK_LIST_VIEW_TYPE, SUBTASK_WIDGET_VIEW_TYPE } from '../../../src/types';
// Mock TaskNotesPlugin
const mockPlugin = {
priorityManager: {
getPriorityConfig: jest.fn((priority: string) => {
if (priority === 'high') return { label: 'High' };
if (priority === 'medium') return { label: 'Medium' };
return null;
})
},
statusManager: {
getStatusConfig: jest.fn((status: string) => {
if (status === 'todo') return { label: 'To Do' };
if (status === 'done') return { label: 'Done' };
return null;
})
},
viewStateManager: {
getViewPreferences: jest.fn(() => ({})),
setViewPreferences: jest.fn()
}
} as any;
describe('GroupingUtils', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('formatGroupName', () => {
it('should format priority group names', () => {
const result = GroupingUtils.formatGroupName('high', mockPlugin);
expect(result).toBe('High priority');
});
it('should format status group names', () => {
const result = GroupingUtils.formatGroupName('todo', mockPlugin);
expect(result).toBe('To Do');
});
it('should handle special group names', () => {
expect(GroupingUtils.formatGroupName('all', mockPlugin)).toBe('All tasks');
expect(GroupingUtils.formatGroupName('no-status', mockPlugin)).toBe('No status assigned');
expect(GroupingUtils.formatGroupName('No Status', mockPlugin)).toBe('No Status');
});
it('should return original name for unknown groups', () => {
const result = GroupingUtils.formatGroupName('custom-group', mockPlugin);
expect(result).toBe('custom-group');
});
});
describe('getGroupDisplayName', () => {
it('should include task count in display name', () => {
const result = GroupingUtils.getGroupDisplayName('high', 5, mockPlugin);
expect(result).toBe('High priority (5)');
});
});
describe('group collapse state management', () => {
it('should check if group is collapsed', () => {
mockPlugin.viewStateManager.getViewPreferences.mockReturnValue({
collapsedGroups: {
'status': {
'todo': true
}
}
});
const result = GroupingUtils.isGroupCollapsed(TASK_LIST_VIEW_TYPE, 'status', 'todo', mockPlugin);
expect(result).toBe(true);
});
it('should return false for non-collapsed groups', () => {
mockPlugin.viewStateManager.getViewPreferences.mockReturnValue({});
const result = GroupingUtils.isGroupCollapsed(TASK_LIST_VIEW_TYPE, 'status', 'todo', mockPlugin);
expect(result).toBe(false);
});
it('should set group collapsed state', () => {
mockPlugin.viewStateManager.getViewPreferences.mockReturnValue({});
GroupingUtils.setGroupCollapsed(SUBTASK_WIDGET_VIEW_TYPE, 'status', 'todo', true, mockPlugin);
expect(mockPlugin.viewStateManager.setViewPreferences).toHaveBeenCalledWith(
SUBTASK_WIDGET_VIEW_TYPE,
{
collapsedGroups: {
'status': {
'todo': true
}
}
}
);
});
it('should expand all groups', () => {
mockPlugin.viewStateManager.getViewPreferences.mockReturnValue({
collapsedGroups: {
'status': {
'todo': true,
'done': true
}
}
});
GroupingUtils.expandAllGroups(TASK_LIST_VIEW_TYPE, 'status', mockPlugin);
expect(mockPlugin.viewStateManager.setViewPreferences).toHaveBeenCalledWith(
TASK_LIST_VIEW_TYPE,
{
collapsedGroups: {
'status': {}
}
}
);
});
it('should collapse all groups', () => {
mockPlugin.viewStateManager.getViewPreferences.mockReturnValue({});
GroupingUtils.collapseAllGroups(TASK_LIST_VIEW_TYPE, 'status', ['todo', 'done'], mockPlugin);
expect(mockPlugin.viewStateManager.setViewPreferences).toHaveBeenCalledWith(
TASK_LIST_VIEW_TYPE,
{
collapsedGroups: {
'status': {
'todo': true,
'done': true
}
}
}
);
});
});
});