diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34eed3e20..0c9e622fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,13 @@ jobs: uses: actions/checkout@v4 with: submodules: true + + - name: Checkout tasknotes-nlp-core + run: git clone --depth 1 https://github.com/callumalpass/tasknotes-nlp-core.git ../tasknotes-nlp-core + + - name: Install tasknotes-nlp-core dependencies + run: npm ci --prefix ../tasknotes-nlp-core --prefer-offline --no-audit + timeout-minutes: 5 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -123,6 +130,13 @@ jobs: uses: actions/checkout@v4 with: submodules: true + + - name: Checkout tasknotes-nlp-core + run: git clone --depth 1 https://github.com/callumalpass/tasknotes-nlp-core.git ../tasknotes-nlp-core + + - name: Install tasknotes-nlp-core dependencies + run: npm ci --prefix ../tasknotes-nlp-core --prefer-offline --no-audit + timeout-minutes: 5 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/PR.md b/PR.md new file mode 100644 index 000000000..0e31c20f2 --- /dev/null +++ b/PR.md @@ -0,0 +1,30 @@ +# fix/swimlane-order + +## Deterministic swimlane ordering + +Swimlane ordering is now deterministic and independent from card sorting. Status/priority swimlanes follow their configured order, other fields sort alphabetically with "None" last. When filters/search are active, empty swimlanes are hidden. Switching between swimlane and non-swimlane boards resets the layout correctly. + +Examples (illustrative): + +- Status swimlanes follow the custom status order; empty lanes are hidden when filters/search are active +- Context swimlanes are sorted A–Z with "None" at the end + +## Changelog + +- Sort swimlanes by property semantics instead of task order +- Include empty status/priority swimlanes based on configured options +- Keep "None" swimlane last for free-text fields +- Hide empty swimlanes when filters/search are active +- Reset swimlane layout class when toggling swimlanes or switching boards + +## Tests + +- `npm run i18n:sync` +- `npm run lint` (warnings only) +- `node generate-release-notes-import.mjs` +- `npm run typecheck` +- `npm run test:ci -- --verbose` (fails: `tests/unit/issues/due-date-timezone-inconsistency.test.ts` — confirmed failing in `upstream/main`) +- `npm run test:integration` +- `npm run test:performance` (no tests found) +- `npm run build` (warning: missing OAuth client IDs) +- `npm run test:build` diff --git a/docs/views/kanban-view.md b/docs/views/kanban-view.md index 963a5717d..0d1a2211e 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 +- **Swim Lane Order**: Managed automatically when dragging swimlane labels. Stores custom row ordering, layered on top of the default semantic order (status order, priority weight, alphabetical, "None" last) 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 @@ -49,6 +50,10 @@ Each swimlane row includes: - Multiple cells, each representing a column within that swimlane - Scrollable cells containing task cards +Drag a swimlane label cell to reorder rows. The new order persists per `.base` file in the `swimLaneOrder` config and survives reloads. Default ordering follows property semantics — configured status order for status, weight for priority, alphabetical otherwise, with `None` always last. A custom user order layers on top: when a previously unseen value first appears, it slots in using the default rule. Row reorder is desktop-only — no touch/mobile support yet. + +When a search or filter is active, only matching rows are visible. Reordering while filtered is safe — hidden rows are anchored at their previous positions instead of being lost. + ## Task Cards Each task card displays information based on the visible properties configured in the Bases view. Standard task information includes title, priority, due date, and scheduled date. @@ -116,10 +121,13 @@ views: type: tasknotesKanban groupBy: property: task.status + direction: ASC config: swimLane: task.priority columnWidth: 300 hideEmptyColumns: true + # Auto-managed when dragging swimlane labels: + swimLaneOrder: '{"task.priority":["high","normal","low","None"]}' --- ``` @@ -128,6 +136,7 @@ This configuration creates a Kanban board with: - Swimlanes based on task priority - 300px column width - Empty columns hidden +- Custom swimlane row order pinned via `swimLaneOrder` ## Filtering and Sorting diff --git a/jest.integration.config.js b/jest.integration.config.js index 3d42070f3..6b3b3f6b7 100644 --- a/jest.integration.config.js +++ b/jest.integration.config.js @@ -14,7 +14,8 @@ module.exports = { // Only mock Obsidian and UI libraries - use real date/parsing libraries '^obsidian$': '/tests/__mocks__/obsidian.ts', '^@fullcalendar/(.*)$': '/tests/__mocks__/fullcalendar.ts', - '^yaml$': '/tests/__mocks__/yaml.ts' + '^yaml$': '/tests/__mocks__/yaml.ts', + '^tasknotes-nlp-core$': '/tests/integration/mocks/tasknotes-nlp-core.ts' // chrono-node, rrule, ical.js, date-fns will use real implementations }, // Integration tests may need more time for complex workflows @@ -28,4 +29,4 @@ module.exports = { '!src/main.ts', '!tests/**/*' ] -}; \ No newline at end of file +}; diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 91cba41de..31494befc 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -22,6 +22,11 @@ import { applySortOrderPlan, DropOperationQueue, } from "./sortOrderUtils"; +import { + mergeUserSwimLaneOrder, + mergeReorderedVisibleKeys, + parseSwimLaneOrderConfig, +} from "./swimLaneOrdering"; export class KanbanView extends BasesViewBase { type = "tasknotesKanban"; @@ -81,6 +86,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 swimLaneOrders: Record = {}; private configLoaded = false; // Track if we've successfully loaded config /** * Threshold for enabling virtual scrolling in kanban columns/swimlane cells. @@ -177,6 +183,12 @@ export class KanbanView extends BasesViewBase { const columnOrderStr = (this.config.get("columnOrder") as string) || "{}"; this.columnOrders = JSON.parse(columnOrderStr); + // Read swim lane orders via the safe parser — malformed user input + // returns {} rather than throwing, so it can't disable other config. + this.swimLaneOrders = parseSwimLaneOrderConfig( + this.config.get("swimLaneOrder") + ); + // Read enableSearch toggle (default: false for backward compatibility) const enableSearchValue = this.config.get("enableSearch"); this.enableSearch = (enableSearchValue as boolean) ?? false; @@ -403,9 +415,21 @@ export class KanbanView extends BasesViewBase { // Must use internal API to detect if groupBy is configured. // We can't rely on isGrouped() because it returns false when all items have null values. - const controller = this.basesController; - // Try to get groupBy from internal API (controller.query.views) + const view = this.getActiveViewConfig(); + if (view?.groupBy) { + if (typeof view.groupBy === "object" && view.groupBy.property) { + return view.groupBy.property; + } else if (typeof view.groupBy === "string") { + return view.groupBy; + } + } + + return null; + } + + private getActiveViewConfig(): any | null { + const controller = this.basesController; if (controller?.query?.views && controller?.viewName) { const views = controller.query.views; const viewName = controller.viewName; @@ -413,16 +437,7 @@ export class KanbanView extends BasesViewBase { for (let i = 0; i < views.length; i++) { const view = views[i]; if (view && view.name === viewName) { - if (view.groupBy) { - if (typeof view.groupBy === "object" && view.groupBy.property) { - return view.groupBy.property; - } else if (typeof view.groupBy === "string") { - return view.groupBy; - } - } - - // View found but no groupBy configured - return null; + return view; } } } @@ -715,6 +730,9 @@ export class KanbanView extends BasesViewBase { ]) ); + this.boardEl.removeClass("kanban-view__board--swimlanes"); + this.boardEl.style.removeProperty("--kanban-swimlane-max-height"); + // Set CSS variable for column width (allows responsive override) this.boardEl.style.setProperty("--kanban-column-width", `${this.columnWidth}px`); @@ -775,9 +793,18 @@ export class KanbanView extends BasesViewBase { swimLaneValues.add(swimLaneKey); } + // Default order from upstream semantic logic (status order, priority weight, + // alpha, "None" last). Layered with persisted user reorder on top: saved + // keys keep their saved positions, new keys append in default's order. + const defaultOrderedKeys = this.getOrderedSwimLaneKeys(swimLaneValues); + const savedOrder = this.swimLanePropertyId + ? this.swimLaneOrders[this.swimLanePropertyId] ?? [] + : []; + const orderedSwimLaneKeys = mergeUserSwimLaneOrder(savedOrder, defaultOrderedKeys); + // Initialize swimlane -> column -> tasks structure // Note: groups already includes empty status columns from augmentWithEmptyStatusColumns() - for (const swimLaneKey of swimLaneValues) { + for (const swimLaneKey of orderedSwimLaneKeys) { const swimLaneMap = new Map(); swimLanes.set(swimLaneKey, swimLaneMap); @@ -811,53 +838,16 @@ export class KanbanView extends BasesViewBase { } } - const candidateSwimLanes = new Map>(); - const candidateSwimLaneValues = new Set(); - - for (const task of allTasksForCandidateScopes) { - const props = pathToProps.get(task.path) || {}; - const swimLaneValue = this.getPropertyValue(props, this.swimLanePropertyId); - const swimLaneKey = this.valueToString(swimLaneValue); - candidateSwimLaneValues.add(swimLaneKey); - } - - for (const swimLaneKey of candidateSwimLaneValues) { - const swimLaneMap = new Map(); - candidateSwimLanes.set(swimLaneKey, swimLaneMap); - - for (const [columnKey] of allGroups) { - swimLaneMap.set(columnKey, []); - } - } - - for (const [columnKey, columnTasks] of allGroups) { - for (const task of columnTasks) { - const props = pathToProps.get(task.path) || {}; - const swimLaneValue = this.getPropertyValue(props, this.swimLanePropertyId); - const swimLaneKey = this.valueToString(swimLaneValue); - const swimLane = candidateSwimLanes.get(swimLaneKey); - if (!swimLane) continue; - if (swimLane.has(columnKey)) { - swimLane.get(columnKey)!.push(task); - } - } - } - - this.setSortScopeCandidatePaths( - Array.from(candidateSwimLanes.entries()).flatMap(([swimLaneKey, columns]) => - Array.from(columns.entries()).map(([columnKey, tasks]) => [ - this.getSortScopeKey(columnKey, swimLaneKey), - tasks.map((task) => task.path), - ] as [string, string[]]) - ) - ); + const swimLanesToRender = this.shouldHideEmptySwimLanes() + ? this.filterEmptySwimLanes(swimLanes, orderedSwimLaneKeys) + : swimLanes; // Apply column ordering const columnKeys = Array.from(groups.keys()); const orderedKeys = this.applyColumnOrder(groupByPropertyId, columnKeys); // Render swimlane table - await this.renderSwimLaneTable(swimLanes, orderedKeys, pathToProps); + await this.renderSwimLaneTable(swimLanesToRender, orderedKeys, pathToProps); } private async renderSwimLaneTable( @@ -924,8 +914,18 @@ export class KanbanView extends BasesViewBase { for (const [swimLaneKey, columns] of swimLanes) { const row = this.boardEl.createEl("div", { cls: "kanban-view__swimlane-row" }); - // Swimlane label cell - const labelCell = row.createEl("div", { cls: "kanban-view__swimlane-label" }); + // Swimlane label cell — draggable so users can reorder rows + const labelCell = row.createEl("div", { + cls: "kanban-view__swimlane-label", + attr: { + "data-swimlane-key": swimLaneKey, + draggable: "true", + }, + }); + + // Drag handle + const dragHandle = labelCell.createSpan({ cls: "kanban-view__swimlane-drag-handle" }); + dragHandle.textContent = "⋮⋮"; // Add swimlane title and count const titleEl = labelCell.createEl("div", { cls: "kanban-view__swimlane-title" }); @@ -941,6 +941,8 @@ export class KanbanView extends BasesViewBase { text: `${totalTasks}`, }); + this.setupSwimLaneLabelDragHandlers(labelCell); + // Render columns in this swimlane for (const columnKey of columnKeys) { const tasks = columns.get(columnKey) || []; @@ -1372,6 +1374,80 @@ export class KanbanView extends BasesViewBase { }); } + private setupSwimLaneLabelDragHandlers(label: HTMLElement): void { + const swimLaneKey = label.dataset.swimlaneKey; + if (!swimLaneKey) return; + + const draggingClass = "kanban-view__swimlane-label--dragging"; + const dragoverClass = "kanban-view__swimlane-label--dragover"; + + label.addEventListener("dragstart", (e: DragEvent) => { + if (!e.dataTransfer) return; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/x-kanban-swimlane", swimLaneKey); + label.classList.add(draggingClass); + }); + + label.addEventListener("dragover", (e: DragEvent) => { + // Only handle swimlane drags + if (!e.dataTransfer?.types.includes("text/x-kanban-swimlane")) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + label.classList.add(dragoverClass); + }); + + label.addEventListener("dragleave", (e: DragEvent) => { + if (!e.dataTransfer?.types.includes("text/x-kanban-swimlane")) return; + if (e.target === label) { + label.classList.remove(dragoverClass); + } + }); + + label.addEventListener("drop", async (e: DragEvent) => { + if (!e.dataTransfer?.types.includes("text/x-kanban-swimlane")) return; + e.preventDefault(); + e.stopPropagation(); + + label.classList.remove(dragoverClass); + + const draggedKey = e.dataTransfer.getData("text/x-kanban-swimlane"); + const targetKey = label.dataset.swimlaneKey; + if (!targetKey || !draggedKey || draggedKey === targetKey) return; + + const swimLanePropId = this.swimLanePropertyId; + if (!swimLanePropId) return; + + // Read visible row order from the DOM. With filters/search active, + // hidden rows aren't here; mergeReorderedVisibleKeys anchors them. + const visibleOrder = Array.from( + this.boardEl!.querySelectorAll(".kanban-view__swimlane-label[data-swimlane-key]") + ) + .map((el) => (el as HTMLElement).dataset.swimlaneKey) + .filter((k): k is string => !!k); + + // Reorder within the visible list + const dragIndex = visibleOrder.indexOf(draggedKey); + const dropIndex = visibleOrder.indexOf(targetKey); + if (dragIndex === -1 || dropIndex === -1) return; + + const reorderedVisible = [...visibleOrder]; + reorderedVisible.splice(dragIndex, 1); + reorderedVisible.splice(dropIndex, 0, draggedKey); + + // Merge onto the previous saved order so hidden rows aren't lost. + const previousOrder = this.swimLaneOrders[swimLanePropId] ?? []; + const mergedOrder = mergeReorderedVisibleKeys(previousOrder, reorderedVisible); + + await this.saveSwimLaneOrder(swimLanePropId, mergedOrder); + await this.render(); + }); + + label.addEventListener("dragend", () => { + label.classList.remove(draggingClass); + }); + } + private setupColumnDragDrop( column: HTMLElement, cardsContainer: HTMLElement, @@ -1379,8 +1455,9 @@ export class KanbanView extends BasesViewBase { ): void { // Drag over handler column.addEventListener("dragover", (e: DragEvent) => { - // Only handle task drags (not column drags) + // Only handle task drags (not column or swimlane-label drags) if (e.dataTransfer?.types.includes("text/x-kanban-column")) return; + if (e.dataTransfer?.types.includes("text/x-kanban-swimlane")) return; e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; @@ -1401,8 +1478,9 @@ export class KanbanView extends BasesViewBase { // Drop handler column.addEventListener("drop", async (e: DragEvent) => { - // Only handle task drags (not column drags) + // Only handle task drags (not column or swimlane-label drags) if (e.dataTransfer?.types.includes("text/x-kanban-column")) return; + if (e.dataTransfer?.types.includes("text/x-kanban-swimlane")) return; e.preventDefault(); e.stopPropagation(); @@ -1487,6 +1565,9 @@ export class KanbanView extends BasesViewBase { ): void { // Drag over handler cell.addEventListener("dragover", (e: DragEvent) => { + // Bail before preventDefault so swimlane-label drags pass through to + // the label drop targets — otherwise we'd visibly highlight the cell. + if (e.dataTransfer?.types.includes("text/x-kanban-swimlane")) return; e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; @@ -1507,6 +1588,8 @@ export class KanbanView extends BasesViewBase { // Drop handler cell.addEventListener("drop", async (e: DragEvent) => { + // Same MIME guard as dragover — keep label drops from being claimed here. + if (e.dataTransfer?.types.includes("text/x-kanban-swimlane")) return; e.preventDefault(); e.stopPropagation(); @@ -3002,6 +3085,203 @@ export class KanbanView extends BasesViewBase { renderGroupTitle(container, displayTitle, linkServices); } + private getOrderedSwimLaneKeys(swimLaneValues: Set): string[] { + if (!this.swimLanePropertyId) { + return Array.from(swimLaneValues); + } + + const noneKey = "None"; + const hasNone = swimLaneValues.has(noneKey); + const rawKeys = Array.from(swimLaneValues); + const valueKeys = rawKeys.filter((key) => key !== noneKey); + const cleanProperty = stripPropertyPrefix(this.swimLanePropertyId); + + const statusField = this.plugin.fieldMapper.toUserField("status"); + if (cleanProperty === statusField) { + const statusValues = this.plugin.statusManager.getStatusesByOrder().map((s) => s.value); + const extras = this.sortSwimLaneKeysAlphabetical( + valueKeys.filter((key) => !statusValues.includes(key)) + ); + const ordered = [...statusValues, ...extras]; + if (hasNone && !ordered.includes(noneKey)) { + ordered.push(noneKey); + } + return ordered; + } + + const priorityField = this.plugin.fieldMapper.toUserField("priority"); + if (cleanProperty === priorityField) { + const priorityValues = this.plugin.priorityManager + .getPrioritiesByWeight() + .map((p) => p.value); + const extras = this.sortSwimLaneKeysAlphabetical( + valueKeys.filter((key) => !priorityValues.includes(key)) + ); + const ordered = [...priorityValues, ...extras]; + if (hasNone && !ordered.includes(noneKey)) { + ordered.push(noneKey); + } + return ordered; + } + + if (cleanProperty.startsWith("user:")) { + return this.sortUserFieldSwimLaneKeys(rawKeys, cleanProperty); + } + + const ordered = this.sortSwimLaneKeysAlphabetical(valueKeys); + if (hasNone) { + ordered.push(noneKey); + } + return ordered; + } + + private sortSwimLaneKeysAlphabetical(keys: string[]): string[] { + return [...keys].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }) + ); + } + + private sortUserFieldSwimLaneKeys(keys: string[], cleanProperty: string): string[] { + const noneKey = "None"; + const hasNone = keys.includes(noneKey); + const valueKeys = keys.filter((key) => key !== noneKey); + const fieldId = cleanProperty.slice("user:".length); + const fields = this.plugin.settings?.userFields || []; + const field = fields.find((f: any) => (f.id || f.key) === fieldId); + + let ordered: string[]; + + if (!field) { + ordered = this.sortSwimLaneKeysAlphabetical(valueKeys); + } else { + switch (field.type) { + case "number": + ordered = [...valueKeys].sort((a, b) => { + const numA = parseFloat(a); + const numB = parseFloat(b); + const isNumA = !isNaN(numA); + const isNumB = !isNaN(numB); + if (isNumA && isNumB) return numB - numA; + if (isNumA && !isNumB) return -1; + if (!isNumA && isNumB) return 1; + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }); + break; + case "boolean": + ordered = [...valueKeys].sort((a, b) => { + const aLower = a.toLowerCase(); + const bLower = b.toLowerCase(); + if (aLower === "true" && bLower === "false") return -1; + if (aLower === "false" && bLower === "true") return 1; + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }); + break; + case "date": + ordered = [...valueKeys].sort((a, b) => { + const tA = Date.parse(a); + const tB = Date.parse(b); + const isValidA = !isNaN(tA); + const isValidB = !isNaN(tB); + if (isValidA && isValidB) return tA - tB; + if (isValidA && !isValidB) return -1; + if (!isValidA && isValidB) return 1; + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }); + break; + case "text": + case "list": + default: + ordered = this.sortSwimLaneKeysAlphabetical(valueKeys); + break; + } + } + + if (hasNone) { + ordered.push(noneKey); + } + return ordered; + } + + private shouldHideEmptySwimLanes(): boolean { + if (this.currentSearchTerm?.length) { + return true; + } + + return this.hasActiveBasesFilters(); + } + + private hasActiveBasesFilters(): boolean { + const viewConfig = this.getActiveViewConfig(); + if (this.isNonEmptyFilterConfig(this.extractFilterConfig(viewConfig))) { + return true; + } + + const controller = this.basesController; + const query = controller?.query; + const config = controller?.getViewConfig?.() ?? query?.getViewConfig?.(); + return this.isNonEmptyFilterConfig(this.extractFilterConfig(config)); + } + + private extractFilterConfig(config: any): any { + if (!config || typeof config !== "object") { + return null; + } + + return ( + config.filter ?? + config.filters ?? + config.query?.filter ?? + config.query?.filters ?? + config.where ?? + config.conditions ?? + null + ); + } + + private isNonEmptyFilterConfig(value: any): boolean { + if (!value) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "object") { + const rules = + value.rules ?? + value.conditions ?? + value.filters ?? + value.groups ?? + null; + if (Array.isArray(rules)) return rules.length > 0; + if (rules && typeof rules === "object") return Object.keys(rules).length > 0; + return Object.keys(value).length > 0; + } + return false; + } + + private filterEmptySwimLanes( + swimLanes: Map>, + orderedKeys: string[] + ): Map> { + const filtered = new Map>(); + + for (const swimLaneKey of orderedKeys) { + const columns = swimLanes.get(swimLaneKey); + if (!columns) continue; + + let hasTasks = false; + for (const tasks of columns.values()) { + if (tasks.length > 0) { + hasTasks = true; + break; + } + } + + if (hasTasks) { + filtered.set(swimLaneKey, columns); + } + } + + return filtered; + } + private applyColumnOrder(groupBy: string, actualKeys: string[]): string[] { // Get saved order for this grouping property const savedOrder = this.columnOrders[groupBy]; @@ -3047,6 +3327,17 @@ export class KanbanView extends BasesViewBase { } } + private async saveSwimLaneOrder(swimLanePropId: string, order: string[]): Promise { + this.swimLaneOrders[swimLanePropId] = order; + + try { + const orderJson = JSON.stringify(this.swimLaneOrders); + this.config.set("swimLaneOrder", orderJson); + } catch (error) { + console.error("[KanbanView] Failed to save swim lane order:", error); + } + } + /** * Get consistent card rendering options for all kanban cards */ diff --git a/src/bases/registration.ts b/src/bases/registration.ts index 5f1b7c526..eca7bb4de 100644 --- a/src/bases/registration.ts +++ b/src/bases/registration.ts @@ -115,6 +115,13 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise { + if (typeof value !== "string") return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return {}; + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + + const result: Record = {}; + for (const [key, val] of Object.entries(parsed)) { + if (Array.isArray(val) && val.every((item) => typeof item === "string")) { + result[key] = val; + } + } + return result; +} + +/** + * Merge a reordered list of visible swimlane keys onto the previous saved order. + * Used after a drag-to-reorder when filters/search may be hiding rows: the DOM + * only reports visible-row order, so we anchor hidden keys at their previous + * positions while the visible slots take the new order in sequence. + */ +export function mergeReorderedVisibleKeys( + previousOrder: string[], + reorderedVisibleKeys: string[] +): string[] { + const visibleSet = new Set(reorderedVisibleKeys); + const result: string[] = []; + let visibleCursor = 0; + for (const key of previousOrder) { + if (visibleSet.has(key)) { + // Visible slot — take the next key from the reordered list + result.push(reorderedVisibleKeys[visibleCursor++]); + } else { + // Hidden — anchor in place + result.push(key); + } + } + // Any reordered keys not consumed (newly visible, not in previousOrder) + // go at the end + for (; visibleCursor < reorderedVisibleKeys.length; visibleCursor++) { + result.push(reorderedVisibleKeys[visibleCursor]); + } + return result; +} + +export function mergeUserSwimLaneOrder( + savedOrder: string[], + defaultOrderedKeys: string[] +): string[] { + if (savedOrder.length === 0) return defaultOrderedKeys; + + const defaultSet = new Set(defaultOrderedKeys); + const savedSet = new Set(savedOrder); + + const fromSaved = savedOrder.filter((key) => defaultSet.has(key)); + const newKeys = defaultOrderedKeys.filter((key) => !savedSet.has(key)); + + return [...fromSaved, ...newKeys]; +} diff --git a/src/services/GoogleCalendarService.ts b/src/services/GoogleCalendarService.ts index 9cbc564aa..2131da06a 100644 --- a/src/services/GoogleCalendarService.ts +++ b/src/services/GoogleCalendarService.ts @@ -145,7 +145,7 @@ export class GoogleCalendarService extends CalendarProvider { return; } this.plugin.settings.googleCalendarSyncTokens[calendarId] = syncToken; - await this.persistSettingsDataOnly(); + await this.plugin.saveSettingsDataOnly?.(); } /** @@ -156,14 +156,7 @@ export class GoogleCalendarService extends CalendarProvider { return; } delete this.plugin.settings.googleCalendarSyncTokens[calendarId]; - await this.persistSettingsDataOnly(); - } - - private async persistSettingsDataOnly(): Promise { - const saveSettingsDataOnly = (this.plugin as unknown as { saveSettingsDataOnly?: () => Promise }).saveSettingsDataOnly; - if (typeof saveSettingsDataOnly === "function") { - await saveSettingsDataOnly.call(this.plugin); - } + await this.plugin.saveSettingsDataOnly?.(); } async initialize(): Promise { diff --git a/src/services/MicrosoftCalendarService.ts b/src/services/MicrosoftCalendarService.ts index 69717dede..95a1ee1dd 100644 --- a/src/services/MicrosoftCalendarService.ts +++ b/src/services/MicrosoftCalendarService.ts @@ -175,7 +175,7 @@ export class MicrosoftCalendarService extends CalendarProvider { return; } this.plugin.settings.microsoftCalendarSyncTokens[calendarId] = syncToken; - await this.persistSettingsDataOnly(); + await this.plugin.saveSettingsDataOnly?.(); } /** @@ -189,14 +189,7 @@ export class MicrosoftCalendarService extends CalendarProvider { return; } delete this.plugin.settings.microsoftCalendarSyncTokens[calendarId]; - await this.persistSettingsDataOnly(); - } - - private async persistSettingsDataOnly(): Promise { - const saveSettingsDataOnly = (this.plugin as unknown as { saveSettingsDataOnly?: () => Promise }).saveSettingsDataOnly; - if (typeof saveSettingsDataOnly === "function") { - await saveSettingsDataOnly.call(this.plugin); - } + await this.plugin.saveSettingsDataOnly?.(); } async initialize(): Promise { diff --git a/styles/bases-views.css b/styles/bases-views.css index 3aa9a066e..23f4fa4c7 100644 --- a/styles/bases-views.css +++ b/styles/bases-views.css @@ -334,6 +334,44 @@ z-index: 5; } +.kanban-view__swimlane-label[draggable="true"] { + cursor: grab; + user-select: none; + transition: all var(--tn-transition-fast); +} + +.kanban-view__swimlane-label[draggable="true"]:active { + cursor: grabbing; +} + +.kanban-view__swimlane-label--dragging { + opacity: 0.5; + transform: rotate(1deg); + box-shadow: var(--tn-shadow-medium); +} + +.kanban-view__swimlane-label--dragover { + box-shadow: inset 0 2px 0 var(--tn-interactive-accent); +} + +/* Swimlane drag handle */ +.kanban-view__swimlane-drag-handle { + color: var(--tn-text-muted); + cursor: grab; + font-size: var(--tn-font-size-sm); + opacity: 0.5; + transition: opacity var(--tn-transition-fast); + flex-shrink: 0; +} + +.kanban-view__swimlane-label:hover .kanban-view__swimlane-drag-handle { + opacity: 1; +} + +.kanban-view__swimlane-drag-handle:active { + cursor: grabbing; +} + /* Swimlane title */ .kanban-view__swimlane-title { font-weight: var(--tn-font-weight-medium); diff --git a/tests/integration/mocks/tasknotes-nlp-core.ts b/tests/integration/mocks/tasknotes-nlp-core.ts new file mode 100644 index 000000000..d711ec3bb --- /dev/null +++ b/tests/integration/mocks/tasknotes-nlp-core.ts @@ -0,0 +1,28 @@ +export interface ParsedTaskData { + title: string; + details?: string; + due?: string; + scheduled?: string; + tags?: string[]; + contexts?: string[]; + projects?: string[]; + status?: string; + priority?: string; + recurrence?: string; +} + +export class NaturalLanguageParserCore { + constructor(..._args: unknown[]) {} + + parseInput(input: string): ParsedTaskData { + return { title: (input || "").trim() }; + } + + getPreviewData(parsedData: ParsedTaskData): ParsedTaskData { + return parsedData; + } + + getStatusSuggestions(): string[] { + return []; + } +} diff --git a/tests/unit/bases/swimLaneOrdering.test.ts b/tests/unit/bases/swimLaneOrdering.test.ts new file mode 100644 index 000000000..2259b1a9c --- /dev/null +++ b/tests/unit/bases/swimLaneOrdering.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "@jest/globals"; +import { + mergeUserSwimLaneOrder, + mergeReorderedVisibleKeys, + parseSwimLaneOrderConfig, +} from "../../../src/bases/swimLaneOrdering"; + +describe("mergeUserSwimLaneOrder", () => { + it("returns defaultOrderedKeys unchanged when savedOrder is empty", () => { + const defaultOrderedKeys = ["high", "normal", "low", "None"]; + const result = mergeUserSwimLaneOrder([], defaultOrderedKeys); + expect(result).toEqual(["high", "normal", "low", "None"]); + }); + + it("returns savedOrder when it is a permutation of defaultOrderedKeys", () => { + const result = mergeUserSwimLaneOrder( + ["low", "high", "None", "normal"], + ["high", "normal", "low", "None"] + ); + expect(result).toEqual(["low", "high", "None", "normal"]); + }); + + it("drops saved keys that are no longer in defaultOrderedKeys", () => { + const result = mergeUserSwimLaneOrder( + ["low", "obsolete", "high", "alsoGone"], + ["high", "normal", "low"] + ); + expect(result).toEqual(["low", "high", "normal"]); + }); + + it("appends new default keys after saved entries in default's relative order", () => { + const result = mergeUserSwimLaneOrder( + ["low", "high"], + ["urgent", "high", "normal", "low", "trivial"] + ); + expect(result).toEqual(["low", "high", "urgent", "normal", "trivial"]); + }); + + it("returns empty array when defaultOrderedKeys is empty even with saved entries", () => { + const result = mergeUserSwimLaneOrder(["a", "b", "c"], []); + expect(result).toEqual([]); + }); +}); + +describe("parseSwimLaneOrderConfig", () => { + it("parses a valid JSON string into a Record of string arrays", () => { + const input = '{"task.priority":["high","normal","low"]}'; + expect(parseSwimLaneOrderConfig(input)).toEqual({ + "task.priority": ["high", "normal", "low"], + }); + }); + + it("returns {} for null", () => { + expect(parseSwimLaneOrderConfig(null)).toEqual({}); + }); + + it("returns {} for undefined", () => { + expect(parseSwimLaneOrderConfig(undefined)).toEqual({}); + }); + + it("returns {} for malformed JSON", () => { + expect(parseSwimLaneOrderConfig("{not json}")).toEqual({}); + }); + + it("returns {} when parsed JSON is not an object (array)", () => { + expect(parseSwimLaneOrderConfig('["a","b"]')).toEqual({}); + }); + + it("returns {} when parsed JSON is not an object (string)", () => { + expect(parseSwimLaneOrderConfig('"hello"')).toEqual({}); + }); + + it("drops entries whose value is not an array of strings", () => { + const input = JSON.stringify({ + good: ["a", "b"], + notArray: "string-value", + numericArray: [1, 2, 3], + mixedArray: ["ok", 5], + nestedObject: { foo: "bar" }, + }); + expect(parseSwimLaneOrderConfig(input)).toEqual({ + good: ["a", "b"], + }); + }); +}); + +describe("mergeReorderedVisibleKeys", () => { + it("returns reordered keys as-is when nothing is hidden", () => { + const result = mergeReorderedVisibleKeys( + ["A", "B", "C"], + ["C", "A", "B"] + ); + expect(result).toEqual(["C", "A", "B"]); + }); + + it("anchors a single hidden key at its previous position", () => { + // prev=[A,B,C,D], B hidden, user reorders visible to [D,A,C] + const result = mergeReorderedVisibleKeys( + ["A", "B", "C", "D"], + ["D", "A", "C"] + ); + expect(result).toEqual(["D", "B", "A", "C"]); + }); + + it("anchors multiple hidden keys at their previous positions", () => { + // prev=[A,B,C,D,E], B and D hidden, user reorders visible to [E,A,C] + const result = mergeReorderedVisibleKeys( + ["A", "B", "C", "D", "E"], + ["E", "A", "C"] + ); + expect(result).toEqual(["E", "B", "A", "D", "C"]); + }); + + it("appends newly visible keys (not in previousOrder) to the end", () => { + // prev=[A,B,C], all visible, NEW just appeared and was placed first by user + const result = mergeReorderedVisibleKeys( + ["A", "B", "C"], + ["NEW", "A", "B", "C"] + ); + // Visible positions in prev fill from reorderedVisibleKeys[0..2], + // then "NEW" goes at the end as a leftover. + expect(result).toEqual(["NEW", "A", "B", "C"]); + }); + + it("preserves a previous key that is neither visible nor in the reorder", () => { + // prev=[A,B,C], B hidden and not in visible list, user reorders [C,A] + const result = mergeReorderedVisibleKeys( + ["A", "B", "C"], + ["C", "A"] + ); + expect(result).toEqual(["C", "B", "A"]); + }); + + it("returns previousOrder unchanged when reorderedVisibleKeys is empty", () => { + const result = mergeReorderedVisibleKeys(["A", "B", "C"], []); + expect(result).toEqual(["A", "B", "C"]); + }); +});