Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 9e05615

Browse files
authored
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.
1 parent 68adf20 commit 9e05615

4 files changed

Lines changed: 189 additions & 0 deletions

File tree

‎packages/api-client/src/posthog-client.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4697,6 +4697,34 @@ export class PostHogAPIClient {
46974697
return (await response.json()) as SandboxCustomImage;
46984698
}
46994699

4700+
async updateSandboxCustomImage(
4701+
id: string,
4702+
input: { name?: string; description?: string },
4703+
): Promise<SandboxCustomImage> {
4704+
const teamId = await this.getTeamId();
4705+
const url = new URL(
4706+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4707+
);
4708+
const response = await this.api.fetcher.fetch({
4709+
method: "patch",
4710+
url,
4711+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4712+
overrides: {
4713+
body: JSON.stringify(input),
4714+
},
4715+
});
4716+
if (!response.ok) {
4717+
const errorData = (await response.json().catch(() => ({}))) as {
4718+
detail?: string;
4719+
};
4720+
throw new Error(
4721+
errorData.detail ??
4722+
`Failed to update sandbox custom image: ${response.statusText}`,
4723+
);
4724+
}
4725+
return (await response.json()) as SandboxCustomImage;
4726+
}
4727+
47004728
async ensureSandboxCustomImageBuilderTask(
47014729
id: string,
47024730
): Promise<SandboxCustomImage> {

‎packages/api-client/src/sandbox-custom-images.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,39 @@ describe("PostHogAPIClient sandbox custom images", () => {
6969
}),
7070
);
7171
});
72+
73+
it.each([
74+
["name only", { name: "renamed" }],
75+
["description only", { description: "updated" }],
76+
["both", { name: "renamed", description: "updated" }],
77+
])("patches %s via updateSandboxCustomImage", async (_name, input) => {
78+
const fetch = vi.fn().mockResolvedValue({
79+
ok: true,
80+
status: 200,
81+
json: async () => ({ id: "im-1", ...input }),
82+
});
83+
84+
await makeClient(fetch).updateSandboxCustomImage("im-1", input);
85+
86+
expect(fetch).toHaveBeenCalledWith(
87+
expect.objectContaining({
88+
method: "patch",
89+
path: `/api/projects/123/sandbox_custom_images/im-1/`,
90+
overrides: { body: JSON.stringify(input) },
91+
}),
92+
);
93+
});
94+
95+
it("throws the backend detail message when update fails", async () => {
96+
const fetch = vi.fn().mockResolvedValue({
97+
ok: false,
98+
status: 400,
99+
statusText: "Bad Request",
100+
json: async () => ({ detail: "Name cannot be blank." }),
101+
});
102+
103+
await expect(
104+
makeClient(fetch).updateSandboxCustomImage("im-1", { name: " " }),
105+
).rejects.toThrow("Name cannot be blank.");
106+
});
72107
});

‎packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ export function CloudEnvironmentsSettings() {
296296
buildMutation,
297297
builderTaskMutation,
298298
deleteMutation: deleteImageMutation,
299+
updateMutation: updateImageMutation,
299300
} = useSandboxCustomImages();
300301
const handleOpenTask = useHandleOpenTask();
301302
const repoPickerProps = useCloudRepoPicker();
@@ -313,6 +314,10 @@ export function CloudEnvironmentsSettings() {
313314
const [specDraft, setSpecDraft] = useState("");
314315
const [deleteConfirmImage, setDeleteConfirmImage] =
315316
useState<SandboxCustomImage | null>(null);
317+
const [renameImage, setRenameImage] = useState<SandboxCustomImage | null>(
318+
null,
319+
);
320+
const [renameName, setRenameName] = useState("");
316321
const [viewingLogImageId, setViewingLogImageId] = useState<string | null>(
317322
null,
318323
);
@@ -456,6 +461,30 @@ export function CloudEnvironmentsSettings() {
456461
setDeleteConfirmImage(null);
457462
}, [deleteConfirmImage, deleteImageMutation]);
458463

464+
const openRenameImage = useCallback((image: SandboxCustomImage) => {
465+
setRenameImage(image);
466+
setRenameName(image.name);
467+
}, []);
468+
469+
const handleRenameImage = useCallback(async () => {
470+
if (!renameImage) return;
471+
const name = renameName.trim();
472+
if (!name) {
473+
toast.error("Name cannot be blank");
474+
return;
475+
}
476+
if (name === renameImage.name) {
477+
setRenameImage(null);
478+
return;
479+
}
480+
try {
481+
await updateImageMutation.mutateAsync({ id: renameImage.id, name });
482+
setRenameImage(null);
483+
} catch {
484+
// The mutation's onError callback displays the failure toast.
485+
}
486+
}, [renameImage, renameName, updateImageMutation]);
487+
459488
if (isFormOpen) {
460489
return (
461490
<Flex direction="column" gap="4">
@@ -1000,6 +1029,18 @@ export function CloudEnvironmentsSettings() {
10001029
>
10011030
Save & build
10021031
</Button>
1032+
<Button
1033+
size="1"
1034+
variant="ghost"
1035+
color="gray"
1036+
onClick={() => openRenameImage(image)}
1037+
disabled={
1038+
deleteImageMutation.isPending ||
1039+
updateImageMutation.isPending
1040+
}
1041+
>
1042+
<PencilSimple size={14} />
1043+
</Button>
10031044
<Button
10041045
size="1"
10051046
variant="ghost"
@@ -1090,6 +1131,59 @@ export function CloudEnvironmentsSettings() {
10901131
</Flex>
10911132
</AlertDialog.Content>
10921133
</AlertDialog.Root>
1134+
<AlertDialog.Root
1135+
open={renameImage !== null}
1136+
onOpenChange={(open) => {
1137+
if (!open) setRenameImage(null);
1138+
}}
1139+
>
1140+
<AlertDialog.Content maxWidth="420px" size="1">
1141+
<AlertDialog.Title className="text-sm">
1142+
Rename custom image
1143+
</AlertDialog.Title>
1144+
<AlertDialog.Description>
1145+
<Text color="gray" className="text-[13px]">
1146+
Updates the display name. The build spec and status are
1147+
unchanged.
1148+
</Text>
1149+
</AlertDialog.Description>
1150+
<Flex direction="column" gap="1" mt="3">
1151+
<Text className="font-medium text-[13px]">Name</Text>
1152+
<TextField.Root
1153+
size="2"
1154+
value={renameName}
1155+
onChange={(e) => setRenameName(e.target.value)}
1156+
placeholder="Image name"
1157+
autoFocus
1158+
onKeyDown={(e) => {
1159+
if (e.key === "Enter" && !updateImageMutation.isPending) {
1160+
e.preventDefault();
1161+
void handleRenameImage();
1162+
}
1163+
}}
1164+
/>
1165+
</Flex>
1166+
<Flex justify="end" gap="3" mt="3">
1167+
<AlertDialog.Cancel>
1168+
<Button variant="soft" color="gray" size="1">
1169+
Cancel
1170+
</Button>
1171+
</AlertDialog.Cancel>
1172+
<Button
1173+
variant="solid"
1174+
size="1"
1175+
onClick={() => void handleRenameImage()}
1176+
loading={updateImageMutation.isPending}
1177+
disabled={
1178+
!renameName.trim() ||
1179+
renameName.trim() === renameImage?.name
1180+
}
1181+
>
1182+
Save
1183+
</Button>
1184+
</Flex>
1185+
</AlertDialog.Content>
1186+
</AlertDialog.Root>
10931187
</>
10941188
)}
10951189
</Flex>

‎packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,37 @@ export function useSandboxCustomImages() {
143143
},
144144
);
145145

146+
const updateMutation = useAuthenticatedMutation(
147+
(
148+
client,
149+
{
150+
id,
151+
...input
152+
}: { id: string } & {
153+
name?: string;
154+
description?: string;
155+
},
156+
) => client.updateSandboxCustomImage(id, input),
157+
{
158+
onSuccess: (image) => {
159+
toast.success("Custom image updated");
160+
// Invalidate rather than setQueryData: the PATCH response may not carry
161+
// every field a detail view depends on, so force a refetch to repopulate
162+
// the full image instead of overwriting the cache with a partial object.
163+
queryClient.invalidateQueries({
164+
queryKey: sandboxCustomImageKeys.detail(image.id),
165+
});
166+
queryClient.invalidateQueries({
167+
queryKey: sandboxCustomImageKeys.list,
168+
});
169+
queryClient.invalidateQueries({ queryKey: sandboxEnvKeys.list });
170+
},
171+
onError: (error: Error) => {
172+
toast.error(error.message || "Failed to update custom image");
173+
},
174+
},
175+
);
176+
146177
return {
147178
images: images ?? [],
148179
isLoading,
@@ -152,5 +183,6 @@ export function useSandboxCustomImages() {
152183
buildMutation,
153184
builderTaskMutation,
154185
deleteMutation,
186+
updateMutation,
155187
};
156188
}

0 commit comments

Comments
 (0)