mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
refactor: migrate project upload (#796)
* refactor: migrate project upload
This commit is contained in:
@@ -135,6 +135,21 @@ export const downloadRundown = (fileName?: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to upload project file
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function importProjectFile(file: File): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file);
|
||||
const response = await axios.post(`${ontimeURL}/db`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// TODO: should this be extracted to shared code?
|
||||
export type ProjectFileImportOptions = {
|
||||
onlyRundown: boolean;
|
||||
@@ -341,10 +356,7 @@ export async function createProject(
|
||||
}
|
||||
>,
|
||||
): Promise<MessageResponse> {
|
||||
// TODO: is this URL correct?
|
||||
const url = `${ontimeURL}/project`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.post(decodedUrl, project);
|
||||
const res = await axios.post(`${ontimeURL}/project`, project);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,37 +38,6 @@ export function validateProjectFile(file: File) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/apiUtils';
|
||||
import { createProject } from '../../../../common/api/ontimeApi';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export type ProjectCreateFormValues = {
|
||||
interface ProjectCreateFromProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ProjectCreateFormValues = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
publicInfo?: string;
|
||||
@@ -13,13 +21,11 @@ export type ProjectCreateFormValues = {
|
||||
backstageUrl?: string;
|
||||
};
|
||||
|
||||
interface ProjectCreateFormProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: ProjectCreateFormValues) => Promise<void>;
|
||||
submitError: string | null;
|
||||
}
|
||||
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
export default function ProjectCreateForm({ onSubmit, onCancel, submitError }: ProjectCreateFormProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
@@ -33,12 +39,40 @@ export default function ProjectCreateForm({ onSubmit, onCancel, submitError }: P
|
||||
},
|
||||
});
|
||||
|
||||
// set focus to first field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
|
||||
try {
|
||||
setError(null);
|
||||
const filename = values.title?.trim();
|
||||
|
||||
await createProject({
|
||||
...values,
|
||||
filename,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Title>
|
||||
Create new project
|
||||
<div className={style.createActionButtons}>
|
||||
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button isDisabled={!isValid} type='submit' isLoading={isSubmitting} variant='ontime-filled' size='sm'>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<div className={style.createFormInputField}>
|
||||
<label>
|
||||
Project title
|
||||
@@ -115,22 +149,6 @@ export default function ProjectCreateForm({ onSubmit, onCancel, submitError }: P
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={style.createActionButtons}>
|
||||
<Button onClick={onCancel} variant='ontime-ghosted' size='sm'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={!isValid}
|
||||
type='submit'
|
||||
isLoading={isSubmitting}
|
||||
variant='ontime-filled'
|
||||
padding='0 2em'
|
||||
size='sm'
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
{submitError && <span className={style.error}>{submitError}</span>}
|
||||
</form>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export type ProjectFormValues = {
|
||||
@@ -62,7 +64,7 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{submitError && <span className={style.error}>{submitError}</span>}
|
||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/apiUtils';
|
||||
import { createProject } from '../../../../common/api/ontimeApi';
|
||||
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ProjectCreateForm, { ProjectCreateFormValues } from './ProjectCreateForm';
|
||||
import ProjectListItem, { EditMode } from './ProjectListItem';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
interface ProjectListProps {
|
||||
isCreatingProject: boolean;
|
||||
onToggleCreate: () => void;
|
||||
}
|
||||
|
||||
export default function ProjectList({ isCreatingProject, onToggleCreate }: ProjectListProps) {
|
||||
export default function ProjectList() {
|
||||
const { data, refetch } = useProjectList();
|
||||
const { files, lastLoadedProject } = data;
|
||||
|
||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleToggleCreateMode = () => {
|
||||
onToggleCreate();
|
||||
setSubmitError(null);
|
||||
};
|
||||
|
||||
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
|
||||
try {
|
||||
setSubmitError(null);
|
||||
const filename = values.title?.trim();
|
||||
|
||||
await createProject({
|
||||
...values,
|
||||
filename,
|
||||
});
|
||||
await refetch();
|
||||
handleToggleCreateMode();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||
@@ -79,14 +49,6 @@ export default function ProjectList({ isCreatingProject, onToggleCreate }: Proje
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isCreatingProject ? (
|
||||
<tr className={style.createContainer}>
|
||||
<td colSpan={99}>
|
||||
<ProjectCreateForm onSubmit={handleSubmitCreate} onCancel={handleToggleCreateMode} submitError='' />
|
||||
{submitError && <span className={style.createSubmitError}>{submitError}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{reorderedProjectFiles.map((project) => (
|
||||
<ProjectListItem
|
||||
key={project.filename}
|
||||
|
||||
@@ -85,9 +85,10 @@ export default function ProjectListItem({
|
||||
};
|
||||
|
||||
const isCurrentlyBeingEdited = editingMode && filename === editingFilename;
|
||||
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
|
||||
|
||||
return (
|
||||
<tr key={filename} className={current ? style.current : undefined}>
|
||||
<tr key={filename} className={classes}>
|
||||
{isCurrentlyBeingEdited ? (
|
||||
<td colSpan={99}>
|
||||
<ProjectForm
|
||||
@@ -160,6 +161,7 @@ function ActionMenu({
|
||||
as={IconButton}
|
||||
aria-label='Options'
|
||||
icon={<IoEllipsisHorizontal />}
|
||||
color='#e2e2e2' // $gray-200
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.current {
|
||||
color: $blue-400;
|
||||
background-color: $gray-1350;
|
||||
background-color: $blue-900;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
@@ -23,25 +22,23 @@
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
.headerButtons,
|
||||
.actionButtons,
|
||||
.createActionButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.createActionButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.createContainer {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
@@ -50,12 +47,6 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.createSubmitError {
|
||||
font-size: calc(1rem - 2px);
|
||||
font-weight: 400;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.containCell {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/apiUtils';
|
||||
import { importProjectFile } from '../../../../common/api/ontimeApi';
|
||||
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ProjectCreateForm from './ProjectCreateForm';
|
||||
import ProjectList from './ProjectList';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ProjectPanel() {
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState<'import' | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleToggleCreate = () => {
|
||||
setIsCreatingProject((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target?.files?.[0];
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('import');
|
||||
|
||||
try {
|
||||
validateProjectFile(selectedFile);
|
||||
await importProjectFile(selectedFile);
|
||||
} catch (error) {
|
||||
const errorMessage = maybeAxiosError(error);
|
||||
setError(`Error uploading file: ${errorMessage}`);
|
||||
} finally {
|
||||
invalidateAllCaches();
|
||||
}
|
||||
|
||||
setLoading(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Project</Panel.Header>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleImport}
|
||||
accept='.json'
|
||||
data-testid='file-input'
|
||||
/>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage projects
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<Button variant='ontime-subtle' onClick={handleToggleCreate} size='sm'>
|
||||
<div className={style.headerButtons}>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
onClick={handleSelectFile}
|
||||
size='sm'
|
||||
isDisabled={Boolean(loading) || isCreatingProject}
|
||||
isLoading={loading === 'import'}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={handleToggleCreate} size='sm'>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
onClick={handleToggleCreate}
|
||||
size='sm'
|
||||
isDisabled={Boolean(loading) || isCreatingProject}
|
||||
rightIcon={<IoAdd />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
<ProjectList onToggleCreate={handleToggleCreate} isCreatingProject={isCreatingProject} />
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
{isCreatingProject && <ProjectCreateForm onClose={() => setIsCreatingProject(false)} />}
|
||||
<ProjectList />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</>
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function SourcesPanel() {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinished = () => {
|
||||
setImportFlow('none');
|
||||
setRundown(null);
|
||||
@@ -119,7 +120,6 @@ export default function SourcesPanel() {
|
||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile);
|
||||
const showReview = rundown !== null && customFields !== null;
|
||||
|
||||
console.log(isAuthenticated);
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Data sources</Panel.Header>
|
||||
|
||||
@@ -5,7 +5,6 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
||||
import AppSettings from '../app-settings/AppSettings';
|
||||
import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore';
|
||||
import MenuBar from '../menu/MenuBar';
|
||||
import UploadModal from '../modals/upload-modal/UploadModal';
|
||||
import Overview from '../overview/Overview';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
@@ -24,7 +23,6 @@ export default function Editor() {
|
||||
};
|
||||
|
||||
const { isOpen: isOldSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure();
|
||||
const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure();
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
@@ -36,7 +34,6 @@ export default function Editor() {
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
||||
<SettingsModal isOpen={isOldSettingsOpen} onClose={onSettingsClose} />
|
||||
</ErrorBoundary>
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
@@ -45,8 +42,6 @@ export default function Editor() {
|
||||
isOldSettingsOpen={isOldSettingsOpen}
|
||||
onSettingsOpen={onSettingsOpen}
|
||||
onSettingsClose={onSettingsClose}
|
||||
isUploadOpen={isUploadModalOpen}
|
||||
onUploadOpen={onUploadModalOpen}
|
||||
openSettings={handleSettings}
|
||||
isSettingsOpen={isSettingsOpen}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { IconButton, MenuButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { IoSnowOutline } from '@react-icons/all-files/io5/IoSnowOutline';
|
||||
|
||||
@@ -21,8 +20,6 @@ interface MenuBarProps {
|
||||
isOldSettingsOpen: boolean;
|
||||
onSettingsOpen: () => void;
|
||||
onSettingsClose: () => void;
|
||||
isUploadOpen: boolean;
|
||||
onUploadOpen: () => void;
|
||||
openSettings: (newTab?: string) => void;
|
||||
isSettingsOpen: boolean;
|
||||
}
|
||||
@@ -40,15 +37,7 @@ const buttonStyle = {
|
||||
};
|
||||
|
||||
const MenuBar = (props: MenuBarProps) => {
|
||||
const {
|
||||
isOldSettingsOpen,
|
||||
onSettingsOpen,
|
||||
onSettingsClose,
|
||||
isUploadOpen,
|
||||
onUploadOpen,
|
||||
openSettings,
|
||||
isSettingsOpen,
|
||||
} = props;
|
||||
const { isOldSettingsOpen, onSettingsOpen, onSettingsClose, openSettings, isSettingsOpen } = props;
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
const appMode = useAppMode((state) => state.mode);
|
||||
@@ -95,16 +84,6 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
<div className={style.menu}>
|
||||
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
|
||||
<div className={style.gap} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
isDisabled={appMode === AppMode.Run}
|
||||
icon={<IoPushOutline />}
|
||||
className={isUploadOpen ? style.open : ''}
|
||||
clickHandler={onUploadOpen}
|
||||
tooltip='Import project file'
|
||||
aria-label='Import project file'
|
||||
/>
|
||||
<div className={style.gap} />
|
||||
<RundownMenu>
|
||||
<Tooltip label='Rundown...'>
|
||||
<MenuButton as={IconButton} icon={<IoAdd />} {...buttonStyle} aria-label='Rundown menu' />
|
||||
@@ -141,8 +120,8 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
icon={<IoSettingsOutline />}
|
||||
className={isOldSettingsOpen ? style.open : ''}
|
||||
clickHandler={onSettingsOpen}
|
||||
tooltip='Settings'
|
||||
aria-label='Settings'
|
||||
tooltip='Settings deprecated'
|
||||
aria-label='Settings deprecated'
|
||||
/>
|
||||
|
||||
<TooltipActionBtn
|
||||
@@ -150,8 +129,8 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
className={cx([isSettingsOpen ? style.open : null, style.bottom])}
|
||||
icon={<IoSettingsOutline />}
|
||||
clickHandler={() => openSettings()}
|
||||
tooltip='About'
|
||||
aria-label='About'
|
||||
tooltip='Application settings'
|
||||
aria-label='Application settings'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import { validateFile } from '../../../common/utils/uploadUtils';
|
||||
|
||||
import UploadEntry from './upload-entry/UploadEntry';
|
||||
import { useUploadModalContextStore } from './uploadModalContext';
|
||||
|
||||
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'
|
||||
data-testid='file-input'
|
||||
/>
|
||||
{!file && (
|
||||
<div className={style.uploadArea} onClick={handleClick} role='button'>
|
||||
Click to select Ontime project
|
||||
</div>
|
||||
)}
|
||||
{(file || errors) && <UploadEntry file={file} errors={errors} progress={progress} handleClear={clearFile} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
@import "../Modal.module.scss";
|
||||
|
||||
.uploadBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.uploadArea {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
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: $modal-note-color;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pad {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.twoColumn {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
import { OntimeRundown } from 'ontime-types';
|
||||
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
|
||||
import { ProjectFileImportOptions, uploadProjectFile } from '../../../common/api/ontimeApi';
|
||||
import { isOntimeFile } from '../../../common/utils/uploadUtils';
|
||||
|
||||
import OntimeFileOptions from './upload-options/OntimeFileOptions';
|
||||
import UploadFile from './UploadFile';
|
||||
import { useUploadModalContextStore } from './uploadModalContext';
|
||||
|
||||
import style from './UploadModal.module.scss';
|
||||
|
||||
export type UploadStep = 'import' | 'review';
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||
const { file, setProgress, clear } = useUploadModalContextStore();
|
||||
|
||||
const [uploadStep, setUploadStep] = useState<UploadStep>('import');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
|
||||
|
||||
const [errors, setErrors] = useState('');
|
||||
|
||||
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
|
||||
|
||||
const updateOntimeFileOptions = <T extends keyof ProjectFileImportOptions>(
|
||||
field: T,
|
||||
value: ProjectFileImportOptions[T],
|
||||
) => {
|
||||
ontimeFileOptions.current = { ...ontimeFileOptions.current, [field]: value };
|
||||
};
|
||||
|
||||
// if the modal re-opens, we want to restart all states
|
||||
useEffect(() => {
|
||||
clear();
|
||||
setUploadStep('import');
|
||||
setSubmitting(false);
|
||||
setRundown(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 {
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setErrors(`Failed uploading file ${message}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
if (doClose) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const undoReview = () => {
|
||||
setUploadStep('import');
|
||||
setErrors('');
|
||||
};
|
||||
|
||||
const isImporting = uploadStep === 'import';
|
||||
const isReview = uploadStep === 'review';
|
||||
const isOntime = isOntimeFile(file);
|
||||
|
||||
const handleGoBack = isImporting ? undefined : undoReview;
|
||||
const handleSubmit = handleUpload;
|
||||
const disableSubmit = (isImporting && !file) || (isReview && rundown === null);
|
||||
const disableGoBack = isImporting;
|
||||
const submitText = isImporting ? 'Import' : 'Finish';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
isOpen={isOpen}
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset='slideInBottom'
|
||||
size='xl'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime-upload'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>File import</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={style.uploadBody}>
|
||||
<>
|
||||
<UploadFile />
|
||||
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} updateOptions={updateOntimeFileOptions} />}
|
||||
</>
|
||||
</ModalBody>
|
||||
<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,50 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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 { isOntimeFile } from '../../../../common/utils/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';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user