Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4697,6 +4697,34 @@ export class PostHogAPIClient {
return (await response.json()) as SandboxCustomImage;
}

async updateSandboxCustomImage(
id: string,
input: { name?: string; description?: string },
): Promise<SandboxCustomImage> {
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<SandboxCustomImage> {
Expand Down
35 changes: 35 additions & 0 deletions packages/api-client/src/sandbox-custom-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export function CloudEnvironmentsSettings() {
buildMutation,
builderTaskMutation,
deleteMutation: deleteImageMutation,
updateMutation: updateImageMutation,
} = useSandboxCustomImages();
const handleOpenTask = useHandleOpenTask();
const repoPickerProps = useCloudRepoPicker();
Expand All @@ -313,6 +314,10 @@ export function CloudEnvironmentsSettings() {
const [specDraft, setSpecDraft] = useState("");
const [deleteConfirmImage, setDeleteConfirmImage] =
useState<SandboxCustomImage | null>(null);
const [renameImage, setRenameImage] = useState<SandboxCustomImage | null>(
null,
);
const [renameName, setRenameName] = useState("");
const [viewingLogImageId, setViewingLogImageId] = useState<string | null>(
null,
);
Expand Down Expand Up @@ -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 (
<Flex direction="column" gap="4">
Expand Down Expand Up @@ -1000,6 +1029,18 @@ export function CloudEnvironmentsSettings() {
>
Save & build
</Button>
<Button
size="1"
variant="ghost"
color="gray"
onClick={() => openRenameImage(image)}
disabled={
deleteImageMutation.isPending ||
updateImageMutation.isPending
}
>
<PencilSimple size={14} />
</Button>
<Button
size="1"
variant="ghost"
Expand Down Expand Up @@ -1090,6 +1131,59 @@ export function CloudEnvironmentsSettings() {
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root
open={renameImage !== null}
onOpenChange={(open) => {
if (!open) setRenameImage(null);
}}
>
<AlertDialog.Content maxWidth="420px" size="1">
<AlertDialog.Title className="text-sm">
Rename custom image
</AlertDialog.Title>
<AlertDialog.Description>
<Text color="gray" className="text-[13px]">
Updates the display name. The build spec and status are
unchanged.
</Text>
</AlertDialog.Description>
<Flex direction="column" gap="1" mt="3">
<Text className="font-medium text-[13px]">Name</Text>
<TextField.Root
size="2"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder="Image name"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !updateImageMutation.isPending) {
e.preventDefault();
void handleRenameImage();
}
}}
Comment thread
tatoalo marked this conversation as resolved.
/>
</Flex>
<Flex justify="end" gap="3" mt="3">
<AlertDialog.Cancel>
<Button variant="soft" color="gray" size="1">
Cancel
</Button>
</AlertDialog.Cancel>
<Button
variant="solid"
size="1"
onClick={() => void handleRenameImage()}
loading={updateImageMutation.isPending}
disabled={
!renameName.trim() ||
renameName.trim() === renameImage?.name
}
>
Save
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
</>
)}
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -152,5 +183,6 @@ export function useSandboxCustomImages() {
buildMutation,
builderTaskMutation,
deleteMutation,
updateMutation,
};
}
Loading