diff --git a/app/components/form/fields/FileField.tsx b/app/components/form/fields/FileField.tsx index a3e396a14..336704be8 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/forms/image-upload.tsx b/app/forms/image-upload.tsx index facc6b28e..a0f4e6086 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') @@ -605,6 +607,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 cfb3f97e0..17c89a417 100644 --- a/test/e2e/image-upload.e2e.ts +++ b/test/e2e/image-upload.e2e.ts @@ -101,6 +101,61 @@ 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 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', + 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 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() + }) + test('form validation', async ({ page, browserName }) => { // eslint-disable-next-line playwright/no-skipped-test test.skip(browserName === 'webkit', 'safari. stop this')