* 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>
);
};
@@ -0,0 +1,36 @@
/**
* Class Event Provider adds functions specific for handling event data
*/
export class DataProvider {
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
*/
static safeMerge(existing, newData) {
const mergedData = { ...existing };
if (typeof newData?.events !== 'undefined') {
mergedData.events = newData.events;
}
if (typeof newData?.event !== 'undefined') {
mergedData.event = { ...newData.event };
}
if (typeof newData?.settings !== 'undefined') {
mergedData.settings = { ...newData.settings };
}
if (typeof newData?.osc !== 'undefined') {
mergedData.osc = { ...newData.osc };
}
if (typeof newData?.http !== 'undefined') {
mergedData.http = { ...newData.http };
}
if (typeof newData?.aliases !== 'undefined') {
mergedData.aliases = [...newData.aliases];
}
if (typeof newData?.userFields !== 'undefined') {
mergedData.userFields = { ...existing.userFields, ...newData.userFields };
}
return mergedData;
}
}
@@ -0,0 +1,97 @@
import { DataProvider } from '../DataProvider';
describe('DataProvider', () => {
describe('safeMerge()', () => {
it('merges two objects ', () => {
const oldData = {
events: [{ event: 'old event' }],
event: {
title: 'old title',
url: 'old url',
endMessage: 'old end message',
},
osc: {
port: 'old port',
},
settings: {
app: 'ontime',
version: 1,
serverPort: 4001,
lock: null,
pinCode: null,
timeFormat: '24',
},
userFields: {
user0: 'old 0',
user1: 'old 1',
user2: 'old 2',
user3: 'old 3',
user4: 'old 4',
user5: 'old 5',
user6: 'old 6',
user7: 'old 7',
user8: 'old 8',
user9: 'old 9',
},
};
const newData = {
events: [{ event: 'new event' }],
event: {
title: 'new title',
url: 'new url',
endMessage: 'old end message',
publicInfo: 'new public info',
},
settings: {
app: 'ontime',
version: 1,
serverPort: 4001,
lock: null,
pinCode: null,
timeFormat: '24',
},
userFields: {
user6: 'new 6',
user7: 'new 7',
user8: 'new 8',
user9: 'new 9',
},
};
const expected = {
events: [{ event: 'new event' }],
event: {
title: 'new title',
url: 'new url',
publicInfo: 'new public info',
endMessage: 'old end message',
},
osc: {
port: 'old port',
},
settings: {
app: 'ontime',
version: 1,
serverPort: 4001,
lock: null,
pinCode: null,
timeFormat: '24',
},
userFields: {
user0: 'old 0',
user1: 'old 1',
user2: 'old 2',
user3: 'old 3',
user4: 'old 4',
user5: 'old 5',
user6: 'new 6',
user7: 'new 7',
user8: 'new 8',
user9: 'new 9',
},
};
const merged = DataProvider.safeMerge(oldData, newData);
expect(merged).toStrictEqual(expected);
});
});
});
+16 -30
View File
@@ -4,6 +4,7 @@ import { networkInterfaces } from 'os';
import { fileHandler } from '../utils/parser.js';
import { generateId } from '../utils/generate_id.js';
import { resolveDbPath } from '../modules/loadDb.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
@@ -33,11 +34,7 @@ export const dbDownload = async (req, res) => {
});
};
/**
* @description Controller for POST request to /ontime/db
* @returns none
*/
const upload = async (file, req, res) => {
const uploadAndParse = async (file, req, res, options) => {
if (!fs.existsSync(file)) {
res.status(500).send({ message: 'Upload failed' });
return;
@@ -50,29 +47,18 @@ const upload = async (file, req, res) => {
res.status(400).send({ message: result.message });
} else if (result.message === 'success') {
// explicitly write objects
if (typeof result.data !== 'undefined') {
if (typeof result.data?.events !== 'undefined') {
data.events = result.data.events;
global.timer.setupWithEventList(result.data?.events);
}
if (typeof result.data?.event !== 'undefined') {
data.event = result.data.event;
}
if (typeof result.data?.settings !== 'undefined') {
data.settings = result.data.settings;
}
if (typeof result.data?.osc !== 'undefined') {
data.osc = result.data.osc;
}
if (typeof result.data?.http !== 'undefined') {
data.http = result.data.http;
}
if (typeof result.data?.aliases !== 'undefined') {
data.aliases = result.data.aliases;
}
if (typeof result.data?.userFields !== 'undefined') {
data.userFields = result.data.userFields;
if (typeof result !== 'undefined') {
if (!options.onlyEvents) {
const mergedData = DataProvider.safeMerge(data, result.data);
data.event = mergedData.event;
data.settings = mergedData.settings;
data.osc = mergedData.osc;
data.http = mergedData.http;
data.aliases = mergedData.aliases;
data.userFields = mergedData.userFields;
}
data.events = result.data.events || [];
global.timer.setupWithEventList(result.data.events || []);
await db.write();
}
res.sendStatus(200);
@@ -297,9 +283,9 @@ export const dbUpload = async (req, res) => {
res.status(400).send({ message: 'File not found' });
return;
}
const options = req.query;
const file = req.file.path;
upload(file, req, res);
uploadAndParse(file, req, res, options);
};
// Create controller for POST request to '/ontime/dbpath'
@@ -309,5 +295,5 @@ export const dbPathToUpload = async (req, res) => {
res.status(400).send({ message: 'Path to file not found' });
return;
}
upload(req.body.path, req, res);
uploadAndParse(req.body.path, req, res);
};
@@ -564,6 +564,9 @@ describe('test parseExcel function', () => {
[],
['Event Name', 'Test Event'],
['Event URL', 'www.carlosvalente.com'],
['Public Info', 'test public info'],
['Backstage Info', 'test backstage info'],
['End Message', 'test end message'],
[],
[],
[
@@ -640,6 +643,14 @@ describe('test parseExcel function', () => {
[],
];
const expectedParsedEvent = {
title: 'Test Event',
url: 'www.carlosvalente.com',
publicInfo: 'test public info',
backstageInfo: 'test backstage info',
endMessage: 'test end message',
};
const expectedParsedEvents = [
{
timeStart: 25200000,
@@ -681,6 +692,7 @@ describe('test parseExcel function', () => {
const parsedData = await parseExcel_v1(testdata);
expect(parsedData.event).toStrictEqual(expectedParsedEvent);
expect(parsedData.events).toBeDefined();
expect(parsedData.events.title).toBe(expectedParsedEvents.title);
expect(parsedData.events.presenter).toBe(expectedParsedEvents.presenter);
+25 -2
View File
@@ -68,6 +68,9 @@ export const parseExcel_v1 = async (excelData) => {
.forEach((row) => {
let eventTitleNext = false;
let eventUrlNext = false;
let publicInfoNext = false;
let backstageInfoNext = false;
let endMessageNext = false;
const event = {};
row.forEach((column, j) => {
@@ -78,6 +81,15 @@ export const parseExcel_v1 = async (excelData) => {
} else if (eventUrlNext) {
eventData.url = column;
eventUrlNext = false;
} else if (publicInfoNext) {
eventData.publicInfo = column;
publicInfoNext = false;
} else if (backstageInfoNext) {
eventData.backstageInfo = column;
backstageInfoNext = false;
} else if (endMessageNext) {
eventData.endMessage = column;
endMessageNext = false;
} else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column);
} else if (j === timeEndIndex) {
@@ -128,6 +140,15 @@ export const parseExcel_v1 = async (excelData) => {
case 'event url':
eventUrlNext = true;
break;
case 'public info':
publicInfoNext = true;
break;
case 'backstage info':
backstageInfoNext = true;
break;
case 'end message':
endMessageNext = true;
break;
case 'time start':
case 'start':
timeStartIndex = j;
@@ -329,7 +350,6 @@ export const fileHandler = async (file) => {
let res = {};
// check which file type are we dealing with
if (file.endsWith('.xlsx')) {
try {
const excelData = xlsx
@@ -341,7 +361,10 @@ export const fileHandler = async (file) => {
// we only look at worksheets called ontime or event schedule
if (excelData?.data) {
const dataFromExcel = await parseExcel_v1(excelData.data);
res.data = await parseJson_v1(dataFromExcel);
res.data = {};
res.data.events = parseEvents_v1(dataFromExcel);
res.data.event = parseEvent_v1(dataFromExcel, true);
res.data.userFields = parseUserFields_v1(dataFromExcel);
res.message = 'success';
} else {
console.log('Error: No sheets found named ontime or event schedule');
+67 -55
View File
@@ -14,37 +14,41 @@ export const parseEvents_v1 = (data) => {
if ('events' in data) {
console.log('Found events definition, importing...');
const events = [];
const ids = [];
for (const e of data.events) {
// cap number of events
if (events.length >= MAX_EVENTS) {
console.log(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
break;
}
// double check unique ids
if (ids.indexOf(e?.id) !== -1) {
console.log('ERROR: ID collision on import, skipping');
continue;
}
if (e.type === 'event') {
const event = validateEvent_v1(e);
if (event != null) {
events.push(event);
ids.push(event.id);
try {
const ids = [];
for (const e of data.events) {
// cap number of events
if (events.length >= MAX_EVENTS) {
console.log(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
break;
}
// double check unique ids
if (ids.indexOf(e?.id) !== -1) {
console.log('ERROR: ID collision on import, skipping');
continue;
}
if (e.type === 'event') {
const event = validateEvent_v1(e);
if (event != null) {
events.push(event);
ids.push(event.id);
}
} else if (e.type === 'delay') {
events.push({
...delayDef,
duration: e.duration,
id: e.id || generateId(),
});
} else if (e.type === 'block') {
events.push({ ...blockDef, id: e.id || generateId() });
} else {
console.log('ERROR: undefined event type, skipping');
}
} else if (e.type === 'delay') {
events.push({
...delayDef,
duration: e.duration,
id: e.id || generateId(),
});
} else if (e.type === 'block') {
events.push({ ...blockDef, id: e.id || generateId() });
} else {
console.log('ERROR: undefined event type, skipping');
}
} catch (error) {
console.log(`Error ${error}`);
}
// write to db
newEvents = events;
@@ -73,7 +77,7 @@ export const parseEvent_v1 = (data, enforce) => {
endMessage: e.endMessage || dbModelv1.event.endMessage,
};
} else if (enforce) {
newEvent = dbModelv1.event;
newEvent = { ...dbModelv1.event };
console.log(`Created event object in db`);
}
return newEvent;
@@ -137,7 +141,7 @@ export const parseOsc_v1 = (data, enforce) => {
...osc,
};
} else if (enforce) {
newOsc = dbModelv1.osc;
newOsc = { ...dbModelv1.osc };
console.log(`Created OSC object in db`);
}
return newOsc;
@@ -165,7 +169,7 @@ export const parseHttp_v1 = (data, enforce) => {
...http,
};
} else if (enforce) {
newHttp.http = dbModelv1.http;
newHttp.http = { ...dbModelv1.http };
console.log(`Created http object in db`);
}
return newHttp;
@@ -181,23 +185,27 @@ export const parseAliases_v1 = (data) => {
if ('aliases' in data) {
console.log('Found Aliases definition, importing...');
const ids = [];
for (const a of data.aliases) {
// double check unique ids
if (ids.indexOf(a?.id) !== -1) {
console.log('ERROR: ID collision on import, skipping');
continue;
}
const newAlias = {
id: a.id || generateId(),
enabled: a.enabled || false,
alias: a.alias || '',
pathAndParams: a.pathAndParams || '',
};
try {
for (const a of data.aliases) {
// double check unique ids
if (ids.indexOf(a?.id) !== -1) {
console.log('ERROR: ID collision on import, skipping');
continue;
}
const newAlias = {
id: a.id || generateId(),
enabled: a.enabled || false,
alias: a.alias || '',
pathAndParams: a.pathAndParams || '',
};
ids.push(newAlias.id);
newAliases.push(newAlias);
ids.push(newAlias.id);
newAliases.push(newAlias);
}
console.log(`Uploaded ${newAliases?.length || 0} alias(es)`);
} catch (error) {
console.log(`Error: ${error}`);
}
console.log(`Uploaded ${newAliases?.length || 0} alias(es)`);
}
return newAliases;
};
@@ -208,19 +216,23 @@ export const parseAliases_v1 = (data) => {
* @returns {object} - event object data
*/
export const parseUserFields_v1 = (data) => {
const newUserFields = dbModelv1.userFields;
const newUserFields = { ...dbModelv1.userFields };
if ('userFields' in data) {
console.log('Found User Fields definition, importing...');
// we will only be importing the fields we know, so look for that
let fieldsFound = 0;
for (const n in newUserFields) {
if (n in data.userFields) {
fieldsFound++;
newUserFields[n] = data.userFields[n];
try {
let fieldsFound = 0;
for (const n in newUserFields) {
if (n in data.userFields) {
fieldsFound++;
newUserFields[n] = data.userFields[n];
}
}
console.log(`Uploaded ${fieldsFound} user fields`);
} catch (error) {
console.log(`Error: ${error}`);
}
console.log(`Uploaded ${fieldsFound} user fields`);
}
return { ...dbModelv1.userFields, ...newUserFields };
return { ...newUserFields };
};