mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 21:54:09 +00:00
Fix/195 (#200)
* 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:
+2
-5
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -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;
|
||||
}
|
||||
+40
-15
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user