Skip to content
Open
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
6 changes: 5 additions & 1 deletion app/components/form/fields/FileField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -30,6 +32,7 @@ export function FileField<
accept,
description,
disabled,
validate,
}: {
id: string
name: TName
Expand All @@ -40,11 +43,12 @@ export function FileField<
accept?: string
description?: string | React.ReactNode
disabled?: boolean
validate?: Validate<FieldPathValue<TFieldValues, TName>, TFieldValues>
}) {
const {
field: { value: _, ...rest },
fieldState: { error },
} = useController({ name, control, rules: { required } })
} = useController({ name, control, rules: { required, validate } })
return (
<div className="max-w-lg">
<div className="mb-2">
Expand Down
11 changes: 10 additions & 1 deletion app/forms/image-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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 && <BootableNotice {...imageValidation} />}
</div>
Expand Down
55 changes: 55 additions & 0 deletions test/e2e/image-upload.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading