From b568fddcc191c8c191119e5c580edb841094b3d4 Mon Sep 17 00:00:00 2001 From: Loukas Andreadelis Date: Sun, 26 Apr 2026 16:05:39 +0300 Subject: [PATCH 1/2] Add pinnedColumns option to Kanban view --- docs/views/kanban-view.md | 4 +- src/bases/KanbanView.ts | 54 ++- src/bases/registration.ts | 7 + .../issue-1784-kanban-pinned-columns.test.ts | 320 ++++++++++++++++++ 4 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 tests/unit/issues/issue-1784-kanban-pinned-columns.test.ts diff --git a/docs/views/kanban-view.md b/docs/views/kanban-view.md index 963a5717d..12615c96f 100644 --- a/docs/views/kanban-view.md +++ b/docs/views/kanban-view.md @@ -27,6 +27,7 @@ Access these options through the Bases view settings panel: - **Hide Empty Columns**: When enabled, columns containing no tasks are hidden from the view - **Show items in multiple columns**: When enabled (default), tasks with multiple values in list properties (contexts, tags, projects) appear in each individual column. For example, a task with `contexts: [work, call]` appears in both the "work" and "call" columns. When disabled, tasks appear in a single combined column (e.g., "work, call") - **Column Order**: Managed automatically when dragging column headers. Stores custom column ordering +- **Pinned Columns**: Comma-separated values that always render as columns, even when empty. Exempt from "Hide Empty Columns" so they remain available as drop targets A common setup is to keep one board grouped by status and another grouped by project or context, each in a separate `.base` file. ## Interface Layout @@ -120,6 +121,7 @@ views: swimLane: task.priority columnWidth: 300 hideEmptyColumns: true + pinnedColumns: to-do, in-progress, done --- ``` @@ -127,7 +129,7 @@ This configuration creates a Kanban board with: - Columns based on task status - Swimlanes based on task priority - 300px column width -- Empty columns hidden +- Empty columns hidden, except for the pinned `to-do`, `in-progress`, and `done` columns which always render ## Filtering and Sorting diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 7525a2a0b..2d255c9b3 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -105,6 +105,7 @@ export class KanbanView extends BasesViewBase { private explodeListColumns = true; // Show items with list properties in multiple columns private consolidateStatusIcon = false; // Show status icon in header only when grouped by status private columnOrders: Record = {}; + private pinnedColumns: string[] = []; private configLoaded = false; // Track if we've successfully loaded config /** * Threshold for enabling virtual scrolling in kanban columns/swimlane cells. @@ -201,6 +202,23 @@ export class KanbanView extends BasesViewBase { const columnOrderStr = (this.config.get("columnOrder") as string) || "{}"; this.columnOrders = JSON.parse(columnOrderStr); + // Read pinned columns. Comma-separated string (settings panel) or + // YAML array (authored in `.base`). Normalize either shape. + const rawPinned = this.config.get("pinnedColumns"); + const pinnedSource: unknown[] = Array.isArray(rawPinned) + ? rawPinned + : typeof rawPinned === "string" + ? rawPinned.split(",") + : []; + const seenPinned = new Set(); + this.pinnedColumns = []; + for (const value of pinnedSource) { + const str = String(value ?? "").trim(); + if (str.length === 0 || seenPinned.has(str)) continue; + seenPinned.add(str); + this.pinnedColumns.push(str); + } + // Read enableSearch toggle (default: false for backward compatibility) const enableSearchValue = this.config.get("enableSearch"); this.enableSearch = (enableSearchValue as boolean) ?? false; @@ -583,6 +601,9 @@ export class KanbanView extends BasesViewBase { // Augment with empty priority columns if grouping by priority this.augmentWithEmptyPriorityColumns(groups, groupByPropertyId); + // Augment with pinned columns regardless of groupBy property + this.augmentWithPinnedColumns(groups); + return groups; } @@ -726,6 +747,18 @@ export class KanbanView extends BasesViewBase { } } + /** + * Augment groups with empty buckets for pinned column keys. + * Runs regardless of groupBy. + */ + private augmentWithPinnedColumns(groups: Map): void { + for (const key of this.pinnedColumns) { + if (!groups.has(key)) { + groups.set(key, []); + } + } + } + private async renderFlat( groups: Map, allGroups: Map @@ -760,8 +793,13 @@ export class KanbanView extends BasesViewBase { for (const groupKey of orderedKeys) { const tasks = groups.get(groupKey) || []; - // Filter empty columns if option enabled - if (this.hideEmptyColumns && tasks.length === 0) { + // Filter empty columns if option enabled. Pinned columns are exempt + // so they remain visible as drop targets. + if ( + this.hideEmptyColumns && + tasks.length === 0 && + !this.pinnedColumns.includes(groupKey) + ) { continue; } @@ -3031,8 +3069,16 @@ export class KanbanView extends BasesViewBase { const savedOrder = this.columnOrders[groupBy]; if (!savedOrder || savedOrder.length === 0) { - // No saved order - use natural order (alphabetical) - return actualKeys.sort(); + // No saved order: pinned columns first (in pinnedColumns array order), + // remaining keys alphabetical. + if (this.pinnedColumns.length === 0) { + return actualKeys.sort(); + } + const pinnedPresent = this.pinnedColumns.filter((k) => actualKeys.includes(k)); + const remaining = actualKeys + .filter((k) => !this.pinnedColumns.includes(k)) + .sort(); + return [...pinnedPresent, ...remaining]; } const ordered: string[] = []; diff --git a/src/bases/registration.ts b/src/bases/registration.ts index 81772876e..834ce4409 100644 --- a/src/bases/registration.ts +++ b/src/bases/registration.ts @@ -119,6 +119,13 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise(); + const result: string[] = []; + for (const value of source) { + const str = String(value ?? "").trim(); + if (str.length === 0 || seen.has(str)) continue; + seen.add(str); + result.push(str); + } + return result; +} + +/** Mirrors `KanbanView.augmentWithPinnedColumns()`. */ +function augmentWithPinnedColumns(groups: Map, pinnedColumns: string[]): void { + for (const key of pinnedColumns) { + if (!groups.has(key)) { + groups.set(key, []); + } + } +} + +/** Mirrors `KanbanView.applyColumnOrder()`. */ +function applyColumnOrder( + savedOrder: string[] | undefined, + pinnedColumns: string[], + actualKeys: string[] +): string[] { + if (!savedOrder || savedOrder.length === 0) { + if (pinnedColumns.length === 0) { + return [...actualKeys].sort(); + } + const pinnedPresent = pinnedColumns.filter((k) => actualKeys.includes(k)); + const remaining = actualKeys.filter((k) => !pinnedColumns.includes(k)).sort(); + return [...pinnedPresent, ...remaining]; + } + + const ordered: string[] = []; + const unsorted: string[] = []; + for (const key of savedOrder) { + if (actualKeys.includes(key)) { + ordered.push(key); + } + } + for (const key of actualKeys) { + if (!savedOrder.includes(key)) { + unsorted.push(key); + } + } + return [...ordered, ...unsorted.sort()]; +} + +/** Mirrors the `renderFlat()` visibility filter. */ +function filterVisibleColumns( + orderedKeys: string[], + groups: Map, + hideEmptyColumns: boolean, + pinnedColumns: string[] +): string[] { + const result: string[] = []; + for (const groupKey of orderedKeys) { + const tasks = groups.get(groupKey) ?? []; + if (hideEmptyColumns && tasks.length === 0 && !pinnedColumns.includes(groupKey)) { + continue; + } + result.push(groupKey); + } + return result; +} + +interface MockTask { + path: string; + status?: string; + priority?: string; + tags?: string[]; +} + +describe("Issue #1784: Kanban pinned columns", () => { + describe("parsePinnedColumns", () => { + it("passes through a string array deduped", () => { + expect(parsePinnedColumns(["backlog", "in-progress", "done"])).toEqual([ + "backlog", + "in-progress", + "done", + ]); + }); + + it("parses a comma-separated string into a trimmed array", () => { + expect(parsePinnedColumns("backlog, in-progress ,done")).toEqual([ + "backlog", + "in-progress", + "done", + ]); + }); + + it("returns [] for undefined, null, numbers, and other non-array/non-string", () => { + expect(parsePinnedColumns(undefined)).toEqual([]); + expect(parsePinnedColumns(null)).toEqual([]); + expect(parsePinnedColumns(42)).toEqual([]); + expect(parsePinnedColumns({ a: 1 })).toEqual([]); + expect(parsePinnedColumns(true)).toEqual([]); + }); + + it("drops empty and whitespace-only entries", () => { + expect(parsePinnedColumns(["", " ", "done", "\t"])).toEqual(["done"]); + expect(parsePinnedColumns(", ,done, ,")).toEqual(["done"]); + }); + + it("removes duplicates preserving first-seen order", () => { + expect( + parsePinnedColumns(["done", "backlog", "done", "in-progress", "backlog"]) + ).toEqual(["done", "backlog", "in-progress"]); + }); + + it("coerces non-string entries to string then trims", () => { + expect(parsePinnedColumns([1, 2, " 3 ", null, undefined, "done"])).toEqual([ + "1", + "2", + "3", + "done", + ]); + }); + }); + + describe("augmentWithPinnedColumns", () => { + it("inserts an empty bucket for a pinned key missing from groups", () => { + const groups = new Map(); + augmentWithPinnedColumns(groups, ["backlog"]); + expect(groups.has("backlog")).toBe(true); + expect(groups.get("backlog")).toEqual([]); + }); + + it("does not overwrite an existing populated bucket", () => { + const existing: MockTask[] = [{ path: "a.md", status: "done" }]; + const groups = new Map([["done", existing]]); + augmentWithPinnedColumns(groups, ["done"]); + expect(groups.get("done")).toBe(existing); + expect(groups.get("done")).toHaveLength(1); + }); + + it("iterates pinnedColumns regardless of groupBy property", () => { + const groups = new Map([ + ["high", [{ path: "a.md", priority: "high" }]], + ]); + augmentWithPinnedColumns(groups, ["urgent", "low"]); + expect([...groups.keys()].sort()).toEqual(["high", "low", "urgent"]); + expect(groups.get("urgent")).toEqual([]); + expect(groups.get("low")).toEqual([]); + }); + }); + + describe("applyColumnOrder (no saved order)", () => { + it("falls back to alphabetical when pinnedColumns is empty", () => { + expect(applyColumnOrder(undefined, [], ["c", "a", "b"])).toEqual(["a", "b", "c"]); + expect(applyColumnOrder([], [], ["c", "a", "b"])).toEqual(["a", "b", "c"]); + }); + + it("renders pinned-first in array order, remaining alphabetical", () => { + const result = applyColumnOrder( + undefined, + ["backlog", "in-progress", "done"], + ["done", "backlog", "in-progress", "archived", "review"] + ); + expect(result).toEqual(["backlog", "in-progress", "done", "archived", "review"]); + }); + + it("omits pinned keys not present in actualKeys (no phantom rendering)", () => { + const result = applyColumnOrder( + undefined, + ["backlog", "never-used", "done"], + ["done", "backlog", "review"] + ); + expect(result).toEqual(["backlog", "done", "review"]); + expect(result).not.toContain("never-used"); + }); + }); + + describe("applyColumnOrder (saved order set)", () => { + it("uses the saved drag order over pinnedColumns array order", () => { + const result = applyColumnOrder( + ["done", "backlog", "in-progress"], + ["backlog", "in-progress", "done"], + ["backlog", "in-progress", "done"] + ); + expect(result).toEqual(["done", "backlog", "in-progress"]); + }); + + it("appends new pinned keys not in saved order alphabetically", () => { + const result = applyColumnOrder( + ["done", "backlog"], + ["backlog", "in-progress", "review"], + ["backlog", "done", "in-progress", "review"] + ); + expect(result).toEqual(["done", "backlog", "in-progress", "review"]); + }); + }); + + describe("filterVisibleColumns", () => { + const groups = new Map([ + ["backlog", []], + ["in-progress", [{ path: "a.md" }]], + ["done", []], + ["review", [{ path: "b.md" }]], + ]); + + it("hides empty non-pinned columns when hideEmptyColumns is true", () => { + const visible = filterVisibleColumns( + ["backlog", "in-progress", "done", "review"], + groups, + true, + [] + ); + expect(visible).toEqual(["in-progress", "review"]); + }); + + it("keeps empty pinned columns when hideEmptyColumns is true", () => { + const visible = filterVisibleColumns( + ["backlog", "in-progress", "done", "review"], + groups, + true, + ["backlog", "done"] + ); + expect(visible).toEqual(["backlog", "in-progress", "done", "review"]); + }); + + it("keeps non-empty columns regardless of pinned status", () => { + const visible = filterVisibleColumns(["in-progress", "review"], groups, true, []); + expect(visible).toEqual(["in-progress", "review"]); + }); + + it("keeps every column when hideEmptyColumns is false", () => { + const visible = filterVisibleColumns( + ["backlog", "in-progress", "done", "review"], + groups, + false, + [] + ); + expect(visible).toEqual(["backlog", "in-progress", "done", "review"]); + }); + }); + + describe("Integration: parser + augmenter + ordering + filter", () => { + function pipeline( + rawPinned: unknown, + groupedTasks: Map, + savedOrder: string[] | undefined, + hideEmptyColumns: boolean + ): string[] { + const pinnedColumns = parsePinnedColumns(rawPinned); + augmentWithPinnedColumns(groupedTasks, pinnedColumns); + const ordered = applyColumnOrder( + savedOrder, + pinnedColumns, + Array.from(groupedTasks.keys()) + ); + return filterVisibleColumns(ordered, groupedTasks, hideEmptyColumns, pinnedColumns); + } + + it("groupBy=status: pinned trio, only done populated, hides others alphabetical", () => { + const groups = new Map([ + ["done", [{ path: "a.md", status: "done" }]], + ["archived", []], + ]); + const visible = pipeline("backlog, in-progress, done", groups, undefined, true); + expect(visible).toEqual(["backlog", "in-progress", "done"]); + }); + + it("groupBy=status: extra non-pinned non-empty column appears after pinned, alphabetical", () => { + const groups = new Map([ + ["done", [{ path: "a.md" }]], + ["review", [{ path: "b.md" }]], + ]); + const visible = pipeline(["backlog", "in-progress", "done"], groups, undefined, true); + expect(visible).toEqual(["backlog", "in-progress", "done", "review"]); + }); + + it("groupBy=priority: same shape works (property-agnostic)", () => { + const groups = new Map([ + ["high", [{ path: "a.md", priority: "high" }]], + ]); + const visible = pipeline(["low", "normal", "high"], groups, undefined, true); + expect(visible).toEqual(["low", "normal", "high"]); + }); + + it("groupBy=tags: same shape works (property-agnostic)", () => { + const groups = new Map([ + ["urgent", [{ path: "a.md", tags: ["urgent"] }]], + ["stale", []], + ]); + const visible = pipeline(["waiting", "urgent"], groups, undefined, true); + expect(visible).toEqual(["waiting", "urgent"]); + }); + + it("unknown pinned key (no matching tasks) renders as empty pinned column", () => { + const groups = new Map([["done", [{ path: "a.md" }]]]); + const visible = pipeline("totally-made-up, done", groups, undefined, true); + expect(visible).toEqual(["totally-made-up", "done"]); + expect(groups.get("totally-made-up")).toEqual([]); + }); + }); +}); From 0bbf9cde35ad1f9027a1ee4f4ee2ea4469d5fdce Mon Sep 17 00:00:00 2001 From: Loukas Andreadelis Date: Sun, 26 Apr 2026 16:23:02 +0300 Subject: [PATCH 2/2] Parse pinnedColumns before columnOrder JSON.parse --- src/bases/KanbanView.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 2d255c9b3..7c274fa5a 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -198,12 +198,10 @@ export class KanbanView extends BasesViewBase { const consolidateValue = this.config.get('consolidateStatusIcon'); this.consolidateStatusIcon = consolidateValue === true; // Default to false if not set - // Read column orders - const columnOrderStr = (this.config.get("columnOrder") as string) || "{}"; - this.columnOrders = JSON.parse(columnOrderStr); - // Read pinned columns. Comma-separated string (settings panel) or // YAML array (authored in `.base`). Normalize either shape. + // Parsed before the columnOrder JSON.parse below so a malformed + // columnOrder cannot leave stale pinnedColumns state. const rawPinned = this.config.get("pinnedColumns"); const pinnedSource: unknown[] = Array.isArray(rawPinned) ? rawPinned @@ -219,6 +217,10 @@ export class KanbanView extends BasesViewBase { this.pinnedColumns.push(str); } + // Read column orders + const columnOrderStr = (this.config.get("columnOrder") as string) || "{}"; + this.columnOrders = JSON.parse(columnOrderStr); + // Read enableSearch toggle (default: false for backward compatibility) const enableSearchValue = this.config.get("enableSearch"); this.enableSearch = (enableSearchValue as boolean) ?? false;