This commit is contained in:
violetadev 2024-07-28 17:25:10 +02:00
parent 744cdff065
commit c177880d11
13 changed files with 401 additions and 52 deletions

View file

@ -0,0 +1,100 @@
import { Icon } from '@iconify/react';
import Button from 'renderer/components/Button';
import { useState } from 'react';
import { FileUploader } from 'renderer/components/FileUploader';
import Modal from 'renderer/components/Modal';
import { addSuites } from 'renderer/store/features/device-manager';
import { useDispatch } from 'react-redux';
import { defaultDevices, Device } from 'common/deviceList';
import { transformFile } from './utils';
export const ManageSuitesTool = () => {
const [open, setOpen] = useState<boolean>(false);
const [error, setError] = useState<boolean>(false);
const dispatch = useDispatch();
const onFileUpload = (fileUploaded: File) =>
transformFile(fileUploaded)
.then((fileTransformed) => {
const { customDevices, suites } = fileTransformed;
if (suites) {
dispatch(addSuites(suites));
}
if (customDevices) {
const importedCustomDevices = customDevices.filter(
(item: Device) => !defaultDevices.includes(item)
);
window.electron.store.set(
'deviceManager.customDevices',
importedCustomDevices
);
}
setOpen(false);
return null;
})
.catch(() => setError(true));
const onFileUploadReset = () => {
setError(false);
setOpen(false);
};
return (
<>
<div className="space-between flex flex-row">
<Button
className="aspect-square w-12 rounded-full hover:!bg-slate-500"
onClick={() => setOpen(true)}
>
<Icon
icon="mdi:folder-upload"
fontSize={18}
onClick={() => setOpen(true)}
/>
</Button>
<Button
className="aspect-square w-12 rounded-full hover:!bg-slate-500"
onClick={() => setOpen(true)}
>
<Icon
icon="mdi:folder-download"
fontSize={18}
onClick={() => setOpen(true)}
/>
</Button>
</div>
<Modal
isOpen={open}
onClose={() => setOpen(false)}
title="Import your devices"
>
<>
<FileUploader
acceptedFileTypes="application/json"
multiple={false}
handleFileUpload={onFileUpload}
/>
<div className="text-align align-items-center flex flex-row flex-nowrap text-orange-500">
<Icon icon="mdi:alert" />
<p className="pl-2">Importing will replace all current settings.</p>
</div>
{error && (
<div className="absolute top-0 left-0 flex h-full w-full flex-col flex-wrap items-center justify-center bg-slate-600 bg-opacity-95">
<div className="text-center text-sm text-white">
<p>There has been an error, please try again.</p>
</div>
<Button onClick={onFileUploadReset} className="p-2">
Close
</Button>
</div>
)}
</>
</Modal>
</>
);
};

View file

@ -0,0 +1,59 @@
{
"customDevices": [
{
"id": "123",
"name": "a new test",
"width": 400,
"height": 600,
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
"type": "phone",
"dpi": 1,
"isTouchCapable": true,
"isMobileCapable": true,
"capabilities": [
"touch",
"mobile"
],
"isCustom": true
}
],
"suites": [
{
"id": "default",
"name": "Default",
"devices": [
"10008"
]
},
{
"id": "a4c142fc-debd-4eaa-beba-aef60093151c",
"name": "my custom suite hehehe",
"devices": [
"10008",
"30014"
]
},
{
"id": "e0395ce6-ce1a-413d-9202-4669ffb35c45",
"name": "ouhu",
"devices": [
"30014",
"10008"
]
},
{
"id": "4c2d85be-64a1-44ba-bebd-749e8badbb86",
"name": "ouhujnn nk",
"devices": [
"10008"
]
},
{
"id": "f4b1c0b1-c2c2-4ca9-b9c5-8f28a87b745b",
"name": "customdev",
"devices": [
"10008"
]
}
]
}

View file

@ -0,0 +1,24 @@
import { transformFile } from './utils';
describe('transformFile', () => {
it('should parse JSON content of the file', async () => {
const jsonContent = { key: 'value' };
const file = new Blob([JSON.stringify(jsonContent)], {
type: 'application/json',
}) as File;
Object.defineProperty(file, 'name', { value: 'test.json' });
const result = await transformFile(file);
expect(result).toEqual(jsonContent);
});
it('should throw an error for invalid JSON', async () => {
const invalidJsonContent = "{ key: 'value' }"; // Invalid JSON
const file = new Blob([invalidJsonContent], {
type: 'application/json',
}) as File;
Object.defineProperty(file, 'name', { value: 'test.json' });
await expect(transformFile(file)).rejects.toThrow();
});
});

View file

@ -0,0 +1,20 @@
export const transformFile = (file: File): Promise<{ [key: string]: any }> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
try {
const jsonContent = JSON.parse(reader.result as string);
resolve(jsonContent);
} catch (error) {
reject(error);
}
};
reader.onerror = () => {
reject(reader.error);
};
reader.readAsText(file);
});
};

View file

@ -5,36 +5,25 @@ import {
selectActiveSuite,
selectSuites,
} from 'renderer/store/features/device-manager';
import { CreateSuiteButton } from './CreateSuiteButton';
import { Suite } from './Suite';
import { useState } from 'react';
import { FileUploader } from 'renderer/components/FileUploader';
import Modal from 'renderer/components/Modal';
import { Suite } from './Suite';
import { CreateSuiteButton } from './CreateSuiteButton';
import { ManageSuitesTool } from './ManageSuitesTool/ManageSuitesTool';
export const PreviewSuites = () => {
const suites = useSelector(selectSuites);
const activeSuite = useSelector(selectActiveSuite);
const [open, setOpen] = useState<boolean>(false);
console.log(suites, 'suites');
return (
<div className="flex flex-col">
<div className="mb-6 flex items-center gap-2 text-lg">
<Icon icon="heroicons:swatch" /> Preview Suites
<div className="space-between">
<Button className="aspect-square w-16" onClick={() => setOpen(true)}>
<Icon
icon="mdi:folder-upload"
fontSize={8}
onClick={() => setOpen(true)}
/>{' '}
Import Devices
</Button>
<Button className="aspect-square" onClick={() => setOpen(true)}>
<Icon
icon="mdi:folder-download"
fontSize={8}
onClick={() => setOpen(true)}
/>
Export Devices
</Button>
<div className="space-between mb-6 flex w-full flex-row items-center gap-2 text-lg">
<div className="align-items-center flex flex-row justify-center">
<Icon icon="heroicons:swatch" />{' '}
<p className="pl-2">Preview Suites</p>
</div>
<ManageSuitesTool />
</div>
<div className="flex w-full items-center gap-4 overflow-x-auto">
<div className="flex flex-shrink-0 gap-4">

View file

@ -168,6 +168,13 @@ const DeviceManager = () => {
device={selectedDevice}
onRemoveDevice={onRemoveDevice}
/>
<Button
onClick={() =>
customDevices.forEach((device) => onRemoveDevice(device))
}
>
remove all
</Button>
</div>
);
};

View file

@ -1,9 +1,9 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { FileUploader } from './FileUploader';
import useFileUpload from './hooks/useFileUpload';
import { FileUploader, FileUploaderProps } from './FileUploader';
import { useFileUpload } from './hooks';
jest.mock('./hooks/useFileUpload');
jest.mock('./hooks');
const mockHandleFileUpload = jest.fn();
const mockHandleUpload = jest.fn();
@ -11,24 +11,35 @@ const mockResetUploadedFile = jest.fn();
describe('FileUploader', () => {
beforeEach(() => {
useFileUpload.mockReturnValue({
(useFileUpload as jest.Mock).mockReturnValue({
uploadedFile: null,
handleUpload: mockHandleUpload,
resetUploadedFile: mockResetUploadedFile,
});
});
const renderComponent = (props?: FileUploaderProps) =>
render(
<FileUploader
handleFileUpload={props?.handleFileUpload || mockHandleFileUpload}
multiple={props?.multiple || false}
acceptedFileTypes={props?.acceptedFileTypes || '*/*'}
/>
);
it('renders the component', () => {
render(<FileUploader handleFileUpload={mockHandleFileUpload} />);
expect(screen.getByText('Upload a File')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /Remove file/i })
).toBeInTheDocument();
const { getByTestId } = renderComponent();
const fileInput = getByTestId('fileUploader');
const removeButton = getByTestId('fileRemover');
expect(fileInput).toBeInTheDocument();
expect(removeButton).toBeInTheDocument();
});
it('calls handleUpload when file input changes', () => {
render(<FileUploader handleFileUpload={mockHandleFileUpload} />);
const fileInput = screen.getByRole('textbox');
const { getByTestId } = renderComponent();
const fileInput = getByTestId('fileUploader');
fireEvent.change(fileInput, {
target: { files: [new File(['content'], 'file.txt')] },
});
@ -36,20 +47,63 @@ describe('FileUploader', () => {
});
it('calls resetUploadedFile when remove button is clicked', () => {
render(<FileUploader handleFileUpload={mockHandleFileUpload} />);
const removeButton = screen.getByRole('button', { name: /Remove file/i });
const { getByTestId } = renderComponent();
const removeButton = getByTestId('fileRemover');
fireEvent.click(removeButton);
expect(mockResetUploadedFile).toHaveBeenCalled();
});
it('calls handleFileUpload when uploadedFile is set', () => {
const mockFile = new File(['content'], 'file.txt');
useFileUpload.mockReturnValue({
(useFileUpload as jest.Mock).mockReturnValue({
uploadedFile: mockFile,
handleUpload: mockHandleUpload,
resetUploadedFile: mockResetUploadedFile,
});
render(<FileUploader handleFileUpload={mockHandleFileUpload} />);
renderComponent();
expect(mockHandleFileUpload).toHaveBeenCalledWith(mockFile);
});
it('clears the file input when remove button is clicked', async () => {
const { getByTestId } = renderComponent();
const fileInput = getByTestId('fileUploader') as HTMLInputElement;
const removeButton = getByTestId('fileRemover');
fireEvent.change(fileInput, {
target: { files: [new File(['content'], 'file.txt')] },
});
await waitFor(() => expect(mockHandleUpload).toHaveBeenCalled());
fireEvent.click(removeButton);
await waitFor(() => expect(fileInput.value).toBe(''));
});
it('sets the accept attribute correctly', () => {
const { getByTestId } = renderComponent({
acceptedFileTypes: 'application/json',
handleFileUpload: mockHandleFileUpload,
});
const fileInput = getByTestId('fileUploader');
expect(fileInput).toHaveAttribute('accept', 'application/json');
});
it('allows multiple file uploads when multiple prop is true', () => {
const { getByTestId } = renderComponent({
multiple: true,
handleFileUpload: mockHandleFileUpload,
});
const fileInput = getByTestId('fileUploader');
expect(fileInput).toHaveAttribute('multiple');
});
it('does not allow multiple file uploads when multiple prop is false', () => {
const { getByTestId } = renderComponent({
multiple: false,
handleFileUpload: mockHandleFileUpload,
});
const fileInput = getByTestId('fileUploader');
expect(fileInput).not.toHaveAttribute('multiple');
});
});

View file

@ -1,14 +1,21 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { Icon } from '@iconify/react';
import useFileUpload from './useFileUpload';
import { useFileUpload } from './hooks';
import Button from '../Button';
export type FileUploaderProps = {
handleFileUpload: (file: File) => void;
multiple?: boolean;
acceptedFileTypes?: string;
};
export const FileUploader = ({
handleFileUpload,
}: {
handleFileUpload?: (file: File) => void;
}) => {
multiple,
acceptedFileTypes,
}: FileUploaderProps) => {
const { uploadedFile, handleUpload, resetUploadedFile } = useFileUpload();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (uploadedFile && handleFileUpload) {
@ -16,13 +23,30 @@ export const FileUploader = ({
}
}, [handleFileUpload, uploadedFile]);
const handleReset = () => {
resetUploadedFile();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="flex flex-col">
<h2>Upload a File</h2>
<input type="file" onChange={handleUpload} multiple={false} />
<Button onClick={resetUploadedFile}>
<Icon icon="mdi:delete" fontSize={8} />
Remove file
<div className="flex flex-row flex-wrap">
<input
ref={fileInputRef}
type="file"
onChange={handleUpload}
multiple={multiple || false}
accept={acceptedFileTypes || '*/*'}
aria-label="Upload file"
data-testid="fileUploader"
/>
<Button
onClick={handleReset}
data-testid="fileRemover"
aria-label="Remove file"
>
<Icon icon="mdi:delete" fontSize={18} />
</Button>
</div>
);

View file

@ -0,0 +1 @@
export { useFileUpload } from './useFileUpload';

View file

@ -0,0 +1,64 @@
import { act, renderHook } from '@testing-library/react';
import { useFileUpload } from './useFileUpload';
describe('useFileUpload', () => {
it('should initialize with null uploadedFile', () => {
const { result } = renderHook(() => useFileUpload());
expect(result.current.uploadedFile).toBeNull();
});
it('should set uploadedFile when handleUpload is called with a file', () => {
const { result } = renderHook(() => useFileUpload());
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
act(() => {
result.current.handleUpload({
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.uploadedFile).toEqual(mockFile);
});
it('should not set uploadedFile when handleUpload is called with no files', () => {
const { result } = renderHook(() => useFileUpload());
act(() => {
result.current.handleUpload({
target: {
files: [],
},
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.uploadedFile).toBeNull();
});
it('should reset uploadedFile when resetUploadedFile is called', () => {
const { result } = renderHook(() => useFileUpload());
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
act(() => {
result.current.handleUpload({
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.uploadedFile).toEqual(mockFile);
act(() => {
result.current.resetUploadedFile();
});
expect(result.current.uploadedFile).toBeNull();
});
});

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
const useFileUpload = () => {
export const useFileUpload = () => {
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -19,5 +19,3 @@ const useFileUpload = () => {
resetUploadedFile,
};
};
export default useFileUpload;

View file

@ -0,0 +1,2 @@
export { FileUploader } from './FileUploader';
export { useFileUpload } from './hooks';

View file

@ -70,6 +70,12 @@ export const deviceManagerSlice = createSlice({
state.activeSuite = action.payload.id;
window.electron.store.set('deviceManager.previewSuites', suites);
},
addSuites(state, action: PayloadAction<PreviewSuite[]>) {
window.electron.store.set('deviceManager.previewSuites', []);
state.suites = action.payload;
state.activeSuite = action.payload[0].id;
window.electron.store.set('deviceManager.previewSuites', action.payload);
},
deleteSuite(state, action: PayloadAction<string>) {
const suites: PreviewSuite[] = window.electron.store.get(
'deviceManager.previewSuites'
@ -93,6 +99,7 @@ export const {
setActiveSuite,
addSuite,
deleteSuite,
addSuites,
} = deviceManagerSlice.actions;
export const selectSuites = (state: RootState) => state.deviceManager.suites;