* refactor: add typescript dependencies
* chore: update chakra-ui
* refactor: typescript config
* refactor: convert <MenuBar> component to typescript
* refactor: convert <TooltipActionBtn> component to typescript
* refactor: improve UX in file upload
* refactor: upgrade dependencies
* refactor: prepare data provider
* refactor: prevent importing bad fields
* refactor: extract merge to provider
* refactor(upload): parser merges only given fields
* refactor(upload): add event fields to excel
* refactor(upload): improve styling on modal open
* style: improve styling in menu
* style: prevent global pollution
* feat(upload): add upload options
* fix: avoid potential bug in log queue
This commit is contained in:
Carlos Valente
2022-09-17 23:28:52 +02:00
committed by GitHub
parent 94b03312a6
commit 3128f5a195
25 changed files with 788 additions and 368 deletions
+2 -5
View File
@@ -2,15 +2,12 @@
"extends": [
"react-app",
"react-app/jest",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
"plugin:react/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@typescript-eslint"
"simple-import-sort"
],
"rules": {
"jest/no-mocks-import": "warn",
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+14 -7
View File
@@ -241,14 +241,21 @@ export const downloadEvents = async () => {
* @description HTTP request to upload events db
* @return {Promise}
*/
export const uploadEvents = async (file) => {
export const uploadEvents = async (file, setProgress, options) => {
const formData = new FormData();
formData.append('userFile', file); // appending file
await axios.post(`${ontimeURL}/db`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
formData.append('userFile', file);
const onlyEvents = options?.onlyEvents;
await axios
.post(`${ontimeURL}/db?onlyEvents=${onlyEvents}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setProgress(complete);
},
})
.then((response) => response.data.id);
};
/**
@@ -1,27 +0,0 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import PropTypes from 'prop-types';
export default function TooltipActionBtn(props) {
const { clickHandler, icon, color, size='xs', tooltip, openDelay = 0, ...rest } = props;
return (
<Tooltip label={tooltip} openDelay={openDelay}>
<IconButton
aria-label={tooltip}
size={size}
icon={icon}
onClick={clickHandler}
{...rest}
/>
</Tooltip>
);
}
TooltipActionBtn.propTypes = {
clickHandler: PropTypes.func,
icon: PropTypes.element,
color: PropTypes.string,
size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']),
tooltip: PropTypes.string,
openDelay: PropTypes.number
}
@@ -0,0 +1,27 @@
import { IconButton } from '@chakra-ui/button';
import { IconButtonProps } from '@chakra-ui/react';
import { Tooltip } from '@chakra-ui/tooltip';
export type Sizes = 'xs' | 'sm' | 'md' | 'lg';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: () => void;
tooltip: string;
openDelay?: number;
}
export default function TooltipActionBtn(props: TooltipActionBtnProps) {
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
return (
<Tooltip label={tooltip} openDelay={openDelay}>
<IconButton
{...rest}
aria-label={tooltip}
size={size}
icon={icon}
onClick={clickHandler}
className={className}
/>
</Tooltip>
);
}
@@ -0,0 +1,45 @@
@use '../../../theme/main' as *;
.modalBody {
min-height: 40vh;
display: flex;
flex-direction: column;
gap: 16px;
.options {
margin-bottom: 1.5em;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.notes {
font-size: 0.9em;
}
.info {
background-color: $bg-gray;
margin: 1em 0;
padding: 0.5em;
border-radius: 2px;
color: black;
position: relative;
}
.corner {
position: absolute;
right: 4px;
top: 4px;
}
.infoList {
font-size: 0.9em;
padding-left: 8px;
}
.flexColumnLeft {
display: flex;
flex-direction: column;
align-items: flex-start;
}
}
@@ -0,0 +1,137 @@
import { ChangeEvent, useCallback, useContext, useRef, useState } from 'react';
import { Button } from '@chakra-ui/button';
import {
Checkbox,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Progress,
} from '@chakra-ui/react';
import { IoCloseSharp } from '@react-icons/all-files/io5/IoCloseSharp';
import { useQueryClient } from '@tanstack/react-query';
import { EVENTS_TABLE } from '../../api/apiConstants';
import { uploadEvents } from '../../api/ontimeApi';
import { LoggingContext } from '../../context/LoggingContext';
import TooltipActionBtn from '../buttons/TooltipActionBtn';
import { validateFile } from './utils';
import style from './UploadModal.module.scss';
interface UploadModalProps {
onClose: () => void;
isOpen: boolean;
}
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const queryClient = useQueryClient();
const { emitError } = useContext(LoggingContext);
const [errors, setErrors] = useState<string[]>([]);
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const overrideOptionRef = useRef<HTMLInputElement>(null);
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const fileUploaded = event?.target?.files?.[0];
if (!fileUploaded) return;
const validate = validateFile(fileUploaded);
setErrors(validate.errors);
if (validate.isValid) {
setFile(fileUploaded);
} else {
setFile(null);
}
}, []);
const handleUpload = useCallback(async () => {
if (file) {
try {
await uploadEvents(file, setProgress, { onlyEvents: overrideOptionRef?.current?.checked });
} catch (error) {
emitError(`Failed uploading file: ${error}`);
} finally {
await queryClient.invalidateQueries(EVENTS_TABLE);
setFile(null);
}
}
}, [emitError, file, queryClient]);
return (
<Modal
onClose={onClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>File upload</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.modalBody}>
<FormControl isInvalid={errors.length > 0}>
<FormLabel>Select file to upload</FormLabel>
<Input type='file' onChange={handleFile} accept='.json, .xlsx' />
{errors.length === 0 ? (
<FormHelperText>.XLSX .JSON with max 1MB</FormHelperText>
) : (
<FormErrorMessage className={style.flexColumnLeft}>
{errors.map((error) => (
<span key={error}>{error}</span>
))}
</FormErrorMessage>
)}
</FormControl>
<div className={style.options}>
<b>Options</b>
<Checkbox ref={overrideOptionRef}>Import only events</Checkbox>
<span className={style.notes}>This will prevent overriding user settings</span>
</div>
{file && (
<div className={style.info}>
<span>File ready to upload</span>
<TooltipActionBtn
clickHandler={() => setFile(null)}
tooltip='Cancel'
aria-label='Cancel'
className={style.corner}
size='sm'
variant='ghosted'
icon={<IoCloseSharp />}
/>
<ul className={style.infoList}>
<li>{file.name}</li>
<li>{`${(file.size / 1024).toFixed(2)}kb`}</li>
<li>{file.type}</li>
</ul>
</div>
)}
<Progress value={progress} />
</ModalBody>
<ModalFooter>
<Button
colorScheme='blue'
disabled={!file || errors.length > 0}
onClick={handleUpload}
isLoading={progress < 0 && progress >= 100}
>
Upload
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,25 @@
type ValidationStatus = {
errors: string[];
isValid: boolean;
};
export function validateFile(file: File): ValidationStatus {
const status:ValidationStatus = { errors: [], isValid: true };
if (!file) {
status.errors.push('No file to upload');
status.isValid = false;
}
// Limit file size to 1MB
if (file.size > 1000000) {
status.errors.push('File size limit (1MB) exceeded');
status.isValid = false;
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
status.errors.push('Unhandled file type');
status.isValid = false;
}
return status;
}
@@ -1,22 +1,47 @@
import { createContext, useCallback, useEffect, useState } from 'react';
import { createContext, ReactNode, useCallback, useEffect, useState } from 'react';
import { generateId } from '../utils/generate_id';
import { nowInMillis, stringFromMillis } from '../utils/time';
import { useSocket } from './socketContext';
export const LoggingContext = createContext({
type LOG_LEVEL = 'INFO' | 'WARN' | 'ERROR';
type Log = {
id: string;
origin: string;
time: string;
level: LOG_LEVEL;
text: string;
};
interface LoggingProviderState {
logData: Log[];
emitInfo: (text: string) => void;
emitWarning: (text: string) => void;
emitError: (text: string) => void;
clearLog: () => void;
}
type LoggingProviderProps = {
children: ReactNode
}
const notInitialised = () => {
throw new Error("Not initialised");
};
export const LoggingContext = createContext<LoggingProviderState>({
logData: [],
emitInfo: () => undefined,
emitWarning: () => undefined,
emitError: () => undefined,
clearLog: () => undefined,
emitInfo: notInitialised,
emitWarning: notInitialised,
emitError: notInitialised,
clearLog: notInitialised
});
export const LoggingProvider = ({ children }) => {
export const LoggingProvider = ({ children }: LoggingProviderProps) => {
const MAX_MESSAGES = 100;
const socket = useSocket();
const [logData, setLogData] = useState([]);
const [logData, setLogData] = useState<Log[]>([]);
const origin = 'USER';
// handle incoming messages
@@ -26,7 +51,7 @@ export const LoggingProvider = ({ children }) => {
// Ask for log data
socket.emit('get-logger');
socket.on('logger', (data) => {
socket.on('logger', (data: Log) => {
setLogData((l) => [data, ...l]);
});
@@ -43,9 +68,9 @@ export const LoggingProvider = ({ children }) => {
* @private
*/
const _send = useCallback(
(text, level) => {
(text: string, level: LOG_LEVEL) => {
if (socket != null) {
const m = {
const m: Log = {
id: generateId(),
origin,
time: stringFromMillis(nowInMillis()),
@@ -56,7 +81,7 @@ export const LoggingProvider = ({ children }) => {
socket.emit('logger', m);
}
if (logData.length > MAX_MESSAGES) {
setLogData((l) => l.pop());
setLogData((l) => l.slice(1));
}
},
[logData, socket]
@@ -67,7 +92,7 @@ export const LoggingProvider = ({ children }) => {
* @param text
*/
const emitInfo = useCallback(
(text) => {
(text: string) => {
_send(text, 'INFO');
},
[_send]
@@ -78,7 +103,7 @@ export const LoggingProvider = ({ children }) => {
* @param text
*/
const emitWarning = useCallback(
(text) => {
(text: string) => {
_send(text, 'WARN');
},
[_send]
@@ -89,7 +114,7 @@ export const LoggingProvider = ({ children }) => {
* @param text
*/
const emitError = useCallback(
(text) => {
(text: string) => {
_send(text, 'ERROR');
},
[_send]
@@ -1,24 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { serverURL } from 'common/api/apiConstants';
import io from 'socket.io-client';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const SocketContext = createContext([[], () => {}]);
export const useSocket = () => {
return useContext(SocketContext);
};
function SocketProvider({ children }) {
const [socket, setSocket] = useState();
useEffect(() => {
const s = io(serverURL, { transports: ['websocket'] });
setSocket(s);
return () => s.disconnect();
}, []);
return <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>;
}
export default SocketProvider;
@@ -0,0 +1,44 @@
// @ts-nocheck
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
import { serverURL } from 'common/api/apiConstants';
import io, { Socket } from 'socket.io-client';
interface SocketProviderState {
socket: Socket | null;
emit: <T>(topic: string, payload?: T) => void;
on: <T>(topic: string, callback: (data: T) => void) => void;
off: (topic: string) => void;
}
type SocketProviderProps = {
children: ReactNode;
};
const SocketContext = createContext<SocketProviderState>({
socket: null,
emit: () => {},
on: () => {},
off: () => {}
});
export const useSocket = () => {
return useContext(SocketContext);
};
function SocketProvider({ children }: SocketProviderProps) {
const [socket, setSocket] = useState({} as Socket);
useEffect(() => {
const socketInstance = io(serverURL, { transports: ["websocket"] });
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, []);
return (
<SocketContext.Provider value={socket}>{children}</SocketContext.Provider>
);
}
export default SocketProvider;
@@ -0,0 +1,12 @@
// @ts-nocheck
export default function useElectronEvent() {
const isElectron = window?.process?.type === 'renderer';
const sendToElectron = (channel: string, args: any) => {
if (isElectron) {
window?.ipcRenderer.send(channel, args);
}
};
return { isElectron, sendToElectron };
}
+10
View File
@@ -0,0 +1,10 @@
declare module '*.scss' {
const content: Record<string, string>;
export default content;
}
declare namespace NodeJS {
export interface ProcessEnv {
type: string
}
}
@@ -1,9 +1,10 @@
import { lazy } from 'react';
import { lazy, useEffect } from 'react';
import { useDisclosure } from '@chakra-ui/hooks';
import { Box } from '@chakra-ui/layout';
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
import ModalManager from 'features/modals/ModalManager';
import UploadModal from '../../common/components/upload-modal/UploadModal';
import { LoggingProvider } from '../../common/context/LoggingContext';
import MenuBar from '../menu/MenuBar';
@@ -15,23 +16,42 @@ const MessageControl = lazy(() => import('features/control/message/MessageContro
const Info = lazy(() => import('features/info/InfoExport'));
export default function Editor() {
const { isOpen, onOpen, onClose } = useDisclosure();
const {
isOpen: isSettingsOpen,
onOpen: onSettingsOpen,
onClose: onSettingsClose,
} = useDisclosure();
const {
isOpen: isUploadModalOpen,
onOpen: onUploadModalOpen,
onClose: onUploadModalClose,
} = useDisclosure();
// Set window title
document.title = 'ontime - Editor';
useEffect(() => {
document.title = 'ontime - Editor';
}, []);
return (
<LoggingProvider>
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<ErrorBoundary>
<ModalManager isOpen={isOpen} onClose={onClose} />
<ModalManager isOpen={isSettingsOpen} onClose={onSettingsClose} />
</ErrorBoundary>
<div className={styles.mainContainer}>
<Box id='settings' className={styles.settings}>
<ErrorBoundary>
<MenuBar onOpen={onOpen} isOpen={isOpen} onClose={onClose} />
<MenuBar
onSettingsOpen={onSettingsOpen}
isSettingsOpen={isSettingsOpen}
onSettingsClose={onSettingsClose}
isUploadOpen={isUploadModalOpen}
onUploadOpen={onUploadModalOpen}
/>
</ErrorBoundary>
</Box>
<EventList onOpen={onOpen} isOpen={isOpen} onClose={onClose} />
<EventList />
<MessageControl />
<TimerControl />
<Info />
@@ -85,8 +85,3 @@
padding: 0 0.5em;
margin: 0 0.5em;
}
ul > li {
font-size: 0.9em;
color: $text-white;
}
+4 -3
View File
@@ -85,14 +85,14 @@ const EventListMenu = ({ eventsHandler }) => {
clickHandler={() => actionHandler('cursorUp')}
icon={<IoCaretUp />}
tooltip='Move cursor up Alt + ↑'
_hover={{ bg: 'pink.400' }}
_hover={{ bg: 'pink.400', color: 'white' }}
/>
<TooltipActionBtn
{...cursorBtnProps}
clickHandler={() => actionHandler('cursorDown')}
icon={<IoCaretDown />}
tooltip='Move cursor down Alt + ↓'
_hover={{ bg: 'pink.400' }}
_hover={{ bg: 'pink.400', color: 'white' }}
/>
<TooltipActionBtn
{...cursorBtnProps}
@@ -101,7 +101,8 @@ const EventListMenu = ({ eventsHandler }) => {
tooltip='Lock cursor to current'
width='3em'
backgroundColor={isCursorLocked && 'pink.400'}
_hover={{ bg: 'pink.300' }}
color={isCursorLocked && 'white'}
_hover={{ bg: 'pink.400', color: 'white' }}
variant={isCursorLocked ? 'solid' : 'outline'}
/>
</ButtonGroup>
-188
View File
@@ -1,188 +0,0 @@
import { useCallback, useContext, useEffect, useRef } from 'react';
import { VStack } from '@chakra-ui/react';
import { FiDownload } from '@react-icons/all-files/fi/FiDownload';
import { FiHelpCircle } from '@react-icons/all-files/fi/FiHelpCircle';
import { FiMaximize } from '@react-icons/all-files/fi/FiMaximize';
import { FiMinimize } from '@react-icons/all-files/fi/FiMinimize';
import { FiSettings } from '@react-icons/all-files/fi/FiSettings';
import { FiUpload } from '@react-icons/all-files/fi/FiUpload';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { EVENTS_TABLE } from 'common/api/apiConstants';
import { downloadEvents, uploadEvents } from 'common/api/ontimeApi';
import PropTypes from 'prop-types';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import { LoggingContext } from '../../common/context/LoggingContext';
import style from './MenuBar.module.scss';
export default function MenuBar(props) {
const { isOpen, onOpen, onClose } = props;
const { emitError } = useContext(LoggingContext);
const hiddenFileInput = useRef(null);
const queryClient = useQueryClient();
const uploaddb = useMutation(uploadEvents, {
onSettled: () => {
queryClient.invalidateQueries(EVENTS_TABLE);
},
});
const handleClick = useCallback(() => {
if (hiddenFileInput && hiddenFileInput.current) {
hiddenFileInput.current.click();
}
}, [hiddenFileInput]);
const buttonStyle = {
fontSize: '1.5em',
size: 'lg',
colorScheme: 'white',
};
const handleUpload = useCallback(
(event) => {
const fileUploaded = event.target.files[0];
if (fileUploaded == null) return;
// Limit file size to 1MB
if (fileUploaded.size > 1000000) {
emitError('Error: File size limit (1MB) exceeded');
return;
}
// Check file extension
if (fileUploaded.name.endsWith('.xlsx') || fileUploaded.name.endsWith('.json')) {
try {
uploaddb.mutate(fileUploaded);
} catch (error) {
emitError(`Failed uploading file: ${error}`);
}
} else {
emitError('Error: File type unknown');
}
// reset input value
hiddenFileInput.current.value = '';
},
[emitError, uploaddb]
);
const handleIPC = useCallback((action) => {
// Stop crashes when testing locally
if (typeof window.process?.type === 'undefined') {
if (action === 'help') {
window.open('https://cpvalente.gitbook.io/ontime/');
}
return;
}
if (window.process?.type === 'renderer') {
switch (action) {
case 'min':
window.ipcRenderer.send('set-window', 'to-tray');
break;
case 'max':
window.ipcRenderer.send('set-window', 'to-max');
break;
case 'shutdown':
window.ipcRenderer.send('shutdown', 'now');
break;
case 'help':
window.ipcRenderer.send('send-to-link', 'help');
break;
default:
break;
}
}
}, []);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(e) => {
// handle held key
if (e.repeat) return;
// check if the alt key is pressed
if (e.ctrlKey) {
if (e.key === ',') {
// if we are in electron
if (window.process?.type === undefined) return;
if (window.process.type === 'renderer') {
// open if not open
isOpen ? onClose() : onOpen();
}
}
}
},
[isOpen, onClose, onOpen]
);
useEffect(() => {
// attach the event listener
document.addEventListener('keydown', handleKeyPress);
// remove the event listener
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [handleKeyPress]);
return (
<VStack>
<QuitIconBtn clickHandler={() => handleIPC('shutdown')} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiMaximize />}
clickHandler={() => handleIPC('max')}
tooltip='Show full window'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiMinimize />}
clickHandler={() => handleIPC('min')}
tooltip='Close to tray'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiHelpCircle />}
clickHandler={() => handleIPC('help')}
tooltip='Help'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiSettings />}
className={isOpen ? style.open : ''}
clickHandler={onOpen}
tooltip='Settings'
isRound
/>
<div className={style.gap} />
<input
type='file'
style={{ display: 'none' }}
ref={hiddenFileInput}
onChange={handleUpload}
accept='.json, .xlsx'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiUpload />}
clickHandler={handleClick}
tooltip='Import event list'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiDownload />}
clickHandler={downloadEvents}
tooltip='Export event list'
/>
</VStack>
);
}
MenuBar.propTypes = {
isOpen: PropTypes.bool,
onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
+145
View File
@@ -0,0 +1,145 @@
import { useCallback, useEffect } from 'react';
import { VStack } from '@chakra-ui/react';
import { FiHelpCircle } from '@react-icons/all-files/fi/FiHelpCircle';
import { FiMaximize } from '@react-icons/all-files/fi/FiMaximize';
import { FiMinimize } from '@react-icons/all-files/fi/FiMinimize';
import { FiSave } from '@react-icons/all-files/fi/FiSave';
import { FiSettings } from '@react-icons/all-files/fi/FiSettings';
import { FiUpload } from '@react-icons/all-files/fi/FiUpload';
import { downloadEvents } from 'common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import style from './MenuBar.module.scss';
interface MenuBarProps {
isSettingsOpen: boolean;
onSettingsOpen: () => void;
onSettingsClose: () => void;
isUploadOpen: boolean;
onUploadOpen: () => void;
}
type Actions = 'min' | 'max' | 'shutdown' | 'help';
const buttonStyle = {
fontSize: '1.5em',
size: 'lg',
colorScheme: 'white',
};
export default function MenuBar(props: MenuBarProps) {
const { isSettingsOpen, onSettingsOpen, onSettingsClose, isUploadOpen, onUploadOpen } = props;
const { isElectron, sendToElectron } = useElectronEvent();
const actionHandler = useCallback((action: Actions) => {
// Stop crashes when testing locally
if (!isElectron) {
if (action === 'help') {
window.open('https://cpvalente.gitbook.io/ontime/');
}
} else {
switch (action) {
case 'min':
sendToElectron('set-window', 'to-tray');
break;
case 'max':
sendToElectron('set-window', 'to-max');
break;
case 'shutdown':
sendToElectron('shutdown', 'now');
break;
case 'help':
sendToElectron('send-to-link', 'help');
break;
default:
break;
}
}
}, [sendToElectron, isElectron]);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// skip if not electron
if (!isElectron) return;
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey) {
// ctrl + , (settings)
if (event.key === ',') {
if (isElectron) {
// open if not open
isSettingsOpen ? onSettingsClose() : onSettingsOpen();
}
}
}
},
[isElectron, isSettingsOpen, onSettingsClose, onSettingsOpen]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [handleKeyPress]);
return (
<VStack>
<QuitIconBtn clickHandler={() => actionHandler('shutdown')} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiMaximize />}
clickHandler={() => actionHandler('max')}
tooltip='Show full window'
aria-label=''
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiMinimize />}
clickHandler={() => actionHandler('min')}
tooltip='Close to tray'
aria-label=''
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiHelpCircle />}
clickHandler={() => actionHandler('help')}
tooltip='Help'
aria-label=''
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiSettings />}
className={isSettingsOpen ? style.open : ''}
clickHandler={onSettingsOpen}
tooltip='Settings'
isRound
aria-label=''
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiUpload />}
className={isUploadOpen ? style.open : ''}
clickHandler={onUploadOpen}
tooltip='Upload event list'
isRound
aria-label=''
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiSave />}
clickHandler={downloadEvents}
tooltip='Export event list'
aria-label=''
/>
</VStack>
);
}
@@ -6,11 +6,13 @@ import MenuBar from '../MenuBar';
const onOpenHandler = jest.fn();
const onCloseHandler = jest.fn();
const isOpen = false;
const onUploadOpenHandler = jest.fn();
const renderInMock = () => {
render(
<QueryClientProvider client={queryClientMock}>
<MenuBar onOpen={onOpenHandler} onClose={onCloseHandler} />
<MenuBar onOpen={onOpenHandler} onClose={onCloseHandler} isOpen={isOpen} onUploadOpen={onUploadOpenHandler} />
</QueryClientProvider>
);
};