yarlo-plugin-board 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ function openBrowser(url) {
41
41
  }
42
42
  const plugin = {
43
43
  name: "board",
44
- version: "0.2.0",
44
+ version: "0.2.1",
45
45
  description: "Kanban board view for yarlo tasks",
46
46
  commands: [
47
47
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarlo-plugin-board",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  "@hono/node-server": "^1.13.0",
16
16
  "hono": "^4.7.0",
17
17
  "zod": "^3.24.0",
18
- "yarlo-types": "0.1.2"
18
+ "yarlo-types": "0.1.3"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/node": "^22.0.0",
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type {
3
+ Task,
4
+ UpdateTaskInput,
5
+ YarloConfig,
6
+ YarloPluginContext,
7
+ } from "yarlo-types";
8
+ import { DEFAULT_CONFIG } from "yarlo-types";
9
+ import { createBoardApp } from "./board-server.js";
10
+
11
+ /**
12
+ * Minimal task fixture for API serialization tests.
13
+ */
14
+ function baseTask(overrides: Partial<Task> = {}): Task {
15
+ return {
16
+ id: "t1",
17
+ title: "Task",
18
+ content: "Body",
19
+ fields: { status: "backlog", priority: "low" },
20
+ created_at: "2024-01-01T00:00:00.000Z",
21
+ updated_at: "2024-01-02T00:00:00.000Z",
22
+ file_path: "/tmp/t1.md",
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Builds a plugin context with default mocked task operations.
29
+ */
30
+ function mockContext(
31
+ overrides: Partial<YarloPluginContext> = {},
32
+ ): YarloPluginContext {
33
+ const config: YarloConfig = {
34
+ ...DEFAULT_CONFIG,
35
+ yarlo_version: "0.1.0",
36
+ };
37
+ const task = baseTask();
38
+ const ctx: YarloPluginContext = {
39
+ config,
40
+ configDir: "/c",
41
+ projectDir: "/p",
42
+ tasks: {
43
+ list: vi.fn(async () => [task]),
44
+ get: vi.fn(async (id: string) => {
45
+ if (id === "t1") return task;
46
+ throw new Error(`Task "${id}" not found`);
47
+ }),
48
+ create: vi.fn(),
49
+ update: vi.fn(async (_id: string, input: UpdateTaskInput) => ({
50
+ ...task,
51
+ title: input.title ?? task.title,
52
+ content: input.content ?? task.content,
53
+ fields: { ...task.fields, ...(input.fields ?? {}) },
54
+ updated_at: "2024-01-03T00:00:00.000Z",
55
+ })),
56
+ delete: vi.fn(),
57
+ },
58
+ ...overrides,
59
+ };
60
+ return ctx;
61
+ }
62
+
63
+ describe("createBoardApp", () => {
64
+ it("GET /api/board returns columns, tasks, and fieldDefinitions", async () => {
65
+ const ctx = mockContext();
66
+ const app = createBoardApp(ctx);
67
+
68
+ const res = await app.request("http://localhost/api/board");
69
+ expect(res.status).toBe(200);
70
+ const json = (await res.json()) as {
71
+ columns: string[];
72
+ tasks: Array<{ id: string; title: string }>;
73
+ fieldDefinitions: unknown[];
74
+ };
75
+ expect(json.columns).toContain("backlog");
76
+ expect(json.tasks).toHaveLength(1);
77
+ expect(json.tasks[0]!.id).toBe("t1");
78
+ expect(json.fieldDefinitions).toEqual(ctx.config.fields);
79
+ expect(ctx.tasks.list).toHaveBeenCalledOnce();
80
+ });
81
+
82
+ it("GET /api/tasks/:id returns 200 when task exists", async () => {
83
+ const ctx = mockContext();
84
+ const app = createBoardApp(ctx);
85
+
86
+ const res = await app.request("http://localhost/api/tasks/t1");
87
+ expect(res.status).toBe(200);
88
+ const json = (await res.json()) as { task: { id: string; title: string } };
89
+ expect(json.task.id).toBe("t1");
90
+ expect(json.task.title).toBe("Task");
91
+ });
92
+
93
+ it("GET /api/tasks/:id returns 404 when task is missing", async () => {
94
+ const ctx = mockContext();
95
+ const app = createBoardApp(ctx);
96
+
97
+ const res = await app.request("http://localhost/api/tasks/missing");
98
+ expect(res.status).toBe(404);
99
+ });
100
+
101
+ it("PATCH /api/tasks/:id returns 400 for invalid JSON", async () => {
102
+ const ctx = mockContext();
103
+ const app = createBoardApp(ctx);
104
+
105
+ const res = await app.request("http://localhost/api/tasks/t1", {
106
+ method: "PATCH",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: "not-json{",
109
+ });
110
+ expect(res.status).toBe(400);
111
+ });
112
+
113
+ it("PATCH /api/tasks/:id returns 400 when body has no updatable fields", async () => {
114
+ const ctx = mockContext();
115
+ const app = createBoardApp(ctx);
116
+
117
+ const res = await app.request("http://localhost/api/tasks/t1", {
118
+ method: "PATCH",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({}),
121
+ });
122
+ expect(res.status).toBe(400);
123
+ });
124
+
125
+ it("PATCH /api/tasks/:id returns 400 for invalid status value", async () => {
126
+ const ctx = mockContext();
127
+ const app = createBoardApp(ctx);
128
+
129
+ const res = await app.request("http://localhost/api/tasks/t1", {
130
+ method: "PATCH",
131
+ headers: { "Content-Type": "application/json" },
132
+ body: JSON.stringify({ status: "__not_a_column__" }),
133
+ });
134
+ expect(res.status).toBe(400);
135
+ const json = (await res.json()) as { error: string };
136
+ expect(json.error).toMatch(/Invalid status/i);
137
+ });
138
+
139
+ it("PATCH /api/tasks/:id returns updated task when title is sent", async () => {
140
+ const ctx = mockContext();
141
+ const app = createBoardApp(ctx);
142
+
143
+ const res = await app.request("http://localhost/api/tasks/t1", {
144
+ method: "PATCH",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify({ title: "Renamed" }),
147
+ });
148
+ expect(res.status).toBe(200);
149
+ const json = (await res.json()) as { task: { title: string } };
150
+ expect(json.task.title).toBe("Renamed");
151
+ expect(ctx.tasks.update).toHaveBeenCalledWith("t1", { title: "Renamed" });
152
+ });
153
+
154
+ it("GET / serves board-ui.html", async () => {
155
+ const ctx = mockContext();
156
+ const app = createBoardApp(ctx);
157
+
158
+ const res = await app.request("http://localhost/");
159
+ expect(res.status).toBe(200);
160
+ const text = await res.text();
161
+ expect(text.length).toBeGreaterThan(100);
162
+ expect(text).toMatch(/html/i);
163
+ });
164
+ });
@@ -3,12 +3,24 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { Hono } from "hono";
5
5
  import { z } from "zod";
6
- import type { Task, YarloPluginContext } from "yarlo-types";
6
+ import type { FieldDefinition, Task, UpdateTaskInput, YarloPluginContext } from "yarlo-types";
7
7
  import { getStatusColumnOptions } from "./status-field.js";
8
8
 
9
- const patchBodySchema = z.object({
10
- status: z.string().min(1),
11
- });
9
+ const patchBodySchema = z
10
+ .object({
11
+ status: z.string().min(1).optional(),
12
+ title: z.string().optional(),
13
+ content: z.string().optional(),
14
+ fields: z.record(z.unknown()).optional(),
15
+ })
16
+ .refine(
17
+ (d) =>
18
+ d.status !== undefined ||
19
+ d.title !== undefined ||
20
+ d.content !== undefined ||
21
+ d.fields !== undefined,
22
+ { message: "At least one of status, title, content, or fields is required" },
23
+ );
12
24
 
13
25
  export type BoardTaskJson = {
14
26
  id: string;
@@ -22,6 +34,15 @@ export type BoardTaskJson = {
22
34
  updated_at: string;
23
35
  };
24
36
 
37
+ export type BoardTaskDetailJson = {
38
+ id: string;
39
+ title: string;
40
+ content: string;
41
+ fields: Record<string, unknown>;
42
+ created_at: string;
43
+ updated_at: string;
44
+ };
45
+
25
46
  /**
26
47
  * Maps a task to a JSON-safe shape for the board UI (no file paths).
27
48
  *
@@ -43,6 +64,31 @@ function serializeBoardTask(task: Task): BoardTaskJson {
43
64
  };
44
65
  }
45
66
 
67
+ /**
68
+ * Full task payload for detail view and PATCH responses (no file_path).
69
+ *
70
+ * @param task - Task from yarlo storage
71
+ * @returns Serializable task detail
72
+ */
73
+ function serializeBoardTaskDetail(task: Task): BoardTaskDetailJson {
74
+ return {
75
+ id: task.id,
76
+ title: task.title,
77
+ content: task.content,
78
+ fields: { ...task.fields },
79
+ created_at: task.created_at,
80
+ updated_at: task.updated_at,
81
+ };
82
+ }
83
+
84
+ function isNotFoundMessage(msg: string): boolean {
85
+ return (
86
+ msg.includes("not found") ||
87
+ msg.includes("Ambiguous ID") ||
88
+ msg.includes("Ambiguous")
89
+ );
90
+ }
91
+
46
92
  /**
47
93
  * Builds the Hono app for the local board (HTML UI + JSON API).
48
94
  *
@@ -62,12 +108,28 @@ export function createBoardApp(ctx: YarloPluginContext): Hono {
62
108
  app.get("/api/board", async (c) => {
63
109
  const columns = getStatusColumnOptions(ctx.config);
64
110
  const tasks = await ctx.tasks.list();
111
+ const fieldDefinitions: FieldDefinition[] = ctx.config.fields;
65
112
  return c.json({
66
113
  columns,
67
114
  tasks: tasks.map(serializeBoardTask),
115
+ fieldDefinitions,
68
116
  });
69
117
  });
70
118
 
119
+ app.get("/api/tasks/:id", async (c) => {
120
+ const id = c.req.param("id");
121
+ try {
122
+ const task = await ctx.tasks.get(id);
123
+ return c.json({ task: serializeBoardTaskDetail(task) });
124
+ } catch (e) {
125
+ const msg = e instanceof Error ? e.message : String(e);
126
+ if (isNotFoundMessage(msg)) {
127
+ return c.json({ error: msg }, 404);
128
+ }
129
+ return c.json({ error: msg }, 500);
130
+ }
131
+ });
132
+
71
133
  app.patch("/api/tasks/:id", async (c) => {
72
134
  const id = c.req.param("id");
73
135
  let body: unknown;
@@ -79,14 +141,29 @@ export function createBoardApp(ctx: YarloPluginContext): Hono {
79
141
 
80
142
  const parsed = patchBodySchema.safeParse(body);
81
143
  if (!parsed.success) {
144
+ const flat = parsed.error.flatten();
145
+ const refineMsg = parsed.error.errors.find((e) => e.code === "custom");
82
146
  return c.json(
83
- { error: "Validation failed", details: parsed.error.flatten() },
147
+ {
148
+ error: refineMsg?.message ?? "Validation failed",
149
+ details: flat,
150
+ },
84
151
  400,
85
152
  );
86
153
  }
87
154
 
88
155
  const columns = getStatusColumnOptions(ctx.config);
89
- if (!columns.includes(parsed.data.status)) {
156
+ const fieldPatch: Record<string, unknown> = {};
157
+ if (parsed.data.fields) {
158
+ Object.assign(fieldPatch, parsed.data.fields);
159
+ }
160
+ if (parsed.data.status !== undefined) {
161
+ fieldPatch.status = parsed.data.status;
162
+ }
163
+
164
+ const statusValue =
165
+ typeof fieldPatch.status === "string" ? fieldPatch.status : undefined;
166
+ if (statusValue !== undefined && !columns.includes(statusValue)) {
90
167
  return c.json(
91
168
  {
92
169
  error: `Invalid status: must be one of: ${columns.join(", ")}`,
@@ -95,18 +172,23 @@ export function createBoardApp(ctx: YarloPluginContext): Hono {
95
172
  );
96
173
  }
97
174
 
175
+ const updateInput: UpdateTaskInput = {};
176
+ if (parsed.data.title !== undefined) {
177
+ updateInput.title = parsed.data.title;
178
+ }
179
+ if (parsed.data.content !== undefined) {
180
+ updateInput.content = parsed.data.content;
181
+ }
182
+ if (Object.keys(fieldPatch).length > 0) {
183
+ updateInput.fields = fieldPatch;
184
+ }
185
+
98
186
  try {
99
- const task = await ctx.tasks.update(id, {
100
- fields: { status: parsed.data.status },
101
- });
102
- return c.json({ task: serializeBoardTask(task) });
187
+ const task = await ctx.tasks.update(id, updateInput);
188
+ return c.json({ task: serializeBoardTaskDetail(task) });
103
189
  } catch (e) {
104
190
  const msg = e instanceof Error ? e.message : String(e);
105
- if (
106
- msg.includes("not found") ||
107
- msg.includes("Ambiguous ID") ||
108
- msg.includes("Ambiguous")
109
- ) {
191
+ if (isNotFoundMessage(msg)) {
110
192
  return c.json({ error: msg }, 404);
111
193
  }
112
194
  return c.json({ error: msg }, 500);