Add Kanban column task creation

This commit is contained in:
callumalpass 2026-05-17 07:22:48 +10:00
parent 8f72d92453
commit 540255af41
6 changed files with 346 additions and 8 deletions

View file

@ -33,6 +33,7 @@ Example:
- ([#1541](https://github.com/callumalpass/tasknotes/issues/1541)) Added `{{currentNotePath}}` and `{{currentNoteTitle}}` support to the default tasks folder, so normal task creation can place new tasks beside the active note without creating an inline link. Thanks to @ChristianAnyanwu for suggesting this, and to @abbasou and @ttlaylor for the follow-up feedback.
- ([#648](https://github.com/callumalpass/tasknotes/issues/648), [#1605](https://github.com/callumalpass/tasknotes/issues/1605)) Added clickable links in task-card contexts for note links, markdown links, and web URLs while keeping plain contexts as tag-search buttons. Thanks to @trdischat and @Glint-Eye for suggesting this, and to @renatomen for the follow-up feedback.
- ([#1482](https://github.com/callumalpass/tasknotes/issues/1482)) Added clickable custom-field links on task cards for wikilinks, markdown links, autolinks, and bare web URLs. Thanks to @ptbosch-figueiredorj for suggesting this, and to @Soleone for the follow-up use case.
- ([#1471](https://github.com/callumalpass/tasknotes/issues/1471)) Added Kanban column and swimlane-cell add buttons that open the TaskNotes creation modal with the grouped values already filled in. Thanks to @byheaven for suggesting this and @Davincible for the project-board use case.
- ([#1475](https://github.com/callumalpass/tasknotes/issues/1475), [#725](https://github.com/callumalpass/tasknotes/issues/725)) Added quote/backtick escapes for NLP input, so literal course codes or date words can stay in task titles without being parsed as estimates or dates. Thanks to @RumiaKitinari and @gavingwebb for suggesting this.
- ([#1603](https://github.com/callumalpass/tasknotes/issues/1603)) Added Calendar view toggles for hiding completed or skipped recurring task instances while keeping future outstanding instances visible. Thanks to @wandererovertheseaofpiss for suggesting this.
- ([#1625](https://github.com/callumalpass/tasknotes/issues/1625)) Added `Shift` + `Cmd`/`Ctrl` + `Enter` in the Create Task modal to save the current task and reopen the modal for the next one. Thanks to @tcb678 for suggesting this.

View file

@ -41,6 +41,7 @@ In standard mode, the Kanban board displays a horizontal row of columns. Each co
Each column includes:
- A header showing the property value and task count
- A scrollable area containing task cards
- An add button that opens the TaskNotes creation modal with the column value filled in
- Drag-and-drop functionality for reordering columns or moving tasks between columns
### Swimlane Layout
@ -51,6 +52,7 @@ Each swimlane row includes:
- A label cell showing the swimlane property value and total task count
- Multiple cells, each representing a column within that swimlane
- Scrollable cells containing task cards
- Add buttons inside cells that fill in both the column value and swimlane value
To pin swimlanes in a stable order, set the advanced `swimLaneOrder` option to a JSON object keyed by the swimlane property:
@ -84,6 +86,8 @@ Drag task cards between columns to update the `groupBy` property value. In swiml
When grouping by a list property (contexts, tags, projects) with "Show items in multiple columns" enabled, dragging a task between columns modifies the list rather than replacing it. The source column's value is removed and the target column's value is added. For example, dragging a task from the "work" column to the "home" column changes `contexts: [work, call]` to `contexts: [call, home]`.
Drag operations write directly to task metadata, so consistent grouping values reduce ambiguity.
The add button at the bottom of a column or swimlane cell keeps deterministic view filter defaults and fills in the grouped values for that location.
### Manual Reordering Within Columns
Kanban also supports drag-to-reorder within a column or swimlane cell when the view is sorted by the manual-order property. With the default field mapping, that property is `tasknotes_manual_order`.

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Legacy Bases view rendering narrows DOM references through lifecycle checks. */
import { Notice, Platform, setIcon, TFile } from "obsidian";
import { Notice, Platform, setIcon, setTooltip, TFile } from "obsidian";
import TaskNotesPlugin from "../main";
import { BasesViewBase } from "./BasesViewBase";
import type { StatusConfig, TaskInfo } from "../types";
@ -1123,7 +1123,12 @@ export class KanbanView extends BasesViewBase {
);
// Create column
const column = await this.createColumn(groupKey, tasks, visibleProperties);
const column = await this.createColumn(
groupKey,
tasks,
visibleProperties,
groupByPropertyId
);
if (this.boardEl) {
this.boardEl.appendChild(column);
}
@ -1239,13 +1244,19 @@ export class KanbanView extends BasesViewBase {
);
// Render swimlane table
await this.renderSwimLaneTable(orderedSwimLanes, orderedKeys, pathToProps);
await this.renderSwimLaneTable(
orderedSwimLanes,
orderedKeys,
pathToProps,
groupByPropertyId
);
}
private async renderSwimLaneTable(
swimLanes: Map<string, Map<string, TaskInfo[]>>,
columnKeys: string[],
pathToProps: Map<string, Record<string, unknown>>
pathToProps: Map<string, Record<string, unknown>>,
groupByPropertyId: string
): Promise<void> {
if (!this.boardEl) return;
@ -1379,6 +1390,8 @@ export class KanbanView extends BasesViewBase {
this.setupCardDragHandlers(cardWrapper, task);
}
}
this.createAddTaskButton(cell, groupByPropertyId, columnKey, swimLaneKey);
}
}
}
@ -1386,7 +1399,8 @@ export class KanbanView extends BasesViewBase {
private async createColumn(
groupKey: string,
tasks: TaskInfo[],
visibleProperties: string[]
visibleProperties: string[],
groupByPropertyId: string | null
): Promise<HTMLElement> {
// Use containerEl.ownerDocument for pop-out window support
const doc = this.containerEl.ownerDocument;
@ -1446,9 +1460,126 @@ export class KanbanView extends BasesViewBase {
this.createNormalColumn(cardsContainer, tasks, visibleProperties, cardOptions);
}
this.createAddTaskButton(column, groupByPropertyId, groupKey);
return column;
}
private createAddTaskButton(
container: HTMLElement,
groupByPropertyId: string | null,
groupKey: string,
swimLaneKey: string | null = null
): void {
const footer = container.createDiv({ cls: "kanban-view__add-task-footer" });
const button = footer.createEl("button", {
cls: "kanban-view__add-task-button clickable-icon",
attr: {
type: "button",
"aria-label": this.getAddTaskLabel(groupKey, swimLaneKey),
},
});
setIcon(button, "plus");
setTooltip(button, this.getAddTaskLabel(groupKey, swimLaneKey));
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void this.openTaskCreationForKanbanCell(groupByPropertyId, groupKey, swimLaneKey);
});
}
private getAddTaskLabel(groupKey: string, swimLaneKey: string | null): string {
if (swimLaneKey) {
return `Add task to ${groupKey} / ${swimLaneKey}`;
}
return `Add task to ${groupKey}`;
}
private async openTaskCreationForKanbanCell(
groupByPropertyId: string | null,
groupKey: string,
swimLaneKey: string | null = null
): Promise<void> {
await this.createFileForView(undefined, (frontmatter) => {
this.applyKanbanCreationDefault(frontmatter, groupByPropertyId, groupKey);
this.applyKanbanCreationDefault(frontmatter, this.swimLanePropertyId, swimLaneKey);
});
}
private applyKanbanCreationDefault(
frontmatter: Record<string, unknown>,
propertyId: string | null,
groupKey: string | null
): void {
if (!propertyId || !groupKey || groupKey === "None") {
return;
}
const property = this.getCreatableFrontmatterProperty(propertyId);
if (!property) {
return;
}
if (this.isCreationListProperty(property)) {
const existing = frontmatter[property];
const values = Array.isArray(existing)
? existing.filter((item): item is string => typeof item === "string")
: typeof existing === "string"
? [existing]
: [];
if (!values.includes(groupKey)) {
frontmatter[property] = [...values, groupKey];
}
return;
}
frontmatter[property] = groupKey;
}
private getCreatableFrontmatterProperty(propertyId: string): string | null {
if (propertyId.startsWith("file.") || propertyId.startsWith("formula.")) {
return null;
}
const property = this.propertyMapper.basesToUserProperty(propertyId);
const fieldMapper = this.plugin.fieldMapper;
const allowedProperties = new Set([
fieldMapper.toUserField("status"),
fieldMapper.toUserField("priority"),
fieldMapper.toUserField("due"),
fieldMapper.toUserField("scheduled"),
fieldMapper.toUserField("contexts"),
fieldMapper.toUserField("projects"),
fieldMapper.toUserField("timeEstimate"),
fieldMapper.toUserField("recurrence"),
fieldMapper.toUserField("blockedBy"),
"tags",
]);
if (allowedProperties.has(property)) {
return property;
}
const userFields = this.plugin.settings.userFields || [];
return userFields.some((field) => field.key === property) ? property : null;
}
private isCreationListProperty(property: string): boolean {
const fieldMapper = this.plugin.fieldMapper;
if (
property === "tags" ||
property === fieldMapper.toUserField("contexts") ||
property === fieldMapper.toUserField("projects") ||
property === fieldMapper.toUserField("blockedBy")
) {
return true;
}
const userField = this.plugin.settings.userFields?.find((field) => field.key === property);
return userField?.type === "list" || this.isListTypeProperty(property);
}
private createVirtualColumn(
cardsContainer: HTMLElement,
groupKey: string,

View file

@ -19,8 +19,12 @@ import { buildCreationBlockingUpdates, buildTaskCreationData } from "./taskCreat
import { NLPSuggest } from "./taskCreationSuggest";
export type { StatusSuggestion } from "./taskCreationSuggest";
export type TaskCreationPrepopulatedValues = Partial<TaskInfo> & {
customFrontmatter?: Record<string, unknown>;
};
export interface TaskCreationOptions {
prePopulatedValues?: Partial<TaskInfo>;
prePopulatedValues?: TaskCreationPrepopulatedValues;
onTaskCreated?: (task: TaskInfo) => void;
creationContext?: "manual-creation" | "modal-inline-creation"; // Folder behavior context
}
@ -626,7 +630,7 @@ export class TaskCreationModal extends TaskModal {
this.originalDetails = this.details;
}
private applyPrePopulatedValues(values: Partial<TaskInfo>): void {
private applyPrePopulatedValues(values: TaskCreationPrepopulatedValues): void {
if (values.title !== undefined) this.title = values.title;
if (values.due !== undefined) this.dueDate = values.due;
if (values.scheduled !== undefined) this.scheduledDate = values.scheduled;
@ -657,6 +661,11 @@ export class TaskCreationModal extends TaskModal {
if (values.recurrence_anchor !== undefined) {
this.recurrenceAnchor = values.recurrence_anchor;
}
if (values.customFrontmatter) {
for (const [fieldKey, fieldValue] of Object.entries(values.customFrontmatter)) {
this.userFields[fieldKey] = fieldValue;
}
}
}
protected async handleSubmitShortcut(shift: boolean): Promise<void> {
@ -754,7 +763,7 @@ export class TaskCreationModal extends TaskModal {
}
private buildTaskData(): Partial<TaskInfo> {
return buildTaskCreationData({
const taskData = buildTaskCreationData({
title: this.title,
dueDate: this.dueDate,
scheduledDate: this.scheduledDate,
@ -775,6 +784,15 @@ export class TaskCreationModal extends TaskModal {
taskTag: this.plugin.settings.taskTag,
normalizeDetails: (value) => this.normalizeDetails(value),
});
const prePopulatedCustomFrontmatter = this.options.prePopulatedValues?.customFrontmatter;
if (prePopulatedCustomFrontmatter) {
taskData.customFrontmatter = {
...prePopulatedCustomFrontmatter,
...taskData.customFrontmatter,
};
}
return taskData;
}
// Override to prevent creating duplicate title input when NLP is enabled

View file

@ -358,6 +358,35 @@
min-height: 0; /* Allow container to shrink and enable scrolling */
}
.tasknotes-plugin .kanban-view__add-task-footer {
display: flex;
justify-content: center;
padding: var(--tn-spacing-xs);
border-top: 1px solid color-mix(in srgb, var(--tn-border-color) 72%, transparent);
flex-shrink: 0;
}
.tasknotes-plugin .kanban-view__add-task-button {
width: 100%;
min-height: 28px;
color: var(--tn-text-muted);
border-radius: var(--tn-radius-sm);
opacity: 0.72;
transition: opacity var(--tn-transition-fast), color var(--tn-transition-fast), background-color var(--tn-transition-fast);
}
.tasknotes-plugin .kanban-view__add-task-button:hover,
.tasknotes-plugin .kanban-view__add-task-button:focus-visible {
color: var(--tn-text-normal);
background: var(--tn-interactive-hover);
opacity: 1;
}
.tasknotes-plugin .kanban-view__add-task-button svg {
width: 16px;
height: 16px;
}
/* Virtualized columns need bounded height and relative positioning. */
.tasknotes-plugin .kanban-view__cards--virtual {
max-height: 100vh;

View file

@ -0,0 +1,155 @@
jest.mock("../../../src/modals/TaskCreationModal", () => ({
TaskCreationModal: jest.fn().mockImplementation(
(_app: unknown, _plugin: unknown, options: unknown) => ({
open: jest.fn(),
options,
})
),
}));
import { KanbanView } from "../../../src/bases/KanbanView";
import { TaskCreationModal } from "../../../src/modals/TaskCreationModal";
const fieldMapping = {
title: "title",
status: "status",
priority: "priority",
due: "due",
scheduled: "scheduled",
contexts: "contexts",
projects: "projects",
timeEstimate: "timeEstimate",
completedDate: "completedDate",
dateCreated: "dateCreated",
dateModified: "dateModified",
recurrence: "recurrence",
recurrenceAnchor: "recurrence_anchor",
archiveTag: "archived",
timeEntries: "timeEntries",
completeInstances: "complete_instances",
skippedInstances: "skipped_instances",
blockedBy: "blockedBy",
pomodoros: "pomodoros",
icsEventId: "icsEventId",
icsEventTag: "icsEventTag",
googleCalendarEventId: "googleCalendarEventId",
reminders: "reminders",
sortOrder: "sort_order",
};
function createPlugin() {
const userFields = [{ id: "workstream", key: "workstream", displayName: "Workstream", type: "text" }];
const recognized = new Set([...Object.values(fieldMapping), ...userFields.map((field) => field.key)]);
return {
app: {
metadataCache: {},
workspace: {},
},
fieldMapper: {
toUserField: (field: keyof typeof fieldMapping) => fieldMapping[field] ?? field,
getMapping: () => fieldMapping,
isRecognizedProperty: (property: string) => recognized.has(property),
},
i18n: {
translate: (key: string) => key,
},
settings: {
taskTag: "task",
userFields,
fieldMapping,
},
statusManager: {
getStatusConfig: () => undefined,
getAllStatuses: () => [],
},
priorityManager: {
getPriorityConfig: () => undefined,
getAllPriorities: () => [],
getPriorityWeight: () => 0,
},
};
}
function createView() {
return new KanbanView(
{
viewName: "Board",
query: {
views: [{ name: "Board", groupBy: { property: "task.status" } }],
},
},
document.createElement("div"),
createPlugin() as any
);
}
describe("Issue #1471: create tasks from Kanban columns", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("opens the TaskNotes creation modal with view filter and column defaults", async () => {
const view = createView();
(view as any).config = {
filters: {
conjunction: "and",
filters: [
{ rule: { text: 'file.hasTag("task")' } },
{ rule: { text: 'projects.contains("[[Project Alpha]]")' } },
],
},
};
await (view as any).openTaskCreationForKanbanCell("task.status", "done");
expect(TaskCreationModal).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
prePopulatedValues: expect.objectContaining({
status: "done",
projects: ["[[Project Alpha]]"],
}),
})
);
});
it("uses both column and swimlane values when creating from a swimlane cell", async () => {
const view = createView();
(view as any).config = {};
(view as any).swimLanePropertyId = "task.priority";
await (view as any).openTaskCreationForKanbanCell("task.status", "in-progress", "high");
expect(TaskCreationModal).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
prePopulatedValues: expect.objectContaining({
status: "in-progress",
priority: "high",
}),
})
);
});
it("passes custom grouped properties through custom frontmatter", async () => {
const view = createView();
(view as any).config = {};
await (view as any).openTaskCreationForKanbanCell("note.workstream", "Research");
expect(TaskCreationModal).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
prePopulatedValues: expect.objectContaining({
customFrontmatter: {
workstream: "Research",
},
}),
})
);
});
});