mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 21:03:29 +00:00
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:
committed by
GitHub
parent
fc5338903b
commit
474f1e2177
@@ -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>}
|
||||
|
||||
+72
@@ -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>
|
||||
);
|
||||
}
|
||||
+99
@@ -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>
|
||||
);
|
||||
}
|
||||
+17
-3
@@ -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;
|
||||
}
|
||||
|
||||
+62
-42
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user