refactor(automation): colocate the automation helpers and refresh on toggle

Two things from review.

The lifecycle labels and the output summariser were put in common/constants and
common/utils, where they read as generic abstractions the whole client can
build on. They are neither: both exist to describe an automation. The
architecture guide is explicit that common is for genuinely cross-feature code
and that feature-specific helpers stay with their owner even when another file
uses them, so both move next to the automations panel. Their one outside caller
is the event editor's trigger list, which is the same feature seen from the
rundown, and which already reaches into app-settings for its navigation helper.
common/constants is gone with them: it held nothing else, because this branch
invented it.

The automations list keeps its original wording for the disabled notice. What
was wrong was underneath it: the settings form saved the master switch and
never refetched, so every other part of the panel kept the stale answer until
the slow poll came round and turning automations on appeared to do nothing.
Every other form in this panel refetches after saving; this one now does too.

Verified in the running app: with automations off and one automation in the
list, flipping the switch and saving clears the notice in about 250ms rather
than waiting out the poll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
This commit is contained in:
Claude
2026-09-09 19:13:48 +00:00
parent 1ea9765092
commit 3d23d53555
9 changed files with 17 additions and 18 deletions
@@ -8,6 +8,7 @@ import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
@@ -32,6 +33,7 @@ export default function AutomationSettingsForm({
oscInputState,
isLoading,
}: AutomationSettingsProps) {
const { refetch } = useAutomationSettings();
const {
handleSubmit,
reset,
@@ -52,6 +54,10 @@ export default function AutomationSettingsForm({
try {
await editAutomationSettings(formData);
reset(formData);
// the rest of the panel reads these settings from the query, and the automations list
// greys itself out while they are off. Without this it keeps the stale answer until the
// slow poll comes round, so turning automations on appears to do nothing
await refetch();
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
@@ -6,16 +6,15 @@ import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import AutomationForm from './AutomationForm';
import { summariseOutputs } from './automationOutputs';
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog';
import NewAutomationDialog from './NewAutomationDialog';
import { getLifecycleLabel } from './timerLifecycle';
import style from './AutomationsList.module.scss';
@@ -40,7 +39,6 @@ export default function AutomationsList({
isLoading,
}: AutomationsListProps) {
const { refetch } = useAutomationSettings();
const { setLocation } = useAppSettingsNavigation();
const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
const [isPickingStart, setIsPickingStart] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
@@ -103,14 +101,9 @@ export default function AutomationsList({
<Panel.Section>
{enabledAutomations === false && (
<Info type='warning'>
<Info.Body>Automations are off, so nothing in this list will run.</Info.Body>
<Info.Footer>
{/* the master switch is at the top of the panel, out of sight once the list has rows */}
<Button size='small' onClick={() => setLocation('automation__settings')}>
Go to automation settings
</Button>
</Info.Footer>
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
</Info>
)}
@@ -6,8 +6,8 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import Info from '../../../../common/components/info/Info';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import * as Panel from '../../panel-utils/PanelUtils';
import { getLifecycleLabel } from './timerLifecycle';
interface DeleteAutomationDialogProps {
automation: Automation;
@@ -11,11 +11,10 @@ import Modal from '../../../../common/components/modal/Modal';
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { cx } from '../../../../common/utils/styleUtils';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { summariseOutputs } from './automationOutputs';
import {
automationRecipes,
defaultValues,
@@ -26,6 +25,7 @@ import {
type RecipeValues,
} from './automationRecipes';
import { makeTriggerTitle } from './automationUtils';
import { getLifecycleLabel } from './timerLifecycle';
import style from './NewAutomationDialog.module.scss';
@@ -0,0 +1,32 @@
import type { AutomationOutput } from 'ontime-types';
import { summariseOutputs } from '../automationOutputs';
describe('summariseOutputs', () => {
it('returns an empty list when there are no outputs', () => {
expect(summariseOutputs([])).toEqual([]);
});
it('counts repeated output kinds', () => {
const outputs: AutomationOutput[] = [
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' },
{ type: 'http', url: 'http://127.0.0.1/start' },
];
expect(summariseOutputs(outputs)).toEqual([
{ type: 'osc', label: 'OSC', count: 2 },
{ type: 'http', label: 'HTTP', count: 1 },
]);
});
it('presents kinds in a stable order regardless of insertion order', () => {
const outputs: AutomationOutput[] = [
{ type: 'ontime', action: 'aux1-start' },
{ type: 'http', url: 'http://127.0.0.1/start' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
];
expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']);
});
});
@@ -0,0 +1,32 @@
import type { AutomationOutput } from 'ontime-types';
const outputLabels: Record<AutomationOutput['type'], string> = {
osc: 'OSC',
http: 'HTTP',
ontime: 'Ontime',
};
export type OutputSummary = {
type: AutomationOutput['type'];
label: string;
count: number;
};
/**
* Summarises an automation's outputs by kind so that a list row can say what the
* automation does without the user having to open the form.
* Shared between the automation settings panel and the rundown event editor.
*/
export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] {
const counts = new Map<AutomationOutput['type'], number>();
for (const output of outputs) {
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
}
// keep a stable presentation order regardless of the order the user added outputs
const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime'];
return order
.filter((type) => counts.has(type))
.map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number }));
}
@@ -1,6 +1,6 @@
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { getLifecycleLabel, lifecycleLabels } from '../../../../common/constants/timerLifecycle';
import { getLifecycleLabel, lifecycleLabels } from './timerLifecycle';
/**
* Names a trigger created from an automation's lifecycle picker.
@@ -0,0 +1,25 @@
import { TimerLifeCycle } from 'ontime-types';
/**
* User facing labels for the timer lifecycle
* Shared between the automation settings and the rundown event editor
* so that a lifecycle is named the same everywhere it is shown
*/
export const lifecycleLabels: Record<TimerLifeCycle, string> = {
[TimerLifeCycle.onLoad]: 'On Load',
[TimerLifeCycle.onStart]: 'On Start',
[TimerLifeCycle.onPause]: 'On Pause',
[TimerLifeCycle.onStop]: 'On Stop',
[TimerLifeCycle.onClock]: 'Every second',
[TimerLifeCycle.onUpdate]: 'On Timer Update',
[TimerLifeCycle.onFinish]: 'On Finish',
[TimerLifeCycle.onWarning]: 'On Warning',
[TimerLifeCycle.onDanger]: 'On Danger',
};
/**
* Resolves a lifecycle to its user facing label, falling back to the raw value
*/
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
}