diff --git a/apps/client/src/common/components/link/app-link/AppLink.module.scss b/apps/client/src/common/components/link/app-link/AppLink.module.scss
index 36a8f13a2..282bc0d07 100644
--- a/apps/client/src/common/components/link/app-link/AppLink.module.scss
+++ b/apps/client/src/common/components/link/app-link/AppLink.module.scss
@@ -3,8 +3,9 @@
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
+ color: $blue-400;
&:hover {
- color: $blue-400;
+ color: $blue-500;
}
}
diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx
index 4e4a3558e..d094e891f 100644
--- a/apps/client/src/features/rundown/event-editor/EventEditor.tsx
+++ b/apps/client/src/features/rundown/event-editor/EventEditor.tsx
@@ -1,17 +1,15 @@
-import { CSSProperties, useCallback } from 'react';
+import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
-import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils';
-import EventEditorImage from './composite/EventEditorImage';
+import EventCustom from './composite/EventEditorCustom';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
-import EventTextArea from './composite/EventTextArea';
-import EventTextInput from './composite/EventTextInput';
+import EventEditorTriggers from './composite/EventEditorTriggers';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
@@ -77,52 +75,16 @@ export default function EventEditor(props: EventEditorProps) {
Custom Fields
- {isEditor && Manage}
+ {isEditor && Manage Custom Fields}
-
- {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 (
-
- );
- }
-
- if (customFields[fieldKey].type === 'image') {
- return (
-
-
-
-
- );
- }
-
- // we should have exhausted all types by now
- return null;
- })}
+
+
+
+
+ Automations
+ {isEditor && Manage Automations}
+
+
);
diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorCustom.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorCustom.tsx
new file mode 100644
index 000000000..41afa07a4
--- /dev/null
+++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorCustom.tsx
@@ -0,0 +1,68 @@
+import { CSSProperties, Fragment } from 'react';
+import { CustomFields, OntimeEvent } from 'ontime-types';
+
+import { getAccessibleColour } from '../../../../common/utils/styleUtils';
+import { EditorUpdateFields } from '../EventEditor';
+
+import EventEditorImage from './EventEditorImage';
+import EventTextArea from './EventTextArea';
+import EventTextInput from './EventTextInput';
+
+import style from '../EventEditor.module.scss';
+
+interface EventEditorCustomProps {
+ fields: CustomFields;
+ event: OntimeEvent;
+ handleSubmit: (field: EditorUpdateFields, value: string) => void;
+}
+
+export default function EventEditorCustom(props: EventEditorCustomProps) {
+ const { fields: customFields, handleSubmit, event } = props;
+ return (
+
+ {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 (
+
+ );
+ }
+
+ if (customFields[fieldKey].type === 'image') {
+ return (
+
+
+
+
+ );
+ }
+
+ // we should have exhausted all types by now
+ return null;
+ })}
+
+ );
+}
diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.module.scss b/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.module.scss
new file mode 100644
index 000000000..18b2729b7
--- /dev/null
+++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.module.scss
@@ -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;
+}
\ No newline at end of file
diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.tsx
new file mode 100644
index 000000000..5aebb3841
--- /dev/null
+++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.tsx
@@ -0,0 +1,173 @@
+import { Fragment, useCallback, useState } from 'react';
+import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
+import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react';
+import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
+import { generateId } from 'ontime-utils';
+
+import Tag from '../../../../common/components/tag/Tag';
+import { useEventAction } from '../../../../common/hooks/useEventAction';
+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(props: EventEditorTriggersProps) {
+ const { triggers, eventId } = props;
+ const showTriggers = triggers !== undefined && triggers.length > 0;
+
+ return (
+ <>
+ {showTriggers && }
+
+ >
+ );
+}
+
+interface EventTriggerFormProps {
+ eventId: string;
+ triggers?: Trigger[];
+}
+
+function EventTriggerForm(props: EventTriggerFormProps) {
+ const { eventId, triggers } = props;
+ const { data: automationSettings } = useAutomationSettings();
+ const { updateEvent } = useEventAction();
+ const [automationId, setAutomationId] = useState(undefined);
+ const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
+
+ const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
+ const newTriggers = triggers ?? new Array();
+ const id = generateId();
+ newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
+ updateEvent({ 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 (
+
+
+
+
+ {validationError !== undefined ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
+
+interface ExistingEventTriggersProps {
+ eventId: string;
+ triggers: Trigger[];
+}
+
+function ExistingEventTriggers(props: ExistingEventTriggersProps) {
+ const { eventId, triggers } = props;
+ const { updateEvent } = useEventAction();
+ const { data: automationSettings } = useAutomationSettings();
+
+ const handleDelete = useCallback(
+ (triggerId: string) => {
+ const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
+ updateEvent({ id: eventId, triggers: newTriggers });
+ },
+ [eventId, triggers, updateEvent],
+ );
+
+ const filteredTriggers: Record = {};
+
+ // 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 (
+
+ {Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
+
+ {triggerGroup.map((trigger) => {
+ const { id, automationId } = trigger;
+ const automationTitle = automationSettings.automations[automationId]?.title ?? '';
+ return (
+
+ {triggerLifeCycle}
+ {automationTitle}
+ }
+ aria-label='Delete entry'
+ onClick={() => handleDelete(id)}
+ />
+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/apps/client/src/features/rundown/event-editor/composite/eventTrigger.constants.ts b/apps/client/src/features/rundown/event-editor/composite/eventTrigger.constants.ts
new file mode 100644
index 000000000..0e9f38847
--- /dev/null
+++ b/apps/client/src/features/rundown/event-editor/composite/eventTrigger.constants.ts
@@ -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,
+];
diff --git a/apps/server/src/api-data/automation/automation.dao.ts b/apps/server/src/api-data/automation/automation.dao.ts
index 66f7244a9..344984fc2 100644
--- a/apps/server/src/api-data/automation/automation.dao.ts
+++ b/apps/server/src/api-data/automation/automation.dao.ts
@@ -9,12 +9,13 @@ import type {
import { deleteAtIndex, generateId } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
+import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
/**
* Gets a copy of the stored automation settings
*/
export function getAutomationSettings(): AutomationSettings {
- return structuredClone(getDataProvider().getAutomation());
+ return getDataProvider().getAutomation();
}
/**
@@ -138,14 +139,25 @@ export async function deleteAutomation(id: string): Promise {
if (!Object.hasOwn(automations, id)) {
return;
}
- // prevent deleting a automation that is in use
- const triggers = getAutomationTriggers();
- for (let i = 0; i < triggers.length; i++) {
- const trigger = triggers[i];
- if (trigger.automationId === id) {
- throw new Error(`Unable to delete automation used in trigger ${trigger.title}`);
- }
+
+ // prevent deleting a automation that is in use in triggers
+ const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === id);
+ if (triggers.length) {
+ throw new Error(
+ `Unable to delete automation used in trigger ${triggers[0].title}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
+ );
}
+
+ // prevent deleting a automation that is in use in events
+ const events = getTimedEvents().filter(
+ (event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
+ );
+ if (events.length) {
+ throw new Error(
+ `Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
+ );
+ }
+
delete automations[id];
await saveChanges({ automations });
}
diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts
index 2811e5ee7..d764775e7 100644
--- a/apps/server/src/api-data/automation/automation.service.ts
+++ b/apps/server/src/api-data/automation/automation.service.ts
@@ -3,10 +3,10 @@ import {
isOntimeAction,
isOSCOutput,
LogOrigin,
+ TimerLifeCycle,
type AutomationFilter,
type AutomationOutput,
type FilterRule,
- type TimerLifeCycle,
} from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils';
@@ -23,14 +23,21 @@ import { toOntimeAction } from './clients/ontime.client.js';
/**
* Exposes a method for triggering actions based on a TimerLifeCycle event
*/
-export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
+export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
if (!getAutomationsEnabled()) {
return;
}
- const triggers = getAutomationTriggers();
- const triggerAutomations = triggers.filter((trigger) => trigger.trigger === event);
- if (triggerAutomations.length === 0) {
+ let triggers = getAutomationTriggers();
+
+ // get triggers from event
+ if (state.eventNow?.triggers) {
+ triggers = triggers.concat(state.eventNow.triggers);
+ }
+
+ // note: there are no onStop triggers in event
+ const filteredTrigger = triggers.filter((trigger) => trigger.trigger === cycle);
+ if (filteredTrigger.length === 0) {
return;
}
@@ -39,7 +46,7 @@ export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
return;
}
- triggerAutomations.forEach((trigger) => {
+ filteredTrigger.forEach((trigger) => {
const automation = automations[trigger.automationId];
if (!automation || automation.outputs.length === 0) {
return;
diff --git a/apps/server/src/services/rundown-service/rundownCacheUtils.ts b/apps/server/src/services/rundown-service/rundownCacheUtils.ts
index 8f165fc81..9089c90a1 100644
--- a/apps/server/src/services/rundown-service/rundownCacheUtils.ts
+++ b/apps/server/src/services/rundown-service/rundownCacheUtils.ts
@@ -118,6 +118,7 @@ export enum regenerateWhitelist {
'timeWarning',
'timeDanger',
'custom',
+ 'triggers',
}
/**
diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts
index 11680f5f6..8f5b53279 100644
--- a/apps/server/src/utils/parser.ts
+++ b/apps/server/src/utils/parser.ts
@@ -396,6 +396,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial | null | undefined;
@@ -29,11 +28,6 @@ export function isKeyOfType(key: PropertyKey, obj: T): key is
return key in obj;
}
-export function isOntimeCycle(maybeCycle: unknown): maybeCycle is TimerLifeCycleKey {
- if (typeof maybeCycle !== 'string') return false;
- return Object.values(TimerLifeCycle).includes(maybeCycle as TimerLifeCycle);
-}
-
export function isOSCOutput(output: AutomationOutput): output is OSCOutput {
return output.type === 'osc';
}
@@ -45,3 +39,8 @@ export function isHTTPOutput(output: AutomationOutput): output is HTTPOutput {
export function isOntimeAction(output: AutomationOutput): output is OntimeAction {
return output.type === 'ontime';
}
+
+export function isTimerLifeCycle(maybeCycle: unknown): maybeCycle is TimerLifeCycle {
+ if (typeof maybeCycle !== 'string') return false;
+ return timerLifecycleValues.includes(maybeCycle);
+}