diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index f0d9237c..6036489c 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -17,6 +17,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({ setDefault: vi.fn(), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ + get: vi.fn(), getExperimental: vi.fn(), })); const mockSecretsApi = vi.hoisted(() => ({ @@ -52,6 +53,13 @@ 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. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; async function act(callback: () => void | Promise) { await callback(); @@ -73,6 +81,14 @@ function testProviderButtons(container: HTMLElement): HTMLButtonElement[] { }); } +function findButton(root: ParentNode, label: string): HTMLButtonElement | undefined { + return Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.trim() === label); +} + +function getOpenDialog(): HTMLElement | null { + return document.body.querySelector("[role='dialog']"); +} + describe("CompanyEnvironments — test provider button", () => { let container: HTMLDivElement; let probeResolvers: Map void>; @@ -81,6 +97,7 @@ describe("CompanyEnvironments — test provider button", () => { container = document.createElement("div"); document.body.appendChild(container); probeResolvers = new Map(); + mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true }); mockEnvironmentsApi.capabilities.mockResolvedValue({ adapters: [], sandboxProviders: {} }); mockSecretsApi.list.mockResolvedValue([]); @@ -88,6 +105,20 @@ describe("CompanyEnvironments — test provider button", () => { { id: "env-1", name: "Alpha", driver: "sandbox", description: null, config: { provider: "e2b" } }, { id: "env-2", name: "Beta", driver: "sandbox", description: null, config: { provider: "e2b" } }, ]); + mockEnvironmentsApi.create.mockImplementation(async (_companyId: string, body: { name: string }) => ({ + id: "env-new", + name: body.name, + driver: "ssh", + description: null, + config: {}, + })); + mockEnvironmentsApi.update.mockImplementation(async (environmentId: string, body: { name: string }) => ({ + id: environmentId, + name: body.name, + driver: "sandbox", + description: null, + config: { provider: "e2b" }, + })); // Each probe stays pending until its resolver is called, so the testing // state remains observable and can be settled per environment. mockEnvironmentsApi.probe.mockImplementation( @@ -175,4 +206,72 @@ describe("CompanyEnvironments — test provider button", () => { expect(buttons[1].textContent?.trim()).toBe("Testing..."); expect(buttons[1].disabled).toBe(true); }); + + it("opens the add-environment form in a dialog and closes it on cancel", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => { + findButton(container, "Add environment")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(getOpenDialog()?.textContent).toContain("Add environment"); + + await act(async () => { + findButton(document.body, "Cancel")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(getOpenDialog()).toBeNull(); + }); + + it("opens the edit form in a dialog with existing values and closes after save", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => { + findButton(container, "Edit")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const dialog = getOpenDialog(); + expect(dialog?.textContent).toContain("Edit environment"); + expect( + Array.from(dialog?.querySelectorAll("input") ?? []).some((input) => (input as HTMLInputElement).value === "Alpha"), + ).toBe(true); + + await act(async () => { + findButton(document.body, "Save environment")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockEnvironmentsApi.update).toHaveBeenCalledExactlyOnceWith( + "env-1", + expect.objectContaining({ name: "Alpha", driver: "sandbox" }), + ); + expect(getOpenDialog()).toBeNull(); + }); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 7463a6b5..094be45c 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -13,6 +13,14 @@ import { environmentsApi } from "@/api/environments"; 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 { EnvVarEditor } from "@/components/EnvVarEditor"; import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -181,6 +189,7 @@ export function CompanyEnvironments() { const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const queryClient = useQueryClient(); + const [environmentDialogOpen, setEnvironmentDialogOpen] = useState(false); const [editingEnvironmentId, setEditingEnvironmentId] = useState(null); const [environmentForm, setEnvironmentForm] = useState(createEmptyEnvironmentForm); const [probeResults, setProbeResults] = useState>({}); @@ -245,13 +254,17 @@ export function CompanyEnvironments() { return await environmentsApi.create(selectedCompanyId!, body); }, onSuccess: async (environment) => { + const wasEditing = editingEnvironmentId !== null; await queryClient.invalidateQueries({ queryKey: queryKeys.environments.list(selectedCompanyId!), }); + setEnvironmentDialogOpen(false); setEditingEnvironmentId(null); setEnvironmentForm(createEmptyEnvironmentForm()); + environmentMutation.reset(); + draftEnvironmentProbeMutation.reset(); pushToast({ - title: editingEnvironmentId ? "Environment updated" : "Environment created", + title: wasEditing ? "Environment updated" : "Environment created", body: `${environment.name} is ready.`, tone: "success", }); @@ -345,14 +358,26 @@ export function CompanyEnvironments() { }); useEffect(() => { + setEnvironmentDialogOpen(false); setEditingEnvironmentId(null); setEnvironmentForm(createEmptyEnvironmentForm()); setProbeResults({}); setTestingEnvironmentId(null); }, [selectedCompanyId]); + function handleStartCreateEnvironment() { + setEditingEnvironmentId(null); + setEnvironmentForm(createEmptyEnvironmentForm()); + environmentMutation.reset(); + draftEnvironmentProbeMutation.reset(); + setEnvironmentDialogOpen(true); + } + function handleEditEnvironment(environment: Environment) { + environmentMutation.reset(); + draftEnvironmentProbeMutation.reset(); setEditingEnvironmentId(environment.id); + setEnvironmentDialogOpen(true); if (environment.driver === "ssh") { const ssh = readSshConfig(environment); setEnvironmentForm({ @@ -396,9 +421,13 @@ export function CompanyEnvironments() { }); } - function handleCancelEnvironmentEdit() { + function closeEnvironmentDialog() { + if (environmentMutation.isPending) return; + setEnvironmentDialogOpen(false); setEditingEnvironmentId(null); setEnvironmentForm(createEmptyEnvironmentForm()); + environmentMutation.reset(); + draftEnvironmentProbeMutation.reset(); } const discoveredPluginSandboxProviders = Object.entries(environmentCapabilities?.sandboxProviders ?? {}) @@ -590,6 +619,12 @@ export function CompanyEnvironments() {
+
+
Saved environments
+ +
{savedEnvironments.length === 0 ? (
No saved environments yet. Local remains the default until you add another target.
) : ( @@ -672,256 +707,269 @@ export function CompanyEnvironments() { }) )}
+ -
-
- {editingEnvironmentId ? "Edit environment" : "Add environment"} -
-
- - setEnvironmentForm((current) => ({ ...current, name: e.target.value }))} - /> - - - setEnvironmentForm((current) => ({ ...current, description: e.target.value }))} - /> - - - - + { + if (open) { + setEnvironmentDialogOpen(true); + return; + } + closeEnvironmentDialog(); + }} + > + + + {editingEnvironmentId ? "Edit environment" : "Add environment"} + + Configure a reusable execution target for your agents. + + - {environmentForm.driver === "ssh" ? ( -
- - setEnvironmentForm((current) => ({ ...current, sshHost: e.target.value }))} - /> - - - setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))} - /> - - - setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))} - /> - - - - setEnvironmentForm((current) => ({ ...current, sshRemoteWorkspacePath: e.target.value }))} - /> - - -
- setEnvironmentForm((current) => ({ ...current, name: e.target.value }))} + /> + + + setEnvironmentForm((current) => ({ ...current, description: e.target.value }))} + /> + + + + + + {environmentForm.driver === "ssh" ? ( +
+ + setEnvironmentForm((current) => ({ ...current, sshHost: e.target.value }))} + /> + + + setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))} + /> + + + setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))} + /> + + + - setEnvironmentForm((current) => ({ - ...current, - sshPrivateKeySecretId: e.target.value, - sshPrivateKey: e.target.value ? "" : current.sshPrivateKey, - }))} - > - - {(secrets ?? []).map((secret) => ( - - ))} - + setEnvironmentForm((current) => ({ ...current, sshRemoteWorkspacePath: e.target.value }))} + /> + + +
+ +