mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 12:53:32 +00:00
refactor: restructure settings
refactor: migrate react components
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.fit {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.aliasConstrain {
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import InfoNif from '../network-panel/NetworkInterfaces';
|
||||
|
||||
import GenerateLinkFormExport from './GenerateLinkFormExport';
|
||||
import ReportSettings from './ReportSettings';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
|
||||
export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
|
||||
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
|
||||
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Sharing and reporting</Panel.Header>
|
||||
<div ref={presetsRef}>
|
||||
<UrlPresetsForm />
|
||||
</div>
|
||||
<div ref={linkRef}>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
{!isOntimeCloud && (
|
||||
<>
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
<InfoNif />
|
||||
</>
|
||||
)}
|
||||
<GenerateLinkFormExport />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</div>
|
||||
<div ref={reportRef}>
|
||||
<ReportSettings />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
.qrCode {
|
||||
padding: 0.5rem;
|
||||
background: $ui-white;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.column {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: $label-gray;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.copiableLink {
|
||||
user-select: text;
|
||||
color: $ui-white;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import QRCode from 'react-qr-code';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
|
||||
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
|
||||
interface GenerateLinkFormProps {
|
||||
hostOptions: { value: string; label: string }[];
|
||||
pathOptions: { value: string; label: string }[];
|
||||
isLockedToView?: boolean;
|
||||
}
|
||||
|
||||
interface GenerateLinkFormOptions {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
lock: boolean;
|
||||
authenticate: boolean;
|
||||
}
|
||||
|
||||
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
|
||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||
const [url, setUrl] = useState(serverURL);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setError,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<GenerateLinkFormOptions>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
baseUrl: currentHostName,
|
||||
path: isLockedToView ? pathOptions[0].value : 'timer',
|
||||
lock: false,
|
||||
authenticate: false,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (options: GenerateLinkFormOptions) => {
|
||||
try {
|
||||
setFormState('loading');
|
||||
const baseUrl = linkToOtherHost(options.baseUrl);
|
||||
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
|
||||
await copyToClipboard(url);
|
||||
setUrl(url);
|
||||
setFormState('success');
|
||||
setTimeout(() => {
|
||||
setFormState('pending');
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
setFormState('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
{!isLockedToView ? (
|
||||
<Info>
|
||||
<Panel.Paragraph>
|
||||
You can generate a link to share with your team or to use in automation (such as companion).
|
||||
</Panel.Paragraph>
|
||||
</Info>
|
||||
) : (
|
||||
<Info>
|
||||
<Panel.Paragraph>You can generate a link to share with your team</Panel.Paragraph>
|
||||
</Info>
|
||||
)}
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Host IP'
|
||||
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
|
||||
/>
|
||||
<Select
|
||||
disabled={isOntimeCloud}
|
||||
options={hostOptions}
|
||||
value={watch('baseUrl')}
|
||||
onValueChange={(value) => 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) => setValue('path', value)} />
|
||||
</Panel.ListItem>
|
||||
)}
|
||||
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Lock navigation'
|
||||
description='Prevent showing navigation (will only work for non production URLs)'
|
||||
/>
|
||||
<Switch name='lock' checked={watch('lock')} onCheckedChange={(checked) => setValue('lock', checked)} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
|
||||
<Switch
|
||||
name='authenticate'
|
||||
checked={watch('authenticate')}
|
||||
onCheckedChange={(checked) => setValue('authenticate', checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
|
||||
<Button variant='primary' loading={formState === 'loading'} type='submit' style={{ alignSelf: 'end' }}>
|
||||
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
|
||||
</Button>
|
||||
<div className={style.column}>
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<div className={style.copiableLink}>{url}</div>
|
||||
</div>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
|
||||
import GenerateLinkForm from './GenerateLinkForm';
|
||||
|
||||
interface GenerateLinkFormExportProps {
|
||||
lockedPath?: { value: string; label: string };
|
||||
}
|
||||
|
||||
export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormExportProps) {
|
||||
const { data: infoData } = useInfo();
|
||||
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
|
||||
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
infoData.networkInterfaces.map((nif) => ({
|
||||
value: nif.address,
|
||||
label: `${nif.name} - ${nif.address}`,
|
||||
})),
|
||||
[infoData.networkInterfaces],
|
||||
);
|
||||
|
||||
const pathOptions = useMemo(() => {
|
||||
if (lockedPath) {
|
||||
return [{ value: lockedPath.value, label: lockedPath.label }];
|
||||
}
|
||||
return [
|
||||
{ value: 'timer', label: 'Timer' },
|
||||
{ value: 'cuesheet', label: 'Cuesheet' },
|
||||
{ value: 'op', label: 'Operator' },
|
||||
{ value: '', label: 'Companion' },
|
||||
...urlPresetData.map((preset) => ({
|
||||
value: preset.alias,
|
||||
label: `Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
}, [lockedPath, urlPresetData]);
|
||||
|
||||
return <GenerateLinkForm hostOptions={hostOptions} pathOptions={pathOptions} isLockedToView={Boolean(lockedPath)} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
th.over {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
th.under {
|
||||
color: $playback-ahead;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IoTrashBin } from 'react-icons/io5';
|
||||
|
||||
import { deleteAllReport } from '../../../../common/api/report';
|
||||
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import useRundown from '../../../../common/hooks-query/useRundown';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../../common/utils/time';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||
|
||||
import style from './ReportSettings.module.scss';
|
||||
|
||||
export default function ReportSettings() {
|
||||
const { data: reportData } = useReport();
|
||||
const { data } = useRundown();
|
||||
|
||||
const clearReport = async () => await deleteAllReport();
|
||||
const downloadCSV = (combinedReport: CombinedReport[]) => {
|
||||
if (!combinedReport) {
|
||||
return;
|
||||
}
|
||||
const csv = makeReportCSV(combinedReport);
|
||||
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
|
||||
downloadBlob(blob, 'ontime-report.csv');
|
||||
};
|
||||
|
||||
const combinedReport = useMemo(() => {
|
||||
return getCombinedReport(reportData, data.entries, data.order);
|
||||
}, [reportData, data.entries, data.order]);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Report</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>
|
||||
Manage report
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
|
||||
<IoTrashBin />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
|
||||
<IoTrashBin />
|
||||
Clear All
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Cue</th>
|
||||
<th>Title</th>
|
||||
<th>Scheduled Start</th>
|
||||
<th>Actual Start</th>
|
||||
<th>Scheduled End</th>
|
||||
<th>Actual End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{combinedReport.length === 0 && (
|
||||
<Panel.TableEmpty label='Reports are generated when running through the show.' />
|
||||
)}
|
||||
|
||||
{combinedReport.map((entry) => {
|
||||
const start = (() => {
|
||||
if (entry.actualStart === null) return null;
|
||||
if (entry.actualStart <= entry.scheduledStart) return 'under';
|
||||
return 'over';
|
||||
})();
|
||||
const end = (() => {
|
||||
if (entry.actualEnd === null) return null;
|
||||
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
|
||||
return 'over';
|
||||
})();
|
||||
return (
|
||||
<tr key={entry.index}>
|
||||
<th>{entry.index}</th>
|
||||
<th>{entry.cue}</th>
|
||||
<th>{entry.title}</th>
|
||||
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
|
||||
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
|
||||
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
|
||||
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
|
||||
import { Switch } from '@chakra-ui/react';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './FeaturePanel.module.scss';
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
type FormData = {
|
||||
data: URLPreset[];
|
||||
};
|
||||
|
||||
export default function UrlPresetsForm() {
|
||||
const { data, status, refetch } = useUrlPresets();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<FormData>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { data },
|
||||
values: { data },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
const { fields, prepend, remove } = useFieldArray({
|
||||
name: 'data',
|
||||
control,
|
||||
});
|
||||
|
||||
// reset form if we get new data from backend
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({ data });
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: FormData) => {
|
||||
for (let i = 0; i < formData.data.length; i++) {
|
||||
const preset = formData.data[i];
|
||||
const { isValid, message } = validateUrlPresetPath(preset.pathAndParams);
|
||||
if (!isValid) {
|
||||
setError(`data.${i}.pathAndParams`, { message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await postUrlPresets(formData.data);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset({ data });
|
||||
};
|
||||
|
||||
const addNew = () => {
|
||||
prepend({
|
||||
enabled: true,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
data-testid='url-preset-form'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Info>
|
||||
URL presets are user defined aliases to Ontime URLs
|
||||
<br />
|
||||
<br />
|
||||
<b>Preset Name</b> <br />
|
||||
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
<b>URL Segment</b> <br />
|
||||
The corresponding alias path and configuration parameters. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||
complete URL
|
||||
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
You will need to save the changes before the presets are functional.
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Title>
|
||||
Manage presets
|
||||
<Button onClick={addNew}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.Title>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.fit}>Active</th>
|
||||
<th className={style.aliasConstrain}>Preset name</th>
|
||||
<th className={style.fullWidth}>URL segment</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
|
||||
{fields.map((preset, index) => {
|
||||
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
||||
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
|
||||
// only saved and enabled URLs can be tested
|
||||
const canTest =
|
||||
preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError;
|
||||
|
||||
return (
|
||||
<tr key={preset.id}>
|
||||
<td className={style.fit}>
|
||||
<Switch
|
||||
{...register(`data.${index}.enabled`)}
|
||||
variant='ontime'
|
||||
data-testid={`field__enable_${index}`}
|
||||
/>
|
||||
</td>
|
||||
<td className={style.aliasConstrain}>
|
||||
<Input
|
||||
{...register(`data.${index}.alias`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='URL Preset'
|
||||
data-testid={`field__alias_${index}`}
|
||||
/>
|
||||
<Panel.Error>{maybeAliasError}</Panel.Error>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
{...register(`data.${index}.pathAndParams`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
data-testid={`field__url_${index}`}
|
||||
/>
|
||||
<Panel.Error>{maybeUrlError}</Panel.Error>
|
||||
</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<TooltipActionBtn
|
||||
size='sm'
|
||||
isDisabled={!canTest}
|
||||
clickHandler={(event) => handleLinks(preset.alias, event)}
|
||||
tooltip='Test preset'
|
||||
aria-label='Test preset'
|
||||
variant='ontime-ghosted'
|
||||
color='#e2e2e2' // $gray-200
|
||||
icon={<IoOpenOutline />}
|
||||
data-testid={`field__test_${index}`}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => remove(index)}
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
data-testid={`field__delete_${index}`}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types';
|
||||
|
||||
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
|
||||
import { formatTime } from '../../../../common/utils/time';
|
||||
|
||||
export type CombinedReport = {
|
||||
index: number;
|
||||
title: string;
|
||||
cue: string;
|
||||
scheduledStart: number;
|
||||
actualStart: MaybeNumber;
|
||||
scheduledEnd: number;
|
||||
actualEnd: MaybeNumber;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a combined report with the rundown data
|
||||
*/
|
||||
export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] {
|
||||
if (Object.keys(report).length === 0) return [];
|
||||
if (order.length === 0) return [];
|
||||
|
||||
const combinedReport: CombinedReport[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(report)) {
|
||||
if (!rundown[key] || !isOntimeEvent(rundown[key])) continue;
|
||||
|
||||
combinedReport.push({
|
||||
index: order.findIndex((id) => id === key),
|
||||
title: rundown[key].title,
|
||||
cue: rundown[key].cue,
|
||||
scheduledStart: rundown[key].timeStart,
|
||||
actualEnd: value.endedAt,
|
||||
scheduledEnd: rundown[key].timeEnd,
|
||||
actualStart: value.startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return combinedReport;
|
||||
}
|
||||
|
||||
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
|
||||
|
||||
/**
|
||||
* Transforms a CombinedReport into a CSV string
|
||||
*/
|
||||
export function makeReportCSV(combinedReport: CombinedReport[]) {
|
||||
const csv: string[][] = [];
|
||||
csv.push(csvHeader);
|
||||
|
||||
for (const entry of combinedReport) {
|
||||
csv.push([
|
||||
String(entry.index),
|
||||
entry.title,
|
||||
entry.cue,
|
||||
formatTime(entry.scheduledStart),
|
||||
formatTime(entry.actualStart),
|
||||
formatTime(entry.scheduledEnd),
|
||||
formatTime(entry.actualEnd),
|
||||
]);
|
||||
}
|
||||
|
||||
return makeCSVFromArrayOfArrays(csv);
|
||||
}
|
||||
Reference in New Issue
Block a user