Merge remote-tracking branch 'origin/main'

This commit is contained in:
callumalpass 2026-05-29 08:09:00 +10:00
commit de9372ca8e
8 changed files with 573 additions and 68 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

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

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

View file

@ -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 = "";
}
/**

View file

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

View file

@ -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,
},
],
},

View file

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

View 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);
});
});