refactor: separate config service
This commit is contained in:
@@ -28,8 +28,7 @@ export function buildKanbanBoard(
|
|||||||
const extraVisible = options.shownHiddenColumns ?? [];
|
const extraVisible = options.shownHiddenColumns ?? [];
|
||||||
const statuses = TASK_STATUS_ORDER.filter(
|
const statuses = TASK_STATUS_ORDER.filter(
|
||||||
(status) =>
|
(status) =>
|
||||||
!TASK_STATUS_META[status].defaultHidden ||
|
!TASK_STATUS_META[status].defaultHidden || extraVisible.includes(status),
|
||||||
extraVisible.includes(status),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: KanbanColumn[] = statuses.map((status) => {
|
const columns: KanbanColumn[] = statuses.map((status) => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/** Config file name inside a task folder (project or global). */
|
/** Config file name inside a task folder (project or global). */
|
||||||
export const TASK_FOLDER_CONFIG_FILENAME = "config.yml";
|
export const TASK_FOLDER_CONFIG_FILENAME = "config.yml";
|
||||||
|
|
||||||
|
import { TaskStatus } from "./taskStatus";
|
||||||
|
|
||||||
export type ProjectConfig = {
|
export type ProjectConfig = {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
shownHiddenColumns: string[];
|
shownHiddenColumns: TaskStatus[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EMPTY_PROJECT_CONFIG: ProjectConfig = {
|
export const EMPTY_PROJECT_CONFIG: ProjectConfig = {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
EMPTY_PROJECT_CONFIG,
|
||||||
|
getProjectConfigPath,
|
||||||
|
} from "@/model/projectConfig";
|
||||||
|
import { TaskStatus } from "@/model/taskStatus";
|
||||||
|
import { IConfigProvider } from "@/ports";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
|
import { ProjectConfigService } from "./ProjectConfigService";
|
||||||
|
|
||||||
|
const FOLDER = "/ws/.xflow";
|
||||||
|
|
||||||
|
function configProvider(partial: {
|
||||||
|
project?: string;
|
||||||
|
global?: string;
|
||||||
|
}): IConfigProvider {
|
||||||
|
return {
|
||||||
|
getProjectTaskPath: () => partial.project,
|
||||||
|
getGlobalTaskPath: () => partial.global,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectConfigService", () => {
|
||||||
|
let fs: InMemoryFileSystem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs = new InMemoryFileSystem();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("load", () => {
|
||||||
|
it("returns empty config when folder path is unset", async () => {
|
||||||
|
const svc = new ProjectConfigService(fs, configProvider({}));
|
||||||
|
await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty config when config.yml missing", async () => {
|
||||||
|
const svc = new ProjectConfigService(
|
||||||
|
fs,
|
||||||
|
configProvider({ project: FOLDER }),
|
||||||
|
);
|
||||||
|
await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns parsed config when config.yml exists with tags", async () => {
|
||||||
|
await fs.writeFile(
|
||||||
|
getProjectConfigPath(FOLDER),
|
||||||
|
"tags:\n - bug\n - docs\n",
|
||||||
|
);
|
||||||
|
const svc = new ProjectConfigService(
|
||||||
|
fs,
|
||||||
|
configProvider({ project: FOLDER }),
|
||||||
|
);
|
||||||
|
const config = await svc.load();
|
||||||
|
expect(config.tags).toEqual(["bug", "docs"]);
|
||||||
|
expect(config.shownHiddenColumns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns config with shownHiddenColumns", async () => {
|
||||||
|
await fs.writeFile(
|
||||||
|
getProjectConfigPath(FOLDER),
|
||||||
|
"tags: []\nshownHiddenColumns:\n - backlog\n",
|
||||||
|
);
|
||||||
|
const svc = new ProjectConfigService(
|
||||||
|
fs,
|
||||||
|
configProvider({ project: FOLDER }),
|
||||||
|
);
|
||||||
|
const config = await svc.load();
|
||||||
|
expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty config when config.yml is invalid", async () => {
|
||||||
|
await fs.writeFile(getProjectConfigPath(FOLDER), "tags: [\n");
|
||||||
|
const svc = new ProjectConfigService(
|
||||||
|
fs,
|
||||||
|
configProvider({ project: FOLDER }),
|
||||||
|
);
|
||||||
|
await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("merge", () => {
|
||||||
|
it("creates config.yml with merged fields", async () => {
|
||||||
|
const svc = new ProjectConfigService(
|
||||||
|
fs,
|
||||||
|
configProvider({ project: FOLDER }),
|
||||||
|
);
|
||||||
|
await svc.merge({ shownHiddenColumns: [TaskStatus.BACKLOG] });
|
||||||
|
const config = await svc.load();
|
||||||
|
expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing tags when merging shownHiddenColumns", async () => {
|
||||||
|
await fs.writeFile(getProjectConfigPath(FOLDER), "tags:\n - bug\n");
|
||||||
|
const svc = new ProjectConfigService(
|
||||||
|
fs,
|
||||||
|
configProvider({ project: FOLDER }),
|
||||||
|
);
|
||||||
|
await svc.merge({ shownHiddenColumns: [TaskStatus.CANCELLED] });
|
||||||
|
const config = await svc.load();
|
||||||
|
expect(config.tags).toEqual(["bug"]);
|
||||||
|
expect(config.shownHiddenColumns).toEqual([TaskStatus.CANCELLED]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("load after merge returns merged config (idempotent)", async () => {
|
||||||
|
const svc = new ProjectConfigService(
|
||||||
|
fs,
|
||||||
|
configProvider({ project: FOLDER }),
|
||||||
|
);
|
||||||
|
await svc.merge({
|
||||||
|
shownHiddenColumns: [TaskStatus.BACKLOG, TaskStatus.UNKNOWN],
|
||||||
|
});
|
||||||
|
await svc.merge({ shownHiddenColumns: [TaskStatus.BACKLOG] });
|
||||||
|
const config = await svc.load();
|
||||||
|
expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
EMPTY_PROJECT_CONFIG,
|
||||||
|
getProjectConfigPath,
|
||||||
|
type ProjectConfig,
|
||||||
|
} from "@/model/projectConfig";
|
||||||
|
import { IConfigProvider, IFileSystem } from "@/ports";
|
||||||
|
import { parseProjectConfig } from "./parseProjectConfig";
|
||||||
|
import { serializeProjectConfig } from "./serializeProjectConfig";
|
||||||
|
|
||||||
|
export class ProjectConfigService {
|
||||||
|
#fs: IFileSystem;
|
||||||
|
#config: IConfigProvider;
|
||||||
|
|
||||||
|
constructor(fs: IFileSystem, config: IConfigProvider) {
|
||||||
|
this.#fs = fs;
|
||||||
|
this.#config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<ProjectConfig> {
|
||||||
|
const folderPath = this.#config.getProjectTaskPath();
|
||||||
|
if (!folderPath) return { ...EMPTY_PROJECT_CONFIG };
|
||||||
|
|
||||||
|
const path = getProjectConfigPath(folderPath);
|
||||||
|
try {
|
||||||
|
const content = await this.#fs.readFile(path);
|
||||||
|
const parsed = parseProjectConfig(content);
|
||||||
|
if (parsed.isErr()) return { ...EMPTY_PROJECT_CONFIG };
|
||||||
|
return parsed.value;
|
||||||
|
} catch {
|
||||||
|
return { ...EMPTY_PROJECT_CONFIG };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async merge(partial: Partial<ProjectConfig>): Promise<void> {
|
||||||
|
const folderPath = this.#config.getProjectTaskPath();
|
||||||
|
if (!folderPath) return;
|
||||||
|
|
||||||
|
const current = await this.load();
|
||||||
|
const merged: ProjectConfig = { ...current, ...partial };
|
||||||
|
const content = serializeProjectConfig(merged);
|
||||||
|
await this.#fs.writeFile(getProjectConfigPath(folderPath), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import * as yaml from "js-yaml";
|
|
||||||
import { err, ok, Result } from "neverthrow";
|
|
||||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||||
import {
|
import {
|
||||||
EMPTY_PROJECT_CONFIG,
|
EMPTY_PROJECT_CONFIG,
|
||||||
normalizeTag,
|
normalizeTag,
|
||||||
type ProjectConfig,
|
type ProjectConfig,
|
||||||
} from "@/model/projectConfig";
|
} from "@/model/projectConfig";
|
||||||
import { isTaskStatus } from "@/model/taskStatus";
|
import { isTaskStatus, TaskStatus } from "@/model/taskStatus";
|
||||||
|
import * as yaml from "js-yaml";
|
||||||
|
import { err, ok, Result } from "neverthrow";
|
||||||
|
|
||||||
function parseShownHiddenColumns(
|
function parseShownHiddenColumns(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): Result<string[], AppError> {
|
): Result<TaskStatus[], AppError> {
|
||||||
if (value === undefined) return ok([]);
|
if (value === undefined) return ok([]);
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return err(
|
return err(
|
||||||
@@ -28,7 +28,7 @@ function parseShownHiddenColumns(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ok(value as string[]);
|
return ok(value as TaskStatus[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseProjectConfig(
|
export function parseProjectConfig(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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";
|
||||||
import { serializeProjectConfig } from "./serializeProjectConfig";
|
import { serializeProjectConfig } from "./serializeProjectConfig";
|
||||||
@@ -27,13 +28,16 @@ describe("serializeProjectConfig", () => {
|
|||||||
it("round-trips tags with shownHiddenColumns", () => {
|
it("round-trips tags with shownHiddenColumns", () => {
|
||||||
const yaml = serializeProjectConfig({
|
const yaml = serializeProjectConfig({
|
||||||
tags: ["bug"],
|
tags: ["bug"],
|
||||||
shownHiddenColumns: ["backlog", "cancelled"],
|
shownHiddenColumns: [TaskStatus.BACKLOG, TaskStatus.CANCELLED],
|
||||||
});
|
});
|
||||||
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(["bug"]);
|
expect(parsed.value.tags).toEqual(["bug"]);
|
||||||
expect(parsed.value.shownHiddenColumns).toEqual(["backlog", "cancelled"]);
|
expect(parsed.value.shownHiddenColumns).toEqual([
|
||||||
|
TaskStatus.BACKLOG,
|
||||||
|
TaskStatus.CANCELLED,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes empty shownHiddenColumns", () => {
|
it("serializes empty shownHiddenColumns", () => {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as vscode from "vscode";
|
|
||||||
import { changeStatus } from "@/commands/changeStatus";
|
import { changeStatus } from "@/commands/changeStatus";
|
||||||
import { createTask } from "@/commands/createTask";
|
import { createTask } from "@/commands/createTask";
|
||||||
import { deleteTask } from "@/commands/deleteTask";
|
import { deleteTask } from "@/commands/deleteTask";
|
||||||
@@ -7,9 +6,11 @@ import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
|||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
import { TaskLocation } from "@/model/taskLocation";
|
import { TaskLocation } from "@/model/taskLocation";
|
||||||
import { IConfigProvider, ITaskRepository } from "@/ports";
|
import { IConfigProvider, ITaskRepository } from "@/ports";
|
||||||
|
import { ProjectConfigService } from "@/projectConfig/ProjectConfigService";
|
||||||
import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
|
import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
|
||||||
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
||||||
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
||||||
|
import * as vscode from "vscode";
|
||||||
import {
|
import {
|
||||||
runAddTagToProject,
|
runAddTagToProject,
|
||||||
runEditTaskPriority,
|
runEditTaskPriority,
|
||||||
@@ -221,7 +222,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,
|
configService: new ProjectConfigService(deps.fs, deps.config),
|
||||||
onTasksMutated: () => {
|
onTasksMutated: () => {
|
||||||
deps.tree.refresh();
|
deps.tree.refresh();
|
||||||
TaskDashboardPanel.refreshIfOpen();
|
TaskDashboardPanel.refreshIfOpen();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as vscode from "vscode";
|
|
||||||
import { changeStatus } from "@/commands/changeStatus";
|
import { changeStatus } from "@/commands/changeStatus";
|
||||||
import { createTask } from "@/commands/createTask";
|
import { createTask } from "@/commands/createTask";
|
||||||
import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
||||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||||
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
import { buildKanbanBoard } from "@/kanban/buildKanbanBoard";
|
import { buildKanbanBoard } from "@/kanban/buildKanbanBoard";
|
||||||
import {
|
import {
|
||||||
parseKanbanInboundMessage,
|
parseKanbanInboundMessage,
|
||||||
@@ -10,21 +10,23 @@ import {
|
|||||||
type KanbanScopeFilter,
|
type KanbanScopeFilter,
|
||||||
} from "@/kanban/messages";
|
} from "@/kanban/messages";
|
||||||
import { KanbanOutboundMessage } from "@/kanban/outboundMessages";
|
import { KanbanOutboundMessage } from "@/kanban/outboundMessages";
|
||||||
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 {
|
||||||
import { IConfigProvider, IFileSystem, ITaskRepository } from "@/ports";
|
TASK_STATUS_META,
|
||||||
import { loadProjectConfig } from "@/commands/loadProjectConfig";
|
TASK_STATUS_ORDER,
|
||||||
import { serializeProjectConfig } from "@/projectConfig/serializeProjectConfig";
|
TaskStatus,
|
||||||
|
} from "@/model/taskStatus";
|
||||||
|
import { IConfigProvider, ITaskRepository } from "@/ports";
|
||||||
|
import { ProjectConfigService } from "@/projectConfig/ProjectConfigService";
|
||||||
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 * as vscode from "vscode";
|
||||||
import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
|
import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
|
||||||
|
|
||||||
export type TaskKanbanDeps = {
|
export type TaskKanbanDeps = {
|
||||||
repo: ITaskRepository;
|
repo: ITaskRepository;
|
||||||
config: IConfigProvider;
|
config: IConfigProvider;
|
||||||
fs: IFileSystem;
|
configService: ProjectConfigService;
|
||||||
onTasksMutated?: () => void;
|
onTasksMutated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,21 +109,14 @@ export class TaskKanbanPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #loadShownHiddenColumns(): Promise<void> {
|
async #loadShownHiddenColumns(): Promise<void> {
|
||||||
const folderPath = this.#deps.config.getProjectTaskPath();
|
const config = await this.#deps.configService.load();
|
||||||
const loaded = await loadProjectConfig(this.#deps.fs, folderPath);
|
this.#shownHiddenColumns = config.shownHiddenColumns;
|
||||||
if (loaded.isErr()) return;
|
|
||||||
this.#shownHiddenColumns = loaded.value.config.shownHiddenColumns as TaskStatus[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async #saveShownHiddenColumns(): Promise<void> {
|
async #saveShownHiddenColumns(): Promise<void> {
|
||||||
const folderPath = this.#deps.config.getProjectTaskPath();
|
await this.#deps.configService.merge({
|
||||||
if (!folderPath) return;
|
shownHiddenColumns: this.#shownHiddenColumns,
|
||||||
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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user