mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 21:54:09 +00:00
refactor: restructure settings
refactor: migrate react components
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFieldEntry from './composite/CustomFieldEntry';
|
||||
import CustomFieldForm from './composite/CustomFieldForm';
|
||||
|
||||
export default function CustomFieldSettings() {
|
||||
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 (key: CustomFieldKey, customField: CustomField) => {
|
||||
await editCustomField(key, customField);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDelete = async (key: CustomFieldKey) => {
|
||||
try {
|
||||
await deleteCustomField(key);
|
||||
refetch();
|
||||
} catch (_error) {
|
||||
/** we do not handle errors here */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Custom fields
|
||||
<Button onClick={handleInitiateCreate}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
Custom fields allow for additional information to be added to an event.
|
||||
<br />
|
||||
<br />
|
||||
This data can be used in the Automation feature by using the generated key.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Colour</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Key (used in Integrations)</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data).map(([key, { colour, label, type }]) => {
|
||||
return (
|
||||
<CustomFieldEntry
|
||||
key={key}
|
||||
fieldKey={key}
|
||||
colour={colour}
|
||||
label={label}
|
||||
type={type}
|
||||
onEdit={handleEditField}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldForm {
|
||||
padding: 1rem;
|
||||
background-color: $gray-1350;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.twoCols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import SourcesPanel from './sources-panel/SourcesPanel';
|
||||
import CustomFieldSettings from './CustomFields';
|
||||
import ManageRundowns from './ManageRundowns';
|
||||
import RundownDefaultSettings from './RundownDefaultSettings';
|
||||
|
||||
export default function ManagePanel({ location }: PanelBaseProps) {
|
||||
const defaultsRef = useScrollIntoView<HTMLDivElement>('defaults', location);
|
||||
const customRef = useScrollIntoView<HTMLDivElement>('custom', location);
|
||||
const rundownsRef = useScrollIntoView<HTMLDivElement>('rundowns', location);
|
||||
const sheetsRef = useScrollIntoView<HTMLDivElement>('sheets', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Project data</Panel.Header>
|
||||
<div ref={defaultsRef}>
|
||||
<RundownDefaultSettings />
|
||||
</div>
|
||||
<div ref={customRef}>
|
||||
<CustomFieldSettings />
|
||||
</div>
|
||||
<div ref={rundownsRef}>
|
||||
<ManageRundowns />
|
||||
</div>
|
||||
<div ref={sheetsRef}>
|
||||
<SourcesPanel />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ManagePanel.module.scss';
|
||||
|
||||
export default function ManageRundowns() {
|
||||
const { data } = useProjectRundowns();
|
||||
const [deleteOpen, deleteHandlers] = useDisclosure();
|
||||
const [loadOpen, loadHandlers] = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage project rundowns
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => undefined} disabled>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th># Entries</th>
|
||||
<th style={{ width: '100%' }}>Title</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.rundowns.map((rundown) => {
|
||||
const isLoaded = data.loaded === rundown.id;
|
||||
return (
|
||||
<tr key={rundown.id} className={cx([isLoaded && style.current])}>
|
||||
<td>{rundown.numEntries}</td>
|
||||
<td>{`${rundown.title}${isLoaded && ' (loaded)'}`}</td>
|
||||
<Panel.InlineElements as='td'>
|
||||
<Button size='small' onClick={() => loadHandlers.open()} disabled={isLoaded}>
|
||||
Load
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
variant='subtle-destructive'
|
||||
onClick={() => deleteHandlers.open()}
|
||||
disabled={isLoaded}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
<Dialog
|
||||
isOpen={deleteOpen}
|
||||
onClose={deleteHandlers.close}
|
||||
title='Load rundown'
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
bodyElements={
|
||||
<>
|
||||
You will lose all data in your rundown. <br /> Are you sure?
|
||||
</>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button size='large' onClick={deleteHandlers.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' size='large' onClick={() => undefined}>
|
||||
Delete rundown
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Dialog
|
||||
isOpen={loadOpen}
|
||||
onClose={loadHandlers.close}
|
||||
title='Delete rundown'
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
bodyElements={
|
||||
<>
|
||||
The current playback will be stopped. <br /> Are you sure?
|
||||
</>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button size='large' onClick={loadHandlers.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' size='large' onClick={() => undefined}>
|
||||
Load rundown
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { parseUserTime } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function RundownDefaultSettings() {
|
||||
const {
|
||||
defaultDuration,
|
||||
linkPrevious,
|
||||
defaultTimeStrategy,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
setDefaultDuration,
|
||||
setLinkPrevious,
|
||||
setTimeStrategy,
|
||||
setWarnTime,
|
||||
setDangerTime,
|
||||
setDefaultTimerType,
|
||||
setDefaultEndAction,
|
||||
} = useEditorSettings((state) => state);
|
||||
|
||||
const durationInMs = parseUserTime(defaultDuration);
|
||||
const warnTimeInMs = parseUserTime(defaultWarnTime);
|
||||
const dangerTimeInMs = parseUserTime(defaultDangerTime);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Rundown defaults</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>Default settings for new events</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Link previous'
|
||||
description='Whether the start time of new events should be linked to the previous event end time'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={linkPrevious}
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Timer strategy'
|
||||
description='Which time should be maintained when event schedule is recalculated'
|
||||
/>
|
||||
<Select
|
||||
variant='ontime'
|
||||
size='sm'
|
||||
width='auto'
|
||||
value={defaultTimeStrategy}
|
||||
onChange={(event) => setTimeStrategy(event.target.value as TimeStrategy)}
|
||||
>
|
||||
<option value={TimeStrategy.LockDuration}>Duration</option>
|
||||
<option value={TimeStrategy.LockEnd}>End Time</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Default duration' description='Default duration for new events' />
|
||||
<TimeInput<'defaultDuration'>
|
||||
name='defaultDuration'
|
||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||
time={durationInMs}
|
||||
placeholder={editorSettingsDefaults.duration}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
||||
<Select
|
||||
variant='ontime'
|
||||
size='sm'
|
||||
width='auto'
|
||||
value={defaultTimerType}
|
||||
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)}
|
||||
>
|
||||
<option value={TimerType.CountDown}>Count down</option>
|
||||
<option value={TimerType.CountUp}>Count up</option>
|
||||
<option value={TimerType.Clock}>Clock</option>
|
||||
<option value={TimerType.None}>None</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='End Action' description='Default end action for new events' />
|
||||
<Select
|
||||
variant='ontime'
|
||||
size='sm'
|
||||
width='auto'
|
||||
value={defaultEndAction}
|
||||
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
|
||||
>
|
||||
<option value={EndAction.None}>None</option>
|
||||
<option value={EndAction.LoadNext}>Load next</option>
|
||||
<option value={EndAction.PlayNext}>Play next</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Warning time' description='Default threshold for warning time in an event' />
|
||||
<TimeInput<'warnTime'>
|
||||
name='warnTime'
|
||||
submitHandler={(_field, value) => setWarnTime(value)}
|
||||
time={warnTimeInMs}
|
||||
placeholder={editorSettingsDefaults.warnTime}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Danger time' description='Default threshold for danger time in an event' />
|
||||
<TimeInput<'dangerTime'>
|
||||
name='dangerTime'
|
||||
submitHandler={(_field, value) => setDangerTime(value)}
|
||||
time={dangerTimeInMs}
|
||||
placeholder={editorSettingsDefaults.dangerTime}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { IoPencil, IoTrash } from 'react-icons/io5';
|
||||
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
|
||||
import IconButton from '../../../../../common/components/buttons/IconButton';
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
import style from '../ManagePanel.module.scss';
|
||||
|
||||
interface CustomFieldEntryProps {
|
||||
colour: string;
|
||||
label: string;
|
||||
fieldKey: string;
|
||||
type: 'string' | 'image';
|
||||
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
|
||||
onDelete: (key: CustomFieldKey) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const handleEdit = async (patch: CustomField) => {
|
||||
await onEdit(fieldKey, patch);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<CustomFieldForm
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSubmit={handleEdit}
|
||||
initialColour={colour}
|
||||
initialLabel={label}
|
||||
initialKey={fieldKey}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<Swatch color={colour} />
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{type}</Tag>
|
||||
</td>
|
||||
<td className={style.halfWidth}>{label}</td>
|
||||
<td className={style.fullWidth}>
|
||||
<CopyTag label='Copy key to use in integrations' copyValue={fieldKey}>
|
||||
{fieldKey}
|
||||
</CopyTag>
|
||||
</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Radio, RadioGroup } from '@chakra-ui/react';
|
||||
import { CustomField } from 'ontime-types';
|
||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import Input from '../../../../../common/components/input/input/Input';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from '../ManagePanel.module.scss';
|
||||
|
||||
interface CustomFieldsFormProps {
|
||||
onSubmit: (field: CustomField) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
initialColour?: string;
|
||||
initialLabel?: string;
|
||||
initialKey?: string;
|
||||
}
|
||||
|
||||
type CustomFieldFormData = CustomField & { key: string };
|
||||
|
||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
||||
const { data } = useCustomFields();
|
||||
|
||||
// we use this to force an update
|
||||
const [_, setColour] = useState(initialColour || '');
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
setFocus,
|
||||
setError,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<CustomFieldFormData>({
|
||||
defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const setupSubmit = async (values: CustomFieldFormData) => {
|
||||
const { type, label, colour } = values;
|
||||
const newField: CustomField = {
|
||||
type,
|
||||
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) => {
|
||||
setColour(colour);
|
||||
setValue('colour', colour, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const colour = getValues('colour');
|
||||
const canSubmit = isDirty && isValid;
|
||||
// if initial values are given, we can assume we are in edit mode
|
||||
const isEditMode = initialKey !== undefined;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(setupSubmit)}
|
||||
className={style.fieldForm}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
>
|
||||
<Info>
|
||||
Please note that images can quickly deteriorate your app's performance.
|
||||
<br />
|
||||
Prefer using small, and compressed images.
|
||||
</Info>
|
||||
<div>
|
||||
<Panel.Description>Type</Panel.Description>
|
||||
<Controller
|
||||
name='type'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RadioGroup {...field} size='sm' isDisabled={isEditMode} variant='ontime'>
|
||||
<Panel.InlineElements relation='component'>
|
||||
<Radio value='string'>Text</Radio>
|
||||
<Radio value='image'>Image</Radio>
|
||||
</Panel.InlineElements>
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.twoCols}>
|
||||
<div>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (!isEditMode) {
|
||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
||||
<Input {...register('key')} readOnly fluid />
|
||||
</div>
|
||||
</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>}
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button variant='ghosted' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||
|
||||
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
|
||||
|
||||
export default function GSheetInfo() {
|
||||
return (
|
||||
<Info>
|
||||
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>
|
||||
</Info>
|
||||
);
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5';
|
||||
|
||||
import { getWorksheetNames } from '../../../../../common/api/sheets';
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Input from '../../../../../common/components/input/input/Input';
|
||||
import { openLink } from '../../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import useGoogleSheet from './useGoogleSheet';
|
||||
import { useSheetStore } from './useSheetStore';
|
||||
|
||||
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);
|
||||
const authenticationError = useSheetStore((state) => state.stepData.authenticate.error);
|
||||
|
||||
/** 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(() => {
|
||||
patchStepData({ authenticate: { available: false, error: '' } });
|
||||
untilAuthenticated();
|
||||
}, []);
|
||||
|
||||
// user cancels the flow
|
||||
const handleRevoke = async () => {
|
||||
setLoading('cancel');
|
||||
await revoke();
|
||||
await getAuthStatus();
|
||||
setLoading('');
|
||||
};
|
||||
|
||||
const handleCancelFlow = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets file from input
|
||||
*/
|
||||
const handleClientSecret = (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;
|
||||
patchStepData({ worksheet: { available: false, error: '' } });
|
||||
|
||||
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 onClick={handleRevoke} loading={loading === 'cancel'}>
|
||||
Revoke Authentication
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleCancelFlow}>Go Back</Button>
|
||||
)}
|
||||
</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
|
||||
<Panel.Error>{authenticationError}</Panel.Error>
|
||||
<Input fluid type='file' onChange={handleClientSecret} accept='.json' disabled={isLoading || canAuthenticate} />
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
|
||||
<Panel.Error>{undefined}</Panel.Error>
|
||||
<Input
|
||||
fluid
|
||||
placeholder='Sheet ID'
|
||||
onChange={(event) => setSheetId(event.target.value)}
|
||||
disabled={isLoading || canAuthenticate}
|
||||
/>
|
||||
</Panel.ListGroup>
|
||||
{!canAuthenticate ? (
|
||||
<Panel.ListGroup>
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={handleConnect} disabled={!canConnect || isLoading} loading={loading === 'connect'}>
|
||||
<IoCheckmark />
|
||||
Connect
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.ListGroup>
|
||||
) : (
|
||||
<Panel.ListGroup>
|
||||
<Panel.InlineElements>
|
||||
{isAuthenticating && <span>Authenticating...</span>}
|
||||
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||
</CopyTag>
|
||||
<Button onClick={handleAuthenticate} disabled={!canAuthenticate}>
|
||||
<IoShieldCheckmarkOutline />
|
||||
Authenticate
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.ListGroup>
|
||||
)}
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { CustomFields, Rundown } from 'ontime-types';
|
||||
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import PreviewSpreadsheet from './preview/PreviewRundown';
|
||||
import useGoogleSheet from './useGoogleSheet';
|
||||
import { useSheetStore } from './useSheetStore';
|
||||
|
||||
interface ImportReviewProps {
|
||||
rundown: Rundown;
|
||||
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.id]: rundown,
|
||||
},
|
||||
customFields,
|
||||
);
|
||||
setLoading(false);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Title>
|
||||
Review Rundown
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={applyImport} variant='primary' loading={loading}>
|
||||
Apply
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
.uploadSection,
|
||||
.finishSection {
|
||||
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;
|
||||
}
|
||||
|
||||
.finishSection {
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.error {
|
||||
color: $red-500;
|
||||
}
|
||||
.success {
|
||||
color: $green-500;
|
||||
}
|
||||
}
|
||||
|
||||
.singleActionCell {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||
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 Button from '../../../../../common/components/buttons/Button';
|
||||
import * as Editor from '../../../../../common/components/editor-utils/EditorUtils';
|
||||
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../../../panel-utils/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 resetFlow = () => {
|
||||
// we purposely omit clearing the authentication status
|
||||
setImportFlow('none');
|
||||
setRundown(null);
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
setError('');
|
||||
setSheetId(null);
|
||||
};
|
||||
|
||||
const openGSheetFlow = async () => {
|
||||
const result = await verifyAuth();
|
||||
if (result) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
setSheetId(result.sheetId);
|
||||
if (result.authenticated === 'authenticated' && result.sheetId) {
|
||||
try {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError(`Error getting worksheets: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
setImportFlow('gsheet');
|
||||
};
|
||||
|
||||
const cancelGSheetFlow = () => {
|
||||
resetFlow();
|
||||
};
|
||||
|
||||
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
||||
setError(''); // to clear previous error
|
||||
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 () => {
|
||||
resetFlow();
|
||||
if (authenticationStatus === 'authenticated') {
|
||||
const result = await verifyAuth();
|
||||
if (result) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinished = () => {
|
||||
setImportFlow('finished');
|
||||
setRundown(null);
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
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 showCompleted = importFlow === 'finished';
|
||||
const showAuth = isGSheetFlow && !isAuthenticated;
|
||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
||||
const showReview = rundown !== null && customFields !== null;
|
||||
|
||||
return (
|
||||
<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='primary' onClick={handleUpload} loading={hasFile === 'loading'}>
|
||||
<IoDownloadOutline />
|
||||
Import from spreadsheet
|
||||
</Button>
|
||||
<Panel.Description>Accepts .xlsx files</Panel.Description>
|
||||
</div>
|
||||
<Editor.Separator orientation='vertical' />
|
||||
<div>
|
||||
<Button variant='primary' onClick={openGSheetFlow} disabled={hasFile !== 'none'}>
|
||||
<IoCloudOutline />
|
||||
Synchronise with Google
|
||||
</Button>
|
||||
<Panel.Description>Start authentication process</Panel.Description>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showCompleted && (
|
||||
<div className={style.finishSection}>
|
||||
{error ? (
|
||||
<span key='finish__error' className={style.error}>
|
||||
Import failed
|
||||
</span>
|
||||
) : (
|
||||
<span key='finish__success' className={style.success}>
|
||||
Import successful
|
||||
</span>
|
||||
)}
|
||||
<Button variant='primary' onClick={resetFlow}>
|
||||
Return
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
|
||||
{showImportMap && !showReview && (
|
||||
<ImportMapForm
|
||||
hasErrors={Boolean(error)}
|
||||
isSpreadsheet={isExcelFlow}
|
||||
onCancel={cancelImportMap}
|
||||
onSubmitExport={handleSubmitExport}
|
||||
onSubmitImport={handleSubmitImportPreview}
|
||||
/>
|
||||
)}
|
||||
{showReview && (
|
||||
<ImportReview
|
||||
rundown={rundown}
|
||||
customFields={customFields}
|
||||
onFinished={handleFinished}
|
||||
onCancel={cancelImportMap}
|
||||
/>
|
||||
)}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
import { Select, Tooltip } from '@chakra-ui/react';
|
||||
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import Button from '../../../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../../../common/components/buttons/IconButton';
|
||||
import Input from '../../../../../../common/components/input/input/Input';
|
||||
import * as Panel from '../../../../panel-utils/PanelUtils';
|
||||
import useGoogleSheet from '../useGoogleSheet';
|
||||
import { useSheetStore } from '../useSheetStore';
|
||||
|
||||
import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportMap } from './importMapUtils';
|
||||
|
||||
import style from '../SourcesPanel.module.scss';
|
||||
|
||||
interface ImportMapFormProps {
|
||||
hasErrors: boolean;
|
||||
isSpreadsheet: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmitExport: (importMap: ImportMap) => Promise<void>;
|
||||
onSubmitImport: (importMap: ImportMap) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
const { hasErrors, isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
|
||||
const namedImportMap = getPersistedOptions();
|
||||
const { revoke } = useGoogleSheet();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<NamedImportMap>({
|
||||
mode: 'onChange',
|
||||
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'>('');
|
||||
|
||||
// Set first sheet as default worksheet when 'event schedule' sheet is not there
|
||||
useEffect(() => {
|
||||
if (!worksheetNames || worksheetNames.length === 0) return;
|
||||
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
|
||||
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
|
||||
}
|
||||
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
|
||||
|
||||
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 && !stepData.worksheet.error;
|
||||
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' id='import-map'>
|
||||
<Panel.Title>
|
||||
Import options
|
||||
<Panel.InlineElements>
|
||||
{!isSpreadsheet && (
|
||||
<Tooltip label='Revoke the google authentication'>
|
||||
<Button onClick={handleRevoke} disabled={isLoading}>
|
||||
Revoke
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button onClick={onCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isSpreadsheet && (
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSubmit(handleExport)}
|
||||
disabled={!canSubmitGSheet}
|
||||
loading={loading === 'export'}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSubmit(handleImportPreview)}
|
||||
disabled={!canSubmit}
|
||||
loading={loading === 'import'}
|
||||
>
|
||||
Import preview
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ontime field</th>
|
||||
<th>Column name in spreadsheet</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}
|
||||
fluid
|
||||
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
|
||||
maxLength={25}
|
||||
fluid
|
||||
defaultValue={ontimeName}
|
||||
placeholder='Name of the field as shown in Ontime'
|
||||
{...register(`custom.${index}.ontimeName`, {
|
||||
validate: (value) => {
|
||||
if (!isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybeOntimeError && <Panel.Error>{maybeOntimeError}</Panel.Error>}
|
||||
</td>
|
||||
<td>
|
||||
<Input
|
||||
maxLength={25}
|
||||
fluid
|
||||
defaultValue={importName}
|
||||
placeholder='Name of the column in the spreadsheet'
|
||||
{...register(`custom.${index}.importName`)}
|
||||
/>
|
||||
</td>
|
||||
<td className={style.singleActionCell}>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
onClick={() => deleteCustomImport(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td />
|
||||
<Panel.InlineElements as='td' align='end'>
|
||||
<Button onClick={addCustomImport}>
|
||||
Add custom field <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
<td />
|
||||
</tr>
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { ImportCustom } from 'ontime-utils';
|
||||
|
||||
import { convertToImportMap, NamedImportMap } from '../importMapUtils';
|
||||
|
||||
describe('convertToImportMap', () => {
|
||||
it('converts a namedImportMap to a importMap', () => {
|
||||
const defaultNamedImporMap = {
|
||||
Worksheet: 'event schedule',
|
||||
Start: 'time start',
|
||||
'Link start': 'link start',
|
||||
End: 'time end',
|
||||
Duration: 'duration',
|
||||
Cue: 'cue',
|
||||
Title: 'title',
|
||||
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[],
|
||||
} as NamedImportMap;
|
||||
|
||||
const importMap = convertToImportMap(defaultNamedImporMap);
|
||||
expect(importMap.custom).toStrictEqual({
|
||||
Custom1: 'custom1',
|
||||
Custom2: 'custom2',
|
||||
Custom3: 'custom3',
|
||||
});
|
||||
});
|
||||
});
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { ImportCustom, ImportMap } from 'ontime-utils';
|
||||
|
||||
export type NamedImportMap = typeof namedImportMap;
|
||||
|
||||
// Record of label and import name
|
||||
const namedImportMap = {
|
||||
Worksheet: 'event schedule',
|
||||
Start: 'time start',
|
||||
'Link start': 'link start',
|
||||
End: 'time end',
|
||||
Duration: 'duration',
|
||||
Cue: 'cue',
|
||||
Title: 'title',
|
||||
'Count to end': 'count to end',
|
||||
Skip: 'skip',
|
||||
Note: 'notes',
|
||||
Colour: 'colour',
|
||||
'End action': 'end action',
|
||||
'Timer type': 'timer type',
|
||||
'Time warning': 'warning time',
|
||||
'Time danger': 'danger time',
|
||||
ID: 'id',
|
||||
custom: [] as ImportCustom[],
|
||||
};
|
||||
|
||||
function isNamedImportMap(obj: unknown): obj is NamedImportMap {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys = Object.keys(namedImportMap);
|
||||
return keys.every((key) => Object.hasOwn(obj, key));
|
||||
}
|
||||
|
||||
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,
|
||||
linkStart: namedImportMap['Link start'],
|
||||
timeEnd: namedImportMap.End,
|
||||
duration: namedImportMap.Duration,
|
||||
cue: namedImportMap.Cue,
|
||||
title: namedImportMap.Title,
|
||||
countToEnd: namedImportMap['Count to end'],
|
||||
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,
|
||||
entryId: namedImportMap.ID,
|
||||
};
|
||||
}
|
||||
|
||||
export function persistImportMap(options: NamedImportMap) {
|
||||
localStorage.setItem('ontime-import-options', JSON.stringify(options));
|
||||
}
|
||||
|
||||
function getPersistImportMap(): unknown {
|
||||
const options = localStorage.getItem('ontime-import-options');
|
||||
if (!options) {
|
||||
throw new Error('no import options found');
|
||||
}
|
||||
return JSON.parse(options);
|
||||
}
|
||||
|
||||
export function getPersistedOptions(): NamedImportMap {
|
||||
try {
|
||||
const options = getPersistImportMap();
|
||||
if (!isNamedImportMap(options)) {
|
||||
return namedImportMap;
|
||||
}
|
||||
return options;
|
||||
} catch {
|
||||
return namedImportMap;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr .secondaryRow {
|
||||
background-color: $white-7;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.linkStartActive {
|
||||
color: $active-indicator;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subdued {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { Fragment } from 'react';
|
||||
import { IoLink } from 'react-icons/io5';
|
||||
import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } 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 '../../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './PreviewRundown.module.scss';
|
||||
|
||||
interface PreviewRundownProps {
|
||||
rundown: Rundown;
|
||||
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 fieldKeys = Object.keys(customFields);
|
||||
const fieldLabels = fieldKeys.map((key) => customFields[key].label);
|
||||
|
||||
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>Count to end</th>
|
||||
<th>Skip</th>
|
||||
<th>Colour</th>
|
||||
<th>Timer Type</th>
|
||||
<th>End Action</th>
|
||||
{fieldLabels.map((label) => (
|
||||
<th key={label}>{label}</th>
|
||||
))}
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rundown.order.map((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (isOntimeBlock(entry)) {
|
||||
return (
|
||||
<tr key={entry.id}>
|
||||
<td className={style.center}>
|
||||
<Tag>-</Tag>
|
||||
</td>
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.type}</Tag>
|
||||
</td>
|
||||
<td />
|
||||
<td colSpan={99}>{entry.title}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (!isOntimeEvent(entry)) {
|
||||
return null;
|
||||
}
|
||||
eventIndex += 1;
|
||||
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
||||
const countToEnd = booleanToText(entry.countToEnd);
|
||||
const skip = booleanToText(entry.skip);
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<tr>
|
||||
<td className={style.center}>
|
||||
<Tag>{eventIndex}</Tag>
|
||||
</td>
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.type}</Tag>
|
||||
</td>
|
||||
<td className={style.nowrap}>{entry.cue}</td>
|
||||
<td>{entry.title}</td>
|
||||
<td className={style.flex}>
|
||||
<span className={entry.linkStart ? style.subdued : undefined}>{millisToString(entry.timeStart)}</span>
|
||||
{entry.linkStart && <IoLink className={style.linkStartActive} />}
|
||||
</td>
|
||||
<td>{millisToString(entry.timeEnd)}</td>
|
||||
<td>{millisToString(entry.duration)}</td>
|
||||
<td>{millisToString(entry.timeWarning)}</td>
|
||||
<td>{millisToString(entry.timeDanger)}</td>
|
||||
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
|
||||
<td>{skip && <Tag>{skip}</Tag>}</td>
|
||||
<td style={{ ...colour }}>{entry.colour}</td>
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.timerType}</Tag>
|
||||
</td>
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.endAction}</Tag>
|
||||
</td>
|
||||
{isOntimeEvent(entry) &&
|
||||
fieldKeys.map((field) => {
|
||||
let value = '';
|
||||
if (field in entry.custom) {
|
||||
value = entry.custom[field];
|
||||
}
|
||||
return <td key={field}>{value}</td>;
|
||||
})}
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.id}</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
{entry.note && (
|
||||
<tr>
|
||||
<td colSpan={99} className={style.secondaryRow}>
|
||||
Note: {entry.note}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AuthenticationStatus, CustomFields, ProjectRundowns } 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 await verifyAuthenticationStatus();
|
||||
} catch (error) {
|
||||
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
|
||||
}
|
||||
};
|
||||
|
||||
/** requests connection to a google sheet */
|
||||
const connect = async (
|
||||
file: File,
|
||||
sheetId: string,
|
||||
): Promise<{ verification_url: string; user_code: string } | void> => {
|
||||
try {
|
||||
return await requestConnection(file, sheetId);
|
||||
} catch (error) {
|
||||
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
|
||||
}
|
||||
};
|
||||
|
||||
/** requests the revoking of an existing authenticated session */
|
||||
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
|
||||
try {
|
||||
return await revokeAuthentication();
|
||||
} catch (error) {
|
||||
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
|
||||
}
|
||||
};
|
||||
|
||||
/** 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 (rundowns: ProjectRundowns, customFields: CustomFields) => {
|
||||
try {
|
||||
await patchData({ rundowns, 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,
|
||||
};
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { AuthenticationStatus, CustomFields, Rundown } 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: Rundown | null;
|
||||
setRundown: (rundown: Rundown | 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: Rundown | 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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user