mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Add Kanban column task creation
This commit is contained in:
parent
8f72d92453
commit
540255af41
6 changed files with 346 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
155
tests/unit/issues/issue-1471-kanban-column-create-task.test.ts
Normal file
155
tests/unit/issues/issue-1471-kanban-column-create-task.test.ts
Normal 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",
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue