mirror of
https://github.com/responsively-org/responsively-app
synced 2024-11-10 06:44:13 +00:00
import
This commit is contained in:
parent
744cdff065
commit
c177880d11
13 changed files with 401 additions and 52 deletions
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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">
|
||||
|
|
|
@ -168,6 +168,13 @@ const DeviceManager = () => {
|
|||
device={selectedDevice}
|
||||
onRemoveDevice={onRemoveDevice}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
customDevices.forEach((device) => onRemoveDevice(device))
|
||||
}
|
||||
>
|
||||
remove all
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { useFileUpload } from './useFileUpload';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
export { FileUploader } from './FileUploader';
|
||||
export { useFileUpload } from './hooks';
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue