diff --git a/apps/meteor/client/hooks/useSingleFileInput.spec.ts b/apps/meteor/client/hooks/useSingleFileInput.spec.ts new file mode 100644 index 0000000000000..cc18ef5806a8a --- /dev/null +++ b/apps/meteor/client/hooks/useSingleFileInput.spec.ts @@ -0,0 +1,126 @@ +import { renderHook, act } from '@testing-library/react'; +import { useSingleFileInput } from './useSingleFileInput'; + +describe('useSingleFileInput', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should create an input element and append it to the body', () => { + const onSetFile = jest.fn(); + renderHook(() => useSingleFileInput(onSetFile)); + + const inputs = document.querySelectorAll('input[type="file"]'); + expect(inputs).toHaveLength(1); + expect((inputs[0] as HTMLInputElement).style.display).toBe('none'); + }); + + it('should set the accept attribute based on fileType option', () => { + const onSetFile = jest.fn(); + renderHook(() => useSingleFileInput(onSetFile, 'image', { fileType: 'video/*' })); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input.getAttribute('accept')).toBe('video/*'); + }); + + it('should use default accept attribute if not provided', () => { + const onSetFile = jest.fn(); + renderHook(() => useSingleFileInput(onSetFile)); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input.getAttribute('accept')).toBe('image/*'); + }); + + it('should trigger click on the input when calling onClick', () => { + const onSetFile = jest.fn(); + const { result } = renderHook(() => useSingleFileInput(onSetFile)); + const [onClick] = result.current; + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = jest.spyOn(input, 'click'); + + act(() => { + onClick(); + }); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('should call onSetFile when a file is selected', () => { + const onSetFile = jest.fn(); + renderHook(() => useSingleFileInput(onSetFile, 'test-field')); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + act(() => { + input.dispatchEvent(new Event('change')); + }); + + expect(onSetFile).toHaveBeenCalledWith(file, expect.any(FormData)); + const formData = onSetFile.mock.calls[0][1] as FormData; + expect(formData.get('test-field')).toBe(file); + }); + + it('should not call onSetFile when no file is selected', () => { + const onSetFile = jest.fn(); + renderHook(() => useSingleFileInput(onSetFile, 'test-field')); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + Object.defineProperty(input, 'files', { + value: [], + }); + + act(() => { + input.dispatchEvent(new Event('change')); + }); + + expect(onSetFile).not.toHaveBeenCalled(); + }); + + it('should remove input element on unmount', () => { + const onSetFile = jest.fn(); + const { unmount } = renderHook(() => useSingleFileInput(onSetFile)); + + expect(document.querySelectorAll('input[type="file"]')).toHaveLength(1); + + unmount(); + + expect(document.querySelectorAll('input[type="file"]')).toHaveLength(0); + }); + + it('should reset input value when calling reset', () => { + const onSetFile = jest.fn(); + const { result } = renderHook(() => useSingleFileInput(onSetFile)); + const [, reset] = result.current; + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const setterSpy = jest.spyOn(input, 'value', 'set'); + + act(() => { + reset(); + }); + + expect(setterSpy).toHaveBeenCalledWith(''); + }); + + it('should update accept attribute when fileType option changes', () => { + const onSetFile = jest.fn(); + const { rerender } = renderHook( + ({ fileType }: { fileType?: string }) => + useSingleFileInput(onSetFile, 'image', { fileType }), + { initialProps: { fileType: 'image/*' } } + ); + + rerender({ fileType: 'video/*' }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input.getAttribute('accept')).toBe('video/*'); + }); +}); diff --git a/apps/meteor/client/hooks/useSingleFileInput.ts b/apps/meteor/client/hooks/useSingleFileInput.ts index 629152008d014..c1eabf58a2d6c 100644 --- a/apps/meteor/client/hooks/useSingleFileInput.ts +++ b/apps/meteor/client/hooks/useSingleFileInput.ts @@ -3,10 +3,13 @@ import { useRef, useEffect } from 'react'; export const useSingleFileInput = ( onSetFile: (file: File, formData: FormData) => void, - fileType = 'image/*', fileField = 'image', + options?: { + fileType?: string; + }, ): [onClick: () => void, reset: () => void] => { const ref = useRef(); + const fileType = options?.fileType || 'image/*'; useEffect(() => { const fileInput = document.createElement('input'); diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index b3a75763c0932..3adef376f13c4 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -28,7 +28,7 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr setSound(soundFile); }, []); - const [clickUpload] = useSingleFileInput(handleChangeFile, 'audio/mp3'); + const [clickUpload] = useSingleFileInput(handleChangeFile, 'sound', { fileType: 'audio/mp3' }); const saveAction = useCallback( // FIXME diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index f46ce0e175b61..e80ff333e6c5e 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -29,10 +29,10 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl const [name, setName] = useState(() => data?.name ?? ''); const [sound, setSound] = useState< | { - _id: string; - name: string; - extension?: string; - } + _id: string; + name: string; + extension?: string; + } | File >(() => data); @@ -123,7 +123,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl ); }, [_id, close, deleteCustomSound, dispatchToastMessage, onChange, setModal, t]); - const [clickUpload] = useSingleFileInput(handleChangeFile, 'audio/mp3'); + const [clickUpload] = useSingleFileInput(handleChangeFile, 'sound', { fileType: 'audio/mp3' }); return ( <> diff --git a/apps/meteor/client/views/marketplace/AppInstallPage.tsx b/apps/meteor/client/views/marketplace/AppInstallPage.tsx index 408b55ab94b71..fc51d0ff80f90 100644 --- a/apps/meteor/client/views/marketplace/AppInstallPage.tsx +++ b/apps/meteor/client/views/marketplace/AppInstallPage.tsx @@ -15,7 +15,7 @@ const AppInstallPage = () => { const { file } = watch(); const { install, isInstalling } = useInstallApp(file); - const [handleUploadButtonClick] = useSingleFileInput((value) => setValue('file', value), 'app'); + const [handleUploadButtonClick] = useSingleFileInput((value) => setValue('file', value), 'app', { fileType: '.zip' }); const handleCancel = useCallback(() => { router.navigate({