refactor: small cleanups and type improvement

This commit is contained in:
cv
2023-09-28 08:15:54 +02:00
parent ba5ef6668d
commit a0d4f40bec
9 changed files with 158 additions and 153 deletions
@@ -17,18 +17,25 @@ export default function UploadFile() {
const clearFile = () => { const clearFile = () => {
setFile(null); setFile(null);
setErrors('');
}; };
const handleFile = (event: ChangeEvent<HTMLInputElement>) => { const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
const fileSelected = event?.target?.files?.[0]; setErrors('');
if (!fileSelected) return;
const validate = validateFile(fileSelected); const selectedFile = event?.target?.files?.[0];
setErrors(validate.errors?.[0]); if (!selectedFile) {
setFile(null);
return;
}
if (validate.isValid) { try {
setFile(fileSelected); validateFile(selectedFile);
} else { setFile(selectedFile);
} catch (error) {
if (error instanceof Error) {
setErrors(error.message);
}
setFile(null); setFile(null);
} }
}; };
@@ -47,9 +54,11 @@ export default function UploadFile() {
accept='.json, .xlsx' accept='.json, .xlsx'
data-testid='file-input' data-testid='file-input'
/> />
<div className={style.uploadArea} onClick={handleClick}> {!file && (
Click to upload Ontime project or xlsx file <div className={style.uploadArea} onClick={handleClick}>
</div> Click to upload Ontime project or xlsx file
</div>
)}
{(file || errors) && ( {(file || errors) && (
<UploadEntry file={file} errors={errors} progress={progress} success={success} handleClear={clearFile} /> <UploadEntry file={file} errors={errors} progress={progress} success={success} handleClear={clearFile} />
)} )}
@@ -10,14 +10,18 @@ import {
ModalOverlay, ModalOverlay,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown } from 'ontime-types'; import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants'; import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
import { maybeAxiosError } from '../../../common/api/apiUtils'; import {
import { postPreviewExcel, uploadData } from '../../../common/api/ontimeApi'; patchData,
postPreviewExcel,
ProjectFileImportOptions,
uploadProjectFile,
} from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData'; import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields'; import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { cx } from '../../../common/utils/styleUtils';
import PreviewExcel from './preview/PreviewExcel'; import PreviewExcel from './preview/PreviewExcel';
import ExcelFileOptions from './upload-options/ExcelFileOptions'; import ExcelFileOptions from './upload-options/ExcelFileOptions';
@@ -25,20 +29,12 @@ import OntimeFileOptions from './upload-options/OntimeFileOptions';
import UploadStepTracker from './upload-step/UploadStep'; import UploadStepTracker from './upload-step/UploadStep';
import UploadFile from './UploadFile'; import UploadFile from './UploadFile';
import { useUploadModalContextStore } from './uploadModalContext'; import { useUploadModalContextStore } from './uploadModalContext';
import { defaultExcelImportMap, ExcelImportMapKeys, isExcelFile, isOntimeFile } from './uploadUtils'; import { isExcelFile, isOntimeFile } from './uploadUtils';
import style from './UploadModal.module.scss'; import style from './UploadModal.module.scss';
export type UploadStep = 'upload' | 'review'; export type UploadStep = 'upload' | 'review';
export interface OntimeInputOptions {
onlyImportRundown?: boolean;
}
export type ExcelInputOptions = {
[K in ExcelImportMapKeys]: string;
};
interface UploadModalProps { interface UploadModalProps {
onClose: () => void; onClose: () => void;
isOpen: boolean; isOpen: boolean;
@@ -51,67 +47,76 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const [uploadStep, setUploadStep] = useState<UploadStep>('upload'); const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [rundown, setRundown] = useState<OntimeRundown>([]); const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState(userFieldsPlaceholder); const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState(projectDataPlaceholder); const [project, setProject] = useState<ProjectData | null>(null);
const [errors, setErrors] = useState(''); const [errors, setErrors] = useState('');
const ontimeFileOptions = useRef<Partial<OntimeInputOptions>>({}); const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
const excelFileOptions = useRef<Partial<ExcelInputOptions>>(defaultExcelImportMap); const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
/* if the modal re-opens, we want to restart all states */
useEffect(() => { useEffect(() => {
clear(); clear();
setUploadStep('upload'); setUploadStep('upload');
setSubmitting(false); setSubmitting(false);
setRundown([]); setRundown(null);
setUserFields(userFieldsPlaceholder); setUserFields(null);
setProject(projectDataPlaceholder); setProject(null);
setErrors(''); setErrors('');
}, [clear, isOpen]); }, [clear, isOpen]);
const handleParse = async () => { /* uploads file to backend
* - in the case of excel, we get the preview
* - in the case of project file, this is end of line
**/
const handleUpload = async () => {
let doClose = false;
if (file) { if (file) {
setSubmitting(true); setSubmitting(true);
setErrors('');
try { try {
if (isOntimeFile(file)) { if (isOntimeFile(file)) {
await handleOntimeFile(file); // TODO: we would also like to have preview for ontime project files
await queryClient.invalidateQueries(RUNDOWN_TABLE); const options = ontimeFileOptions.current;
await handleOntimeFile(file, options);
doClose = true;
} else if (isExcelFile(file)) { } else if (isExcelFile(file)) {
await handleExcelFile(file); const options = excelFileOptions.current;
} await handleExcelFile(file, options);
} catch (error) { await invalidateAllCaches();
setErrors(`Failed uploading file: ${error}`);
} finally {
setSubmitting(false);
}
}
async function handleExcelFile(file: File) {
const options = excelFileOptions.current;
// TODO: option type should be central, to also be used by backend
try {
const response = await postPreviewExcel(file, setProgress, options);
if (response.status === 200) {
setRundown(response.data.rundown);
setUserFields(response.data.userFields);
setProject(response.data.project);
setUploadStep('review');
} }
} catch (error) { } catch (error) {
const message = maybeAxiosError(error); const message = maybeAxiosError(error);
setErrors(`Error importing excel ${message}`); setErrors(`Failed uploading file ${message}`);
} finally {
setSubmitting(false);
if (doClose) {
handleClose();
}
} }
} }
async function handleOntimeFile(file: File) { // when we upload excel, we populate state with preview data
const options = { async function handleExcelFile(file: File, options: ExcelImportMap) {
onlyRundown: Boolean(ontimeFileOptions.current.onlyImportRundown), const response = await postPreviewExcel(file, setProgress, options);
}; if (response.status === 200) {
await uploadData(file, setProgress, options); setRundown(response.data.rundown);
setUserFields(response.data.userFields);
setProject(response.data.project);
// in excel imports we have an extra review step
setUploadStep('review');
}
}
// when we upload project files, no extra operations are done
async function handleOntimeFile(file: File, options: Partial<ProjectFileImportOptions>) {
await uploadProjectFile(file, setProgress, options);
} }
}; };
// before closing the modal, we clear data from mutations
const handleClose = () => { const handleClose = () => {
clear(); clear();
setRundown([]); setRundown([]);
@@ -121,36 +126,42 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
}; };
const handleFinalise = async () => { const handleFinalise = async () => {
if (file) { // this step is currently only used for excel files, after preview
if (isExcel && rundown && userFields && project) {
let doClose = false;
setSubmitting(true); setSubmitting(true);
try { try {
const options = { await patchData({ rundown, userFields, project });
//onlyRundown: overrideOptionRef.current?.checked || false, await queryClient.invalidateQueries(['rundown', 'userFields', 'project']);
}; doClose = true;
await uploadData(file, setProgress, options);
handleClose();
} catch (error) { } catch (error) {
console.error(error); const message = maybeAxiosError(error);
setErrors(`Failed applying changes ${message}`);
} finally { } finally {
await queryClient.invalidateQueries(RUNDOWN_TABLE);
setSubmitting(false); setSubmitting(false);
if (doClose) {
handleClose();
}
} }
} }
}; };
const undoReview = () => {
setUploadStep('upload');
setErrors('');
};
const isUpload = uploadStep === 'upload'; const isUpload = uploadStep === 'upload';
const isReview = uploadStep === 'review';
const isExcel = isExcelFile(file); const isExcel = isExcelFile(file);
const isOntime = isOntimeFile(file); const isOntime = isOntimeFile(file);
const handleGoBack = isUpload ? undefined : () => setUploadStep('upload'); const handleGoBack = isUpload ? undefined : undoReview;
const handleSubmit = isUpload ? handleParse : handleFinalise; const handleSubmit = isUpload ? handleUpload : handleFinalise;
const disableSubmit = isUpload && !file; const disableSubmit = (isUpload && !file) || (isReview && rundown === null);
const disableGoBack = isUpload; const disableGoBack = isUpload;
const submitText = isUpload ? 'Upload' : 'Finish'; const submitText = isUpload ? 'Upload' : 'Finish';
const modalClasses = cx([style.modalWidthOverride, isExcel ? style.doExtend : null]);
console.log('debug', isExcel, modalClasses);
return ( return (
<Modal <Modal
onClose={handleClose} onClose={handleClose}
@@ -171,33 +182,40 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
{uploadStep === 'upload' ? ( {uploadStep === 'upload' ? (
<> <>
<UploadFile /> <UploadFile />
{errors && <div className={style.error}>{errors}</div>}
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />} {isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />} {isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
</> </>
) : ( ) : (
<PreviewExcel rundown={rundown} project={project} userFields={userFields} /> <PreviewExcel
rundown={rundown ?? []}
project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder}
/>
)} )}
</ModalBody> </ModalBody>
<ModalFooter className={`${style.buttonSection} ${style.pad}`}> <ModalFooter>
<Button <div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
onClick={handleGoBack}
isDisabled={disableGoBack || submitting} <div className={`${style.buttonSection} ${style.pad}`}>
variant='ontime-ghost-on-light' <Button
size='sm' onClick={handleGoBack}
> isDisabled={disableGoBack || submitting}
Go Back variant='ontime-ghost-on-light'
</Button> size='sm'
<Button >
onClick={handleSubmit} Go Back
isLoading={submitting} </Button>
isDisabled={disableSubmit} <Button
variant='ontime-filled' onClick={handleSubmit}
padding='0 2em' isLoading={submitting}
size='sm' isDisabled={disableSubmit}
> variant='ontime-filled'
{submitText} padding='0 2em'
</Button> size='sm'
>
{submitText}
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -1,35 +1,32 @@
import { MutableRefObject } from 'react'; import { MutableRefObject } from 'react';
import { Switch } from '@chakra-ui/react'; import { Switch } from '@chakra-ui/react';
import { ProjectFileImportOptions } from '../../../../common/api/ontimeApi';
import ModalSplitInput from '../../ModalSplitInput'; import ModalSplitInput from '../../ModalSplitInput';
import { OntimeInputOptions } from '../UploadModal';
import style from '../UploadModal.module.scss'; import style from '../UploadModal.module.scss';
interface OntimeFileOptionsProps { interface OntimeFileOptionsProps {
optionsRef: MutableRefObject<OntimeInputOptions>; optionsRef: MutableRefObject<Partial<ProjectFileImportOptions>>;
} }
export default function OntimeFileOptions(props: OntimeFileOptionsProps) { export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
const { optionsRef } = props; const { optionsRef } = props;
const updateRef = <T extends keyof OntimeInputOptions>(field: T, value: OntimeInputOptions[T]) => { const updateRef = <T extends keyof ProjectFileImportOptions>(field: T, value: ProjectFileImportOptions[T]) => {
optionsRef.current = { ...optionsRef.current, [field]: value }; optionsRef.current = { ...optionsRef.current, [field]: value };
}; };
return ( return (
<div className={style.uploadOptions}> <div className={style.uploadOptions}>
<span className={style.title}>Import options</span> <span className={style.title}>Import options</span>
<ModalSplitInput <ModalSplitInput field='' title='Only import rundown' description='All other project options will be kept'>
field=''
title='Only import rundown'
description='All other options, including application settings will be discarded'
>
<Switch <Switch
variant='ontime-on-light' variant='ontime-on-light'
onChange={(e) => { onChange={(e) => {
updateRef('onlyImportRundown', e.target.checked); updateRef('onlyRundown', e.target.checked);
}} }}
defaultChecked={Boolean(optionsRef.current.onlyRundown)}
/> />
</ModalSplitInput> </ModalSplitInput>
</div> </div>
@@ -1,33 +1,22 @@
type ValidationStatus = { export function validateFile(file: File) {
errors: string[];
isValid: boolean;
};
export function validateFile(file: File): ValidationStatus {
const status: ValidationStatus = { errors: [], isValid: true };
if (!file) { if (!file) {
status.errors.push('No file to upload'); throw new Error('No file to upload');
status.isValid = false;
} }
// Limit file size of a project file to around 1MB // Limit file size of a project file to around 1MB
if (file.name.endsWith('.json') && file.size > 1_000_000) { if (file.name.endsWith('.json') && file.size > 1_000_000) {
status.errors.push('File size limit (1MB) exceeded'); throw new Error('File size limit (1MB) exceeded');
status.isValid = false;
} }
// Limit file size of an excel file to around 10MB // Limit file size of an excel file to around 10MB
if (file.name.endsWith('.xlsx') && file.size > 10_000_000) { if (file.name.endsWith('.xlsx') && file.size > 10_000_000) {
status.errors.push('File size limit (10MB) exceeded'); throw new Error('File size limit (10MB) exceeded');
status.isValid = false;
} }
// Check file extension // Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) { if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
status.errors.push('Unhandled file type'); throw new Error('Unhandled file type');
status.isValid = false;
} }
return status;
} }
export function isExcelFile(file: File | null) { export function isExcelFile(file: File | null) {
@@ -2,7 +2,16 @@
* Class Event Provider is a mediator for handling the local db * Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data * and adds logic specific to ontime data
*/ */
import { ProjectData, OntimeRundown, ViewSettings } from 'ontime-types'; import {
ProjectData,
OntimeRundown,
ViewSettings,
DatabaseModel,
OSCSettings,
UserFields,
Alias,
Settings,
} from 'ontime-types';
import { data, db } from '../../modules/loadDb.js'; import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js'; import { safeMerge } from './DataProvider.utils.js';
@@ -45,7 +54,7 @@ export class DataProvider {
return data.settings; return data.settings;
} }
static async setSettings(newData) { static async setSettings(newData: Settings) {
data.settings = { ...newData }; data.settings = { ...newData };
await this.persist(); await this.persist();
} }
@@ -58,7 +67,7 @@ export class DataProvider {
return data.aliases; return data.aliases;
} }
static async setAliases(newData) { static async setAliases(newData: Alias[]) {
data.aliases = newData; data.aliases = newData;
await this.persist(); await this.persist();
} }
@@ -76,12 +85,12 @@ export class DataProvider {
await this.persist(); await this.persist();
} }
static async setUserFields(newData) { static async setUserFields(newData: UserFields) {
data.userFields = { ...newData }; data.userFields = { ...newData };
await this.persist(); await this.persist();
} }
static async setOsc(newData) { static async setOsc(newData: OSCSettings) {
data.osc = { ...newData }; data.osc = { ...newData };
await this.persist(); await this.persist();
} }
@@ -95,7 +104,7 @@ export class DataProvider {
await db.write(); await db.write();
} }
static async mergeIntoData(newData) { static async mergeIntoData(newData: Partial<DatabaseModel>) {
const mergedData = safeMerge(data, newData); const mergedData = safeMerge(data, newData);
data.project = mergedData.project; data.project = mergedData.project;
data.settings = mergedData.settings; data.settings = mergedData.settings;
@@ -1,10 +1,12 @@
import { DatabaseModel } from 'ontime-types';
/** /**
* Merges two data objects * Merges two data objects
* @param {object} existing * @param {object} existing
* @param {object} newData * @param {object} newData
*/ */
export function safeMerge(existing, newData) { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
const { rundown, project, settings, viewSettings, osc, http, aliases, userFields } = newData || {}; const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
return { return {
...existing, ...existing,
rundown: rundown ?? existing.rundown, rundown: rundown ?? existing.rundown,
@@ -32,6 +34,5 @@ export function safeMerge(existing, newData) {
: {}), : {}),
}, },
}, },
http: { ...existing.http, ...http },
}; };
} }
@@ -42,11 +42,6 @@ describe('safeMerge', () => {
onFinish: [], onFinish: [],
}, },
}, },
http: {
enabled: true,
user: null,
pwd: null,
},
}; };
it('returns existing data if new data is not provided', () => { it('returns existing data if new data is not provided', () => {
@@ -188,19 +183,6 @@ describe('safeMerge', () => {
onFinish: [], onFinish: [],
}, },
}, },
http: {
user: null,
pwd: null,
messages: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
enabled: true,
},
}; };
const newData = { const newData = {
+1 -1
View File
@@ -42,7 +42,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
adapterToUse.data = dbModel; adapterToUse.data = dbModel;
} }
return parseJson(adapterToUse.data, true); return parseJson(adapterToUse.data);
}; };
/** /**
@@ -287,13 +287,13 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
updateTimer(); updateTimer();
} }
if (options.external) {
// advice socket subscribers of change
sendRefetch();
}
if (options.reset) { if (options.reset) {
// force rundown to be recalculated // force rundown to be recalculated
forceReset(); forceReset();
} }
if (options.external) {
// advice socket subscribers of change
sendRefetch();
}
} }