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, }; }