From 9e056151a2a2285fb391efda7bb5e0c4e33fc3fa Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 21 Jul 2026 13:19:13 +0100 Subject: [PATCH] feat(cloud): rename custom sandbox env images Add a rename action to the custom sandbox image list so users can update an image's display name without deleting and rebuilding it. Wired through a new updateSandboxCustomImage API client method that PATCHes the existing custom-image detail endpoint, plus an updateMutation hook and a rename AlertDialog mirroring the existing delete confirmation flow. The rename handler swallows the mutation rejection (onError shows the toast), the Enter key respects the pending guard, and the update mutation invalidates the detail cache rather than overwriting it with the PATCH response, so a partial response cannot drop cached fields. --- packages/api-client/src/posthog-client.ts | 28 ++++++ .../src/sandbox-custom-images.test.ts | 35 +++++++ .../CloudEnvironmentsSettings.tsx | 94 +++++++++++++++++++ .../environments/useSandboxCustomImages.ts | 32 +++++++ 4 files changed, 189 insertions(+) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 29c687a4fb..bc33a43596 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4697,6 +4697,34 @@ export class PostHogAPIClient { return (await response.json()) as SandboxCustomImage; } + async updateSandboxCustomImage( + id: string, + input: { name?: string; description?: string }, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`, + overrides: { + body: JSON.stringify(input), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to update sandbox custom image: ${response.statusText}`, + ); + } + return (await response.json()) as SandboxCustomImage; + } + async ensureSandboxCustomImageBuilderTask( id: string, ): Promise { diff --git a/packages/api-client/src/sandbox-custom-images.test.ts b/packages/api-client/src/sandbox-custom-images.test.ts index 22c97e771b..269bec2716 100644 --- a/packages/api-client/src/sandbox-custom-images.test.ts +++ b/packages/api-client/src/sandbox-custom-images.test.ts @@ -69,4 +69,39 @@ describe("PostHogAPIClient sandbox custom images", () => { }), ); }); + + it.each([ + ["name only", { name: "renamed" }], + ["description only", { description: "updated" }], + ["both", { name: "renamed", description: "updated" }], + ])("patches %s via updateSandboxCustomImage", async (_name, input) => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: "im-1", ...input }), + }); + + await makeClient(fetch).updateSandboxCustomImage("im-1", input); + + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "patch", + path: `/api/projects/123/sandbox_custom_images/im-1/`, + overrides: { body: JSON.stringify(input) }, + }), + ); + }); + + it("throws the backend detail message when update fails", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + json: async () => ({ detail: "Name cannot be blank." }), + }); + + await expect( + makeClient(fetch).updateSandboxCustomImage("im-1", { name: " " }), + ).rejects.toThrow("Name cannot be blank."); + }); }); diff --git a/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx b/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx index 3ed5b5c390..2252998d4c 100644 --- a/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx +++ b/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx @@ -296,6 +296,7 @@ export function CloudEnvironmentsSettings() { buildMutation, builderTaskMutation, deleteMutation: deleteImageMutation, + updateMutation: updateImageMutation, } = useSandboxCustomImages(); const handleOpenTask = useHandleOpenTask(); const repoPickerProps = useCloudRepoPicker(); @@ -313,6 +314,10 @@ export function CloudEnvironmentsSettings() { const [specDraft, setSpecDraft] = useState(""); const [deleteConfirmImage, setDeleteConfirmImage] = useState(null); + const [renameImage, setRenameImage] = useState( + null, + ); + const [renameName, setRenameName] = useState(""); const [viewingLogImageId, setViewingLogImageId] = useState( null, ); @@ -456,6 +461,30 @@ export function CloudEnvironmentsSettings() { setDeleteConfirmImage(null); }, [deleteConfirmImage, deleteImageMutation]); + const openRenameImage = useCallback((image: SandboxCustomImage) => { + setRenameImage(image); + setRenameName(image.name); + }, []); + + const handleRenameImage = useCallback(async () => { + if (!renameImage) return; + const name = renameName.trim(); + if (!name) { + toast.error("Name cannot be blank"); + return; + } + if (name === renameImage.name) { + setRenameImage(null); + return; + } + try { + await updateImageMutation.mutateAsync({ id: renameImage.id, name }); + setRenameImage(null); + } catch { + // The mutation's onError callback displays the failure toast. + } + }, [renameImage, renameName, updateImageMutation]); + if (isFormOpen) { return ( @@ -1000,6 +1029,18 @@ export function CloudEnvironmentsSettings() { > Save & build + + + + + + )} diff --git a/packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts b/packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts index c780e55b8d..aa17d0d308 100644 --- a/packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts +++ b/packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts @@ -143,6 +143,37 @@ export function useSandboxCustomImages() { }, ); + const updateMutation = useAuthenticatedMutation( + ( + client, + { + id, + ...input + }: { id: string } & { + name?: string; + description?: string; + }, + ) => client.updateSandboxCustomImage(id, input), + { + onSuccess: (image) => { + toast.success("Custom image updated"); + // Invalidate rather than setQueryData: the PATCH response may not carry + // every field a detail view depends on, so force a refetch to repopulate + // the full image instead of overwriting the cache with a partial object. + queryClient.invalidateQueries({ + queryKey: sandboxCustomImageKeys.detail(image.id), + }); + queryClient.invalidateQueries({ + queryKey: sandboxCustomImageKeys.list, + }); + queryClient.invalidateQueries({ queryKey: sandboxEnvKeys.list }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update custom image"); + }, + }, + ); + return { images: images ?? [], isLoading, @@ -152,5 +183,6 @@ export function useSandboxCustomImages() { buildMutation, builderTaskMutation, deleteMutation, + updateMutation, }; }