mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 15:09:10 +00:00
refactor(automation): improve automation UX
This commit is contained in:
committed by
Carlos Valente
parent
86b2132cdc
commit
6911e37c18
@@ -293,8 +293,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
<h3>Outputs</h3>
|
<h3>Outputs</h3>
|
||||||
<Info>
|
<Info>
|
||||||
Automation outputs can be used to send data from Ontime to external software <br />
|
Automation outputs can be used to send data from Ontime to external software <br />
|
||||||
or to change properties of Ontime itself.
|
or to change properties of Ontime itself. <br /> <br />
|
||||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||||
|
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||||
</Info>
|
</Info>
|
||||||
|
|
||||||
{fieldOutputs.map((output, index) => {
|
{fieldOutputs.map((output, index) => {
|
||||||
@@ -341,12 +342,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Address
|
Address
|
||||||
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
|
<TemplateInput
|
||||||
|
{...register(`outputs.${index}.address`)}
|
||||||
|
value={output.address}
|
||||||
|
fluid
|
||||||
|
placeholder='/cue/start'
|
||||||
|
/>
|
||||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Arguments
|
Arguments
|
||||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
|
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
||||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
@@ -376,7 +382,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
<div className={style.httpSection}>
|
<div className={style.httpSection}>
|
||||||
<label>
|
<label>
|
||||||
Target URL
|
Target URL
|
||||||
<Input
|
<TemplateInput
|
||||||
{...register(`outputs.${index}.url`, {
|
{...register(`outputs.${index}.url`, {
|
||||||
required: { value: true, message: 'Required field' },
|
required: { value: true, message: 'Required field' },
|
||||||
pattern: {
|
pattern: {
|
||||||
@@ -384,6 +390,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
message: 'HTTP messages should target http:// or https://',
|
message: 'HTTP messages should target http:// or https://',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
value={output.url}
|
||||||
fluid
|
fluid
|
||||||
placeholder='http://127.0.0.1/start/1'
|
placeholder='http://127.0.0.1/start/1'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
|
|||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import TemplateInput from './template-input/TemplateInput';
|
||||||
|
|
||||||
import style from './AutomationForm.module.scss';
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
@@ -71,8 +72,8 @@ export default function OntimeActionForm({
|
|||||||
{ value: 'playback-pause', label: 'Playback: pause' },
|
{ value: 'playback-pause', label: 'Playback: pause' },
|
||||||
{ value: 'playback-roll', label: 'Playback: roll' },
|
{ value: 'playback-roll', label: 'Playback: roll' },
|
||||||
|
|
||||||
{ value: 'message-set', label: 'Primary Message: set' },
|
{ value: 'message-set', label: 'Primary Message' },
|
||||||
{ value: 'message-secondary', label: 'Secondary Message: source' },
|
{ value: 'message-secondary', label: 'Secondary Message' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
||||||
@@ -96,7 +97,12 @@ export default function OntimeActionForm({
|
|||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
Text (leave empty for no change)
|
Text (leave empty for no change)
|
||||||
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
|
<TemplateInput
|
||||||
|
{...register(`outputs.${index}.text`)}
|
||||||
|
value={watch(`outputs.${index}.text`) ?? ''}
|
||||||
|
fluid
|
||||||
|
placeholder='eg: Timer is finished'
|
||||||
|
/>
|
||||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -123,15 +129,25 @@ export default function OntimeActionForm({
|
|||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
Text (leave empty for no change)
|
Text (leave empty for no change)
|
||||||
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Next up: keynote' />
|
<TemplateInput
|
||||||
|
{...register(`outputs.${index}.text`)}
|
||||||
|
value={watch(`outputs.${index}.text`) ?? ''}
|
||||||
|
fluid
|
||||||
|
placeholder='eg: Next up: keynote'
|
||||||
|
/>
|
||||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Timer secondary source
|
Timer secondary source
|
||||||
<Select<SecondarySource | 'null' | null>
|
<Select<SecondarySource | 'no-change' | 'null' | null>
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
// null -> no selection
|
// null -> no selection
|
||||||
if (value === null) return;
|
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
|
// 'null' -> clear the secondary source
|
||||||
if (value === 'null') {
|
if (value === 'null') {
|
||||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||||
@@ -139,9 +155,9 @@ export default function OntimeActionForm({
|
|||||||
}
|
}
|
||||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||||
}}
|
}}
|
||||||
value={watch(`outputs.${index}.secondarySource`)}
|
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
|
||||||
options={[
|
options={[
|
||||||
{ value: null, label: 'Select secondary source' },
|
{ value: 'no-change', label: 'No change' },
|
||||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{!showForm && triggers.length === 0 && (
|
{!showForm && triggers.length === 0 && (
|
||||||
<Panel.TableEmpty
|
<Panel.TableEmpty
|
||||||
label='Create an automation to attach triggers to'
|
label='Create a trigger to run an automation'
|
||||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+55
-17
@@ -1,29 +1,67 @@
|
|||||||
.wrapper {
|
.inputShell {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestions {
|
.fluid {
|
||||||
background: $gray-1250;
|
|
||||||
color: $ui-white;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
}
|
||||||
z-index: $zindex-floating;
|
|
||||||
padding-block: 0.25rem;
|
|
||||||
|
|
||||||
max-height: 200px;
|
.input {
|
||||||
overflow-y: auto;
|
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;
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
li {
|
.list {
|
||||||
padding: 0.25rem;
|
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;
|
color: $ui-white;
|
||||||
background: $blue-700;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+236
-47
@@ -1,69 +1,258 @@
|
|||||||
import { mergeRefs, useClickOutside } from '@mantine/hooks';
|
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
||||||
import { forwardRef, useMemo, useState } from 'react';
|
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 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';
|
import style from './TemplateInput.module.scss';
|
||||||
|
|
||||||
interface TemplateInputProps extends InputProps {}
|
interface TemplateInputProps extends Omit<InputProps, 'value'> {
|
||||||
|
ref?: Ref<HTMLInputElement>;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
|
interface TemplateEditorModalProps {
|
||||||
const { value, onChange, ...rest } = props;
|
autocompleteList: string[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
|
||||||
|
|
||||||
|
interface TemplateAutocompleteRootProps {
|
||||||
|
autocomplete: TemplateAutocompleteState;
|
||||||
|
children: ReactNode;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignRef<T>(ref: Ref<T> | 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<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TemplateInput({
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
fluid,
|
||||||
|
height = 'medium',
|
||||||
|
onChange,
|
||||||
|
readOnly,
|
||||||
|
ref,
|
||||||
|
value,
|
||||||
|
variant = 'subtle',
|
||||||
|
...rest
|
||||||
|
}: TemplateInputProps) {
|
||||||
const { data } = useCustomFields();
|
const { data } = useCustomFields();
|
||||||
const localRef = useClickOutside(() => setShowSuggestions(false));
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const autocompleteList = useMemo(() => {
|
const autocompleteList = useMemo(() => {
|
||||||
return makeAutoCompleteList(data);
|
return makeAutoCompleteList(data);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState(value || '');
|
const updateInputValue = useCallback(
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
(nextValue: string) => {
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
setInputValue(nextValue);
|
||||||
|
emitInputChange(rest.name, nextValue, onChange);
|
||||||
|
},
|
||||||
|
[onChange, rest.name],
|
||||||
|
);
|
||||||
|
|
||||||
const updateSuggestions = (value: string) => {
|
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
|
||||||
const template = selectFromLastTemplate(value);
|
const { setCursorForValue } = autocomplete;
|
||||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
|
|
||||||
|
// 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<HTMLInputElement>) => {
|
const closeExpandedEditor = () => {
|
||||||
setInputValue(event.target.value);
|
setIsExpanded(false);
|
||||||
|
|
||||||
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 handleSuggestion = (value: string) => {
|
const saveExpandedEditor = (nextValue: string) => {
|
||||||
setInputValue((prev) => {
|
updateInputValue(nextValue);
|
||||||
const remaining = matchRemaining(prev as string, value);
|
autocomplete.setCursorForValue(nextValue, nextValue.length);
|
||||||
return prev + remaining;
|
setIsExpanded(false);
|
||||||
});
|
|
||||||
setShowSuggestions(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
|
<>
|
||||||
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
|
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
|
||||||
{showSuggestions && suggestions.length > 0 && (
|
<div className={cx([style.inputShell, fluid && style.fluid])}>
|
||||||
<ul className={style.suggestions}>
|
<BaseAutocomplete.Input
|
||||||
{suggestions.map((suggestion) => (
|
ref={setInputRef}
|
||||||
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
|
className={cx([
|
||||||
{suggestion}
|
inputStyle.input,
|
||||||
</li>
|
inputStyle[variant],
|
||||||
))}
|
inputStyle[height],
|
||||||
</ul>
|
fluid && inputStyle.fluid,
|
||||||
)}
|
style.input,
|
||||||
</div>
|
className,
|
||||||
|
])}
|
||||||
|
{...rest}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={autocomplete.updateCursor}
|
||||||
|
onFocus={autocomplete.updateCursor}
|
||||||
|
onKeyUp={autocomplete.updateCursor}
|
||||||
|
onSelect={autocomplete.updateCursor}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
aria-label='Expand template editor'
|
||||||
|
className={style.expandButton}
|
||||||
|
disabled={disabled || readOnly}
|
||||||
|
onClick={openExpandedEditor}
|
||||||
|
size='small'
|
||||||
|
title='Expand template editor'
|
||||||
|
variant='ghosted-white'
|
||||||
|
>
|
||||||
|
<IoExpandOutline />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</TemplateAutocompleteRoot>
|
||||||
|
<TemplateEditorModal
|
||||||
|
autocompleteList={autocompleteList}
|
||||||
|
isOpen={isExpanded}
|
||||||
|
onClose={closeExpandedEditor}
|
||||||
|
onSave={saveExpandedEditor}
|
||||||
|
value={inputValue}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
export default TemplateInput;
|
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
|
||||||
|
return (
|
||||||
|
<BaseAutocomplete.Root
|
||||||
|
items={autocomplete.suggestions}
|
||||||
|
autoHighlight
|
||||||
|
highlightItemOnHover
|
||||||
|
mode='none'
|
||||||
|
open={autocomplete.open}
|
||||||
|
value={value}
|
||||||
|
onOpenChange={autocomplete.setShowSuggestions}
|
||||||
|
onValueChange={autocomplete.handleValueChange}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TemplateSuggestionPopup />
|
||||||
|
</BaseAutocomplete.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
|
||||||
|
const expandedInputRef = useRef<HTMLTextAreaElement | null>(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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
showBackdrop
|
||||||
|
showCloseButton
|
||||||
|
title='Edit template'
|
||||||
|
bodyElements={
|
||||||
|
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
|
||||||
|
<BaseAutocomplete.Input
|
||||||
|
autoFocus
|
||||||
|
className={style.expandedEditor}
|
||||||
|
onClick={autocomplete.updateCursor}
|
||||||
|
onFocus={autocomplete.updateCursor}
|
||||||
|
onKeyUp={autocomplete.updateCursor}
|
||||||
|
onSelect={autocomplete.updateCursor}
|
||||||
|
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
|
||||||
|
/>
|
||||||
|
</TemplateAutocompleteRoot>
|
||||||
|
}
|
||||||
|
footerElements={
|
||||||
|
<>
|
||||||
|
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
|
||||||
|
<Button onClick={handleClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} variant='primary'>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateSuggestionPopup() {
|
||||||
|
return (
|
||||||
|
<BaseAutocomplete.Portal>
|
||||||
|
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
|
||||||
|
<BaseAutocomplete.Popup className={style.popup}>
|
||||||
|
<BaseAutocomplete.List className={style.list}>
|
||||||
|
<BaseAutocomplete.Collection>
|
||||||
|
{(suggestion: string) => (
|
||||||
|
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
|
||||||
|
{suggestion}
|
||||||
|
</BaseAutocomplete.Item>
|
||||||
|
)}
|
||||||
|
</BaseAutocomplete.Collection>
|
||||||
|
</BaseAutocomplete.List>
|
||||||
|
</BaseAutocomplete.Popup>
|
||||||
|
</BaseAutocomplete.Positioner>
|
||||||
|
</BaseAutocomplete.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+52
-1
@@ -1,4 +1,4 @@
|
|||||||
import { matchRemaining } from '../templateInput.utils';
|
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
|
||||||
|
|
||||||
describe('matchRemaining()', () => {
|
describe('matchRemaining()', () => {
|
||||||
it('should return a partial string needed for autocomplete', () => {
|
it('should return a partial string needed for autocomplete', () => {
|
||||||
@@ -15,3 +15,54 @@ describe('matchRemaining()', () => {
|
|||||||
expect(matchRemaining('test', 'test')).toBe('');
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+54
-8
@@ -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
|
* Returns the partial string b needed to autocomplete string a
|
||||||
* @example matchRemaining('te', 'test') -> 'st'
|
* @example matchRemaining('te', 'test') -> 'st'
|
||||||
@@ -123,13 +128,54 @@ export function matchRemaining(a: string, b: string) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
|
||||||
* Selects the last starting template in a string
|
const textBeforeCursor = text.slice(0, cursorIndex);
|
||||||
*/
|
const start = textBeforeCursor.lastIndexOf('{{');
|
||||||
export function selectFromLastTemplate(text: string) {
|
if (start === -1) {
|
||||||
const lastBraceIndex = text.lastIndexOf('{{');
|
return null;
|
||||||
if (lastBraceIndex !== -1) {
|
|
||||||
return text.slice(lastBraceIndex);
|
|
||||||
}
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -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<T extends TemplateElement>(
|
||||||
|
value: string,
|
||||||
|
autocompleteList: string[],
|
||||||
|
elementRef: RefObject<T | null>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -165,6 +165,25 @@ describe('parseOutput', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('parses message-secondary with a text value', () => {
|
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(
|
expect(
|
||||||
parseOutput({
|
parseOutput({
|
||||||
type: 'ontime',
|
type: 'ontime',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
|
|||||||
} else if (isHTTPOutput(payload)) {
|
} else if (isHTTPOutput(payload)) {
|
||||||
emitHTTP(payload, store);
|
emitHTTP(payload, store);
|
||||||
} else if (isOntimeAction(payload)) {
|
} else if (isOntimeAction(payload)) {
|
||||||
toOntimeAction(payload);
|
toOntimeAction(payload, store);
|
||||||
} else {
|
} else {
|
||||||
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
|
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,8 +225,6 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (maybeOntimeAction.action === 'message-secondary') {
|
if (maybeOntimeAction.action === 'message-secondary') {
|
||||||
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
|
|
||||||
|
|
||||||
// the secondary text is optional, an empty string is treated as no change
|
// the secondary text is optional, an empty string is treated as no change
|
||||||
let text: string | undefined = undefined;
|
let text: string | undefined = undefined;
|
||||||
if ('text' in maybeOntimeAction) {
|
if ('text' in maybeOntimeAction) {
|
||||||
@@ -234,6 +232,14 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
|||||||
text = indeterminateText(maybeOntimeAction.text);
|
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
|
// null is used to clear the secondary source
|
||||||
if (maybeOntimeAction.secondarySource === null) {
|
if (maybeOntimeAction.secondarySource === null) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { LogOrigin, OntimeAction } from 'ontime-types';
|
import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types';
|
||||||
import { parseUserTime } from 'ontime-utils';
|
import { parseUserTime } from 'ontime-utils';
|
||||||
|
import { DeepReadonly } from 'ts-essentials';
|
||||||
|
|
||||||
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';
|
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<RuntimeStore>) {
|
||||||
const actionType = action.action;
|
const actionType = action.action;
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
// Aux timer actions
|
// Aux timer actions
|
||||||
@@ -55,19 +57,25 @@ export function toOntimeAction(action: OntimeAction) {
|
|||||||
case 'message-set': {
|
case 'message-set': {
|
||||||
messageService.patch({
|
messageService.patch({
|
||||||
timer: {
|
timer: {
|
||||||
text: action.text,
|
text: action.text ? parseTemplateNested(action.text, store) : action.text,
|
||||||
visible: action.visible,
|
visible: action.visible,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'message-secondary': {
|
case 'message-secondary': {
|
||||||
messageService.patch({
|
const secondary = action.text ? parseTemplateNested(action.text, store) : action.text;
|
||||||
timer: {
|
const patch =
|
||||||
secondarySource: action.secondarySource,
|
action.secondarySource === undefined
|
||||||
},
|
? { secondary }
|
||||||
secondary: action.text,
|
: {
|
||||||
});
|
timer: {
|
||||||
|
secondarySource: action.secondarySource,
|
||||||
|
},
|
||||||
|
secondary,
|
||||||
|
};
|
||||||
|
|
||||||
|
messageService.patch(patch);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,6 @@ export type OntimeAction =
|
|||||||
| {
|
| {
|
||||||
type: 'ontime';
|
type: 'ontime';
|
||||||
action: OntimeMessageSecondary;
|
action: OntimeMessageSecondary;
|
||||||
secondarySource: SecondarySource;
|
secondarySource?: SecondarySource;
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user