diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index ff5137ae2..b967e6790 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -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. diff --git a/docs/views/agenda-view.md b/docs/views/agenda-view.md index 7f5db2d40..1fdd977f0 100644 --- a/docs/views/agenda-view.md +++ b/docs/views/agenda-view.md @@ -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 diff --git a/docs/views/default-base-templates.md b/docs/views/default-base-templates.md index 625a06c81..4f140dab8 100644 --- a/docs/views/default-base-templates.md +++ b/docs/views/default-base-templates.md @@ -242,9 +242,8 @@ views: groupBy: property: status direction: ASC - config: - columnWidth: 280 - hideEmptyColumns: false + columnWidth: 280 + hideEmptyColumns: false ``` ## Tasks List @@ -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 @@ -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" @@ -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 diff --git a/docs/views/kanban-view.md b/docs/views/kanban-view.md index 5b3493fa3..67170bcfb 100644 --- a/docs/views/kanban-view.md +++ b/docs/views/kanban-view.md @@ -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 @@ -61,9 +61,8 @@ 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. @@ -71,8 +70,7 @@ Listed swimlane values render first in the configured order. Values not listed i 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. @@ -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: diff --git a/e2e/issues/issue-2382-kanban-nested-settings.spec.ts b/e2e/issues/issue-2382-kanban-nested-settings.spec.ts new file mode 100644 index 000000000..5e3828821 --- /dev/null +++ b/e2e/issues/issue-2382-kanban-nested-settings.spec.ts @@ -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)); +}); diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 67af6b6bb..617937378 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -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"; @@ -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; diff --git a/src/bases/MiniCalendarView.ts b/src/bases/MiniCalendarView.ts index f27a348e4..558e5b4e4 100644 --- a/src/bases/MiniCalendarView.ts +++ b/src/bases/MiniCalendarView.ts @@ -11,6 +11,7 @@ import { import type { BasesEntry, BasesPropertyId, BasesView, BasesViewFactory } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesViewBase } from "./BasesViewBase"; +import { readBasesViewConfigValue } from "./basesViewConfig"; import { ICSEvent, TaskInfo } from "../types"; import { format } from "date-fns"; import { @@ -173,11 +174,10 @@ export class MiniCalendarView extends BasesViewBase { } try { - this.dateProperty = (this.config.get("dateProperty") as string) || "file.ctime"; - this.titleProperty = (this.config.get("titleProperty") as string) || "file.name"; - this.heatMapMaxCount = normalizeMiniCalendarHeatMapMaxCount( - this.config.get("heatMapMaxCount") - ); + const get = (key: string): unknown => readBasesViewConfigValue(this.config, key); + this.dateProperty = (get("dateProperty") as string) || "file.ctime"; + this.titleProperty = (get("titleProperty") as string) || "file.name"; + this.heatMapMaxCount = normalizeMiniCalendarHeatMapMaxCount(get("heatMapMaxCount")); this.readCalendarToggles(); this.configLoaded = true; } catch (e) { @@ -198,7 +198,8 @@ export class MiniCalendarView extends BasesViewBase { return; } - const getToggleValue = (key: string): boolean => this.config.get(key) !== false; + const getToggleValue = (key: string): boolean => + readBasesViewConfigValue(this.config, key) !== false; if (this.plugin.icsSubscriptionService) { for (const subscription of this.plugin.icsSubscriptionService.getSubscriptions()) { diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index bd4edcf28..0a170a193 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -3,6 +3,7 @@ import { Menu, Notice, TFile, setIcon } from "obsidian"; import type { BasesView, BasesViewFactory } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesViewBase } from "./BasesViewBase"; +import { readBasesViewConfigValue, readBasesViewPropertyId } from "./basesViewConfig"; import { TaskInfo } from "../types"; import { identifyTaskNotesFromBasesData } from "./helpers"; import { createTaskCard, showTaskContextMenu, type TaskCardOptions } from "../ui/TaskCard"; @@ -308,23 +309,22 @@ export class TaskListView extends BasesViewBase { } try { - this.subGroupPropertyId = this.config.getAsPropertyId("subGroup"); + const get = (key: string): unknown => readBasesViewConfigValue(this.config, key); + this.subGroupPropertyId = readBasesViewPropertyId(this.config, "subGroup"); // 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 defaultCollapsedStateValue = this.config.get("defaultCollapsedState"); + const defaultCollapsedStateValue = get("defaultCollapsedState"); this.defaultCollapsedState = defaultCollapsedStateValue === "Collapsed" || defaultCollapsedStateValue === "1" ? "Collapsed" : "Expanded"; - 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; } catch (e) { diff --git a/src/bases/api.ts b/src/bases/api.ts index f31c06ab8..900d01a98 100644 --- a/src/bases/api.ts +++ b/src/bases/api.ts @@ -8,6 +8,7 @@ import { App, Plugin } from "obsidian"; import type { BasesViewRegistration as ObsidianBasesViewRegistration } from "obsidian"; import { createTaskNotesLogger, type TaskNotesLogger } from "../utils/tasknotesLogger"; +import { applyBasesViewOptionDefaults } from "./basesViewOptionDefaults"; export interface BasesQuery { on?: (event: string, callback: () => void) => void; @@ -130,7 +131,14 @@ export function registerBasesView( // Use public API (Obsidian 1.10.0+) if (typeof plugin.registerBasesView === "function") { try { - const success = plugin.registerBasesView(viewId, registration); + const options = registration.options; + const compatibleRegistration: BasesViewRegistration = options + ? { + ...registration, + options: (config) => applyBasesViewOptionDefaults(options(config), config), + } + : registration; + const success = plugin.registerBasesView(viewId, compatibleRegistration); if (success) { logger.debug("Successfully registered view via public API", { category: "configuration", diff --git a/src/bases/basesViewConfig.ts b/src/bases/basesViewConfig.ts new file mode 100644 index 000000000..17e29fd1c --- /dev/null +++ b/src/bases/basesViewConfig.ts @@ -0,0 +1,56 @@ +export type BasesViewConfigReader = { + get(key: string): unknown; +}; + +/** + * Bases stores custom settings directly on each view. Older TaskNotes generators + * and examples nested them under `config` or `options`; keep those files readable + * without rewriting them. Direct settings win, followed by config, then options. + */ +export function readBasesViewConfigValue( + config: BasesViewConfigReader | undefined, + key: string +): unknown { + if (!config || typeof config.get !== "function") { + return undefined; + } + + const directValue = config.get(key); + if (directValue !== null && directValue !== undefined) { + return directValue; + } + + for (const wrapper of ["config", "options"]) { + const nested = config.get(wrapper); + if ( + typeof nested !== "object" || + nested === null || + Array.isArray(nested) || + !Object.prototype.hasOwnProperty.call(nested, key) + ) { + continue; + } + + const value = (nested as Record)[key]; + if (value !== null && value !== undefined) { + return value; + } + } + + return undefined; +} + +/** Apply the same property-ID normalization as BasesViewConfig.getAsPropertyId. */ +export function readBasesViewPropertyId( + config: BasesViewConfigReader | undefined, + key: string +): string | null { + const value = readBasesViewConfigValue(config, key); + if (typeof value !== "string" || !value) { + return null; + } + if (value.startsWith("note.") || value.startsWith("file.") || value.startsWith("formula.")) { + return value; + } + return value === "file" ? "file.file" : `note.${value}`; +} diff --git a/src/bases/basesViewOptionDefaults.ts b/src/bases/basesViewOptionDefaults.ts new file mode 100644 index 000000000..8f8cf6a65 --- /dev/null +++ b/src/bases/basesViewOptionDefaults.ts @@ -0,0 +1,36 @@ +import type { BasesAllOptions, BasesOptions } from "obsidian"; +import { readBasesViewConfigValue, type BasesViewConfigReader } from "./basesViewConfig"; + +function applyOptionDefault(option: BasesOptions, config: BasesViewConfigReader): BasesOptions { + const value = readBasesViewConfigValue(config, option.key); + switch (option.type) { + case "toggle": + if (typeof value === "boolean") return { ...option, default: value }; + break; + case "slider": + if (typeof value === "number" && Number.isFinite(value)) { + return { ...option, default: value }; + } + break; + case "multitext": + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return { ...option, default: value }; + } + break; + default: + if (typeof value === "string") return { ...option, default: value }; + } + return option; +} + +/** Show legacy settings in the native options panel without writing to the file. */ +export function applyBasesViewOptionDefaults( + options: BasesAllOptions[], + config: BasesViewConfigReader +): BasesAllOptions[] { + return options.map((option) => + option.type === "group" + ? { ...option, items: option.items.map((item) => applyOptionDefault(item, config)) } + : applyOptionDefault(option, config) + ); +} diff --git a/src/bases/calendarConfigSnapshot.ts b/src/bases/calendarConfigSnapshot.ts index a1d714c98..7516aa201 100644 --- a/src/bases/calendarConfigSnapshot.ts +++ b/src/bases/calendarConfigSnapshot.ts @@ -1,6 +1,6 @@ -export type CalendarViewConfigReader = { - get(key: string): unknown; -}; +import { readBasesViewConfigValue, type BasesViewConfigReader } from "./basesViewConfig"; + +export type CalendarViewConfigReader = BasesViewConfigReader; const CALENDAR_CONFIG_SNAPSHOT_KEYS = [ // Event toggles @@ -50,10 +50,6 @@ const CALENDAR_CONFIG_SNAPSHOT_KEYS = [ "createDailyNotesFromDateLinks", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - function normalizeProviderIds(ids: readonly unknown[] | undefined): string[] { const normalized: string[] = []; @@ -75,21 +71,7 @@ export function readCalendarConfigValue( config: CalendarViewConfigReader | undefined, key: string ): unknown { - if (!config || typeof config.get !== "function") { - return undefined; - } - - const directValue = config.get(key); - if (directValue !== null && directValue !== undefined) { - return directValue; - } - - const options = config.get("options"); - if (!isRecord(options)) { - return undefined; - } - - return options[key]; + return readBasesViewConfigValue(config, key); } export function getCalendarConfigValue( diff --git a/src/bases/calendarViewOptions.ts b/src/bases/calendarViewOptions.ts index c24ca25d6..40c7bf8d5 100644 --- a/src/bases/calendarViewOptions.ts +++ b/src/bases/calendarViewOptions.ts @@ -2,6 +2,7 @@ import type { BasesAllOptions, BasesOptions, BasesViewConfig } from "obsidian"; import type TaskNotesPlugin from "../main"; import { DEFAULT_MINI_CALENDAR_HEAT_MAP_MAX_COUNT } from "./MiniCalendarView"; import { isNoteFileOrFormulaProperty } from "./propertyFilters"; +import { readBasesViewConfigValue } from "./basesViewConfig"; type CalendarOptionsConfig = Pick; type Translate = (key: string) => string; @@ -20,7 +21,7 @@ function translateCalendarSetting(plugin: TaskNotesPlugin): Translate { } function readOption(config: CalendarOptionsConfig, key: string, fallback: T): T { - const value = config.get(key); + const value = readBasesViewConfigValue(config, key); return value === null || value === undefined ? fallback : (value as T); } @@ -35,7 +36,7 @@ function readBooleanOption(config: CalendarOptionsConfig, key: string, fallback: } function hasConfiguredValue(config: CalendarOptionsConfig, key: string): boolean { - const value = config.get(key); + const value = readBasesViewConfigValue(config, key); if (value === null || value === undefined) { return false; } diff --git a/src/services/BasesFilterConverter.ts b/src/services/BasesFilterConverter.ts index 5e3b3952e..e7d54d0c1 100644 --- a/src/services/BasesFilterConverter.ts +++ b/src/services/BasesFilterConverter.ts @@ -593,7 +593,6 @@ export class BasesFilterConverter { // Add view options if present if (savedView.viewOptions && Object.keys(savedView.viewOptions).length > 0) { - content += ` options:\n`; Object.entries(savedView.viewOptions).forEach(([key, value]) => { // Format value appropriately based on type let formattedValue: string; @@ -604,7 +603,7 @@ export class BasesFilterConverter { } else { formattedValue = JSON.stringify(value); } - content += ` ${key}: ${formattedValue}\n`; + content += ` ${key}: ${formattedValue}\n`; }); } @@ -716,7 +715,6 @@ export class BasesFilterConverter { // Add view options if present if (savedView.viewOptions && Object.keys(savedView.viewOptions).length > 0) { - viewDef += ` options:\n`; Object.entries(savedView.viewOptions).forEach(([key, value]) => { // Format value appropriately based on type let formattedValue: string; @@ -727,7 +725,7 @@ export class BasesFilterConverter { } else { formattedValue = JSON.stringify(value); } - viewDef += ` ${key}: ${formattedValue}\n`; + viewDef += ` ${key}: ${formattedValue}\n`; }); } diff --git a/src/templates/defaultBasesFiles.ts b/src/templates/defaultBasesFiles.ts index c516812ad..391fb1023 100644 --- a/src/templates/defaultBasesFiles.ts +++ b/src/templates/defaultBasesFiles.ts @@ -670,9 +670,8 @@ ${orderYaml} groupBy: property: ${statusProperty} direction: ASC - config: - columnWidth: 280 - hideEmptyColumns: false + columnWidth: 280 + hideEmptyColumns: false `; } @@ -881,17 +880,16 @@ views: name: "Calendar" order: ${orderYaml} - options: - showScheduled: true - showDue: true - showRecurring: true - showTimeEntries: true - showTimeblocks: true - showPropertyBasedEvents: true - createDailyNotesFromDateLinks: true - calendarView: "timeGridWeek" - customDayCount: 3 - firstDay: ${plugin.settings.calendarViewSettings.firstDay} + showScheduled: true + showDue: true + showRecurring: true + showTimeEntries: true + showTimeblocks: true + showPropertyBasedEvents: true + createDailyNotesFromDateLinks: true + calendarView: "timeGridWeek" + customDayCount: 3 + firstDay: ${plugin.settings.calendarViewSettings.firstDay} `; case 'open-agenda-view': { @@ -918,10 +916,9 @@ views: name: "Agenda" order: ${agendaOrderYaml} - options: - showPropertyBasedEvents: false - showOverdueOnToday: true - createDailyNotesFromDateLinks: true + showPropertyBasedEvents: false + showOverdueOnToday: true + createDailyNotesFromDateLinks: true calendarView: "listWeek" startDateProperty: file.ctime listDayCount: 7 diff --git a/tests/unit/bases/basesViewConfig.test.ts b/tests/unit/bases/basesViewConfig.test.ts new file mode 100644 index 000000000..c48053a80 --- /dev/null +++ b/tests/unit/bases/basesViewConfig.test.ts @@ -0,0 +1,66 @@ +import { + readBasesViewConfigValue, + readBasesViewPropertyId, +} from "../../../src/bases/basesViewConfig"; + +const config = (values: Record) => ({ get: (key: string) => values[key] }); + +describe("Bases view settings compatibility", () => { + it.each(["config", "options"])("reads legacy %s settings without mutating them", (wrapper) => { + const values = Object.freeze({ [wrapper]: Object.freeze({ hideEmptyColumns: true }) }); + expect(readBasesViewConfigValue(config(values), "hideEmptyColumns")).toBe(true); + expect(values).toEqual({ [wrapper]: { hideEmptyColumns: true } }); + }); + + it.each([false, 0, "", [], {}])("preserves an explicit direct value: %p", (value) => { + expect(readBasesViewConfigValue(config({ + setting: value, + config: { setting: "config" }, + options: { setting: "options" }, + }), "setting")).toBe(value); + }); + + it("resolves settings per key with direct > config > options precedence", () => { + const reader = config({ + a: false, + config: { a: true, b: 0, c: null }, + options: { a: true, b: 1, c: "fallback", d: "options" }, + }); + expect(["a", "b", "c", "d", "missing"].map(key => readBasesViewConfigValue(reader, key))) + .toEqual([false, 0, "fallback", "options", undefined]); + }); + + it.each([null, undefined, true, 42, "invalid", [{ setting: true }]])( + "ignores malformed wrappers: %p", (value) => { + expect(readBasesViewConfigValue(config({ config: value, options: { setting: false } }), "setting")) + .toBe(false); + }); + + it("ignores inherited nested settings", () => { + expect(readBasesViewConfigValue(config({ options: Object.create({ setting: true }) }), "setting")) + .toBeUndefined(); + }); + + it("handles unavailable readers", () => { + expect(readBasesViewConfigValue(undefined, "setting")).toBeUndefined(); + }); + + it.each([ + ["priority", "note.priority"], + ["file", "file.file"], + ["note.contexts", "note.contexts"], + ["file.folder", "file.folder"], + ["formula.stage", "formula.stage"], + ["task.priority", "note.task.priority"], + ["", null], + [false, null], + ])("normalizes legacy property %p as %p", (value, expected) => { + expect(readBasesViewPropertyId(config({ options: { swimLane: value } }), "swimLane")) + .toBe(expected); + }); + + it("lets an empty direct property disable a legacy swimlane", () => { + expect(readBasesViewPropertyId(config({ swimLane: "", config: { swimLane: "priority" } }), "swimLane")) + .toBeNull(); + }); +}); diff --git a/tests/unit/bases/basesViewOptionDefaults.test.ts b/tests/unit/bases/basesViewOptionDefaults.test.ts new file mode 100644 index 000000000..ea349d5bb --- /dev/null +++ b/tests/unit/bases/basesViewOptionDefaults.test.ts @@ -0,0 +1,50 @@ +import { applyBasesViewOptionDefaults } from "../../../src/bases/basesViewOptionDefaults"; +import { registerBasesView } from "../../../src/bases/api"; +import type { BasesAllOptions } from "obsidian"; + +const options: BasesAllOptions[] = [ + { type: "group", displayName: "Board", items: [ + { type: "toggle", key: "hideEmptyColumns", displayName: "Hide empty", default: false }, + { type: "text", key: "pinnedColumns", displayName: "Pinned", default: "" }, + { type: "slider", key: "firstDay", displayName: "First day", min: 0, max: 6, step: 1, default: 1 }, + { type: "property", key: "swimLane", displayName: "Swimlane" }, + { type: "multitext", key: "values", displayName: "Values", default: [] }, + ] }, +]; +const reader = (values: Record) => ({ get: (key: string) => values[key] }); +const defaults = (result: any[]) => result[0].items.map((option: any) => option.default); + +describe("native Bases option defaults for legacy settings", () => { + it("uses nested settings for the panel without mutating option definitions", () => { + const result = applyBasesViewOptionDefaults(options, reader({ options: { + hideEmptyColumns: true, pinnedColumns: "ordered, received", firstDay: 0, + swimLane: "priority", values: ["shopping"], + } })); + expect(defaults(result)).toEqual([true, "ordered, received", 0, "priority", ["shopping"]]); + expect(defaults(options)).toEqual([false, "", 1, undefined, []]); + }); + + it("preserves direct false, zero, and empty overrides", () => { + expect(defaults(applyBasesViewOptionDefaults(options, reader({ + hideEmptyColumns: false, pinnedColumns: "", firstDay: 0, + config: { hideEmptyColumns: true, pinnedColumns: "received", firstDay: 1 }, + })))).toEqual([false, "", 0, undefined, []]); + }); + + it("keeps declared defaults for missing or incorrectly typed settings", () => { + expect(defaults(applyBasesViewOptionDefaults(options, reader({ options: { + hideEmptyColumns: "true", pinnedColumns: false, firstDay: Infinity, + swimLane: [], values: [42], + } })))).toEqual([false, "", 1, undefined, []]); + }); + + it("wraps registered options so the native panel receives legacy defaults", () => { + const plugin = { registerBasesView: jest.fn(() => true) }; + const registration = { name: "Kanban", icon: "square", factory: jest.fn(), options: jest.fn(() => options) }; + expect(registerBasesView(plugin as any, "tasknotesKanban", registration)).toBe(true); + const registered = plugin.registerBasesView.mock.calls[0] as any; + const config = reader({ config: { hideEmptyColumns: true } }); + expect(defaults(registered[1].options(config))[0]).toBe(true); + expect(registration.options).toHaveBeenCalledWith(config); + }); +}); diff --git a/tests/unit/bases/calendarConfigSnapshot.test.ts b/tests/unit/bases/calendarConfigSnapshot.test.ts index 9f8111198..94d68df6c 100644 --- a/tests/unit/bases/calendarConfigSnapshot.test.ts +++ b/tests/unit/bases/calendarConfigSnapshot.test.ts @@ -27,6 +27,23 @@ describe("calendarConfigSnapshot", () => { expect(getCalendarConfigValue(config, "missing", "fallback")).toBe("fallback"); }); + it("reads config wrappers and preserves direct falsy overrides in snapshots", () => { + const config = createConfig({ + showDue: false, + firstDay: 0, + calendarView: "", + config: { showDue: true, firstDay: 1, calendarView: "timeGridWeek", showScheduled: false }, + options: { showScheduled: true, showRecurring: false }, + }); + const keys = getCalendarConfigSnapshotKeys(); + const values = JSON.parse(buildCalendarConfigSnapshot({ config })); + expect(values[keys.indexOf("showDue")]).toBe(false); + expect(values[keys.indexOf("firstDay")]).toBe(0); + expect(values[keys.indexOf("calendarView")]).toBe(""); + expect(values[keys.indexOf("showScheduled")]).toBe(false); + expect(values[keys.indexOf("showRecurring")]).toBe(false); + }); + it("builds stable snapshot keys including external provider toggles", () => { expect( getCalendarConfigSnapshotKeys({ diff --git a/tests/unit/issues/issue-2382-kanban-nested-settings.test.ts b/tests/unit/issues/issue-2382-kanban-nested-settings.test.ts new file mode 100644 index 000000000..9db467bb6 --- /dev/null +++ b/tests/unit/issues/issue-2382-kanban-nested-settings.test.ts @@ -0,0 +1,88 @@ +import { parse } from "yaml"; +import { KanbanView } from "../../../src/bases/KanbanView"; +import { StatusManager } from "../../../src/services/StatusManager"; +import { PriorityManager } from "../../../src/services/PriorityManager"; +import { getVisibleKanbanSwimLaneColumnKeys } from "../../../src/bases/kanbanGrouping"; + +const statuses = ["ready", "in-progress", "done", "inbox", "blocked", "next", "waiting", "ordered", "shipped", "received", "returned"]; +const shopping = ["ordered", "shipped", "received", "returned"]; + +function makeView(yaml: string) { + const statusConfigs = statuses.map((value, order) => ({ + id: value, value, label: value, color: "#808080", isCompleted: value === "done", + order, autoArchive: false, autoArchiveDelay: 0, + })); + const plugin = { + app: {}, + fieldMapper: { toUserField: (field: string) => field, isRecognizedProperty: () => true }, + statusManager: new StatusManager(statusConfigs, "ready"), + priorityManager: new PriorityManager([]), + settings: { customStatuses: statusConfigs, fieldMapping: { sortOrder: "tasknotes_manual_order" } }, + }; + const view = new KanbanView({}, document.createElement("div"), plugin as any) as any; + const settings = parse(yaml).views[0]; + view.config = { get: (key: string) => settings[key] }; + view.readViewOptions(); + view.boardEl = document.createElement("div"); + view.getGroupByPropertyId = () => "note.status"; + view.getVisibleProperties = () => []; + view.createColumn = jest.fn(async (key: string) => { + const el = document.createElement("div"); + el.dataset.group = key; + return el; + }); + return view; +} + +function base(placement: string, extra = "") { + const indent = placement === "direct" ? " " : " "; + return `views:\n - type: tasknotesKanban\n name: Purchase Board\n${placement === "direct" ? "" : ` ${placement}:\n`}${indent}hideEmptyColumns: true\n${indent}columnWidth: 300\n${indent}pinnedColumns: ordered, shipped, received, returned\n${extra}`; +} + +function groups() { + return new Map(statuses.map(key => [key, key === "received" ? [{ path: "Amazon.md", status: key }] : []])); +} + +describe("Issue #2382: filtered Kanban boards with legacy nested settings", () => { + it.each(["direct", "config", "options"])("renders only four shopping columns for %s settings", async placement => { + const view = makeView(base(placement)); + const filteredGroups = groups(); + await view.renderFlat(filteredGroups, filteredGroups); + expect([...view.boardEl.children].map((el: HTMLElement) => el.dataset.group)).toEqual(shopping); + expect(view.boardEl.style.getPropertyValue("--kanban-column-width")).toBe("300px"); + const swimlanes = new Map([["Shopping", filteredGroups]]); + expect(getVisibleKanbanSwimLaneColumnKeys(statuses, swimlanes, view.hideEmptyColumns, view.pinnedColumns)).toEqual(shopping); + }); + + it("lets direct false override nested hiding", async () => { + const view = makeView(base("options", " hideEmptyColumns: false\n")); + const filteredGroups = groups(); + await view.renderFlat(filteredGroups, filteredGroups); + expect(view.boardEl.children).toHaveLength(11); + }); + + it("lets direct empty pinning override legacy pins", async () => { + const view = makeView(base("config", ' pinnedColumns: ""\n')); + const filteredGroups = groups(); + await view.renderFlat(filteredGroups, filteredGroups); + expect([...view.boardEl.children].map((el: HTMLElement) => el.dataset.group)).toEqual(["received"]); + }); + + it("does not treat pinned columns as a whitelist", async () => { + const view = makeView(base("options")); + const filteredGroups = groups(); + filteredGroups.set("ready", [{ path: "GTD.md", status: "ready" }]); + await view.renderFlat(filteredGroups, filteredGroups); + expect(view.createColumn.mock.calls.map(([key]: [string]) => key)).toContain("ready"); + }); + + it("reads grouping and other options from legacy files", () => { + const view = makeView(`views:\n - type: tasknotesKanban\n name: Board\n config:\n swimLane: formula.stage\n explodeListColumns: false\n columnOrder: '{"status":["received","ordered"]}'\n wipLimits: '{"received":5}'\n options:\n enableSearch: true\n hideTopLevelSubtasks: true\n`); + expect(view.swimLanePropertyId).toBe("formula.stage"); + expect(view.explodeListColumns).toBe(false); + expect(view.columnOrders).toEqual({ status: ["received", "ordered"] }); + expect(view.wipLimits).toEqual({ received: 5 }); + expect(view.enableSearch).toBe(true); + expect(view.hideTopLevelSubtasks).toBe(true); + }); +}); diff --git a/tests/unit/services/BasesFilterConverter.viewSettings.test.ts b/tests/unit/services/BasesFilterConverter.viewSettings.test.ts new file mode 100644 index 000000000..ceb3ec376 --- /dev/null +++ b/tests/unit/services/BasesFilterConverter.viewSettings.test.ts @@ -0,0 +1,52 @@ +import { parse } from "yaml"; +import { BasesFilterConverter } from "../../../src/services/BasesFilterConverter"; +import { readBasesViewConfigValue } from "../../../src/bases/basesViewConfig"; +import type { SavedView } from "../../../src/types"; + +const settings = { + columnWidth: 300, + hideEmptyColumns: true, + pinnedColumns: "ordered, shipped, received, returned", + swimLane: "priority", + enableSearch: false, + wipLimits: { received: 5 }, +}; + +function makeConverter() { + return new BasesFilterConverter({ + fieldMapper: { toUserField: (key: string) => key }, + settings: { userFields: [] }, + } as any); +} + +function savedView(viewOptions = settings): SavedView { + return { + id: "purchase-board", name: "Purchase Board", + query: { type: "group", conjunction: "and", children: [], groupKey: "status", sortKey: "title" }, + viewOptions, + } as unknown as SavedView; +} + +describe("Bases saved-view export settings", () => { + it.each(["single", "all"])("exports %s views with settings readable through the native flat getter", mode => { + const converter = makeConverter(); + const content = mode === "single" + ? converter.convertSavedViewToBasesFile(savedView(), "tasknotesKanban") + : converter.convertAllSavedViewsToBasesFile([savedView()]); + const view = parse(content).views[0]; + expect(view).toMatchObject({ type: "tasknotesKanban", ...settings }); + expect(view.config).toBeUndefined(); + expect(view.options).toBeUndefined(); + const reader = { get: (key: string) => view[key] }; + for (const [key, value] of Object.entries(settings)) { + expect(reader.get(key)).toEqual(value); + expect(readBasesViewConfigValue(reader, key)).toEqual(value); + } + }); + + it("preserves explicit false, zero, and empty strings in exported settings", () => { + const view = savedView({ hideEmptyColumns: false, firstDay: 0, pinnedColumns: "" } as any); + const exported = parse(makeConverter().convertAllSavedViewsToBasesFile([view])).views[0]; + expect(exported).toMatchObject({ hideEmptyColumns: false, firstDay: 0, pinnedColumns: "" }); + }); +}); diff --git a/tests/unit/templates/defaultBasesFiles.test.ts b/tests/unit/templates/defaultBasesFiles.test.ts index a04e7f824..277372635 100644 --- a/tests/unit/templates/defaultBasesFiles.test.ts +++ b/tests/unit/templates/defaultBasesFiles.test.ts @@ -81,8 +81,25 @@ describe("defaultBasesFiles", () => { expect(template).toContain('name: "Kanban Board"'); expect(template).toContain("sort:\n - column: tasknotes_manual_order\n direction: DESC"); expect(template).toContain("groupBy:\n property: status"); - expect(template).toContain("config:\n columnWidth: 280\n hideEmptyColumns: false"); - expect(template).not.toContain(" options:\n columnWidth: 280"); + const view = parse(template).views[0]; + expect(view.columnWidth).toBe(280); + expect(view.hideEmptyColumns).toBe(false); + expect(view.config).toBeUndefined(); + expect(view.options).toBeUndefined(); + }); + + it.each(commands)("writes custom settings directly on generated %s views", (command) => { + const base = parse(generateBasesFileTemplate(command, createMockPlugin() as any)); + for (const view of base.views) { + expect(view.config).toBeUndefined(); + expect(view.options).toBeUndefined(); + } + if (command === "open-advanced-calendar-view") { + expect(base.views[0]).toMatchObject({ calendarView: "timeGridWeek", showDue: true, firstDay: 1 }); + } + if (command === "open-agenda-view") { + expect(base.views[0]).toMatchObject({ showPropertyBasedEvents: false, showOverdueOnToday: true }); + } }); it("adds a dedicated manual-order task list view while preserving urgency views", () => {