From 3128f5a195744b841e19c78176da488e191267f7 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Sat, 17 Sep 2022 23:28:52 +0200 Subject: [PATCH] Fix/195 (#200) * refactor: add typescript dependencies * chore: update chakra-ui * refactor: typescript config * refactor: convert component to typescript * refactor: convert 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 --- client/.eslintrc | 7 +- client/react-app-env.d.ts | 1 + client/src/common/api/ontimeApi.js | 21 +- .../components/buttons/TooltipActionBtn.jsx | 27 --- .../components/buttons/TooltipActionBtn.tsx | 27 +++ .../upload-modal/UploadModal.module.scss | 45 +++++ .../components/upload-modal/UploadModal.tsx | 137 +++++++++++++ .../common/components/upload-modal/utils.ts | 25 +++ ...{LoggingContext.jsx => LoggingContext.tsx} | 55 +++-- client/src/common/context/socketContext.jsx | 24 --- client/src/common/context/socketContext.tsx | 44 ++++ client/src/common/hooks/useElectronEvent.ts | 12 ++ client/src/declarations/declaration.d.ts | 10 + .../editors/{Editor.jsx => Editor.tsx} | 32 ++- client/src/features/info/Info.module.scss | 5 - client/src/features/menu/EventListMenu.jsx | 7 +- client/src/features/menu/MenuBar.jsx | 188 ------------------ client/src/features/menu/MenuBar.tsx | 145 ++++++++++++++ .../{MenuBar.test.js => MenuBar.test.tsx} | 4 +- .../src/classes/data-provider/DataProvider.js | 36 ++++ .../__tests__/DataProvider.test.js | 97 +++++++++ server/src/controllers/ontimeController.js | 46 ++--- server/src/utils/__tests__/parser.tests.js | 12 ++ server/src/utils/parser.js | 27 ++- server/src/utils/parserUtils_v1.js | 122 +++++++----- 25 files changed, 788 insertions(+), 368 deletions(-) create mode 100644 client/react-app-env.d.ts create mode 100644 client/src/common/components/buttons/TooltipActionBtn.tsx create mode 100644 client/src/common/components/upload-modal/UploadModal.module.scss create mode 100644 client/src/common/components/upload-modal/UploadModal.tsx create mode 100644 client/src/common/components/upload-modal/utils.ts rename client/src/common/context/{LoggingContext.jsx => LoggingContext.tsx} (61%) delete mode 100644 client/src/common/context/socketContext.jsx create mode 100644 client/src/common/context/socketContext.tsx create mode 100644 client/src/common/hooks/useElectronEvent.ts create mode 100644 client/src/declarations/declaration.d.ts rename client/src/features/editors/{Editor.jsx => Editor.tsx} (55%) create mode 100644 client/src/features/menu/MenuBar.tsx rename client/src/features/menu/__tests__/{MenuBar.test.js => MenuBar.test.tsx} (77%) create mode 100644 server/src/classes/data-provider/DataProvider.js create mode 100644 server/src/classes/data-provider/__tests__/DataProvider.test.js diff --git a/client/.eslintrc b/client/.eslintrc index a45b45274..142b2b4a4 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -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", diff --git a/client/react-app-env.d.ts b/client/react-app-env.d.ts new file mode 100644 index 000000000..6889c6b37 --- /dev/null +++ b/client/react-app-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/client/src/common/api/ontimeApi.js b/client/src/common/api/ontimeApi.js index 59d554f32..5abb115f0 100644 --- a/client/src/common/api/ontimeApi.js +++ b/client/src/common/api/ontimeApi.js @@ -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); }; /** diff --git a/client/src/common/components/buttons/TooltipActionBtn.jsx b/client/src/common/components/buttons/TooltipActionBtn.jsx index 9dd453bfe..e69de29bb 100644 --- a/client/src/common/components/buttons/TooltipActionBtn.jsx +++ b/client/src/common/components/buttons/TooltipActionBtn.jsx @@ -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 ( - - - - ); -} - -TooltipActionBtn.propTypes = { - clickHandler: PropTypes.func, - icon: PropTypes.element, - color: PropTypes.string, - size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), - tooltip: PropTypes.string, - openDelay: PropTypes.number -} diff --git a/client/src/common/components/buttons/TooltipActionBtn.tsx b/client/src/common/components/buttons/TooltipActionBtn.tsx new file mode 100644 index 000000000..ea7acc851 --- /dev/null +++ b/client/src/common/components/buttons/TooltipActionBtn.tsx @@ -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 ( + + + + ); +} diff --git a/client/src/common/components/upload-modal/UploadModal.module.scss b/client/src/common/components/upload-modal/UploadModal.module.scss new file mode 100644 index 000000000..a09e57d5e --- /dev/null +++ b/client/src/common/components/upload-modal/UploadModal.module.scss @@ -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; + } +} diff --git a/client/src/common/components/upload-modal/UploadModal.tsx b/client/src/common/components/upload-modal/UploadModal.tsx new file mode 100644 index 000000000..f8a448d3c --- /dev/null +++ b/client/src/common/components/upload-modal/UploadModal.tsx @@ -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([]); + const [file, setFile] = useState(null); + const [progress, setProgress] = useState(0); + const overrideOptionRef = useRef(null); + + const handleFile = useCallback((event: ChangeEvent) => { + 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 ( + + + + File upload + + + 0}> + Select file to upload + + {errors.length === 0 ? ( + .XLSX .JSON with max 1MB + ) : ( + + {errors.map((error) => ( + {error} + ))} + + )} + +
+ Options + Import only events + This will prevent overriding user settings +
+ {file && ( +
+ File ready to upload + setFile(null)} + tooltip='Cancel' + aria-label='Cancel' + className={style.corner} + size='sm' + variant='ghosted' + icon={} + /> +
    +
  • {file.name}
  • +
  • {`${(file.size / 1024).toFixed(2)}kb`}
  • +
  • {file.type}
  • +
+
+ )} + +
+ + + +
+
+ ); +} diff --git a/client/src/common/components/upload-modal/utils.ts b/client/src/common/components/upload-modal/utils.ts new file mode 100644 index 000000000..ca3902559 --- /dev/null +++ b/client/src/common/components/upload-modal/utils.ts @@ -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; +} diff --git a/client/src/common/context/LoggingContext.jsx b/client/src/common/context/LoggingContext.tsx similarity index 61% rename from client/src/common/context/LoggingContext.jsx rename to client/src/common/context/LoggingContext.tsx index 973a3d744..e431cb56f 100644 --- a/client/src/common/context/LoggingContext.jsx +++ b/client/src/common/context/LoggingContext.tsx @@ -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({ 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([]); 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] diff --git a/client/src/common/context/socketContext.jsx b/client/src/common/context/socketContext.jsx deleted file mode 100644 index 55b55f19b..000000000 --- a/client/src/common/context/socketContext.jsx +++ /dev/null @@ -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 {children}; -} - -export default SocketProvider; diff --git a/client/src/common/context/socketContext.tsx b/client/src/common/context/socketContext.tsx new file mode 100644 index 000000000..f7304c1cc --- /dev/null +++ b/client/src/common/context/socketContext.tsx @@ -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: (topic: string, payload?: T) => void; + on: (topic: string, callback: (data: T) => void) => void; + off: (topic: string) => void; +} + +type SocketProviderProps = { + children: ReactNode; +}; + +const SocketContext = createContext({ + 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 ( + {children} + ); +} + +export default SocketProvider; diff --git a/client/src/common/hooks/useElectronEvent.ts b/client/src/common/hooks/useElectronEvent.ts new file mode 100644 index 000000000..504c76d60 --- /dev/null +++ b/client/src/common/hooks/useElectronEvent.ts @@ -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 }; +} diff --git a/client/src/declarations/declaration.d.ts b/client/src/declarations/declaration.d.ts new file mode 100644 index 000000000..77a73098f --- /dev/null +++ b/client/src/declarations/declaration.d.ts @@ -0,0 +1,10 @@ +declare module '*.scss' { + const content: Record; + export default content; +} + +declare namespace NodeJS { + export interface ProcessEnv { + type: string + } +} diff --git a/client/src/features/editors/Editor.jsx b/client/src/features/editors/Editor.tsx similarity index 55% rename from client/src/features/editors/Editor.jsx rename to client/src/features/editors/Editor.tsx index ad5b36254..464fa17d4 100644 --- a/client/src/features/editors/Editor.jsx +++ b/client/src/features/editors/Editor.tsx @@ -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 ( + - +
- + - + diff --git a/client/src/features/info/Info.module.scss b/client/src/features/info/Info.module.scss index fe1634c09..26a64cafb 100644 --- a/client/src/features/info/Info.module.scss +++ b/client/src/features/info/Info.module.scss @@ -85,8 +85,3 @@ padding: 0 0.5em; margin: 0 0.5em; } - -ul > li { - font-size: 0.9em; - color: $text-white; -} diff --git a/client/src/features/menu/EventListMenu.jsx b/client/src/features/menu/EventListMenu.jsx index 6cc9a6139..d436f498a 100644 --- a/client/src/features/menu/EventListMenu.jsx +++ b/client/src/features/menu/EventListMenu.jsx @@ -85,14 +85,14 @@ const EventListMenu = ({ eventsHandler }) => { clickHandler={() => actionHandler('cursorUp')} icon={} tooltip='Move cursor up Alt + ↑' - _hover={{ bg: 'pink.400' }} + _hover={{ bg: 'pink.400', color: 'white' }} /> actionHandler('cursorDown')} icon={} tooltip='Move cursor down Alt + ↓' - _hover={{ bg: 'pink.400' }} + _hover={{ bg: 'pink.400', color: 'white' }} /> { 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'} /> diff --git a/client/src/features/menu/MenuBar.jsx b/client/src/features/menu/MenuBar.jsx index f1d19c6f5..e69de29bb 100644 --- a/client/src/features/menu/MenuBar.jsx +++ b/client/src/features/menu/MenuBar.jsx @@ -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 ( - - handleIPC('shutdown')} /> - } - clickHandler={() => handleIPC('max')} - tooltip='Show full window' - /> - } - clickHandler={() => handleIPC('min')} - tooltip='Close to tray' - /> -
- } - clickHandler={() => handleIPC('help')} - tooltip='Help' - /> - } - className={isOpen ? style.open : ''} - clickHandler={onOpen} - tooltip='Settings' - isRound - /> -
- - } - clickHandler={handleClick} - tooltip='Import event list' - /> - } - clickHandler={downloadEvents} - tooltip='Export event list' - /> - - ); -} - -MenuBar.propTypes = { - isOpen: PropTypes.bool, - onOpen: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; diff --git a/client/src/features/menu/MenuBar.tsx b/client/src/features/menu/MenuBar.tsx new file mode 100644 index 000000000..77b23d9ed --- /dev/null +++ b/client/src/features/menu/MenuBar.tsx @@ -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 ( + + actionHandler('shutdown')} /> + } + clickHandler={() => actionHandler('max')} + tooltip='Show full window' + aria-label='' + /> + } + clickHandler={() => actionHandler('min')} + tooltip='Close to tray' + aria-label='' + /> +
+ } + clickHandler={() => actionHandler('help')} + tooltip='Help' + aria-label='' + /> + } + className={isSettingsOpen ? style.open : ''} + clickHandler={onSettingsOpen} + tooltip='Settings' + isRound + aria-label='' + /> +
+ } + className={isUploadOpen ? style.open : ''} + clickHandler={onUploadOpen} + tooltip='Upload event list' + isRound + aria-label='' + /> + } + clickHandler={downloadEvents} + tooltip='Export event list' + aria-label='' + /> + + ); +} diff --git a/client/src/features/menu/__tests__/MenuBar.test.js b/client/src/features/menu/__tests__/MenuBar.test.tsx similarity index 77% rename from client/src/features/menu/__tests__/MenuBar.test.js rename to client/src/features/menu/__tests__/MenuBar.test.tsx index 54aefd185..1b8b1ae3b 100644 --- a/client/src/features/menu/__tests__/MenuBar.test.js +++ b/client/src/features/menu/__tests__/MenuBar.test.tsx @@ -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( - + ); }; diff --git a/server/src/classes/data-provider/DataProvider.js b/server/src/classes/data-provider/DataProvider.js new file mode 100644 index 000000000..df1796d57 --- /dev/null +++ b/server/src/classes/data-provider/DataProvider.js @@ -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; + } +} diff --git a/server/src/classes/data-provider/__tests__/DataProvider.test.js b/server/src/classes/data-provider/__tests__/DataProvider.test.js new file mode 100644 index 000000000..a84a5523a --- /dev/null +++ b/server/src/classes/data-provider/__tests__/DataProvider.test.js @@ -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); + }); + }); +}); diff --git a/server/src/controllers/ontimeController.js b/server/src/controllers/ontimeController.js index f12080890..7dd1f19b6 100644 --- a/server/src/controllers/ontimeController.js +++ b/server/src/controllers/ontimeController.js @@ -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); }; diff --git a/server/src/utils/__tests__/parser.tests.js b/server/src/utils/__tests__/parser.tests.js index 90a38275e..ddc77361a 100644 --- a/server/src/utils/__tests__/parser.tests.js +++ b/server/src/utils/__tests__/parser.tests.js @@ -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); diff --git a/server/src/utils/parser.js b/server/src/utils/parser.js index b37ffa58e..c26fb7a7e 100644 --- a/server/src/utils/parser.js +++ b/server/src/utils/parser.js @@ -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'); diff --git a/server/src/utils/parserUtils_v1.js b/server/src/utils/parserUtils_v1.js index 7968d996c..d20078b3e 100644 --- a/server/src/utils/parserUtils_v1.js +++ b/server/src/utils/parserUtils_v1.js @@ -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 }; };