Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
## Removed

## Fixed

- (#2382) Fixed Kanban settings being ignored in existing `.base` files when nested under `options:` or `config:`, including empty-column hiding and pinned columns. Newly generated default files and exported v3 views now write settings directly on each view; existing files remain unchanged. See [Kanban View](https://tasknotes.dev/views/kanban-view/#configuration). Thanks to @techwiththiru for reporting this.
5 changes: 2 additions & 3 deletions docs/views/agenda-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ views:
listDayCount: 7
startDateProperty: file.ctime
titleProperty: file.basename
options:
showPropertyBasedEvents: false
showOverdueOnToday: true
showPropertyBasedEvents: false
showOverdueOnToday: true
order:
- note.status
- note.priority
Expand Down
44 changes: 20 additions & 24 deletions docs/views/default-base-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,8 @@ views:
groupBy:
property: status
direction: ASC
config:
columnWidth: 280
hideEmptyColumns: false
columnWidth: 280
hideEmptyColumns: false
```

## Tasks List
Expand Down Expand Up @@ -521,17 +520,16 @@ views:
- recurrence
- complete_instances
- file.tasks
options:
showScheduled: true
showDue: true
showRecurring: true
showTimeEntries: true
showTimeblocks: true
showPropertyBasedEvents: true
createDailyNotesFromDateLinks: true
calendarView: "timeGridWeek"
customDayCount: 3
firstDay: 1
showScheduled: true
showDue: true
showRecurring: true
showTimeEntries: true
showTimeblocks: true
showPropertyBasedEvents: true
createDailyNotesFromDateLinks: true
calendarView: "timeGridWeek"
customDayCount: 3
firstDay: 1
```

## Agenda
Expand All @@ -542,12 +540,11 @@ Note: Property-based events are disabled by default to avoid duplicate entries w
To build an Agenda variant for completed tasks that do not have due or scheduled dates, enable property-based events and use the completed-date property as the event start date:

```yaml
options:
showScheduled: false
showDue: false
showRecurring: false
showTimeEntries: false
showPropertyBasedEvents: true
showScheduled: false
showDue: false
showRecurring: false
showTimeEntries: false
showPropertyBasedEvents: true
showOverdueOnToday: false
createDailyNotesFromDateLinks: true
calendarView: "listWeek"
Expand Down Expand Up @@ -587,10 +584,9 @@ views:
- recurrence
- complete_instances
- file.tasks
options:
showPropertyBasedEvents: false
showOverdueOnToday: true
createDailyNotesFromDateLinks: true
showPropertyBasedEvents: false
showOverdueOnToday: true
createDailyNotesFromDateLinks: true
calendarView: "listWeek"
startDateProperty: file.ctime
listDayCount: 7
Expand Down
33 changes: 13 additions & 20 deletions docs/views/kanban-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ Choosing a stable `groupBy` field is the most important design decision. Frequen

### Kanban-Specific Options

Access these options through the Bases view settings panel:
Access these options through the Bases view settings panel. In YAML, put them directly on the view, alongside `type`, `groupBy`, and `sort`. Existing settings nested under `config:` or `options:` remain supported; direct settings take precedence, followed by `config:` and then `options:`. TaskNotes does not rewrite existing files.

- **Swim Lane**: Optional property for horizontal grouping. Creates a two-dimensional layout where tasks are organized by both column (groupBy) and row (swimLane)
- **Column Width**: Controls the width of columns in pixels. Range: 200-500px. Default: 280px
- **Max Swimlane Height**: Caps each swimlane row before the task area scrolls. Range: 300-1200px. Default: 600px
- **Hide Empty Columns**: When enabled, columns containing no tasks are hidden from the view
- **Pinned Columns**: Optional comma-separated list of column values that should stay visible even when empty. This is useful with **Hide Empty Columns** when each board needs a small stable subset of shared statuses or categories
- **Hide Empty Columns**: When enabled, unpinned columns containing no tasks matching the view's filters are hidden
- **Pinned Columns**: Optional comma-separated list of column values that should stay visible even when empty. This is useful with **Hide Empty Columns** when each board needs a small stable subset of shared statuses or categories. Pinning is not a whitelist: other columns with matching tasks remain visible
- **WIP Limits**: Advanced JSON configuration for showing per-column work-in-progress limits in column headers
- **Hide Empty Swimlanes**: When enabled, swimlanes with no visible top-level cards are hidden. When disabled, all configured priority values appear as swimlanes, even if they have no cards or only contain hidden subtasks
- **Card Layout**: Chooses the task card density for this board. **Default** uses the standard TaskNotes card layout. **Compact** uses a tighter card layout for denser boards
Expand Down Expand Up @@ -61,18 +61,16 @@ Each swimlane row includes:
To pin swimlanes in a stable order, set the advanced `swimLaneOrder` option to a JSON object keyed by the swimlane property:

```yaml
config:
swimLane: note.contexts
swimLaneOrder: '{"note.contexts":["transitcal","bill","cycles-research","astrolabe"]}'
swimLane: note.contexts
swimLaneOrder: '{"note.contexts":["transitcal","bill","cycles-research","astrolabe"]}'
```

Listed swimlane values render first in the configured order. Values not listed in `swimLaneOrder` render below them using the default order for status and priority swimlanes, or alphabetically for other properties. If `hideEmptySwimLanes` is disabled, listed values can remain visible even when no matching tasks are currently present. Priority swimlanes also include every configured priority, including priorities with no visible top-level cards.

To show WIP limits in column headers, set the advanced `wipLimits` option to a JSON object keyed by column value:

```yaml
config:
wipLimits: '{"in-progress":5,"review":2}'
wipLimits: '{"in-progress":5,"review":2}'
```

Columns with limits display counts as `(current/limit)`. When the current count is above the configured limit, the count uses the error color. In swimlane boards, the column header count is the total for that column across all swimlanes.
Expand Down Expand Up @@ -139,23 +137,18 @@ Virtual scrolling activates automatically based on task count. No configuration
A typical Kanban view `.base` file includes:

```yaml
---
type: query
source: TaskNotes
view: TaskNotes Kanban
views:
- name: TaskNotes Kanban
type: tasknotesKanban
groupBy:
property: status
config:
swimLane: priority
swimLaneOrder: '{"priority":["high","normal","low"]}'
columnWidth: 300
hideEmptyColumns: true
pinnedColumns: to-do, in-progress, done
wipLimits: '{"in-progress":5}'
---
direction: ASC
swimLane: priority
swimLaneOrder: '{"priority":["high","normal","low"]}'
columnWidth: 300
hideEmptyColumns: true
pinnedColumns: to-do, in-progress, done
wipLimits: '{"in-progress":5}'
```

This configuration creates a Kanban board with:
Expand Down
150 changes: 150 additions & 0 deletions e2e/issues/issue-2382-kanban-nested-settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { test, expect } from '@playwright/test';
import { parse, stringify } from 'yaml';
import { launchObsidian, closeObsidian, type ObsidianApp } from '../obsidian';

const statuses = ['ready', 'in-progress', 'done', 'inbox', 'blocked', 'next', 'waiting', 'ordered', 'shipped', 'received', 'returned'];
const shopping = ['ordered', 'shipped', 'received', 'returned'];
const folder = `Issue2382-${Date.now()}`;
let instance: ObsidianApp;
let originalSettings: any;

const settings = {
columnWidth: 300,
hideEmptyColumns: true,
pinnedColumns: shopping.join(', '),
};

function filteredView(swimlanes = false) {
return {
type: 'tasknotesKanban',
name: 'Purchase Board',
filters: {
and: [
`file.inFolder("${folder}")`,
`file.hasLink("${folder}/Purchase.md")`,
{ or: shopping.map(status => `status == "${status}"`) },
],
},
groupBy: { property: 'status', direction: 'ASC' },
...(swimlanes ? { swimLane: 'priority' } : {}),
};
}

async function renderBase(name: string, content: string, swimlanes = false) {
const path = `${folder}/${name}.base`;
await instance.page.evaluate(async ({ path, content }) => {
const app = (window as any).app;
const file = await app.vault.create(path, content);
await app.workspace.getLeaf(false).openFile(file);
}, { path, content });
const selector = swimlanes
? '.workspace-leaf.mod-active .kanban-view__column-header-cell[data-column-key]'
: '.workspace-leaf.mod-active .kanban-view__column[data-group]';
const columns = instance.page.locator(selector);
await expect(columns).toHaveCount(4);
expect(await columns.evaluateAll((elements, attribute) => elements.map(el => el.getAttribute(attribute)),
swimlanes ? 'data-column-key' : 'data-group')).toEqual(shopping);
await expect(instance.page.locator('.workspace-leaf.mod-active .kanban-view__column-count').filter({ hasText: '(2)' })).toHaveCount(1);
// Reading/rendering must not migrate or rewrite the existing YAML.
expect(await instance.page.evaluate(async path => {
const app = (window as any).app;
return app.vault.read(app.vault.getAbstractFileByPath(path));
}, path)).toBe(content);
}

test.beforeAll(async () => {
instance = await launchObsidian();
originalSettings = await instance.page.evaluate(async ({ statuses, folder }) => {
const app = (window as any).app;
const plugin = app.plugins.plugins.tasknotes;
const original = {
customStatuses: plugin.settings.customStatuses,
taskIdentificationMethod: plugin.settings.taskIdentificationMethod,
taskTag: plugin.settings.taskTag,
commandFileMapping: plugin.settings.commandFileMapping,
};
plugin.settings.customStatuses = statuses.map((value, order) => ({
id: value, value, label: value, color: '#808080', isCompleted: value === 'done', order,
autoArchive: false, autoArchiveDelay: 0,
}));
plugin.statusManager.updateStatuses(plugin.settings.customStatuses);
plugin.settings.taskIdentificationMethod = 'tag';
plugin.settings.taskTag = 'task';
plugin.settings.commandFileMapping = {
...plugin.settings.commandFileMapping,
'open-kanban-view': `${folder}/generated.base`,
};
await plugin.saveSettings();
await app.vault.createFolder(folder);
await app.vault.create(`${folder}/Purchase.md`, '# Purchase\n');
const statusField = plugin.fieldMapper.toUserField('status');
const priorityField = plugin.fieldMapper.toUserField('priority');
for (const [name, status] of [['Amazon', 'received'], ['Newegg', 'received'], ['GTD', 'ready']]) {
await app.vault.create(`${folder}/${name}.md`, `---\n${statusField}: ${status}\n${priorityField}: normal\ntags: [task]\ntype: "[[${folder}/Purchase]]"\n---\n`);
}
return original;
}, { statuses, folder });
await expect.poll(() => instance.page.evaluate(async folder => {
const plugin = (window as any).app.plugins.plugins.tasknotes;
const tasks = await Promise.all(['Amazon', 'Newegg', 'GTD'].map(name => plugin.cacheManager.getTaskInfo(`${folder}/${name}.md`)));
return tasks.filter(Boolean).length;
}, folder)).toBe(3);
});

test.afterAll(async () => {
if (!instance) return;
try {
if (originalSettings) {
await instance.page.evaluate(async ({ originalSettings, folder }) => {
const app = (window as any).app;
const plugin = app.plugins.plugins.tasknotes;
Object.assign(plugin.settings, originalSettings);
plugin.statusManager.updateStatuses(plugin.settings.customStatuses);
await plugin.saveSettings();
const fixture = app.vault.getAbstractFileByPath(folder);
if (fixture) await app.vault.delete(fixture, true);
}, { originalSettings, folder });
}
} finally { await closeObsidian(instance); }
});

for (const placement of ['direct', 'config', 'options']) {
for (const swimlanes of [false, true]) {
test(`${placement} settings keep only shopping columns (${swimlanes ? 'swimlanes' : 'flat'})`, async () => {
const options = { ...settings, ...(swimlanes ? { swimLane: 'priority' } : {}) };
const view = { ...filteredView(), ...(placement === 'direct' ? options : { [placement]: options }) };
await renderBase(`${placement}-${swimlanes}`, stringify({ views: [view] }), swimlanes);
});
}
}

test('generated default Kanban settings are flat and load through native Bases', async () => {
const content = await instance.page.evaluate(async folder => {
const app = (window as any).app;
await app.plugins.plugins.tasknotes.ensureBasesViewFiles();
return app.vault.read(app.vault.getAbstractFileByPath(`${folder}/generated.base`));
}, folder);
const base = parse(content);
expect(base.views[0].config).toBeUndefined();
expect(base.views[0].options).toBeUndefined();
expect(base.views[0]).toMatchObject({ columnWidth: 280, hideEmptyColumns: false });
Object.assign(base.views[0], filteredView(), settings);
await renderBase('generated-filtered', stringify(base));
});

test('exported v3 Kanban settings load through native Bases', async () => {
const content = await instance.page.evaluate(settings => {
const plugin = (window as any).app.plugins.plugins.tasknotes;
return plugin.basesFilterConverter.convertAllSavedViewsToBasesFile([{
id: 'issue-2382', name: 'Purchase Board',
query: { type: 'group', conjunction: 'and', children: [], groupKey: 'status' },
viewOptions: settings,
}]);
}, settings);
const base = parse(content);
expect(base.views[0].config).toBeUndefined();
expect(base.views[0].options).toBeUndefined();
expect(base.views[0]).toMatchObject(settings);
Object.assign(base.views[0], filteredView());
await renderBase('exported', stringify(base));
});
34 changes: 17 additions & 17 deletions src/bases/KanbanView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Notice, Platform, setIcon, setTooltip, TFile } from "obsidian";
import type { BasesView, BasesViewFactory } from "obsidian";
import TaskNotesPlugin from "../main";
import { BasesViewBase } from "./BasesViewBase";
import { readBasesViewConfigValue, readBasesViewPropertyId } from "./basesViewConfig";
import type { StatusConfig, TaskInfo } from "../types";
import { identifyTaskNotesFromBasesData } from "./helpers";
import { createTaskCard, showTaskContextMenu, type TaskCardOptions } from "../ui/TaskCard";
Expand Down Expand Up @@ -374,42 +375,41 @@ export class KanbanView extends BasesViewBase {
}

try {
this.swimLanePropertyId = this.config.getAsPropertyId("swimLane");
this.columnWidth = (this.config.get("columnWidth") as number) || 280;
this.maxSwimlaneHeight = (this.config.get("maxSwimlaneHeight") as number) || 600;
this.hideEmptyColumns = (this.config.get("hideEmptyColumns") as boolean) || false;
const get = (key: string): unknown => readBasesViewConfigValue(this.config, key);
this.swimLanePropertyId = readBasesViewPropertyId(this.config, "swimLane");
this.columnWidth = (get("columnWidth") as number) || 280;
this.maxSwimlaneHeight = (get("maxSwimlaneHeight") as number) || 600;
this.hideEmptyColumns = get("hideEmptyColumns") === true;

// Read explodeListColumns option (defaults to true)
const explodeValue = this.config.get("explodeListColumns");
const explodeValue = get("explodeListColumns");
this.explodeListColumns = explodeValue !== false; // Default to true if not set

// Read consolidateStatusIcon option (defaults to false)
const consolidateValue = this.config.get("consolidateStatusIcon");
const consolidateValue = get("consolidateStatusIcon");
this.consolidateStatusIcon = consolidateValue === true; // Default to false if not set

// Read column orders
this.columnOrders = normalizeKanbanOrderConfig(this.config.get("columnOrder"));
this.pinnedColumns = normalizePinnedColumnConfig(this.config.get("pinnedColumns"));
this.wipLimits = normalizeKanbanWipLimitsConfig(this.config.get("wipLimits"));
this.columnOrders = normalizeKanbanOrderConfig(get("columnOrder"));
this.pinnedColumns = normalizePinnedColumnConfig(get("pinnedColumns"));
this.wipLimits = normalizeKanbanWipLimitsConfig(get("wipLimits"));

// Read swimlane orders. Support both the public singular key and the
// originally proposed plural key for manually-authored Bases YAML.
this.swimLaneOrders = normalizeKanbanOrderConfig(
this.config.get("swimLaneOrder") ?? this.config.get("swimLaneOrders")
get("swimLaneOrder") ?? get("swimLaneOrders")
);
this.hideEmptySwimLanes = this.config.get("hideEmptySwimLanes") === true;
this.cardLayout = normalizeKanbanCardLayout(this.config.get("cardLayout"));
this.hideEmptySwimLanes = get("hideEmptySwimLanes") === true;
this.cardLayout = normalizeKanbanCardLayout(get("cardLayout"));

// Read enableSearch toggle (default: false for backward compatibility)
const enableSearchValue = this.config.get("enableSearch");
const enableSearchValue = get("enableSearch");
this.enableSearch = (enableSearchValue as boolean) ?? false;
const expandedRelationshipFilterModeValue = this.config.get(
"expandedRelationshipFilterMode"
);
const expandedRelationshipFilterModeValue = get("expandedRelationshipFilterMode");
this.expandedRelationshipFilterMode = normalizeExpandedRelationshipFilterMode(
expandedRelationshipFilterModeValue
);
this.hideTopLevelSubtasks = this.config.get("hideTopLevelSubtasks") === true;
this.hideTopLevelSubtasks = get("hideTopLevelSubtasks") === true;

// Mark config as successfully loaded
this.configLoaded = true;
Expand Down
Loading
Loading