diff --git a/apps/fastvideo_studio/src/components/jobs/CreateJobModal.test.tsx b/apps/fastvideo_studio/src/components/jobs/CreateJobModal.test.tsx index ca8d8d366..a2ae5517d 100644 --- a/apps/fastvideo_studio/src/components/jobs/CreateJobModal.test.tsx +++ b/apps/fastvideo_studio/src/components/jobs/CreateJobModal.test.tsx @@ -50,6 +50,57 @@ function renderModal( } describe('CreateJobModal', () => { + it('shows a model loading error instead of an empty model list', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(getModels).mockRejectedValueOnce(new Error('network down')); + + renderModal(); + + expect( + await screen.findByText(/Models could not be loaded/), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Model')).toHaveAttribute( + 'aria-invalid', + 'true', + ); + }); + + it('keeps the form open and reports job creation failures', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(createJob).mockRejectedValueOnce(new Error('API rejected job')); + const user = userEvent.setup(); + const { onClose, onSuccess } = renderModal(); + + await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' }); + await user.type(screen.getByLabelText('Prompt'), 'a careful test prompt'); + await user.click(screen.getByRole('button', { name: 'Create Job' })); + + expect( + await screen.findByText(/API rejected job.*then try again/), + ).toBeInTheDocument(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('reports image upload failures next to the file input', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(uploadImage).mockRejectedValueOnce(new Error('Upload failed')); + const user = userEvent.setup(); + renderModal({ workloadType: 'i2v' }); + + await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' }); + const input = screen.getByLabelText('Image'); + await user.upload( + input, + new File(['image'], 'input.png', { type: 'image/png' }), + ); + + expect( + await screen.findByText(/Upload failed.*Choose the image again/), + ).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + it('renders the form fields for an inference job', async () => { renderModal(); diff --git a/apps/fastvideo_studio/src/components/jobs/CreateJobModal.tsx b/apps/fastvideo_studio/src/components/jobs/CreateJobModal.tsx index c48c93fae..1abccf4a6 100644 --- a/apps/fastvideo_studio/src/components/jobs/CreateJobModal.tsx +++ b/apps/fastvideo_studio/src/components/jobs/CreateJobModal.tsx @@ -103,6 +103,17 @@ export default function CreateJobModal({ const [fakeScoreModelPath, setFakeScoreModelPath] = React.useState(''); const [isSubmitting, setIsSubmitting] = React.useState(false); const [isLoadingModels, setIsLoadingModels] = React.useState(false); + const [isLoadingDatasets, setIsLoadingDatasets] = React.useState(false); + const [modelLoadError, setModelLoadError] = React.useState( + null, + ); + const [datasetLoadError, setDatasetLoadError] = React.useState( + null, + ); + const [imageUploadError, setImageUploadError] = React.useState( + null, + ); + const [submitError, setSubmitError] = React.useState(null); const imageInputRef = React.useRef(null); // Seed field values from the persisted default options each time the modal @@ -144,6 +155,10 @@ export default function CreateJobModal({ setImageFileName(''); setSelectedDatasetId(''); setSelectedValidationDatasetId(''); + setModelLoadError(null); + setDatasetLoadError(null); + setImageUploadError(null); + setSubmitError(null); if (workloadType === 'dmd_t2v') { setDmdUseVsa(false); setDmdVsaSparsity(0.8); @@ -162,6 +177,7 @@ export default function CreateJobModal({ // can't overwrite the current workload's model list/selection. let stale = false; setIsLoadingModels(true); + setModelLoadError(null); getModels(inferenceWorkload) .then((list) => { if (stale) return; @@ -180,7 +196,13 @@ export default function CreateJobModal({ } }) .catch((e) => { - if (!stale) console.error('Failed to load models:', e); + if (stale) return; + console.error('Failed to load models:', e); + setModels([]); + setModelId(''); + setModelLoadError( + 'Models could not be loaded. Check the Studio API and reopen this form to try again.', + ); }) .finally(() => { if (!stale) setIsLoadingModels(false); @@ -193,11 +215,22 @@ export default function CreateJobModal({ // Training jobs need a dataset; load the ready datasets when relevant. React.useEffect(() => { if (isOpen && !isInference) { + setIsLoadingDatasets(true); + setDatasetLoadError(null); getDatasets() .then(setReadyDatasets) - .catch(() => setReadyDatasets([])); + .catch((error) => { + console.error('Failed to load datasets:', error); + setReadyDatasets([]); + setDatasetLoadError( + 'Datasets could not be loaded. Check the Studio API and reopen this form to try again.', + ); + }) + .finally(() => setIsLoadingDatasets(false)); } else { setReadyDatasets([]); + setIsLoadingDatasets(false); + setDatasetLoadError(null); } }, [isOpen, isInference]); @@ -206,16 +239,24 @@ export default function CreateJobModal({ if (!file) { setImagePath(''); setImageFileName(''); + setImageUploadError(null); return; } setIsUploadingImage(true); setImageFileName(file.name); + setImageUploadError(null); try { const { path } = await uploadImage(file); setImagePath(path); - } catch { + } catch (error) { + console.error('Failed to upload image:', error); setImagePath(''); setImageFileName(''); + setImageUploadError( + error instanceof Error + ? `${error.message}. Choose the image again to retry.` + : 'The image could not be uploaded. Choose it again to retry.', + ); } finally { setIsUploadingImage(false); } @@ -224,6 +265,7 @@ export default function CreateJobModal({ function clearImage() { setImagePath(''); setImageFileName(''); + setImageUploadError(null); if (imageInputRef.current) imageInputRef.current.value = ''; } @@ -239,6 +281,7 @@ export default function CreateJobModal({ workloadType === 'lora_t2v' ? 'lora' : jobType ) as JobType; setIsSubmitting(true); + setSubmitError(null); try { const payload: CreateJobRequest = { model_id: modelId, @@ -296,6 +339,11 @@ export default function CreateJobModal({ onClose(); } catch (err) { console.error('Failed to create job:', err); + setSubmitError( + err instanceof Error + ? `${err.message}. Check the form and Studio API, then try again.` + : 'The job could not be created. Check the form and Studio API, then try again.', + ); } finally { setIsSubmitting(false); } @@ -343,7 +391,11 @@ export default function CreateJobModal({ value={modelId} onChange={(e) => setModelId(e.target.value)} required - disabled={isSubmitting || isLoadingModels} + aria-describedby={ + modelLoadError ? 'modal-model-error' : undefined + } + aria-invalid={modelLoadError ? true : undefined} + disabled={isSubmitting || isLoadingModels || !!modelLoadError} > ))} + {modelLoadError && ( + + )} {isInference && workloadType === 'i2v' && ( @@ -369,6 +430,10 @@ export default function CreateJobModal({ accept=".png,.jpg,.jpeg,.webp,.bmp" onChange={handleImageChange} disabled={isSubmitting || isUploadingImage} + aria-describedby={ + imageUploadError ? 'modal-image-error' : undefined + } + aria-invalid={imageUploadError ? true : undefined} required className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground" /> @@ -385,6 +450,15 @@ export default function CreateJobModal({ )} + {imageUploadError && ( + + )} )} @@ -432,12 +506,22 @@ export default function CreateJobModal({ id="modal-dataset" value={selectedDatasetId} onChange={(e) => setSelectedDatasetId(e.target.value)} - disabled={isSubmitting} + aria-describedby={ + datasetLoadError ? 'modal-dataset-error' : undefined + } + aria-invalid={datasetLoadError ? true : undefined} + disabled={ + isSubmitting || isLoadingDatasets || !!datasetLoadError + } > {readyDatasets.map((d) => ( ))} + {datasetLoadError && ( + + )} setSelectedValidationDatasetId(e.target.value) } - disabled={isSubmitting} + disabled={ + isSubmitting || isLoadingDatasets || !!datasetLoadError + } > {readyDatasets.map((d) => ( @@ -811,9 +906,22 @@ export default function CreateJobModal({ )} -
-