refactor: remove userFields (#791)

* refactor: process custom fields on cache generate

* refactor: remove userFields
This commit is contained in:
Carlos Valente
2024-02-27 13:23:12 +01:00
committed by GitHub
parent 429df21557
commit d7392b93d2
73 changed files with 1580 additions and 2290 deletions
@@ -30,6 +30,9 @@ $inner-padding: 1rem;
margin-top: 2rem;
font-size: calc(1rem - 1px);
max-width: 800px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.paragraph {
@@ -60,15 +63,25 @@ $inner-padding: 1rem;
border-collapse: collapse;
font-size: calc(1rem - 2px);
text-align: left;
margin-bottom: 2rem;
thead {
position: sticky;
top: 0;
z-index: 3;
box-shadow: 0 1px $white-10;
}
tr {
padding: 1rem 0;
}
th {
border-bottom: 1px solid $white-10;
font-weight: 400;
color: $gray-400;
background-color: $gray-1350;
white-space: nowrap;
text-transform: capitalize;
}
th,
@@ -1,5 +1,7 @@
import { HTMLAttributes, ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils';
import style from './Panel.module.scss';
export function Header({ children }: { children: ReactNode }) {
@@ -41,10 +43,11 @@ export function Card({ children, ...props }: { children: ReactNode } & JSX.Intri
);
}
export function Table({ children }: { children: ReactNode }) {
export function Table({ className, children }: { className?: string; children: ReactNode }) {
const classes = cx([style.table, className]);
return (
<div className={style.pad}>
<table className={style.table}>{children}</table>
<table className={classes}>{children}</table>
</div>
);
}
@@ -72,7 +72,7 @@ export default function ProjectList({ isCreatingProject, onToggleCreate }: Proje
<Panel.Table>
<thead>
<tr>
<th>Project Name</th>
<th className={style.containCell}>Project Name</th>
<th>Date Created</th>
<th>Date Modified</th>
<th />
@@ -100,7 +100,7 @@ export default function ProjectListItem({
</td>
) : (
<>
<td>{filename}</td>
<td className={style.containCell}>{filename}</td>
<td>{new Date(createdAt).toLocaleString()}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
@@ -54,4 +54,8 @@
font-size: calc(1rem - 2px);
font-weight: 400;
color: $red-500;
}
}
.containCell {
max-width: 400px;
}
@@ -11,7 +11,7 @@ import * as Panel from '../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
const customFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
export default function ProjectSettingsPanel() {
const { data, refetch } = useCustomFields();
@@ -64,7 +64,7 @@ export default function ProjectSettingsPanel() {
<br />
<br />
This data is not used by Ontime.
<ExternalLink href={userFieldsDocsUrl}>See the docs</ExternalLink>
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
@@ -16,7 +16,9 @@ interface GSheetSetupProps {
onCancel: () => void;
}
export default function GSheetSetup({ onCancel }: GSheetSetupProps) {
export default function GSheetSetup(props: GSheetSetupProps) {
const { onCancel } = props;
const { revoke, connect, verifyAuth } = useGoogleSheet();
const [file, setFile] = useState<File | null>(null);
const [authKey, setAuthKey] = useState<string | null>(null);
@@ -34,6 +36,10 @@ export default function GSheetSetup({ onCancel }: GSheetSetupProps) {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
// if we are still pending, lets check again in 2seconds
if (result.authenticated === 'pending') {
setTimeout(getAuthStatus, 2000);
}
}
};
@@ -42,11 +48,6 @@ export default function GSheetSetup({ onCancel }: GSheetSetupProps) {
getAuthStatus();
}, []);
const handleCancelFlow = () => {
revoke();
onCancel();
};
// user cancels the flow
const handleRevoke = async () => {
setLoading('cancel');
@@ -55,6 +56,11 @@ export default function GSheetSetup({ onCancel }: GSheetSetupProps) {
setLoading('');
};
const handleCancelFlow = async () => {
await handleRevoke();
onCancel();
};
/**
* Gets file from input
* @param event
@@ -104,85 +110,79 @@ export default function GSheetSetup({ onCancel }: GSheetSetupProps) {
const canAuthenticate = Boolean(authKey) && Boolean(authLink);
const isLoading = Boolean(loading);
const isAuthenticated = authenticationStatus === 'authenticated';
const isAuthenticating = authenticationStatus === 'pending';
return (
<Panel.Section>
<Panel.Title>
Sync with Google Sheet (experimental)
<Button variant='ontime-subtle' size='sm' onClick={handleCancelFlow}>
Cancel
</Button>
</Panel.Title>
{isAuthenticated ? (
<Panel.ListGroup>
<Panel.Title>Authenticated</Panel.Title>
{isAuthenticated ? (
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isLoading={loading === 'cancel'}>
Revoke Authentication
</Button>
) : (
<Button variant='ontime-subtle' size='sm' onClick={handleCancelFlow}>
Go Back
</Button>
)}
</Panel.Title>
<Panel.ListGroup>
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Input
type='file'
onChange={handleClientSecret}
accept='.json'
size='sm'
variant='ontime-filled'
isDisabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='Sheet ID'
onChange={(event) => setSheetId(event.target.value)}
isDisabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup>
{!canAuthenticate ? (
<Panel.ListGroup>
<div className={style.buttonRow}>
<Button
variant='ontime-subtle'
size='sm'
leftIcon={<IoCheckmark />}
onClick={handleConnect}
isDisabled={!canConnect || isLoading}
isLoading={loading === 'connect'}
>
Connect
</Button>
</div>
</Panel.ListGroup>
) : (
<>
<Panel.ListGroup>
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Input
type='file'
onChange={handleClientSecret}
accept='.json'
size='sm'
<Panel.ListGroup>
<div className={style.buttonRow}>
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
<Button
variant='ontime-filled'
isDisabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='Sheet ID'
onChange={(event) => setSheetId(event.target.value)}
isDisabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup>
{!canAuthenticate ? (
<Panel.ListGroup>
<div className={style.buttonRow}>
<Button
variant='ontime-subtle'
size='sm'
leftIcon={<IoCheckmark />}
onClick={handleConnect}
isDisabled={!canConnect || isLoading}
isLoading={loading === 'connect'}
>
Connect
</Button>
</div>
</Panel.ListGroup>
) : (
<Panel.ListGroup>
<div className={style.buttonRow}>
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate}
isDisabled={!canAuthenticate || isLoading}
isLoading={loading === 'authenticate'}
>
Authenticate
</Button>
</div>
</Panel.ListGroup>
)}
</>
leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate}
isDisabled={!canAuthenticate || isLoading}
isLoading={loading === 'authenticate' || isAuthenticating}
>
Authenticate
</Button>
</div>
</Panel.ListGroup>
)}
</Panel.Section>
);
@@ -1,65 +0,0 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import ExcelFileOptions from '../../../modals/upload-modal/upload-options/ExcelFileOptions';
import * as Panel from '../PanelUtils';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
export default function ImportMap() {
const { importRundownPreview, exportRundown } = useGoogleSheet();
const importOptions = useSheetStore((state) => state.excelFileOptions);
const patchImportOptions = useSheetStore((state) => state.patchExcelFileOptions);
const stepData = useSheetStore((state) => state.stepData);
const sheetId = useSheetStore((state) => state.sheetId);
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
const handleExport = async () => {
if (!sheetId) return;
setLoading('export');
await exportRundown(sheetId, importOptions);
setLoading('');
};
const handleImportPreview = async () => {
if (!sheetId) return;
setLoading('import');
await importRundownPreview(sheetId, importOptions);
setLoading('');
};
const isLoading = Boolean(loading);
return (
<Panel.Section>
<Panel.Title>Import options</Panel.Title>
<ExcelFileOptions importOptions={importOptions} updateOptions={patchImportOptions} />
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
<div className={style.buttonRow}>
<Button
variant='ontime-filled'
size='sm'
onClick={handleExport}
isDisabled={isLoading || !sheetId}
isLoading={loading === 'export'}
>
Export
</Button>
<Button
variant='ontime-filled'
size='sm'
onClick={handleImportPreview}
isDisabled={isLoading || !sheetId}
isLoading={loading === 'import'}
>
Import preview
</Button>
</div>
</Panel.Section>
);
}
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { OntimeRundown, UserFields } from 'ontime-types';
import { CustomFields, OntimeRundown } from 'ontime-types';
import PreviewExcel from '../../../modals/upload-modal/preview/PreviewExcel';
import * as Panel from '../PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
@@ -10,28 +12,43 @@ import style from './SourcesPanel.module.scss';
interface ImportReviewProps {
rundown: OntimeRundown;
userFields: UserFields;
customFields: CustomFields;
onFinished: () => void;
}
export default function ImportReview({ rundown, userFields }: ImportReviewProps) {
export default function ImportReview(props: ImportReviewProps) {
const { rundown, customFields, onFinished } = props;
const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet();
const resetPreview = useSheetStore((state) => state.resetPreview);
const applyImport = () => {
importRundown(rundown, userFields);
const handleCancel = () => {
resetPreview();
onFinished();
};
const applyImport = async () => {
setLoading(true);
await importRundown(rundown, customFields);
setLoading(false);
onFinished();
};
return (
<>
<PreviewExcel rundown={rundown} userFields={userFields} />
<div className={style.buttonRow}>
<Button onClick={resetPreview} variant='ontime-ghosted' size='sm'>
Cancel
</Button>
<Button onClick={applyImport} variant='ontime-filled' size='sm'>
Apply
</Button>
</div>
</>
<Panel.Section>
<Panel.Title>
Review Rundown
<div className={style.buttonRow}>
<Button onClick={handleCancel} variant='ontime-ghosted' size='sm' isDisabled={loading}>
Cancel
</Button>
<Button onClick={applyImport} variant='ontime-filled' size='sm' isLoading={loading}>
Apply
</Button>
</div>
</Panel.Title>
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
</Panel.Section>
);
}
@@ -20,3 +20,8 @@
.inputContainer {
flex: 1;
}
.singleActionCell {
width: 50px;
text-align: center;
}
@@ -1,35 +1,61 @@
import { useRef, useState } from 'react';
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { ImportMap, unpackError } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { importSpreadsheetPreview } from '../../../../common/api/ontimeApi';
import { validateSpreadsheetImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils';
import ImportMapForm from './import-map/ImportMapForm';
import GSheetInfo from './GSheetInfo';
import GSheetSetup from './GSheetSetup';
import ImportMap from './ImportMap';
import ImportReview from './ImportReview';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
export default function SourcesPanel() {
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet'>('none');
const [error, setError] = useState('');
const { exportRundown, importRundownPreview, revoke, verifyAuth } = useGoogleSheet();
const spreadsheet = useSheetStore((state) => state.spreadsheet);
const setSpreadsheet = useSheetStore((state) => state.setSpreadsheet);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
const rundown = useSheetStore((state) => state.rundown);
const userFields = useSheetStore((state) => state.userFields);
const isAuthenticated = authenticationStatus === 'authenticated';
const hasData = rundown && userFields;
const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
const sheetId = useSheetStore((state) => state.sheetId);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = () => console.error('not yet implemented');
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
const fileToUpload = event.target.files?.[0];
if (!fileToUpload) {
setSpreadsheet(null);
return;
}
try {
validateSpreadsheetImport(fileToUpload);
setSpreadsheet(fileToUpload);
setImportFlow('excel');
} catch (error) {
const errorMessage = unpackError(error);
setError(`Error uploading file: ${errorMessage}`);
setSpreadsheet(null);
}
};
const handleUpload = () => {
fileInputRef.current?.click();
setImportFlow('excel');
};
const openGSheetFlow = () => {
@@ -40,28 +66,81 @@ export default function SourcesPanel() {
setImportFlow('none');
};
const handleSubmitImportPreview = async (importMap: ImportMap) => {
if (importFlow === 'excel') {
if (!spreadsheet) return;
try {
const previewData = await importSpreadsheetPreview(spreadsheet, importMap);
setRundown(previewData.rundown);
setCustomFields(previewData.customFields);
} catch (error) {
setError(maybeAxiosError(error));
}
}
if (importFlow === 'gsheet') {
if (!sheetId) return;
await importRundownPreview(sheetId, importMap);
}
};
const cancelImportMap = async () => {
setImportFlow('none');
if (spreadsheet) {
setSpreadsheet(null);
}
if (authenticationStatus === 'authenticated') {
await revoke();
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
}
}
};
const handleFinished = () => {
setImportFlow('none');
setRundown(null);
setSpreadsheet(null);
setCustomFields(null);
};
const handleSubmitExport = async (importMap: ImportMap) => {
if (!sheetId) return;
await exportRundown(sheetId, importMap);
};
const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet';
const hasFile = Boolean(spreadsheet);
const isAuthenticated = authenticationStatus === 'authenticated';
const showInput = importFlow === 'none';
const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile);
const showReview = rundown !== null && customFields !== null;
console.log(isAuthenticated);
return (
<>
<Panel.Header>Data sources</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
<GSheetInfo />
{!isGSheetFlow && (
{error && <Panel.Error>{error}</Panel.Error>}
{showInput && (
<>
<Input ref={fileInputRef} style={{ display: 'none' }} type='file' onChange={handleFile} accept='.xlsx' />
<GSheetInfo />
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.xlsx'
data-testid='file-input'
/>
<div className={style.uploadSection}>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoDownloadOutline />}
onClick={handleUpload}
isDisabled
>
<Button variant='ontime-filled' size='sm' leftIcon={<IoDownloadOutline />} onClick={handleUpload}>
Import from spreadsheet
</Button>
<Panel.Description>Accepts .xlsx files</Panel.Description>
@@ -75,10 +154,16 @@ export default function SourcesPanel() {
</div>
</>
)}
{isGSheetFlow && <GSheetSetup onCancel={cancelGSheetFlow} />}
{isExcelFlow && <Panel.Title>Not yet implemented</Panel.Title>}
{isAuthenticated && <ImportMap />}
{hasData && <ImportReview rundown={rundown} userFields={userFields} />}
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showImportMap && !showReview && (
<ImportMapForm
isSpreadsheet={isExcelFlow}
onCancel={cancelImportMap}
onSubmitExport={handleSubmitExport}
onSubmitImport={handleSubmitImportPreview}
/>
)}
{showReview && <ImportReview rundown={rundown} customFields={customFields} onFinished={handleFinished} />}
</Panel.Card>
</Panel.Section>
</>
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap } from 'ontime-utils';
import { isAlphanumeric } from '../../../../../common/utils/regex';
import * as Panel from '../../PanelUtils';
import { useSheetStore } from '../useSheetStore';
import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportMap } from './importMapUtils';
import style from '../SourcesPanel.module.scss';
interface ImportMapFormProps {
isSpreadsheet?: boolean;
onCancel: () => void;
onSubmitExport: (importMap: ImportMap) => Promise<void>;
onSubmitImport: (importMap: ImportMap) => Promise<void>;
}
export default function ImportMapForm(props: ImportMapFormProps) {
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
const namedImportMap = getPersistedOptions();
const {
control,
handleSubmit,
register,
formState: { errors, isValid },
} = useForm<NamedImportMap>({
mode: 'onBlur',
defaultValues: namedImportMap,
values: namedImportMap,
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
const stepData = useSheetStore((state) => state.stepData);
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
const handleExport = async (values: NamedImportMap) => {
setLoading('export');
const importMap = convertToImportMap(values);
await onSubmitExport(importMap);
setLoading('');
};
const handleImportPreview = async (values: NamedImportMap) => {
setLoading('import');
const importMap = convertToImportMap(values);
persistImportMap(values);
await onSubmitImport(importMap);
setLoading('');
};
const deleteCustomImport = (index: number) => {
remove(index);
};
const addCustomImport = () => {
append({});
};
const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading;
const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return (
<Panel.Section as='form' id='import-map'>
<Panel.Title>
Import options
<div className={style.buttonRow}>
<Button variant='ontime-subtle' size='sm' onClick={onCancel} isDisabled={isLoading}>
Cancel
</Button>
{!isSpreadsheet && (
<Button
variant='ontime-filled'
size='sm'
onClick={handleSubmit(handleExport)}
isDisabled={!canSubmitGSheet}
isLoading={loading === 'export'}
>
Export
</Button>
)}
<Button
variant='ontime-filled'
size='sm'
onClick={handleSubmit(handleImportPreview)}
isDisabled={!canSubmit}
isLoading={loading === 'import'}
>
Import preview
</Button>
</div>
</Panel.Title>
<Panel.Table>
<thead>
<tr>
<th>Ontime field</th>
<th>From spreadsheet name</th>
<th className={style.singleActionCell} />
</tr>
</thead>
<tbody>
{Object.entries(namedImportMap).map(([label, importName]) => {
if (label === 'custom') {
return null;
}
return (
<tr key={importName as string}>
<td>{label}</td>
<td>
<Input
id={importName as string}
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25}
defaultValue={importName as string}
placeholder='Use default column name'
{...register(label as keyof NamedImportMap)}
/>
</td>
<td className={style.singleActionCell} />
</tr>
);
})}
{fields.map((field, index) => {
const ontimeName = field.ontimeName;
const importName = field.importName;
const maybeOntimeError = errors.custom?.[index]?.ontimeName?.message;
const key = `custom.${index}.ontimeName`;
return (
<tr key={key}>
<td>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25}
defaultValue={ontimeName}
placeholder='Name of the field as shown in Ontime'
{...register(`custom.${index}.ontimeName`, {
pattern: {
value: isAlphanumeric,
message: 'Custom field name must be alphanumeric',
},
})}
/>
{maybeOntimeError && <Panel.Error>{maybeOntimeError}</Panel.Error>}
</td>
<td>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25}
defaultValue={importName}
placeholder='Name of the column in the spreadsheet'
{...register(`custom.${index}.importName`)}
/>
</td>
<td className={style.singleActionCell}>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => deleteCustomImport(index)}
/>
</td>
</tr>
);
})}
<tr>
<td />
<td className={style.buttonRow} colSpan={99}>
<Button size='sm' variant='ontime-subtle' rightIcon={<IoAdd />} onClick={addCustomImport}>
Add custom field
</Button>
</td>
<td />
</tr>
</tbody>
</Panel.Table>
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
</Panel.Section>
);
}
@@ -0,0 +1,40 @@
import { ImportCustom } from 'ontime-utils';
import { convertToImportMap } from '../importMapUtils';
describe('convertToImportMap', () => {
it('converts a namedImportMap to a importMap', () => {
const defaultNamedImporMap = {
Worksheet: 'event schedule',
Start: 'time start',
End: 'time end',
Duration: 'duration',
Cue: 'cue',
Title: 'title',
Presenter: 'presenter',
Subtitle: 'subtitle',
'Is Public': 'public',
Skip: 'skip',
Note: 'notes',
Colour: 'colour',
'End action': 'end action',
'Timer type': 'timer type',
'Time warning': 'warning time',
'Time danger': 'danger time',
custom: [
{ ontimeName: 'Custom1 ', importName: 'custom1' },
{ ontimeName: 'Custom2', importName: 'custom2' },
{ ontimeName: 'Custom3', importName: 'custom3' },
{ ontimeName: 'EmptyImportName', importName: '' },
{ ontimeName: '', importName: 'EmptyOntimeName' },
] as ImportCustom[],
};
const importMap = convertToImportMap(defaultNamedImporMap);
expect(importMap.custom).toStrictEqual({
Custom1: 'custom1',
Custom2: 'custom2',
Custom3: 'custom3',
});
});
});
@@ -0,0 +1,65 @@
import { ImportCustom, ImportMap } from 'ontime-utils';
export type NamedImportMap = typeof namedImportMap;
// Record of label and import name
export const namedImportMap = {
Worksheet: 'event schedule',
Start: 'time start',
End: 'time end',
Duration: 'duration',
Cue: 'cue',
Title: 'title',
Presenter: 'presenter',
Subtitle: 'subtitle',
'Is Public': 'public',
Skip: 'skip',
Note: 'notes',
Colour: 'colour',
'End action': 'end action',
'Timer type': 'timer type',
'Time warning': 'warning time',
'Time danger': 'danger time',
custom: [] as ImportCustom[],
};
export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
const custom = namedImportMap.custom.reduce((accumulator, { ontimeName, importName }) => {
if (ontimeName && importName) {
accumulator[ontimeName.trim()] = importName.trim();
}
return accumulator;
}, {});
return {
worksheet: namedImportMap.Worksheet,
timeStart: namedImportMap.Start,
timeEnd: namedImportMap.End,
duration: namedImportMap.Duration,
cue: namedImportMap.Cue,
title: namedImportMap.Title,
presenter: namedImportMap.Presenter,
subtitle: namedImportMap.Subtitle,
isPublic: namedImportMap['Is Public'],
skip: namedImportMap.Skip,
note: namedImportMap.Note,
colour: namedImportMap.Colour,
endAction: namedImportMap['End action'],
timerType: namedImportMap['Timer type'],
timeWarning: namedImportMap['Time warning'],
timeDanger: namedImportMap['Time danger'],
custom,
};
}
export function persistImportMap(options: NamedImportMap) {
localStorage.setItem('ontime-import-options', JSON.stringify(options));
}
export function getPersistedOptions(): NamedImportMap {
const options = localStorage.getItem('ontime-import-options');
if (!options) {
return namedImportMap;
}
return JSON.parse(options);
}
@@ -0,0 +1,12 @@
.center {
text-align: center;
}
.nowrap {
white-space: nowrap;
}
tr .secondaryRow {
background-color: $white-7;
padding-left: 2em;
}
@@ -0,0 +1,126 @@
import { Fragment } from 'react';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag';
import { getAccessibleColour } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../PanelUtils';
import style from './PreviewRundown.module.scss';
interface PreviewRundownProps {
rundown: OntimeRundown;
customFields: CustomFields;
}
function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown(props: PreviewRundownProps) {
const { rundown, customFields } = props;
// we only count Ontime Events which are 1 based in client
let eventIndex = 0;
const fieldHeaders = Object.keys(customFields);
return (
<Panel.Table>
<thead>
<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>Warning Time</th>
<th>Danger Time</th>
<th>Is Public</th>
<th>Skip</th>
<th>Colour</th>
<th>Timer Type</th>
<th>End Action</th>
{fieldHeaders.map((field) => (
<th key={field}>{field}</th>
))}
</tr>
</thead>
<tbody>
{rundown.map((event) => {
if (isOntimeBlock(event)) {
return (
<tr key={event.id}>
<td className={style.center}>
<Tag>-</Tag>
</td>
<td className={style.center}>
<Tag>{event.type}</Tag>
</td>
<td />
<td colSpan={99}>{event.title}</td>
</tr>
);
}
if (!isOntimeEvent(event)) {
return null;
}
eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<Fragment key={event.id}>
<tr>
<td className={style.center}>
<Tag>{eventIndex}</Tag>
</td>
<td className={style.center}>
<Tag>{event.type}</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>{millisToString(event.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</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>
{isOntimeEvent(event) &&
fieldHeaders.map((field) => {
let value = '';
if (field in event.custom) {
value = event.custom[field].value;
}
return <td key={field}>{value}</td>;
})}
</tr>
{event.note && (
<tr>
<td colSpan={99} className={style.secondaryRow}>
Note: {event.note}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Panel.Table>
);
}
@@ -1,8 +1,8 @@
import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { RUNDOWN, USERFIELDS } from '../../../../common/api/apiConstants';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/apiConstants';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import {
patchData,
@@ -20,7 +20,7 @@ export default function useGoogleSheet() {
// functions push data to store
const patchStepData = useSheetStore((state) => state.patchStepData);
const setRundown = useSheetStore((state) => state.setRundown);
const setUserFields = useSheetStore((state) => state.setUserFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
@@ -43,6 +43,7 @@ export default function useGoogleSheet() {
}
};
/** requests the revoking of an existing authenticated session */
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
try {
return revokeAuthentication();
@@ -52,18 +53,18 @@ export default function useGoogleSheet() {
};
/** fetches data from a worksheet by its ID */
const importRundownPreview = async (sheetId: string, fileOptions: ExcelImportMap) => {
const importRundownPreview = async (sheetId: string, fileOptions: ImportMap) => {
try {
const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown);
setUserFields(data.userFields);
setCustomFields(data.customFields);
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
}
};
/** writes data to a worksheet by its ID */
const exportRundown = async (sheetId: string, fileOptions: ExcelImportMap) => {
const exportRundown = async (sheetId: string, fileOptions: ImportMap) => {
try {
// write data to google
await uploadRundown(sheetId, fileOptions);
@@ -73,14 +74,14 @@ export default function useGoogleSheet() {
}
};
/** applies rundown and userfields to current project */
const importRundown = async (rundown: OntimeRundown, userFields: UserFields) => {
/** applies rundown and customFields to current project */
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => {
try {
await patchData({ rundown, userFields });
await patchData({ rundown, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN, ...USERFIELDS],
queryKey: [RUNDOWN, CUSTOM_FIELDS],
});
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
@@ -1,30 +1,30 @@
import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand';
// TODO: persist excelFileOptions to localStorage
type SheetStore = {
stepData: typeof initialStepData;
patchStepData: (patch: Partial<typeof initialStepData>) => void;
spreadsheet: File | null;
setSpreadsheet: (spreadsheet: File | null) => void;
sheetId: string | null;
setSheetId: (sheetId: string | null) => void;
authenticationStatus: AuthenticationStatus;
setAuthenticationStatus: (status: AuthenticationStatus) => void;
// we get this from a preview response
rundown: OntimeRundown | null;
setRundown: (rundown: OntimeRundown | null) => void;
userFields: UserFields | null;
setUserFields: (userFields: UserFields | null) => void;
// we get this from a preview response
customFields: CustomFields | null;
setCustomFields: (customFields: CustomFields | null) => void;
worksheetOptions: string[] | null;
setWorksheetOptions: (worksheetOptions: string[] | null) => void;
excelFileOptions: ExcelImportMap;
patchExcelFileOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => void;
spreadsheetImportMap: ImportMap;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
reset: () => void;
resetPreview: () => void;
@@ -39,12 +39,12 @@ const initialStepData = {
const initialState = {
stepData: initialStepData,
spreadsheet: null,
sheetId: null,
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null,
userFields: null,
worksheetOptions: null,
excelFileOptions: defaultExcelImportMap,
customFields: null,
spreadsheetImportMap: defaultImportMap,
};
export const useSheetStore = create<SheetStore>((set, get) => ({
@@ -55,23 +55,23 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
set({ stepData: { ...stepData, ...patch } });
},
setSpreadsheet: (spreadsheet: File | null) => set({ spreadsheet }),
setSheetId: (sheetId: string | null) => set({ sheetId }),
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }),
setUserFields: (userFields: UserFields | null) => set({ userFields }),
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
setWorksheetOptions: (worksheetOptions: string[] | null) => set({ worksheetOptions }),
patchExcelFileOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
const excelFileOptions = get().excelFileOptions;
if (excelFileOptions[field] !== value) {
excelFileOptions[field] = value;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
const currentImportMap = get().spreadsheetImportMap;
if (currentImportMap[field] !== value) {
currentImportMap[field] = value;
}
},
reset: () => set(initialState),
resetPreview: () => set({ rundown: null, userFields: null }),
resetPreview: () => set({ rundown: null, customFields: null }),
}));
+1 -22
View File
@@ -13,30 +13,9 @@ export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'subtitle',
'presenter',
'note',
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
];
/**
* @description set default hidden columns
*/
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
];
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [];
@@ -1,9 +1,10 @@
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 { validateFile } from './uploadUtils';
import style from './UploadModal.module.scss';
@@ -52,12 +53,12 @@ export default function UploadFile() {
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
accept='.json'
data-testid='file-input'
/>
{!file && (
<div className={style.uploadArea} onClick={handleClick} role='button'>
Click to select Ontime project or xlsx rundown
Click to select Ontime project
</div>
)}
{(file || errors) && <UploadEntry file={file} errors={errors} progress={progress} handleClear={clearFile} />}
@@ -9,27 +9,15 @@ import {
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { OntimeRundown } from 'ontime-types';
import { RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
import {
patchData,
postPreviewExcel,
ProjectFileImportOptions,
uploadProjectFile,
} from '../../../common/api/ontimeApi';
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { ProjectFileImportOptions, uploadProjectFile } from '../../../common/api/ontimeApi';
import { isOntimeFile } from '../../../common/utils/uploadUtils';
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';
@@ -41,19 +29,15 @@ interface UploadModalProps {
}
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const queryClient = useQueryClient();
const { file, setProgress, clear } = useUploadModalContextStore();
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 [errors, setErrors] = useState('');
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
const updateOntimeFileOptions = <T extends keyof ProjectFileImportOptions>(
field: T,
@@ -62,27 +46,12 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
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;
}
}, []);
// if the modal re-opens, we want to restart all states
useEffect(() => {
clear();
setUploadStep('import');
setSubmitting(false);
setRundown(null);
setUserFields(null);
setErrors('');
}, [clear, isOpen]);
@@ -102,10 +71,6 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
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) {
const message = maybeAxiosError(error);
@@ -118,17 +83,6 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
}
}
// 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);
// 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);
@@ -139,37 +93,9 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const handleClose = () => {
clear();
setRundown([]);
setUserFields(userFieldsPlaceholder);
onClose();
};
const handleFinalise = async () => {
// this step is currently only used for excel files, after preview
if (isExcel && rundown && userFields) {
let doClose = false;
setSubmitting(true);
try {
await patchData({ rundown, userFields });
// TODO: broken :(
// we need to normalise the data here
queryClient.setQueryData(RUNDOWN, { rundown, revision: -1 });
queryClient.setQueryData(USERFIELDS, userFields);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN, ...USERFIELDS],
});
doClose = true;
} catch (error) {
const message = maybeAxiosError(error);
setErrors(`Failed applying changes ${message}`);
} finally {
setSubmitting(false);
if (doClose) {
handleClose();
}
}
}
};
const undoReview = () => {
setUploadStep('import');
setErrors('');
@@ -177,11 +103,10 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
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 handleSubmit = handleUpload;
const disableSubmit = (isImporting && !file) || (isReview && rundown === null);
const disableGoBack = isImporting;
const submitText = isImporting ? 'Import' : 'Finish';
@@ -202,18 +127,10 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
<ModalHeader>File import</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.uploadBody}>
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
{uploadStep === 'import' ? (
<>
<UploadFile />
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} updateOptions={updateOntimeFileOptions} />}
{isExcel && (
<ExcelFileOptions importOptions={excelFileOptions.current} updateOptions={updateExcelFileOptions} />
)}
</>
) : (
<PreviewExcel rundown={rundown ?? []} userFields={userFields ?? userFieldsPlaceholder} />
)}
<>
<UploadFile />
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} updateOptions={updateOntimeFileOptions} />}
</>
</ModalBody>
<ModalFooter>
<div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
@@ -1,21 +0,0 @@
import { OntimeRundown, UserFields } from 'ontime-types';
import PreviewRundown from './PreviewRundown';
import style from '../../Modal.module.scss';
interface PreviewExcelProps {
rundown: OntimeRundown;
userFields: UserFields;
}
export default function PreviewExcel(props: PreviewExcelProps) {
const { rundown, userFields } = props;
return (
<div className={`${style.column}`}>
<div className={style.title}>Review Rundown</div>
<PreviewRundown rundown={rundown} userFields={userFields} />
</div>
);
}
@@ -1,151 +0,0 @@
import { Fragment } from 'react';
import { isOntimeBlock, 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) {
// we only count Ontime Events which are 1 based in client
let eventIndex = 0;
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>Warning Time</th>
<th>Danger Time</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) => {
if (isOntimeBlock(event)) {
return (
<tr key={event.id}>
<td className={style.center}>
<Tag>-</Tag>
</td>
<td className={style.center}>
<Tag>{event.type}</Tag>
</td>
<td />
<td colSpan={99}>{event.title}</td>
</tr>
);
}
if (!isOntimeEvent(event)) {
return null;
}
eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<Fragment key={event.id}>
<tr>
<td className={style.center}>
<Tag>{eventIndex}</Tag>
</td>
<td className={style.center}>
<Tag>{event.type}</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>{millisToString(event.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</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>
);
}
@@ -1,68 +0,0 @@
.container {
max-width: 100%;
max-height: max(300px, 30vh);
overflow: scroll;
}
.rundownPreview {
font-size: calc(1rem - 2px);
overflow-x: scroll;
tr td:first-child,
tr th:first-child {
position: sticky;
left: 0;
z-index: 2;
background-color: white;
box-shadow: 1px 0 $gray-50;
}
}
.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;
}
}
@@ -1,11 +0,0 @@
.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;
text-transform: capitalize;
}
@@ -1,7 +0,0 @@
import { ReactNode } from 'react';
import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
}
@@ -3,7 +3,7 @@ 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 { isOntimeFile } from '../../../../common/utils/uploadUtils';
import style from './UploadEntry.module.scss';
@@ -33,8 +33,6 @@ export default function UploadEntry(props: UploadEntryProps) {
let fileType = '';
if (isOntimeFile(file)) {
fileType = 'Ontime Project File';
} else if (isExcelFile(file)) {
fileType = 'Excel Rundown';
}
return (
@@ -1,71 +0,0 @@
import { ExcelImportMap } from 'ontime-utils';
import ImportMapTable, { type TableEntry } from './ImportMapTable';
import style from '../UploadModal.module.scss';
interface ExcelFileOptionsProps {
importOptions: ExcelImportMap;
updateOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => void;
}
export default function ExcelFileOptions(props: ExcelFileOptionsProps) {
const { importOptions, updateOptions } = props;
const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: importOptions.worksheet }];
const timings: TableEntry[] = [
{ label: 'Start time', title: 'timeStart', value: importOptions.timeStart },
{ label: 'End Time', title: 'timeEnd', value: importOptions.timeEnd },
{ label: 'Duration', title: 'duration', value: importOptions.duration },
{ label: 'Warning Time', title: 'timeWarning', value: importOptions.timeWarning },
{ label: 'Danger Time', title: 'timeDanger', value: importOptions.timeDanger },
];
const titles: TableEntry[] = [
{ label: 'Cue', title: 'cue', value: importOptions.cue },
{ label: 'Colour', title: 'colour', value: importOptions.colour },
{ label: 'Title', title: 'title', value: importOptions.title },
{ label: 'Presenter', title: 'presenter', value: importOptions.presenter },
{ label: 'Subtitle', title: 'subtitle', value: importOptions.subtitle },
{ label: 'Note', title: 'note', value: importOptions.note },
];
const options: TableEntry[] = [
{ label: 'Is Public', title: 'isPublic', value: importOptions.isPublic },
{ label: 'Skip', title: 'skip', value: importOptions.skip },
{ label: 'Timer Type', title: 'timerType', value: importOptions.timerType },
{ label: 'End Action', title: 'endAction', value: importOptions.endAction },
];
const userFields: TableEntry[] = [
{ label: 'User 0', title: 'user0', value: importOptions.user0 },
{ label: 'User 1', title: 'user1', value: importOptions.user1 },
{ label: 'User 2', title: 'user2', value: importOptions.user2 },
{ label: 'User 3', title: 'user3', value: importOptions.user3 },
{ label: 'User 4', title: 'user4', value: importOptions.user4 },
{ label: 'User 5', title: 'user5', value: importOptions.user5 },
{ label: 'User 6', title: 'user6', value: importOptions.user6 },
{ label: 'User 7', title: 'user7', value: importOptions.user7 },
{ label: 'User 8', title: 'user8', value: importOptions.user8 },
{ label: 'User 9', title: 'user9', value: importOptions.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>
);
}
@@ -1,30 +0,0 @@
.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%;
}
@@ -1,50 +0,0 @@
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='sm'
variant='ontime-filled'
maxLength={25}
defaultValue={field.value}
placeholder='Use default column name'
onBlur={(event) => {
handleOnChange(field.title, event.target.value);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
@@ -1,64 +0,0 @@
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,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
@@ -107,7 +107,7 @@ export default function Operator() {
const handleEdit = useCallback(
(event: EditEvent) => {
const field = searchParams.get('subscribe') as keyof UserFields | null;
const field = searchParams.get('subscribe') as keyof CustomField | null;
if (field) {
setEditEvent({ ...event, field });
@@ -7,10 +7,12 @@
display: flex;
flex-direction: column;
overflow-x: auto;
}
.content {
padding-right: 4px;
padding-bottom: 4rem;
flex: 1;
display: flex;
@@ -44,6 +46,7 @@
font-size: calc(1rem - 3px);
color: $label-gray;
margin-bottom: 0.25rem;
text-transform: capitalize;
}
.delayLabel {
@@ -16,25 +16,7 @@ import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
// TODO: this logic will become dynamic
export type EditorUpdateFields =
| 'cue'
| 'title'
| 'presenter'
| 'subtitle'
| 'note'
| 'colour'
| 'user0'
| 'user1'
| 'user2'
| 'user3'
| 'user4'
| 'user5'
| 'user6'
| 'user7'
| 'user8'
| 'user9'
| CustomFieldLabel; // TODO: keyof customFields
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour' | CustomFieldLabel; // TODO: keyof customFields
export default function EventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -85,8 +67,6 @@ export default function EventEditor() {
);
}
const customKeys = Object.keys(customFields ?? {});
return (
<div className={style.eventEditor} data-testid='editor-container'>
<div className={style.content}>
@@ -123,13 +103,17 @@ export default function EventEditor() {
Manage
</Button>
</div>
{customKeys.map((label) => {
{Object.keys(customFields).map((label) => {
const key = `${event.id}-${label}`;
const fieldName = `custom-${label}`;
const initialValue = event.custom[label]?.value ?? '';
return (
<EventTextArea
key={`${event.id}-${label}`}
field={`custom-${label}`}
key={key}
field={fieldName}
label={label}
initialValue={event.custom[label]?.value ?? ''}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-color': customFields[label].colour } as CSSProperties}
@@ -46,13 +46,13 @@ const EventEditorTitles = (props: EventEditorLeftProps) => {
</div>
<EventTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
</div>
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EventTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<EventTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
<div>
<label className={style.inputLabel}>Colour</label>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EventTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<EventTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
);