diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 46dd51a26c..83fe93f288 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -103,6 +103,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 7b148c7978..5d6ea11179 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CompanyEnvironments } from "./CompanyEnvironments"; @@ -149,7 +150,7 @@ const mockSecretsApi = vi.hoisted(() => ({ vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", - selectedCompany: { id: "company-1", name: "Paperclip" }, + selectedCompany: null, }), })); @@ -177,7 +178,7 @@ vi.mock("@/api/secrets", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -// Minimal Radix dialog dependency for jsdom. +// Minimal browser APIs for jsdom. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).ResizeObserver = class { observe() {} @@ -262,12 +263,16 @@ function findButton(root: ParentNode, label: string): HTMLButtonElement | undefi return Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.trim() === label); } -function editButtons(root: ParentNode): HTMLButtonElement[] { - return Array.from(root.querySelectorAll("button")).filter((button) => button.textContent?.trim() === "Edit"); +function findAction(root: ParentNode, label: string): HTMLElement | undefined { + return Array.from(root.querySelectorAll("button,a")).find((element) => element.textContent?.trim() === label); +} + +function editButtons(root: ParentNode): HTMLElement[] { + return Array.from(root.querySelectorAll("button,a")).filter((element) => element.textContent?.trim() === "Edit"); } function click(element: Element | null | undefined) { - element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + element?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } function setInputValue(input: HTMLInputElement, value: string) { @@ -276,8 +281,26 @@ function setInputValue(input: HTMLInputElement, value: string) { input.dispatchEvent(new Event("input", { bubbles: true })); } -function getOpenDialog(): HTMLElement | null { - return document.body.querySelector("[role='dialog']"); +const ENVIRONMENTS_PATH = "/company/settings/instance/environments"; + +function getEnvironmentFormPage(): HTMLElement | null { + return document.body.querySelector("[data-testid='environment-form-page']"); +} + +function renderCompanyEnvironments(queryClient: QueryClient, initialPath = ENVIRONMENTS_PATH) { + return ( + + + + + } /> + } /> + } /> + + + + + ); } function createSession(overrides: Record = {}) { @@ -459,13 +482,7 @@ describe("CompanyEnvironments — test provider button", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); @@ -511,13 +528,7 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); @@ -556,13 +567,7 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); @@ -582,13 +587,7 @@ describe("CompanyEnvironments — test provider button", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); @@ -613,66 +612,58 @@ describe("CompanyEnvironments — test provider button", () => { expect(buttons[1].disabled).toBe(true); }); - it("opens the add-environment form in a dialog and closes it on cancel", async () => { + it("opens the add-environment form on a standalone page and closes it on cancel", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => { - findButton(container, "Add environment")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + click(findAction(container, "Add environment")); }); - await flushReact(); - expect(getOpenDialog()?.textContent).toContain("Add environment"); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()?.textContent).toContain("Add environment"); + }); await act(async () => { findButton(document.body, "Cancel")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); - expect(getOpenDialog()).toBeNull(); + expect(getEnvironmentFormPage()).toBeNull(); }); - it("opens the edit form in a dialog with existing values and closes after save", async () => { + it("opens the edit form on a standalone page with existing values and closes after save", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => { - findButton(container, "Edit")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + click(findAction(container, "Edit")); }); - await flushReact(); - const dialog = getOpenDialog(); - expect(dialog?.textContent).toContain("Edit environment"); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()?.textContent).toContain("Edit environment"); + }); + + const page = getEnvironmentFormPage(); + expect(document.body.querySelector("[role='dialog']")).toBeNull(); expect( - Array.from(dialog?.querySelectorAll("input") ?? []).some((input) => (input as HTMLInputElement).value === "Alpha"), + Array.from(page?.querySelectorAll("input") ?? []).some((input) => (input as HTMLInputElement).value === "Alpha"), ).toBe(true); - await act(async () => click(findButton(dialog!, "Add variable"))); + await act(async () => click(findButton(page!, "Add variable"))); await flushReact(); - const variableName = dialog!.querySelector('input[aria-label="Variable name"]')!; - const variableValue = dialog!.querySelector('input[aria-label="Variable value"]')!; + const variableName = page!.querySelector('input[aria-label="Variable name"]')!; + const variableValue = page!.querySelector('input[aria-label="Variable value"]')!; await act(async () => { setInputValue(variableName, "API_TOKEN"); setInputValue(variableValue, "draft-token"); @@ -692,7 +683,7 @@ describe("CompanyEnvironments — test provider button", () => { envVars: { API_TOKEN: { type: "plain", value: "draft-token" } }, }), ); - expect(getOpenDialog()).toBeNull(); + expect(getEnvironmentFormPage()).toBeNull(); }); it("shows image setup controls only for providers advertising setup and capture support", async () => { @@ -747,40 +738,34 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); - // Daytona supports setup + capture → "Configure image" in its config dialog. + // Daytona supports setup + capture -> "Configure image" on its edit page. await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("Configure image"); + expect(getEnvironmentFormPage()?.textContent).toContain("Configure image"); }); expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); await act(async () => click(findButton(document.body, "Cancel"))); - await waitForAssertion(() => expect(getOpenDialog()).toBeNull()); + await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeNull()); // E2B does not advertise interactive setup. await act(async () => click(editButtons(container)[1])); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("Unsupported provider"); + expect(getEnvironmentFormPage()?.textContent).toContain("Unsupported provider"); }); - expect(getOpenDialog()?.textContent).not.toContain("Configure image"); + expect(getEnvironmentFormPage()?.textContent).not.toContain("Configure image"); await act(async () => click(findButton(document.body, "Cancel"))); - await waitForAssertion(() => expect(getOpenDialog()).toBeNull()); + await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeNull()); // Provider advertises setup but cannot capture an image. await act(async () => click(editButtons(container)[2])); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("Setup capture unavailable"); + expect(getEnvironmentFormPage()?.textContent).toContain("Setup capture unavailable"); }); - expect(getOpenDialog()?.textContent).not.toContain("Configure image"); + expect(getEnvironmentFormPage()?.textContent).not.toContain("Configure image"); }); it("shows a live connect command and removes it after cancellation", async () => { @@ -826,31 +811,25 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain(command); + expect(getEnvironmentFormPage()?.textContent).toContain(command); }); - await act(async () => click(findButton(getOpenDialog()!, "Cancel"))); + await act(async () => click(findButton(getEnvironmentFormPage()!, "Cancel"))); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("Setup cancelled"); + expect(getEnvironmentFormPage()?.textContent).toContain("Setup cancelled"); }); expect(mockEnvironmentsApi.cancelCustomImageSetupSession).toHaveBeenCalledExactlyOnceWith( "session-1", { reason: "operator cancelled" }, ); - expect(getOpenDialog()?.textContent).not.toContain(command); + expect(getEnvironmentFormPage()?.textContent).not.toContain(command); }); it("opens an embedded browser terminal automatically while preserving the SSH command fallback", async () => { @@ -872,21 +851,15 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain(command); - expect(getOpenDialog()?.textContent).toContain("Browser terminal"); - expect(getOpenDialog()?.textContent).toContain("SSH command fallback"); + expect(getEnvironmentFormPage()?.textContent).toContain(command); + expect(getEnvironmentFormPage()?.textContent).toContain("Browser terminal"); + expect(getEnvironmentFormPage()?.textContent).toContain("SSH command fallback"); expect(mockEnvironmentsApi.createCustomImageTerminalSessionToken).toHaveBeenCalledExactlyOnceWith("session-1", {}); expect(FakeWebSocket.instances).toHaveLength(1); }); @@ -915,7 +888,7 @@ describe("CompanyEnvironments — test provider button", () => { FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "ready" })); FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "output", data: "\u001b[?2004hsetup shell\r\n$ " })); }); - const terminalScreen = getOpenDialog()?.querySelector( + const terminalScreen = getEnvironmentFormPage()?.querySelector( "[data-testid='custom-image-terminal-screen-session-1']", ); await waitForAssertion(() => { @@ -923,7 +896,7 @@ describe("CompanyEnvironments — test provider button", () => { expect(xtermMocks.terminalInstances[0].writes.join("")).toContain("setup shell"); expect(xtermMocks.terminalInstances[0].focused).toBe(true); expect(document.activeElement).toBe(terminalScreen); - expect(getOpenDialog()?.textContent).toContain(command); + expect(getEnvironmentFormPage()?.textContent).toContain(command); }); expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "auth", @@ -940,6 +913,48 @@ describe("CompanyEnvironments — test provider button", () => { expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "input", data: "\r" })); }); + it("keeps the environment edit page open when Escape is pressed in setup terminal", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const command = "ssh sandbox@setup.example.invalid -p 2222"; + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: null, + activeSession: createSession(), + latestSession: createSession(), + }); + mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({ + session: createSession(), + connectionPayload: { type: "ssh", command }, + }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient)); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + let terminalScreen: HTMLElement | null = null; + await waitForAssertion(() => { + terminalScreen = getEnvironmentFormPage()?.querySelector( + "[data-testid='custom-image-terminal-screen-session-1']", + ) ?? null; + expect(terminalScreen).toBeTruthy(); + expect(document.body.querySelector("[role='dialog']")).toBeNull(); + }); + + await act(async () => { + terminalScreen?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + await flushReact(); + + expect(getEnvironmentFormPage()?.textContent).toContain(command); + expect(getEnvironmentFormPage()?.textContent).toContain("Edit environment"); + }); + it("does not render connect details when an active session refreshes as expired", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -976,21 +991,15 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("Setup expired"); + expect(getEnvironmentFormPage()?.textContent).toContain("Setup expired"); }); - expect(getOpenDialog()?.textContent).not.toContain(command); + expect(getEnvironmentFormPage()?.textContent).not.toContain(command); }); it("shows a setup connection refresh fallback without breaking finish or cancel", async () => { @@ -1008,22 +1017,16 @@ describe("CompanyEnvironments — test provider button", () => { mockEnvironmentsApi.customImageSetupSession.mockRejectedValue(new Error("proxy unavailable")); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("Setup connection details could not be refreshed."); + expect(getEnvironmentFormPage()?.textContent).toContain("Setup connection details could not be refreshed."); }); - const dialog = getOpenDialog()!; + const dialog = getEnvironmentFormPage()!; expect(findButton(dialog, "Finished")?.disabled).toBe(false); expect(findButton(dialog, "Cancel")?.disabled).toBe(false); @@ -1051,22 +1054,16 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("Browser terminal is not available for this provider connection."); + expect(getEnvironmentFormPage()?.textContent).toContain("Browser terminal is not available for this provider connection."); }); - const dialog = getOpenDialog()!; + const dialog = getEnvironmentFormPage()!; expect(findButton(dialog, "Finished")?.disabled).toBe(false); expect(findButton(dialog, "Cancel")?.disabled).toBe(false); }); @@ -1103,19 +1100,13 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - const dialog = getOpenDialog(); + const dialog = getEnvironmentFormPage(); expect(dialog?.textContent).toContain("Active template"); expect(dialog?.textContent).toContain("redacted-template-ref"); expect(dialog?.textContent).not.toContain("id 12345678-90a"); @@ -1129,7 +1120,7 @@ describe("CompanyEnvironments — test provider button", () => { expect(findButton(dialog!, "Disable")).toBeTruthy(); }); - await act(async () => click(findButton(getOpenDialog()!, "Refresh"))); + await act(async () => click(findButton(getEnvironmentFormPage()!, "Refresh"))); await flushReact(); expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith( @@ -1138,8 +1129,8 @@ describe("CompanyEnvironments — test provider button", () => { { templateId: activeTemplateId }, ); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("Browser terminal"); - expect(getOpenDialog()?.textContent).toContain("SSH command fallback"); + expect(getEnvironmentFormPage()?.textContent).toContain("Browser terminal"); + expect(getEnvironmentFormPage()?.textContent).toContain("SSH command fallback"); expect(mockEnvironmentsApi.createCustomImageTerminalSessionToken).toHaveBeenCalledExactlyOnceWith("session-1", {}); expect(FakeWebSocket.instances).toHaveLength(1); }); @@ -1171,28 +1162,22 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - const dialog = getOpenDialog()!; + const dialog = getEnvironmentFormPage()!; expect(dialog.textContent).toContain("Capturing template"); expect(dialog.textContent).toContain("Capture is in progress."); expect(findButton(dialog, "Finished")?.disabled).toBe(true); expect(findButton(dialog, "Cancel")?.disabled).toBe(false); }); - await act(async () => click(findButton(getOpenDialog()!, "Cancel"))); + await act(async () => click(findButton(getEnvironmentFormPage()!, "Cancel"))); await waitForAssertion(() => { - const dialog = getOpenDialog()!; + const dialog = getEnvironmentFormPage()!; expect(dialog.textContent).toContain("Active template"); expect(findButton(dialog, "Refresh")).toBeTruthy(); }); @@ -1218,19 +1203,13 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - const dialog = getOpenDialog()!; + const dialog = getEnvironmentFormPage()!; expect(dialog.textContent).toContain("Active template"); expect(dialog.textContent).toContain("Not in use — the environment configuration changed"); }); @@ -1251,19 +1230,13 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - const dialog = getOpenDialog()!; + const dialog = getEnvironmentFormPage()!; expect(dialog.textContent).toContain("Active template"); expect(dialog.textContent).not.toContain("Not in use"); }); @@ -1300,30 +1273,24 @@ describe("CompanyEnvironments — test provider button", () => { }); await act(async () => { - root!.render( - - - - - , - ); + root!.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { - const dialog = getOpenDialog(); + const dialog = getEnvironmentFormPage(); expect(dialog?.textContent).toContain("Active template"); expect(findButton(dialog!, "Rollback")).toBeTruthy(); expect(findButton(dialog!, "Disable")).toBeTruthy(); }); - await act(async () => click(findButton(getOpenDialog()!, "Rollback"))); + await act(async () => click(findButton(getEnvironmentFormPage()!, "Rollback"))); await waitForAssertion(() => { expect(mockEnvironmentsApi.rollbackCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); }); - await act(async () => click(findButton(getOpenDialog()!, "Disable"))); + await act(async () => click(findButton(getEnvironmentFormPage()!, "Disable"))); await waitForAssertion(() => { expect(mockEnvironmentsApi.disableCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 42f040f3e7..8405145c7b 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -5,7 +5,7 @@ import { useState, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react"; +import { ArrowLeft, Check, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react"; import { FitAddon } from "@xterm/addon-fit"; import { Terminal as XTermTerminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; @@ -26,14 +26,6 @@ import { import { instanceSettingsApi } from "@/api/instanceSettings"; import { secretsApi } from "@/api/secrets"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { EnvironmentVariablesEditor, type EnvironmentVariablesEditorHandle, @@ -43,6 +35,7 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; +import { Link, useNavigate, useParams } from "@/lib/router"; import { buildSameOriginWebSocketUrl } from "@/lib/websocket-url"; import { Field, @@ -66,6 +59,18 @@ type EnvironmentFormState = { envVars: Record; }; +type CompanyEnvironmentsMode = "list" | "create" | "edit"; + +type CompanyEnvironmentsProps = { + mode?: CompanyEnvironmentsMode; +}; + +const ENVIRONMENTS_PATH = "/company/settings/instance/environments"; + +function environmentEditPath(environmentId: string) { + return `${ENVIRONMENTS_PATH}/${encodeURIComponent(environmentId)}/edit`; +} + function buildEnvironmentPayload(form: EnvironmentFormState) { return { name: form.name.trim(), @@ -168,6 +173,48 @@ function readSandboxConfig(environment: Environment) { }; } +function createEnvironmentFormFromEnvironment(environment: Environment): EnvironmentFormState { + if (environment.driver === "ssh") { + const ssh = readSshConfig(environment); + return { + ...createEmptyEnvironmentForm(), + name: environment.name, + description: environment.description ?? "", + driver: "ssh", + sshHost: ssh.host, + sshPort: ssh.port, + sshUsername: ssh.username, + sshRemoteWorkspacePath: ssh.remoteWorkspacePath, + sshPrivateKey: ssh.privateKey, + sshPrivateKeySecretId: ssh.privateKeySecretId, + sshKnownHosts: ssh.knownHosts, + sshStrictHostKeyChecking: ssh.strictHostKeyChecking, + envVars: environment.envVars ?? {}, + }; + } + + if (environment.driver === "sandbox") { + const sandbox = readSandboxConfig(environment); + return { + ...createEmptyEnvironmentForm(), + name: environment.name, + description: environment.description ?? "", + driver: "sandbox", + sandboxProvider: sandbox.provider, + sandboxConfig: sandbox.config, + envVars: environment.envVars ?? {}, + }; + } + + return { + ...createEmptyEnvironmentForm(), + name: environment.name, + description: environment.description ?? "", + driver: "local", + envVars: environment.envVars ?? {}, + }; +} + function normalizeJsonSchema(schema: unknown): JsonSchema | null { return schema && typeof schema === "object" && !Array.isArray(schema) ? schema as JsonSchema @@ -1067,25 +1114,33 @@ function EnvironmentImageTemplatePanel({ ); } -export function CompanyEnvironments() { +export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) { + const { environmentId: routeEnvironmentId } = useParams<{ environmentId?: string }>(); + const navigate = useNavigate(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const queryClient = useQueryClient(); - const [environmentDialogOpen, setEnvironmentDialogOpen] = useState(false); - const [editingEnvironmentId, setEditingEnvironmentId] = useState(null); + const isEnvironmentFormPage = mode === "create" || mode === "edit"; + const editingEnvironmentId = mode === "edit" ? routeEnvironmentId ?? null : null; const [environmentForm, setEnvironmentForm] = useState(createEmptyEnvironmentForm); const environmentVariablesEditorRef = useRef(null); + const initializedFormKeyRef = useRef(null); const [probeResults, setProbeResults] = useState>({}); const [testingEnvironmentId, setTestingEnvironmentId] = useState(null); useEffect(() => { - setBreadcrumbs([ + const crumbs = [ { label: "Settings", href: "/company/settings" }, { label: "Instance settings", href: "/company/settings/instance/general" }, - { label: "Environments" }, - ]); - }, [setBreadcrumbs]); + isEnvironmentFormPage + ? { label: "Environments", href: ENVIRONMENTS_PATH } + : { label: "Environments" }, + ]; + if (mode === "create") crumbs.push({ label: "Add environment" }); + if (mode === "edit") crumbs.push({ label: "Edit environment" }); + setBreadcrumbs(crumbs); + }, [isEnvironmentFormPage, mode, setBreadcrumbs]); const { data: instanceSettings } = useQuery({ queryKey: queryKeys.instance.settings, @@ -1105,6 +1160,7 @@ export function CompanyEnvironments() { queryFn: () => environmentsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId) && environmentsEnabled, }); + const savedEnvironments = environments ?? []; const { data: environmentCapabilities } = useQuery({ queryKey: selectedCompanyId ? ["environment-capabilities", selectedCompanyId] : ["environment-capabilities", "none"], queryFn: () => environmentsApi.capabilities(selectedCompanyId!), @@ -1135,21 +1191,24 @@ export function CompanyEnvironments() { return await environmentsApi.update(editingEnvironmentId, body); } + if (!selectedCompanyId) throw new Error("Select a company to create environments"); return await environmentsApi.create(selectedCompanyId!, body); }, onSuccess: async (environment) => { const wasEditing = editingEnvironmentId !== null; - await queryClient.invalidateQueries({ - queryKey: queryKeys.environments.list(selectedCompanyId!), - }); + if (selectedCompanyId) { + await queryClient.invalidateQueries({ + queryKey: queryKeys.environments.list(selectedCompanyId), + }); + } await queryClient.invalidateQueries({ queryKey: queryKeys.environments.customImageTemplate(environment.id), }); - setEnvironmentDialogOpen(false); - setEditingEnvironmentId(null); + initializedFormKeyRef.current = null; setEnvironmentForm(createEmptyEnvironmentForm()); environmentMutation.reset(); draftEnvironmentProbeMutation.reset(); + navigate(ENVIRONMENTS_PATH, { replace: true }); pushToast({ title: wasEditing ? "Environment updated" : "Environment created", body: `${environment.name} is ready.`, @@ -1239,8 +1298,9 @@ export function CompanyEnvironments() { const draftEnvironmentProbeMutation = useMutation({ mutationFn: async (form: EnvironmentFormState) => { + if (!selectedCompanyId) throw new Error("Select a company to test environments"); const body = buildEnvironmentPayload(form); - return await environmentsApi.probeConfig(selectedCompanyId!, body); + return await environmentsApi.probeConfig(selectedCompanyId, body); }, onSuccess: (probe) => { pushToast({ @@ -1259,76 +1319,60 @@ export function CompanyEnvironments() { }); useEffect(() => { - setEnvironmentDialogOpen(false); - setEditingEnvironmentId(null); + initializedFormKeyRef.current = null; setEnvironmentForm(createEmptyEnvironmentForm()); setProbeResults({}); setTestingEnvironmentId(null); }, [selectedCompanyId]); - function handleStartCreateEnvironment() { - setEditingEnvironmentId(null); - setEnvironmentForm(createEmptyEnvironmentForm()); - environmentMutation.reset(); - draftEnvironmentProbeMutation.reset(); - setEnvironmentDialogOpen(true); - } + const resetEnvironmentMutation = environmentMutation.reset; + const resetDraftEnvironmentProbeMutation = draftEnvironmentProbeMutation.reset; - function handleEditEnvironment(environment: Environment) { - environmentMutation.reset(); - draftEnvironmentProbeMutation.reset(); - setEditingEnvironmentId(environment.id); - setEnvironmentDialogOpen(true); - if (environment.driver === "ssh") { - const ssh = readSshConfig(environment); - setEnvironmentForm({ - ...createEmptyEnvironmentForm(), - name: environment.name, - description: environment.description ?? "", - driver: "ssh", - sshHost: ssh.host, - sshPort: ssh.port, - sshUsername: ssh.username, - sshRemoteWorkspacePath: ssh.remoteWorkspacePath, - sshPrivateKey: ssh.privateKey, - sshPrivateKeySecretId: ssh.privateKeySecretId, - sshKnownHosts: ssh.knownHosts, - sshStrictHostKeyChecking: ssh.strictHostKeyChecking, - envVars: environment.envVars ?? {}, - }); + useEffect(() => { + if (!isEnvironmentFormPage) { + initializedFormKeyRef.current = null; return; } - if (environment.driver === "sandbox") { - const sandbox = readSandboxConfig(environment); - setEnvironmentForm({ - ...createEmptyEnvironmentForm(), - name: environment.name, - description: environment.description ?? "", - driver: "sandbox", - sandboxProvider: sandbox.provider, - sandboxConfig: sandbox.config, - envVars: environment.envVars ?? {}, - }); + const formKey = mode === "create" + ? `create:${selectedCompanyId ?? "none"}` + : `edit:${selectedCompanyId ?? "none"}:${editingEnvironmentId ?? "missing"}`; + + if (initializedFormKeyRef.current === formKey) return; + + resetEnvironmentMutation(); + resetDraftEnvironmentProbeMutation(); + + if (mode === "create") { + setEnvironmentForm(createEmptyEnvironmentForm()); + initializedFormKeyRef.current = formKey; return; } - setEnvironmentForm({ - ...createEmptyEnvironmentForm(), - name: environment.name, - description: environment.description ?? "", - driver: "local", - envVars: environment.envVars ?? {}, - }); - } + const environment = editingEnvironmentId + ? (environments ?? []).find((candidate) => candidate.id === editingEnvironmentId) ?? null + : null; + if (!environment) return; - function closeEnvironmentDialog() { + setEnvironmentForm(createEnvironmentFormFromEnvironment(environment)); + initializedFormKeyRef.current = formKey; + }, [ + editingEnvironmentId, + environments, + isEnvironmentFormPage, + mode, + resetDraftEnvironmentProbeMutation, + resetEnvironmentMutation, + selectedCompanyId, + ]); + + function closeEnvironmentForm() { if (environmentMutation.isPending) return; - setEnvironmentDialogOpen(false); - setEditingEnvironmentId(null); + initializedFormKeyRef.current = null; setEnvironmentForm(createEmptyEnvironmentForm()); environmentMutation.reset(); draftEnvironmentProbeMutation.reset(); + navigate(ENVIRONMENTS_PATH); } function flushEnvironmentForm(): EnvironmentFormState { @@ -1395,7 +1439,6 @@ export function CompanyEnvironments() { environmentForm.sandboxProvider !== "fake" && Object.keys(sandboxConfigErrors).length === 0); - const savedEnvironments = environments ?? []; const editingEnvironment = editingEnvironmentId ? savedEnvironments.find((environment) => environment.id === editingEnvironmentId) ?? null : null; @@ -1426,6 +1469,7 @@ export function CompanyEnvironments() { return (
+ {!isEnvironmentFormPage ? (
@@ -1453,13 +1497,12 @@ export function CompanyEnvironments() {
-
{savedEnvironments.map((environment) => { const probe = probeResults[environment.id] ?? null; - const isEditing = editingEnvironmentId === environment.id; const sandboxProvider = readEnvironmentSandboxProvider(environment); const sandboxProviderCapability = sandboxProvider ? environmentCapabilities?.sandboxProviders?.[sandboxProvider] @@ -1510,12 +1553,8 @@ export function CompanyEnvironments() { : "Test provider"} ) : null} -
@@ -1538,26 +1577,42 @@ export function CompanyEnvironments() { })}
+ ) : null} - { - if (open) { - setEnvironmentDialogOpen(true); - return; - } - closeEnvironmentDialog(); - }} - > - - - {editingEnvironmentId ? "Edit environment" : "Add environment"} - + {isEnvironmentFormPage && mode === "edit" && environments === undefined ? ( +
+ Loading environment... +
+ ) : null} + + {isEnvironmentFormPage && mode === "edit" && environments !== undefined && !editingEnvironment ? ( +
+
Environment not found
+
The environment may have been removed or is not available in this company.
+ +
+ ) : null} + + {isEnvironmentFormPage && (mode === "create" || editingEnvironment) ? ( +
+
+
+ +
+

{editingEnvironmentId ? "Edit environment" : "Add environment"}

+

Configure a reusable execution target for your agents. Saved changes affect future runs; Paperclip may start fresh sessions or sandbox leases after environment config changes. - - +

+
-
+
- +
- - -
+
+ + ) : null} ); } diff --git a/ui/src/pages/CompanySettings.test.tsx b/ui/src/pages/CompanySettings.test.tsx index 491c72602c..d4e0ebf554 100644 --- a/ui/src/pages/CompanySettings.test.tsx +++ b/ui/src/pages/CompanySettings.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; import { AGENT_ADAPTER_TYPES, getEnvironmentCapabilities } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CompanyEnvironments } from "./CompanyEnvironments"; @@ -32,6 +33,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({ })); const mockInstanceSettingsApi = vi.hoisted(() => ({ + get: vi.fn(), getExperimental: vi.fn(), })); @@ -84,14 +86,7 @@ vi.mock("../context/ToastContext", () => ({ vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }], - selectedCompany: { - id: "company-1", - name: "Paperclip", - description: null, - brandColor: null, - logoUrl: null, - issuePrefix: "PAP", - }, + selectedCompany: null, selectedCompanyId: "company-1", setSelectedCompanyId: mockSetSelectedCompanyId, }), @@ -122,8 +117,48 @@ async function flushReact() { }); } -function getOpenDialog(): HTMLElement | null { - return document.body.querySelector("[role='dialog']"); +async function waitForAssertion(assertion: () => void) { + let lastError: unknown; + for (let i = 0; i < 20; i += 1) { + await flushReact(); + try { + assertion(); + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +const ENVIRONMENTS_PATH = "/company/settings/instance/environments"; + +function getEnvironmentFormPage(): HTMLElement | null { + return document.body.querySelector("[data-testid='environment-form-page']"); +} + +function findAction(root: ParentNode, label: string): HTMLElement | undefined { + return Array.from(root.querySelectorAll("button,a")).find((element) => element.textContent?.trim() === label); +} + +function click(element: Element | null | undefined) { + element?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); +} + +function renderCompanyEnvironments(queryClient: QueryClient, initialPath = ENVIRONMENTS_PATH) { + return ( + + + + + } /> + } /> + } /> + + + + + ); } describe("CompanyEnvironments", () => { @@ -136,6 +171,7 @@ describe("CompanyEnvironments", () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true, }); + mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockEnvironmentsApi.list.mockResolvedValue([]); mockEnvironmentsApi.capabilities.mockResolvedValue( getEnvironmentCapabilities(AGENT_ADAPTER_TYPES), @@ -164,13 +200,7 @@ describe("CompanyEnvironments", () => { }); await act(async () => { - root.render( - - - - - , - ); + root.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await flushReact(); @@ -208,29 +238,20 @@ describe("CompanyEnvironments", () => { ); await act(async () => { - root.render( - - - - - , - ); + root.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await flushReact(); - const addEnvironmentButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Add environment", - ); + const addEnvironmentButton = findAction(container, "Add environment"); expect(addEnvironmentButton).toBeTruthy(); await act(async () => { - addEnvironmentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + click(addEnvironmentButton); }); - await flushReact(); - const dialog = getOpenDialog(); - expect(dialog).toBeTruthy(); + await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeTruthy()); + const dialog = getEnvironmentFormPage(); const driverSelect = Array.from(dialog?.querySelectorAll("select") ?? []) .find((select) => Array.from(select.options).some((option) => option.value === "ssh")) as @@ -268,28 +289,20 @@ describe("CompanyEnvironments", () => { ]); await act(async () => { - root.render( - - - - - , - ); + root.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await flushReact(); - const editButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.trim() === "Edit"); + const editButton = findAction(container, "Edit"); expect(editButton).toBeTruthy(); await act(async () => { - editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + click(editButton); }); - await flushReact(); - const dialog = getOpenDialog(); - expect(dialog).toBeTruthy(); + await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeTruthy()); + const dialog = getEnvironmentFormPage(); const driverSelect = Array.from(dialog?.querySelectorAll("select") ?? []) .find((select) => Array.from(select.options).some((option) => option.value === "ssh")) as @@ -350,30 +363,22 @@ describe("CompanyEnvironments", () => { ); await act(async () => { - root.render( - - - - - , - ); + root.render(renderCompanyEnvironments(queryClient)); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("Secure Sandbox"); - const editButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.trim() === "Edit"); + const editButton = findAction(container, "Edit"); expect(editButton).toBeTruthy(); await act(async () => { - editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + click(editButton); }); - await flushReact(); - const dialog = getOpenDialog(); - expect(dialog).toBeTruthy(); + await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeTruthy()); + const dialog = getEnvironmentFormPage(); const providerSelect = Array.from(dialog?.querySelectorAll("select") ?? []).find((select) => Array.from(select.options).some((option) => option.value === "secure-plugin"),