Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -1,54 +1,81 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DashboardPage } from "./dashboard";
|
||||
import { HomePage } from "./dashboard";
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockDashboard = vi.fn();
|
||||
const mockSessions = vi.fn();
|
||||
const mockProjects = vi.fn();
|
||||
const mockRepos = vi.fn();
|
||||
|
||||
vi.mock("../api/dashboard", () => ({
|
||||
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
|
||||
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
||||
}));
|
||||
|
||||
describe("DashboardPage", () => {
|
||||
vi.mock("../api/sessions", () => ({
|
||||
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
stopInstance: vi.fn(),
|
||||
deleteInstance: vi.fn(),
|
||||
recreateInstanceTunnel: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../api/projects", () => ({
|
||||
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/git_repositories", () => ({
|
||||
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/tool_types", () => ({
|
||||
listToolTypes: vi.fn().mockResolvedValue([])
|
||||
}));
|
||||
|
||||
describe("HomePage", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockDashboard.mockReset();
|
||||
mockSessions.mockReset();
|
||||
mockProjects.mockReset();
|
||||
mockRepos.mockReset();
|
||||
});
|
||||
|
||||
it("shows loading then empty state when summary has no data", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
projects: 0,
|
||||
repositories: 0,
|
||||
sshKeys: 0,
|
||||
recentActivity: []
|
||||
});
|
||||
it("shows overview sections", async () => {
|
||||
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||
mockSessions.mockResolvedValue([]);
|
||||
mockProjects.mockResolvedValue([]);
|
||||
mockRepos.mockResolvedValue([]);
|
||||
|
||||
render(<DashboardPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No activity yet")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retry action when summary request fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("failed"));
|
||||
mockGet.mockResolvedValueOnce({
|
||||
projects: 2,
|
||||
repositories: 5,
|
||||
sshKeys: 1,
|
||||
recentActivity: ["Created repo"]
|
||||
});
|
||||
it("shows retry action when home load fails", async () => {
|
||||
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||
|
||||
render(<DashboardPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,83 +1,338 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
const CARDS = [
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
{ label: "SSH Keys", key: "sshKeys" }
|
||||
] as const;
|
||||
|
||||
type DashboardStatus = "loading" | "ready" | "error";
|
||||
type SessionView = SessionApi;
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const [status, setStatus] = useState<DashboardStatus>("loading");
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await getDashboardSummary();
|
||||
setSummary(data);
|
||||
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setSummary(null);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
}, [loadSummary]);
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const cards = useMemo(() => CARDS, []);
|
||||
const isEmpty =
|
||||
status === "ready" &&
|
||||
summary !== null &&
|
||||
summary.projects === 0 &&
|
||||
summary.repositories === 0 &&
|
||||
summary.sshKeys === 0 &&
|
||||
summary.recentActivity.length === 0;
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setDisplayName("");
|
||||
setSelectedProject("");
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setSaveState("idle");
|
||||
await loadHome();
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (session: SessionView) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
};
|
||||
|
||||
const handleStop = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Dashboard</h1>
|
||||
<p className="muted">Your workspace overview will appear here.</p>
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading dashboard...</p>}
|
||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Dashboard is unavailable</p>
|
||||
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
|
||||
<p>Unable to load your workspace overview.</p>
|
||||
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{cards.map((card) => (
|
||||
<article className="card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessions.length
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEmpty && <p className="muted">No activity yet</p>}
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>{activeSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions right now.</p>
|
||||
) : (
|
||||
<div className="home-session-grid">
|
||||
{activeSessions.map((session) => (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name}</h3>
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
</div>
|
||||
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="quick-actions">
|
||||
<button className="primary-button" type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Project
|
||||
</button>
|
||||
<button className="secondary-button" type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
Add Repository
|
||||
</button>
|
||||
</div>
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Available projects</p>
|
||||
<h2>{projects.length}</h2>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="muted">No projects yet.</p>
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article className="card project-card home-project-card" key={project.id}>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form className="stack create-session-form" onSubmit={handleCreate}>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Tool type
|
||||
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Display name
|
||||
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
||||
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
||||
</button>
|
||||
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{recentSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{recentSessions.map((session) => (
|
||||
<article className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">{session.display_name}</span>
|
||||
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { HomePage as DashboardPage };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./projects";
|
||||
@@ -29,13 +30,21 @@ afterEach(() => {
|
||||
describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -46,7 +55,11 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -55,7 +68,11 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||
@@ -67,7 +84,11 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -96,7 +117,11 @@ describe("ProjectsPage", () => {
|
||||
it("shows validation error when name is empty", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -112,7 +137,11 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -141,7 +170,11 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
type SettingsStatus = "loading" | "ready" | "error";
|
||||
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
{ label: "Tool Types", path: "tool-types" },
|
||||
{ label: "Tool Configs", path: "tool-configs" },
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
type SettingsOutletContext = {
|
||||
config: UserConfig;
|
||||
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
||||
handleSave: () => Promise<void>;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
};
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
@@ -50,21 +66,15 @@ export const SettingsPage = () => {
|
||||
git_user_name: config.git_user_name,
|
||||
git_user_email: config.git_user_email,
|
||||
};
|
||||
console.log("Sending update:", update);
|
||||
const updated = await updateUserConfig(update);
|
||||
console.log("Received response:", updated);
|
||||
setConfig(updated);
|
||||
setSaveStatus("saved");
|
||||
|
||||
// Apply theme immediately
|
||||
const theme = updated.theme ?? "system";
|
||||
if (theme === "system") {
|
||||
if (updated.theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||
}
|
||||
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
@@ -86,81 +96,71 @@ export const SettingsPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<section className="stack settings-page">
|
||||
<header className="settings-header card stack-sm">
|
||||
<div>
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||
</header>
|
||||
|
||||
<div className="card stack">
|
||||
<h2>Appearance</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select
|
||||
value={config.theme}
|
||||
onChange={(e) => handleChange("theme", e.target.value)}
|
||||
<nav className="settings-tabs" aria-label="Settings sections">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.path}
|
||||
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||
>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="card stack">
|
||||
<h2>Git Identity</h2>
|
||||
<label className="form-field">
|
||||
User Name
|
||||
<input
|
||||
type="text"
|
||||
value={config.git_user_name ?? ""}
|
||||
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
|
||||
placeholder="Your git commit name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
User Email
|
||||
<input
|
||||
type="email"
|
||||
value={config.git_user_email ?? ""}
|
||||
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card stack">
|
||||
<h2>Editor</h2>
|
||||
<label className="form-field">
|
||||
Default Editor
|
||||
<input
|
||||
type="text"
|
||||
value={config.default_editor ?? ""}
|
||||
onChange={(e) => handleChange("default_editor", e.target.value || null)}
|
||||
placeholder="e.g., vscode, vim, cursor"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||
{saveStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||
<div className="settings-panel card">
|
||||
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const GeneralSettingsTab = () => {
|
||||
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<h2>General</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user name
|
||||
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user email
|
||||
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Default editor
|
||||
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
||||
</label>
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
||||
</button>
|
||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -61,7 +63,15 @@ export const SSHKeysPage = () => {
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>SSH Keys</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>SSH Keys</h1>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icon } from "../components/icon";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
type ConfigStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const ToolConfigsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
@@ -135,7 +137,13 @@ export const ToolConfigsPage = () => {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
<p className="muted">
|
||||
Manage environment variables and configuration files for your tools
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
createToolType,
|
||||
@@ -15,6 +16,7 @@ type ToolTypesStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ToolTypesPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
@@ -165,12 +167,18 @@ export const ToolTypesPage = () => {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h1>Tool Types</h1>
|
||||
<button onClick={openCreate}>
|
||||
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Types</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
|
||||
<button onClick={openCreate}>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Tool Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolTypes.length === 0 ? (
|
||||
|
||||
@@ -18,10 +18,13 @@ const mockToolTypes = [
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
||||
dockerfile_template: null,
|
||||
build_context: null,
|
||||
readiness_probe: null,
|
||||
required_variables: ["REPO_PATH"],
|
||||
is_builtin: true,
|
||||
created_by_id: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "type-2",
|
||||
@@ -34,6 +37,7 @@ const mockToolTypes = [
|
||||
definition_type: "dockerfile",
|
||||
compose_template: null,
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
build_context: null,
|
||||
readiness_probe: {
|
||||
command: "python --version",
|
||||
timeout: 30,
|
||||
@@ -42,6 +46,8 @@ const mockToolTypes = [
|
||||
required_variables: [],
|
||||
is_builtin: false,
|
||||
created_by_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -49,6 +55,7 @@ const mockConfigs = [
|
||||
{
|
||||
id: "config-1",
|
||||
tool_type_id: "type-1",
|
||||
project_id: null,
|
||||
key: "OPENAI_API_KEY",
|
||||
value: "sk-test123",
|
||||
config_type: "env",
|
||||
@@ -62,9 +69,11 @@ const mockConfigs = [
|
||||
{
|
||||
id: "config-2",
|
||||
tool_type_id: "type-2",
|
||||
project_id: null,
|
||||
key: "advanced-config",
|
||||
value: "test-value",
|
||||
config_type: "env",
|
||||
file_path: null,
|
||||
port_override: 9090,
|
||||
start_command: "python app.py",
|
||||
working_directory: "/app",
|
||||
@@ -76,15 +85,19 @@ const mockConfigs = [
|
||||
const mockFolders = [
|
||||
{
|
||||
id: "folder-1",
|
||||
user_id: "user-1",
|
||||
name: "my-dotfiles",
|
||||
description: "My personal config files",
|
||||
mount_path: "/home/user",
|
||||
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||
project_overrides: {},
|
||||
is_active: true,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "folder-2",
|
||||
user_id: "user-1",
|
||||
name: "project-configs",
|
||||
description: "Project specific configs",
|
||||
mount_path: "/workspace",
|
||||
@@ -96,6 +109,8 @@ const mockFolders = [
|
||||
},
|
||||
},
|
||||
is_active: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -115,9 +130,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("renders tool types tab by default", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -128,9 +143,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("switches to configs tab", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -147,9 +162,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("switches to folders tab", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -166,9 +181,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("opens tool type creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -178,15 +193,15 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/display name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates tool type with compose definition", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -196,12 +211,15 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
target: { value: "new-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
target: { value: "New Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "8080" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
|
||||
});
|
||||
@@ -218,14 +236,13 @@ describe("ToolWorkshopPage", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("creates tool type with dockerfile definition", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -235,17 +252,22 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
target: { value: "docker-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
target: { value: "Docker Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "3000" },
|
||||
});
|
||||
|
||||
// Switch to dockerfile
|
||||
fireEvent.click(screen.getByLabelText(/dockerfile/i));
|
||||
fireEvent.change(screen.getByLabelText("Definition Type"), {
|
||||
target: { value: "dockerfile" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/dockerfile template/i), {
|
||||
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
|
||||
target: { value: "FROM python:3.11\\nRUN pip install flask" },
|
||||
});
|
||||
|
||||
@@ -263,9 +285,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("shows readiness probe fields", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -275,20 +297,15 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByLabelText(/readiness command/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/timeout/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/interval/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens config creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
|
||||
tool_type_id: "type-1",
|
||||
suggested_configs: [],
|
||||
port_override: null,
|
||||
});
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -309,15 +326,10 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("creates config with advanced fields", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
|
||||
tool_type_id: "type-1",
|
||||
suggested_configs: [],
|
||||
port_override: null,
|
||||
});
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -346,7 +358,7 @@ describe("ToolWorkshopPage", () => {
|
||||
target: { value: "python app.py" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
@@ -362,9 +374,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("opens folder creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -380,15 +392,15 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/mount path/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates config folder successfully", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -404,10 +416,10 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
target: { value: "new-folder" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/mount path/i), {
|
||||
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
||||
target: { value: "/home/dev" },
|
||||
});
|
||||
|
||||
@@ -425,9 +437,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("shows folder active/inactive status", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -441,46 +453,8 @@ describe("ToolWorkshopPage", () => {
|
||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that active folder is marked
|
||||
const activeFolder = screen.getByText("my-dotfiles").closest("[data-testid='folder-item']") ||
|
||||
screen.getByText("my-dotfiles").parentElement;
|
||||
expect(activeFolder?.textContent).toContain("active");
|
||||
});
|
||||
|
||||
it("validates tool type before creation", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
const validateMock = vi.spyOn(toolTypesApi, "validateToolType").mockResolvedValue({
|
||||
valid: false,
|
||||
errors: ["Invalid YAML syntax"],
|
||||
});
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
||||
target: { value: "bad-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
||||
target: { value: "Bad Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||
target: { value: "invalid: yaml: [" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /validate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(validateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/invalid yaml syntax/i)).toBeInTheDocument();
|
||||
// Check that active folder shows Active badge
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles error state gracefully", async () => {
|
||||
@@ -500,13 +474,13 @@ describe("ToolWorkshopPage", () => {
|
||||
it("retries loading after error", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockToolTypes);
|
||||
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockConfigs);
|
||||
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockFolders);
|
||||
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -523,10 +497,10 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("deletes tool type successfully", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -535,19 +509,14 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
// Find and click delete button for custom tool (not built-in)
|
||||
const customToolCard = screen.getByText("Custom Tool").closest("[data-testid='tool-type-item']") ||
|
||||
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
|
||||
screen.getByText("Custom Tool").parentElement;
|
||||
if (customToolCard) {
|
||||
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
|
||||
if (deleteButton) {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// Confirm deletion
|
||||
const confirmButton = screen.queryByRole("button", { name: /confirm/i });
|
||||
if (confirmButton) {
|
||||
fireEvent.click(confirmButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith("type-2");
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
validateToolType,
|
||||
type CreateToolTypeRequest,
|
||||
type ReadinessProbe,
|
||||
type ToolType,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
import {
|
||||
createToolConfig,
|
||||
deleteToolConfig,
|
||||
getToolConfigDefaults,
|
||||
listToolConfigs,
|
||||
updateToolConfig,
|
||||
type CreateToolConfigRequest,
|
||||
@@ -605,8 +603,9 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Readiness Probe Command</label>
|
||||
<label htmlFor="readiness-command">Readiness Probe Command</label>
|
||||
<input
|
||||
id="readiness-command"
|
||||
type="text"
|
||||
value={toolTypeForm.readiness_command}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
||||
@@ -617,8 +616,9 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Timeout (seconds)</label>
|
||||
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
||||
<input
|
||||
id="readiness-timeout"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_timeout}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
||||
@@ -626,8 +626,9 @@ export const ToolWorkshopPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Interval (seconds)</label>
|
||||
<label htmlFor="readiness-interval">Interval (seconds)</label>
|
||||
<input
|
||||
id="readiness-interval"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_interval}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
||||
@@ -764,8 +765,9 @@ export const ToolWorkshopPage = () => {
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Value</label>
|
||||
<label htmlFor="config-value">Value</label>
|
||||
<textarea
|
||||
id="config-value"
|
||||
value={configForm.value}
|
||||
onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })}
|
||||
placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."}
|
||||
@@ -777,8 +779,9 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Port Override</label>
|
||||
<label htmlFor="config-port-override">Port Override</label>
|
||||
<input
|
||||
id="config-port-override"
|
||||
type="number"
|
||||
value={configForm.port_override}
|
||||
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
|
||||
@@ -787,8 +790,9 @@ export const ToolWorkshopPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Start Command</label>
|
||||
<label htmlFor="config-start-command">Start Command</label>
|
||||
<input
|
||||
id="config-start-command"
|
||||
type="text"
|
||||
value={configForm.start_command}
|
||||
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
|
||||
@@ -918,8 +922,9 @@ export const ToolWorkshopPage = () => {
|
||||
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
|
||||
<form onSubmit={handleFolderSubmit} className="stack">
|
||||
<div className="form-group">
|
||||
<label>Name *</label>
|
||||
<label htmlFor="folder-name">Name *</label>
|
||||
<input
|
||||
id="folder-name"
|
||||
type="text"
|
||||
value={folderForm.name}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
|
||||
@@ -930,8 +935,9 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<label htmlFor="folder-description">Description</label>
|
||||
<input
|
||||
id="folder-description"
|
||||
type="text"
|
||||
value={folderForm.description}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
|
||||
@@ -941,8 +947,9 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Mount Path *</label>
|
||||
<label htmlFor="folder-mount-path">Mount Path *</label>
|
||||
<input
|
||||
id="folder-mount-path"
|
||||
type="text"
|
||||
value={folderForm.mount_path}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
|
||||
@@ -953,8 +960,9 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Files (JSON object)</label>
|
||||
<label htmlFor="folder-files">Files (JSON object)</label>
|
||||
<textarea
|
||||
id="folder-files"
|
||||
value={folderForm.files_json}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })}
|
||||
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
|
||||
|
||||
Reference in New Issue
Block a user