From 6545ce26e258994af6ec3d1df30a217822ecfdd3 Mon Sep 17 00:00:00 2001 From: Falk Eysen Date: Wed, 26 Aug 2026 15:13:20 +0200 Subject: [PATCH] feat(kanban): add collapsible columns --- docs/releases/unreleased.md | 5 + src/bases/KanbanView.ts | 68 +++ src/i18n/resources/en.ts | 1 + styles/kanban-view.css | 64 ++- ...-1341-kanban-column-collapse-state.test.ts | 471 ++++++------------ 5 files changed, 299 insertions(+), 310 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..33d4b1fa6 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Added + +- (#1341) Added controls to collapse individual columns in Kanban Bases views. Each view keeps its own collapsed columns when Obsidian refreshes or recreates it. + - Thanks to @meew0 for the suggestion diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 3581fe337..fba3271f9 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -114,6 +114,7 @@ type VirtualScrollerWithContainer = { type KanbanEphemeralState = { scrollTop?: unknown; columnScroll?: unknown; + collapsedColumns?: unknown; }; type KanbanDropExecutionOptions = { @@ -155,6 +156,16 @@ function getColumnScrollState(state: KanbanEphemeralState): Record | null { + if ( + !Array.isArray(state.collapsedColumns) || + !state.collapsedColumns.every((value): value is string => typeof value === "string") + ) { + return null; + } + return new Set(state.collapsedColumns); +} + function normalizeExpandedRelationshipFilterMode(value: unknown): "inherit" | "show-all" { if (typeof value === "number") { return value === 1 ? "show-all" : "inherit"; @@ -237,6 +248,7 @@ export class KanbanView extends BasesViewBase { private sortScopeCandidateTaskPaths = new Map(); private containerListenersRegistered = false; private columnScrollers = new Map>(); // columnKey -> scroller + private collapsedColumns = new Set(); private expandedRelationshipFilterMode: TaskCardOptions["expandedRelationshipFilterMode"] = "inherit"; private currentVisibleTaskPaths = new Set(); @@ -472,6 +484,7 @@ export class KanbanView extends BasesViewBase { ...baseStateObject, scrollTop: this.rootElement?.scrollTop || 0, columnScroll, + collapsedColumns: Array.from(this.collapsedColumns), }; } @@ -482,6 +495,12 @@ export class KanbanView extends BasesViewBase { if (!isKanbanEphemeralState(state)) return; super.setEphemeralState(state); const columnScroll = getColumnScrollState(state); + const collapsedColumns = getCollapsedColumnState(state); + if (collapsedColumns) { + this.collapsedColumns = collapsedColumns; + } else if ("collapsedColumns" in state) { + this.collapsedColumns.clear(); + } // Restore board-level horizontal scroll if (typeof state.scrollTop === "number" && this.rootElement) { @@ -1725,6 +1744,7 @@ export class KanbanView extends BasesViewBase { this.renderGroupTitleWrapper(titleContainer, groupKey, false, true); this.renderColumnCount(header, groupKey, tasks.length); + this.createColumnCollapseButton(header, column, groupKey, groupByPropertyId); // Setup column header drag handlers this.setupColumnHeaderDragHandlers(header); @@ -1758,6 +1778,54 @@ export class KanbanView extends BasesViewBase { return column; } + private createColumnCollapseButton( + header: HTMLElement, + column: HTMLElement, + groupKey: string, + groupByPropertyId: string | null + ): void { + const button = header.createEl("button", { + cls: "kanban-view__column-collapse-button", + attr: { + type: "button", + "aria-label": this.plugin.i18n.translate("views.kanban.toggleColumn", { + column: this.getGroupDisplayTitle(groupKey, groupByPropertyId), + }), + }, + }); + + const applyState = (collapsed: boolean) => { + column.classList.toggle("kanban-view__column--collapsed", collapsed); + column.dataset.collapsed = String(collapsed); + column.style.width = collapsed + ? "var(--tn-kanban-collapsed-column-width, 48px)" + : `${this.columnWidth}px`; + button.setAttribute("aria-expanded", String(!collapsed)); + setIcon(button, collapsed ? "chevron-right" : "chevron-left"); + }; + + applyState(this.collapsedColumns.has(groupKey)); + + for (const eventName of ["mousedown", "pointerdown", "touchstart"]) { + button.addEventListener(eventName, (event) => event.stopPropagation()); + } + button.addEventListener("dragstart", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const collapsed = !this.collapsedColumns.has(groupKey); + if (collapsed) { + this.collapsedColumns.add(groupKey); + } else { + this.collapsedColumns.delete(groupKey); + } + applyState(collapsed); + }); + } + private renderColumnCount(container: HTMLElement, groupKey: string, taskCount: number): void { const count = formatKanbanColumnCount(taskCount, this.wipLimits[groupKey]); const countEl = container.createSpan({ diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 2a427eb2f..ff5e455b7 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -307,6 +307,7 @@ export const en: TranslationTree = { title: "Kanban", newTask: "New task", addCard: "+ Add a card", + toggleColumn: "Collapse or expand {column}", noTasks: "No tasks", uncategorized: "Uncategorized", noProject: "No project", diff --git a/styles/kanban-view.css b/styles/kanban-view.css index f439a51e9..e6a42da85 100644 --- a/styles/kanban-view.css +++ b/styles/kanban-view.css @@ -196,7 +196,34 @@ flex-direction: column; height: fit-content; max-height: calc(100vh - 200px); - transition: border-color var(--tn-transition-fast), box-shadow var(--tn-transition-fast), background-color var(--tn-transition-fast); + transition: width var(--tn-transition-fast), min-width var(--tn-transition-fast), max-width var(--tn-transition-fast), border-color var(--tn-transition-fast), box-shadow var(--tn-transition-fast), background-color var(--tn-transition-fast); +} + +.tasknotes-plugin .kanban-view__column--collapsed { + min-width: var(--tn-kanban-collapsed-column-width, 48px); + max-width: var(--tn-kanban-collapsed-column-width, 48px); +} + +.tasknotes-plugin .kanban-view__column--collapsed .kanban-view__column-header { + flex-direction: column; + align-items: center; + padding: var(--tn-spacing-sm) var(--tn-spacing-xs); + border-bottom: 0; +} + +.tasknotes-plugin .kanban-view__column--collapsed .kanban-view__drag-handle, +.tasknotes-plugin .kanban-view__column--collapsed .kanban-view__column-icon, +.tasknotes-plugin .kanban-view__column--collapsed .kanban-view__column-count, +.tasknotes-plugin .kanban-view__column--collapsed .kanban-view__cards, +.tasknotes-plugin .kanban-view__column--collapsed .kanban-view__add-task-button { + display: none; +} + +.tasknotes-plugin .kanban-view__column--collapsed .kanban-view__column-title { + flex: none; + overflow: visible; + writing-mode: vertical-rl; + transform: rotate(180deg); } .tasknotes-plugin .kanban-view__column--dragover { @@ -370,6 +397,41 @@ font-weight: var(--tn-font-weight-semibold); } +.tasknotes-plugin .kanban-view__column-collapse-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + min-width: 24px; + min-height: 24px; + margin: 0; + padding: 0; + flex-shrink: 0; + border: 0; + border-radius: var(--tn-radius-sm); + box-shadow: none; + background: transparent; + color: var(--tn-text-muted); + cursor: var(--cursor-link, pointer); +} + +.tasknotes-plugin .kanban-view__column-collapse-button:hover, +.tasknotes-plugin .kanban-view__column-collapse-button:focus-visible { + background: var(--tn-interactive-hover); + color: var(--tn-text-normal); +} + +.tasknotes-plugin .kanban-view__column-collapse-button svg { + width: 16px; + height: 16px; +} + +.tasknotes-plugin .kanban-view__column--collapsed .kanban-view__column-collapse-button { + order: -1; +} + /* ================================================ KANBAN TASK CARDS ================================================ */ diff --git a/tests/unit/issues/issue-1341-kanban-column-collapse-state.test.ts b/tests/unit/issues/issue-1341-kanban-column-collapse-state.test.ts index 19d069f85..3f00bb54e 100644 --- a/tests/unit/issues/issue-1341-kanban-column-collapse-state.test.ts +++ b/tests/unit/issues/issue-1341-kanban-column-collapse-state.test.ts @@ -1,310 +1,163 @@ -/** - * Failing tests for Issue #1341: Kanban board column collapse/fold state persistence - * - * Feature request: Add option to keep toggle ALWAYS unfolded OR save state of fold/unfold - * - * These tests describe the expected behavior for: - * 1. Column collapse state management - * 2. Persistence of collapse state in view config - * 3. "Always expanded" toggle option - */ - -import { describe, it, expect } from '@jest/globals'; - -describe('Issue #1341: Kanban column collapse/fold state', () => { - /** - * Simulate the column collapse state manager that should be implemented. - * This helper mimics what KanbanView.readViewOptions() and related methods should do. - */ - function createColumnCollapseManager(config: { - alwaysExpandColumns?: boolean; - collapsedColumns?: Record; - }) { - const state = { - alwaysExpandColumns: config.alwaysExpandColumns ?? false, - collapsedColumns: { ...(config.collapsedColumns ?? {}) }, - }; - - return { - isColumnCollapsed(columnKey: string): boolean { - // When alwaysExpandColumns is true, columns are never collapsed - if (state.alwaysExpandColumns) { - return false; - } - return state.collapsedColumns[columnKey] ?? false; - }, - - toggleColumnCollapse(columnKey: string): void { - // When alwaysExpandColumns is true, toggling has no effect - if (state.alwaysExpandColumns) { - return; - } - state.collapsedColumns[columnKey] = !state.collapsedColumns[columnKey]; - }, - - setColumnCollapsed(columnKey: string, collapsed: boolean): void { - if (state.alwaysExpandColumns) { - return; - } - state.collapsedColumns[columnKey] = collapsed; - }, - - getCollapsedState(): Record { - return { ...state.collapsedColumns }; - }, - - setAlwaysExpand(value: boolean): void { - state.alwaysExpandColumns = value; - }, - - isAlwaysExpandEnabled(): boolean { - return state.alwaysExpandColumns; - }, - }; - } - - describe('Column collapse state tracking', () => { - it('should track collapsed state per column key', () => { - const manager = createColumnCollapseManager({}); - - expect(manager.isColumnCollapsed('todo')).toBe(false); - expect(manager.isColumnCollapsed('done')).toBe(false); - - manager.setColumnCollapsed('todo', true); - - expect(manager.isColumnCollapsed('todo')).toBe(true); - expect(manager.isColumnCollapsed('done')).toBe(false); - }); - - it('should toggle column collapse state', () => { - const manager = createColumnCollapseManager({}); - - expect(manager.isColumnCollapsed('in-progress')).toBe(false); - - manager.toggleColumnCollapse('in-progress'); - expect(manager.isColumnCollapsed('in-progress')).toBe(true); - - manager.toggleColumnCollapse('in-progress'); - expect(manager.isColumnCollapsed('in-progress')).toBe(false); - }); - - it('should restore collapsed state from saved config', () => { - const manager = createColumnCollapseManager({ - collapsedColumns: { - 'todo': false, - 'in-progress': true, - 'done': true, - }, - }); - - expect(manager.isColumnCollapsed('todo')).toBe(false); - expect(manager.isColumnCollapsed('in-progress')).toBe(true); - expect(manager.isColumnCollapsed('done')).toBe(true); - }); - - it('should return collapsed state for serialization', () => { - const manager = createColumnCollapseManager({}); - - manager.setColumnCollapsed('todo', false); - manager.setColumnCollapsed('in-progress', true); - manager.setColumnCollapsed('blocked', true); - - const state = manager.getCollapsedState(); - - expect(state).toEqual({ - 'todo': false, - 'in-progress': true, - 'blocked': true, - }); - }); - }); - - describe('Always expand columns option', () => { - it('should always return false for isColumnCollapsed when alwaysExpandColumns is true', () => { - const manager = createColumnCollapseManager({ - alwaysExpandColumns: true, - collapsedColumns: { - 'todo': true, - 'in-progress': true, - }, - }); - - // Even though columns are marked as collapsed, they should appear expanded - expect(manager.isColumnCollapsed('todo')).toBe(false); - expect(manager.isColumnCollapsed('in-progress')).toBe(false); - expect(manager.isColumnCollapsed('done')).toBe(false); - }); - - it('should ignore toggle requests when alwaysExpandColumns is true', () => { - const manager = createColumnCollapseManager({ - alwaysExpandColumns: true, - }); - - manager.toggleColumnCollapse('todo'); - - // Toggle should have no effect - expect(manager.isColumnCollapsed('todo')).toBe(false); - // The underlying state should not be modified - expect(manager.getCollapsedState()).toEqual({}); - }); - - it('should ignore setColumnCollapsed requests when alwaysExpandColumns is true', () => { - const manager = createColumnCollapseManager({ - alwaysExpandColumns: true, - }); - - manager.setColumnCollapsed('todo', true); - - expect(manager.isColumnCollapsed('todo')).toBe(false); - expect(manager.getCollapsedState()).toEqual({}); - }); - - it('should allow toggling alwaysExpandColumns setting', () => { - const manager = createColumnCollapseManager({ - alwaysExpandColumns: false, - }); - - expect(manager.isAlwaysExpandEnabled()).toBe(false); - - manager.setAlwaysExpand(true); - expect(manager.isAlwaysExpandEnabled()).toBe(true); - - manager.setAlwaysExpand(false); - expect(manager.isAlwaysExpandEnabled()).toBe(false); - }); - - it('should restore individual collapse states when alwaysExpandColumns is disabled', () => { - const manager = createColumnCollapseManager({ - alwaysExpandColumns: false, - collapsedColumns: { - 'todo': true, - 'done': true, - }, - }); - - // Initially columns are collapsed per their saved state - expect(manager.isColumnCollapsed('todo')).toBe(true); - expect(manager.isColumnCollapsed('done')).toBe(true); - - // Enable always expand - manager.setAlwaysExpand(true); - expect(manager.isColumnCollapsed('todo')).toBe(false); - expect(manager.isColumnCollapsed('done')).toBe(false); - - // Disable always expand - collapsed state should be restored - manager.setAlwaysExpand(false); - expect(manager.isColumnCollapsed('todo')).toBe(true); - expect(manager.isColumnCollapsed('done')).toBe(true); - }); - }); - - describe('Config serialization format', () => { - it('should serialize collapsed state to JSON string like columnOrder', () => { - // This mirrors how KanbanView stores columnOrder as JSON string in BasesViewConfig - const collapsedState = { - 'todo': false, - 'in-progress': true, - 'done': true, - }; - - const serialized = JSON.stringify(collapsedState); - const deserialized = JSON.parse(serialized); - - expect(deserialized).toEqual(collapsedState); - }); - - it('should handle empty collapsed state gracefully', () => { - const emptyState = {}; - const serialized = JSON.stringify(emptyState); - - expect(serialized).toBe('{}'); - expect(JSON.parse(serialized)).toEqual({}); - }); - }); -}); - -describe('Issue #1341: KanbanView integration expectations', () => { - /** - * These tests describe how the feature should integrate with KanbanView. - * They test the expected interface that KanbanView should expose. - */ - - // Mock the config interface that KanbanView uses - interface MockConfig { - get(key: string): unknown; - set(key: string, value: unknown): void; - } - - function createMockConfig(initialValues: Record = {}): MockConfig { - const values = { ...initialValues }; - return { - get(key: string): unknown { - return values[key]; - }, - set(key: string, value: unknown): void { - values[key] = value; - }, - }; - } - - describe('View options reading', () => { - it('should read alwaysExpandColumns from config (defaults to false)', () => { - const config = createMockConfig({}); - - const alwaysExpandValue = config.get('alwaysExpandColumns'); - const alwaysExpandColumns = (alwaysExpandValue as boolean) ?? false; - - expect(alwaysExpandColumns).toBe(false); - }); - - it('should read alwaysExpandColumns as true when set', () => { - const config = createMockConfig({ - alwaysExpandColumns: true, - }); - - const alwaysExpandColumns = config.get('alwaysExpandColumns') as boolean; - - expect(alwaysExpandColumns).toBe(true); - }); - - it('should read collapsedColumns from config as JSON string', () => { - const config = createMockConfig({ - collapsedColumns: JSON.stringify({ 'todo': true, 'done': false }), - }); - - const collapsedColumnsStr = (config.get('collapsedColumns') as string) || '{}'; - const collapsedColumns = JSON.parse(collapsedColumnsStr); - - expect(collapsedColumns).toEqual({ 'todo': true, 'done': false }); - }); - - it('should handle missing collapsedColumns config gracefully', () => { - const config = createMockConfig({}); - - const collapsedColumnsStr = (config.get('collapsedColumns') as string) || '{}'; - const collapsedColumns = JSON.parse(collapsedColumnsStr); - - expect(collapsedColumns).toEqual({}); - }); - }); - - describe('View options writing', () => { - it('should save collapsedColumns to config as JSON string', () => { - const config = createMockConfig({}); - - const collapsedState = { 'in-progress': true, 'blocked': true }; - config.set('collapsedColumns', JSON.stringify(collapsedState)); - - const savedValue = config.get('collapsedColumns') as string; - expect(JSON.parse(savedValue)).toEqual(collapsedState); - }); - - it('should save alwaysExpandColumns boolean to config', () => { - const config = createMockConfig({}); - - config.set('alwaysExpandColumns', true); - - expect(config.get('alwaysExpandColumns')).toBe(true); - }); - }); +import { KanbanView } from "../../../src/bases/KanbanView"; +import { StatusManager } from "../../../src/services/StatusManager"; +import type { StatusConfig } from "../../../src/types"; + +const OPEN_STATUS: StatusConfig = { + id: "open", + value: "open", + label: "Open", + color: "#7c3aed", + icon: "lucide-circle", + isCompleted: false, + order: 1, + autoArchive: false, + autoArchiveDelay: 5, +}; + +function makePlugin() { + return { + app: { + metadataCache: { + getFirstLinkpathDest: () => null, + getFileCache: () => undefined, + }, + vault: { + getAbstractFileByPath: () => null, + }, + workspace: { + getLeaf: () => ({ openFile: jest.fn() }), + openLinkText: jest.fn(), + }, + }, + fieldMapper: { + toUserField: (field: string) => field, + isRecognizedProperty: () => true, + getMapping: () => ({ sortOrder: "sort_order" }), + }, + statusManager: new StatusManager([OPEN_STATUS], "open"), + priorityManager: { + getAllPriorities: () => [], + normalizePriorityValue: (value: string) => value, + }, + i18n: { + translate: (key: string, variables?: Record) => + key === "views.kanban.toggleColumn" + ? `Toggle ${variables?.column ?? "column"}` + : key, + }, + settings: { + customStatuses: [OPEN_STATUS], + fieldMapping: { + sortOrder: "sort_order", + }, + }, + }; +} + +function makeView(): KanbanView { + const view = new KanbanView({}, document.createElement("div"), makePlugin() as any); + (view as any).config = { + get: jest.fn(() => undefined), + getOrder: jest.fn(() => []), + getDisplayName: jest.fn(() => undefined), + }; + return view; +} + +async function createColumn(view: KanbanView, groupKey = "open"): Promise { + return (view as any).createColumn(groupKey, [], [], "status"); +} + +describe("Issue #1341: Kanban column collapse state", () => { + it("renders a dedicated accessible control that toggles only its flat column", async () => { + const view = makeView(); + const column = await createColumn(view); + const header = column.querySelector(".kanban-view__column-header"); + const toggle = column.querySelector( + ".kanban-view__column-collapse-button" + ); + const bubbledClick = jest.fn(); + header?.addEventListener("click", bubbledClick); + + expect(toggle).not.toBeNull(); + expect(toggle?.type).toBe("button"); + expect(toggle?.getAttribute("aria-expanded")).toBe("true"); + expect(toggle?.getAttribute("aria-label")).toBe("Toggle Open"); + expect(column.classList.contains("kanban-view__column--collapsed")).toBe(false); + + toggle?.click(); + + expect(column.classList.contains("kanban-view__column--collapsed")).toBe(true); + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + expect(bubbledClick).not.toHaveBeenCalled(); + expect(view.getEphemeralState()).toEqual( + expect.objectContaining({ collapsedColumns: ["open"] }) + ); + + toggle?.click(); + + expect(column.classList.contains("kanban-view__column--collapsed")).toBe(false); + expect(toggle?.getAttribute("aria-expanded")).toBe("true"); + expect(view.getEphemeralState()).toEqual( + expect.objectContaining({ collapsedColumns: [] }) + ); + }); + + it("does not render collapse controls in swimlane column headers", async () => { + const view = makeView(); + const board = document.createElement("div"); + (view as any).boardEl = board; + (view as any).setupColumnHeaderDragHandlers = jest.fn(); + (view as any).setupSwimLaneCellDragDrop = jest.fn(); + (view as any).renderEmptyCellHint = jest.fn(); + (view as any).createAddTaskButton = jest.fn(); + + await (view as any).renderSwimLaneTable( + new Map([["todo", new Map([["open", []]])]]), + ["open"], + new Map(), + "status" + ); + + expect(board.querySelector(".kanban-view__column-header-cell")).not.toBeNull(); + expect(board.querySelector(".kanban-view__column-collapse-button")).toBeNull(); + }); + + it("restores collapsed keys in a recreated view instance", async () => { + const sourceView = makeView(); + const sourceColumn = await createColumn(sourceView, "open"); + sourceColumn.querySelector( + ".kanban-view__column-collapse-button" + )?.click(); + const savedState = { + ...sourceView.getEphemeralState(), + collapsedColumns: ["open", "no-longer-rendered"], + }; + + const restoredView = makeView(); + restoredView.setEphemeralState(savedState); + const restoredColumn = await createColumn(restoredView, "open"); + const otherColumn = await createColumn(restoredView, "done"); + + expect(restoredColumn.classList.contains("kanban-view__column--collapsed")).toBe(true); + expect( + restoredColumn + .querySelector(".kanban-view__column-collapse-button") + ?.getAttribute("aria-expanded") + ).toBe("false"); + expect(otherColumn.classList.contains("kanban-view__column--collapsed")).toBe(false); + }); + + it("ignores malformed restored collapse state", async () => { + const view = makeView(); + + expect(() => + view.setEphemeralState({ + collapsedColumns: ["open", 42, null], + }) + ).not.toThrow(); + + const column = await createColumn(view, "open"); + expect(column.classList.contains("kanban-view__column--collapsed")).toBe(false); + }); });