From 7982a32d363c9417b47e4a6ceb41a60e1129d78f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 16 Apr 2026 12:14:14 -0500 Subject: [PATCH 1/3] validate image size is a multiple of block size --- app/components/form/fields/FileField.tsx | 6 ++- app/components/form/fields/RadioField.tsx | 7 +++- app/forms/image-upload.tsx | 13 ++++++- test/e2e/image-upload.e2e.ts | 46 +++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/app/components/form/fields/FileField.tsx b/app/components/form/fields/FileField.tsx index a3e396a147..336704be86 100644 --- a/app/components/form/fields/FileField.tsx +++ b/app/components/form/fields/FileField.tsx @@ -9,7 +9,9 @@ import { useController, type Control, type FieldPath, + type FieldPathValue, type FieldValues, + type Validate, } from 'react-hook-form' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -30,6 +32,7 @@ export function FileField< accept, description, disabled, + validate, }: { id: string name: TName @@ -40,11 +43,12 @@ export function FileField< accept?: string description?: string | React.ReactNode disabled?: boolean + validate?: Validate, TFieldValues> }) { const { field: { value: _, ...rest }, fieldState: { error }, - } = useController({ name, control, rules: { required } }) + } = useController({ name, control, rules: { required, validate } }) return (
diff --git a/app/components/form/fields/RadioField.tsx b/app/components/form/fields/RadioField.tsx index 10e3b316cd..3e61d61a75 100644 --- a/app/components/form/fields/RadioField.tsx +++ b/app/components/form/fields/RadioField.tsx @@ -13,6 +13,7 @@ import { type FieldPath, type FieldValues, type PathValue, + type RegisterOptions, } from 'react-hook-form' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -41,6 +42,9 @@ export type RadioFieldProps< units?: string control: Control items: { value: PathValue; label: string }[] + /** Forwarded to react-hook-form's `useController`; use `deps` to trigger + * validation on other fields when this one changes. */ + rules?: Pick, 'deps'> } & (PathValue extends string // this is wild lmao ? { parseValue?: never } : { @@ -63,10 +67,11 @@ export function RadioField< control, items, parseValue, + rules, ...props }: RadioFieldProps) { const id = useId() - const { field } = useController({ name, control }) + const { field } = useController({ name, control, rules }) return (
diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index facc6b28ea..f7bfe1f122 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -495,7 +495,9 @@ export default function ImageCreate() { setAllDone(true) } - const form = useForm({ defaultValues }) + // onChange mode so the file-size / block-size cross-validation surfaces + // inline as soon as the user picks a file or changes block size + const form = useForm({ defaultValues, mode: 'onChange' }) const file = form.watch('imageFile') const blockSize = form.watch('blockSize') @@ -590,6 +592,8 @@ export default function ImageCreate() { units="Bytes" control={form.control} parseValue={(val) => parseInt(val, 10) as BlockSize} + // re-run imageFile validation when block size changes + rules={{ deps: ['imageFile'] }} items={[ { label: '512', value: 512 }, { label: '2048', value: 2048 }, @@ -605,6 +609,13 @@ export default function ImageCreate() { label="Image file" required control={form.control} + // Crucible rejects bulk-write imports whose total size isn't a + // multiple of the block size, so catch it before the long upload. + validate={(f, { blockSize }) => { + if (f && f.size % blockSize !== 0) { + return `File size must be a multiple of the block size (${blockSize} bytes)` + } + }} /> {imageValidation && }
diff --git a/test/e2e/image-upload.e2e.ts b/test/e2e/image-upload.e2e.ts index cfb3f97e03..d1e46898b3 100644 --- a/test/e2e/image-upload.e2e.ts +++ b/test/e2e/image-upload.e2e.ts @@ -101,6 +101,52 @@ test.describe('Image upload', () => { // TODO: changing name alone should cause error to disappear }) + test('block size validation', async ({ page, browserName }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(browserName === 'webkit', 'safari. stop this') + + await page.goto('/projects/mock-project/images-new') + + await page.getByRole('textbox', { name: 'Name' }).fill('new-image') + await page.getByRole('textbox', { name: 'Description' }).fill('image description') + await page.getByRole('textbox', { name: 'OS' }).fill('Ubuntu') + await page.getByRole('textbox', { name: 'Version' }).fill('Dapper Drake') + + const sideModal = page.getByRole('dialog', { name: 'Upload image' }) + const uploadError = sideModal.getByText(/must be a multiple of the block size/i) + const submit = page.getByRole('button', { name: 'Upload image' }) + const progressModal = page.getByRole('dialog', { name: 'Image upload progress' }) + + // 1000 bytes is not a multiple of any supported block size (512/2048/4096) + await page.getByLabel('Image file').setInputFiles({ + name: 'my-image.iso', + mimeType: 'application/octet-stream', + buffer: Buffer.alloc(1000, 'a'), + }) + + await expect(uploadError).toBeVisible() + + // clicking submit does nothing — validation blocks it and the progress + // modal never opens + await submit.click() + await expect(progressModal).toBeHidden() + await expect(uploadError).toBeVisible() + + // replace with an aligned file — error clears + await page.getByLabel('Image file').setInputFiles({ + name: 'my-image.iso', + mimeType: 'application/octet-stream', + buffer: Buffer.alloc(2048, 'a'), + }) + + await expect(uploadError).toBeHidden() + + // switching block size to one that no longer divides the file brings it + // back (exercises the cross-field `deps` re-validation path) + await page.getByLabel('4096').click() + await expect(uploadError).toBeVisible() + }) + test('form validation', async ({ page, browserName }) => { // eslint-disable-next-line playwright/no-skipped-test test.skip(browserName === 'webkit', 'safari. stop this') From d5e1ec62a48add0e32b94448b552efd1e9f68b75 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 17 Apr 2026 12:54:59 -0500 Subject: [PATCH 2/3] don't show image required when you change block size --- app/components/form/fields/RadioField.tsx | 7 +------ app/forms/image-upload.tsx | 2 -- test/e2e/image-upload.e2e.ts | 13 +++++++++++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/components/form/fields/RadioField.tsx b/app/components/form/fields/RadioField.tsx index 3e61d61a75..10e3b316cd 100644 --- a/app/components/form/fields/RadioField.tsx +++ b/app/components/form/fields/RadioField.tsx @@ -13,7 +13,6 @@ import { type FieldPath, type FieldValues, type PathValue, - type RegisterOptions, } from 'react-hook-form' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -42,9 +41,6 @@ export type RadioFieldProps< units?: string control: Control items: { value: PathValue; label: string }[] - /** Forwarded to react-hook-form's `useController`; use `deps` to trigger - * validation on other fields when this one changes. */ - rules?: Pick, 'deps'> } & (PathValue extends string // this is wild lmao ? { parseValue?: never } : { @@ -67,11 +63,10 @@ export function RadioField< control, items, parseValue, - rules, ...props }: RadioFieldProps) { const id = useId() - const { field } = useController({ name, control, rules }) + const { field } = useController({ name, control }) return (
diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index f7bfe1f122..a0f4e60864 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -592,8 +592,6 @@ export default function ImageCreate() { units="Bytes" control={form.control} parseValue={(val) => parseInt(val, 10) as BlockSize} - // re-run imageFile validation when block size changes - rules={{ deps: ['imageFile'] }} items={[ { label: '512', value: 512 }, { label: '2048', value: 2048 }, diff --git a/test/e2e/image-upload.e2e.ts b/test/e2e/image-upload.e2e.ts index d1e46898b3..17c89a4174 100644 --- a/test/e2e/image-upload.e2e.ts +++ b/test/e2e/image-upload.e2e.ts @@ -114,9 +114,16 @@ test.describe('Image upload', () => { const sideModal = page.getByRole('dialog', { name: 'Upload image' }) const uploadError = sideModal.getByText(/must be a multiple of the block size/i) + const fileRequired = sideModal.getByText('Image file is required') const submit = page.getByRole('button', { name: 'Upload image' }) const progressModal = page.getByRole('dialog', { name: 'Image upload progress' }) + // with no file picked, changing block size should not trigger a required + // error on the file field + await page.getByLabel('4096').click() + await expect(fileRequired).toBeHidden() + await page.getByLabel('512').click() + // 1000 bytes is not a multiple of any supported block size (512/2048/4096) await page.getByLabel('Image file').setInputFiles({ name: 'my-image.iso', @@ -141,9 +148,11 @@ test.describe('Image upload', () => { await expect(uploadError).toBeHidden() - // switching block size to one that no longer divides the file brings it - // back (exercises the cross-field `deps` re-validation path) + // switching block size to one that no longer divides the file doesn't + // surface the error live, but submit-time validation still catches it await page.getByLabel('4096').click() + await submit.click() + await expect(progressModal).toBeHidden() await expect(uploadError).toBeVisible() }) From 2e1ef89da371cf50eb9ec7cbd18a684480929d7a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 27 Apr 2026 18:20:20 -0500 Subject: [PATCH 3/3] comment tweak --- app/forms/image-upload.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index a0f4e60864..a55dfa9a33 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -495,8 +495,8 @@ export default function ImageCreate() { setAllDone(true) } - // onChange mode so the file-size / block-size cross-validation surfaces - // inline as soon as the user picks a file or changes block size + // Surface file validation as soon as the user picks a file. Block-size + // changes are still validated on submit. const form = useForm({ defaultValues, mode: 'onChange' }) const file = form.watch('imageFile') const blockSize = form.watch('blockSize')