diff --git a/docs/assets/expand_collapse_all.gif b/docs/assets/expand_collapse_all.gif new file mode 100644 index 000000000..3ef67cb43 Binary files /dev/null and b/docs/assets/expand_collapse_all.gif differ diff --git a/package-lock.json b/package-lock.json index d5100954c..e773057f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 5eb217efc..493abef8f 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -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 = ""; } /** diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index b1b95abf6..512f2b52a 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -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,46 @@ function normalizeExpandedRelationshipFilterMode(value: unknown): "inherit" | "s return "inherit"; } +type DefaultCollapsedState = "Expanded" | "Collapsed"; + +type PrimaryHeaderRenderItem = { + type: "primary-header"; + groupKey: string; + groupTitle: string; + taskCount: number; + groupEntries: any[]; + isCollapsed: boolean; +}; + +type SubHeaderRenderItem = { + type: "sub-header"; + groupKey: string; + subGroupKey: string; + subGroupTitle: string; + taskCount: number; + isCollapsed: boolean; + parentKey: string; +}; + +type TaskRenderItem = { + type: "task"; + task: TaskInfo; + groupKey: string; + subGroupKey?: string; +}; + +type GroupHeaderRenderItem = PrimaryHeaderRenderItem | SubHeaderRenderItem; +type GroupedRenderItem = GroupHeaderRenderItem | TaskRenderItem; + +type GroupHierarchySnapshot = { + primaryGroupKeys: string[]; + subGroupKeysByParent: Map; +}; + 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(); private lastRenderWasGrouped = false; @@ -136,11 +173,16 @@ export class TaskListView extends BasesViewBase { private collapsedGroups = new Set(); // Track collapsed group keys private collapsedSubGroups = new Set(); // 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(); private currentVisibleTaskOrder = new Map(); - private configLoaded = false; // Track if we've successfully loaded config + private currentPrimaryGroupKeys: string[] = []; + private currentSubGroupKeysByParent = new Map(); + private initializedPrimaryGroupKeys = new Set(); + private initializedSubGroupKeys = new Set(); + private deferCollapseDefaultForNextSnapshot = false; // Drag-to-reorder state private basesController: TaskListController; @@ -191,6 +233,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(".task-group-header"); + if (groupHeader) { + const groupSection = groupHeader.closest(".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 +334,10 @@ 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 +356,113 @@ export class TaskListView extends BasesViewBase { } } + private clearGroupingSnapshot(): void { + this.currentPrimaryGroupKeys = []; + this.currentSubGroupKeysByParent.clear(); + } + + private initializeCollapseStateForSnapshot( + primaryGroupKeys: string[], + subGroupKeysByParent: Map + ): 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(); + 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(), + }; + const pathToProps = this.subGroupPropertyId + ? buildTaskListPathProperties(this.dataAdapter.extractDataItems()) + : new Map>(); + + 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 + ): GroupHierarchySnapshot { + return { + primaryGroupKeys: Array.from(groupedTasks.keys()), + subGroupKeysByParent: new Map(), + }; + } + protected setupContainer(): void { super.setupContainer(); @@ -308,10 +542,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 +561,7 @@ export class TaskListView extends BasesViewBase { this.clearAllTaskElements(); this.sortScopeTaskPaths.clear(); this.sortScopeCandidateTaskPaths.clear(); + this.clearGroupingSnapshot(); this.renderEmptyState(); this.lastRenderWasGrouped = false; return; @@ -352,10 +587,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 +1675,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 +1714,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 +1775,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 +1945,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 +1971,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 +2145,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 +2171,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 +2303,76 @@ export class TaskListView extends BasesViewBase { // This prevents double-firing when clicking on tasks }; - private async handleGroupToggle(groupKey: string): Promise { - // 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, 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 { + 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 { + 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 { + 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 { + 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 +2387,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>(); - 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 diff --git a/src/bases/registration.ts b/src/bases/registration.ts index 83c6eba97..30923cacd 100644 --- a/src/bases/registration.ts +++ b/src/bases/registration.ts @@ -57,6 +57,13 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise, + }, { type: "dropdown", key: "expandedRelationshipFilterMode", @@ -65,7 +72,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise, }, ], }, @@ -175,7 +182,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise, }, ], }, diff --git a/styles/bases-views.css b/styles/bases-views.css index 64f1a9186..1434603a5 100644 --- a/styles/bases-views.css +++ b/styles/bases-views.css @@ -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; diff --git a/tests/unit/ui/TaskListView.groupCollapse.test.ts b/tests/unit/ui/TaskListView.groupCollapse.test.ts new file mode 100644 index 000000000..a394f718e --- /dev/null +++ b/tests/unit/ui/TaskListView.groupCollapse.test.ts @@ -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); + }); +}); \ No newline at end of file