feat: edit groups

This commit is contained in:
Carlos Valente
2025-06-25 06:10:12 +02:00
committed by Carlos Valente
parent 4c08482258
commit 473f50b493
75 changed files with 916 additions and 511 deletions
@@ -0,0 +1,147 @@
import { useCallback } from 'react';
import { OntimeBlock } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import NullableTimeInput from '../../../common/components/input/time-input/NullableTimeInput';
import AppLink from '../../../common/components/link/app-link/AppLink';
import Switch from '../../../common/components/switch/Switch';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EntryEditorTextInput from './composite/EventTextInput';
import style from './EntryEditor.module.scss';
// title + colour + custom field labels
export type BlockEditorUpdateTextFields = 'targetDuration' | 'title' | 'colour' | string;
export type BlockEditorUpdateMaybeNumberFields = 'targetDuration';
export type BlockEditorBooleanFields = 'isNextDay';
interface BlockEditorProps {
block: OntimeBlock;
}
export default function BlockEditor({ block }: BlockEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions();
const handleSubmit = useCallback(
(
field: BlockEditorUpdateTextFields | BlockEditorUpdateMaybeNumberFields | BlockEditorBooleanFields,
value: string | boolean,
) => {
// Handle custom fields
if (typeof field === 'string' && field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEntry({ id: block.id, custom: { [fieldLabel]: value as string } });
return;
}
if (field === 'targetDuration') {
if (value === '') {
return updateEntry({ id: block.id, targetDuration: null });
}
return updateEntry({ id: block.id, targetDuration: parseUserTime(value as string) });
}
if (field === 'isNextDay') {
return updateEntry({ id: block.id, isNextDay: value as boolean });
}
// all other strings are text fields
return updateEntry({ id: block.id, [field]: value as string });
},
[block.id, updateEntry],
);
const isEditor = window.location.pathname.includes('editor');
const planOffset = typeof block.targetDuration !== 'number' ? null : block.targetDuration - block.duration;
console.log('targetDuration:', block.targetDuration);
return (
<div className={style.content}>
<div className={style.column}>
<Editor.Title>Block schedule</Editor.Title>
<div className={style.inline}>
<div>
{
// TODO: format with user time settings
}
<Editor.Label>First event start</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.startTime, { fallback: timerPlaceholder })}
</TextLikeInput>
</div>
<div>
<Editor.Label htmlFor='endTime'>Last event end</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.endTime, { fallback: timerPlaceholder })}
</TextLikeInput>
</div>
<div>
<Editor.Label htmlFor='duration'>Scheduled duration</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.duration, { fallback: enDash })}
</TextLikeInput>
</div>
</div>
<div className={style.inline}>
<div>
<Editor.Label htmlFor='targetDuration'>Target duration</Editor.Label>
<NullableTimeInput
name='targetDuration'
time={block.targetDuration}
submitHandler={handleSubmit}
emptyDisplay={enDash}
/>
</div>
<div>
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
{
// TODO: update remote data
// TODO: remove tab index
}
<TextLikeInput delayed={Boolean(planOffset)} className={style.textLikeInput}>
{millisToString(planOffset, { fallback: enDash })}
</TextLikeInput>
</div>
</div>
<div>
<Editor.Label htmlFor='isNextDay'>Is next day?</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
checked={block.isNextDay}
onCheckedChange={(checked) => {
handleSubmit('isNextDay', checked);
}}
/>
{block.isNextDay ? 'Events start the day after' : '-'}
</Editor.Label>
</div>
</div>
<div className={style.column}>
<Editor.Title>Block data</Editor.Title>
<div>
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={block.colour} handleChange={handleSubmit} />
</div>
<EntryEditorTextInput field='title' label='Title' initialValue={block.title} submitHandler={handleSubmit} />
</div>
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} event={block} />
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
import { cx } from '../../../common/utils/styleUtils';
import EventEditor from './EventEditor';
import style from './EntryEditor.module.scss';
interface CuesheetEventEditorProps {
eventId: string;
}
export default function CuesheetEventEditor({ eventId }: CuesheetEventEditorProps) {
const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (data.order.length === 0) {
setEvent(null);
return;
}
const event = data.entries[eventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [eventId, data.order, data.entries]);
if (!event) {
return null;
}
return (
<div className={cx([style.entryEditor, style.inModal])} data-testid='editor-container'>
<EventEditor event={event} />
</div>
);
}
@@ -0,0 +1,106 @@
.entryEditor {
max-height: 100%;
display: flex;
flex-direction: column;
overflow-x: auto;
&.inModal {
max-height: 80vh;
}
}
.content {
padding-inline: 0.5rem 1.5rem;
padding-bottom: 4rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
overflow-y: auto;
}
.timeSettings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.column {
display: flex;
flex-direction: column;
gap: 1rem;
h3 {
margin-bottom: -0.5rem; // bring the title closer to the section elements
}
}
.decorated {
color: var(--decorator-color, $ui-white);
background-color: var(--decorator-bg, $gray-1100);
width: fit-content;
padding-inline: 0.5rem;
border-radius: $component-border-radius-sm;
}
.delayLabel {
font-size: $aux-text-size;
color: $ontime-delay-text;
&::after {
content: '\200b';
}
}
.switchLabel {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: max-content;
cursor: pointer;
height: 2rem; // manually match the height of a text input
margin-bottom: 0; // reset margin from label component
}
.inline {
display: flex;
align-items: center;
gap: 1rem;
}
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 1rem;
row-gap: 1rem;
}
.tooltipIcon {
color: $blue-500;
display: inline-block;
font-size: 1.25em;
margin-left: 0.25em;
}
.customImage {
display: grid;
grid-template-columns: 1fr 72px;
gap: 1rem;
}
/* approximating the style of a disabled input */
.textLikeInput {
background-color: rgba($gray-1200, 0.4);
font-weight: 400;
color: $gray-200;
border: 1px solid transparent;
justify-content: center;
width: 7.5em;
&:hover {
background-color: rgba($gray-1200, 0.4);
}
}
@@ -0,0 +1,83 @@
import { useCallback } from 'react';
import { OntimeEvent } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorTriggers from './composite/EventEditorTriggers';
import style from './EntryEditor.module.scss';
// any of the titles + colour + custom field labels
export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps {
event: OntimeEvent;
}
export default function EventEditor({ event }: EventEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions();
const isEditor = window.location.pathname.includes('editor');
const handleSubmit = useCallback(
(field: EventEditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
} else {
updateEntry({ id: event.id, [field]: value });
}
},
[event.id, updateEntry],
);
return (
<div className={style.content}>
<EventEditorTimes
key={`${event.id}-times`}
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
timeStrategy={event.timeStrategy}
linkStart={event.linkStart}
countToEnd={event.countToEnd}
delay={event.delay}
endAction={event.endAction}
timerType={event.timerType}
timeWarning={event.timeWarning}
timeDanger={event.timeDanger}
/>
<EventEditorTitles
key={`${event.id}-titles`}
eventId={event.id}
cue={event.cue}
title={event.title}
note={event.note}
colour={event.colour}
handleSubmit={handleSubmit}
/>
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} event={event} />
</div>
<div className={style.column}>
<Editor.Title>
Automations
{isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>}
</Editor.Title>
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
</div>
</div>
);
}
@@ -0,0 +1,60 @@
.entryEditor {
color: $label-gray;
height: 100%;
max-height: 100%;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
overflow-x: auto;
}
.shortcutSection {
flex: 1;
display: grid;
place-content: center;
gap: 1rem;
}
.shortcuts {
font-size: calc(1rem - 3px);
border-collapse: separate;
border-spacing: 4rem 0;
tr {
td:nth-child(odd) {
text-align: left;
}
td:nth-child(even) {
text-align: right;
white-space: nowrap;
}
}
}
.spacer {
height: 1rem;
}
.prompt {
margin-left: 4rem;
}
.divider {
display: inline-block;
text-align: center;
width: 1em;
}
.kbd {
font-family: monospace;
white-space: nowrap;
font-size: calc(1rem - 2px);
padding: 0.125rem 0.5rem;
background-color: $gray-1200;
color: $ui-white;
border-radius: 2px;
font-weight: 400;
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
}
@@ -0,0 +1,177 @@
import { memo, PropsWithChildren } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss';
export default memo(EventEditorEmpty);
function EventEditorEmpty() {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<table className={style.shortcuts}>
<tbody>
<tr>
<td>Find in rundown</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>F</Kbd>
</td>
</tr>
<tr>
<td>Open Settings</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>,</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Select entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Select block</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Deselect entry</td>
<td>
<Kbd>Esc</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Reorder selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Copy selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>C</Kbd>
</td>
</tr>
<tr>
<td>Paste above</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Paste below</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Add event below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add event above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add block below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>B</Kbd>
</td>
</tr>
<tr>
<td>Add block above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>B</Kbd>
</td>
</tr>
<tr>
<td>Add delay below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Add delay above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
function AuxKey({ children }: PropsWithChildren) {
return <span className={style.divider}>{children}</span>;
}
function Kbd({ children }: PropsWithChildren) {
return <span className={style.kbd}>{children}</span>;
}
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { isOntimeBlock, isOntimeDelay, OntimeBlock, OntimeEvent } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection';
import EventEditorFooter from './composite/EventEditorFooter';
import BlockEditor from './BlockEditor';
import EventEditor from './EventEditor';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EntryEditor.module.scss';
export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | OntimeBlock | null>(null);
useEffect(() => {
if (data.order.length === 0) {
setEvent(null);
return;
}
const selectedEventId = Array.from(selectedEvents).at(0);
if (!selectedEventId) {
setEvent(null);
return;
}
const event = data.entries[selectedEventId];
if (event && !isOntimeDelay(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [data.order, data.entries, selectedEvents]);
if (!event) {
return <EventEditorEmpty />;
}
if (isOntimeBlock(event)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<BlockEditor block={event} />
</div>
);
}
return (
<div className={style.entryEditor} data-testid='editor-container'>
<EventEditor event={event} />
<EventEditorFooter id={event.id} cue={event.cue} />
</div>
);
}
@@ -0,0 +1,71 @@
import { CSSProperties, Fragment } from 'react';
import { CustomFields, OntimeBlock, OntimeEvent } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EventEditorUpdateFields } from '../EventEditor';
import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
import style from '../EntryEditor.module.scss';
interface EntryEditorCustomFieldsProps {
fields: CustomFields;
event: OntimeEvent | OntimeBlock;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EntryEditorCustomFields({
fields: customFields,
handleSubmit,
event,
}: EntryEditorCustomFieldsProps) {
return (
<Fragment>
{Object.keys(customFields).map((fieldKey) => {
const key = `${event.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`;
const initialValue = event.custom[fieldKey] ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
if (customFields[fieldKey].type === 'string') {
return (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EntryEditorTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})}
</Fragment>
);
}
@@ -0,0 +1,7 @@
.footer {
border-top: 1px solid $white-10;
padding-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -0,0 +1,27 @@
import { memo } from 'react';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import style from './EventEditorFooter.module.scss';
interface EventEditorFooterProps {
id: string;
cue: string;
}
export default memo(EventEditorFooter);
function EventEditorFooter({ id, cue }: EventEditorFooterProps) {
const loadById = `/ontime/load/id "${id}"`;
const loadByCue = `/ontime/load/cue "${cue}"`;
return (
<div className={style.footer}>
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
{loadById}
</CopyTag>
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
{loadByCue}
</CopyTag>
</div>
);
}
@@ -0,0 +1,12 @@
.imageContainer {
width: 100%;
height: 100%;
background-color: $gray-1250;
display: grid;
place-content: center;
}
.imageOverlay {
width: 100%;
height: 100%;
}
@@ -0,0 +1,14 @@
import style from './EventEditorImage.module.scss';
interface EventEditorImageProps {
src: string;
}
export default function EventEditorImage({ src }: EventEditorImageProps) {
return (
<div className={style.imageContainer}>
<img loading='lazy' src={src} />
<div className={style.imageOverlay} />
</div>
);
}
@@ -0,0 +1,177 @@
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
interface EventEditorTimesProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
countToEnd: boolean;
delay: number;
endAction: EndAction;
timerType: TimerType;
timeWarning: number;
timeDanger: number;
}
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
export default memo(EventEditorTimes);
function EventEditorTimes({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
delay,
endAction,
timerType,
timeWarning,
timeDanger,
}: EventEditorTimesProps) {
const { updateEntry } = useEntryActions();
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') {
updateEntry({ id: eventId, countToEnd: value as boolean });
return;
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string);
updateEntry({ id: eventId, [field]: newTime });
return;
}
if (field === 'timerType' || field === 'endAction') {
updateEntry({ id: eventId, [field]: value });
return;
}
};
const hasDelay = delay !== 0;
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${millisToString(
timeStart + delay,
)}${millisToString(timeEnd + delay)}`
: '';
return (
<>
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
countToEnd={countToEnd}
showLabels
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
</div>
<div className={style.column}>
<Editor.Title>Event Behaviour</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
value={endAction}
onChange={(value) => handleSubmit('endAction', value)}
options={[
{ value: EndAction.None, label: 'None' },
{ value: EndAction.LoadNext, label: 'Load next event' },
{ value: EndAction.PlayNext, label: 'Play next event' },
]}
/>
</div>
<div>
<Editor.Label htmlFor='countToEnd'>Count to End</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='countToEnd'
checked={countToEnd}
onCheckedChange={(value) => handleSubmit('countToEnd', value)}
/>
{countToEnd ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
</div>
<div className={style.column}>
<Editor.Title>
<Tooltip label='Changes how the timer is displayed in different views. It is not reflected in the rundown'>
<span>
Display Options
<IoInformationCircle className={style.tooltipIcon} />
</span>
</Tooltip>
</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
value={timerType}
onChange={(value) => handleSubmit('timerType', value)}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
{ value: TimerType.Clock, label: 'Clock' },
{ value: TimerType.None, label: 'None' },
]}
/>
</div>
<div className={style.inline}>
<div>
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleSubmit}
time={timeWarning}
placeholder='Duration'
/>
</div>
<div>
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleSubmit}
time={timeDanger}
placeholder='Duration'
/>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,53 @@
import { memo } from 'react';
import { sanitiseCue } from 'ontime-utils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../common/components/input/input/Input';
import { type EventEditorUpdateFields } from '../EventEditor';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
import style from '../EntryEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
cue: string;
title: string;
note: string;
colour: string;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
}
export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, cue, title, note, colour, handleSubmit }: EventEditorTitlesProps) {
const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue));
};
return (
<div className={style.column}>
<Editor.Title>Event Data</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
</div>
<EntryEditorTextInput
field='cue'
label='Cue'
initialValue={cue}
submitHandler={cueSubmitHandler}
maxLength={10}
/>
</div>
<div>
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
<EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
);
}
@@ -0,0 +1,30 @@
.triggerForm {
padding-block: 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem;
align-items: center;
}
.trigger {
padding: 0.25rem 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
&:nth-child(even) {
background-color: $white-1;
}
& > span {
width: fit-content;
}
}
.errorLabel {
color: $red-500;
}
.success {
color: $green-500;
}
@@ -0,0 +1,150 @@
import { Fragment, useCallback, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants';
import style from './EventEditorTriggers.module.scss';
interface EventEditorTriggersProps {
eventId: string;
triggers: Trigger[];
}
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
const showTriggers = triggers.length > 0;
return (
<>
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
<EventTriggerForm triggers={triggers} eventId={eventId} />
</>
);
}
interface EventTriggerFormProps {
eventId: string;
triggers?: Trigger[];
}
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const { data: automationSettings } = useAutomationSettings();
const { updateEntry } = useEntryActions();
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
const newTriggers = triggers ?? new Array<Trigger>();
const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
updateEntry({ id: eventId, triggers: newTriggers });
};
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
if (automationId === undefined) {
return 'Select an automation';
}
if (!Object.keys(automationSettings.automations).includes(automationId)) {
return 'This automation does not exist';
}
if (triggers === undefined) {
return;
}
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
? 'Automation can only be used once'
: undefined;
};
const validationError = getValidationError(cycleValue, automationId);
return (
<div className={style.triggerForm}>
<Select
value={cycleValue}
placeholder='Choose a trigger'
onChange={(value) => setCycleValue(value)}
options={eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }))}
/>
<Select
value={automationId}
placeholder='Choose an automation'
onChange={(value) => setAutomationId(value)}
options={Object.values(automationSettings.automations).map(({ id, title }) => ({ value: id, label: title }))}
/>
<Button
disabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add
</Button>
{validationError !== undefined ? (
<Tooltip label={validationError} shouldWrapChildren>
<IoAlertCircle className={style.errorLabel} />
</Tooltip>
) : (
<IoCheckmarkCircle className={style.success} />
)}
</div>
);
}
interface ExistingEventTriggersProps {
eventId: string;
triggers: Trigger[];
}
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActions();
const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback(
(triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEntry({ id: eventId, triggers: newTriggers });
},
[eventId, triggers, updateEntry],
);
const filteredTriggers: Record<string, Trigger[]> = {};
// sort triggers out into groups by the Lifecycle they are on
timerLifecycleValues.forEach((triggerType) => {
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
if (thisTriggerType.length) {
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
}
});
return (
<div>
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
<Fragment key={triggerLifeCycle}>
{triggerGroup.map((trigger) => {
const { id, automationId } = trigger;
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
return (
<div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag>
<IconButton variant='subtle-destructive' onClick={() => handleDelete(id)}>
<IoTrash />
</IconButton>
</div>
);
})}
</Fragment>
))}
</div>
);
}
@@ -0,0 +1,52 @@
import { type CSSProperties, useCallback, useRef } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EventEditorUpdateFields } from '../EventEditor';
interface CountedTextAreaProps {
className?: string;
field: EventEditorUpdateFields;
label: string;
initialValue: string;
style?: CSSProperties;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EventTextArea({
className,
field,
label,
initialValue,
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true,
});
return (
<div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<AutoTextArea
id={field}
inputref={ref}
rows={1}
size='sm'
resize='none'
variant='ontime-filled'
data-testid='input-textarea'
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
}
@@ -0,0 +1,53 @@
import { useCallback, useRef } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import Input, { type InputProps } from '../../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { BlockEditorUpdateTextFields } from '../BlockEditor';
import { EventEditorUpdateFields } from '../EventEditor';
interface EntryEditorTextInputProps extends InputProps {
field: EventEditorUpdateFields | BlockEditorUpdateTextFields;
label: string;
initialValue: string;
placeholder?: string;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EntryEditorTextInput({
className,
field,
label,
initialValue,
style: givenStyles,
submitHandler,
maxLength,
placeholder,
}: EntryEditorTextInputProps) {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnEnter: true,
});
return (
<div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<Input
id={field}
ref={ref}
maxLength={maxLength}
fluid
data-testid='input-textfield'
value={value}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
}
@@ -0,0 +1,10 @@
import { TimerLifeCycle } from 'ontime-types';
export const eventTriggerOptions: TimerLifeCycle[] = [
TimerLifeCycle.onLoad,
TimerLifeCycle.onStart,
TimerLifeCycle.onPause,
TimerLifeCycle.onFinish,
TimerLifeCycle.onWarning,
TimerLifeCycle.onDanger,
];