feat(tasklist): expand/collapse all controls in top bar (3.19.1)

This commit is contained in:
renatomen 2025-08-13 02:46:04 +00:00 committed by Callum Alpass
parent a0f720e734
commit 04bb13bbd4
13 changed files with 252 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

View file

@ -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.

View file

@ -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 topbar 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.

View file

@ -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' });

View file

@ -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

View file

@ -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<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 });
// 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<any>(TASK_LIST_VIEW_TYPE) || {};
const next = { ...(prefs.collapsedGroups || {}) } as Record<string, Record<string, boolean>>;
const collapsed: Record<string, boolean> = {};
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);

View file

@ -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;
}

View file

@ -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<string, string>) { 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<T extends (...args: any[]) => 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,

View file

@ -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'));
}

View file

@ -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);

View file

@ -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<any>(key) || {};
const next = { ...prefs1, collapsedGroups: { status: { Done: true } } };
vsm.setViewPreferences(key, next);
const prefs2 = vsm.getViewPreferences<any>(key) || {};
expect(prefs2.collapsedGroups.status.Done).toBe(true);
});
});

View file

@ -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();
});
});