diff --git a/docs/assets/collapse-expand-all-buttons.gif b/docs/assets/collapse-expand-all-buttons.gif new file mode 100644 index 00000000..8e2dc55b Binary files /dev/null and b/docs/assets/collapse-expand-all-buttons.gif differ diff --git a/docs/assets/tasklist-collapsible-groups.gif b/docs/assets/tasklist-collapsible-groups.gif new file mode 100644 index 00000000..66bd1a15 Binary files /dev/null and b/docs/assets/tasklist-collapsible-groups.gif differ diff --git a/docs/views/kanban-view.md b/docs/views/kanban-view.md index b105ffe2..c5481dec 100644 --- a/docs/views/kanban-view.md +++ b/docs/views/kanban-view.md @@ -6,6 +6,9 @@ The Kanban View displays tasks as cards in columns, where each column represents The Kanban View includes the same FilterBar functionality as the Task List View, allowing you to filter, sort, and save views of your tasks. See the [Task List View](task-list.md) documentation for complete FilterBar functionality details. + +Note: The Task List view includes “Collapse/Expand All” buttons for grouped lists. In Kanban, columns act as groups and are not collapsible yet, so these buttons are hidden in Kanban until they provide a function. + ## Interface Layout The Kanban View consists of a set of columns, each corresponding to a status in your configured workflow. Tasks are displayed as cards within the appropriate column. You can move tasks between columns by dragging and dropping them, which will automatically update the task's status. diff --git a/docs/views/task-list.md b/docs/views/task-list.md index 7b7c127d..749645e9 100644 --- a/docs/views/task-list.md +++ b/docs/views/task-list.md @@ -114,6 +114,17 @@ When grouping tasks by project, the project group headers are interactive: - `due` - By due date ranges (Today, Tomorrow, This week, etc.) - `scheduled` - By scheduled date ranges + +### Group Controls: Expand/Collapse All + +When grouping is enabled, the FilterBar shows top‑bar controls to collapse or expand all groups at once. This helps quickly condense or expand long lists. + +- Collapse All: hides the contents of every group +- Expand All: reveals the contents of every group +- The collapsed state is remembered per grouping key for the current view + +![Expand/Collapse All demo](../assets/collapse-expand-all-buttons.gif) + ## Task Actions The Task List View provides a variety of ways to interact with your tasks. You can click on a task to open it for editing, or you can use the context menu to perform a variety of actions, such as marking the task as complete, changing its priority, or deleting it. diff --git a/src/ui/FilterBar.ts b/src/ui/FilterBar.ts index a090948e..497ce807 100644 --- a/src/ui/FilterBar.ts +++ b/src/ui/FilterBar.ts @@ -88,12 +88,15 @@ export class FilterBar extends EventEmitter { viewOptions: false // View Options - collapsed by default }; + private enableGroupExpandCollapse: boolean = true; + constructor( app: App, container: HTMLElement, initialQuery: FilterQuery, filterOptions: FilterOptions, - viewsButtonAlignment: 'left' | 'right' = 'right' + viewsButtonAlignment: 'left' | 'right' = 'right', + options?: { enableGroupExpandCollapse?: boolean } ) { super(); this.app = app; @@ -101,6 +104,7 @@ export class FilterBar extends EventEmitter { this.currentQuery = FilterUtils.deepCloneFilterQuery(initialQuery); this.filterOptions = filterOptions; this.viewsButtonAlignment = viewsButtonAlignment; + this.enableGroupExpandCollapse = options?.enableGroupExpandCollapse ?? true; // Initialize drag and drop handler this.dragDropHandler = new DragDropHandler((fromIndex, toIndex) => { @@ -379,7 +383,23 @@ export class FilterBar extends EventEmitter { }); }; - // Order based on alignment + // Add expand/collapse all group buttons if grouping is enabled and allowed + const isGrouped = (this.currentQuery.groupKey || 'none') !== 'none'; + if (isGrouped && this.enableGroupExpandCollapse) { + const collapseAllBtn = new ButtonComponent(topControls) + .setIcon('list-collapse') + .setTooltip('Collapse All Groups') + .onClick(() => this.emit('collapseAllGroups')); + collapseAllBtn.buttonEl.addClass('filter-bar__collapse-groups'); + + const expandAllBtn = new ButtonComponent(topControls) + .setIcon('list-tree') + .setTooltip('Expand All Groups') + .onClick(() => this.emit('expandAllGroups')); + expandAllBtn.buttonEl.addClass('filter-bar__expand-groups'); + } + + // Order controls based on alignment preference if (this.viewsButtonAlignment === 'left') { makeViewsButton(); makeFilterToggle(); @@ -391,6 +411,9 @@ export class FilterBar extends EventEmitter { makeViewsButton(); } + // Update button state based on active saved view + this.updateViewSelectorButtonState(); + // Main filter box (now rendered within top-controls for positioning) this.renderMainFilterBox(topControls); @@ -1107,6 +1130,7 @@ export class FilterBar extends EventEmitter { }); setTooltip(titleWrapper, 'Click to expand/collapse sorting and grouping options', { placement: 'top' }); + // Content const content = section.createDiv('filter-bar__section-content'); if (!this.sectionStates.display) { @@ -1161,6 +1185,8 @@ export class FilterBar extends EventEmitter { .setValue(this.currentQuery.groupKey || 'none') .onChange((value: TaskGroupKey) => { this.currentQuery.groupKey = value; + // Re-render controls to show/hide group actions when grouping changes + this.updateUI(); this.emitQueryChange(); }); setTooltip(groupDropdown.selectEl, 'Group tasks by a common property', { placement: 'top' }); diff --git a/src/views/KanbanView.ts b/src/views/KanbanView.ts index 29232220..61ee9f39 100644 --- a/src/views/KanbanView.ts +++ b/src/views/KanbanView.ts @@ -207,7 +207,8 @@ export class KanbanView extends ItemView { filterBarContainer, this.currentQuery, filterOptions, - this.plugin.settings.viewsButtonAlignment || 'right' + this.plugin.settings.viewsButtonAlignment || 'right', + { enableGroupExpandCollapse: false } ); // Get saved views for the FilterBar diff --git a/src/views/TaskListView.ts b/src/views/TaskListView.ts index 84668ebc..4bde88ad 100644 --- a/src/views/TaskListView.ts +++ b/src/views/TaskListView.ts @@ -266,7 +266,37 @@ export class TaskListView extends ItemView { filterOptions, this.plugin.settings.viewsButtonAlignment || 'right' ); - + + // Wire expand/collapse all (as in preview-all) + this.filterBar.on('expandAllGroups', () => { + const key = this.currentQuery.groupKey || 'none'; + const prefs = this.plugin.viewStateManager.getViewPreferences(TASK_LIST_VIEW_TYPE) || {}; + const next = { ...(prefs.collapsedGroups || {}) } as Record>; + next[key] = {}; + this.plugin.viewStateManager.setViewPreferences(TASK_LIST_VIEW_TYPE, { ...prefs, collapsedGroups: next }); + // Update DOM + this.contentEl.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 prefs = this.plugin.viewStateManager.getViewPreferences(TASK_LIST_VIEW_TYPE) || {}; + const next = { ...(prefs.collapsedGroups || {}) } as Record>; + const collapsed: Record = {}; + 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'; + }); + next[key] = collapsed; + this.plugin.viewStateManager.setViewPreferences(TASK_LIST_VIEW_TYPE, { ...prefs, collapsedGroups: next }); + }); + // Get saved views for the FilterBar const savedViews = this.plugin.viewStateManager.getSavedViews(); this.filterBar.updateSavedViews(savedViews); diff --git a/styles/filter-bar-bem.css b/styles/filter-bar-bem.css index 70725b05..b3c0dc51 100644 --- a/styles/filter-bar-bem.css +++ b/styles/filter-bar-bem.css @@ -320,7 +320,7 @@ height: 2px; background: currentColor; border-radius: 1px; - box-shadow: + box-shadow: 0 -4px 0 currentColor, 0 4px 0 currentColor; } @@ -362,10 +362,10 @@ } @keyframes placeholderPulse { - 0%, 100% { + 0%, 100% { opacity: 0.5; } - 50% { + 50% { opacity: 0.7; } } @@ -502,6 +502,10 @@ } .tasknotes-plugin .filter-bar__section-header-actions { + display: flex; + align-items: center; + gap: var(--tn-spacing-sm); + padding-right: var(--tn-spacing-sm); } @@ -513,6 +517,14 @@ .tasknotes-plugin .filter-bar__save-button svg { width: 16px; + +/* Expand/Collapse all group buttons */ +.tasknotes-plugin .filter-bar__expand-groups svg, +.tasknotes-plugin .filter-bar__collapse-groups svg { + width: 16px; + height: 16px; +} + height: 16px; } @@ -922,7 +934,7 @@ .tasknotes-plugin .filter-bar__condition { flex-wrap: wrap; } - + .tasknotes-plugin .filter-bar__value-container { min-width: 120px; } diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index adf541b5..a1837018 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -864,6 +864,55 @@ export function parseYaml(text: string): any { return require('yaml').parse(text); } +// Minimal UI component mocks used by our UI tests +export class ButtonComponent { + buttonEl: HTMLButtonElement; + constructor(container: HTMLElement) { + this.buttonEl = document.createElement('button'); + container.appendChild(this.buttonEl); + } + setIcon(icon: string) { this.buttonEl.setAttribute('data-icon', icon); return this; } + setTooltip(tip: string) { this.buttonEl.setAttribute('data-tooltip', tip); return this; } + setClass(cls: string) { this.buttonEl.classList.add(cls); return this; } + setButtonText(text: string) { this.buttonEl.textContent = text; return this; } + setCta() { return this; } + onClick(cb: () => void) { this.buttonEl.addEventListener('click', cb); return this; } +} + +export class TextComponent { + inputEl: HTMLInputElement; + constructor(container: HTMLElement) { + this.inputEl = document.createElement('input'); + container.appendChild(this.inputEl); + } + setPlaceholder(text: string) { this.inputEl.placeholder = text; return this; } + setValue(val: string) { this.inputEl.value = val ?? ''; return this; } + getValue(): string { return this.inputEl.value; } + onChange(cb: (value: string) => void) { this.inputEl.addEventListener('input', () => cb(this.inputEl.value)); return this; } +} + +export class DropdownComponent { + selectEl: HTMLSelectElement; + constructor(container: HTMLElement) { + this.selectEl = document.createElement('select'); + container.appendChild(this.selectEl); + } + addOption(value: string, label: string) { const opt = document.createElement('option'); opt.value = value; opt.textContent = label; this.selectEl.appendChild(opt); return this; } + addOptions(options: Record) { Object.entries(options).forEach(([v, l]) => this.addOption(v, l)); return this; } + setValue(value: string) { this.selectEl.value = value; return this; } + onChange(cb: (value: any) => void) { this.selectEl.addEventListener('change', () => cb(this.selectEl.value)); return this; } +} + +export function debounce void>(fn: T, wait = 0): T { + let timer: any; + const wrapped = ((...args: any[]) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), wait); + }) as any as T; + return wrapped; +} + + export const Notice = jest.fn().mockImplementation((message: string, timeout?: number) => { // Mock Notice for testing - just track that it was called return {}; @@ -972,12 +1021,12 @@ export const MockObsidian = { getFileSystem: () => mockFileSystem, - // Helpers exposed for tests to use + // Helper to create test files createTestFile: (path: string, content: string) => mockFileSystem.create(path, content), getTestFiles: () => mockFileSystem.getFiles(), createMockApp: () => new App(), - // Expose certain constructors/mocks for easy overriding in tests + // Export class constructors for testing Menu, Notice, setIcon, diff --git a/tests/helpers/dom-helpers.ts b/tests/helpers/dom-helpers.ts new file mode 100644 index 00000000..12ea70a0 --- /dev/null +++ b/tests/helpers/dom-helpers.ts @@ -0,0 +1,34 @@ +export function augmentEl(el: HTMLElement): HTMLElement { + (el as any).createDiv = function(attrs?: any) { + const child = document.createElement('div'); + if (typeof attrs === 'string') child.className = attrs; + else if (attrs?.cls) child.className = attrs.cls; + this.appendChild(child); + // Add the same helpers to children for nested usage + augmentEl(child); + return child; + }; + (el as any).createEl = function(tag: string, attrs?: any) { + const child = document.createElement(tag); + if (typeof attrs === 'string') child.className = attrs; + else { + if (attrs?.cls) child.className = attrs.cls; + if (attrs?.text) child.textContent = attrs.text; + if (attrs?.attr) { + Object.entries(attrs.attr).forEach(([k, v]) => child.setAttribute(k, String(v))); + } + } + this.appendChild(child); + augmentEl(child as HTMLElement); + return child; + }; + (el as any).empty = function() { this.innerHTML=''; return this; }; + (el as any).addClass = function(...classes: string[]) { this.classList.add(...classes); return this; }; + (el as any).removeClass = function(...classes: string[]) { this.classList.remove(...classes); return this; }; + return el; +} + +export function makeContainer(): HTMLElement { + return augmentEl(document.createElement('div')); +} + diff --git a/tests/helpers/integration-helpers.ts b/tests/helpers/integration-helpers.ts index 6490fed6..4a8400cb 100644 --- a/tests/helpers/integration-helpers.ts +++ b/tests/helpers/integration-helpers.ts @@ -291,14 +291,22 @@ export class TestEnvironment { return MockObsidian.getFileSystem().read(file.path); }); - // FieldMapper responses - this.mockPlugin.fieldMapper.mapToFrontmatter.mockImplementation((taskData: any) => { - return { ...taskData }; - }); - - this.mockPlugin.fieldMapper.mapFromFrontmatter.mockImplementation((frontmatter: any) => { - return { ...frontmatter }; - }); + // FieldMapper responses (support both jest-mocked and real implementations) + const fm = this.mockPlugin.fieldMapper; + if (fm && typeof fm.mapToFrontmatter === 'function') { + if ((fm.mapToFrontmatter as any).mockImplementation) { + (fm.mapToFrontmatter as any).mockImplementation((taskData: any) => ({ ...taskData })); + } else { + jest.spyOn(fm, 'mapToFrontmatter').mockImplementation((taskData: any) => ({ ...taskData })); + } + } + if (fm && typeof fm.mapFromFrontmatter === 'function') { + if ((fm.mapFromFrontmatter as any).mockImplementation) { + (fm.mapFromFrontmatter as any).mockImplementation((frontmatter: any) => ({ ...frontmatter })); + } else { + jest.spyOn(fm, 'mapFromFrontmatter').mockImplementation((frontmatter: any) => ({ ...frontmatter })); + } + } // Cache manager responses this.mockPlugin.cacheManager.updateTaskInfoInCache.mockResolvedValue(undefined); diff --git a/tests/unit/services/ViewStateManager.collapsed-groups.test.ts b/tests/unit/services/ViewStateManager.collapsed-groups.test.ts new file mode 100644 index 00000000..84d5581d --- /dev/null +++ b/tests/unit/services/ViewStateManager.collapsed-groups.test.ts @@ -0,0 +1,19 @@ +import { App } from 'obsidian'; +import { ViewStateManager } from '../../../src/services/ViewStateManager'; +import { TASK_LIST_VIEW_TYPE } from '../../../src/types'; + +describe('ViewStateManager collapsed groups persistence', () => { + it('stores and retrieves collapsedGroups per view type', () => { + const app = new App(); + const plugin: any = { settings: { savedViews: [] } }; + const vsm = new ViewStateManager(app, plugin); + + const key = TASK_LIST_VIEW_TYPE; + const prefs1 = vsm.getViewPreferences(key) || {}; + const next = { ...prefs1, collapsedGroups: { status: { Done: true } } }; + vsm.setViewPreferences(key, next); + const prefs2 = vsm.getViewPreferences(key) || {}; + expect(prefs2.collapsedGroups.status.Done).toBe(true); + }); +}); + diff --git a/tests/unit/ui/FilterBar.top-controls.test.ts b/tests/unit/ui/FilterBar.top-controls.test.ts new file mode 100644 index 00000000..4dd0607d --- /dev/null +++ b/tests/unit/ui/FilterBar.top-controls.test.ts @@ -0,0 +1,41 @@ +import { App } from 'obsidian'; +import { FilterBar } from '../../../src/ui/FilterBar'; +import { FilterQuery, FilterOptions } from '../../../src/types'; +import { makeContainer } from '../../helpers/dom-helpers'; + + +const filterOptions: FilterOptions = { statuses: [], priorities: [], contexts: [], projects: [], tags: [] }; + +describe('FilterBar top controls', () => { + let app: App; + let container: HTMLElement; + let fb: FilterBar; + + beforeEach(() => { + document.body.innerHTML = ''; + app = new App(); + container = makeContainer(); + }); + + it('renders collapse/expand buttons when grouped', () => { + const query: FilterQuery = { type: 'group', id: 'root', conjunction: 'and', children: [], sortKey: 'due', sortDirection: 'asc', groupKey: 'status' }; + fb = new FilterBar(app, container, query, filterOptions); + const top = container.querySelector('.filter-bar__top-controls') as HTMLElement; + expect(top).toBeTruthy(); + // collapse then expand buttons exist in order + const buttons = top.querySelectorAll('button'); + // Expect at least 3 buttons: filter, collapse, expand + expect(buttons.length).toBeGreaterThanOrEqual(3); + }); + + it('does not render collapse/expand when grouping is none', () => { + const query: FilterQuery = { type: 'group', id: 'root', conjunction: 'and', children: [], sortKey: 'due', sortDirection: 'asc', groupKey: 'none' }; + fb = new FilterBar(app, container, query, filterOptions); + const top = container.querySelector('.filter-bar__top-controls') as HTMLElement; + const collapse = top.querySelector('.filter-bar__collapse-groups'); + const expand = top.querySelector('.filter-bar__expand-groups'); + expect(collapse).toBeNull(); + expect(expand).toBeNull(); + }); +}); +