fix(ui): make environment edit a routed page (#9386)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Instance settings include an Environments section where operators configure sandbox/SSH/local execution environments, including interactive custom-image setup sessions with a browser terminal > - The environment create/edit form was rendered inside a modal dialog, so pressing Escape anywhere — including inside the embedded SSH terminal while capturing a snapshot — closed the whole modal and destroyed the in-progress session > - Environment editing is a heavyweight, long-lived flow; losing it to a reflexive Escape keypress is destructive and surprising > - This pull request converts environment create/edit from a modal into routed standalone pages, so Escape no longer dismisses the form > - The benefit is that terminal sessions and half-completed edits survive Escape, and the flow gets shareable URLs and normal back/forward navigation ## Linked Issues or Issue Description No existing public issue; described per the bug report template: **What happened?** While editing an environment's sandbox snapshot in the embedded SSH terminal, pressing Escape (e.g. to exit a mode inside the terminal) closed the entire environment edit modal, discarding the setup session and any unsaved form state. **Expected behavior** Escape inside the terminal or form should not dismiss the environment editor. A heavyweight flow like environment configuration should be a standalone page where Escape behaves as expected within the focused widget. **Steps to reproduce** 1. Open Instance settings → Environments and edit a sandbox environment 2. Start a custom image setup session and focus the browser terminal 3. Press Escape 4. The modal closes and the session context is lost ## What Changed - Converted the environment create/edit dialog in `CompanyEnvironments.tsx` into routed pages at `/company/settings/instance/environments/new` and `/company/settings/instance/environments/:environmentId/edit` - Registered the new routes in `App.tsx` and wired breadcrumbs for the list/create/edit states - Form state now initializes from the route (create vs edit) instead of dialog open/close state, and successful saves navigate back to the environments list - Updated `CompanyEnvironments.test.tsx` and `CompanySettings.test.tsx` to render through a router with the new routes and assert against the routed form page instead of a dialog ## Verification - `pnpm vitest run ui/src/pages/CompanyEnvironments.test.tsx ui/src/pages/CompanySettings.test.tsx` — 22/22 passing - `tsc --noEmit` on the `ui` package — clean - Behavioral coverage: the updated tests exercise the routed create/edit pages end to end (open edit via the list, interact with the setup-session controls on the form page, save navigates back to the list); with the form no longer in a dialog there is no Escape-close handler to trigger ## Risks - Low risk; UI-only routing change. Deep links into the old modal state do not exist (the modal had no URL), so no redirects are needed - The edit page resolves the environment from the route param; a stale/unknown id falls back to the environments list ## Model Used - Claude (Anthropic), model ID `claude-fable-5` (Fable 5), extended thinking enabled, agentic tool use via Claude Code ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Cody <cody@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b15115e05b
commit
0f9b1d399c
|
|
@ -103,6 +103,8 @@ function boardRoutes() {
|
|||
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
|
||||
<Route path="company/settings/instance/general" element={<InstanceGeneralSettings />} />
|
||||
<Route path="company/settings/instance/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/instance/environments/new" element={<CompanyEnvironments mode="create" />} />
|
||||
<Route path="company/settings/instance/environments/:environmentId/edit" element={<CompanyEnvironments mode="edit" />} />
|
||||
<Route path="company/settings/instance/access" element={<InstanceAccess />} />
|
||||
<Route path="company/settings/instance/heartbeats" element={<InstanceSettings />} />
|
||||
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>("button,a")).find((element) => element.textContent?.trim() === label);
|
||||
}
|
||||
|
||||
function editButtons(root: ParentNode): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>("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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<TooltipProvider>
|
||||
<Routes>
|
||||
<Route path={ENVIRONMENTS_PATH} element={<CompanyEnvironments />} />
|
||||
<Route path={`${ENVIRONMENTS_PATH}/new`} element={<CompanyEnvironments mode="create" />} />
|
||||
<Route path={`${ENVIRONMENTS_PATH}/:environmentId/edit`} element={<CompanyEnvironments mode="edit" />} />
|
||||
</Routes>
|
||||
</TooltipProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function createSession(overrides: Record<string, unknown> = {}) {
|
||||
|
|
@ -459,13 +482,7 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
root!.render(renderCompanyEnvironments(queryClient));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
|
|
@ -511,13 +528,7 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
});
|
||||
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
root!.render(renderCompanyEnvironments(queryClient));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
|
|
@ -556,13 +567,7 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
});
|
||||
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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<HTMLInputElement>('input[aria-label="Variable name"]')!;
|
||||
const variableValue = dialog!.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!;
|
||||
const variableName = page!.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!;
|
||||
const variableValue = page!.querySelector<HTMLInputElement>('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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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<HTMLElement>(
|
||||
const terminalScreen = getEnvironmentFormPage()?.querySelector<HTMLElement>(
|
||||
"[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<HTMLElement>(
|
||||
"[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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, EnvBinding>;
|
||||
};
|
||||
|
||||
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<string | null>(null);
|
||||
const isEnvironmentFormPage = mode === "create" || mode === "edit";
|
||||
const editingEnvironmentId = mode === "edit" ? routeEnvironmentId ?? null : null;
|
||||
const [environmentForm, setEnvironmentForm] = useState<EnvironmentFormState>(createEmptyEnvironmentForm);
|
||||
const environmentVariablesEditorRef = useRef<EnvironmentVariablesEditorHandle | null>(null);
|
||||
const initializedFormKeyRef = useRef<string | null>(null);
|
||||
const [probeResults, setProbeResults] = useState<Record<string, EnvironmentProbeResult | null>>({});
|
||||
const [testingEnvironmentId, setTestingEnvironmentId] = useState<string | null>(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 (
|
||||
<div className="max-w-5xl space-y-6" data-testid="instance-settings-environments-section">
|
||||
{!isEnvironmentFormPage ? (
|
||||
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
|
|
@ -1453,13 +1497,12 @@ export function CompanyEnvironments() {
|
|||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleStartCreateEnvironment}>
|
||||
Add environment
|
||||
<Button size="sm" asChild>
|
||||
<Link to={`${ENVIRONMENTS_PATH}/new`}>Add environment</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{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"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditEnvironment(environment)}
|
||||
>
|
||||
{isEditing ? "Editing" : "Edit"}
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link to={environmentEditPath(environment.id)}>Edit</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1538,26 +1577,42 @@ export function CompanyEnvironments() {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={environmentDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setEnvironmentDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
closeEnvironmentDialog();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
|
||||
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
|
||||
<DialogTitle>{editingEnvironmentId ? "Edit environment" : "Add environment"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEnvironmentFormPage && mode === "edit" && environments === undefined ? (
|
||||
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
|
||||
Loading environment...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEnvironmentFormPage && mode === "edit" && environments !== undefined && !editingEnvironment ? (
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4 text-sm">
|
||||
<div className="font-medium">Environment not found</div>
|
||||
<div className="text-muted-foreground">The environment may have been removed or is not available in this company.</div>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={ENVIRONMENTS_PATH}>Back to environments</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEnvironmentFormPage && (mode === "create" || editingEnvironment) ? (
|
||||
<div className="rounded-md border border-border bg-background" data-testid="environment-form-page">
|
||||
<div className="border-b border-border/60 px-6 pb-4 pt-6">
|
||||
<div className="mb-4">
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link to={ENVIRONMENTS_PATH}>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Environments
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold">{editingEnvironmentId ? "Edit environment" : "Add environment"}</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<Field label="Name" hint="Operator-facing name for this execution target.">
|
||||
<input
|
||||
|
|
@ -1798,10 +1853,10 @@ export function CompanyEnvironments() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border/60 bg-background px-6 py-4">
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 bg-background px-6 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeEnvironmentDialog}
|
||||
onClick={closeEnvironmentForm}
|
||||
disabled={environmentMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -1827,9 +1882,9 @@ export function CompanyEnvironments() {
|
|||
? "Save environment"
|
||||
: "Create environment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>("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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<TooltipProvider>
|
||||
<Routes>
|
||||
<Route path={ENVIRONMENTS_PATH} element={<CompanyEnvironments />} />
|
||||
<Route path={`${ENVIRONMENTS_PATH}/new`} element={<CompanyEnvironments mode="create" />} />
|
||||
<Route path={`${ENVIRONMENTS_PATH}/:environmentId/edit`} element={<CompanyEnvironments mode="edit" />} />
|
||||
</Routes>
|
||||
</TooltipProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
root.render(renderCompanyEnvironments(queryClient));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
|
@ -208,29 +238,20 @@ describe("CompanyEnvironments", () => {
|
|||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue