refactor: separate config service
This commit is contained in:
@@ -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 {
|
||||
EMPTY_PROJECT_CONFIG,
|
||||
normalizeTag,
|
||||
type 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(
|
||||
value: unknown,
|
||||
): Result<string[], AppError> {
|
||||
): Result<TaskStatus[], AppError> {
|
||||
if (value === undefined) return ok([]);
|
||||
if (!Array.isArray(value)) {
|
||||
return err(
|
||||
@@ -28,7 +28,7 @@ function parseShownHiddenColumns(
|
||||
);
|
||||
}
|
||||
}
|
||||
return ok(value as string[]);
|
||||
return ok(value as TaskStatus[]);
|
||||
}
|
||||
|
||||
export function parseProjectConfig(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TaskStatus } from "@/model/taskStatus";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseProjectConfig } from "./parseProjectConfig";
|
||||
import { serializeProjectConfig } from "./serializeProjectConfig";
|
||||
@@ -27,13 +28,16 @@ describe("serializeProjectConfig", () => {
|
||||
it("round-trips tags with shownHiddenColumns", () => {
|
||||
const yaml = serializeProjectConfig({
|
||||
tags: ["bug"],
|
||||
shownHiddenColumns: ["backlog", "cancelled"],
|
||||
shownHiddenColumns: [TaskStatus.BACKLOG, TaskStatus.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"]);
|
||||
expect(parsed.value.shownHiddenColumns).toEqual([
|
||||
TaskStatus.BACKLOG,
|
||||
TaskStatus.CANCELLED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes empty shownHiddenColumns", () => {
|
||||
|
||||
Reference in New Issue
Block a user