From f1c6f53352086350c4c5cb9d7952b1b07a091522 Mon Sep 17 00:00:00 2001 From: thenick775 Date: Wed, 8 Jan 2025 16:15:11 -0800 Subject: [PATCH] feat: upload multiple roms --- .../src/components/modals/upload-patches.tsx | 6 +- ...load-rom.spec.tsx => upload-roms.spec.tsx} | 189 ++++++++++++++++-- .../{upload-rom.tsx => upload-roms.tsx} | 126 +++++++++--- .../navigation-menu/navigation-menu.spec.tsx | 4 +- .../navigation-menu/navigation-menu.tsx | 6 +- .../shared/drag-and-drop-input.spec.tsx | 43 ++++ .../components/shared/drag-and-drop-input.tsx | 59 ++++-- 7 files changed, 360 insertions(+), 73 deletions(-) rename gbajs3/src/components/modals/{upload-rom.spec.tsx => upload-roms.spec.tsx} (57%) rename gbajs3/src/components/modals/{upload-rom.tsx => upload-roms.tsx} (66%) diff --git a/gbajs3/src/components/modals/upload-patches.tsx b/gbajs3/src/components/modals/upload-patches.tsx index 7341dc42..7429b091 100644 --- a/gbajs3/src/components/modals/upload-patches.tsx +++ b/gbajs3/src/components/modals/upload-patches.tsx @@ -92,7 +92,11 @@ export const UploadPatchesModal = () => { hideAcceptedFiles={!value?.length} multiple > -

Drag and drop patch files here, or click to upload files

+

+ Drag and drop patch files here, +
+ or click to upload files +

)} /> diff --git a/gbajs3/src/components/modals/upload-rom.spec.tsx b/gbajs3/src/components/modals/upload-roms.spec.tsx similarity index 57% rename from gbajs3/src/components/modals/upload-rom.spec.tsx rename to gbajs3/src/components/modals/upload-roms.spec.tsx index 2176eca6..61d50f91 100644 --- a/gbajs3/src/components/modals/upload-rom.spec.tsx +++ b/gbajs3/src/components/modals/upload-roms.spec.tsx @@ -2,7 +2,7 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { UploadRomModal } from './upload-rom.tsx'; +import { UploadRomsModal } from './upload-roms.tsx'; import { testRomLocation } from '../../../test/mocks/handlers.ts'; import { renderWithContext } from '../../../test/render-with-context.tsx'; import * as contextHooks from '../../hooks/context.tsx'; @@ -12,7 +12,7 @@ import { productTourLocalStorageKey } from '../product-tour/consts.tsx'; import type { GBAEmulator } from '../../emulator/mgba/mgba-emulator.tsx'; -describe('', () => { +describe('', () => { it('uploads file and closes modal', async () => { const setIsModalOpenSpy = vi.fn(); const uploadRomSpy: (file: File, cb?: () => void) => void = vi.fn( @@ -51,7 +51,7 @@ describe('', () => { const testRom = new File(['Some rom file contents'], 'rom1.gba'); - renderWithContext(); + renderWithContext(); const romInput = screen.getByTestId('hidden-file-input'); @@ -67,9 +67,157 @@ describe('', () => { expect(uploadRomSpy).toHaveBeenCalledOnce(); expect(uploadRomSpy).toHaveBeenCalledWith(testRom, expect.anything()); + expect(runGameSpy).toHaveBeenCalledOnce(); + expect(runGameSpy).toHaveBeenCalledWith('rom1.gba'); expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); + }); + + it('uploads multiple files, loads first file, and closes modal', async () => { + const setIsModalOpenSpy = vi.fn(); + const uploadRomSpy: (file: File, cb?: () => void) => void = vi.fn( + (_file, cb) => cb && cb() + ); + const syncActionIfEnabledSpy = vi.fn(); + const runGameSpy = vi.fn(() => true); + + const { + useEmulatorContext: originalEmulator, + useModalContext: originalModal + } = await vi.importActual('../../hooks/context.tsx'); + const { useAddCallbacks: originalCallbacks } = await vi.importActual< + typeof addCallbackHooks + >('../../hooks/emulator/use-add-callbacks.tsx'); + + vi.spyOn(contextHooks, 'useEmulatorContext').mockImplementation(() => ({ + ...originalEmulator(), + emulator: { + uploadRom: uploadRomSpy + } as GBAEmulator + })); + + vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({ + ...originalModal(), + setIsModalOpen: setIsModalOpenSpy + })); + + vi.spyOn(addCallbackHooks, 'useAddCallbacks').mockImplementation(() => ({ + ...originalCallbacks(), + syncActionIfEnabled: syncActionIfEnabledSpy + })); + + vi.spyOn(runGameHooks, 'useRunGame').mockReturnValue(runGameSpy); + + const testRomFiles = [ + new File(['Some rom file contents 1'], 'rom1.gba'), + new File(['Some rom file contents 2'], 'rom2.gba') + ]; + + renderWithContext(); + + const romInput = screen.getByTestId('hidden-file-input'); + + expect(romInput).toBeInTheDocument(); + + await userEvent.upload(romInput, testRomFiles); + + expect(screen.getByText('Files to upload:')).toBeVisible(); + expect(screen.getByText('rom1.gba')).toBeVisible(); + expect(screen.getByText('rom2.gba')).toBeVisible(); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect(uploadRomSpy).toHaveBeenCalledTimes(2); + expect(uploadRomSpy).toHaveBeenCalledWith( + testRomFiles[0], + expect.anything() + ); + expect(uploadRomSpy).toHaveBeenCalledWith( + testRomFiles[1], + expect.anything() + ); + expect(runGameSpy).toHaveBeenCalledOnce(); expect(runGameSpy).toHaveBeenCalledWith('rom1.gba'); + + expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); + }); + + it('uploads multiple files, loads selected file, and closes modal', async () => { + const setIsModalOpenSpy = vi.fn(); + const uploadRomSpy: (file: File, cb?: () => void) => void = vi.fn( + (_file, cb) => cb && cb() + ); + const syncActionIfEnabledSpy = vi.fn(); + const runGameSpy = vi.fn(() => true); + + const { + useEmulatorContext: originalEmulator, + useModalContext: originalModal + } = await vi.importActual('../../hooks/context.tsx'); + const { useAddCallbacks: originalCallbacks } = await vi.importActual< + typeof addCallbackHooks + >('../../hooks/emulator/use-add-callbacks.tsx'); + + vi.spyOn(contextHooks, 'useEmulatorContext').mockImplementation(() => ({ + ...originalEmulator(), + emulator: { + uploadRom: uploadRomSpy + } as GBAEmulator + })); + + vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({ + ...originalModal(), + setIsModalOpen: setIsModalOpenSpy + })); + + vi.spyOn(addCallbackHooks, 'useAddCallbacks').mockImplementation(() => ({ + ...originalCallbacks(), + syncActionIfEnabled: syncActionIfEnabledSpy + })); + + vi.spyOn(runGameHooks, 'useRunGame').mockReturnValue(runGameSpy); + + const testRomFiles = [ + new File(['Some rom file contents 1'], 'rom1.gba'), + new File(['Some rom file contents 2'], 'rom2.gba') + ]; + + renderWithContext(); + + const romInput = screen.getByTestId('hidden-file-input'); + + expect(romInput).toBeInTheDocument(); + + await userEvent.upload(romInput, testRomFiles); + + await userEvent.click( + screen.getByLabelText('Run rom2.gba', { selector: 'input' }) + ); + expect( + screen.getByLabelText('Run rom2.gba', { selector: 'input' }) + ).toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect(uploadRomSpy).toHaveBeenCalledTimes(2); + expect(uploadRomSpy).toHaveBeenCalledWith( + testRomFiles[0], + expect.anything() + ); + expect(uploadRomSpy).toHaveBeenCalledWith( + testRomFiles[1], + expect.anything() + ); + + expect(runGameSpy).toHaveBeenCalledOnce(); + expect(runGameSpy).toHaveBeenCalledWith('rom2.gba'); + + expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledOnce(); expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); }); @@ -112,7 +260,7 @@ describe('', () => { vi.spyOn(runGameHooks, 'useRunGame').mockReturnValue(runGameSpy); - renderWithContext(); + renderWithContext(); const uploadRomFromURLInput = screen.getByLabelText('Upload from a URL'); @@ -157,7 +305,7 @@ describe('', () => { } as GBAEmulator })); - renderWithContext(); + renderWithContext(); const uploadRomFromURLInput = screen.getByLabelText('Upload from a URL'); @@ -184,12 +332,27 @@ describe('', () => { ).toBeVisible(); }); + it('renders invalid url error', async () => { + renderWithContext(); + + await userEvent.type( + screen.getByLabelText('Upload from a URL'), + `invalid url` + ); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect(screen.getByText(/Invalid URL/)).toBeVisible(); + }); + it('renders form validation error', async () => { - renderWithContext(); + renderWithContext(); await userEvent.click(screen.getByRole('button', { name: 'Upload' })); - expect(screen.getByText(/A rom file or URL is required/)).toBeVisible(); + expect( + screen.getByText(/At least one rom file or URL is required/) + ).toBeVisible(); }); it('closes modal using the close button', async () => { @@ -203,7 +366,7 @@ describe('', () => { setIsModalOpen: setIsModalOpenSpy })); - renderWithContext(); + renderWithContext(); // click the close button const closeButton = screen.getByText('Close', { selector: 'button' }); @@ -228,11 +391,11 @@ describe('', () => { '{"hasCompletedProductTourIntro":"finished"}' ); - renderWithContext(); + renderWithContext(); expect( await screen.findByText( - 'Use this area to drag and drop your rom or zipped rom file, or click to select a file.' + 'Use this area to drag and drop roms or zipped rom files, or click to select files.' ) ).toBeInTheDocument(); expect( @@ -242,7 +405,7 @@ describe('', () => { ).toBeInTheDocument(); expect( screen.getByText( - 'You may drop or select one rom at a time, once uploaded your game will boot!' + 'You may drop or select multiple files, once uploaded the selected game will boot!' ) ).toBeInTheDocument(); @@ -253,7 +416,7 @@ describe('', () => { expect( screen.getByText( - 'Use this area to drag and drop your rom or zipped rom file, or click to select a file.' + 'Use this area to drag and drop roms or zipped rom files, or click to select files.' ) ).toBeVisible(); expect( @@ -263,7 +426,7 @@ describe('', () => { ).toBeVisible(); expect( screen.getByText( - 'You may drop or select one rom at a time, once uploaded your game will boot!' + 'You may drop or select multiple files, once uploaded the selected game will boot!' ) ).toBeVisible(); }); diff --git a/gbajs3/src/components/modals/upload-rom.tsx b/gbajs3/src/components/modals/upload-roms.tsx similarity index 66% rename from gbajs3/src/components/modals/upload-rom.tsx rename to gbajs3/src/components/modals/upload-roms.tsx index 540eb4e7..32b3f1cf 100644 --- a/gbajs3/src/components/modals/upload-rom.tsx +++ b/gbajs3/src/components/modals/upload-roms.tsx @@ -1,4 +1,4 @@ -import { Button, Divider, TextField } from '@mui/material'; +import { Button, Divider, Checkbox, TextField } from '@mui/material'; import { useCallback, useEffect, useId, useState } from 'react'; import { useForm, Controller, type SubmitHandler } from 'react-hook-form'; import { BiError } from 'react-icons/bi'; @@ -22,14 +22,55 @@ import { PacmanIndicator } from '../shared/loading-indicator.tsx'; +import type { CheckboxProps } from '@mui/material'; + type InputProps = { - romFile: File; + romFiles: File[]; + romFileToRun: string; romURL: string; }; +type RunRomCheckbox = { + fileName: string; +} & Pick; + +type AdditionalFileActionsProps = { + fileName: string; + index: number; + selectedFileName: string | null; + setSelectedFileName: (name: string | null) => void; +}; + const validFileExtensions = ['.gba', '.gbc', '.gb', '.zip', '.7z']; -export const UploadRomModal = () => { +const RunRomCheckbox = ({ fileName, checked, onChange }: RunRomCheckbox) => ( + +); + +const AdditionalFileActions = ({ + fileName, + index, + selectedFileName, + setSelectedFileName +}: AdditionalFileActionsProps) => { + const isChecked = + fileName === selectedFileName || (!selectedFileName && index === 0); + + return ( + setSelectedFileName(isChecked ? null : fileName)} + /> + ); +}; + +export const UploadRomsModal = () => { const theme = useTheme(); const { setIsModalOpen } = useModalContext(); const { emulator } = useEmulatorContext(); @@ -61,9 +102,7 @@ export const UploadRomModal = () => { const runCallback = () => { syncActionIfEnabled(); const hasSucceeded = runGame(externalRomFile.name); - if (hasSucceeded) { - setIsModalOpen(false); - } + if (hasSucceeded) setIsModalOpen(false); }; emulator?.uploadRom(externalRomFile, runCallback); setCurrentRomURL(null); @@ -80,45 +119,58 @@ export const UploadRomModal = () => { const onDrop = useCallback( (acceptedFiles: File[]) => { reset(); - setValue('romFile', acceptedFiles[0], { shouldValidate: true }); + setValue('romFiles', acceptedFiles, { shouldValidate: true }); }, [reset, setValue] ); - const onSubmit: SubmitHandler = async ({ romFile, romURL }) => { + const onSubmit: SubmitHandler = async ({ + romFiles, + romFileToRun, + romURL + }) => { if (romURL) { setCurrentRomURL(romURL); await executeLoadExternalRom({ url: new URL(romURL) }); return; } - const runCallback = () => { - syncActionIfEnabled(); - const hasSucceeded = runGame(romFile.name); - if (hasSucceeded) { - setIsModalOpen(false); - } - }; - emulator?.uploadRom(romFile, runCallback); + await Promise.all( + romFiles.map( + (romFile, idx) => + new Promise((resolve) => { + const runCallback = () => + resolve( + (romFile.name === romFileToRun || + (!romFileToRun && idx === 0)) && + runGame(romFile.name) + ); + emulator?.uploadRom(romFile, runCallback); + }) + ) + ); + + syncActionIfEnabled(); + setIsModalOpen(false); }; - const isRomFileSet = !!watch('romFile'); + const isRomFileSet = !!watch('romFiles')?.length; const tourSteps: TourSteps = [ { content: ( <>

- Use this area to drag and drop your rom or zipped rom file, or click - to select a file. + Use this area to drag and drop roms or zipped rom files, or click to + select files.

Rom files should have an extension of:{' '} {validFileExtensions.map((ext) => `'${ext}'`).join(', ')}.

- You may drop or select one rom at a time, once uploaded your game - will boot! + You may drop or select multiple files, once uploaded the selected + game will boot!

), @@ -144,17 +196,17 @@ export const UploadRomModal = () => { >
+ validate: (roms, formValues) => !!formValues.romURL || - !!rom || - 'A rom file or URL is required' + !!roms || + 'At least one rom file or URL is required' }} render={({ field: { name }, fieldState: { error } }) => ( { name={name} validFileExtensions={validFileExtensions} hideErrors={!!error} + multiple + renderAdditionalFileActions={(props) => ( + + setValue('romFileToRun', name ?? 'none') + } + {...props} + /> + )} >

- Drag and drop a rom or zipped rom file here, or click to - upload a file + Drag and drop roms or zipped rom files here, or click to + upload files

)} @@ -189,8 +251,8 @@ export const UploadRomModal = () => { validate: (romURL, formValues) => { if (!romURL) return true; - if (formValues.romFile) - return 'Cannot specify both a file and a URL'; + if (formValues.romFiles) + return 'Cannot specify both files and a URL'; try { new URL(romURL); @@ -209,10 +271,10 @@ export const UploadRomModal = () => { )} )} - {errors.romFile?.message && ( + {errors.romFiles?.message && ( } - text={errors.romFile.message} + text={errors.romFiles.message} /> )} diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx index bae9ae41..256f0908 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx @@ -23,7 +23,7 @@ import { SaveStatesModal } from '../modals/save-states.tsx'; import { UploadCheatsModal } from '../modals/upload-cheats.tsx'; import { UploadPatchesModal } from '../modals/upload-patches.tsx'; import { UploadRomToServerModal } from '../modals/upload-rom-to-server.tsx'; -import { UploadRomModal } from '../modals/upload-rom.tsx'; +import { UploadRomsModal } from '../modals/upload-roms.tsx'; import { UploadSaveToServerModal } from '../modals/upload-save-to-server.tsx'; import { UploadSavesModal } from '../modals/upload-saves.tsx'; @@ -90,7 +90,7 @@ describe('', () => { ['Upload Saves', ], ['Upload Cheats', ], ['Upload Patches', ], - ['Upload Rom', ], + ['Upload Roms', ], ['Load Local Rom', ], ['Controls', ], ['File System', ], diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.tsx index 926f08b9..9de98cd7 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.tsx @@ -50,7 +50,7 @@ import { SaveStatesModal } from '../modals/save-states.tsx'; import { UploadCheatsModal } from '../modals/upload-cheats.tsx'; import { UploadPatchesModal } from '../modals/upload-patches.tsx'; import { UploadRomToServerModal } from '../modals/upload-rom-to-server.tsx'; -import { UploadRomModal } from '../modals/upload-rom.tsx'; +import { UploadRomsModal } from '../modals/upload-roms.tsx'; import { UploadSaveToServerModal } from '../modals/upload-save-to-server.tsx'; import { UploadSavesModal } from '../modals/upload-saves.tsx'; import { ButtonBase } from '../shared/custom-button-base.tsx'; @@ -230,11 +230,11 @@ export const NavigationMenu = () => { }} /> } onClick={() => { - setModalContent(); + setModalContent(); setIsModalOpen(true); }} /> diff --git a/gbajs3/src/components/shared/drag-and-drop-input.spec.tsx b/gbajs3/src/components/shared/drag-and-drop-input.spec.tsx index f2eb4bbf..0a8f4c36 100644 --- a/gbajs3/src/components/shared/drag-and-drop-input.spec.tsx +++ b/gbajs3/src/components/shared/drag-and-drop-input.spec.tsx @@ -339,6 +339,23 @@ describe('', () => { expect(screen.getByText('Some files were rejected')).toBeVisible(); }); + it('renders passed in error', () => { + renderWithContext( + +

Upload file here

+
+ ); + + expect(screen.getByText('some error')).toBeVisible(); + }); + it('deletes file from the accepted file list', async () => { const testFiles = [ new File(['Some test file contents 1'], 'test_file1.test'), @@ -374,4 +391,30 @@ describe('', () => { expect(screen.getByText('test_file1.test')).toBeVisible(); expect(screen.queryByText('test_file2.test')).not.toBeInTheDocument(); }); + + it('renders additional file actions', async () => { + const testFile = new File(['Some test file contents'], 'test_file.test'); + const onDropSpy = vi.fn(); + + renderWithContext( + ( + + )} + > +

Upload file here

+
+ ); + + await userEvent.upload(screen.getByTestId('hidden-file-input'), testFile); + + expect( + screen.getByRole('button', { name: 'test_file.test_0' }) + ).toBeVisible(); + }); }); diff --git a/gbajs3/src/components/shared/drag-and-drop-input.tsx b/gbajs3/src/components/shared/drag-and-drop-input.tsx index deeadda2..079c5bf0 100644 --- a/gbajs3/src/components/shared/drag-and-drop-input.tsx +++ b/gbajs3/src/components/shared/drag-and-drop-input.tsx @@ -24,6 +24,10 @@ type DragAndDropInputProps = { name: string; onDrop: (acceptedFiles: File[]) => void; validFileExtensions: Extension[]; + renderAdditionalFileActions?: (fileInfo: { + fileName: string; + index: number; + }) => ReactNode; }; type DropAreaProps = { @@ -80,36 +84,45 @@ const AcceptedFile = styled.li` const IconSeparator = styled.div` display: flex; - gap: 15px; + gap: 8px; `; const AcceptedFiles = ({ fileNames, + renderAdditionalActions, onDeleteFile }: { fileNames: string[]; + renderAdditionalActions?: (fileInfo: { + fileName: string; + index: number; + totalFiles: number; + }) => ReactNode; onDeleteFile: (fileName: string) => void; -}) => { - return ( - -

File{fileNames.length > 1 && 's'} to upload:

- {fileNames.map((name, idx) => ( - -

{name}

- - onDeleteFile(name)} - > - - - -
- ))} -
- ); -}; +}) => ( + +

File{fileNames.length > 1 && 's'} to upload:

+ {fileNames.map((fileName, index) => ( + +

{fileName}

+ + {renderAdditionalActions?.({ + fileName, + index, + totalFiles: fileNames.length + })} + onDeleteFile(fileName)} + > + + + +
+ ))} +
+); const hasValidFileExtension = (file: File, validExtensions: Extension[]) => { const fileExtension = `.${file.name.split('.').pop()}`; @@ -161,6 +174,7 @@ export const DragAndDropInput = ({ id, multiple = false, name, + renderAdditionalFileActions, onDrop, validFileExtensions }: DragAndDropInputProps) => { @@ -213,6 +227,7 @@ export const DragAndDropInput = ({ )} {!!rejectedFileErrors.length && !hideErrors && (