mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
de9372ca8e
8 changed files with 573 additions and 68 deletions
BIN
docs/assets/expand_collapse_all.gif
Normal file
BIN
docs/assets/expand_collapse_all.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
|
|
@ -24,6 +24,11 @@ Example:
|
|||
|
||||
-->
|
||||
|
||||
## Added
|
||||
|
||||
- (#1791) Added collapse and expand controls for grouped Bases Task List views, including a default collapsed state option.
|
||||
- Thanks to @renatomen for the contribution.
|
||||
|
||||
## Fixed
|
||||
|
||||
- (#1961) Fixed the default custom filename template so it uses the recommended double-brace syntax and no longer warns on first use.
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -14006,7 +14006,6 @@
|
|||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/tasknotes-nlp-core/-/tasknotes-nlp-core-0.1.5.tgz",
|
||||
"integrity": "sha512-4SfXpKkFaq64CtDdb228FkOcgc5rAYRoYYrFk8qYNw9/XfVZELu4Wt7lfuxPdG+rov4LEtSWW1vZE5EK1gejmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chrono-node": "^2.7.5",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export abstract class BasesViewBase extends Component {
|
|||
// Search functionality (opt-in via enableSearch flag)
|
||||
protected enableSearch = false;
|
||||
protected searchBox: SearchBox | null = null;
|
||||
protected searchContainerEl: HTMLElement | null = null;
|
||||
protected searchFilter: TaskSearchFilter | null = null;
|
||||
protected currentSearchTerm = "";
|
||||
|
||||
|
|
@ -550,6 +551,12 @@ export abstract class BasesViewBase extends Component {
|
|||
* Requires enableSearch to be true and will only create the UI once.
|
||||
*/
|
||||
protected setupSearch(container: HTMLElement): void {
|
||||
// Tear down search UI if it exists but search has been disabled
|
||||
if (this.searchBox && !this.enableSearch) {
|
||||
this.teardownSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency: if search UI is already created, restore value and return
|
||||
if (this.searchBox) {
|
||||
// Restore search term if it was cleared during re-render
|
||||
|
|
@ -584,16 +591,25 @@ export abstract class BasesViewBase extends Component {
|
|||
});
|
||||
this.searchFilter = searchControls.searchFilter;
|
||||
this.searchBox = searchControls.searchBox;
|
||||
this.searchContainerEl = searchControls.searchContainer;
|
||||
|
||||
// Register cleanup using Component lifecycle
|
||||
this.register(() => {
|
||||
if (this.searchBox) {
|
||||
this.searchBox.destroy();
|
||||
this.searchBox = null;
|
||||
}
|
||||
this.searchFilter = null;
|
||||
this.currentSearchTerm = "";
|
||||
});
|
||||
this.register(() => this.teardownSearch());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the search UI and reset search state.
|
||||
* Called when enableSearch is toggled off.
|
||||
*/
|
||||
protected teardownSearch(): void {
|
||||
if (this.searchBox) {
|
||||
this.searchBox.destroy();
|
||||
this.searchBox = null;
|
||||
}
|
||||
this.searchContainerEl?.remove();
|
||||
this.searchContainerEl = null;
|
||||
this.searchFilter = null;
|
||||
this.currentSearchTerm = "";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Legacy Bases view rendering narrows DOM references through lifecycle checks. */
|
||||
import { Notice, TFile, setIcon } from "obsidian";
|
||||
import { Menu, Notice, TFile, setIcon } from "obsidian";
|
||||
import type { BasesView, BasesViewFactory } from "obsidian";
|
||||
import TaskNotesPlugin from "../main";
|
||||
import { BasesViewBase } from "./BasesViewBase";
|
||||
|
|
@ -117,9 +117,17 @@ function normalizeExpandedRelationshipFilterMode(value: unknown): "inherit" | "s
|
|||
return "inherit";
|
||||
}
|
||||
|
||||
type DefaultCollapsedState = "Expanded" | "Collapsed";
|
||||
|
||||
type GroupHierarchySnapshot = {
|
||||
primaryGroupKeys: string[];
|
||||
subGroupKeysByParent: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export class TaskListView extends BasesViewBase {
|
||||
type = "tasknotesTaskList";
|
||||
|
||||
private configLoaded = false; // Track if we've successfully loaded config
|
||||
private itemsContainer: HTMLElement | null = null;
|
||||
private currentTaskElements = new Map<string, HTMLElement>();
|
||||
private lastRenderWasGrouped = false;
|
||||
|
|
@ -136,11 +144,16 @@ export class TaskListView extends BasesViewBase {
|
|||
private collapsedGroups = new Set<string>(); // Track collapsed group keys
|
||||
private collapsedSubGroups = new Set<string>(); // Track collapsed sub-group keys
|
||||
private subGroupPropertyId: string | null = null; // Property ID for sub-grouping
|
||||
private defaultCollapsedState: DefaultCollapsedState = "Expanded";
|
||||
private expandedRelationshipFilterMode: TaskCardOptions["expandedRelationshipFilterMode"] =
|
||||
"inherit";
|
||||
private currentVisibleTaskPaths = new Set<string>();
|
||||
private currentVisibleTaskOrder = new Map<string, number>();
|
||||
private configLoaded = false; // Track if we've successfully loaded config
|
||||
private currentPrimaryGroupKeys: string[] = [];
|
||||
private currentSubGroupKeysByParent = new Map<string, string[]>();
|
||||
private initializedPrimaryGroupKeys = new Set<string>();
|
||||
private initializedSubGroupKeys = new Set<string>();
|
||||
private deferCollapseDefaultForNextSnapshot = false;
|
||||
|
||||
// Drag-to-reorder state
|
||||
private basesController: TaskListController;
|
||||
|
|
@ -191,6 +204,87 @@ export class TaskListView extends BasesViewBase {
|
|||
this.readViewOptions();
|
||||
// Call parent onload which sets up container and listeners
|
||||
super.onload();
|
||||
this.registerGroupContextMenuListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register contextmenu listeners for group collapse actions.
|
||||
* - Right-click on a primary group header → expand/collapse branch
|
||||
* - Right-click on empty container area → expand/collapse all groups
|
||||
*/
|
||||
private registerGroupContextMenuListeners(): void {
|
||||
if (!this.rootElement) return;
|
||||
|
||||
this.rootElement.addEventListener("contextmenu", (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if right-clicking on a primary group header (not a subgroup)
|
||||
const groupHeader = target.closest<HTMLElement>(".task-group-header");
|
||||
if (groupHeader) {
|
||||
const groupSection = groupHeader.closest<HTMLElement>(".task-group");
|
||||
const groupKey = groupSection?.dataset.groupKey;
|
||||
if (groupKey && !this.isSubGroupKey(groupKey)) {
|
||||
event.preventDefault();
|
||||
this.showGroupHeaderContextMenu(event, groupKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if right-clicking on empty area (not on a task card or group header)
|
||||
const isOnTaskCard = target.closest(".task-card");
|
||||
if (!isOnTaskCard && !groupHeader && this.currentPrimaryGroupKeys.length > 0) {
|
||||
event.preventDefault();
|
||||
this.showContainerContextMenu(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showGroupHeaderContextMenu(event: MouseEvent, groupKey: string): void {
|
||||
const subGroupKeys = this.currentSubGroupKeysByParent.get(groupKey);
|
||||
if (!subGroupKeys || subGroupKeys.length === 0) return;
|
||||
|
||||
const allSubGroupsCollapsed = subGroupKeys.every((key) =>
|
||||
this.collapsedSubGroups.has(key)
|
||||
);
|
||||
const menu = new Menu();
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(allSubGroupsCollapsed ? "Expand subgroups" : "Collapse subgroups")
|
||||
.setIcon(allSubGroupsCollapsed ? "list-tree" : "list-collapse")
|
||||
.onClick(() => void this.setSubGroupsCollapsed(groupKey, !allSubGroupsCollapsed));
|
||||
});
|
||||
|
||||
menu.showAtMouseEvent(event);
|
||||
}
|
||||
|
||||
private showContainerContextMenu(event: MouseEvent): void {
|
||||
const menu = new Menu();
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Expand all groups")
|
||||
.setIcon("chevrons-down")
|
||||
.onClick(() => void this.setAllPrimaryGroupsCollapsed(false));
|
||||
});
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Collapse all groups")
|
||||
.setIcon("chevrons-up")
|
||||
.onClick(() => void this.setAllPrimaryGroupsCollapsed(true));
|
||||
});
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Expand all groups and subgroups")
|
||||
.setIcon("list-tree")
|
||||
.onClick(() => void this.setAllGroupsAndSubGroupsCollapsed(false));
|
||||
});
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Collapse all groups and subgroups")
|
||||
.setIcon("list-collapse")
|
||||
.onClick(() => void this.setAllGroupsAndSubGroupsCollapsed(true));
|
||||
});
|
||||
|
||||
menu.showAtMouseEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -211,6 +305,12 @@ export class TaskListView extends BasesViewBase {
|
|||
// Read enableSearch toggle (default: false for backward compatibility)
|
||||
const enableSearchValue = this.config.get("enableSearch");
|
||||
this.enableSearch = (enableSearchValue as boolean) ?? false;
|
||||
const defaultCollapsedStateValue = this.config.get("defaultCollapsedState");
|
||||
|
||||
this.defaultCollapsedState =
|
||||
defaultCollapsedStateValue === "Collapsed" || defaultCollapsedStateValue === "1"
|
||||
? "Collapsed"
|
||||
: "Expanded";
|
||||
const expandedRelationshipFilterModeValue = this.config.get(
|
||||
"expandedRelationshipFilterMode"
|
||||
);
|
||||
|
|
@ -229,6 +329,113 @@ export class TaskListView extends BasesViewBase {
|
|||
}
|
||||
}
|
||||
|
||||
private clearGroupingSnapshot(): void {
|
||||
this.currentPrimaryGroupKeys = [];
|
||||
this.currentSubGroupKeysByParent.clear();
|
||||
}
|
||||
|
||||
private initializeCollapseStateForSnapshot(
|
||||
primaryGroupKeys: string[],
|
||||
subGroupKeysByParent: Map<string, string[]>
|
||||
): void {
|
||||
const shouldSeedCollapsedState =
|
||||
this.defaultCollapsedState === "Collapsed" && !this.deferCollapseDefaultForNextSnapshot;
|
||||
|
||||
for (const primaryGroupKey of primaryGroupKeys) {
|
||||
if (this.initializedPrimaryGroupKeys.has(primaryGroupKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.initializedPrimaryGroupKeys.add(primaryGroupKey);
|
||||
if (shouldSeedCollapsedState) {
|
||||
this.collapsedGroups.add(primaryGroupKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const subGroupKeys of subGroupKeysByParent.values()) {
|
||||
for (const subGroupKey of subGroupKeys) {
|
||||
if (this.initializedSubGroupKeys.has(subGroupKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.initializedSubGroupKeys.add(subGroupKey);
|
||||
if (shouldSeedCollapsedState) {
|
||||
this.collapsedSubGroups.add(subGroupKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.deferCollapseDefaultForNextSnapshot = false;
|
||||
}
|
||||
|
||||
private applyGroupingSnapshot(snapshot: GroupHierarchySnapshot): void {
|
||||
const subGroupKeysByParent = new Map<string, string[]>();
|
||||
for (const [primaryKey, subGroupKeys] of snapshot.subGroupKeysByParent.entries()) {
|
||||
subGroupKeysByParent.set(primaryKey, [...subGroupKeys]);
|
||||
}
|
||||
|
||||
this.initializeCollapseStateForSnapshot(snapshot.primaryGroupKeys, subGroupKeysByParent);
|
||||
this.currentPrimaryGroupKeys = [...snapshot.primaryGroupKeys];
|
||||
this.currentSubGroupKeysByParent = subGroupKeysByParent;
|
||||
}
|
||||
|
||||
private createGroupedHierarchySnapshot(
|
||||
groups: readonly TaskListGroup[],
|
||||
taskNotes: readonly TaskInfo[]
|
||||
): GroupHierarchySnapshot {
|
||||
const snapshot: GroupHierarchySnapshot = {
|
||||
primaryGroupKeys: [],
|
||||
subGroupKeysByParent: new Map<string, string[]>(),
|
||||
};
|
||||
const pathToProps = this.subGroupPropertyId
|
||||
? buildTaskListPathProperties(this.dataAdapter.extractDataItems())
|
||||
: new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const group of groups) {
|
||||
const primaryKey = this.dataAdapter.convertGroupKeyToString(group.key);
|
||||
const groupPaths = new Set(group.entries.map((entry) => entry.file?.path));
|
||||
const groupTasks = taskNotes.filter((task) => groupPaths.has(task.path));
|
||||
|
||||
if (groupTasks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
snapshot.primaryGroupKeys.push(primaryKey);
|
||||
if (!this.subGroupPropertyId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subGroupKeys: string[] = [];
|
||||
const subGroups = groupTasksByTaskListSubProperty(
|
||||
groupTasks,
|
||||
this.subGroupPropertyId,
|
||||
pathToProps
|
||||
);
|
||||
|
||||
for (const [subKey, subTasks] of subGroups) {
|
||||
if (subTasks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
subGroupKeys.push(`${primaryKey}:${subKey}`);
|
||||
}
|
||||
|
||||
if (subGroupKeys.length > 0) {
|
||||
snapshot.subGroupKeysByParent.set(primaryKey, subGroupKeys);
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private createSubPropertyHierarchySnapshot(
|
||||
groupedTasks: Map<string, TaskInfo[]>
|
||||
): GroupHierarchySnapshot {
|
||||
return {
|
||||
primaryGroupKeys: Array.from(groupedTasks.keys()),
|
||||
subGroupKeysByParent: new Map<string, string[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
protected setupContainer(): void {
|
||||
super.setupContainer();
|
||||
|
||||
|
|
@ -308,10 +515,10 @@ export class TaskListView extends BasesViewBase {
|
|||
if (this.rootElement) {
|
||||
this.setupSearch(this.rootElement);
|
||||
}
|
||||
|
||||
try {
|
||||
// Skip rendering if we have no data yet (prevents flickering during data updates)
|
||||
if (!this.data?.data) {
|
||||
this.clearGroupingSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -327,6 +534,7 @@ export class TaskListView extends BasesViewBase {
|
|||
this.clearAllTaskElements();
|
||||
this.sortScopeTaskPaths.clear();
|
||||
this.sortScopeCandidateTaskPaths.clear();
|
||||
this.clearGroupingSnapshot();
|
||||
this.renderEmptyState();
|
||||
this.lastRenderWasGrouped = false;
|
||||
return;
|
||||
|
|
@ -352,10 +560,10 @@ export class TaskListView extends BasesViewBase {
|
|||
if (this.lastRenderWasGrouped) {
|
||||
this.clearAllTaskElements();
|
||||
}
|
||||
this.clearGroupingSnapshot();
|
||||
await this.renderFlat(taskNotes);
|
||||
this.lastRenderWasGrouped = false;
|
||||
}
|
||||
|
||||
// Check if we have grouped data
|
||||
} catch (error: unknown) {
|
||||
tasknotesLogger.error("[TaskNotes][TaskListView] Error rendering:", {
|
||||
|
|
@ -1440,10 +1648,6 @@ export class TaskListView extends BasesViewBase {
|
|||
this.lastCardRenderSignature = cardRenderSignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build flattened list of render items (headers + tasks) for grouped view
|
||||
* Shared between renderGrouped() and refreshGroupedView()
|
||||
*/
|
||||
/**
|
||||
* Render tasks grouped by sub-property (when no primary grouping is configured).
|
||||
* This treats the sub-group property as primary grouping.
|
||||
|
|
@ -1483,6 +1687,7 @@ export class TaskListView extends BasesViewBase {
|
|||
pathToProps
|
||||
);
|
||||
this.setSortScopeCandidatePaths(buildTaskListSubPropertyScopePaths(allGroupedTasks));
|
||||
this.applyGroupingSnapshot(this.createSubPropertyHierarchySnapshot(groupedTasks));
|
||||
|
||||
// Build flat items array (treat sub-groups as primary groups)
|
||||
const items = buildTaskListSubPropertyRenderItems(groupedTasks, this.collapsedGroups);
|
||||
|
|
@ -1543,6 +1748,7 @@ export class TaskListView extends BasesViewBase {
|
|||
const targetDate = createUTCDateFromLocalCalendarDate(new Date());
|
||||
this.currentTargetDate = targetDate;
|
||||
const cardOptions = this.getCardOptions(targetDate);
|
||||
this.applyGroupingSnapshot(this.createGroupedHierarchySnapshot(groups, filteredTasks));
|
||||
|
||||
// Build flattened list of items using shared method
|
||||
const pathToProps = this.subGroupPropertyId
|
||||
|
|
@ -1712,12 +1918,15 @@ export class TaskListView extends BasesViewBase {
|
|||
const isSubHeader = headerItem.type === "sub-header";
|
||||
const level = isSubHeader ? "sub" : "primary";
|
||||
groupHeader.dataset.level = level;
|
||||
const groupKey = isSubHeader
|
||||
? `${headerItem.groupKey}:${headerItem.subGroupKey}`
|
||||
: headerItem.groupKey;
|
||||
|
||||
if (isSubHeader) {
|
||||
groupHeader.dataset.groupKey = `${headerItem.groupKey}:${headerItem.subGroupKey}`;
|
||||
groupHeader.dataset.groupKey = groupKey;
|
||||
groupHeader.dataset.parentKey = headerItem.parentKey;
|
||||
} else {
|
||||
groupHeader.dataset.groupKey = headerItem.groupKey;
|
||||
groupHeader.dataset.groupKey = groupKey;
|
||||
}
|
||||
|
||||
// Apply collapsed state
|
||||
|
|
@ -1735,7 +1944,7 @@ export class TaskListView extends BasesViewBase {
|
|||
toggleBtn.type = "button";
|
||||
toggleBtn.setAttribute("aria-label", "Toggle group");
|
||||
toggleBtn.setAttribute("aria-expanded", String(!headerItem.isCollapsed));
|
||||
toggleBtn.dataset.groupKey = groupHeader.dataset.groupKey!;
|
||||
toggleBtn.dataset.groupKey = groupKey;
|
||||
headerElement.appendChild(toggleBtn);
|
||||
|
||||
// Add chevron icon
|
||||
|
|
@ -1909,6 +2118,9 @@ export class TaskListView extends BasesViewBase {
|
|||
this.useVirtualScrolling = false;
|
||||
this.collapsedGroups.clear();
|
||||
this.collapsedSubGroups.clear();
|
||||
this.clearGroupingSnapshot();
|
||||
this.initializedPrimaryGroupKeys.clear();
|
||||
this.initializedSubGroupKeys.clear();
|
||||
this.taskGroupKeys.clear();
|
||||
this.sortScopeTaskPaths.clear();
|
||||
}
|
||||
|
|
@ -1932,19 +2144,26 @@ export class TaskListView extends BasesViewBase {
|
|||
setEphemeralState(state: unknown): void {
|
||||
if (!isTaskListEphemeralState(state)) return;
|
||||
|
||||
let restoredCollapsedState = false;
|
||||
|
||||
// Restore collapsed groups immediately
|
||||
if (state.collapsedGroups && Array.isArray(state.collapsedGroups)) {
|
||||
this.collapsedGroups = new Set(
|
||||
state.collapsedGroups.filter((value) => typeof value === "string")
|
||||
const filtered = state.collapsedGroups.filter(
|
||||
(value): value is string => typeof value === "string"
|
||||
);
|
||||
this.collapsedGroups = new Set(filtered);
|
||||
restoredCollapsedState = restoredCollapsedState || filtered.length > 0;
|
||||
}
|
||||
|
||||
// Restore collapsed sub-groups immediately
|
||||
if (state.collapsedSubGroups && Array.isArray(state.collapsedSubGroups)) {
|
||||
this.collapsedSubGroups = new Set(
|
||||
state.collapsedSubGroups.filter((value) => typeof value === "string")
|
||||
const filtered = state.collapsedSubGroups.filter(
|
||||
(value): value is string => typeof value === "string"
|
||||
);
|
||||
this.collapsedSubGroups = new Set(filtered);
|
||||
restoredCollapsedState = restoredCollapsedState || filtered.length > 0;
|
||||
}
|
||||
this.deferCollapseDefaultForNextSnapshot = restoredCollapsedState;
|
||||
|
||||
// Restore scroll position after render completes
|
||||
if (typeof state.scrollTop === "number" && this.rootElement) {
|
||||
|
|
@ -2057,24 +2276,76 @@ export class TaskListView extends BasesViewBase {
|
|||
// This prevents double-firing when clicking on tasks
|
||||
};
|
||||
|
||||
private async handleGroupToggle(groupKey: string): Promise<void> {
|
||||
// Detect if this is a sub-group toggle (compound key contains colon)
|
||||
const isSubGroup = groupKey.includes(":");
|
||||
private isSubGroupKey(groupKey: string): boolean {
|
||||
return groupKey.includes(":");
|
||||
}
|
||||
|
||||
if (isSubGroup) {
|
||||
// Toggle sub-group collapsed state
|
||||
if (this.collapsedSubGroups.has(groupKey)) {
|
||||
this.collapsedSubGroups.delete(groupKey);
|
||||
} else {
|
||||
this.collapsedSubGroups.add(groupKey);
|
||||
}
|
||||
private setSetEntry(set: Set<string>, key: string, collapsed: boolean): void {
|
||||
if (collapsed) {
|
||||
set.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
set.delete(key);
|
||||
}
|
||||
|
||||
private getCurrentSubGroupKeys(): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const subGroupKeys of this.currentSubGroupKeysByParent.values()) {
|
||||
keys.push(...subGroupKeys);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set collapsed state for all subgroups within a specific primary group,
|
||||
* without affecting the primary group itself.
|
||||
*/
|
||||
private async setSubGroupsCollapsed(groupKey: string, collapsed: boolean): Promise<void> {
|
||||
for (const subGroupKey of this.currentSubGroupKeysByParent.get(groupKey) || []) {
|
||||
this.setSetEntry(this.collapsedSubGroups, subGroupKey, collapsed);
|
||||
}
|
||||
|
||||
if (this.lastRenderWasGrouped) {
|
||||
await this.refreshGroupedView();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set collapsed state for all primary groups,
|
||||
* without affecting subgroups.
|
||||
*/
|
||||
private async setAllPrimaryGroupsCollapsed(collapsed: boolean): Promise<void> {
|
||||
for (const groupKey of this.currentPrimaryGroupKeys) {
|
||||
this.setSetEntry(this.collapsedGroups, groupKey, collapsed);
|
||||
}
|
||||
|
||||
if (this.lastRenderWasGrouped) {
|
||||
await this.refreshGroupedView();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set collapsed state for all primary groups and all subgroups.
|
||||
*/
|
||||
private async setAllGroupsAndSubGroupsCollapsed(collapsed: boolean): Promise<void> {
|
||||
for (const groupKey of this.currentPrimaryGroupKeys) {
|
||||
this.setSetEntry(this.collapsedGroups, groupKey, collapsed);
|
||||
}
|
||||
for (const subGroupKey of this.getCurrentSubGroupKeys()) {
|
||||
this.setSetEntry(this.collapsedSubGroups, subGroupKey, collapsed);
|
||||
}
|
||||
|
||||
if (this.lastRenderWasGrouped) {
|
||||
await this.refreshGroupedView();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGroupToggle(groupKey: string): Promise<void> {
|
||||
if (this.isSubGroupKey(groupKey)) {
|
||||
this.setSetEntry(this.collapsedSubGroups, groupKey, !this.collapsedSubGroups.has(groupKey));
|
||||
} else {
|
||||
// Toggle primary group collapsed state
|
||||
if (this.collapsedGroups.has(groupKey)) {
|
||||
this.collapsedGroups.delete(groupKey);
|
||||
} else {
|
||||
this.collapsedGroups.add(groupKey);
|
||||
}
|
||||
this.setSetEntry(this.collapsedGroups, groupKey, !this.collapsedGroups.has(groupKey));
|
||||
}
|
||||
|
||||
// Rebuild items and update virtual scroller without full re-render
|
||||
|
|
@ -2089,33 +2360,40 @@ export class TaskListView extends BasesViewBase {
|
|||
const dataItems = this.dataAdapter.extractDataItems();
|
||||
computeBasesFormulas(this.data, dataItems);
|
||||
const taskNotes = await identifyTaskNotesFromBasesData(dataItems, this.plugin);
|
||||
const filteredTasks = this.applySearchFilter(taskNotes);
|
||||
this.setCurrentVisibleTaskPaths(filteredTasks);
|
||||
|
||||
const pathToProps = this.subGroupPropertyId
|
||||
? buildTaskListPathProperties(dataItems)
|
||||
: new Map<string, Record<string, unknown>>();
|
||||
const items =
|
||||
!this.dataAdapter.isGrouped() && this.subGroupPropertyId
|
||||
? buildTaskListSubPropertyRenderItems(
|
||||
groupTasksByTaskListSubProperty(
|
||||
taskNotes,
|
||||
this.subGroupPropertyId,
|
||||
pathToProps
|
||||
),
|
||||
this.collapsedGroups
|
||||
)
|
||||
: buildTaskListGroupedRenderItems({
|
||||
groups: this.dataAdapter.getGroupedData() as TaskListGroup[],
|
||||
taskNotes,
|
||||
subGroupPropertyId: this.subGroupPropertyId,
|
||||
pathToProps,
|
||||
collapsedGroups: this.collapsedGroups,
|
||||
collapsedSubGroups: this.collapsedSubGroups,
|
||||
convertGroupKeyToString: (key) =>
|
||||
this.dataAdapter.convertGroupKeyToString(key),
|
||||
});
|
||||
let items: TaskListRenderItem[];
|
||||
|
||||
if (!this.dataAdapter.isGrouped() && this.subGroupPropertyId) {
|
||||
const groupedTasks = groupTasksByTaskListSubProperty(
|
||||
filteredTasks,
|
||||
this.subGroupPropertyId,
|
||||
pathToProps
|
||||
);
|
||||
this.applyGroupingSnapshot(this.createSubPropertyHierarchySnapshot(groupedTasks));
|
||||
items = buildTaskListSubPropertyRenderItems(groupedTasks, this.collapsedGroups);
|
||||
} else {
|
||||
const groups = this.dataAdapter.getGroupedData() as TaskListGroup[];
|
||||
this.applyGroupingSnapshot(this.createGroupedHierarchySnapshot(groups, filteredTasks));
|
||||
items = buildTaskListGroupedRenderItems({
|
||||
groups,
|
||||
taskNotes: filteredTasks,
|
||||
subGroupPropertyId: this.subGroupPropertyId,
|
||||
pathToProps,
|
||||
collapsedGroups: this.collapsedGroups,
|
||||
collapsedSubGroups: this.collapsedSubGroups,
|
||||
convertGroupKeyToString: (key) =>
|
||||
this.dataAdapter.convertGroupKeyToString(key),
|
||||
});
|
||||
}
|
||||
|
||||
// Update virtual scroller with new items
|
||||
if (this.useVirtualScrolling && this.virtualScroller) {
|
||||
this.syncGroupedDragMetadata(items);
|
||||
this.virtualScroller.updateItems(items);
|
||||
} else {
|
||||
// If not using virtual scrolling, do full render
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ const KANBAN_CARD_LAYOUT_OPTIONS: Record<string, string> = {
|
|||
compact: "Compact",
|
||||
};
|
||||
|
||||
const TASK_LIST_DEFAULT_COLLAPSED_STATE_OPTIONS: Record<string, string> = {
|
||||
Expanded: "Expanded",
|
||||
Collapsed: "Collapsed",
|
||||
};
|
||||
|
||||
const EXPANDED_RELATIONSHIP_FILTER_MODE_OPTIONS: Record<string, string> = {
|
||||
inherit: "Inherit",
|
||||
"show-all": "Show all",
|
||||
};
|
||||
|
||||
/**
|
||||
* Register TaskNotes views with Bases plugin
|
||||
* Requires Obsidian 1.10.1+ (public Bases API with groupBy support)
|
||||
|
|
@ -57,15 +67,19 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
|
|||
displayName: "Enable search box",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
type: "dropdown",
|
||||
key: "defaultCollapsedState",
|
||||
displayName: "Default collapsed state",
|
||||
default: "Expanded",
|
||||
options: TASK_LIST_DEFAULT_COLLAPSED_STATE_OPTIONS,
|
||||
},
|
||||
{
|
||||
type: "dropdown",
|
||||
key: "expandedRelationshipFilterMode",
|
||||
displayName: "Expanded relationships",
|
||||
default: "inherit",
|
||||
options: {
|
||||
inherit: "Inherit",
|
||||
"show-all": "Show all",
|
||||
},
|
||||
options: EXPANDED_RELATIONSHIP_FILTER_MODE_OPTIONS,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -172,10 +186,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
|
|||
key: "expandedRelationshipFilterMode",
|
||||
displayName: "Expanded relationships",
|
||||
default: "inherit",
|
||||
options: {
|
||||
inherit: "Inherit",
|
||||
"show-all": "Show all",
|
||||
},
|
||||
options: EXPANDED_RELATIONSHIP_FILTER_MODE_OPTIONS,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -100,6 +100,18 @@
|
|||
margin-right: 0;
|
||||
}
|
||||
|
||||
.tn-tasknotesTaskList .tn-search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tn-spacing-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tn-tasknotesTaskList .tn-search-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Toggle button for groups */
|
||||
.tn-bases-tasknotes-list .task-group-toggle {
|
||||
appearance: none;
|
||||
|
|
|
|||
184
tests/unit/ui/TaskListView.groupCollapse.test.ts
Normal file
184
tests/unit/ui/TaskListView.groupCollapse.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { App, MockObsidian } from "../../__mocks__/obsidian";
|
||||
import { TaskListView } from "../../../src/bases/TaskListView";
|
||||
import { FieldMapper } from "../../../src/services/FieldMapper";
|
||||
import { DEFAULT_FIELD_MAPPING } from "../../../src/settings/defaults";
|
||||
|
||||
jest.mock("obsidian");
|
||||
jest.mock(
|
||||
"tasknotes-nlp-core",
|
||||
() => ({
|
||||
NaturalLanguageParserCore: class {},
|
||||
}),
|
||||
{ virtual: true }
|
||||
);
|
||||
jest.mock("../../../src/bases/groupTitleRenderer", () => ({
|
||||
renderGroupTitle: jest.fn((container: HTMLElement, title: string) => {
|
||||
container.textContent = title;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("TaskListView group collapse controls", () => {
|
||||
const createView = () => {
|
||||
const plugin = {
|
||||
app: new App(),
|
||||
fieldMapper: new FieldMapper(DEFAULT_FIELD_MAPPING),
|
||||
settings: {
|
||||
fieldMapping: DEFAULT_FIELD_MAPPING,
|
||||
},
|
||||
};
|
||||
const containerEl = document.createElement("div");
|
||||
document.body.appendChild(containerEl);
|
||||
const view = new TaskListView({}, containerEl, plugin as any);
|
||||
(view as any).app = {
|
||||
metadataCache: {
|
||||
getFirstLinkpathDest: jest.fn(() => null),
|
||||
},
|
||||
workspace: {},
|
||||
};
|
||||
return view;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
MockObsidian.reset();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("initializes new groups as collapsed when the default state is Collapsed", () => {
|
||||
const view = createView();
|
||||
(view as any).defaultCollapsedState = "Collapsed";
|
||||
|
||||
(view as any).initializeCollapseStateForSnapshot(
|
||||
["Open"],
|
||||
new Map([["Open", ["Open:Urgent", "Open:Today"]]])
|
||||
);
|
||||
|
||||
expect((view as any).collapsedGroups.has("Open")).toBe(true);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Urgent")).toBe(true);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Today")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves restored ephemeral state over the default collapse seed on the first snapshot", () => {
|
||||
const view = createView();
|
||||
(view as any).defaultCollapsedState = "Collapsed";
|
||||
|
||||
view.setEphemeralState({
|
||||
scrollTop: 0,
|
||||
collapsedGroups: ["Done"],
|
||||
collapsedSubGroups: ["Done:Later"],
|
||||
});
|
||||
|
||||
(view as any).initializeCollapseStateForSnapshot(
|
||||
["Done", "Open"],
|
||||
new Map([
|
||||
["Done", ["Done:Later"]],
|
||||
["Open", ["Open:Urgent"]],
|
||||
])
|
||||
);
|
||||
|
||||
expect((view as any).collapsedGroups.has("Done")).toBe(true);
|
||||
expect((view as any).collapsedSubGroups.has("Done:Later")).toBe(true);
|
||||
expect((view as any).collapsedGroups.has("Open")).toBe(false);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Urgent")).toBe(false);
|
||||
});
|
||||
|
||||
it("still applies the collapsed default when restored ephemeral state is empty", () => {
|
||||
const view = createView();
|
||||
(view as any).defaultCollapsedState = "Collapsed";
|
||||
|
||||
view.setEphemeralState({
|
||||
scrollTop: 0,
|
||||
collapsedGroups: [],
|
||||
collapsedSubGroups: [],
|
||||
});
|
||||
|
||||
(view as any).initializeCollapseStateForSnapshot(
|
||||
["Open"],
|
||||
new Map([["Open", ["Open:Urgent"]]])
|
||||
);
|
||||
|
||||
expect((view as any).collapsedGroups.has("Open")).toBe(true);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Urgent")).toBe(true);
|
||||
});
|
||||
|
||||
it("reads defaultCollapsedState correctly from config.get", () => {
|
||||
const view = createView();
|
||||
(view as any).config = {
|
||||
get: jest.fn((key: string) => {
|
||||
// Bases returns array indices as strings for string[] dropdowns
|
||||
if (key === "defaultCollapsedState") return "1"; // "1" = "Collapsed"
|
||||
if (key === "enableSearch") return false;
|
||||
if (key === "expandedRelationshipFilterMode") return "0"; // "0" = "inherit"
|
||||
return undefined;
|
||||
}),
|
||||
getAsPropertyId: jest.fn(() => null),
|
||||
};
|
||||
|
||||
(view as any).readViewOptions();
|
||||
|
||||
expect((view as any).defaultCollapsedState).toBe("Collapsed");
|
||||
});
|
||||
|
||||
it("collapses and expands subgroups within a primary group without affecting the parent", () => {
|
||||
const view = createView();
|
||||
(view as any).currentSubGroupKeysByParent = new Map([
|
||||
["Open", ["Open:Urgent", "Open:Today"]],
|
||||
]);
|
||||
|
||||
(view as any).setSubGroupsCollapsed("Open", true);
|
||||
|
||||
expect((view as any).collapsedGroups.has("Open")).toBe(false);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Urgent")).toBe(true);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Today")).toBe(true);
|
||||
|
||||
(view as any).setSubGroupsCollapsed("Open", false);
|
||||
|
||||
expect((view as any).collapsedGroups.has("Open")).toBe(false);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Urgent")).toBe(false);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Today")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses and expands all primary groups without affecting subgroups", () => {
|
||||
const view = createView();
|
||||
(view as any).currentPrimaryGroupKeys = ["Open", "Done"];
|
||||
(view as any).currentSubGroupKeysByParent = new Map([
|
||||
["Open", ["Open:Urgent"]],
|
||||
["Done", ["Done:Later"]],
|
||||
]);
|
||||
|
||||
(view as any).setAllPrimaryGroupsCollapsed(true);
|
||||
|
||||
expect((view as any).collapsedGroups.has("Open")).toBe(true);
|
||||
expect((view as any).collapsedGroups.has("Done")).toBe(true);
|
||||
expect((view as any).collapsedSubGroups.size).toBe(0);
|
||||
|
||||
(view as any).setAllPrimaryGroupsCollapsed(false);
|
||||
|
||||
expect((view as any).collapsedGroups.size).toBe(0);
|
||||
expect((view as any).collapsedSubGroups.size).toBe(0);
|
||||
});
|
||||
|
||||
it("collapses and expands all groups and subgroups together", () => {
|
||||
const view = createView();
|
||||
(view as any).currentPrimaryGroupKeys = ["Open", "Done"];
|
||||
(view as any).currentSubGroupKeysByParent = new Map([
|
||||
["Open", ["Open:Urgent"]],
|
||||
["Done", ["Done:Later"]],
|
||||
]);
|
||||
|
||||
(view as any).setAllGroupsAndSubGroupsCollapsed(true);
|
||||
|
||||
expect((view as any).collapsedGroups.has("Open")).toBe(true);
|
||||
expect((view as any).collapsedGroups.has("Done")).toBe(true);
|
||||
expect((view as any).collapsedSubGroups.has("Open:Urgent")).toBe(true);
|
||||
expect((view as any).collapsedSubGroups.has("Done:Later")).toBe(true);
|
||||
|
||||
(view as any).setAllGroupsAndSubGroupsCollapsed(false);
|
||||
|
||||
expect((view as any).collapsedGroups.size).toBe(0);
|
||||
expect((view as any).collapsedSubGroups.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue