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