mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 15:33:59 +00:00
cf206e6220
* chore: upgrade vite * fix: inconsistentCjsInterop for ReactQR * chore: e2e tests should use relativve path and get host from playwright config * refactor: drop qr code dependency --------- Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { OntimeView, URLPreset } from 'ontime-types';
|
|
import { generateId } from 'ontime-utils';
|
|
import { useRef, useState } from 'react';
|
|
import { FieldErrors, useForm } from 'react-hook-form';
|
|
|
|
import { generateUrl } from '../../common/api/session';
|
|
import { maybeAxiosError } from '../../common/api/utils';
|
|
import Button from '../../common/components/buttons/Button';
|
|
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
|
import Info from '../../common/components/info/Info';
|
|
import Input from '../../common/components/input/input/Input';
|
|
import QRCode from '../../common/components/qr-code/QrCode';
|
|
import Select from '../../common/components/select/Select';
|
|
import Switch from '../../common/components/switch/Switch';
|
|
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
|
|
import { safeCopyToClipboard } from '../../common/utils/copyToClipboard';
|
|
import { preventEscape } from '../../common/utils/keyEvent';
|
|
import { isUrlSafe } from '../../common/utils/regex';
|
|
import { isOntimeCloud, serverURL } from '../../externals';
|
|
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
|
import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
|
|
|
|
import style from './GenerateLinkForm.module.scss';
|
|
|
|
interface GenerateLinkFormProps {
|
|
hostOptions: { value: string; label: string }[];
|
|
pathOptions: { value: OntimeView | string; label: string }[];
|
|
presets: URLPreset[];
|
|
isLockedToView?: boolean;
|
|
}
|
|
|
|
type GenericLinkOptions = {
|
|
baseUrl: string;
|
|
path: OntimeView | string; // we use empty string for Companion view
|
|
authenticate: boolean;
|
|
lockConfig: boolean;
|
|
lockNav: boolean;
|
|
};
|
|
|
|
type CuesheetLinkOptions = GenericLinkOptions & {
|
|
path: OntimeView.Cuesheet;
|
|
|
|
alias: string;
|
|
options: {
|
|
read?: string;
|
|
write?: string;
|
|
};
|
|
};
|
|
|
|
type GenerateLinkFormOptions = GenericLinkOptions | CuesheetLinkOptions;
|
|
|
|
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
|
|
|
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
|
|
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
|
const [url, setUrl] = useState(serverURL);
|
|
const cuesheetReadRef = useRef<HTMLInputElement>(null);
|
|
const cuesheetWriteRef = useRef<HTMLInputElement>(null);
|
|
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
|
|
|
|
const { addPreset } = useUpdateUrlPreset();
|
|
|
|
const {
|
|
handleSubmit,
|
|
setError,
|
|
watch,
|
|
setValue,
|
|
reset,
|
|
register,
|
|
formState: { errors, isDirty },
|
|
} = useForm<GenerateLinkFormOptions>({
|
|
mode: 'onChange',
|
|
defaultValues: {
|
|
baseUrl: serverURL,
|
|
path: isLockedToView ? pathOptions[0].value : OntimeView.Timer,
|
|
authenticate: false,
|
|
lockConfig: false,
|
|
lockNav: false,
|
|
},
|
|
});
|
|
|
|
/**
|
|
* If the user is generating a link to the cuesheet we gather extra options
|
|
* The extra options are saved into a URL preset which we then request a share link for
|
|
*/
|
|
const createPresetFromOptions = async (
|
|
alias: string,
|
|
options: Required<CuesheetLinkOptions['options']>,
|
|
): Promise<URLPreset | undefined> => {
|
|
if (options.read === '-') {
|
|
throw new Error('Cannot create a share with no read permissions');
|
|
}
|
|
const presets = await addPreset({
|
|
target: OntimeView.Cuesheet,
|
|
enabled: true,
|
|
alias,
|
|
search: '',
|
|
options: {
|
|
read: options.read,
|
|
write: options.write,
|
|
},
|
|
});
|
|
return presets.find((preset) => preset.alias === alias);
|
|
};
|
|
|
|
const onSubmit = async (options: GenerateLinkFormOptions) => {
|
|
try {
|
|
setFormState('loading');
|
|
if (options.path === OntimeView.Cuesheet) {
|
|
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
|
|
read: cuesheetReadRef.current?.value ?? 'full',
|
|
write: cuesheetWriteRef.current?.value ?? 'full',
|
|
});
|
|
|
|
if (!urlPreset) {
|
|
throw new Error('Failed to create URL preset for Cuesheet');
|
|
}
|
|
|
|
const url = await generateUrl({
|
|
baseUrl: options.baseUrl,
|
|
path: options.path,
|
|
authenticate: options.authenticate,
|
|
lockConfig: options.lockConfig,
|
|
lockNav: options.lockNav,
|
|
preset: urlPreset.alias,
|
|
});
|
|
await safeCopyToClipboard(url);
|
|
setUrl(url);
|
|
} else {
|
|
const presetPath = options.path.startsWith('preset-') ? options.path.replace('preset-', '') : undefined;
|
|
const path = presetPath ? presets.find((preset) => preset.alias === presetPath)?.target : options.path;
|
|
if (!path) {
|
|
throw new Error(`Could not resolve preset: ${path}`);
|
|
}
|
|
|
|
const url = await generateUrl({
|
|
baseUrl: options.baseUrl,
|
|
path,
|
|
authenticate: options.authenticate,
|
|
lockConfig: options.lockConfig,
|
|
lockNav: options.lockNav,
|
|
preset: presetPath,
|
|
});
|
|
|
|
await safeCopyToClipboard(url);
|
|
setUrl(url);
|
|
}
|
|
reset(options, {
|
|
keepValues: true,
|
|
keepDirty: false,
|
|
});
|
|
setFormState('success');
|
|
} catch (error) {
|
|
const message = maybeAxiosError(error);
|
|
setError('root', { message });
|
|
setFormState('error');
|
|
}
|
|
};
|
|
|
|
const canSubmit = isDirty || formState !== 'success';
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
|
|
{!isLockedToView && (
|
|
<Info>You can generate a link to share with your team or to use in automation (such as companion).</Info>
|
|
)}
|
|
<div className={style.shareInline}>
|
|
<div className={style.column}>
|
|
<Panel.ListGroup>
|
|
{isOntimeCloud ? (
|
|
<input hidden readOnly name='baseUrl' value={serverURL} />
|
|
) : (
|
|
<Panel.ListItem>
|
|
<Panel.Field
|
|
title='Host IP'
|
|
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
|
|
/>
|
|
<Select
|
|
options={hostOptions}
|
|
value={watch('baseUrl')}
|
|
onValueChange={(value: string | null) => {
|
|
if (value === null) return;
|
|
setValue('baseUrl', value);
|
|
}}
|
|
/>
|
|
</Panel.ListItem>
|
|
)}
|
|
{isLockedToView ? (
|
|
<input type='hidden' value={watch('path')} />
|
|
) : (
|
|
<Panel.ListItem>
|
|
<Panel.Field title='Ontime view' description='Which view or preset will the link point to' />
|
|
<Select
|
|
options={pathOptions}
|
|
value={watch('path')}
|
|
onValueChange={(value: OntimeView | string | null) => {
|
|
if (value === null) return;
|
|
setValue('path', value, { shouldDirty: true });
|
|
}}
|
|
/>
|
|
</Panel.ListItem>
|
|
)}
|
|
|
|
{watch('path') === OntimeView.Cuesheet && (
|
|
<>
|
|
<Panel.ListItem>
|
|
<Panel.Field
|
|
title='Preset alias'
|
|
description='The name of the preset we will create to hold this options'
|
|
error={(errors as FieldErrors<CuesheetLinkOptions>).alias?.message}
|
|
/>
|
|
<Input
|
|
defaultValue={generatedAlias.current}
|
|
{...register('alias', {
|
|
required: 'Alias cannot be empty and must be unique',
|
|
pattern: {
|
|
value: isUrlSafe,
|
|
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
|
|
},
|
|
})}
|
|
/>
|
|
</Panel.ListItem>
|
|
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
|
|
</>
|
|
)}
|
|
|
|
<Panel.ListItem>
|
|
<Panel.Field title='Lock navigation' description='Whether to hide the navigation menu' />
|
|
<Switch
|
|
size='large'
|
|
name='lockNav'
|
|
data-testid='lockNav'
|
|
checked={watch('lockNav')}
|
|
onCheckedChange={(checked) => setValue('lockNav', checked, { shouldDirty: true })}
|
|
disabled={watch('lockConfig')}
|
|
/>
|
|
</Panel.ListItem>
|
|
{watch('path') !== OntimeView.Cuesheet && (
|
|
<Panel.ListItem>
|
|
<Panel.Field
|
|
title='Lock configuration'
|
|
description='Whether to hide the configuration panel (also hides navigation)'
|
|
/>
|
|
<Switch
|
|
size='large'
|
|
name='lockConfig'
|
|
data-testid='lockConfig'
|
|
checked={watch('lockConfig')}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setValue('lockNav', checked, { shouldDirty: true });
|
|
}
|
|
setValue('lockConfig', checked, { shouldDirty: true });
|
|
}}
|
|
/>
|
|
</Panel.ListItem>
|
|
)}
|
|
<Panel.ListItem>
|
|
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
|
|
<Switch
|
|
size='large'
|
|
name='authenticate'
|
|
data-testid='authenticate'
|
|
checked={watch('authenticate')}
|
|
onCheckedChange={(checked) => setValue('authenticate', checked, { shouldDirty: true })}
|
|
/>
|
|
</Panel.ListItem>
|
|
</Panel.ListGroup>
|
|
<Panel.Error>{errors.root?.message}</Panel.Error>
|
|
<Panel.InlineElements align='end' className={style.end}>
|
|
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
|
|
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
|
|
</Button>
|
|
</Panel.InlineElements>
|
|
</div>
|
|
<Panel.Section className={style.column}>
|
|
<Panel.Description>Share this link</Panel.Description>
|
|
<QRCode size={172} value={url} />
|
|
<div className={style.copiableLink} data-testid='copy-link'>
|
|
{url}
|
|
</div>
|
|
<CopyTag copyValue={url}>Copy link</CopyTag>
|
|
</Panel.Section>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|