Reputation: 3159
I'm using react-hook-form in my React application to manage form state, and I'm having trouble with a File input MuiFileInput. When a user selects a file, the form doesn't recognize that the file has changed, so the "Save" button remains disabled.
Here is the relevant part of my form:
/* eslint-disable no-console */
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import _ from 'lodash';
import { Controller, useForm } from 'react-hook-form';
import Box from '@mui/system/Box';
import FuseLoading from '@fuse/core/FuseLoading';
import FuseSvgIcon from '@fuse/core/FuseSvgIcon';
import { Button, CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useQuery, useMutation } from '@apollo/client';
import { sanitizeInputAgainstDefaults, urlToFile } from 'app/utils';
import { SUBMISSION_STATUS, SUPPORTED_SONG_FORMAT } from 'app/constants';
import { Song, SongQueryResponse, CreateOneSongResponse, UpdateOneSongResponse } from 'app/types';
import history from '@history';
import { yupResolver } from '@hookform/resolvers/yup';
import { useAppSelector } from 'app/store/hooks';
import { selectUser } from 'src/app/auth/user/store/userSlice';
import { showMessage } from '@fuse/core/FuseMessage/fuseMessageSlice';
import { MuiFileInput } from 'mui-file-input';
import { CREATE_SONG, DELETE_SONG, UPDATE_SONG } from '../../../../graphql/mutations';
import { GET_SONG, GET_SONG_VARIABLES, GET_SONGS, GET_SONGS_VARIABLES } from '../../../../graphql/queries';
import SongModel from './models/SongModel';
import { schema } from './models/schema';
export default function SongForm() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { artistId, albumId, songId } = useParams();
const { t } = useTranslation('songsPage');
const handleNavigation = () => navigate(`/apps/songs/${artistId}/${albumId}`);
const user = useAppSelector(selectUser);
const isNewForm = songId === 'new';
const {
data: songData,
loading: songLoading,
error
} = useQuery<SongQueryResponse>(GET_SONG, {
variables: GET_SONG_VARIABLES(songId),
skip: isNewForm
});
const [submissionStatus, setSubmissionStatus] = useState<
(typeof SUBMISSION_STATUS)[keyof typeof SUBMISSION_STATUS]
>(SUBMISSION_STATUS.IDLE);
const [files, setFiles] = useState({
mp3FileUrl: {
originalMP3URL: '',
originalHLSURL: '',
selectedFile: null as File | null,
hasChanged: false
}
});
const fileMappings = [
{
key: 'mp3FileUrl',
formMP3Key: 'mp3_file_url',
formHLSKey: 'hls_url',
folder: 'songs'
}
];
const { control, watch, reset, handleSubmit, formState, trigger, setValue } = useForm<Song>({
mode: 'all',
resolver: yupResolver(schema)
});
const { isDirty, isValid, dirtyFields, errors } = formState;
const form = watch();
useEffect(() => {
const fetchDataAndSetState = async () => {
if (isNewForm) {
reset(SongModel({}));
} else if (!songLoading && songData && songData.song) {
const sanitizedInput = sanitizeInputAgainstDefaults(songData.song, SongModel({}));
setFilesFromSanitizedInput(sanitizedInput);
if (songData.song.mp3_file_url) {
const file = await urlToFile(songData.song.mp3_file_url);
console.log('Converted File:', file); // Log the file details
sanitizedInput.file = file;
}
// setSong(sanitizedInput);
reset({ ...sanitizedInput });
// trigger();
}
};
const setFilesFromSanitizedInput = (input: Partial<Song>) => {
setFiles((prevFiles) => ({
...prevFiles,
mp3FileUrl: {
...prevFiles.mp3FileUrl,
originalMP3URL: input.mp3_file_url || '',
originalHLSURL: input.hls_url || ''
}
}));
};
fetchDataAndSetState();
}, [isNewForm, songId, songLoading, songData, reset]);
const onSubmit = async () => {};
const handleRemoveSong = async () => {};
if (error && songId !== 'new') {
setTimeout(() => {
handleNavigation();
dispatch(showMessage({ message: 'NOT FOUND' }));
}, 0);
return null;
}
if (_.isEmpty(form) || (!isNewForm && songLoading)) {
return <FuseLoading className="min-h-screen" />;
}
const handleChange = async (file: File) => {
setFiles((prevFiles) => ({
...prevFiles,
mp3FileUrl: {
...prevFiles.mp3FileUrl,
selectedFile: file || null,
hasChanged: true
}
}));
setValue('file', file, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
await trigger();
};
return (
<>
<div className="relative flex flex-col flex-auto items-center px-24 sm:px-48">
<Controller
name="file"
control={control}
render={({ field, fieldState }) => (
<MuiFileInput
className="mt-32"
{...field}
label={t('FILE_URL_LABEL')}
placeholder={t('FILE_URL_PLACEHOLDER')}
variant="outlined"
required
fullWidth
helperText={fieldState.error ? fieldState.error.message : 'Accepted formats: .mp3'}
error={fieldState.invalid}
inputProps={{
accept: SUPPORTED_SONG_FORMAT
}}
onChange={handleChange}
/>
)}
/>
</div>
<Box
className="flex items-center mt-40 py-14 pr-16 pl-4 sm:pr-48 sm:pl-36 border-t"
sx={{ backgroundColor: 'background.default' }}
>
{songId !== 'new' && (
<Button
color="error"
onClick={handleRemoveSong}
disabled={submissionStatus === SUBMISSION_STATUS.SUBMITTING}
>
{t('DELETE')}
</Button>
)}
<Button
className="ml-auto hover:bg-gray-400"
variant="contained"
onClick={() => history.back()}
disabled={submissionStatus === SUBMISSION_STATUS.SUBMITTING}
startIcon={<FuseSvgIcon>heroicons-outline:x-circle</FuseSvgIcon>}
>
{t('CANCEL')}
</Button>
<Button
className="ml-8"
variant="contained"
color="secondary"
disabled={_.isEmpty(dirtyFields) || !isValid || submissionStatus === SUBMISSION_STATUS.SUBMITTING}
onClick={handleSubmit(onSubmit)}
>
{submissionStatus === SUBMISSION_STATUS.SUBMITTING ? (
<>
<CircularProgress
size={24}
className="text-secondary mr-10"
/>
<span className="text-black">{t('SUBMITTING')}</span>
</>
) : (
<>
<FuseSvgIcon className="mr-10">heroicons-outline:save</FuseSvgIcon>
<span>{t('SAVE')}</span>
</>
)}
</Button>
</Box>
</>
);
}
yup schema
import { SUPPORTED_SONG_FORMAT } from 'app/constants';
import * as yup from 'yup';
export const schema = yup.object().shape({
title: yup.string().required('Title is required'),
description: yup.string().optional(),
sort_order: yup.number().positive().integer().required(),
file: yup
.mixed<File>()
.required('File is required')
.test('fileFormat', 'Unsupported Format', (value) => {
if (value instanceof File) {
return SUPPORTED_SONG_FORMAT.includes(value.type);
}
return false; // Fails validation if value is not a File
}),
active: yup.boolean().optional()
});
Problem:
When the user selects a new file, the File object is correctly updated in the form values. However, the Save button remains disabled because react-hook-form doesn't recognize the change in the File field. I believe this is due to how react-hook-form performs shallow comparisons, and since File is a reference type, it may not detect the change.
What I've Tried:
Using setValue with shouldValidate and shouldDirty options. Manually triggering validation with trigger('file').
Question:
Why is react-hook-form not detecting changes in the File input field, and how can I ensure that the form recognizes when a file has been selected or changed, enabling the "Save" button when the form is valid?
Upvotes: 1
Views: 75
Reputation: 536
I believe the issue is due to how you are updating the file
field.
Instead of using setValue
in the handleChange
function, try using the field.onChange
handler provided by the Controller
.
Example:
const handleChange = async (file: File) => {
setFiles((prevFiles) => ({
...prevFiles,
mp3FileUrl: {
...prevFiles.mp3FileUrl,
selectedFile: file || null,
hasChanged: true
}
}));
};
<MuiFileInput
className="mt-32"
{...field}
label={t('FILE_URL_LABEL')}
placeholder={t('FILE_URL_PLACEHOLDER')}
variant="outlined"
required
fullWidth
helperText={fieldState.error ? fieldState.error.message : 'Accepted formats: .mp3'}
error={fieldState.invalid}
inputProps={{
accept: SUPPORTED_SONG_FORMAT
}}
onChange={(file) => {
handleChange(file)
field.onChange(file)
}}
/>
Upvotes: 0