feat: excel import (#527)

* refactor: simplify excel import

* chore: add external deepmerge utility

* chore: create import map utilities

* chore: increase size limits on uploads

* style: small presentation tweaks

* feat: resolve times on excel import, refs #508
This commit is contained in:
Carlos Valente
2023-11-11 14:28:43 +01:00
committed by GitHub
parent 0ce3449083
commit 45fe669f0a
61 changed files with 1905 additions and 685 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
}
}, [data, searchParams, navigate, location]);
return <Component {...props} />;
return <Component {...(props as P)} />;
};
};
@@ -2,16 +2,15 @@ import { useCallback } from 'react';
import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorDataLeft';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
field: TitleActions;
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function CountedTextArea(props: CountedTextAreaProps) {
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
.title {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 8px;
margin: 8px 0;
padding-left: 0.5rem;
margin: 0.5rem 0;
text-transform: uppercase;
}
@@ -107,6 +107,16 @@ $el-padding-with-compensation: 24px; // 16 + 8
color: $error-red;
}
.success {
@include subsection;
color: $action-blue;
}
.feedbackSection {
justify-content: flex-start;
}
.buttonSection {
margin-top: $section-spacing;
display: flex;
@@ -117,6 +127,10 @@ $el-padding-with-compensation: 24px; // 16 + 8
flex-grow: 1;
}
.vSpacer {
height: 2rem;
}
.shiftRight {
align-self: flex-end;
}
@@ -135,6 +149,12 @@ $el-padding-with-compensation: 24px; // 16 + 8
grid-template-columns: auto 1fr;
}
.twoEqualColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.padBottom {
padding-bottom: $element-spacing;
}
@@ -0,0 +1,66 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import UploadEntry from './upload-entry/UploadEntry';
import { useUploadModalContextStore } from './uploadModalContext';
import { validateFile } from './uploadUtils';
import style from './UploadModal.module.scss';
export default function UploadFile() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { file, setFile, progress } = useUploadModalContextStore();
const [errors, setErrors] = useState<string>('');
const clearFile = () => {
setFile(null);
setErrors('');
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
setErrors('');
const selectedFile = event?.target?.files?.[0];
if (!selectedFile) {
setFile(null);
return;
}
try {
validateFile(selectedFile);
setFile(selectedFile);
} catch (error) {
if (error instanceof Error) {
setErrors(error.message);
} else {
setErrors('An unexpected error occurred while validating the file.');
}
setFile(null);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
{!file && (
<div className={style.uploadArea} onClick={handleClick} role='button'>
Click to select Ontime project or xlsx rundown
</div>
)}
{(file || errors) && <UploadEntry file={file} errors={errors} progress={progress} handleClear={clearFile} />}
</>
);
}
@@ -5,79 +5,31 @@
.uploadBody {
display: flex;
flex-direction: column;
gap: 16px;
gap: 1rem;
}
.uploadArea {
margin: 0 auto;
width: 100%;
min-height: 200px;
border: 2px dashed $gray-50;
max-width: 550px;
min-height: 150px;
border: 2px dashed $gray-200;
border-radius: 3px;
display: grid;
place-content: center;
transition-property: background-color;
transition-duration: $transition-time-action;
font-size: calc(1rem - 1px);
&:hover {
border: 2px solid $blue-500;
background-color: $blue-50;
cursor: pointer;
}
&.comment {
color: gray;
}
}
.uploadedItem {
background-color: $gray-50;
padding: 8px;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 16px;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 32px;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: 14px;
color: $gray-1350;
}
.fileInfo {
grid-area: info;
font-size: 12px;
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
color: $modal-note-color;
}
}
@@ -89,3 +41,9 @@
.pad {
margin: 8px;
}
.twoColumn {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@@ -1,7 +1,6 @@
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
@@ -9,23 +8,34 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Progress,
Switch,
} from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { uploadData } from '../../../common/api/ontimeApi';
import { useEmitLog } from '../../../common/stores/logger';
import ModalSplitInput from '../ModalSplitInput';
import { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
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 { validateFile } from './utils';
import PreviewExcel from './preview/PreviewExcel';
import ExcelFileOptions from './upload-options/ExcelFileOptions';
import OntimeFileOptions from './upload-options/OntimeFileOptions';
import UploadStepTracker from './upload-step/UploadStep';
import UploadFile from './UploadFile';
import { useUploadModalContextStore } from './uploadModalContext';
import { getPersistedOptions, isExcelFile, isOntimeFile, persistOptions } from './uploadUtils';
import style from './UploadModal.module.scss';
export type UploadStep = 'import' | 'review';
interface UploadModalProps {
onClose: () => void;
isOpen: boolean;
@@ -33,64 +43,152 @@ interface UploadModalProps {
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const queryClient = useQueryClient();
const { emitError } = useEmitLog();
const [errors, setErrors] = useState<string | undefined>();
const [isSubmitting, setSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const overrideOptionRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [success, setSuccess] = useState(false);
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const fileUploaded = event?.target?.files?.[0];
if (!fileUploaded) return;
const { file, setProgress, clear } = useUploadModalContextStore();
const validate = validateFile(fileUploaded);
setErrors(validate.errors?.[0]);
const [uploadStep, setUploadStep] = useState<UploadStep>('import');
const [submitting, setSubmitting] = useState(false);
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState<ProjectData | null>(null);
if (validate.isValid) {
setFile(fileUploaded);
} else {
setFile(null);
const [errors, setErrors] = useState('');
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
const updateOntimeFileOptions = <T extends keyof ProjectFileImportOptions>(
field: T,
value: ProjectFileImportOptions[T],
) => {
ontimeFileOptions.current = { ...ontimeFileOptions.current, [field]: value };
};
const updateExcelFileOptions = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
if (excelFileOptions.current[field] !== value) {
excelFileOptions.current = { ...excelFileOptions.current, [field]: value };
}
};
// We want to populate the options with any previous options given by the user
useEffect(() => {
const excelOptions = getPersistedOptions('excel');
if (excelOptions) {
excelFileOptions.current = excelOptions;
}
}, []);
const handleSubmit = useCallback(async () => {
setSubmitting(true);
// if the modal re-opens, we want to restart all states
useEffect(() => {
clear();
setUploadStep('import');
setSubmitting(false);
setRundown(null);
setUserFields(null);
setProject(null);
setErrors('');
}, [clear, isOpen]);
/* 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 {
const options = {
onlyRundown: overrideOptionRef.current?.checked || false,
};
await uploadData(file, setProgress, options);
if (isOntimeFile(file)) {
// TODO: we would also like to have preview for ontime project files
const options = ontimeFileOptions.current;
await handleOntimeFile(file, options);
await invalidateAllCaches();
doClose = true;
} else if (isExcelFile(file)) {
const options = excelFileOptions.current;
persistOptions({ optionType: 'excel', options });
await handleExcelFile(file, options);
}
} catch (error) {
emitError(`Failed uploading file: ${error}`);
const message = maybeAxiosError(error);
setErrors(`Failed uploading file ${message}`);
} finally {
await queryClient.invalidateQueries(RUNDOWN_TABLE);
setSuccess(true);
setSubmitting(false);
if (doClose) {
handleClose();
}
}
}
setSubmitting(false);
}, [emitError, file, queryClient]);
const handleClick = () => {
fileInputRef.current?.click();
};
const clearFile = () => {
setFile(null);
// 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 = () => {
clearFile();
setSuccess(false);
setErrors(undefined);
setProgress(0);
clear();
setRundown([]);
setUserFields(userFieldsPlaceholder);
setProject(projectDataPlaceholder);
onClose();
};
const disableSubmit = !file || isSubmitting;
const handleFinalise = async () => {
// this step is currently only used for excel files, after preview
if (isExcel && rundown && userFields && project) {
let doClose = false;
setSubmitting(true);
try {
await patchData({ rundown, userFields, project });
queryClient.setQueryData(RUNDOWN_TABLE, rundown);
queryClient.setQueryData(USERFIELDS, userFields);
queryClient.setQueryData(PROJECT_DATA, project);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN_TABLE, ...USERFIELDS, ...PROJECT_DATA],
});
doClose = true;
} catch (error) {
const message = maybeAxiosError(error);
setErrors(`Failed applying changes ${message}`);
} finally {
setSubmitting(false);
if (doClose) {
handleClose();
}
}
}
};
const undoReview = () => {
setUploadStep('import');
setErrors('');
};
const isImporting = uploadStep === 'import';
const isReview = uploadStep === 'review';
const isExcel = isExcelFile(file);
const isOntime = isOntimeFile(file);
const handleGoBack = isImporting ? undefined : undoReview;
const handleSubmit = isImporting ? handleUpload : handleFinalise;
const disableSubmit = (isImporting && !file) || (isReview && rundown === null);
const disableGoBack = isImporting;
const submitText = isImporting ? 'Import' : 'Finish';
return (
<Modal
@@ -101,66 +199,50 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
variant='ontime-upload'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>File import</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.uploadBody}>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project file
</div>
{file && (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={clearFile} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
{uploadStep === 'import' ? (
<>
<UploadFile />
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} updateOptions={updateOntimeFileOptions} />}
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} updateOptions={updateExcelFileOptions} />}
</>
) : (
<PreviewExcel
rundown={rundown ?? []}
project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder}
/>
)}
{errors && (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
<Progress className={style.fileProgress} value={progress} />
</div>
)}
<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'
>
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
</ModalSplitInput>
</div>
</ModalBody>
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
Cancel
</Button>
<Button
onClick={handleSubmit}
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Import
</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>
@@ -0,0 +1,22 @@
@use "../../../../theme/_ontimeColours" as *;
@mixin pad-item {
padding-left: 0.5rem;
padding-right: 1rem;
}
.previewTable {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(6, auto);
font-size: calc(1rem - 2px);
}
.field {
font-weight: 200;
@include pad-item;
}
.value {
@include pad-item;
}
@@ -0,0 +1,26 @@
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import PreviewProjectData from './PreviewProjectData';
import PreviewRundown from './PreviewRundown';
import style from '../../Modal.module.scss';
interface PreviewExcelProps {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
}
export default function PreviewExcel(props: PreviewExcelProps) {
const { rundown, project, userFields } = props;
return (
<div className={`${style.column}`}>
<div className={style.title}>Review Project Data</div>
<PreviewProjectData project={project} />
<div className={style.vSpacer} />
<div className={style.title}>Review Rundown</div>
<PreviewRundown rundown={rundown} userFields={userFields} />
</div>
);
}
@@ -0,0 +1,26 @@
import { ProjectData } from 'ontime-types';
import style from './PreviewColumn.module.scss';
interface PreviewProjectDataProps {
project: ProjectData;
}
export default function PreviewProjectData({ project }: PreviewProjectDataProps) {
return (
<div className={style.previewTable}>
<span className={style.field}>Title</span>
<span className={style.value}>{project.title}</span>
<span className={style.field}>Description</span>
<span className={style.value}>{project.description}</span>
<span className={style.field}>Public URL</span>
<span className={style.value}>{project.publicUrl}</span>
<span className={style.field}>Public info</span>
<span className={style.value}>{project.publicInfo}</span>
<span className={style.field}>Backstage URL</span>
<span className={style.value}>{project.backstageUrl}</span>
<span className={style.field}>Backstage info</span>
<span className={style.value}>{project.backstageInfo}</span>
</div>
);
}
@@ -0,0 +1,131 @@
import { Fragment } from 'react';
import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import Tag from './Tag';
import style from './PreviewTable.module.scss';
interface PreviewRundownProps {
rundown: OntimeRundown;
userFields: UserFields;
}
function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) {
return (
<div className={style.container}>
<table className={style.rundownPreview}>
<thead className={style.header}>
<tr>
<th>#</th>
<th>Type</th>
<th>Cue</th>
<th>Title</th>
<th>Subtitle</th>
<th>Presenter</th>
<th>Time Start</th>
<th>Time End</th>
<th>Duration</th>
<th>Is Public</th>
<th>Skip</th>
<th>Colour</th>
<th>Timer Type</th>
<th>End Action</th>
<th>
user0 <Tag>{userFields.user0}</Tag>
</th>
<th>
user1 <Tag>{userFields.user1}</Tag>
</th>
<th>
user2 <Tag>{userFields.user2}</Tag>
</th>
<th>
user3 <Tag>{userFields.user3}</Tag>
</th>
<th>
user4 <Tag>{userFields.user4}</Tag>
</th>
<th>
user5 <Tag>{userFields.user5}</Tag>
</th>
<th>
user6 <Tag>{userFields.user6}</Tag>
</th>
<th>
user7 <Tag>{userFields.user7}</Tag>
</th>
<th>
user8 <Tag>{userFields.user8}</Tag>
</th>
<th>
user9 <Tag>{userFields.user9}</Tag>
</th>
</tr>
</thead>
<tbody className={style.body}>
{rundown.map((event, index) => {
if (!isOntimeEvent(event)) {
return null;
}
const key = event.id;
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<Fragment key={key}>
<tr>
<td className={style.center}>
<Tag>{index + 1}</Tag>
</td>
<td className={style.center}>
<Tag>Event</Tag>
</td>
<td className={style.nowrap}>{event.cue}</td>
<td>{event.title}</td>
<td>{event.subtitle}</td>
<td>{event.presenter}</td>
<td>{millisToString(event.timeStart)}</td>
<td>{millisToString(event.timeEnd)}</td>
<td>{millisToString(event.duration)}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td>
<td className={style.center}>
<Tag>{event.timerType}</Tag>
</td>
<td className={style.center}>
<Tag>{event.endAction}</Tag>
</td>
<td>{event.user0}</td>
<td>{event.user1}</td>
<td>{event.user2}</td>
<td>{event.user3}</td>
<td>{event.user4}</td>
<td>{event.user5}</td>
<td>{event.user6}</td>
<td>{event.user7}</td>
<td>{event.user8}</td>
<td>{event.user9}</td>
</tr>
{event.note && (
<tr>
<td colSpan={99} className={style.secondaryRow}>
Note: {event.note}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,71 @@
@use "../../../../theme/_ontimeColours" as *;
.container {
max-width: 100%;
max-height: max(300px, 30vh);
overflow: scroll;
}
.rundownPreview {
font-size: calc(1rem - 2px);
overflow-x: scroll;
}
.header,
.body {
th {
font-weight: 400;
height: unset;
line-height: calc(1rem - 2px);
white-space: nowrap;
padding-left: 0.25rem;
padding-right: 1rem;
}
}
.header {
position: sticky;
top: 0;
background-color: white;
box-shadow: 0 2px $gray-50;
z-index: 3;
th {
font-weight: 200;
text-align: left;
}
tr {
word-wrap: unset;
}
}
.body {
td {
text-align: left;
vertical-align: top;
padding: 0 0.5em;
}
.center {
text-align: center;
}
.nowrap {
white-space: nowrap;
}
.secondaryRow {
padding: 0.25em 0.25em;
background-color: $gray-50;
}
}
table tr td:first-child,
table tr th:first-child {
position: sticky;
left: 0;
z-index: 2;
background-color: white;
box-shadow: 1px 0 $gray-50;
}
@@ -0,0 +1,11 @@
@use "../../../../theme/_ontimeColours" as *;
.tag {
font-size: calc(1rem - 3px);
letter-spacing: 0.5px;
background-color: $gray-100;
color: $ui-black;
border-radius: 2px;
padding: 0 0.25rem;
white-space: nowrap;
}
@@ -0,0 +1,7 @@
import { ReactNode } from 'react';
import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
}
@@ -0,0 +1,53 @@
@use '../../../../theme/ontimeColours' as *;
@use '../../../../theme/v2Styles' as *;
.uploadedItem {
margin: 0 auto;
width: 100%;
max-width: 550px;
border: 1px solid $gray-200;
padding: 0.5rem;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 1rem;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 2rem;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: calc(1rem - 2px);
color: $ui-black;
}
.fileInfo {
grid-area: info;
font-size: calc(1rem - 4px);
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
}
@@ -0,0 +1,52 @@
import { Progress } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { isExcelFile, isOntimeFile } from '../uploadUtils';
import style from './UploadEntry.module.scss';
interface UploadEntryProps {
file: File | null;
errors?: string;
progress: number;
handleClear: () => void;
}
export default function UploadEntry(props: UploadEntryProps) {
const { file, errors, progress, handleClear } = props;
if (errors) {
return (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
</div>
);
}
if (file) {
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
let fileType = '';
if (isOntimeFile(file)) {
fileType = 'Ontime Project File';
} else if (isExcelFile(file)) {
fileType = 'Excel Rundown';
}
return (
<div className={style.uploadedItem}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
);
}
return null;
}
@@ -0,0 +1,70 @@
import { MutableRefObject } from 'react';
import { ExcelImportMap } from 'ontime-utils';
import ImportMapTable, { type TableEntry } from './ImportMapTable';
import style from '../UploadModal.module.scss';
interface ExcelFileOptionsProps {
optionsRef: MutableRefObject<ExcelImportMap>;
updateOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => void;
}
export default function ExcelFileOptions(props: ExcelFileOptionsProps) {
const { optionsRef, updateOptions } = props;
const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }];
const timings: TableEntry[] = [
{ label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart },
{ label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd },
{ label: 'Duration', title: 'duration', value: optionsRef.current.duration },
];
const titles: TableEntry[] = [
{ label: 'Cue', title: 'cue', value: optionsRef.current.cue },
{ label: 'Colour', title: 'colour', value: optionsRef.current.colour },
{ label: 'Title', title: 'title', value: optionsRef.current.title },
{ label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter },
{ label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle },
{ label: 'Note', title: 'note', value: optionsRef.current.note },
];
const options: TableEntry[] = [
{ label: 'Is Public', title: 'isPublic', value: optionsRef.current.isPublic },
{ label: 'Skip', title: 'skip', value: optionsRef.current.skip },
{ label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType },
{ label: 'End Action', title: 'endAction', value: optionsRef.current.endAction },
];
const userFields: TableEntry[] = [
{ label: 'User 0', title: 'user0', value: optionsRef.current.user0 },
{ label: 'User 1', title: 'user1', value: optionsRef.current.user1 },
{ label: 'User 2', title: 'user2', value: optionsRef.current.user2 },
{ label: 'User 3', title: 'user3', value: optionsRef.current.user3 },
{ label: 'User 4', title: 'user4', value: optionsRef.current.user4 },
{ label: 'User 5', title: 'user5', value: optionsRef.current.user5 },
{ label: 'User 6', title: 'user6', value: optionsRef.current.user6 },
{ label: 'User 7', title: 'user7', value: optionsRef.current.user7 },
{ label: 'User 8', title: 'user8', value: optionsRef.current.user8 },
{ label: 'User 9', title: 'user9', value: optionsRef.current.user9 },
];
return (
<div className={style.uploadOptions}>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Sheet settings' fields={worksheet} handleOnChange={updateOptions} />
</div>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Timings' fields={timings} handleOnChange={updateOptions} />
<ImportMapTable title='Options' fields={options} handleOnChange={updateOptions} />
</div>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Titles' fields={titles} handleOnChange={updateOptions} />
<ImportMapTable title='User Fields' fields={userFields} handleOnChange={updateOptions} />
</div>
</div>
);
}
@@ -0,0 +1,33 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.importTable {
margin: 0.5rem;
height: fit-content;
thead {
color: $gray-500;
text-transform: uppercase;
width: 10em;
}
tr:hover {
background-color: $gray-50;
}
tbody {
td {
max-width: fit-content;
}
}
}
.label {
display: inline-block;
min-width: 6em;
font-size: $inner-section-text-size;
}
.input {
width: 100%;
}
@@ -0,0 +1,50 @@
import { Input } from '@chakra-ui/react';
import { ExcelImportMap } from 'ontime-utils';
import style from './ImportMapTable.module.scss';
export type TableEntry = { label: string; title: keyof ExcelImportMap; value: string };
interface ImportMapTableProps {
title: string;
fields: TableEntry[];
handleOnChange: (field: keyof ExcelImportMap, value: string) => void;
}
export default function ImportMapTable(props: ImportMapTableProps) {
const { title, fields, handleOnChange } = props;
return (
<table className={style.importTable}>
<thead>
<tr>
<td colSpan={2}>{title}</td>
</tr>
</thead>
<tbody>
{fields.map((field) => {
return (
<tr key={field.title}>
<td className={style.label}>
<label htmlFor={field.title}>{field.label}</label>
</td>
<td className={style.input}>
<Input
id={field.title}
size='xs'
variant='ontime-filled-on-light'
maxLength={25}
defaultValue={field.value}
placeholder='Use default column name'
onBlur={(event) => {
handleOnChange(field.title, event.target.value);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
@@ -0,0 +1,31 @@
import { MutableRefObject } from 'react';
import { Switch } from '@chakra-ui/react';
import { ProjectFileImportOptions } from '../../../../common/api/ontimeApi';
import ModalSplitInput from '../../ModalSplitInput';
import style from '../UploadModal.module.scss';
interface OntimeFileOptionsProps {
optionsRef: MutableRefObject<Partial<ProjectFileImportOptions>>;
updateOptions: <T extends keyof ProjectFileImportOptions>(field: T, value: ProjectFileImportOptions[T]) => void;
}
export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
const { optionsRef, updateOptions } = props;
return (
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput field='' title='Only import rundown' description='All other project options will be kept'>
<Switch
variant='ontime-on-light'
onChange={(e) => {
updateOptions('onlyRundown', e.target.checked);
}}
defaultChecked={Boolean(optionsRef.current.onlyRundown)}
/>
</ModalSplitInput>
</div>
);
}
@@ -0,0 +1,40 @@
@use '../../../../theme/ontimeColours' as *;
@mixin row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.25rem;
}
.stepRow {
display: flex;
gap: 2rem;
align-items: center;
margin: 0 auto;
font-size: 1rem;
}
.idle {
@include row;
color: $blue-500;
}
.inactive {
@include row;
color: $gray-700;
}
.active {
@include row;
color: $blue-700;
}
.inactiveIcon {
color: $gray-700;
}
.activeIcon {
color: $blue-500;
}
@@ -0,0 +1,26 @@
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { IoChevronForward } from '@react-icons/all-files/io5/IoChevronForward';
import { IoEllipseOutline } from '@react-icons/all-files/io5/IoEllipseOutline';
import type { UploadStep } from '../UploadModal';
import style from './UploadStep.module.scss';
export default function UploadStepTracker({ uploadStep }: { uploadStep: UploadStep }) {
const isImporting = uploadStep === 'import';
const isReview = uploadStep === 'review';
return (
<div className={style.stepRow}>
<div className={isImporting ? style.active : style.idle}>
<IoCheckmarkCircle />
Import
</div>
<IoChevronForward className={isReview ? style.activeIcon : style.inactiveIcon} />
<div className={isReview ? style.active : style.inactive}>
{isReview ? <IoCheckmarkCircle /> : <IoEllipseOutline />}
Review
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { create } from 'zustand';
type UploadModalContext = {
file: File | null;
setFile: (file: File | null) => void;
progress: number;
setProgress: (progress: number) => void;
clear: () => void;
};
export const useUploadModalContextStore = create<UploadModalContext>((set) => ({
file: null,
setFile: (file: File | null) => set({ file }),
progress: 0,
setProgress: (progress: number) => set({ progress }),
clear: () => set({ file: null, progress: 0 }),
}));
@@ -0,0 +1,64 @@
import { ExcelImportMap } from 'ontime-utils';
import { ProjectFileImportOptions } from '../../../common/api/ontimeApi';
/**
* Validates a file according to the app upload contract
* @throws
* @param file
*/
export function validateFile(file: File) {
if (!file) {
throw new Error('No file to upload');
}
// Check if file is empty
if (file.size === 0) {
throw new Error('File is empty');
}
// Limit file size of a project file to around 1MB
if (file.name.endsWith('.json') && file.size > 1_000_000) {
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) {
throw new Error('File size limit (10MB) exceeded');
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
throw new Error('Unhandled file type');
}
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json');
}
type PersistedOntimeOptions = {
optionType: 'ontime';
options: Partial<ProjectFileImportOptions>;
};
type PersistedExcelOptions = {
optionType: 'excel';
options: ExcelImportMap;
};
export function persistOptions(options: PersistedOntimeOptions | PersistedExcelOptions) {
localStorage.setItem(`ontime-import-options-${options.optionType}`, JSON.stringify(options.options));
}
export function getPersistedOptions(optionType: 'excel' | 'ontime') {
const options = localStorage.getItem(`ontime-import-options-${optionType}`);
if (!options) {
return null;
}
return JSON.parse(options);
}
@@ -1,25 +0,0 @@
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;
}