Skip to content

Commit 97f151a

Browse files
authored
feat(cloud): rename custom sandbox env images (#3617)
1 parent b62749f commit 97f151a

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)