mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
feat(tasklist): expand/collapse all controls in top bar (3.19.1)
This commit is contained in:
parent
a0f720e734
commit
04bb13bbd4
13 changed files with 252 additions and 18 deletions
BIN
docs/assets/collapse-expand-all-buttons.gif
Normal file
BIN
docs/assets/collapse-expand-all-buttons.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 530 KiB |
BIN
docs/assets/tasklist-collapsible-groups.gif
Normal file
BIN
docs/assets/tasklist-collapsible-groups.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 587 KiB |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
34
tests/helpers/dom-helpers.ts
Normal file
34
tests/helpers/dom-helpers.ts
Normal 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'));
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
41
tests/unit/ui/FilterBar.top-controls.test.ts
Normal file
41
tests/unit/ui/FilterBar.top-controls.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in a new issue