-
Notifications
You must be signed in to change notification settings - Fork 12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix auto-updating of boot disk size when selected image is larger than current disk size; add tests #1829
Fix auto-updating of boot disk size when selected image is larger than current disk size; add tests #1829
Changes from 5 commits
c250f54
107d226
4f2bec0
6885220
74f5a22
ca5abc2
093d59a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,7 +14,6 @@ import { | |
genName, | ||
INSTANCE_MAX_CPU, | ||
INSTANCE_MAX_RAM_GiB, | ||
MAX_DISK_SIZE_GiB, | ||
useApiMutation, | ||
useApiQueryClient, | ||
usePrefetchedApiQuery, | ||
|
@@ -137,9 +136,11 @@ export function CreateInstanceForm() { | |
|
||
const imageInput = useWatch({ control: control, name: 'image' }) | ||
const image = allImages.find((i) => i.id === imageInput) | ||
const imageSize = image?.size ? Math.ceil(image.size / GiB) : undefined | ||
|
||
return ( | ||
<FullPageForm | ||
submitDisabled={allImages.length ? undefined : 'Image required'} | ||
id="create-instance-form" | ||
form={form} | ||
title="Create instance" | ||
|
@@ -151,9 +152,10 @@ export function CreateInstanceForm() { | |
values.presetId === 'custom' | ||
? { memory: values.memory, ncpus: values.ncpus } | ||
: { memory: preset.memory, ncpus: preset.ncpus } | ||
|
||
// we should also never have an image ID that's not in the list | ||
const image = allImages.find((i) => values.image === i.id) | ||
// There should always be an image present, because … | ||
// - The form is disabled unless there are images available. | ||
// - The form defaults to including at least one image. | ||
invariant(image, 'Expected image to be defined') | ||
|
||
const bootDiskName = values.bootDiskName || genName(values.name, image.name) | ||
|
@@ -287,37 +289,51 @@ export function CreateInstanceForm() { | |
<FormDivider /> | ||
|
||
<Form.Heading id="boot-disk">Boot disk</Form.Heading> | ||
<Tabs.Root id="boot-disk-tabs" className="full-width" defaultValue="project"> | ||
<Tabs.Root | ||
id="boot-disk-tabs" | ||
className="full-width" | ||
// default to the project images tab if there are only project images | ||
defaultValue={ | ||
projectImages.length > 0 && siloImages.length === 0 ? 'project' : 'silo' | ||
} | ||
Comment on lines
+295
to
+298
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a nice touch 👍 |
||
> | ||
<Tabs.List aria-describedby="boot-disk"> | ||
<Tabs.Trigger value="project">Project images</Tabs.Trigger> | ||
<Tabs.Trigger value="silo">Silo images</Tabs.Trigger> | ||
<Tabs.Trigger value="project">Project images</Tabs.Trigger> | ||
</Tabs.List> | ||
<Tabs.Content value="project" className="space-y-4"> | ||
{projectImages.length === 0 ? ( | ||
{allImages.length === 0 && ( | ||
<Message | ||
className="mb-8 ml-10 max-w-lg" | ||
variant="notice" | ||
content="Images are required to create a boot disk." | ||
/> | ||
)} | ||
<Tabs.Content value="silo" className="space-y-4"> | ||
{siloImages.length === 0 ? ( | ||
<div className="flex max-w-lg items-center justify-center rounded-lg border p-6 border-default"> | ||
<EmptyMessage | ||
icon={<Images16Icon />} | ||
title="No project images found" | ||
body="An image needs to be uploaded to be seen here" | ||
buttonText="Upload image" | ||
onClick={() => navigate(pb.projectImageNew(projectSelector))} | ||
title="No silo images found" | ||
body="Project images need to be promoted to be seen here" | ||
/> | ||
</div> | ||
) : ( | ||
<ImageSelectField images={projectImages} control={control} /> | ||
<ImageSelectField images={siloImages} control={control} /> | ||
)} | ||
</Tabs.Content> | ||
<Tabs.Content value="silo" className="space-y-4"> | ||
{siloImages.length === 0 ? ( | ||
<Tabs.Content value="project" className="space-y-4"> | ||
{projectImages.length === 0 ? ( | ||
<div className="flex max-w-lg items-center justify-center rounded-lg border p-6 border-default"> | ||
<EmptyMessage | ||
icon={<Images16Icon />} | ||
title="No silo images found" | ||
body="Project images need to be promoted to be seen here" | ||
title="No project images found" | ||
body="An image needs to be uploaded to be seen here" | ||
buttonText="Upload image" | ||
onClick={() => navigate(pb.projectImageNew(projectSelector))} | ||
/> | ||
</div> | ||
) : ( | ||
<ImageSelectField images={siloImages} control={control} /> | ||
<ImageSelectField images={projectImages} control={control} /> | ||
)} | ||
</Tabs.Content> | ||
</Tabs.Root> | ||
|
@@ -328,15 +344,9 @@ export function CreateInstanceForm() { | |
label="Disk size" | ||
name="bootDiskSize" | ||
control={control} | ||
// Imitate API logic: only require that the disk is big enough to fit the image | ||
validate={(diskSizeGiB) => { | ||
if (!image) return true | ||
if (diskSizeGiB < image.size / GiB) { | ||
const minSize = Math.ceil(image.size / GiB) | ||
return `Must be as large as selected image (min. ${minSize} GiB)` | ||
} | ||
if (diskSizeGiB > MAX_DISK_SIZE_GiB) { | ||
return `Can be at most ${MAX_DISK_SIZE_GiB} GiB` | ||
validate={(diskSizeGiB: number) => { | ||
if (imageSize && diskSizeGiB < imageSize) { | ||
return `Must be as large as selected image (min. ${imageSize} GiB)` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought we had also added |
||
} | ||
}} | ||
/> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -128,6 +128,43 @@ test('can create an instance with custom hardware', async ({ page }) => { | |
]) | ||
}) | ||
|
||
test('automatically updates disk size when larger image selected', async ({ page }) => { | ||
await page.goto('/projects/mock-project/instances-new') | ||
|
||
const instanceName = 'my-new-instance' | ||
await page.fill('input[name=name]', instanceName) | ||
|
||
// set the disk size larger than it needs to be, to verify it doesn't get reduced | ||
const diskSizeInput = page.getByRole('textbox', { name: 'Disk size (GiB)' }) | ||
await diskSizeInput.fill('5') | ||
|
||
// pick a disk image that's smaller than 5GiB (the first project image works [4GiB]) | ||
await page.getByRole('tab', { name: 'Project images' }).click() | ||
await page.getByRole('button', { name: 'Image' }).click() | ||
await page.getByRole('option', { name: images[0].name }).click() | ||
|
||
// test that it still says 5, as that's larger than the given image | ||
await expect(diskSizeInput).toHaveValue('5') | ||
|
||
// pick a disk image that's larger than 5GiB (the third project image works [6GiB]) | ||
await page.getByRole('button', { name: 'Image' }).click() | ||
await page.getByRole('option', { name: images[2].name }).click() | ||
|
||
// test that it has been automatically increased to next-largest incremement of 10 | ||
await expect(diskSizeInput).toHaveValue('10') | ||
|
||
// pick another image, just to verify that the diskSizeInput stays as it was | ||
await page.getByRole('button', { name: 'Image' }).click() | ||
await page.getByRole('option', { name: images[1].name }).click() | ||
await expect(diskSizeInput).toHaveValue('10') | ||
|
||
const submitButton = page.getByRole('button', { name: 'Create instance' }) | ||
await submitButton.click() | ||
|
||
await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) | ||
await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB']) | ||
}) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Love the test! |
||
test('with disk name already taken', async ({ page }) => { | ||
await page.goto('/projects/mock-project/instances-new') | ||
await page.fill('input[name=name]', 'my-instance') | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is more of a FYI about React Hook form, not a suggestion, because I can't bring myself to suggest something so much worse than what you have, and I don't think it's really more typesafe in practice. But if you hover on the validate prop, you can see it is using some helpers from RHF to look up the value of the field using the field name.
If you wanted to match that type on the prop, you could do this:
This produces the same result as what you have (the argument is a number, of course) except it also has a second argument, which is all the form values together:
This could be handy in theory because you could validate one field value based on the value of another. But we're not doing that — well, we're doing it, but we're doing it at the call site so this field doesn't have to know about it. So we don't need it, and the resulting code is dramatically harder to understand, plus the second arg is required (lol) so it actually gets mad at you for leaving it out.
All this is to say, I've already typed this so I might as well shoot it into the ether, but it is not a suggestion to use this horrible type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, that's good to know, about, though! Thanks for writing that all out.