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); +}