feat: Improve automation flow and add ontime playback actions (#2025) (#2024)

* fix: prevent add filter from submitting form

* refactor: event clarifies whether automations exist but are disabled

* refactor: improve readability of automation form

* refactor: add affordance for field warnings

* refactor: improve visibility of automation off state

* refactor: apply warning styles to other feature toggles

* Adding playback actions to automations (#2024)

* adding intial automation actions

* cleaning up

* fixing formatting

* ran oxfmt

* switching action names to playback- to match the dropdown strings

* fixing flicker that was caused by scroll arrows gettting unmounted

---------

Co-authored-by: Cameron Slipp <cdslipp@gmail.com>
This commit is contained in:
Carlos Valente
2026-03-24 09:13:37 +01:00
committed by GitHub
parent cf206e6220
commit 95536902f5
22 changed files with 346 additions and 89 deletions
@@ -125,6 +125,11 @@
font-size: 0.5rem; font-size: 0.5rem;
display: grid; display: grid;
place-content: center; place-content: center;
visibility: hidden;
&[data-visible] {
visibility: visible;
}
&::before { &::before {
content: ''; content: '';
@@ -29,7 +29,7 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
</BaseSelect.Trigger> </BaseSelect.Trigger>
<BaseSelect.Portal> <BaseSelect.Portal>
<BaseSelect.Positioner side='bottom' align='start'> <BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} /> <BaseSelect.ScrollUpArrow className={styles.scrollArrow} keepMounted />
<BaseSelect.Popup className={styles.popup}> <BaseSelect.Popup className={styles.popup}>
<BaseSelect.Arrow /> <BaseSelect.Arrow />
<BaseSelect.List className={styles.list}> <BaseSelect.List className={styles.list}>
@@ -43,7 +43,7 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
))} ))}
</BaseSelect.List> </BaseSelect.List>
</BaseSelect.Popup> </BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} /> <BaseSelect.ScrollDownArrow className={styles.scrollArrow} keepMounted />
</BaseSelect.Positioner> </BaseSelect.Positioner>
</BaseSelect.Portal> </BaseSelect.Portal>
</BaseSelect.Root> </BaseSelect.Root>
@@ -1,9 +1,17 @@
.tag { .tag {
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
letter-spacing: 0.5px; letter-spacing: 0.5px;
background-color: $gray-900;
color: $ui-white;
border-radius: 2px; border-radius: 2px;
padding: 0 0.25rem; padding: 0 0.25rem;
white-space: nowrap; white-space: nowrap;
} }
.default {
background-color: $gray-900;
color: $ui-white;
}
.warning {
background-color: $orange-1300;
color: $orange-300;
}
@@ -1,11 +1,14 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Tag.module.scss'; import style from './Tag.module.scss';
interface TagProps { interface TagProps {
className?: string; className?: string;
variant?: 'default' | 'warning';
} }
export default function Tag({ className, children }: PropsWithChildren<TagProps>) { export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
return <span className={`${style.tag} ${className || ''}`}>{children}</span>; return <span className={cx([style.tag, style[variant], className])}>{children}</span>;
} }
@@ -140,11 +140,21 @@ $inner-padding: 1rem;
font-size: 1rem; font-size: 1rem;
} }
.fieldHeading {
display: flex;
align-items: center;
gap: 0.5rem;
}
.fieldDescription { .fieldDescription {
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
color: $gray-400; color: $gray-400;
} }
.warningText {
color: $orange-500;
}
.fieldError { .fieldError {
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
color: $red-500; color: $red-500;
@@ -85,18 +85,34 @@ export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>; return <li className={style.listItem}>{children}</li>;
} }
export function Field({ title, description, error }: { title: string; description: string; error?: string }) { export function Field({
title,
description,
error,
descriptionTone = 'default',
}: {
title: ReactNode;
description: ReactNode;
error?: string;
descriptionTone?: 'default' | 'warning';
}) {
return ( return (
<div className={style.fieldTitle}> <div className={style.fieldTitle}>
{title} <div className={style.fieldHeading}>{title}</div>
{error && <Error>{error}</Error>} {error && <Error>{error}</Error>}
{!error && description && <Description>{description}</Description>} {!error && description && <Description tone={descriptionTone}>{description}</Description>}
</div> </div>
); );
} }
export function Description({ children }: { children: ReactNode }) { export function Description({
return <div className={style.fieldDescription}>{children}</div>; children,
tone = 'default',
}: {
children: ReactNode;
tone?: 'default' | 'warning';
}) {
return <div className={cx([style.fieldDescription, tone === 'warning' && style.warningText])}>{children}</div>;
} }
export function Highlight({ children }: { children: ReactNode }) { export function Highlight({ children }: { children: ReactNode }) {
@@ -282,7 +282,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
); );
})} })}
<div> <div>
<Button type='submit' onClick={handleAddNewFilter}> <Button onClick={handleAddNewFilter}>
Add filter <IoAdd /> Add filter <IoAdd />
</Button> </Button>
</div> </div>
@@ -13,6 +13,8 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location); const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location);
const isLoading = status === 'pending'; const isLoading = status === 'pending';
const automationState = isLoading ? undefined : data.enabledAutomations;
const oscInputState = isLoading ? undefined : data.enabledOscIn;
return ( return (
<> <>
@@ -24,13 +26,15 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
enabledAutomations={data.enabledAutomations} enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn} enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn} oscPortIn={data.oscPortIn}
automationState={automationState}
oscInputState={oscInputState}
/> />
</div> </div>
<div ref={automationsRef}> <div ref={automationsRef}>
<AutomationsList automations={data.automations} /> <AutomationsList automations={data.automations} enabledAutomations={automationState} />
</div> </div>
<div ref={triggersRef}> <div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} /> <TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
</div> </div>
</Panel.Section> </Panel.Section>
</> </>
@@ -7,6 +7,7 @@ import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import Tag from '../../../../common/components/tag/Tag';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
@@ -18,12 +19,16 @@ interface AutomationSettingsProps {
enabledAutomations: boolean; enabledAutomations: boolean;
enabledOscIn: boolean; enabledOscIn: boolean;
oscPortIn: number; oscPortIn: number;
automationState?: boolean;
oscInputState?: boolean;
} }
export default function AutomationSettingsForm({ export default function AutomationSettingsForm({
enabledAutomations, enabledAutomations,
enabledOscIn, enabledOscIn,
oscPortIn, oscPortIn,
automationState,
oscInputState,
}: AutomationSettingsProps) { }: AutomationSettingsProps) {
const { const {
handleSubmit, handleSubmit,
@@ -56,6 +61,8 @@ export default function AutomationSettingsForm({
}; };
const canSubmit = !isSubmitting && isDirty && isValid; const canSubmit = !isSubmitting && isDirty && isValid;
const automationsEnabled = watch('enabledAutomations');
const oscInputEnabled = watch('enabledOscIn');
return ( return (
<Panel.Card> <Panel.Card>
@@ -102,33 +109,52 @@ export default function AutomationSettingsForm({
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Enable automations' title={
description='Allow Ontime to send messages on lifecycle triggers' <>
<span>Enable automations</span>
{automationState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
automationState === false
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
: 'Allow Ontime to send messages on lifecycle triggers'
}
descriptionTone={automationState === false ? 'warning' : 'default'}
error={errors.enabledAutomations?.message} error={errors.enabledAutomations?.message}
/> />
<Switch <Switch
size='large' size='large'
checked={watch('enabledAutomations')} checked={automationsEnabled}
onCheckedChange={(value: boolean) => onCheckedChange={(value: boolean) =>
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true }) setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
} }
/> />
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title> <Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>} {isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='OSC input' title={
description='Allow control of Ontime through OSC' <>
<span>OSC input</span>
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
oscInputState === false
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
: 'Allow control of Ontime through OSC'
}
descriptionTone={oscInputState === false ? 'warning' : 'default'}
error={errors.enabledOscIn?.message} error={errors.enabledOscIn?.message}
/> />
<Switch <Switch
size='large' size='large'
checked={watch('enabledOscIn')} checked={oscInputEnabled}
onCheckedChange={(value: boolean) => onCheckedChange={(value: boolean) =>
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true }) setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
} }
@@ -5,6 +5,7 @@ import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { deleteAutomation } from '../../../../common/api/automation'; import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
@@ -20,10 +21,11 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean;
} }
export default function AutomationsList(props: AutomationsListProps) { export default function AutomationsList(props: AutomationsListProps) {
const { automations } = props; const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null); const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -56,6 +58,13 @@ export default function AutomationsList(props: AutomationsListProps) {
<Panel.Divider /> <Panel.Divider />
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
</Info>
)}
{automationFormData !== null && ( {automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} /> <AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)} )}
@@ -66,6 +66,11 @@ export default function OntimeActionForm({
{ value: 'aux2-set', label: 'Aux 2: set' }, { value: 'aux2-set', label: 'Aux 2: set' },
{ value: 'aux3-set', label: 'Aux 3: set' }, { value: 'aux3-set', label: 'Aux 3: set' },
{ value: 'playback-start', label: 'Playback: start' },
{ value: 'playback-stop', label: 'Playback: stop' },
{ value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message: set' }, { value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message: source' }, { value: 'message-secondary', label: 'Secondary Message: source' },
]} ]}
@@ -5,6 +5,7 @@ import { IoAdd } from 'react-icons/io5';
import { deleteTrigger } from '../../../../common/api/automation'; import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils'; import { checkDuplicates } from './automationUtils';
@@ -14,10 +15,11 @@ import TriggersListItem from './TriggersListItem';
interface TriggersListProps { interface TriggersListProps {
triggers: Trigger[]; triggers: Trigger[];
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean;
} }
export default function TriggersList(props: TriggersListProps) { export default function TriggersList(props: TriggersListProps) {
const { triggers, automations } = props; const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -52,6 +54,9 @@ export default function TriggersList(props: TriggersListProps) {
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
{enabledAutomations === false && (
<Info>Automations are disabled. You can still manage triggers here, but they will not run until enabled.</Info>
)}
{duplicates && ( {duplicates && (
<Panel.Error> <Panel.Error>
You have created multiple links between the same trigger and automation which can performance issues. You have created multiple links between the same trigger and automation which can performance issues.
@@ -9,6 +9,7 @@ import Info from '../../../../common/components/info/Info';
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker'; import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import Tag from '../../../../common/components/tag/Tag';
import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -55,6 +56,8 @@ export default function ViewSettings() {
reset(data); reset(data);
}; };
const overrideStylesEnabled = watch('overrideStyles');
if (!control) { if (!control) {
return null; return null;
} }
@@ -91,12 +94,22 @@ export default function ViewSettings() {
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={codeEditorHandler.close} /> <CodeEditorModal isOpen={isCodeEditorOpen} onClose={codeEditorHandler.close} />
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Override CSS styles' title={
description='Enables overriding view styles with custom stylesheet' <>
<span>Override CSS styles</span>
{overrideStylesEnabled && <Tag variant='warning'>ON</Tag>}
</>
}
description={
overrideStylesEnabled
? 'CSS override is ON. Ontime views will use the custom override stylesheet.'
: 'Enables overriding view styles with custom stylesheet'
}
descriptionTone={overrideStylesEnabled ? 'warning' : 'default'}
/> />
<Switch <Switch
size='large' size='large'
checked={watch('overrideStyles')} checked={overrideStylesEnabled}
onCheckedChange={(value: boolean) => setValue('overrideStyles', value, { shouldDirty: true })} onCheckedChange={(value: boolean) => setValue('overrideStyles', value, { shouldDirty: true })}
/> />
<Button onClick={codeEditorHandler.open} disabled={isSubmitting}> <Button onClick={codeEditorHandler.open} disabled={isSubmitting}>
+16 -1
View File
@@ -13,6 +13,7 @@ import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { useEntryActionsContext } from '../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import useAutomationSettings from '../../common/hooks-query/useAutomationSettings';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata'; import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata';
import { AppMode } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
@@ -49,6 +50,8 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
// invoke the compiler for the component // invoke the compiler for the component
'use memo'; 'use memo';
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries)); const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [metadata, setMetadata] = useState<RundownMetadataObject>(rundownMetadata); const [metadata, setMetadata] = useState<RundownMetadataObject>(rundownMetadata);
@@ -263,6 +266,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
isPast={entryMetadata.isPast} isPast={entryMetadata.isPast}
eventIndex={entryMetadata.eventIndex} eventIndex={entryMetadata.eventIndex}
data={entry} data={entry}
automationsEnabled={automationsEnabled}
loaded={entryMetadata.isLoaded} loaded={entryMetadata.isLoaded}
hasCursor={hasCursor} hasCursor={hasCursor}
isNext={isNext} isNext={isNext}
@@ -282,7 +286,18 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
</Fragment> </Fragment>
); );
}, },
[entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup], [
entries,
metadata,
getIsCollapsed,
isEditMode,
cursor,
nextEventId,
playback,
lastEntryId,
handleCollapseGroup,
automationsEnabled,
],
); );
if (sortableData.length < 1) { if (sortableData.length < 1) {
@@ -8,6 +8,7 @@ interface RundownEntryProps {
type: SupportedEntry; type: SupportedEntry;
isPast: boolean; isPast: boolean;
data: OntimeEntry; data: OntimeEntry;
automationsEnabled?: boolean;
loaded: boolean; loaded: boolean;
eventIndex: number; eventIndex: number;
hasCursor: boolean; hasCursor: boolean;
@@ -22,6 +23,7 @@ interface RundownEntryProps {
export default function RundownEntry({ export default function RundownEntry({
isPast, isPast,
data, data,
automationsEnabled,
loaded, loaded,
hasCursor, hasCursor,
isNext, isNext,
@@ -52,6 +54,7 @@ export default function RundownEntry({
title={data.title} title={data.title}
note={data.note} note={data.note}
delay={data.delay} delay={data.delay}
automationsEnabled={automationsEnabled}
colour={data.colour} colour={data.colour}
isPast={isPast} isPast={isPast}
isNext={isNext} isNext={isNext}
@@ -1,30 +1,90 @@
.triggerForm { .triggers {
padding-block: 0.5rem; display: flex;
display: grid; flex-direction: column;
grid-template-columns: 8rem 1fr auto 2rem; gap: 0.75rem;
}
.section,
.formSection {
display: flex;
flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
}
.sectionTitle {
font-size: $aux-text-size;
color: $label-gray;
}
.triggerList {
display: flex;
flex-direction: column;
background-color: $black-10;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
overflow: hidden;
}
.triggerForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.formFields {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.75rem;
align-items: end;
}
.formActions {
display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 0.75rem;
min-height: 2rem;
} }
.trigger { .trigger {
padding: 0.25rem 0.5rem; padding: 0.5rem 0.75rem;
display: grid; display: grid;
grid-template-columns: 8rem 1fr 2rem; grid-template-columns: 8rem 1fr 2rem;
align-items: center; align-items: center;
min-height: 2.5rem;
&:nth-child(even) { &:not(:first-child) {
background-color: $white-1; border-top: 1px solid $white-10;
}
& > span {
width: fit-content;
} }
} }
.errorLabel { .triggerMeta {
min-width: 0;
}
.metaLabel {
color: $label-gray;
font-size: $aux-text-size;
line-height: 1.2;
margin-bottom: 0.125rem;
}
.automationTitle {
overflow-wrap: anywhere;
}
.validationError,
.validationSuccess {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: $aux-text-size;
}
.validationError {
color: $red-500; color: $red-500;
} }
.success { .validationSuccess {
color: $green-500; color: $green-500;
} }
@@ -1,13 +1,13 @@
import { TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import { Fragment, useCallback, useMemo, useState } from 'react'; import { Fragment, useCallback, useMemo, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5'; import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import Info from '../../../../common/components/info/Info';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants'; import { eventTriggerOptions } from './eventTrigger.constants';
@@ -20,23 +20,36 @@ interface EventEditorTriggersProps {
} }
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) { export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
const showTriggers = triggers.length > 0; const showTriggers = triggers.length > 0;
return ( return (
<> <div className={style.triggers}>
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />} {automationsEnabled === false && (
<EventTriggerForm triggers={triggers} eventId={eventId} /> <Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
</> )}
{showTriggers && (
<div className={style.section}>
<div className={style.sectionTitle}>Applied automations</div>
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
</div>
)}
<Editor.Panel className={style.formSection}>
<div className={style.sectionTitle}>Add automation</div>
<EventTriggerForm triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
</Editor.Panel>
</div>
); );
} }
interface EventTriggerFormProps { interface EventTriggerFormProps {
eventId: string; eventId: string;
triggers?: Trigger[]; triggers?: Trigger[];
automations: NormalisedAutomation;
} }
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) { function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
const { data: automationSettings } = useAutomationSettings();
const { updateEntry } = useEntryActionsContext(); const { updateEntry } = useEntryActionsContext();
const [automationId, setAutomationId] = useState<string | undefined>(undefined); const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart); const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
@@ -52,7 +65,7 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
if (automationId === undefined) { if (automationId === undefined) {
return 'Select an automation'; return 'Select an automation';
} }
if (!Object.keys(automationSettings.automations).includes(automationId)) { if (!Object.keys(automations).includes(automationId)) {
return 'This automation does not exist'; return 'This automation does not exist';
} }
if (triggers === undefined) { if (triggers === undefined) {
@@ -64,10 +77,11 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
}; };
const validationError = getValidationError(cycleValue, automationId); const validationError = getValidationError(cycleValue, automationId);
const validationLabel = validationError ?? 'Ready to add automation';
const triggerOptions = useMemo( const triggerOptions = useMemo(
() => [ () => [
{ value: null, label: 'Select Trigger' }, { value: null, label: 'Select lifecycle' },
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })), ...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
], ],
[], // eventTriggerOptions is a constant, no need for dependency [], // eventTriggerOptions is a constant, no need for dependency
@@ -76,42 +90,49 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const automationOptions = useMemo( const automationOptions = useMemo(
() => [ () => [
{ value: null, label: 'Select Automation' }, { value: null, label: 'Select Automation' },
...Object.values(automationSettings.automations).map(({ id, title }) => ({ value: id, label: title })), ...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
], ],
[automationSettings.automations], // This needs to be a dependency as it can change [automations], // This needs to be a dependency as it can change
); );
return ( return (
<div className={style.triggerForm}> <div className={style.triggerForm}>
<Select <div className={style.formFields}>
value={cycleValue} <div>
onValueChange={(value) => { <Editor.Label>Lifecycle</Editor.Label>
if (value !== null) setCycleValue(value); <Select
}} value={cycleValue}
options={triggerOptions} onValueChange={(value) => {
/> if (value !== null) setCycleValue(value);
}}
options={triggerOptions}
/>
</div>
<Select <div>
value={automationId ?? null} <Editor.Label>Automation</Editor.Label>
onValueChange={(value) => { <Select
if (value !== null) setAutomationId(value); value={automationId ?? null}
}} onValueChange={(value) => {
options={automationOptions} if (value !== null) setAutomationId(value);
/> }}
options={automationOptions}
/>
</div>
</div>
<Button <div className={style.formActions}>
disabled={validationError !== undefined} <div className={validationError ? style.validationError : style.validationSuccess}>
onClick={() => automationId && handleSubmit(cycleValue, automationId)} {validationError ? <IoAlertCircle /> : <IoCheckmarkCircle />}
> <span>{validationLabel}</span>
Add </div>
</Button> <Button
{validationError !== undefined ? ( disabled={validationError !== undefined}
<Tooltip text={validationError} render={<span />}> onClick={() => automationId && handleSubmit(cycleValue, automationId)}
<IoAlertCircle className={style.errorLabel} /> >
</Tooltip> Add automation
) : ( </Button>
<IoCheckmarkCircle className={style.success} /> </div>
)}
</div> </div>
); );
} }
@@ -119,11 +140,11 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
interface ExistingEventTriggersProps { interface ExistingEventTriggersProps {
eventId: string; eventId: string;
triggers: Trigger[]; triggers: Trigger[];
automations: NormalisedAutomation;
} }
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) { function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActionsContext(); const { updateEntry } = useEntryActionsContext();
const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback( const handleDelete = useCallback(
(triggerId: string) => { (triggerId: string) => {
@@ -144,16 +165,22 @@ function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps
}); });
return ( return (
<div> <div className={style.triggerList}>
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => ( {Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
<Fragment key={triggerLifeCycle}> <Fragment key={triggerLifeCycle}>
{triggerGroup.map((trigger) => { {triggerGroup.map((trigger) => {
const { id, automationId } = trigger; const { id, automationId } = trigger;
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>'; const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
return ( return (
<div key={id} className={style.trigger}> <div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag> <div className={style.triggerMeta}>
<Tag>{automationTitle}</Tag> <div className={style.metaLabel}>Lifecycle</div>
<div>{triggerLifeCycle}</div>
</div>
<div className={style.triggerMeta}>
<div className={style.metaLabel}>Automation</div>
<div className={style.automationTitle}>{automationTitle}</div>
</div>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}> <IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
@@ -231,6 +231,10 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator); color: var(--status-color-active-override, $active-indicator);
} }
.statusIcon.warning {
color: $orange-500;
}
.statusIcon.disabled { .statusIcon.disabled {
color: $gray-1000; color: $gray-1000;
} }
@@ -43,6 +43,7 @@ interface RundownEventProps {
title: string; title: string;
note: string; note: string;
delay: number; delay: number;
automationsEnabled?: boolean;
colour: string; colour: string;
isPast: boolean; isPast: boolean;
isNext: boolean; isNext: boolean;
@@ -76,6 +77,7 @@ export default function RundownEvent({
title, title,
note, note,
delay, delay,
automationsEnabled,
colour, colour,
isPast, isPast,
isNext, isNext,
@@ -302,6 +304,7 @@ export default function RundownEvent({
title={title} title={title}
note={note} note={note}
delay={delay} delay={delay}
automationsEnabled={automationsEnabled}
isNext={isNext} isNext={isNext}
skip={skip} skip={skip}
loaded={loaded} loaded={loaded}
@@ -38,6 +38,7 @@ interface RundownEventInnerProps {
title: string; title: string;
note: string; note: string;
delay: number; delay: number;
automationsEnabled?: boolean;
isNext: boolean; isNext: boolean;
skip: boolean; skip: boolean;
loaded: boolean; loaded: boolean;
@@ -64,6 +65,7 @@ function RundownEventInner({
title, title,
note, note,
delay, delay,
automationsEnabled,
isNext, isNext,
skip = false, skip = false,
loaded, loaded,
@@ -79,6 +81,21 @@ function RundownEventInner({
const eventIsPlaying = playback === Playback.Play; const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause; const eventIsPaused = playback === Playback.Pause;
const automationTooltip = (() => {
if (!hasTriggers) {
return 'Event has no triggers';
}
if (automationsEnabled !== false) {
return 'Event has triggers';
}
return 'Event has triggers, but automations are disabled';
})();
const automationIconClasses = cx([
style.statusIcon,
hasTriggers ? (automationsEnabled === false ? style.warning : style.active) : style.disabled,
]);
const playBtnStyles = { _hover: {} }; const playBtnStyles = { _hover: {} };
if (!skip && eventIsPlaying) { if (!skip && eventIsPlaying) {
@@ -142,8 +159,8 @@ function RundownEventInner({
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}> <Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} /> <LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
</Tooltip> </Tooltip>
<Tooltip text='Event has Triggers' render={<span />}> <Tooltip text={automationTooltip} render={<span />}>
<IoFlash className={`${style.statusIcon} ${hasTriggers ? style.active : style.disabled}`} /> <IoFlash className={automationIconClasses} />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@@ -4,6 +4,7 @@ import { parseUserTime } from 'ontime-utils';
import { logger } from '../../../classes/Logger.js'; import { logger } from '../../../classes/Logger.js';
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js'; import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
import * as messageService from '../../../services/message-service/message.service.js'; import * as messageService from '../../../services/message-service/message.service.js';
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
export function toOntimeAction(action: OntimeAction) { export function toOntimeAction(action: OntimeAction) {
const actionType = action.action; const actionType = action.action;
@@ -40,6 +41,16 @@ export function toOntimeAction(action: OntimeAction) {
return auxTimerService.setTime(time, 3); return auxTimerService.setTime(time, 3);
} }
// Playback actions
case 'playback-start':
return runtimeService.start();
case 'playback-stop':
return runtimeService.stop();
case 'playback-pause':
return runtimeService.pause();
case 'playback-roll':
return runtimeService.roll();
// Message actions // Message actions
case 'message-set': { case 'message-set': {
messageService.patch({ messageService.patch({
@@ -68,15 +68,24 @@ const ontimeAuxTriggerAction = [
const ontimeAuxSetAction = ['aux1-set', 'aux2-set', 'aux3-set'] as const; const ontimeAuxSetAction = ['aux1-set', 'aux2-set', 'aux3-set'] as const;
const ontimePlaybackAction = ['playback-start', 'playback-stop', 'playback-pause', 'playback-roll'] as const;
type OntimeAuxTriggerAction = (typeof ontimeAuxTriggerAction)[number]; type OntimeAuxTriggerAction = (typeof ontimeAuxTriggerAction)[number];
type OntimeAuxSetAction = (typeof ontimeAuxSetAction)[number]; type OntimeAuxSetAction = (typeof ontimeAuxSetAction)[number];
type OntimePlaybackAction = (typeof ontimePlaybackAction)[number];
type OntimeMessageSet = 'message-set'; type OntimeMessageSet = 'message-set';
type OntimeMessageSecondary = 'message-secondary'; type OntimeMessageSecondary = 'message-secondary';
export type OntimeActionKey = OntimeAuxTriggerAction | OntimeAuxSetAction | OntimeMessageSet | OntimeMessageSecondary; export type OntimeActionKey =
| OntimeAuxTriggerAction
| OntimePlaybackAction
| OntimeAuxSetAction
| OntimeMessageSet
| OntimeMessageSecondary;
export const ontimeActionKeyValues = [ export const ontimeActionKeyValues = [
...ontimeAuxTriggerAction, ...ontimeAuxTriggerAction,
...ontimePlaybackAction,
...ontimeAuxSetAction, ...ontimeAuxSetAction,
'message-set', 'message-set',
'message-secondary', 'message-secondary',
@@ -87,6 +96,10 @@ export type OntimeAction =
type: 'ontime'; type: 'ontime';
action: OntimeAuxTriggerAction; action: OntimeAuxTriggerAction;
} }
| {
type: 'ontime';
action: OntimePlaybackAction;
}
| { | {
type: 'ontime'; type: 'ontime';
action: OntimeAuxSetAction; action: OntimeAuxSetAction;