feat: add config for kanban columns
This commit is contained in:
@@ -51,6 +51,7 @@ export function addProjectTag(
|
|||||||
|
|
||||||
const config: ProjectConfig = {
|
const config: ProjectConfig = {
|
||||||
tags: [...loaded.config.tags, tag],
|
tags: [...loaded.config.tags, tag],
|
||||||
|
shownHiddenColumns: loaded.config.shownHiddenColumns,
|
||||||
};
|
};
|
||||||
const content = serializeProjectConfig(config);
|
const content = serializeProjectConfig(config);
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ describe("projectConfig model", () => {
|
|||||||
expect(getProjectConfigPath("C:\\proj\\.xflow")).toMatch(/config\.yml$/);
|
expect(getProjectConfigPath("C:\\proj\\.xflow")).toMatch(/config\.yml$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty config has no tags", () => {
|
it("empty config has no tags and no shown hidden columns", () => {
|
||||||
expect(EMPTY_PROJECT_CONFIG).toEqual({ tags: [] });
|
expect(EMPTY_PROJECT_CONFIG).toEqual({
|
||||||
|
tags: [],
|
||||||
|
shownHiddenColumns: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizeTag trims and rejects empty", () => {
|
it("normalizeTag trims and rejects empty", () => {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ export const TASK_FOLDER_CONFIG_FILENAME = "config.yml";
|
|||||||
|
|
||||||
export type ProjectConfig = {
|
export type ProjectConfig = {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
shownHiddenColumns: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EMPTY_PROJECT_CONFIG: ProjectConfig = {
|
export const EMPTY_PROJECT_CONFIG: ProjectConfig = {
|
||||||
tags: [],
|
tags: [],
|
||||||
|
shownHiddenColumns: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Absolute/joined path to config.yml in a task folder. */
|
/** Absolute/joined path to config.yml in a task folder. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AppErrorVariant } from "@/error";
|
import { AppErrorVariant } from "@/error";
|
||||||
|
import { TaskStatus } from "@/model/taskStatus";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseProjectConfig } from "./parseProjectConfig";
|
import { parseProjectConfig } from "./parseProjectConfig";
|
||||||
|
|
||||||
@@ -43,4 +44,32 @@ describe("parseProjectConfig", () => {
|
|||||||
if (result.isOk()) return;
|
if (result.isOk()) return;
|
||||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG);
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses shownHiddenColumns", () => {
|
||||||
|
const result = parseProjectConfig(
|
||||||
|
"tags: []\nshownHiddenColumns:\n - backlog\n - cancelled\n",
|
||||||
|
);
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.shownHiddenColumns).toEqual([
|
||||||
|
TaskStatus.BACKLOG,
|
||||||
|
TaskStatus.CANCELLED,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shownHiddenColumns defaults to empty array when missing", () => {
|
||||||
|
const result = parseProjectConfig("tags: []\n");
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.shownHiddenColumns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects shownHiddenColumns with invalid status values", () => {
|
||||||
|
const result = parseProjectConfig(
|
||||||
|
"tags: []\nshownHiddenColumns:\n - backlog\n - not_a_status\n",
|
||||||
|
);
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,11 +6,31 @@ import {
|
|||||||
normalizeTag,
|
normalizeTag,
|
||||||
type ProjectConfig,
|
type ProjectConfig,
|
||||||
} from "@/model/projectConfig";
|
} from "@/model/projectConfig";
|
||||||
|
import { isTaskStatus } from "@/model/taskStatus";
|
||||||
|
|
||||||
|
function parseShownHiddenColumns(
|
||||||
|
value: unknown,
|
||||||
|
): Result<string[], AppError> {
|
||||||
|
if (value === undefined) return ok([]);
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return err(
|
||||||
|
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
|
||||||
|
detail: "shownHiddenColumns must be an array of strings",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item !== "string" || !isTaskStatus(item)) {
|
||||||
|
return err(
|
||||||
|
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
|
||||||
|
detail: `invalid status in shownHiddenColumns: ${item}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok(value as string[]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse task-folder config.yml content.
|
|
||||||
* Accepts missing/empty tags → [].
|
|
||||||
*/
|
|
||||||
export function parseProjectConfig(
|
export function parseProjectConfig(
|
||||||
content: string,
|
content: string,
|
||||||
): Result<ProjectConfig, AppError> {
|
): Result<ProjectConfig, AppError> {
|
||||||
@@ -39,10 +59,11 @@ export function parseProjectConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = data as Record<string, unknown>;
|
const record = data as Record<string, unknown>;
|
||||||
if (record.tags === undefined) {
|
|
||||||
return ok({ tags: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let tags: string[];
|
||||||
|
if (record.tags === undefined) {
|
||||||
|
tags = [];
|
||||||
|
} else {
|
||||||
if (!Array.isArray(record.tags)) {
|
if (!Array.isArray(record.tags)) {
|
||||||
return err(
|
return err(
|
||||||
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
|
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
|
||||||
@@ -51,7 +72,7 @@ export function parseProjectConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags: string[] = [];
|
tags = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const item of record.tags) {
|
for (const item of record.tags) {
|
||||||
if (typeof item !== "string") {
|
if (typeof item !== "string") {
|
||||||
@@ -66,6 +87,16 @@ export function parseProjectConfig(
|
|||||||
seen.add(tag);
|
seen.add(tag);
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ok({ tags });
|
const shownHiddenColumnsResult = parseShownHiddenColumns(
|
||||||
|
record.shownHiddenColumns,
|
||||||
|
);
|
||||||
|
if (shownHiddenColumnsResult.isErr())
|
||||||
|
return err(shownHiddenColumnsResult.error);
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
tags,
|
||||||
|
shownHiddenColumns: shownHiddenColumnsResult.value,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { serializeProjectConfig } from "./serializeProjectConfig";
|
|||||||
|
|
||||||
describe("serializeProjectConfig", () => {
|
describe("serializeProjectConfig", () => {
|
||||||
it("round-trips tags", () => {
|
it("round-trips tags", () => {
|
||||||
const yaml = serializeProjectConfig({ tags: ["bug", "docs"] });
|
const yaml = serializeProjectConfig({
|
||||||
|
tags: ["bug", "docs"],
|
||||||
|
shownHiddenColumns: [],
|
||||||
|
});
|
||||||
expect(yaml).toContain("bug");
|
expect(yaml).toContain("bug");
|
||||||
expect(yaml).toContain("docs");
|
expect(yaml).toContain("docs");
|
||||||
const parsed = parseProjectConfig(yaml);
|
const parsed = parseProjectConfig(yaml);
|
||||||
@@ -14,10 +17,33 @@ describe("serializeProjectConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("serializes empty tags", () => {
|
it("serializes empty tags", () => {
|
||||||
const yaml = serializeProjectConfig({ tags: [] });
|
const yaml = serializeProjectConfig({ tags: [], shownHiddenColumns: [] });
|
||||||
const parsed = parseProjectConfig(yaml);
|
const parsed = parseProjectConfig(yaml);
|
||||||
expect(parsed.isOk()).toBe(true);
|
expect(parsed.isOk()).toBe(true);
|
||||||
if (parsed.isErr()) return;
|
if (parsed.isErr()) return;
|
||||||
expect(parsed.value.tags).toEqual([]);
|
expect(parsed.value.tags).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips tags with shownHiddenColumns", () => {
|
||||||
|
const yaml = serializeProjectConfig({
|
||||||
|
tags: ["bug"],
|
||||||
|
shownHiddenColumns: ["backlog", "cancelled"],
|
||||||
|
});
|
||||||
|
const parsed = parseProjectConfig(yaml);
|
||||||
|
expect(parsed.isOk()).toBe(true);
|
||||||
|
if (parsed.isErr()) return;
|
||||||
|
expect(parsed.value.tags).toEqual(["bug"]);
|
||||||
|
expect(parsed.value.shownHiddenColumns).toEqual(["backlog", "cancelled"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes empty shownHiddenColumns", () => {
|
||||||
|
const yaml = serializeProjectConfig({
|
||||||
|
tags: [],
|
||||||
|
shownHiddenColumns: [],
|
||||||
|
});
|
||||||
|
const parsed = parseProjectConfig(yaml);
|
||||||
|
expect(parsed.isOk()).toBe(true);
|
||||||
|
if (parsed.isErr()) return;
|
||||||
|
expect(parsed.value.shownHiddenColumns).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import * as yaml from "js-yaml";
|
|||||||
import type { ProjectConfig } from "@/model/projectConfig";
|
import type { ProjectConfig } from "@/model/projectConfig";
|
||||||
|
|
||||||
export function serializeProjectConfig(config: ProjectConfig): string {
|
export function serializeProjectConfig(config: ProjectConfig): string {
|
||||||
|
const data: Record<string, unknown> = { tags: config.tags };
|
||||||
|
if (config.shownHiddenColumns.length > 0) {
|
||||||
|
data.shownHiddenColumns = config.shownHiddenColumns;
|
||||||
|
}
|
||||||
return yaml
|
return yaml
|
||||||
.dump(
|
.dump(data, {
|
||||||
{ tags: config.tags },
|
|
||||||
{
|
|
||||||
lineWidth: -1,
|
lineWidth: -1,
|
||||||
forceQuotes: false,
|
forceQuotes: false,
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.trimEnd()
|
.trimEnd()
|
||||||
.concat("\n");
|
.concat("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export function runOpenKanban(deps: TaskCommandDeps): void {
|
|||||||
TaskKanbanPanel.show({
|
TaskKanbanPanel.show({
|
||||||
repo: deps.repo,
|
repo: deps.repo,
|
||||||
config: deps.config,
|
config: deps.config,
|
||||||
|
fs: deps.fs,
|
||||||
onTasksMutated: () => {
|
onTasksMutated: () => {
|
||||||
deps.tree.refresh();
|
deps.tree.refresh();
|
||||||
TaskDashboardPanel.refreshIfOpen();
|
TaskDashboardPanel.refreshIfOpen();
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ import {
|
|||||||
} from "@/kanban/messages";
|
} from "@/kanban/messages";
|
||||||
import { KanbanOutboundMessage } from "@/kanban/outboundMessages";
|
import { KanbanOutboundMessage } from "@/kanban/outboundMessages";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
|
import { getProjectConfigPath } from "@/model/projectConfig";
|
||||||
import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation";
|
import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation";
|
||||||
import { TaskStatus, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus";
|
import { TaskStatus, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus";
|
||||||
import { IConfigProvider, ITaskRepository } from "@/ports";
|
import { IConfigProvider, IFileSystem, ITaskRepository } from "@/ports";
|
||||||
|
import { loadProjectConfig } from "@/commands/loadProjectConfig";
|
||||||
|
import { serializeProjectConfig } from "@/projectConfig/serializeProjectConfig";
|
||||||
import { presentError } from "@/ui/presentError";
|
import { presentError } from "@/ui/presentError";
|
||||||
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
|
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
|
||||||
import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
|
import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
|
||||||
@@ -21,6 +24,7 @@ import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
|
|||||||
export type TaskKanbanDeps = {
|
export type TaskKanbanDeps = {
|
||||||
repo: ITaskRepository;
|
repo: ITaskRepository;
|
||||||
config: IConfigProvider;
|
config: IConfigProvider;
|
||||||
|
fs: IFileSystem;
|
||||||
onTasksMutated?: () => void;
|
onTasksMutated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +89,8 @@ export class TaskKanbanPanel {
|
|||||||
async reloadTasks(): Promise<void> {
|
async reloadTasks(): Promise<void> {
|
||||||
if (this.#disposed) return;
|
if (this.#disposed) return;
|
||||||
|
|
||||||
|
await this.#loadShownHiddenColumns();
|
||||||
|
|
||||||
const locations = resolveTaskLocations(this.#deps.config);
|
const locations = resolveTaskLocations(this.#deps.config);
|
||||||
const result = await listLocatedTasks(this.#deps.repo, locations);
|
const result = await listLocatedTasks(this.#deps.repo, locations);
|
||||||
|
|
||||||
@@ -100,6 +106,24 @@ export class TaskKanbanPanel {
|
|||||||
this.#postState();
|
this.#postState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #loadShownHiddenColumns(): Promise<void> {
|
||||||
|
const folderPath = this.#deps.config.getProjectTaskPath();
|
||||||
|
const loaded = await loadProjectConfig(this.#deps.fs, folderPath);
|
||||||
|
if (loaded.isErr()) return;
|
||||||
|
this.#shownHiddenColumns = loaded.value.config.shownHiddenColumns as TaskStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async #saveShownHiddenColumns(): Promise<void> {
|
||||||
|
const folderPath = this.#deps.config.getProjectTaskPath();
|
||||||
|
if (!folderPath) return;
|
||||||
|
const loaded = await loadProjectConfig(this.#deps.fs, folderPath);
|
||||||
|
if (loaded.isErr()) return;
|
||||||
|
const config = { ...loaded.value.config, shownHiddenColumns: this.#shownHiddenColumns };
|
||||||
|
const content = serializeProjectConfig(config);
|
||||||
|
const path = getProjectConfigPath(folderPath);
|
||||||
|
await this.#deps.fs.writeFile(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
async #onMessage(raw: unknown): Promise<void> {
|
async #onMessage(raw: unknown): Promise<void> {
|
||||||
const parsed = parseKanbanInboundMessage(raw);
|
const parsed = parseKanbanInboundMessage(raw);
|
||||||
if (parsed.isErr()) {
|
if (parsed.isErr()) {
|
||||||
@@ -208,6 +232,7 @@ export class TaskKanbanPanel {
|
|||||||
this.#shownHiddenColumns.push(status);
|
this.#shownHiddenColumns.push(status);
|
||||||
}
|
}
|
||||||
this.#postState();
|
this.#postState();
|
||||||
|
void this.#saveShownHiddenColumns();
|
||||||
}
|
}
|
||||||
|
|
||||||
async #pickLocationForCreate(): Promise<TaskLocation | undefined> {
|
async #pickLocationForCreate(): Promise<TaskLocation | undefined> {
|
||||||
|
|||||||
Reference in New Issue
Block a user