feat: separate column toggle buttons

This commit is contained in:
2026-07-18 08:32:34 +05:00
parent 9e63a41a20
commit 14c6e87a65
7 changed files with 166 additions and 45 deletions
+38 -7
View File
@@ -21,7 +21,11 @@ function task(partial: Partial<Task> & Pick<Task, "id" | "title">): Task {
}; };
} }
const visibleStatuses = TASK_STATUS_ORDER.filter( const defaultHiddenStatuses = TASK_STATUS_ORDER.filter(
(status) => TASK_STATUS_META[status].defaultHidden,
);
const visibleByDefaultStatuses = TASK_STATUS_ORDER.filter(
(status) => !TASK_STATUS_META[status].defaultHidden, (status) => !TASK_STATUS_META[status].defaultHidden,
); );
@@ -65,19 +69,21 @@ describe("buildKanbanBoard", () => {
}), }),
]; ];
it("hides defaultHidden statuses by default", () => { it("hides all defaultHidden statuses by default", () => {
const board = buildKanbanBoard(tasks); const board = buildKanbanBoard(tasks);
const statuses = board.columns.map((col) => col.status); const statuses = board.columns.map((col) => col.status);
expect(statuses).toEqual(visibleStatuses); expect(statuses).toEqual(visibleByDefaultStatuses);
for (const col of board.columns) { for (const col of board.columns) {
expect(TASK_STATUS_META[col.status].defaultHidden).toBe(false); expect(TASK_STATUS_META[col.status].defaultHidden).toBe(false);
expect(col.label).toBe(TASK_STATUS_META[col.status].label); expect(col.label).toBe(TASK_STATUS_META[col.status].label);
} }
}); });
it("includes all statuses when showHiddenStatuses is true", () => { it("includes all statuses when all defaultHidden statuses are in shownHiddenColumns", () => {
const board = buildKanbanBoard(tasks, { showHiddenStatuses: true }); const board = buildKanbanBoard(tasks, {
shownHiddenColumns: [...defaultHiddenStatuses],
});
expect(board.columns.map((col) => col.status)).toEqual(TASK_STATUS_ORDER); expect(board.columns.map((col) => col.status)).toEqual(TASK_STATUS_ORDER);
for (const col of board.columns) { for (const col of board.columns) {
@@ -85,8 +91,31 @@ describe("buildKanbanBoard", () => {
} }
}); });
it("shows only selected hidden columns", () => {
const board = buildKanbanBoard(tasks, {
shownHiddenColumns: [TaskStatus.BACKLOG],
});
const statuses = board.columns.map((col) => col.status);
expect(statuses).toContain(TaskStatus.BACKLOG);
expect(statuses).not.toContain(TaskStatus.CANCELLED);
expect(statuses).not.toContain(TaskStatus.UNKNOWN);
for (const status of visibleByDefaultStatuses) {
expect(statuses).toContain(status);
}
});
it("empty shownHiddenColumns hides all defaultHidden columns", () => {
const board = buildKanbanBoard(tasks, { shownHiddenColumns: [] });
const statuses = board.columns.map((col) => col.status);
expect(statuses).toEqual(visibleByDefaultStatuses);
});
it("places each task into the column matching its status", () => { it("places each task into the column matching its status", () => {
const board = buildKanbanBoard(tasks, { showHiddenStatuses: true }); const board = buildKanbanBoard(tasks, {
shownHiddenColumns: [...defaultHiddenStatuses],
});
for (const col of board.columns) { for (const col of board.columns) {
for (const t of col.tasks) { for (const t of col.tasks) {
@@ -113,7 +142,9 @@ describe("buildKanbanBoard", () => {
it("returns empty visible columns when there are no tasks", () => { it("returns empty visible columns when there are no tasks", () => {
const board = buildKanbanBoard([]); const board = buildKanbanBoard([]);
expect(board.columns.map((col) => col.status)).toEqual(visibleStatuses); expect(board.columns.map((col) => col.status)).toEqual(
visibleByDefaultStatuses,
);
for (const col of board.columns) { for (const col of board.columns) {
expect(col.tasks).toEqual([]); expect(col.tasks).toEqual([]);
} }
+6 -4
View File
@@ -16,8 +16,8 @@ export type KanbanBoard = {
}; };
export type BuildKanbanBoardOptions = { export type BuildKanbanBoardOptions = {
/** When false (default), columns with meta.defaultHidden are omitted. */ /** `defaultHidden`-statuses to show; empty/undefined = hide all hidden. */
showHiddenStatuses?: boolean; shownHiddenColumns?: TaskStatus[];
}; };
/** Build status columns; empty columns kept. Hidden statuses optional. */ /** Build status columns; empty columns kept. Hidden statuses optional. */
@@ -25,9 +25,11 @@ export function buildKanbanBoard(
tasks: Task[], tasks: Task[],
options: BuildKanbanBoardOptions = {}, options: BuildKanbanBoardOptions = {},
): KanbanBoard { ): KanbanBoard {
const showHidden = options.showHiddenStatuses ?? false; const extraVisible = options.shownHiddenColumns ?? [];
const statuses = TASK_STATUS_ORDER.filter( const statuses = TASK_STATUS_ORDER.filter(
(status) => showHidden || !TASK_STATUS_META[status].defaultHidden, (status) =>
!TASK_STATUS_META[status].defaultHidden ||
extraVisible.includes(status),
); );
const columns: KanbanColumn[] = statuses.map((status) => { const columns: KanbanColumn[] = statuses.map((status) => {
+24 -6
View File
@@ -49,12 +49,6 @@ describe("parseKanbanInboundMessage", () => {
).toEqual(ok({ variant: "setScopeFilter", scope: "all" })); ).toEqual(ok({ variant: "setScopeFilter", scope: "all" }));
}); });
it("parses setShowHidden", () => {
expect(
parseKanbanInboundMessage({ variant: "setShowHidden", showHidden: true }),
).toEqual(ok({ variant: "setShowHidden", showHidden: true }));
});
it("parses createTask", () => { it("parses createTask", () => {
expect( expect(
parseKanbanInboundMessage({ parseKanbanInboundMessage({
@@ -64,6 +58,30 @@ describe("parseKanbanInboundMessage", () => {
).toEqual(ok({ variant: "createTask", status: TaskStatus.BACKLOG })); ).toEqual(ok({ variant: "createTask", status: TaskStatus.BACKLOG }));
}); });
it("parses toggleHiddenColumn", () => {
expect(
parseKanbanInboundMessage({
variant: "toggleHiddenColumn",
status: TaskStatus.BACKLOG,
}),
).toEqual(
ok({
variant: "toggleHiddenColumn",
status: TaskStatus.BACKLOG,
} satisfies KanbanInboundMessage),
);
});
it("rejects toggleHiddenColumn with invalid status", () => {
const result = parseKanbanInboundMessage({
variant: "toggleHiddenColumn",
status: "__not_a_status__",
});
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
it("rejects non-objects", () => { it("rejects non-objects", () => {
const result = parseKanbanInboundMessage(null); const result = parseKanbanInboundMessage(null);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
+9 -9
View File
@@ -11,8 +11,8 @@ export type KanbanInboundMessage =
| { variant: "refresh" } | { variant: "refresh" }
| { variant: "moveTask"; id: string; status: TaskStatus } | { variant: "moveTask"; id: string; status: TaskStatus }
| { variant: "setScopeFilter"; scope: KanbanScopeFilter } | { variant: "setScopeFilter"; scope: KanbanScopeFilter }
| { variant: "setShowHidden"; showHidden: boolean } | { variant: "createTask"; status: TaskStatus }
| { variant: "createTask"; status: TaskStatus }; | { variant: "toggleHiddenColumn"; status: TaskStatus };
export function isKanbanScopeFilter( export function isKanbanScopeFilter(
value: unknown, value: unknown,
@@ -66,13 +66,6 @@ export function parseKanbanInboundMessage(
return ok({ variant: "setScopeFilter", scope: raw.scope }); return ok({ variant: "setScopeFilter", scope: raw.scope });
} }
case "setShowHidden": {
if (typeof raw.showHidden !== "boolean") {
return invalid(variant);
}
return ok({ variant: "setShowHidden", showHidden: raw.showHidden });
}
case "createTask": { case "createTask": {
if (!isTaskStatus(raw.status)) { if (!isTaskStatus(raw.status)) {
return invalid(variant); return invalid(variant);
@@ -80,6 +73,13 @@ export function parseKanbanInboundMessage(
return ok({ variant: "createTask", status: raw.status }); return ok({ variant: "createTask", status: raw.status });
} }
case "toggleHiddenColumn": {
if (!isTaskStatus(raw.status)) {
return invalid(variant);
}
return ok({ variant: "toggleHiddenColumn", status: raw.status });
}
default: default:
return invalid(variant); return invalid(variant);
} }
+3 -1
View File
@@ -1,5 +1,6 @@
import { AppError } from "@/error"; import { AppError } from "@/error";
import { TaskScope } from "@/model/taskLocation"; import { TaskScope } from "@/model/taskLocation";
import { TaskStatus } from "@/model/taskStatus";
import { KanbanBoard } from "./buildKanbanBoard"; import { KanbanBoard } from "./buildKanbanBoard";
import { KanbanScopeFilter } from "./messages"; import { KanbanScopeFilter } from "./messages";
@@ -9,7 +10,8 @@ export type KanbanOutboundMessage =
variant: "state"; variant: "state";
board: KanbanBoard; board: KanbanBoard;
scopeFilter: KanbanScopeFilter; scopeFilter: KanbanScopeFilter;
showHidden: boolean; shownHiddenColumns: TaskStatus[];
defaultHiddenStatuses: TaskStatus[];
availableScopes: TaskScope[]; availableScopes: TaskScope[];
} }
| { variant: "error"; error: AppError }; | { variant: "error"; error: AppError };
+66 -11
View File
@@ -76,6 +76,46 @@ export function getTaskKanbanHtml(
line-height: 1.2; line-height: 1.2;
min-width: 24px; min-width: 24px;
} }
button.eye-btn {
padding: 0 4px;
font-size: 0.85em;
line-height: 1.2;
min-width: 20px;
background: transparent;
border: 1px solid transparent;
color: var(--vscode-foreground);
opacity: 0.6;
cursor: pointer;
}
button.eye-btn:hover { opacity: 1; }
#visibility-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
font-size: 0.85em;
}
#visibility-bar .label {
opacity: 0.7;
margin-right: 4px;
}
#visibility-bar button {
font: inherit;
cursor: pointer;
padding: 2px 8px;
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 2px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-size: 0.85em;
}
#visibility-bar button.shown {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border-color: transparent;
}
#status-line { #status-line {
font-size: 0.85em; font-size: 0.85em;
opacity: 0.85; opacity: 0.85;
@@ -147,11 +187,11 @@ export function getTaskKanbanHtml(
<h1>Kanban</h1> <h1>Kanban</h1>
<label for="scopeFilter">Scope</label> <label for="scopeFilter">Scope</label>
<select id="scopeFilter" title="Project / Global filter"></select> <select id="scopeFilter" title="Project / Global filter"></select>
<button id="toggleHidden" type="button" class="secondary" title="Show or hide backlog and cancelled">Show hidden</button>
<span class="spacer"></span> <span class="spacer"></span>
<span id="status-line"></span> <span id="status-line"></span>
<button id="refresh" type="button" class="secondary" title="Refresh">↻</button> <button id="refresh" type="button" class="secondary" title="Refresh">↻</button>
</header> </header>
<div id="visibility-bar"></div>
<div id="board"></div> <div id="board"></div>
<script nonce="${nonce}"> <script nonce="${nonce}">
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
@@ -164,11 +204,12 @@ export function getTaskKanbanHtml(
const statusLine = document.getElementById("status-line"); const statusLine = document.getElementById("status-line");
const refreshBtn = document.getElementById("refresh"); const refreshBtn = document.getElementById("refresh");
const scopeFilterEl = document.getElementById("scopeFilter"); const scopeFilterEl = document.getElementById("scopeFilter");
const toggleHiddenBtn = document.getElementById("toggleHidden"); const visibilityBarEl = document.getElementById("visibility-bar");
let board = { columns: [] }; let board = { columns: [] };
let scopeFilter = "all"; let scopeFilter = "all";
let showHidden = false; let shownHiddenColumns = [];
let defaultHiddenStatuses = [];
let availableScopes = []; let availableScopes = [];
let dragTaskId = null; let dragTaskId = null;
@@ -214,13 +255,30 @@ export function getTaskKanbanHtml(
.join(""); .join("");
} }
function renderToggleHidden() { function renderVisibilityBar() {
toggleHiddenBtn.textContent = showHidden ? "Hide backlog/cancelled" : "Show hidden"; visibilityBarEl.innerHTML = "";
if (defaultHiddenStatuses.length === 0) return;
const label = document.createElement("span");
label.className = "label";
label.textContent = "Columns:";
visibilityBarEl.appendChild(label);
for (const status of defaultHiddenStatuses) {
const btn = document.createElement("button");
btn.textContent = status;
btn.dataset.status = status;
if (shownHiddenColumns.indexOf(status) >= 0) {
btn.classList.add("shown");
}
btn.addEventListener("click", () => {
post({ variant: "toggleHiddenColumn", status: status });
});
visibilityBarEl.appendChild(btn);
}
} }
function render() { function render() {
renderScopeFilter(); renderScopeFilter();
renderToggleHidden(); renderVisibilityBar();
boardEl.innerHTML = ""; boardEl.innerHTML = "";
for (const column of board.columns || []) { for (const column of board.columns || []) {
const col = document.createElement("section"); const col = document.createElement("section");
@@ -308,17 +366,14 @@ export function getTaskKanbanHtml(
post({ variant: "setScopeFilter", scope: scopeFilterEl.value }); post({ variant: "setScopeFilter", scope: scopeFilterEl.value });
}); });
toggleHiddenBtn.addEventListener("click", () => {
post({ variant: "setShowHidden", showHidden: !showHidden });
});
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
const message = event.data; const message = event.data;
if (!message || typeof message !== "object") return; if (!message || typeof message !== "object") return;
if (message.variant === "state") { if (message.variant === "state") {
board = message.board || { columns: [] }; board = message.board || { columns: [] };
scopeFilter = message.scopeFilter || "all"; scopeFilter = message.scopeFilter || "all";
showHidden = Boolean(message.showHidden); shownHiddenColumns = message.shownHiddenColumns || [];
defaultHiddenStatuses = message.defaultHiddenStatuses || [];
availableScopes = message.availableScopes || []; availableScopes = message.availableScopes || [];
setStatus(""); setStatus("");
render(); render();
+20 -7
View File
@@ -12,7 +12,7 @@ import {
import { KanbanOutboundMessage } from "@/kanban/outboundMessages"; import { KanbanOutboundMessage } from "@/kanban/outboundMessages";
import { appError, AppErrorVariant } from "@/error"; import { appError, AppErrorVariant } from "@/error";
import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation"; import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation";
import { TaskStatus } from "@/model/taskStatus"; import { TaskStatus, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus";
import { IConfigProvider, ITaskRepository } from "@/ports"; import { IConfigProvider, ITaskRepository } from "@/ports";
import { presentError } from "@/ui/presentError"; import { presentError } from "@/ui/presentError";
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels"; import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
@@ -60,7 +60,7 @@ export class TaskKanbanPanel {
#tasks: LocatedTask[] = []; #tasks: LocatedTask[] = [];
#scopeFilter: KanbanScopeFilter = "all"; #scopeFilter: KanbanScopeFilter = "all";
#showHidden = false; #shownHiddenColumns: TaskStatus[] = [];
#disposed = false; #disposed = false;
constructor(panel: vscode.WebviewPanel, deps: TaskKanbanDeps) { constructor(panel: vscode.WebviewPanel, deps: TaskKanbanDeps) {
@@ -125,9 +125,8 @@ export class TaskKanbanPanel {
this.#postState(); this.#postState();
return; return;
case "setShowHidden": case "toggleHiddenColumn":
this.#showHidden = message.showHidden; this.#toggleHiddenColumn(message.status);
this.#postState();
return; return;
case "createTask": case "createTask":
@@ -201,6 +200,16 @@ export class TaskKanbanPanel {
this.#deps.onTasksMutated?.(); this.#deps.onTasksMutated?.();
} }
#toggleHiddenColumn(status: TaskStatus): void {
const index = this.#shownHiddenColumns.indexOf(status);
if (index >= 0) {
this.#shownHiddenColumns.splice(index, 1);
} else {
this.#shownHiddenColumns.push(status);
}
this.#postState();
}
async #pickLocationForCreate(): Promise<TaskLocation | undefined> { async #pickLocationForCreate(): Promise<TaskLocation | undefined> {
const locations = resolveTaskLocations(this.#deps.config); const locations = resolveTaskLocations(this.#deps.config);
if (locations.length === 0) { if (locations.length === 0) {
@@ -251,13 +260,17 @@ export class TaskKanbanPanel {
#postState(): void { #postState(): void {
const board = buildKanbanBoard( const board = buildKanbanBoard(
this.#filteredTasks().map((item) => item.task), this.#filteredTasks().map((item) => item.task),
{ showHiddenStatuses: this.#showHidden }, { shownHiddenColumns: this.#shownHiddenColumns },
); );
this.#post({ this.#post({
variant: "state", variant: "state",
board, board,
scopeFilter: this.#scopeFilter, scopeFilter: this.#scopeFilter,
showHidden: this.#showHidden, shownHiddenColumns: this.#shownHiddenColumns,
defaultHiddenStatuses: TASK_STATUS_ORDER.filter(
(s) => TASK_STATUS_META[s].defaultHidden,
),
availableScopes: this.#availableScopes(), availableScopes: this.#availableScopes(),
}); });
} }