diff --git a/PR.md b/PR.md new file mode 100644 index 00000000..0e31c20f --- /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/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 2c0c0918..10606746 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -333,9 +333,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; @@ -343,16 +355,7 @@ export class KanbanView extends BasesViewBase { for (let i = 0; i < 20; 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; } } } @@ -569,6 +572,9 @@ export class KanbanView extends BasesViewBase { private async renderFlat(groups: Map): Promise { if (!this.boardEl) return; + 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`); @@ -624,9 +630,11 @@ export class KanbanView extends BasesViewBase { swimLaneValues.add(swimLaneKey); } + const orderedSwimLaneKeys = this.getOrderedSwimLaneKeys(swimLaneValues); + // 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); @@ -660,12 +668,16 @@ export class KanbanView extends BasesViewBase { } } + 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( @@ -2052,6 +2064,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 = this.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];