* refactor: cleanup routes

* style: smaller base font

* chore: upgrade dependencies

* chore: lock node version to electron

* refactor: pass HTTP to integration controller (#652)

* refactor: deprecate onair control

* refactor: remove playback router

* Several project files user folder (#617)

* chore: automated screenshots (#667)

* feat: app settings (#658)

* refactor: remove deprecated event data (#674)

* Studio clock (#663)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Feat: reorder events with alt+ctrl + arrow up/down (#645)

* Warning and danger per event (#677)

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* refactor: stabilise actionHandler (#683)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improvement: hide seconds (#675)

* wip: overview (#688)

* fix: focus cursor (#695)

* refactor: update lower third (#665)

* Refactor/time formatting (#696)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: multiple selection (#703)

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>

* fix: test - go to `Edit mode` befor tying to click `Event options` button (#708)

* refactor: runtime service (#715)

* fix: issue with loosing cursor position on message (#719)

* remove info panel (#721)

* Event editor continue (#722)

* update API - part  (#709)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: update timers (#729)

* feat: many timers (#706)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* refactor: excel cleanup (#734)

* refactor: allow import of blocks and skip import (#735)

* Project manager (#697)

* refactor: UI for linking events (#763)

* upgraded pipeline actions (#777)

* Over under (#771)

* custom fields (#744)


---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Sheets settings (#774)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: tweaks to lower thirds (#785)

* refactor: delays account for gaps (#784)

* refactor: partial state updates (#780)

* feat: generate crash report (#787)

* Sheet use limited input device auth flow (#782)

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Custom fields views (#789)

* refactor: deprecate presenter and subtitle (#795)

* refactor: organise API around resources (#798)

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>

* Time to end (#804)

* Skip fixes (#805)

* fix: onair derives from playback

* Param nav (#822)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

* refactor: download files from interface (#831)

* Quick options (#814)

* End pause (#832)

* chore: bump node version in docker (#834)

* refactor: follow in run mode (#840)

* fix: uncaught error in http integration (#837)

* Apply project (#843)

Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com>
Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com>
Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
This commit is contained in:
Carlos Valente
2024-04-13 10:06:46 +02:00
committed by GitHub
parent debdbd1c5a
commit 55d1aca9b6
640 changed files with 26702 additions and 21623 deletions
@@ -0,0 +1,21 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
export default function GSheetInfo() {
return (
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Ontime allows you to synchronize your rundown with a Google Sheet.
<br />
<br />
To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime.
<br />
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,211 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { Button, Input, Spinner } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../PanelUtils';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
interface GSheetSetupProps {
onCancel: () => void;
}
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);
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate'>('');
const [authLink, setAuthLink] = useState('');
const sheetId = useSheetStore((state) => state.sheetId);
const setSheetId = useSheetStore((state) => state.setSheetId);
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const patchStepData = useSheetStore((state) => state.patchStepData);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
/** Check if we are authenticated */
const getAuthStatus = async () => {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
}
};
/** check if the current session has been authenticated */
useEffect(() => {
untilAuthenticated();
}, []);
// user cancels the flow
const handleRevoke = async () => {
setLoading('cancel');
await revoke();
await getAuthStatus();
setLoading('');
};
const handleCancelFlow = async () => {
onCancel();
};
/**
* Gets file from input
* @param event
*/
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) {
return;
}
setFile(event.target.files[0]);
};
/**
* Requests connection to google auth
*/
const handleConnect = async () => {
if (!file) return;
if (!sheetId) return;
setLoading('connect');
const result = await connect(file, sheetId);
if (result) {
setAuthLink(result.verification_url);
setAuthKey(result.user_code);
}
setLoading('');
};
const untilAuthenticated = async (attempts: number = 0) => {
const result = await verifyAuth();
if (result?.authenticated) {
setAuthenticationStatus(result.authenticated);
if (result.authenticated !== 'pending') {
if (result.authenticated == 'authenticated') {
try {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
patchStepData({ worksheet: { available: false, error: message } });
}
}
setLoading('');
return;
}
}
if (attempts <= 10) {
setTimeout(() => untilAuthenticated(attempts + 1), 2000);
return;
}
setLoading('');
};
/**
* Open google auth
*/
const handleAuthenticate = async () => {
setLoading('authenticate');
// open link and schedule a check for when the user focuses again
openLink(authLink);
window.addEventListener(
'focus',
async () => {
untilAuthenticated();
},
{ once: true },
);
};
const canConnect = file && sheetId;
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)
{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>
<div className={style.buttonRow}>
{isAuthenticating && <Spinner />}
<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}
>
Authenticate
</Button>
</div>
</Panel.ListGroup>
)}
</Panel.Section>
);
}
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { CustomFields, OntimeRundown } from 'ontime-types';
import * as Panel from '../PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
interface ImportReviewProps {
rundown: OntimeRundown;
customFields: CustomFields;
onFinished: () => void;
onCancel: () => void;
}
export default function ImportReview(props: ImportReviewProps) {
const { rundown, customFields, onFinished, onCancel } = props;
const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet();
const resetPreview = useSheetStore((state) => state.resetPreview);
const handleCancel = () => {
resetPreview();
onCancel();
};
const applyImport = async () => {
setLoading(true);
await importRundown(rundown, customFields);
setLoading(false);
onFinished();
};
return (
<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>
);
}
@@ -0,0 +1,40 @@
.uploadSection,
.successSection {
margin-top: 1rem;
display: flex;
padding: 3rem 1rem;
align-items: center;
justify-content: center;
background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: 3px;
}
.uploadSection {
flex-direction: row;
gap: 2rem;
}
.successSection {
color: $green-500;
font-size: 1.5rem;
text-align: center;
flex-direction: column;
gap: 1rem;
}
.buttonRow {
display: flex;
gap: 1rem;
align-items: center;
justify-content: end;
}
.inputContainer {
flex: 1;
}
.singleActionCell {
width: 50px;
text-align: center;
}
@@ -0,0 +1,216 @@
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 { getErrorMessage, ImportMap } from 'ontime-utils';
import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel,
} from '../../../../common/api/excel';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import { validateExcelImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils';
import ImportMapForm from './import-map/ImportMapForm';
import GSheetInfo from './GSheetInfo';
import GSheetSetup from './GSheetSetup';
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' | 'finished'>('none');
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const { exportRundown, importRundownPreview, verifyAuth } = useGoogleSheet();
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
const rundown = useSheetStore((state) => state.rundown);
const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSheetId = useSheetStore((state) => state.setSheetId);
const sheetId = useSheetStore((state) => state.sheetId);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
const fileToUpload = event.target.files?.[0];
if (!fileToUpload) {
setWorksheets(null);
setHasFile('none');
return;
}
try {
setHasFile('loading');
validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
setWorksheets(names);
setImportFlow('excel');
setHasFile('done');
} catch (error) {
const errorMessage = getErrorMessage(error);
setError(`Error uploading file: ${errorMessage}`);
setWorksheets(null);
setHasFile('none');
}
};
const handleUpload = () => {
fileInputRef.current?.click();
};
const openGSheetFlow = async () => {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
setSheetId(result.sheetId);
if (result.authenticated === 'authenticated' && result.sheetId) {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
}
}
setImportFlow('gsheet');
};
const cancelGSheetFlow = () => {
setImportFlow('none');
};
const handleSubmitImportPreview = async (importMap: ImportMap) => {
if (importFlow === 'excel') {
try {
const previewData = await importRundownPreviewExcel(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');
setHasFile('none');
setWorksheets(null);
if (authenticationStatus === 'authenticated') {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
}
}
};
const handleFinished = () => {
setImportFlow('finished');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
};
const handleSubmitExport = async (importMap: ImportMap) => {
if (!sheetId) return;
await exportRundown(sheetId, importMap);
};
const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet';
const isAuthenticated = authenticationStatus === 'authenticated';
const showInput = importFlow === 'none';
const showSuccess = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null;
return (
<>
<Panel.Header>Data sources</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
{showInput && (
<>
<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}
isLoading={hasFile === 'loading'}
>
Import from spreadsheet
</Button>
<Panel.Description>Accepts .xlsx files</Panel.Description>
</div>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoCloudOutline />}
onClick={openGSheetFlow}
isDisabled={hasFile !== 'none'}
>
Synchronise with Google
</Button>
<Panel.Description>Start authentication process</Panel.Description>
</div>
</div>
</>
)}
{showSuccess && (
<div className={style.successSection}>
<span>Import successful</span>
<Button variant='ontime-filled' size='sm' onClick={() => setImportFlow('none')}>
Return
</Button>
</div>
)}
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showImportMap && !showReview && (
<ImportMapForm
isSpreadsheet={isExcelFlow}
onCancel={cancelImportMap}
onSubmitExport={handleSubmitExport}
onSubmitImport={handleSubmitImportPreview}
/>
)}
{showReview && (
<ImportReview
rundown={rundown}
customFields={customFields}
onFinished={handleFinished}
onCancel={cancelImportMap}
/>
)}
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -0,0 +1,238 @@
import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Tooltip } 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 useGoogleSheet from '../useGoogleSheet';
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 { revoke } = useGoogleSheet();
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 worksheetNames = useSheetStore((state) => state.worksheetNames);
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
const handleExport = async (values: NamedImportMap) => {
setLoading('export');
const importMap = convertToImportMap(values);
await onSubmitExport(importMap);
setLoading('');
};
const handleRevoke = async () => {
await revoke();
onCancel();
};
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}>
{!isSpreadsheet && (
<Tooltip label='Revoke the google authentication'>
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
Revoke
</Button>
</Tooltip>
)}
<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;
}
if (label === 'Worksheet') {
return (
<tr key={importName as string}>
<td>{label}</td>
<td>
<Select
variant='ontime'
id={importName as string}
size='sm'
{...register(label as keyof NamedImportMap)}
>
{worksheetNames?.map((name) => {
return (
<option key={name} value={name}>
{name}
</option>
);
})}
</Select>
</td>
<td className={style.singleActionCell} />
</tr>
);
}
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,38 @@
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',
'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,61 @@
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',
'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,
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,122 @@
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>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>{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>
);
}
@@ -0,0 +1,100 @@
import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
import { patchData } from '../../../../common/api/db';
import {
previewRundown,
requestConnection,
revokeAuthentication,
uploadRundown,
verifyAuthenticationStatus,
} from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import { useSheetStore } from './useSheetStore';
export default function useGoogleSheet() {
const queryClient = useQueryClient();
// functions push data to store
const patchStepData = useSheetStore((state) => state.patchStepData);
const setRundown = useSheetStore((state) => state.setRundown);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
try {
return verifyAuthenticationStatus();
} catch (_error) {
/** we do not handle errors here */
}
};
/** requests connection to a google sheet */
const connect = async (
file: File,
sheetId: string,
): Promise<{ verification_url: string; user_code: string } | void> => {
try {
return requestConnection(file, sheetId);
} catch (_error) {
/** we do not handle errors here */
}
};
/** requests the revoking of an existing authenticated session */
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
try {
return revokeAuthentication();
} catch (_error) {
/** we do not handle errors here */
}
};
/** fetches data from a worksheet by its ID */
const importRundownPreview = async (sheetId: string, fileOptions: ImportMap) => {
try {
const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown);
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: ImportMap) => {
try {
// write data to google
await uploadRundown(sheetId, fileOptions);
patchStepData({ pullPush: { available: false, error: '' } });
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
}
};
/** applies rundown and customFields to current project */
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => {
try {
await patchData({ rundown, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: [RUNDOWN, CUSTOM_FIELDS],
});
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
}
};
return {
connect,
revoke,
verifyAuth,
importRundownPreview,
importRundown,
exportRundown,
};
}
@@ -0,0 +1,76 @@
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand';
type SheetStore = {
stepData: typeof initialStepData;
patchStepData: (patch: Partial<typeof initialStepData>) => void;
setWorksheets: (worksheetNames: string[] | null) => void;
worksheetNames: string[] | null;
//gSheet
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;
// we get this from a preview response
customFields: CustomFields | null;
setCustomFields: (customFields: CustomFields | null) => void;
spreadsheetImportMap: ImportMap;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
reset: () => void;
resetPreview: () => void;
};
const initialStepData = {
authenticate: { available: false, error: '' },
sheetId: { available: false, error: '' },
worksheet: { available: false, error: '' },
pullPush: { available: false, error: '' },
};
const initialState = {
stepData: initialStepData,
worksheetNames: null,
sheetId: null,
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null,
customFields: null,
spreadsheetImportMap: defaultImportMap,
};
export const useSheetStore = create<SheetStore>((set, get) => ({
...initialState,
patchStepData: (patch: Partial<typeof initialStepData>) => {
const stepData = get().stepData;
set({ stepData: { ...stepData, ...patch } });
},
setWorksheets: (worksheetNames: string[] | null) => set({ worksheetNames }),
setSheetId: (sheetId: string | null) => set({ sheetId }),
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }),
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
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, customFields: null }),
}));