mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-04 05:49:06 +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": [
|
"extends": [
|
||||||
"react-app",
|
"react-app",
|
||||||
"react-app/jest",
|
"react-app/jest",
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended"
|
||||||
"plugin:@typescript-eslint/recommended"
|
|
||||||
],
|
],
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"react",
|
"react",
|
||||||
"testing-library",
|
"testing-library",
|
||||||
"simple-import-sort",
|
"simple-import-sort"
|
||||||
"@typescript-eslint"
|
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"jest/no-mocks-import": "warn",
|
"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
|
* @description HTTP request to upload events db
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
export const uploadEvents = async (file) => {
|
export const uploadEvents = async (file, setProgress, options) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('userFile', file); // appending file
|
formData.append('userFile', file);
|
||||||
await axios.post(`${ontimeURL}/db`, formData, {
|
const onlyEvents = options?.onlyEvents;
|
||||||
headers: {
|
await axios
|
||||||
'Content-Type': 'multipart/form-data',
|
.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 { generateId } from '../utils/generate_id';
|
||||||
import { nowInMillis, stringFromMillis } from '../utils/time';
|
import { nowInMillis, stringFromMillis } from '../utils/time';
|
||||||
|
|
||||||
import { useSocket } from './socketContext';
|
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: [],
|
logData: [],
|
||||||
emitInfo: () => undefined,
|
emitInfo: notInitialised,
|
||||||
emitWarning: () => undefined,
|
emitWarning: notInitialised,
|
||||||
emitError: () => undefined,
|
emitError: notInitialised,
|
||||||
clearLog: () => undefined,
|
clearLog: notInitialised
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LoggingProvider = ({ children }) => {
|
export const LoggingProvider = ({ children }: LoggingProviderProps) => {
|
||||||
const MAX_MESSAGES = 100;
|
const MAX_MESSAGES = 100;
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const [logData, setLogData] = useState([]);
|
const [logData, setLogData] = useState<Log[]>([]);
|
||||||
const origin = 'USER';
|
const origin = 'USER';
|
||||||
|
|
||||||
// handle incoming messages
|
// handle incoming messages
|
||||||
@@ -26,7 +51,7 @@ export const LoggingProvider = ({ children }) => {
|
|||||||
// Ask for log data
|
// Ask for log data
|
||||||
socket.emit('get-logger');
|
socket.emit('get-logger');
|
||||||
|
|
||||||
socket.on('logger', (data) => {
|
socket.on('logger', (data: Log) => {
|
||||||
setLogData((l) => [data, ...l]);
|
setLogData((l) => [data, ...l]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,9 +68,9 @@ export const LoggingProvider = ({ children }) => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _send = useCallback(
|
const _send = useCallback(
|
||||||
(text, level) => {
|
(text: string, level: LOG_LEVEL) => {
|
||||||
if (socket != null) {
|
if (socket != null) {
|
||||||
const m = {
|
const m: Log = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
origin,
|
origin,
|
||||||
time: stringFromMillis(nowInMillis()),
|
time: stringFromMillis(nowInMillis()),
|
||||||
@@ -56,7 +81,7 @@ export const LoggingProvider = ({ children }) => {
|
|||||||
socket.emit('logger', m);
|
socket.emit('logger', m);
|
||||||
}
|
}
|
||||||
if (logData.length > MAX_MESSAGES) {
|
if (logData.length > MAX_MESSAGES) {
|
||||||
setLogData((l) => l.pop());
|
setLogData((l) => l.slice(1));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[logData, socket]
|
[logData, socket]
|
||||||
@@ -67,7 +92,7 @@ export const LoggingProvider = ({ children }) => {
|
|||||||
* @param text
|
* @param text
|
||||||
*/
|
*/
|
||||||
const emitInfo = useCallback(
|
const emitInfo = useCallback(
|
||||||
(text) => {
|
(text: string) => {
|
||||||
_send(text, 'INFO');
|
_send(text, 'INFO');
|
||||||
},
|
},
|
||||||
[_send]
|
[_send]
|
||||||
@@ -78,7 +103,7 @@ export const LoggingProvider = ({ children }) => {
|
|||||||
* @param text
|
* @param text
|
||||||
*/
|
*/
|
||||||
const emitWarning = useCallback(
|
const emitWarning = useCallback(
|
||||||
(text) => {
|
(text: string) => {
|
||||||
_send(text, 'WARN');
|
_send(text, 'WARN');
|
||||||
},
|
},
|
||||||
[_send]
|
[_send]
|
||||||
@@ -89,7 +114,7 @@ export const LoggingProvider = ({ children }) => {
|
|||||||
* @param text
|
* @param text
|
||||||
*/
|
*/
|
||||||
const emitError = useCallback(
|
const emitError = useCallback(
|
||||||
(text) => {
|
(text: string) => {
|
||||||
_send(text, 'ERROR');
|
_send(text, 'ERROR');
|
||||||
},
|
},
|
||||||
[_send]
|
[_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 { useDisclosure } from '@chakra-ui/hooks';
|
||||||
import { Box } from '@chakra-ui/layout';
|
import { Box } from '@chakra-ui/layout';
|
||||||
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
|
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
|
||||||
import ModalManager from 'features/modals/ModalManager';
|
import ModalManager from 'features/modals/ModalManager';
|
||||||
|
|
||||||
|
import UploadModal from '../../common/components/upload-modal/UploadModal';
|
||||||
import { LoggingProvider } from '../../common/context/LoggingContext';
|
import { LoggingProvider } from '../../common/context/LoggingContext';
|
||||||
import MenuBar from '../menu/MenuBar';
|
import MenuBar from '../menu/MenuBar';
|
||||||
|
|
||||||
@@ -15,23 +16,42 @@ const MessageControl = lazy(() => import('features/control/message/MessageContro
|
|||||||
const Info = lazy(() => import('features/info/InfoExport'));
|
const Info = lazy(() => import('features/info/InfoExport'));
|
||||||
|
|
||||||
export default function Editor() {
|
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
|
// Set window title
|
||||||
document.title = 'ontime - Editor';
|
useEffect(() => {
|
||||||
|
document.title = 'ontime - Editor';
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoggingProvider>
|
<LoggingProvider>
|
||||||
|
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ModalManager isOpen={isOpen} onClose={onClose} />
|
<ModalManager isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<div className={styles.mainContainer}>
|
<div className={styles.mainContainer}>
|
||||||
<Box id='settings' className={styles.settings}>
|
<Box id='settings' className={styles.settings}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<MenuBar onOpen={onOpen} isOpen={isOpen} onClose={onClose} />
|
<MenuBar
|
||||||
|
onSettingsOpen={onSettingsOpen}
|
||||||
|
isSettingsOpen={isSettingsOpen}
|
||||||
|
onSettingsClose={onSettingsClose}
|
||||||
|
isUploadOpen={isUploadModalOpen}
|
||||||
|
onUploadOpen={onUploadModalOpen}
|
||||||
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
<EventList onOpen={onOpen} isOpen={isOpen} onClose={onClose} />
|
<EventList />
|
||||||
<MessageControl />
|
<MessageControl />
|
||||||
<TimerControl />
|
<TimerControl />
|
||||||
<Info />
|
<Info />
|
||||||
@@ -85,8 +85,3 @@
|
|||||||
padding: 0 0.5em;
|
padding: 0 0.5em;
|
||||||
margin: 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')}
|
clickHandler={() => actionHandler('cursorUp')}
|
||||||
icon={<IoCaretUp />}
|
icon={<IoCaretUp />}
|
||||||
tooltip='Move cursor up Alt + ↑'
|
tooltip='Move cursor up Alt + ↑'
|
||||||
_hover={{ bg: 'pink.400' }}
|
_hover={{ bg: 'pink.400', color: 'white' }}
|
||||||
/>
|
/>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...cursorBtnProps}
|
{...cursorBtnProps}
|
||||||
clickHandler={() => actionHandler('cursorDown')}
|
clickHandler={() => actionHandler('cursorDown')}
|
||||||
icon={<IoCaretDown />}
|
icon={<IoCaretDown />}
|
||||||
tooltip='Move cursor down Alt + ↓'
|
tooltip='Move cursor down Alt + ↓'
|
||||||
_hover={{ bg: 'pink.400' }}
|
_hover={{ bg: 'pink.400', color: 'white' }}
|
||||||
/>
|
/>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...cursorBtnProps}
|
{...cursorBtnProps}
|
||||||
@@ -101,7 +101,8 @@ const EventListMenu = ({ eventsHandler }) => {
|
|||||||
tooltip='Lock cursor to current'
|
tooltip='Lock cursor to current'
|
||||||
width='3em'
|
width='3em'
|
||||||
backgroundColor={isCursorLocked && 'pink.400'}
|
backgroundColor={isCursorLocked && 'pink.400'}
|
||||||
_hover={{ bg: 'pink.300' }}
|
color={isCursorLocked && 'white'}
|
||||||
|
_hover={{ bg: 'pink.400', color: 'white' }}
|
||||||
variant={isCursorLocked ? 'solid' : 'outline'}
|
variant={isCursorLocked ? 'solid' : 'outline'}
|
||||||
/>
|
/>
|
||||||
</ButtonGroup>
|
</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 onOpenHandler = jest.fn();
|
||||||
const onCloseHandler = jest.fn();
|
const onCloseHandler = jest.fn();
|
||||||
|
const isOpen = false;
|
||||||
|
const onUploadOpenHandler = jest.fn();
|
||||||
|
|
||||||
const renderInMock = () => {
|
const renderInMock = () => {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={queryClientMock}>
|
<QueryClientProvider client={queryClientMock}>
|
||||||
<MenuBar onOpen={onOpenHandler} onClose={onCloseHandler} />
|
<MenuBar onOpen={onOpenHandler} onClose={onCloseHandler} isOpen={isOpen} onUploadOpen={onUploadOpenHandler} />
|
||||||
</QueryClientProvider>
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { networkInterfaces } from 'os';
|
|||||||
import { fileHandler } from '../utils/parser.js';
|
import { fileHandler } from '../utils/parser.js';
|
||||||
import { generateId } from '../utils/generate_id.js';
|
import { generateId } from '../utils/generate_id.js';
|
||||||
import { resolveDbPath } from '../modules/loadDb.js';
|
import { resolveDbPath } from '../modules/loadDb.js';
|
||||||
|
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
// Create controller for GET request to '/ontime/poll'
|
// Create controller for GET request to '/ontime/poll'
|
||||||
// Returns data for current state
|
// Returns data for current state
|
||||||
@@ -33,11 +34,7 @@ export const dbDownload = async (req, res) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const uploadAndParse = async (file, req, res, options) => {
|
||||||
* @description Controller for POST request to /ontime/db
|
|
||||||
* @returns none
|
|
||||||
*/
|
|
||||||
const upload = async (file, req, res) => {
|
|
||||||
if (!fs.existsSync(file)) {
|
if (!fs.existsSync(file)) {
|
||||||
res.status(500).send({ message: 'Upload failed' });
|
res.status(500).send({ message: 'Upload failed' });
|
||||||
return;
|
return;
|
||||||
@@ -50,29 +47,18 @@ const upload = async (file, req, res) => {
|
|||||||
res.status(400).send({ message: result.message });
|
res.status(400).send({ message: result.message });
|
||||||
} else if (result.message === 'success') {
|
} else if (result.message === 'success') {
|
||||||
// explicitly write objects
|
// explicitly write objects
|
||||||
if (typeof result.data !== 'undefined') {
|
if (typeof result !== 'undefined') {
|
||||||
if (typeof result.data?.events !== 'undefined') {
|
if (!options.onlyEvents) {
|
||||||
data.events = result.data.events;
|
const mergedData = DataProvider.safeMerge(data, result.data);
|
||||||
global.timer.setupWithEventList(result.data?.events);
|
data.event = mergedData.event;
|
||||||
}
|
data.settings = mergedData.settings;
|
||||||
if (typeof result.data?.event !== 'undefined') {
|
data.osc = mergedData.osc;
|
||||||
data.event = result.data.event;
|
data.http = mergedData.http;
|
||||||
}
|
data.aliases = mergedData.aliases;
|
||||||
if (typeof result.data?.settings !== 'undefined') {
|
data.userFields = mergedData.userFields;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
data.events = result.data.events || [];
|
||||||
|
global.timer.setupWithEventList(result.data.events || []);
|
||||||
await db.write();
|
await db.write();
|
||||||
}
|
}
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
@@ -297,9 +283,9 @@ export const dbUpload = async (req, res) => {
|
|||||||
res.status(400).send({ message: 'File not found' });
|
res.status(400).send({ message: 'File not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const options = req.query;
|
||||||
const file = req.file.path;
|
const file = req.file.path;
|
||||||
upload(file, req, res);
|
uploadAndParse(file, req, res, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create controller for POST request to '/ontime/dbpath'
|
// 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' });
|
res.status(400).send({ message: 'Path to file not found' });
|
||||||
return;
|
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 Name', 'Test Event'],
|
||||||
['Event URL', 'www.carlosvalente.com'],
|
['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 = [
|
const expectedParsedEvents = [
|
||||||
{
|
{
|
||||||
timeStart: 25200000,
|
timeStart: 25200000,
|
||||||
@@ -681,6 +692,7 @@ describe('test parseExcel function', () => {
|
|||||||
|
|
||||||
const parsedData = await parseExcel_v1(testdata);
|
const parsedData = await parseExcel_v1(testdata);
|
||||||
|
|
||||||
|
expect(parsedData.event).toStrictEqual(expectedParsedEvent);
|
||||||
expect(parsedData.events).toBeDefined();
|
expect(parsedData.events).toBeDefined();
|
||||||
expect(parsedData.events.title).toBe(expectedParsedEvents.title);
|
expect(parsedData.events.title).toBe(expectedParsedEvents.title);
|
||||||
expect(parsedData.events.presenter).toBe(expectedParsedEvents.presenter);
|
expect(parsedData.events.presenter).toBe(expectedParsedEvents.presenter);
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export const parseExcel_v1 = async (excelData) => {
|
|||||||
.forEach((row) => {
|
.forEach((row) => {
|
||||||
let eventTitleNext = false;
|
let eventTitleNext = false;
|
||||||
let eventUrlNext = false;
|
let eventUrlNext = false;
|
||||||
|
let publicInfoNext = false;
|
||||||
|
let backstageInfoNext = false;
|
||||||
|
let endMessageNext = false;
|
||||||
const event = {};
|
const event = {};
|
||||||
|
|
||||||
row.forEach((column, j) => {
|
row.forEach((column, j) => {
|
||||||
@@ -78,6 +81,15 @@ export const parseExcel_v1 = async (excelData) => {
|
|||||||
} else if (eventUrlNext) {
|
} else if (eventUrlNext) {
|
||||||
eventData.url = column;
|
eventData.url = column;
|
||||||
eventUrlNext = false;
|
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) {
|
} else if (j === timeStartIndex) {
|
||||||
event.timeStart = parseExcelDate(column);
|
event.timeStart = parseExcelDate(column);
|
||||||
} else if (j === timeEndIndex) {
|
} else if (j === timeEndIndex) {
|
||||||
@@ -128,6 +140,15 @@ export const parseExcel_v1 = async (excelData) => {
|
|||||||
case 'event url':
|
case 'event url':
|
||||||
eventUrlNext = true;
|
eventUrlNext = true;
|
||||||
break;
|
break;
|
||||||
|
case 'public info':
|
||||||
|
publicInfoNext = true;
|
||||||
|
break;
|
||||||
|
case 'backstage info':
|
||||||
|
backstageInfoNext = true;
|
||||||
|
break;
|
||||||
|
case 'end message':
|
||||||
|
endMessageNext = true;
|
||||||
|
break;
|
||||||
case 'time start':
|
case 'time start':
|
||||||
case 'start':
|
case 'start':
|
||||||
timeStartIndex = j;
|
timeStartIndex = j;
|
||||||
@@ -329,7 +350,6 @@ export const fileHandler = async (file) => {
|
|||||||
let res = {};
|
let res = {};
|
||||||
|
|
||||||
// check which file type are we dealing with
|
// check which file type are we dealing with
|
||||||
|
|
||||||
if (file.endsWith('.xlsx')) {
|
if (file.endsWith('.xlsx')) {
|
||||||
try {
|
try {
|
||||||
const excelData = xlsx
|
const excelData = xlsx
|
||||||
@@ -341,7 +361,10 @@ export const fileHandler = async (file) => {
|
|||||||
// we only look at worksheets called ontime or event schedule
|
// we only look at worksheets called ontime or event schedule
|
||||||
if (excelData?.data) {
|
if (excelData?.data) {
|
||||||
const dataFromExcel = await parseExcel_v1(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';
|
res.message = 'success';
|
||||||
} else {
|
} else {
|
||||||
console.log('Error: No sheets found named ontime or event schedule');
|
console.log('Error: No sheets found named ontime or event schedule');
|
||||||
|
|||||||
@@ -14,37 +14,41 @@ export const parseEvents_v1 = (data) => {
|
|||||||
if ('events' in data) {
|
if ('events' in data) {
|
||||||
console.log('Found events definition, importing...');
|
console.log('Found events definition, importing...');
|
||||||
const events = [];
|
const events = [];
|
||||||
const ids = [];
|
try {
|
||||||
for (const e of data.events) {
|
const ids = [];
|
||||||
// cap number of events
|
for (const e of data.events) {
|
||||||
if (events.length >= MAX_EVENTS) {
|
// cap number of events
|
||||||
console.log(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
|
if (events.length >= MAX_EVENTS) {
|
||||||
break;
|
console.log(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
// double check unique ids
|
|
||||||
if (ids.indexOf(e?.id) !== -1) {
|
// double check unique ids
|
||||||
console.log('ERROR: ID collision on import, skipping');
|
if (ids.indexOf(e?.id) !== -1) {
|
||||||
continue;
|
console.log('ERROR: ID collision on import, skipping');
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
if (e.type === 'event') {
|
|
||||||
const event = validateEvent_v1(e);
|
if (e.type === 'event') {
|
||||||
if (event != null) {
|
const event = validateEvent_v1(e);
|
||||||
events.push(event);
|
if (event != null) {
|
||||||
ids.push(event.id);
|
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
|
// write to db
|
||||||
newEvents = events;
|
newEvents = events;
|
||||||
@@ -73,7 +77,7 @@ export const parseEvent_v1 = (data, enforce) => {
|
|||||||
endMessage: e.endMessage || dbModelv1.event.endMessage,
|
endMessage: e.endMessage || dbModelv1.event.endMessage,
|
||||||
};
|
};
|
||||||
} else if (enforce) {
|
} else if (enforce) {
|
||||||
newEvent = dbModelv1.event;
|
newEvent = { ...dbModelv1.event };
|
||||||
console.log(`Created event object in db`);
|
console.log(`Created event object in db`);
|
||||||
}
|
}
|
||||||
return newEvent;
|
return newEvent;
|
||||||
@@ -137,7 +141,7 @@ export const parseOsc_v1 = (data, enforce) => {
|
|||||||
...osc,
|
...osc,
|
||||||
};
|
};
|
||||||
} else if (enforce) {
|
} else if (enforce) {
|
||||||
newOsc = dbModelv1.osc;
|
newOsc = { ...dbModelv1.osc };
|
||||||
console.log(`Created OSC object in db`);
|
console.log(`Created OSC object in db`);
|
||||||
}
|
}
|
||||||
return newOsc;
|
return newOsc;
|
||||||
@@ -165,7 +169,7 @@ export const parseHttp_v1 = (data, enforce) => {
|
|||||||
...http,
|
...http,
|
||||||
};
|
};
|
||||||
} else if (enforce) {
|
} else if (enforce) {
|
||||||
newHttp.http = dbModelv1.http;
|
newHttp.http = { ...dbModelv1.http };
|
||||||
console.log(`Created http object in db`);
|
console.log(`Created http object in db`);
|
||||||
}
|
}
|
||||||
return newHttp;
|
return newHttp;
|
||||||
@@ -181,23 +185,27 @@ export const parseAliases_v1 = (data) => {
|
|||||||
if ('aliases' in data) {
|
if ('aliases' in data) {
|
||||||
console.log('Found Aliases definition, importing...');
|
console.log('Found Aliases definition, importing...');
|
||||||
const ids = [];
|
const ids = [];
|
||||||
for (const a of data.aliases) {
|
try {
|
||||||
// double check unique ids
|
for (const a of data.aliases) {
|
||||||
if (ids.indexOf(a?.id) !== -1) {
|
// double check unique ids
|
||||||
console.log('ERROR: ID collision on import, skipping');
|
if (ids.indexOf(a?.id) !== -1) {
|
||||||
continue;
|
console.log('ERROR: ID collision on import, skipping');
|
||||||
}
|
continue;
|
||||||
const newAlias = {
|
}
|
||||||
id: a.id || generateId(),
|
const newAlias = {
|
||||||
enabled: a.enabled || false,
|
id: a.id || generateId(),
|
||||||
alias: a.alias || '',
|
enabled: a.enabled || false,
|
||||||
pathAndParams: a.pathAndParams || '',
|
alias: a.alias || '',
|
||||||
};
|
pathAndParams: a.pathAndParams || '',
|
||||||
|
};
|
||||||
|
|
||||||
ids.push(newAlias.id);
|
ids.push(newAlias.id);
|
||||||
newAliases.push(newAlias);
|
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;
|
return newAliases;
|
||||||
};
|
};
|
||||||
@@ -208,19 +216,23 @@ export const parseAliases_v1 = (data) => {
|
|||||||
* @returns {object} - event object data
|
* @returns {object} - event object data
|
||||||
*/
|
*/
|
||||||
export const parseUserFields_v1 = (data) => {
|
export const parseUserFields_v1 = (data) => {
|
||||||
const newUserFields = dbModelv1.userFields;
|
const newUserFields = { ...dbModelv1.userFields };
|
||||||
|
|
||||||
if ('userFields' in data) {
|
if ('userFields' in data) {
|
||||||
console.log('Found User Fields definition, importing...');
|
console.log('Found User Fields definition, importing...');
|
||||||
// we will only be importing the fields we know, so look for that
|
// we will only be importing the fields we know, so look for that
|
||||||
let fieldsFound = 0;
|
try {
|
||||||
for (const n in newUserFields) {
|
let fieldsFound = 0;
|
||||||
if (n in data.userFields) {
|
for (const n in newUserFields) {
|
||||||
fieldsFound++;
|
if (n in data.userFields) {
|
||||||
newUserFields[n] = data.userFields[n];
|
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 };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user