Sheet use limited input device auth flow (#782)

* refactor: limited-input-device auth

* refactor: resolve sheet directory from setup

* refactor: extract sheet logic in backend

* refactor: simplify sheet integration

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2024-02-24 11:58:50 +01:00
committed by GitHub
parent fc5338903b
commit 474f1e2177
54 changed files with 1636 additions and 1144 deletions
@@ -99,7 +99,6 @@ export default function OscIntegrations() {
<Panel.Divider />
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Title>OSC Settings</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import Swatch from '../../../../common/components/input/colour-input/Swatch';
import CustomFieldForm from './CustomFieldForm';
import style from './ProjectSettingsPanel.module.scss';
interface CustomFieldEntryProps {
colour: string;
label: string;
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
onDelete: (label: CustomFieldLabel) => Promise<void>;
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
const oldLabel = label;
await onEdit(oldLabel, patch);
setIsEditing(false);
};
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<CustomFieldForm
onCancel={() => setIsEditing(false)}
onSubmit={handleEdit}
initialColour={colour}
initialLabel={label}
/>
</td>
</tr>
);
}
return (
<tr>
<td>
<Swatch color={colour} />
</td>
<td className={style.fullWidth}>{label}</td>
<td className={style.actions}>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
onClick={() => setIsEditing(true)}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => onDelete(label)}
/>
</td>
</tr>
);
}
@@ -0,0 +1,99 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types';
import { isAlphanumeric } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import * as Panel from '../PanelUtils';
import style from './ProjectSettingsPanel.module.scss';
interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>;
onCancel: () => void;
initialColour?: string;
initialLabel?: string;
}
export default function CustomFieldForm(props: CustomFieldsFormProps) {
const { onSubmit, onCancel, initialColour, initialLabel } = props;
const {
handleSubmit,
register,
setFocus,
setError,
setValue,
getValues,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm({
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
resetOptions: {
keepDirtyValues: true,
},
});
const setupSubmit = async (values: { label: string; colour: string }) => {
const { label, colour } = values;
const newField: CustomField = {
type: 'string', // type is not user definable yet
colour,
label,
};
try {
await onSubmit(newField);
} catch (error) {
setError('root', { type: 'custom', message: maybeAxiosError(error) });
}
};
// give initial focus to the label
useEffect(() => {
setFocus('label');
}, [setFocus]);
const handleSelectColour = (colour: string) => {
setValue('colour', colour, { shouldDirty: true });
};
const colour = getValues('colour');
const canSubmit = isDirty && isValid;
return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.column}>
<Panel.Description>Label</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
required: { value: true, message: 'Required field' },
validate: (value) => {
if (value.trim().length === 0) return 'Required field';
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
return true;
},
})}
size='sm'
variant='ontime-filled'
autoComplete='off'
/>
</div>
<div>
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<div className={style.buttonRow}>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
Cancel
</Button>
<Button size='sm' type='submit' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</div>
</form>
);
}
@@ -1,8 +1,22 @@
.fullWidth {
width: 100%;
width: 100%;
}
.actions {
display: flex;
gap: 0.5rem;
display: flex;
gap: 0.5rem;
}
.fieldForm {
padding: 1rem;
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 1rem;
}
.buttonRow {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
@@ -1,74 +1,94 @@
import { Alert, AlertDescription, AlertIcon, IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { useState } from 'react';
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/ontimeApi';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import * as Panel from '../PanelUtils';
import style from './ProjectSettingsPanel.module.scss';
const demoCustomFields = {
Apple: { value: 'Fruit' },
Dog: { value: 'Animal' },
Sun: { value: 'Star' },
Car: { value: 'Vehicle' },
Tree: { value: 'Plant' },
Bird: { value: 'Creature' },
Book: { value: 'Reading' },
Chair: { value: 'Furniture' },
Music: { value: 'Melody' },
Ocean: { value: 'Sea' },
};
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
export default function ProjectSettingsPanel() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
await editCustomField(label, customField);
refetch();
};
const handleDelete = async (label: string) => {
try {
await deleteCustomField(label);
refetch();
} catch (_error) {
/** we do not handle errors here */
}
};
return (
<>
<Panel.Header>Project Settings</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Custom fields</Panel.SubHeader>
<div>
<Panel.SubHeader>
Custom fields
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
New
</Button>
</Panel.SubHeader>
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Custom fields allow for additional information to be added to an event (eg. light, sound, camera).{' '}
<br />
This data is not used by Ontime. <br />
<br />
This data is not used by Ontime.
<ExternalLink href={userFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</div>
</Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
<th>Colour</th>
<th>Name</th>
<th />
</tr>
</thead>
<tbody>
{Object.entries(demoCustomFields).map(([key, { value }]) => (
<tr key={key}>
<td className={style.fullWidth}>{value}</td>
<td className={style.actions}>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
/>
</td>
</tr>
))}
{Object.entries(data).map(([key, { colour, label }]) => {
return (
<CustomFieldEntry
key={key}
colour={colour}
label={label}
onEdit={handleEditField}
onDelete={handleDelete}
/>
);
})}
</tbody>
</Panel.Table>
</Panel.Card>
@@ -1,9 +1,10 @@
import { useRef } from 'react';
import { Button, Input, Select } from '@chakra-ui/react';
import { ChangeEvent, useEffect, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoCloudDownloadOutline } from '@react-icons/all-files/io5/IoCloudDownloadOutline';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../PanelUtils';
import useGoogleSheet from './useGoogleSheet';
@@ -12,140 +13,177 @@ import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
interface GSheetSetupProps {
cancel: () => void;
onCancel: () => void;
}
export default function GSheetSetup({ cancel }: GSheetSetupProps) {
const { handleClientSecret, handleAuthenticate, handleConnect } = useGoogleSheet();
const sheetIdInputRef = useRef<HTMLInputElement>(null);
const stepData = useSheetStore((state) => state.stepData);
const reset = useSheetStore((state) => state.reset);
export default function GSheetSetup({ onCancel }: GSheetSetupProps) {
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 worksheetOptions = useSheetStore((state) => state.worksheetOptions) ?? [];
const setWorksheet = useSheetStore((state) => state.setWorksheet);
const setSheetId = useSheetStore((state) => state.setSheetId);
const worksheetIdInputRef = useRef<HTMLSelectElement>(null);
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(() => {
getAuthStatus();
}, []);
const handleCancelFlow = () => {
revoke();
onCancel();
};
// user cancels the flow
const onCancel = () => {
reset();
cancel();
const handleRevoke = async () => {
setLoading('cancel');
await revoke();
await getAuthStatus();
setLoading('');
};
// connect to the accoutn with the given sheet ID
const connectToId = () => {
const sheetId = sheetIdInputRef.current?.value;
/**
* 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;
handleConnect(sheetId);
setLoading('connect');
const result = await connect(file, sheetId);
if (result) {
setAuthLink(result.verification_url);
setAuthKey(result.user_code);
}
setLoading('');
};
// adds the user input sheet ID to the store
const addSheetId = () => {
const sheetId = sheetIdInputRef.current?.value;
console.log('adding', sheetId);
if (!sheetId) return;
setSheetId(sheetId);
/**
* 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 () => {
getAuthStatus();
setLoading('');
},
{ once: true },
);
};
// adds the selected worksheet to the store
const addWorksheetSheetId = () => {
const worksheetId = worksheetIdInputRef.current?.value;
if (!worksheetId) return;
setWorksheet(worksheetId);
};
const canAuthenticate = stepData.authenticate.available;
const canConnect = stepData.authenticate.available && sheetId;
const canConnect = file && sheetId;
const canAuthenticate = Boolean(authKey) && Boolean(authLink);
const isLoading = Boolean(loading);
const isAuthenticated = authenticationStatus === 'authenticated';
return (
<Panel.Section>
<Panel.Title>
Sync with Google Sheet (experimental)
<Button variant='ontime-subtle' size='sm' onClick={onCancel}>
<Button variant='ontime-subtle' size='sm' onClick={handleCancelFlow}>
Cancel
</Button>
</Panel.Title>
<Panel.ListGroup>
<div className={style.buttonRow}>
<div className={style.inputContainer}>
<Input type='file' onChange={handleClientSecret} accept='.json' size='sm' variant='ontime-filled' />
</div>
<Button
variant='ontime-subtle'
size='sm'
onClick={handleAuthenticate}
leftIcon={<IoShieldCheckmarkOutline />}
isDisabled={!canAuthenticate}
>
Authenticate
{isAuthenticated ? (
<Panel.ListGroup>
<Panel.Title>Authenticated</Panel.Title>
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isLoading={loading === 'cancel'}>
Revoke Authentication
</Button>
</div>
<Panel.Error>{stepData.clientSecret.error}</Panel.Error>
</Panel.ListGroup>
</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'
variant='ontime-filled'
isDisabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.Error>{stepData.sheetId.error}</Panel.Error>
<div className={style.buttonRow}>
<div className={style.inputContainer}>
<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'
isDisabled={!stepData.sheetId.available}
placeholder='Enter Sheet ID'
onBlur={addSheetId}
onSubmit={addSheetId}
ref={sheetIdInputRef}
placeholder='Sheet ID'
onChange={(event) => setSheetId(event.target.value)}
isDisabled={isLoading || canAuthenticate}
/>
</div>
<Button
variant='ontime-subtle'
size='sm'
onClick={connectToId}
isDisabled={!canConnect}
leftIcon={<IoCheckmark />}
>
Connect
</Button>
</div>
</Panel.ListGroup>
</Panel.ListGroup>
<Panel.ListGroup>
<div className={style.buttonRow}>
<div className={style.inputContainer}>
<Select
size='sm'
variant='ontime'
isDisabled={!stepData.worksheet.available}
placeholder='Select worksheet'
ref={worksheetIdInputRef}
>
{worksheetOptions.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</Select>
</div>
<Button
variant='ontime-filled'
size='sm'
onClick={addWorksheetSheetId}
isDisabled={!stepData.worksheet.available}
leftIcon={<IoCloudDownloadOutline />}
>
Continue
</Button>
</div>
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
<Panel.Error>{stepData.pullPush.error}</Panel.Error>
</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>
)}
</>
)}
</Panel.Section>
);
}
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import ExcelFileOptions from '../../../modals/upload-modal/upload-options/ExcelFileOptions';
@@ -9,34 +10,53 @@ import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
export default function ImportMap() {
const { handleImportPreview, handleExport } = useGoogleSheet();
const { importRundownPreview, exportRundown } = useGoogleSheet();
const sheetId = useSheetStore((state) => state.sheetId);
const worksheetId = useSheetStore((state) => state.worksheet);
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 exportRundown = () => {
if (!worksheetId || !sheetId) return;
handleExport(sheetId, worksheetId, importOptions);
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
const handleExport = async () => {
if (!sheetId) return;
setLoading('export');
await exportRundown(sheetId, importOptions);
setLoading('');
};
const importPreviewRundown = () => {
if (!worksheetId || !sheetId) return;
handleImportPreview(sheetId, worksheetId, importOptions);
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={exportRundown}>
<Button
variant='ontime-filled'
size='sm'
onClick={handleExport}
isDisabled={isLoading || !sheetId}
isLoading={loading === 'export'}
>
Export
</Button>
<Button variant='ontime-filled' size='sm' onClick={importPreviewRundown}>
<Button
variant='ontime-filled'
size='sm'
onClick={handleImportPreview}
isDisabled={isLoading || !sheetId}
isLoading={loading === 'import'}
>
Import preview
</Button>
</div>
@@ -14,11 +14,11 @@ interface ImportReviewProps {
}
export default function ImportReview({ rundown, userFields }: ImportReviewProps) {
const { handleImport } = useGoogleSheet();
const { importRundown } = useGoogleSheet();
const resetPreview = useSheetStore((state) => state.resetPreview);
const applyImport = () => {
handleImport(rundown, userFields);
importRundown(rundown, userFields);
};
return (
@@ -14,6 +14,7 @@
.buttonRow {
display: flex;
gap: 1rem;
justify-content: end;
}
.inputContainer {
@@ -16,14 +16,16 @@ import style from './SourcesPanel.module.scss';
export default function SourcesPanel() {
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet'>('none');
const hasDataSource = useSheetStore((state) => state.stepData.worksheet.available);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const rundown = useSheetStore((state) => state.rundown);
const userFields = useSheetStore((state) => state.userFields);
const isAuthenticated = authenticationStatus === 'authenticated';
const hasData = rundown && userFields;
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = () => console.error('not yet implementeed');
const handleFile = () => console.error('not yet implemented');
const handleUpload = () => {
fileInputRef.current?.click();
@@ -73,9 +75,9 @@ export default function SourcesPanel() {
</div>
</>
)}
{isGSheetFlow && <GSheetSetup cancel={cancelGSheetFlow} />}
{isGSheetFlow && <GSheetSetup onCancel={cancelGSheetFlow} />}
{isExcelFlow && <Panel.Title>Not yet implemented</Panel.Title>}
{hasDataSource && <ImportMap />}
{isAuthenticated && <ImportMap />}
{hasData && <ImportReview rundown={rundown} userFields={userFields} />}
</Panel.Card>
</Panel.Section>
@@ -1,111 +1,60 @@
import { ChangeEvent } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, UserFields } from 'ontime-types';
import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { RUNDOWN, USERFIELDS } from '../../../../common/api/apiConstants';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import {
getAuthentication,
getClientSecret,
getSheetsAuthUrl,
patchData,
postId,
postPreviewSheet,
postPushSheet,
postWorksheet,
uploadSheetClientFile,
previewRundown,
requestConnection,
revokeAuthentication,
uploadRundown,
verifyAuthenticationStatus,
} from '../../../../common/api/ontimeApi';
import { openLink } from '../../../../common/utils/linkUtils';
import { useSheetStore } from './useSheetStore';
// TODO: recover useEffect for resuming previous state
export default function useGoogleSheet() {
const queryClient = useQueryClient();
// functions push data to store
const setClientSecret = useSheetStore((state) => state.setClientSecret);
const patchStepData = useSheetStore((state) => state.patchStepData);
const setSheetId = useSheetStore((state) => state.setSheetId);
const setWorksheetOptions = useSheetStore((state) => state.setWorksheetOptions);
const setRundown = useSheetStore((state) => state.setRundown);
const setUserFields = useSheetStore((state) => state.setUserFields);
/** receives a client secrets file and passes on to the server */
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) {
patchStepData({
clientSecret: { available: true, error: 'Missing file' },
authenticate: { available: false, error: '' },
});
return;
}
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
try {
const selectedFile = event.target.files[0];
await uploadSheetClientFile(selectedFile);
// TODO: why do we need this call?
await getClientSecret();
setClientSecret(selectedFile);
patchStepData({
clientSecret: { available: true, error: '' },
authenticate: { available: true, error: '' },
});
} catch (error) {
patchStepData({
clientSecret: { available: true, error: maybeAxiosError(error) },
authenticate: { available: false, error: '' },
});
return verifyAuthenticationStatus();
} catch (_error) {
/** we do not handle errors here */
}
};
/** authenticate with the Google Sheets API */
const handleAuthenticate = async () => {
/** requests connection to a google sheet */
const connect = async (
file: File,
sheetId: string,
): Promise<{ verification_url: string; user_code: string } | void> => {
try {
const authLink = await getSheetsAuthUrl();
// request window to open link and check auth when user is back
openLink(authLink);
window.addEventListener('focus', async () => await getAuthentication(), { once: true });
patchStepData({
authenticate: { available: true, error: '' },
sheetId: { available: true, error: '' },
});
} catch (error) {
patchStepData({
authenticate: { available: true, error: maybeAxiosError(error) },
sheetId: { available: false, error: '' },
});
return requestConnection(file, sheetId);
} catch (_error) {
/** we do not handle errors here */
}
};
/** fetches data from a Google Sheet by its ID */
const handleConnect = async (sheetId: string) => {
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
try {
setSheetId(sheetId);
const data = await postId(sheetId);
setWorksheetOptions(data.worksheetOptions);
patchStepData({ worksheet: { available: true, error: '' } });
} catch (error) {
patchStepData({
sheetId: { available: true, error: maybeAxiosError(error) },
worksheet: { available: false, error: '' },
pullPush: { available: false, error: '' },
});
setWorksheetOptions([]);
return revokeAuthentication();
} catch (_error) {
/** we do not handle errors here */
}
};
/** fetches data from a worksheet by its ID */
const handleImportPreview = async (sheetId: string, worksheet: string, fileOptions: ExcelImportMap) => {
const importRundownPreview = async (sheetId: string, fileOptions: ExcelImportMap) => {
try {
// update worksheet data in the server
await postWorksheet(sheetId, worksheet);
// get data from google
const data = await postPreviewSheet(sheetId, fileOptions);
const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown);
setUserFields(data.userFields);
} catch (error) {
@@ -114,13 +63,10 @@ export default function useGoogleSheet() {
};
/** writes data to a worksheet by its ID */
const handleExport = async (sheetId: string, worksheet: string, fileOptions: ExcelImportMap) => {
const exportRundown = async (sheetId: string, fileOptions: ExcelImportMap) => {
try {
// update worksheet data in the server
await postWorksheet(sheetId, worksheet);
// write data to google
await postPushSheet(sheetId, fileOptions);
await uploadRundown(sheetId, fileOptions);
patchStepData({ pullPush: { available: false, error: '' } });
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
@@ -128,11 +74,11 @@ export default function useGoogleSheet() {
};
/** applies rundown and userfields to current project */
const handleImport = async (rundown: OntimeRundown, userFields: UserFields) => {
const importRundown = async (rundown: OntimeRundown, userFields: UserFields) => {
try {
await patchData({ rundown, userFields });
queryClient.setQueryData(RUNDOWN, rundown);
queryClient.setQueryData(USERFIELDS, userFields);
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN, ...USERFIELDS],
});
@@ -142,11 +88,12 @@ export default function useGoogleSheet() {
};
return {
handleClientSecret,
handleAuthenticate,
handleConnect,
handleImportPreview,
handleImport,
handleExport,
connect,
revoke,
verifyAuth,
importRundownPreview,
importRundown,
exportRundown,
};
}
@@ -1,32 +1,36 @@
import { OntimeRundown, UserFields } from 'ontime-types';
import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { create } from 'zustand';
// TODO: persist excelFileOptions to localStorage
type SheetStore = {
clientSecret: File | null;
rundown: OntimeRundown | null;
userFields: UserFields | null;
sheetId: string | null;
worksheet: string | null;
worksheetOptions: string[] | null;
excelFileOptions: ExcelImportMap;
stepData: typeof initialStepData;
setClientSecret: (clientSecret: File | null) => void;
setRundown: (rundown: OntimeRundown | null) => void;
setUserFields: (userFields: UserFields | null) => void;
setSheetId: (sheetId: string) => void;
setWorksheet: (worksheet: string) => void;
setWorksheetOptions: (worksheetOptions: string[] | null) => void;
patchExcelFileOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => void;
patchStepData: (patch: Partial<typeof initialStepData>) => void;
sheetId: string | null;
setSheetId: (sheetId: string | null) => void;
authenticationStatus: AuthenticationStatus;
setAuthenticationStatus: (status: AuthenticationStatus) => void;
rundown: OntimeRundown | null;
setRundown: (rundown: OntimeRundown | null) => void;
userFields: UserFields | null;
setUserFields: (userFields: UserFields | null) => void;
worksheetOptions: string[] | null;
setWorksheetOptions: (worksheetOptions: string[] | null) => void;
excelFileOptions: ExcelImportMap;
patchExcelFileOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => void;
reset: () => void;
resetPreview: () => void;
};
const initialStepData = {
clientSecret: { available: true, error: '' },
authenticate: { available: false, error: '' },
sheetId: { available: false, error: '' },
worksheet: { available: false, error: '' },
@@ -34,34 +38,40 @@ const initialStepData = {
};
const initialState = {
clientSecret: null,
stepData: initialStepData,
sheetId: null,
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null,
userFields: null,
sheetId: null,
worksheet: null,
worksheetOptions: null,
excelFileOptions: defaultExcelImportMap,
stepData: initialStepData,
};
export const useSheetStore = create<SheetStore>((set, get) => ({
...initialState,
setClientSecret: (clientSecret: File | null) => set({ clientSecret }),
patchStepData: (patch: Partial<typeof initialStepData>) => {
const stepData = get().stepData;
set({ stepData: { ...stepData, ...patch } });
},
setSheetId: (sheetId: string | null) => set({ sheetId }),
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }),
setUserFields: (userFields: UserFields | null) => set({ userFields }),
setSheetId: (sheetId: string) => set({ sheetId }),
setWorksheet: (worksheet: string) => set({ worksheet }),
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;
}
},
patchStepData: (patch: Partial<typeof initialStepData>) => {
const stepData = get().stepData;
set({ stepData: { ...stepData, ...patch } });
},
reset: () => set(initialState),
resetPreview: () => set({ rundown: null, userFields: null }),
}));
@@ -1,14 +1,17 @@
import { useCallback, useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import { Button } from '@chakra-ui/react';
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
import CopyTag from '../../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorUser from './composite/EventEditorUser';
import EventTextArea from './composite/EventTextArea';
import style from './EventEditor.module.scss';
@@ -31,11 +34,13 @@ export type EditorUpdateFields =
| 'user6'
| 'user7'
| 'user8'
| 'user9';
| 'user9'
| CustomFieldLabel; // TODO: keyof customFields
export default function EventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const { data: customFields } = useCustomFields();
const { order, rundown } = data;
const { updateEvent } = useEventAction();
@@ -63,7 +68,12 @@ export default function EventEditor() {
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
updateEvent({ id: event?.id, [field]: value });
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEvent({ id: event?.id, custom: { [fieldLabel]: { value } } });
} else {
updateEvent({ id: event?.id, [field]: value });
}
},
[event?.id, updateEvent],
);
@@ -91,6 +101,8 @@ export default function EventEditor() {
user9: event.user9,
};
const customKeys = Object.keys(customFields ?? {});
return (
<div className={style.eventEditor} data-testid='editor-container'>
<div className={style.content}>
@@ -120,6 +132,25 @@ export default function EventEditor() {
colour={event.colour}
handleSubmit={handleSubmit}
/>
<div className={style.column}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Custom Fields</span>
<Button variant='ontime-subtle' size='sm' isDisabled>
Manage
</Button>
</div>
{customKeys.map((label) => {
return (
<EventTextArea
key={label}
field={`custom-${label}`}
label={label}
initialValue={event.custom[label]?.value ?? ''}
submitHandler={handleSubmit}
/>
);
})}
</div>
<EventEditorUser key={`${event.id}-user`} userFields={userFields} handleSubmit={handleSubmit} />
</div>
<div className={style.footer}>