Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
```

-->

## Added

- Kanban boards grouped by status now tint columns with each status's configured color, including swimlane boards.
48 changes: 48 additions & 0 deletions src/bases/KanbanView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
shouldRenderKanbanColumn,
} from "./kanbanGrouping";
import { createTaskNotesLogger } from "../utils/tasknotesLogger";
import { isSupportedColorValue, normalizeThemeColor } from "../utils/themeColors";
import { processVaultFrontMatter } from "../services/VaultMutationService";

const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/KanbanView" });
Expand Down Expand Up @@ -1369,6 +1370,35 @@ export class KanbanView extends BasesViewBase {
);
}

private applyStatusColor(
element: HTMLElement,
groupKey: string,
groupByPropertyId: string | null,
modifierClass: string
): void {
if (!groupByPropertyId || !this.isStatusGroupingProperty(groupByPropertyId)) return;

const status = this.findStatusConfigForGroupKey(groupKey);
if (!status || !isSupportedColorValue(status.color)) return;

const color = normalizeThemeColor(status.color);
if (!color || !this.isRenderableStatusColor(element, color)) return;

element.addClass(modifierClass);
element.style.setProperty("--tn-kanban-status-color", color);
}

private isRenderableStatusColor(element: HTMLElement, color: string): boolean {
const css = element.ownerDocument.defaultView?.CSS;
if (css && typeof css.supports === "function") {
return css.supports("color", color);
}

const probe = element.ownerDocument.win.createSpan();
probe.style.color = color;
return probe.style.color !== "";
}

private isUnknownStatusGroup(groupKey: string, groupByPropertyId: string | null): boolean {
if (!groupByPropertyId || !this.isStatusGroupingProperty(groupByPropertyId)) {
return false;
Expand Down Expand Up @@ -1554,6 +1584,12 @@ export class KanbanView extends BasesViewBase {
});
headerCell.setAttribute("draggable", "true");
headerCell.setAttribute("data-column-key", columnKey);
this.applyStatusColor(
headerCell,
columnKey,
groupByPropertyId,
"kanban-view__column-header-cell--status-colored"
);
const isUnknownStatusColumn = this.isUnknownStatusGroup(columnKey, groupByPropertyId);
if (isUnknownStatusColumn) {
this.markUnknownStatusColumnHeader(headerCell, columnKey);
Expand Down Expand Up @@ -1629,6 +1665,12 @@ export class KanbanView extends BasesViewBase {
"data-swimlane": swimLaneKey,
},
});
this.applyStatusColor(
cell,
columnKey,
groupByPropertyId,
"kanban-view__swimlane-column--status-colored"
);
if (isUnknownStatusColumn) {
this.markUnknownStatusColumn(cell, columnKey);
}
Expand Down Expand Up @@ -1693,6 +1735,12 @@ export class KanbanView extends BasesViewBase {
column.className = "kanban-view__column";
column.style.width = `${this.columnWidth}px`;
column.setAttribute("data-group", groupKey);
this.applyStatusColor(
column,
groupKey,
groupByPropertyId,
"kanban-view__column--status-colored"
);
const isUnknownStatusColumn = this.isUnknownStatusGroup(groupKey, groupByPropertyId);
if (isUnknownStatusColumn) {
this.markUnknownStatusColumn(column, groupKey);
Expand Down
10 changes: 10 additions & 0 deletions styles/bases-views.css
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,11 @@ body.is-mobile .tn-bases-kanban-column {
border-right: 1px solid var(--tn-border-color);
}

.kanban-view__column-header-cell--status-colored {
background: color-mix(in srgb, var(--tn-kanban-status-color) 12%, var(--tn-bg-secondary));
border-right-color: color-mix(in srgb, var(--tn-kanban-status-color) 38%, var(--tn-border-color));
}

.kanban-view__column-header-cell:active {
cursor: grabbing;
}
Expand Down Expand Up @@ -569,6 +574,11 @@ body.is-mobile .tn-bases-kanban-column {
overflow: hidden;
}

.kanban-view__swimlane-column--status-colored {
background: color-mix(in srgb, var(--tn-kanban-status-color) 6%, var(--tn-bg-primary));
border-right-color: color-mix(in srgb, var(--tn-kanban-status-color) 38%, var(--tn-border-color));
}

.kanban-view__swimlane-column.kanban-view__column--unknown-status {
border-left: 1px dashed var(--tn-text-faint);
background: var(--tn-bg-secondary);
Expand Down
7 changes: 6 additions & 1 deletion styles/kanban-view.css
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
================================================ */

/* Column */
.tasknotes-plugin .kanban-view__column {
.tasknotes-plugin .kanban-view__column {
min-width: 200px;
max-width: 500px;
flex-shrink: 0;
Expand All @@ -199,6 +199,11 @@
transition: border-color var(--tn-transition-fast), box-shadow var(--tn-transition-fast), background-color var(--tn-transition-fast);
}

.tasknotes-plugin .kanban-view__column--status-colored {
background: color-mix(in srgb, var(--tn-kanban-status-color) 6%, var(--tn-bg-primary));
border-color: color-mix(in srgb, var(--tn-kanban-status-color) 38%, var(--tn-border-color));
}

.tasknotes-plugin .kanban-view__column--dragover {
border-color: var(--tn-interactive-accent);
box-shadow: var(--tn-shadow-medium);
Expand Down
223 changes: 223 additions & 0 deletions tests/unit/bases/KanbanView.statusColors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { KanbanView } from "../../../src/bases/KanbanView";
import { StatusManager } from "../../../src/services/StatusManager";
import type { StatusConfig } from "../../../src/types";

const OPEN_STATUS: StatusConfig = {
id: "open",
value: "open",
label: "Open",
color: "#7c3aed",
icon: "lucide-circle",
isCompleted: false,
order: 1,
autoArchive: false,
autoArchiveDelay: 5,
};

const THEME_COLOR_STATUS: StatusConfig = {
...OPEN_STATUS,
id: "theme-color",
value: "theme-color",
label: "Theme color",
color: "blue",
order: 2,
};

const FUNCTION_COLOR_STATUS: StatusConfig = {
...OPEN_STATUS,
id: "function-color",
value: "function-color",
label: "Function color",
color: "rgb(10 20 30)",
order: 3,
};

const INVALID_COLOR_STATUS: StatusConfig = {
...OPEN_STATUS,
id: "invalid-color",
value: "invalid-color",
label: "Invalid color",
color: "rgb(",
order: 4,
};

const STATUSES = [
OPEN_STATUS,
THEME_COLOR_STATUS,
FUNCTION_COLOR_STATUS,
INVALID_COLOR_STATUS,
];

function makePlugin() {
return {
app: {
metadataCache: {
getFirstLinkpathDest: () => null,
getFileCache: () => undefined,
},
vault: {
getAbstractFileByPath: () => null,
},
workspace: {
getLeaf: () => ({ openFile: jest.fn() }),
openLinkText: jest.fn(),
},
},
fieldMapper: {
toUserField: (field: string) => field,
isRecognizedProperty: () => true,
},
statusManager: new StatusManager(STATUSES, "open"),
priorityManager: {
getAllPriorities: () => [],
normalizePriorityValue: (value: string) => value,
},
i18n: {
translate: (key: string) => (key === "views.kanban.noTasks" ? "No tasks" : key),
},
settings: {
customStatuses: STATUSES,
fieldMapping: {
sortOrder: "sort_order",
},
},
};
}

function makeView(): KanbanView {
const view = new KanbanView({}, document.createElement("div"), makePlugin() as any);
(view as any).config = {
get: jest.fn(() => undefined),
getOrder: jest.fn(() => []),
getDisplayName: jest.fn(() => undefined),
};
return view;
}

function expectStatusColor(
element: Element | null,
modifierClass: string,
color = OPEN_STATUS.color
): void {
expect(element).not.toBeNull();
expect(element?.classList.contains(modifierClass)).toBe(true);
expect((element as HTMLElement).style.getPropertyValue("--tn-kanban-status-color")).toBe(
color
);
}

function expectNoStatusColor(element: HTMLElement, modifierClass: string): void {
expect(element.classList.contains(modifierClass)).toBe(false);
expect(element.style.getPropertyValue("--tn-kanban-status-color")).toBe("");
}

describe("Kanban status colors", () => {
const originalCss = window.CSS;

beforeAll(() => {
Object.defineProperty(window, "CSS", {
configurable: true,
value: {
supports: (_property: string, value: string) => value !== INVALID_COLOR_STATUS.color,
},
});
});

afterAll(() => {
Object.defineProperty(window, "CSS", {
configurable: true,
value: originalCss,
});
});

it("marks only configured status-grouped flat columns with their status color", async () => {
const view = makeView();

const statusColumn = await (view as any).createColumn("open", [], [], "status");
const projectColumn = await (view as any).createColumn("open", [], [], "projects");
const unknownStatusColumn = await (view as any).createColumn(
"external-status",
[],
[],
"status"
);
const themeColorColumn = await (view as any).createColumn(
"theme-color",
[],
[],
"status"
);
const functionColorColumn = await (view as any).createColumn(
"function-color",
[],
[],
"status"
);
const invalidColorColumn = await (view as any).createColumn(
"invalid-color",
[],
[],
"status"
);

expectStatusColor(statusColumn, "kanban-view__column--status-colored");
expectStatusColor(
themeColorColumn,
"kanban-view__column--status-colored",
"var(--color-blue)"
);
expectStatusColor(
functionColorColumn,
"kanban-view__column--status-colored",
FUNCTION_COLOR_STATUS.color
);
expectNoStatusColor(projectColumn, "kanban-view__column--status-colored");
expectNoStatusColor(unknownStatusColumn, "kanban-view__column--status-colored");
expectNoStatusColor(invalidColorColumn, "kanban-view__column--status-colored");
});

it("marks configured status swimlane headers and matching cells", async () => {
const view = makeView();
const board = document.createElement("div");
(view as any).boardEl = board;
(view as any).getVisibleProperties = jest.fn(() => []);
(view as any).setupColumnHeaderDragHandlers = jest.fn();
(view as any).setupSwimLaneCellDragDrop = jest.fn();
(view as any).renderEmptyCellHint = jest.fn();
(view as any).createAddTaskButton = jest.fn();

await (view as any).renderSwimLaneTable(
new Map([
[
"todo",
new Map([
["open", []],
["external-status", []],
]),
],
]),
["open", "external-status"],
new Map(),
"status"
);

expectStatusColor(
board.querySelector('[data-column-key="open"]'),
"kanban-view__column-header-cell--status-colored"
);
expectStatusColor(
board.querySelector('[data-column="open"]'),
"kanban-view__swimlane-column--status-colored"
);
expectNoStatusColor(
board.querySelector<HTMLElement>(
'[data-column-key="external-status"]'
) as HTMLElement,
"kanban-view__column-header-cell--status-colored"
);
expectNoStatusColor(
board.querySelector<HTMLElement>('[data-column="external-status"]') as HTMLElement,
"kanban-view__swimlane-column--status-colored"
);
});
});