diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx
index 1ce3da620..545a0e94d 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx
+++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx
@@ -293,8 +293,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
Outputs
Automation outputs can be used to send data from Ontime to external software
- or to change properties of Ontime itself.
- See the documentation for templates
+ or to change properties of Ontime itself.
+ Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
+ read the docs
{fieldOutputs.map((output, index) => {
@@ -341,12 +342,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
Address
-
+
{rowErrors?.address?.message}
Arguments
-
+
{rowErrors?.args?.message}
@@ -376,7 +382,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
Target URL
-
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx
index 6eac2eb7c..11f6413b4 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx
+++ b/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx
@@ -5,6 +5,7 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
+import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
@@ -71,8 +72,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' },
- { value: 'message-set', label: 'Primary Message: set' },
- { value: 'message-secondary', label: 'Secondary Message: source' },
+ { value: 'message-set', label: 'Primary Message' },
+ { value: 'message-secondary', label: 'Secondary Message' },
]}
/>
{rowErrors?.action?.message}
@@ -96,7 +97,12 @@ export default function OntimeActionForm({
<>
Text (leave empty for no change)
-
+
{rowErrors?.text?.message}
@@ -123,15 +129,25 @@ export default function OntimeActionForm({
<>
Text (leave empty for no change)
-
+
{rowErrors?.text?.message}
Timer secondary source
-
+
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
+ // no-change -> leave the current secondary source untouched
+ if (value === 'no-change') {
+ setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
+ return;
+ }
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
@@ -139,9 +155,9 @@ export default function OntimeActionForm({
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
- value={watch(`outputs.${index}.secondarySource`)}
+ value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
- { value: null, label: 'Select secondary source' },
+ { value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx
index 5fa132a45..2978cdbd9 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx
+++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
{!showForm && triggers.length === 0 && (
setShowForm(true) : undefined}
/>
)}
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/template-input/TemplateInput.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/template-input/TemplateInput.module.scss
index ffb5fc2ce..94b28586c 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/template-input/TemplateInput.module.scss
+++ b/apps/client/src/features/app-settings/panel/automations-panel/template-input/TemplateInput.module.scss
@@ -1,29 +1,67 @@
-.wrapper {
+.inputShell {
position: relative;
}
-.suggestions {
- background: $gray-1250;
- color: $ui-white;
-
- position: absolute;
- top: 100%;
- left: 0;
+.fluid {
width: 100%;
- margin: 0;
- z-index: $zindex-floating;
- padding-block: 0.25rem;
+}
- max-height: 200px;
- overflow-y: auto;
+.input {
+ padding-right: 2rem;
+}
+
+.expandButton {
+ position: absolute;
+ top: 50%;
+ right: 0.25rem;
+ transform: translateY(-50%);
+}
+
+.positioner {
+ outline: 0;
+}
+
+.popup {
+ box-sizing: border-box;
+ min-width: var(--anchor-width);
+ max-width: var(--available-width);
+ border: 1px solid $gray-1000;
+ border-radius: $component-border-radius-md;
+ background: $gray-1250;
color: $label-gray;
+}
- li {
- padding: 0.25rem;
- }
+.list {
+ box-sizing: border-box;
+ max-height: min(20rem, var(--available-height));
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding-block: 0.25rem;
+ outline: 0;
+}
- li:hover {
+.item {
+ box-sizing: border-box;
+ padding: 0.25rem 0.5rem;
+ outline: 0;
+ cursor: default;
+ user-select: none;
+ overflow-wrap: anywhere;
+
+ &[data-highlighted] {
color: $ui-white;
background: $blue-700;
}
}
+
+.expandedEditor {
+ min-height: min(18rem, 45vh);
+ font-family: monospace;
+ line-height: 1.4;
+}
+
+.footerHint {
+ margin-right: auto;
+ color: $label-gray;
+ font-size: calc(1rem - 2px);
+}
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/template-input/TemplateInput.tsx b/apps/client/src/features/app-settings/panel/automations-panel/template-input/TemplateInput.tsx
index 7f9bb16c2..d79c29923 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/template-input/TemplateInput.tsx
+++ b/apps/client/src/features/app-settings/panel/automations-panel/template-input/TemplateInput.tsx
@@ -1,69 +1,258 @@
-import { mergeRefs, useClickOutside } from '@mantine/hooks';
-import { forwardRef, useMemo, useState } from 'react';
+import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
+import type { ChangeEvent, ReactNode, Ref } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { IoExpandOutline } from 'react-icons/io5';
-import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
+import Button from '../../../../../common/components/buttons/Button';
+import IconButton from '../../../../../common/components/buttons/IconButton';
+import { type InputProps } from '../../../../../common/components/input/input/Input';
+import Textarea from '../../../../../common/components/input/textarea/Textarea';
+import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
-import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
+import { cx } from '../../../../../common/utils/styleUtils';
+import { makeAutoCompleteList } from './templateInput.utils';
+import { useTemplateAutocomplete } from './useTemplateAutocomplete';
+import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss';
-interface TemplateInputProps extends InputProps {}
+interface TemplateInputProps extends Omit {
+ ref?: Ref;
+ value?: string;
+}
-const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
- const { value, onChange, ...rest } = props;
+interface TemplateEditorModalProps {
+ autocompleteList: string[];
+ isOpen: boolean;
+ onClose: () => void;
+ onSave: (value: string) => void;
+ value: string;
+}
+
+type TemplateAutocompleteState = ReturnType>;
+
+interface TemplateAutocompleteRootProps {
+ autocomplete: TemplateAutocompleteState;
+ children: ReactNode;
+ value: string;
+}
+
+function assignRef(ref: Ref | undefined, value: T | null) {
+ if (typeof ref === 'function') {
+ ref(value);
+ } else if (ref) {
+ ref.current = value;
+ }
+}
+
+function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
+ onChange?.({
+ target: { name, value },
+ currentTarget: { name, value },
+ } as ChangeEvent);
+}
+
+export default function TemplateInput({
+ className,
+ disabled,
+ fluid,
+ height = 'medium',
+ onChange,
+ readOnly,
+ ref,
+ value,
+ variant = 'subtle',
+ ...rest
+}: TemplateInputProps) {
const { data } = useCustomFields();
- const localRef = useClickOutside(() => setShowSuggestions(false));
+ const inputRef = useRef(null);
+ const [inputValue, setInputValue] = useState(value || '');
+ const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
- const [inputValue, setInputValue] = useState(value || '');
- const [suggestions, setSuggestions] = useState([]);
- const [showSuggestions, setShowSuggestions] = useState(false);
+ const updateInputValue = useCallback(
+ (nextValue: string) => {
+ setInputValue(nextValue);
+ emitInputChange(rest.name, nextValue, onChange);
+ },
+ [onChange, rest.name],
+ );
- const updateSuggestions = (value: string) => {
- const template = selectFromLastTemplate(value);
- return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
+ const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
+ const { setCursorForValue } = autocomplete;
+
+ // Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
+ useEffect(() => {
+ const nextValue = value || '';
+ setInputValue(nextValue);
+ setCursorForValue(nextValue, nextValue.length);
+ }, [setCursorForValue, value]);
+
+ const setInputRef = useCallback(
+ (element: HTMLInputElement | null) => {
+ inputRef.current = element;
+ assignRef(ref, element);
+ },
+ [ref],
+ );
+
+ const openExpandedEditor = () => {
+ autocomplete.setShowSuggestions(false);
+ setIsExpanded(true);
};
- const handleInputChange = (event: React.ChangeEvent) => {
- setInputValue(event.target.value);
-
- if (event.target.value.endsWith('{')) {
- setShowSuggestions(true);
- setSuggestions(updateSuggestions(event.target.value));
- } else if (event.target.value === '' || event.target.value.endsWith('}}')) {
- setShowSuggestions(false);
- } else if (showSuggestions) {
- setSuggestions(updateSuggestions(event.target.value));
- }
-
- onChange?.(event);
+ const closeExpandedEditor = () => {
+ setIsExpanded(false);
};
- const handleSuggestion = (value: string) => {
- setInputValue((prev) => {
- const remaining = matchRemaining(prev as string, value);
- return prev + remaining;
- });
- setShowSuggestions(false);
+ const saveExpandedEditor = (nextValue: string) => {
+ updateInputValue(nextValue);
+ autocomplete.setCursorForValue(nextValue, nextValue.length);
+ setIsExpanded(false);
};
return (
-
-
- {showSuggestions && suggestions.length > 0 && (
-
- {suggestions.map((suggestion) => (
- handleSuggestion(suggestion)}>
- {suggestion}
-
- ))}
-
- )}
-
+ <>
+
+
+
+
+
+
+
+
+
+ >
);
-});
+}
-export default TemplateInput;
+function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
+ const expandedInputRef = useRef(null);
+ const [draftValue, setDraftValue] = useState(value);
+ const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
+ const { setShowSuggestions } = autocomplete;
+
+ // Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ setDraftValue(value);
+ setShowSuggestions(false);
+ }, [isOpen, setShowSuggestions, value]);
+
+ const handleClose = () => {
+ setShowSuggestions(false);
+ onClose();
+ };
+
+ const handleSave = () => {
+ setShowSuggestions(false);
+ onSave(draftValue);
+ };
+
+ return (
+
+ }
+ />
+
+ }
+ footerElements={
+ <>
+ Start a template with {'{{'} to see autocomplete.
+ Cancel
+
+ Save
+
+ >
+ }
+ />
+ );
+}
+
+function TemplateSuggestionPopup() {
+ return (
+
+
+
+
+
+ {(suggestion: string) => (
+
+ {suggestion}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/template-input/__tests__/templateInput.utils.test.ts b/apps/client/src/features/app-settings/panel/automations-panel/template-input/__tests__/templateInput.utils.test.ts
index 3af67c7cd..4d65d7a62 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/template-input/__tests__/templateInput.utils.test.ts
+++ b/apps/client/src/features/app-settings/panel/automations-panel/template-input/__tests__/templateInput.utils.test.ts
@@ -1,4 +1,4 @@
-import { matchRemaining } from '../templateInput.utils';
+import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
@@ -15,3 +15,54 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe('');
});
});
+
+describe('selectActiveTemplate()', () => {
+ it('returns the last unclosed template fragment', () => {
+ expect(selectActiveTemplate('send {{event')).toBe('{{event');
+ expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
+ });
+
+ it('ignores single braces and closed templates', () => {
+ expect(selectActiveTemplate('send {')).toBe('');
+ expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
+ });
+
+ it('only considers templates before the cursor', () => {
+ expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
+ expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
+ });
+
+ it('selects a partial template when the cursor is inside a completed template', () => {
+ expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
+ });
+});
+
+describe('completeTemplateAtCursor()', () => {
+ it('completes the active template before the cursor', () => {
+ expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
+ value: 'send {{timer.current}} after',
+ cursorIndex: 22,
+ });
+ });
+
+ it('preserves text before and after the cursor', () => {
+ expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
+ value: 'before {{eventNow.title}} after',
+ cursorIndex: 25,
+ });
+ });
+
+ it('preserves a following template when completing between templates', () => {
+ expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
+ value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
+ cursorIndex: 31,
+ });
+ });
+
+ it('replaces the whole template when the cursor is inside a completed template', () => {
+ expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
+ value: 'before {{timer.duration}} after',
+ cursorIndex: 25,
+ });
+ });
+});
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts b/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts
index 51fa7a7b4..86887fe8b 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts
+++ b/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts
@@ -93,6 +93,11 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
];
}
+interface TemplateCompletion {
+ cursorIndex: number;
+ value: string;
+}
+
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
@@ -123,13 +128,54 @@ export function matchRemaining(a: string, b: string) {
return '';
}
-/**
- * Selects the last starting template in a string
- */
-export function selectFromLastTemplate(text: string) {
- const lastBraceIndex = text.lastIndexOf('{{');
- if (lastBraceIndex !== -1) {
- return text.slice(lastBraceIndex);
+function getActiveTemplateRange(text: string, cursorIndex = text.length) {
+ const textBeforeCursor = text.slice(0, cursorIndex);
+ const start = textBeforeCursor.lastIndexOf('{{');
+ if (start === -1) {
+ return null;
}
- return '';
+
+ const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
+ if (closeBeforeCursor > start) {
+ return null;
+ }
+
+ const closeAfterStart = text.indexOf('}}', start);
+ const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
+ const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
+ const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
+
+ return {
+ end,
+ start,
+ template: text.slice(start, cursorIndex),
+ };
+}
+
+/**
+ * Selects the last unclosed starting template before the cursor.
+ */
+export function selectActiveTemplate(text: string, cursorIndex = text.length) {
+ return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
+}
+
+/**
+ * Replaces the active template fragment before the cursor with the selected suggestion.
+ */
+export function completeTemplateAtCursor(
+ text: string,
+ suggestion: string,
+ cursorIndex = text.length,
+): TemplateCompletion {
+ const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
+ if (!activeTemplateRange) {
+ const value = text + matchRemaining(text, suggestion);
+ return { value, cursorIndex: value.length };
+ }
+
+ const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
+ return {
+ value,
+ cursorIndex: activeTemplateRange.start + suggestion.length,
+ };
}
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/template-input/useTemplateAutocomplete.ts b/apps/client/src/features/app-settings/panel/automations-panel/template-input/useTemplateAutocomplete.ts
new file mode 100644
index 000000000..eaea4a31f
--- /dev/null
+++ b/apps/client/src/features/app-settings/panel/automations-panel/template-input/useTemplateAutocomplete.ts
@@ -0,0 +1,72 @@
+import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
+import { useCallback, useMemo, useState, type RefObject } from 'react';
+
+import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
+
+type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
+
+function getCursorIndex(element: TemplateElement | null, fallback: string) {
+ return element?.selectionStart ?? fallback.length;
+}
+
+function focusCursor(element: TemplateElement | null, cursorIndex: number) {
+ requestAnimationFrame(() => {
+ element?.focus();
+ element?.setSelectionRange(cursorIndex, cursorIndex);
+ });
+}
+
+export function useTemplateAutocomplete(
+ value: string,
+ autocompleteList: string[],
+ elementRef: RefObject,
+ onValueChange: (value: string) => void,
+) {
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [cursor, setCursor] = useState(value.length);
+
+ const activeTemplate = selectActiveTemplate(value, cursor);
+ const suggestions = useMemo(() => {
+ if (!activeTemplate) {
+ return [];
+ }
+ return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
+ }, [activeTemplate, autocompleteList]);
+
+ const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
+ setCursor(cursorIndex);
+ setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
+ }, []);
+
+ const updateCursor = useCallback(() => {
+ const cursorIndex = getCursorIndex(elementRef.current, value);
+ setCursorForValue(value, cursorIndex);
+ }, [elementRef, setCursorForValue, value]);
+
+ const handleValueChange = useCallback(
+ (nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
+ if (eventDetails.reason === 'item-press') {
+ eventDetails.cancel();
+ const completed = completeTemplateAtCursor(value, nextValue, cursor);
+ setCursorForValue(completed.value, completed.cursorIndex);
+ onValueChange(completed.value);
+ focusCursor(elementRef.current, completed.cursorIndex);
+ return;
+ }
+
+ const cursorIndex = getCursorIndex(elementRef.current, nextValue);
+ setCursorForValue(nextValue, cursorIndex);
+ onValueChange(nextValue);
+ },
+ [cursor, elementRef, onValueChange, setCursorForValue, value],
+ );
+
+ return {
+ handleValueChange,
+ open: showSuggestions && suggestions.length > 0,
+ setCursorForValue,
+ setShowSuggestions,
+ suggestions,
+ updateCursor,
+ };
+}
diff --git a/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts b/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts
index 6519cf548..a5e02c9bd 100644
--- a/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts
+++ b/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts
@@ -165,6 +165,25 @@ describe('parseOutput', () => {
});
it('parses message-secondary with a text value', () => {
+ expect(
+ parseOutput({
+ type: 'ontime',
+ action: 'message-secondary',
+ text: 'hello',
+ }),
+ ).toMatchObject({
+ text: 'hello',
+ });
+ expect(
+ parseOutput({
+ type: 'ontime',
+ action: 'message-secondary',
+ secondarySource: undefined,
+ text: 'hello',
+ }),
+ ).toMatchObject({
+ text: 'hello',
+ });
expect(
parseOutput({
type: 'ontime',
diff --git a/apps/server/src/api-data/automation/__tests__/ontime.client.test.ts b/apps/server/src/api-data/automation/__tests__/ontime.client.test.ts
new file mode 100644
index 000000000..a767b83dc
--- /dev/null
+++ b/apps/server/src/api-data/automation/__tests__/ontime.client.test.ts
@@ -0,0 +1,113 @@
+import { runtimeStorePlaceholder } from 'ontime-types';
+
+import * as messageService from '../../../services/message-service/message.service.js';
+import { toOntimeAction } from '../clients/ontime.client.js';
+
+vi.mock('../../../services/message-service/message.service.js', () => ({
+ patch: vi.fn(),
+}));
+
+describe('toOntimeAction()', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('parses templates in primary message text', () => {
+ toOntimeAction(
+ {
+ type: 'ontime',
+ action: 'message-set',
+ text: 'Current: {{timer.current}}',
+ visible: true,
+ },
+ {
+ ...runtimeStorePlaceholder,
+ timer: {
+ ...runtimeStorePlaceholder.timer,
+ current: 42,
+ },
+ },
+ );
+
+ expect(messageService.patch).toHaveBeenCalledWith({
+ timer: {
+ text: 'Current: 42',
+ visible: true,
+ },
+ });
+ });
+
+ it('parses templates in secondary message text', () => {
+ toOntimeAction(
+ {
+ type: 'ontime',
+ action: 'message-secondary',
+ secondarySource: 'secondary',
+ text: 'Next: {{eventNext.title}}',
+ },
+ {
+ ...runtimeStorePlaceholder,
+ eventNext: {
+ id: 'next-event',
+ type: 'event',
+ cue: '2',
+ title: 'Keynote',
+ note: '',
+ timeStart: 0,
+ timeEnd: 0,
+ duration: 0,
+ timerType: 'count-down',
+ colour: '',
+ delay: 0,
+ isPublic: true,
+ skip: false,
+ endAction: 'none',
+ revision: 0,
+ custom: {},
+ },
+ },
+ );
+
+ expect(messageService.patch).toHaveBeenCalledWith({
+ timer: {
+ secondarySource: 'secondary',
+ },
+ secondary: 'Next: Keynote',
+ });
+ });
+
+ it('can set secondary message text without changing the secondary source', () => {
+ toOntimeAction(
+ {
+ type: 'ontime',
+ action: 'message-secondary',
+ text: 'Next: {{eventNext.title}}',
+ },
+ {
+ ...runtimeStorePlaceholder,
+ eventNext: {
+ id: 'next-event',
+ type: 'event',
+ cue: '2',
+ title: 'Keynote',
+ note: '',
+ timeStart: 0,
+ timeEnd: 0,
+ duration: 0,
+ timerType: 'count-down',
+ colour: '',
+ delay: 0,
+ isPublic: true,
+ skip: false,
+ endAction: 'none',
+ revision: 0,
+ custom: {},
+ },
+ },
+ );
+
+ expect(messageService.patch).toHaveBeenCalledWith({
+ secondary: 'Next: Keynote',
+ });
+ });
+});
diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts
index 77efa24da..d2376bf5c 100644
--- a/apps/server/src/api-data/automation/automation.service.ts
+++ b/apps/server/src/api-data/automation/automation.service.ts
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, store);
} else if (isOntimeAction(payload)) {
- toOntimeAction(payload);
+ toOntimeAction(payload, store);
} else {
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
}
diff --git a/apps/server/src/api-data/automation/automation.validation.ts b/apps/server/src/api-data/automation/automation.validation.ts
index 26f03252a..4295d3aed 100644
--- a/apps/server/src/api-data/automation/automation.validation.ts
+++ b/apps/server/src/api-data/automation/automation.validation.ts
@@ -225,8 +225,6 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
}
if (maybeOntimeAction.action === 'message-secondary') {
- assert.hasKeys(maybeOntimeAction, ['secondarySource']);
-
// the secondary text is optional, an empty string is treated as no change
let text: string | undefined = undefined;
if ('text' in maybeOntimeAction) {
@@ -234,6 +232,14 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
text = indeterminateText(maybeOntimeAction.text);
}
+ if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) {
+ return {
+ type: 'ontime',
+ action: 'message-secondary',
+ text,
+ };
+ }
+
// null is used to clear the secondary source
if (maybeOntimeAction.secondarySource === null) {
return {
diff --git a/apps/server/src/api-data/automation/clients/ontime.client.ts b/apps/server/src/api-data/automation/clients/ontime.client.ts
index 4c706cb46..e972f122a 100644
--- a/apps/server/src/api-data/automation/clients/ontime.client.ts
+++ b/apps/server/src/api-data/automation/clients/ontime.client.ts
@@ -1,12 +1,14 @@
-import { LogOrigin, OntimeAction } from 'ontime-types';
+import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
+import { DeepReadonly } from 'ts-essentials';
import { logger } from '../../../classes/Logger.js';
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
import * as messageService from '../../../services/message-service/message.service.js';
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
+import { parseTemplateNested } from '../automation.utils.js';
-export function toOntimeAction(action: OntimeAction) {
+export function toOntimeAction(action: OntimeAction, store: DeepReadonly) {
const actionType = action.action;
switch (actionType) {
// Aux timer actions
@@ -55,19 +57,25 @@ export function toOntimeAction(action: OntimeAction) {
case 'message-set': {
messageService.patch({
timer: {
- text: action.text,
+ text: action.text ? parseTemplateNested(action.text, store) : action.text,
visible: action.visible,
},
});
break;
}
case 'message-secondary': {
- messageService.patch({
- timer: {
- secondarySource: action.secondarySource,
- },
- secondary: action.text,
- });
+ const secondary = action.text ? parseTemplateNested(action.text, store) : action.text;
+ const patch =
+ action.secondarySource === undefined
+ ? { secondary }
+ : {
+ timer: {
+ secondarySource: action.secondarySource,
+ },
+ secondary,
+ };
+
+ messageService.patch(patch);
break;
}
diff --git a/packages/types/src/definitions/core/Automation.type.ts b/packages/types/src/definitions/core/Automation.type.ts
index 7e3382697..c67768b1b 100644
--- a/packages/types/src/definitions/core/Automation.type.ts
+++ b/packages/types/src/definitions/core/Automation.type.ts
@@ -114,6 +114,6 @@ export type OntimeAction =
| {
type: 'ontime';
action: OntimeMessageSecondary;
- secondarySource: SecondarySource;
+ secondarySource?: SecondarySource;
text?: string;
};