feat(KB-048): add collapsible list sections to dashboard
- Add section expansion state management with localStorage persistence - Update section headers with chevron toggle controls - Implement conditional task row rendering based on section state - Add Expand All / Collapse All toolbar controls - Add CSS styles for chevron rotation animation and section headers - Add comprehensive tests for collapsible section behavior
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "./api";
|
||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
|
||||
const FAKE_DETAIL: TaskDetail = {
|
||||
@@ -211,3 +211,430 @@ describe("logoutProvider", () => {
|
||||
await expect(logoutProvider("anthropic")).rejects.toThrow("logout failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSteeringComment", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const FAKE_TASK: Task = {
|
||||
id: "KB-001",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1234567890-abc123",
|
||||
text: "Please handle the edge case",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("sends POST with text and returns updated task", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
const result = await addSteeringComment("KB-001", "Please handle the edge case");
|
||||
|
||||
expect(result.id).toBe("KB-001");
|
||||
expect(result.steeringComments).toHaveLength(1);
|
||||
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/steer", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Please handle the edge case" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Task not found" })
|
||||
);
|
||||
|
||||
await expect(addSteeringComment("KB-001", "Test comment")).rejects.toThrow("Task not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitRemotes", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns array of GitHub remotes", async () => {
|
||||
const remotes = [
|
||||
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, remotes));
|
||||
|
||||
const result = await fetchGitRemotes();
|
||||
|
||||
expect(result).toEqual(remotes);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array when no remotes", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const result = await fetchGitRemotes();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Failed to execute git command" }));
|
||||
|
||||
await expect(fetchGitRemotes()).rejects.toThrow("Failed to execute git command");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Plan approval API tests ---
|
||||
|
||||
import { approvePlan, rejectPlan } from "./api";
|
||||
|
||||
describe("approvePlan", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("approves plan and returns updated task", async () => {
|
||||
const approvedTask: Task = {
|
||||
...FAKE_DETAIL,
|
||||
column: "todo",
|
||||
status: undefined,
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, approvedTask));
|
||||
|
||||
const result = await approvePlan("KB-001");
|
||||
|
||||
expect(result.column).toBe("todo");
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/approve-plan", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Task must be in 'triage' column to approve plan" }, 400)
|
||||
);
|
||||
|
||||
await expect(approvePlan("KB-001")).rejects.toThrow("triage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejectPlan", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("rejects plan and returns updated task", async () => {
|
||||
const rejectedTask: Task = {
|
||||
...FAKE_DETAIL,
|
||||
column: "triage",
|
||||
status: undefined,
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, rejectedTask));
|
||||
|
||||
const result = await rejectPlan("KB-001");
|
||||
|
||||
expect(result.column).toBe("triage");
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/reject-plan", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Task must have status 'awaiting-approval' to reject plan" }, 400)
|
||||
);
|
||||
|
||||
await expect(rejectPlan("KB-001")).rejects.toThrow("awaiting-approval");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Git Management API tests ---
|
||||
|
||||
import {
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
fetchGitBranches,
|
||||
fetchGitWorktrees,
|
||||
createBranch,
|
||||
checkoutBranch,
|
||||
deleteBranch,
|
||||
fetchRemote,
|
||||
pullBranch,
|
||||
pushBranch,
|
||||
} from "./api";
|
||||
|
||||
describe("Git Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("fetchGitStatus", () => {
|
||||
it("returns git status", async () => {
|
||||
const status = { branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0 };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, status));
|
||||
|
||||
const result = await fetchGitStatus();
|
||||
|
||||
expect(result).toEqual(status);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/status", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not a git repository" }, 400));
|
||||
|
||||
await expect(fetchGitStatus()).rejects.toThrow("Not a git repository");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitCommits", () => {
|
||||
it("returns commits without limit", async () => {
|
||||
const commits = [
|
||||
{ hash: "abc123", shortHash: "abc", message: "Test commit", author: "User", date: "2026-01-01", parents: [] },
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, commits));
|
||||
|
||||
const result = await fetchGitCommits();
|
||||
|
||||
expect(result).toEqual(commits);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes limit in query string", async () => {
|
||||
const commits = [{ hash: "abc123", shortHash: "abc", message: "Test", author: "User", date: "2026-01-01", parents: [] }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, commits));
|
||||
|
||||
const result = await fetchGitCommits(50);
|
||||
|
||||
expect(result).toEqual(commits);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits?limit=50", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCommitDiff", () => {
|
||||
it("returns diff for a commit", async () => {
|
||||
const diff = { stat: "1 file changed", patch: "diff content" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, diff));
|
||||
|
||||
const result = await fetchCommitDiff("abc123");
|
||||
|
||||
expect(result).toEqual(diff);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits/abc123/diff", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on 404", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Commit not found" }, 404));
|
||||
|
||||
await expect(fetchCommitDiff("invalid")).rejects.toThrow("Commit not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitBranches", () => {
|
||||
it("returns branches array", async () => {
|
||||
const branches = [{ name: "main", isCurrent: true, remote: "origin/main" }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, branches));
|
||||
|
||||
const result = await fetchGitBranches();
|
||||
|
||||
expect(result).toEqual(branches);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitWorktrees", () => {
|
||||
it("returns worktrees array", async () => {
|
||||
const worktrees = [{ path: "/path/to/repo", branch: "main", isMain: true, isBare: false }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, worktrees));
|
||||
|
||||
const result = await fetchGitWorktrees();
|
||||
|
||||
expect(result).toEqual(worktrees);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBranch", () => {
|
||||
it("sends POST to create branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { created: true }, 201));
|
||||
|
||||
await createBranch("feature-branch");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "feature-branch", base: undefined }),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends base when provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { created: true }, 201));
|
||||
|
||||
await createBranch("feature-branch", "main");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "feature-branch", base: "main" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid branch name" }, 400));
|
||||
|
||||
await expect(createBranch("invalid")).rejects.toThrow("Invalid branch name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkoutBranch", () => {
|
||||
it("sends POST to checkout branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { checkedOut: "main" }));
|
||||
|
||||
await checkoutBranch("main");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/main/checkout", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes branch name", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
|
||||
await checkoutBranch("feature/test");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature%2Ftest/checkout", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteBranch", () => {
|
||||
it("sends DELETE to remove branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { deleted: "feature" }));
|
||||
|
||||
await deleteBranch("feature");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes force query param when true", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { deleted: "feature" }));
|
||||
|
||||
await deleteBranch("feature", true);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature?force=true", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRemote", () => {
|
||||
it("sends POST to fetch origin by default", async () => {
|
||||
const result = { fetched: true, message: "Fetched" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await fetchRemote();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/fetch", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote: undefined }),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends custom remote when provided", async () => {
|
||||
const result = { fetched: true, message: "Fetched from upstream" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
await fetchRemote("upstream");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/fetch", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote: "upstream" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullBranch", () => {
|
||||
it("sends POST to pull", async () => {
|
||||
const result = { success: true, message: "Pulled 2 commits" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await pullBranch();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns conflict info when there are conflicts", async () => {
|
||||
const result = { success: false, message: "Merge conflict", conflict: true };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result, 409));
|
||||
|
||||
const response = await pullBranch();
|
||||
|
||||
expect(response.conflict).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushBranch", () => {
|
||||
it("sends POST to push", async () => {
|
||||
const result = { success: true, message: "Pushed to origin" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await pushBranch();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/push", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on rejection", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Push rejected" }, 409));
|
||||
|
||||
await expect(pushBranch()).rejects.toThrow("Push rejected");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user