mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 10:23:54 +00:00
refactor(automation): trim the branch to reduce risk
Adoption is low, and the app must not be put at risk fixing that. A measured look at the diff showed the risk was concentrated in exactly two places: the timer's hot path and a new file-writing route. Everything that actually makes automations discoverable — recipes, a panel that explains itself, one-step creation, Test buttons that report — is client-only settings-panel code with zero server churn. This cuts the two pieces that weren't, and simplifies two more that only existed to serve them. Removed entirely: templates. Newest, riskiest, and the least connected to the adoption problem — it helps someone who already uses automations share them, not someone who has never tried the feature. It was also the source of two of the bugs found in review. Removed: the websocket broadcast behind "last fired". reportFired ran inside triggerAutomations, called from onClock every second and from onUpdate. A new protocol message pushed to every connected client, including stage displays, up to once a second per automation, is real new traffic on the busiest code path in the app for a feature still trying to prove it's worth using. The log line stays: it's additive, throttled, nowhere near the hot path once written, and answers the same question — did this run — without a new message. Trimmed: the demo goes from two automations tied together by necessity — a danger-time message and a second automation whose only job was undoing the first on finish — to one, attached by an event-level trigger instead of a global one. Same two discovery wins, an automation visibly fires on Play and per-event triggers are found by opening an event, with no message mutation and nothing to keep in sync. That pairing was also the first thing review found broken. Trimmed: the delete dialog no longer deletes blocking triggers and restores them if the automation still won't delete. That rollback was the other multi-request destructive sequence review found a bug in. It now confirms, names what's blocking, and says to remove global triggers from the Global Triggers list first — a single always-safe request the user already has. What stays: one-step creation (lifecycles picked on the automation form), the recipe library, and the panel legibility work — none of it touches the server or the runtime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
@@ -1,12 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
DatabaseModel,
|
||||
MessageResponse,
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
QuickStartData,
|
||||
TemplateSection,
|
||||
} from 'ontime-types';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
@@ -124,24 +117,6 @@ export async function duplicateProject(filename: string, newFilename: string): P
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a template: a project file holding only the selected sections
|
||||
* The result is not loaded, the current project stays as it is
|
||||
*/
|
||||
export async function partialDuplicateProject(
|
||||
filename: string,
|
||||
newFilename: string,
|
||||
sections: TemplateSection[],
|
||||
): Promise<{ filename: string }> {
|
||||
const url = `${dbPath}/${filename}/partial-duplicate`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.post(decodedUrl, {
|
||||
newFilename,
|
||||
sections,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to rename a project file
|
||||
*/
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { TimerLifeCycle } from 'ontime-types';
|
||||
import { useStore } from 'zustand';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
|
||||
type FiredRecord = { at: number; cycle: TimerLifeCycle };
|
||||
|
||||
type AutomationFiredStore = {
|
||||
fired: Record<string, FiredRecord>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks when each automation last ran.
|
||||
* Ephemeral by design: this is runtime feedback for the settings panel, not project data,
|
||||
* and it is fed by a socket message the server already coalesces to once a second per automation
|
||||
*/
|
||||
const automationFired = createStore<AutomationFiredStore>(() => ({
|
||||
fired: {},
|
||||
}));
|
||||
|
||||
export const useAutomationFired = () => useStore(automationFired);
|
||||
|
||||
export const addAutomationFired = (automationId: string, cycle: TimerLifeCycle) =>
|
||||
automationFired.setState((state) => ({
|
||||
fired: { ...state.fired, [automationId]: { at: Date.now(), cycle } },
|
||||
}));
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from '../api/constants';
|
||||
import { invalidateAllCaches } from '../api/utils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import { addAutomationFired } from '../stores/automationFired';
|
||||
import {
|
||||
getClientId,
|
||||
getClientName,
|
||||
@@ -174,10 +173,6 @@ export const connectSocket = () => {
|
||||
addLog(payload as Log);
|
||||
break;
|
||||
}
|
||||
case MessageTag.AutomationFired: {
|
||||
addAutomationFired(payload.automationId, payload.cycle);
|
||||
break;
|
||||
}
|
||||
case MessageTag.RuntimeData: {
|
||||
patchRuntime(payload);
|
||||
updateDevTools(payload);
|
||||
|
||||
-5
@@ -1,8 +1,3 @@
|
||||
.muted {
|
||||
color: $muted-gray;
|
||||
}
|
||||
|
||||
.lastFired {
|
||||
font-size: $aux-text-size;
|
||||
color: $green-400;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoShareOutline, IoSparklesOutline, IoTrash } from 'react-icons/io5';
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoSparklesOutline, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import { PROJECT_LIST } from '../../../../common/api/constants';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
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 { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||
import { ontimeQueryClient } from '../../../../common/queryClient';
|
||||
import { useAutomationFired } from '../../../../common/stores/automationFired';
|
||||
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||
import ProjectPartialCloneForm from '../project-panel/ProjectPartialCloneForm';
|
||||
import AutomationForm from './AutomationForm';
|
||||
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
|
||||
import DeleteAutomationDialog from './DeleteAutomationDialog';
|
||||
@@ -47,12 +40,7 @@ export default function AutomationsList({
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [showRecipes, setShowRecipes] = useState(false);
|
||||
const [showTemplateForm, setShowTemplateForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const {
|
||||
data: { lastLoadedProject },
|
||||
} = useOrderedProjectList();
|
||||
|
||||
/**
|
||||
* A recipe lands in the editor rather than only in the list.
|
||||
@@ -71,7 +59,6 @@ export default function AutomationsList({
|
||||
};
|
||||
|
||||
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
|
||||
const { fired } = useAutomationFired();
|
||||
|
||||
const arrayAutomations = Object.keys(automations);
|
||||
|
||||
@@ -100,44 +87,11 @@ export default function AutomationsList({
|
||||
blockingTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onDeleted={handleDeleted}
|
||||
onRefetch={refetch}
|
||||
/>
|
||||
)}
|
||||
{showTemplateForm && (
|
||||
<ProjectPartialCloneForm
|
||||
fileName={lastLoadedProject}
|
||||
preselected={['automation']}
|
||||
onClose={() => setShowTemplateForm(false)}
|
||||
onCreated={async () => {
|
||||
// do not rely on the project panel refetching when it mounts
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
||||
setLocation('project__list');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<DropdownMenu
|
||||
render={<Button />}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Save automations as template',
|
||||
description: 'A small project with only your automations, to reuse or share',
|
||||
disabled: arrayAutomations.length === 0 || lastLoadedProject === '',
|
||||
onClick: () => setShowTemplateForm(true),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Load from a template',
|
||||
description: 'Bring automations in from another project file',
|
||||
onClick: () => setLocation('project__list'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
Share <IoShareOutline />
|
||||
</DropdownMenu>
|
||||
<Button onClick={() => setShowRecipes(true)}>
|
||||
Browse recipes <IoSparklesOutline />
|
||||
</Button>
|
||||
@@ -160,11 +114,10 @@ export default function AutomationsList({
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30%' }}>Title</th>
|
||||
<th style={{ width: '22%' }}>Runs on</th>
|
||||
<th style={{ width: '13%' }}>Filter rule</th>
|
||||
<th style={{ width: '35%' }}>Title</th>
|
||||
<th style={{ width: '25%' }}>Runs on</th>
|
||||
<th style={{ width: '15%' }}>Filter rule</th>
|
||||
<th style={{ width: '15%' }}>Sends</th>
|
||||
<th style={{ width: '12%' }}>Last fired</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -220,9 +173,6 @@ export default function AutomationsList({
|
||||
))
|
||||
)}
|
||||
</Panel.InlineElements>
|
||||
<td>
|
||||
<LastFired at={fired[automationId]?.at} />
|
||||
</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
@@ -250,47 +200,3 @@ export default function AutomationsList({
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows how long ago an automation last ran.
|
||||
* An automation that never ticks while its neighbours do is the clearest signal
|
||||
* that something upstream of it, a filter or a trigger, is wrong
|
||||
*/
|
||||
function LastFired({ at }: { at?: number }) {
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// once the label counts whole minutes it only changes once a minute, so drop to that
|
||||
// cadence instead of holding a 1Hz timer per automation for the life of the panel
|
||||
const isRecent = at !== undefined && Date.now() - at < millisPerMinute;
|
||||
|
||||
useEffect(() => {
|
||||
if (at === undefined) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => setTick((value) => value + 1), isRecent ? 1000 : millisPerMinute);
|
||||
return () => clearInterval(interval);
|
||||
}, [at, isRecent]);
|
||||
|
||||
if (at === undefined) {
|
||||
return <span className={style.muted}>—</span>;
|
||||
}
|
||||
|
||||
return <span className={style.lastFired}>{formatElapsed(Date.now() - at)}</span>;
|
||||
}
|
||||
|
||||
const millisPerMinute = 60 * 1000;
|
||||
|
||||
function formatElapsed(elapsed: number): string {
|
||||
const seconds = Math.max(0, Math.floor(elapsed / 1000));
|
||||
if (seconds < 5) {
|
||||
return 'just now';
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
return `${Math.floor(minutes / 60)}h ago`;
|
||||
}
|
||||
|
||||
+9
-44
@@ -1,7 +1,7 @@
|
||||
import type { Automation, Trigger } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { addTrigger, deleteAutomation, deleteTrigger } from '../../../../common/api/automation';
|
||||
import { deleteAutomation } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||
@@ -15,65 +15,32 @@ interface DeleteAutomationDialogProps {
|
||||
blockingTriggers: Trigger[];
|
||||
onCancel: () => void;
|
||||
onDeleted: () => void;
|
||||
/** pulls fresh settings after a rollback, so the restored triggers carry their new ids */
|
||||
onRefetch: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server refuses to delete an automation that is still referenced, and the panel used to
|
||||
* dump that refusal into a stray row under the table. Global triggers are ours to clean up, so
|
||||
* we offer to do it. A reference from a rundown event is not: editing rundown data from a
|
||||
* settings screen would be a surprising, hard to undo action, so that stays a block with an
|
||||
* explanation of where to go.
|
||||
* dump that refusal into a stray row under the table. This dialog confirms first and, on a
|
||||
* refusal, names what is blocking it: global triggers to remove from the Global Triggers list,
|
||||
* or an event reference to remove from the event editor. It does not delete those triggers for
|
||||
* the user — a single extra step there is safer than a delete-then-restore sequence here.
|
||||
*/
|
||||
export default function DeleteAutomationDialog({
|
||||
automation,
|
||||
blockingTriggers,
|
||||
onCancel,
|
||||
onDeleted,
|
||||
onRefetch,
|
||||
}: DeleteAutomationDialogProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
/** set only in the rare case where we could not undo our own trigger deletions */
|
||||
const [removeFailed, setRemoveFailed] = useState(false);
|
||||
|
||||
/**
|
||||
* Puts back triggers we deleted before the automation turned out to be undeletable.
|
||||
* The new triggers get fresh ids, which nothing outside this panel holds on to
|
||||
*/
|
||||
const restoreTriggers = async (removed: Trigger[]) => {
|
||||
for (const trigger of removed) {
|
||||
try {
|
||||
await addTrigger({ title: trigger.title, trigger: trigger.trigger, automationId: trigger.automationId });
|
||||
} catch (_error) {
|
||||
setRemoveFailed(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null);
|
||||
setIsDeleting(true);
|
||||
|
||||
// the server reports the trigger references first, so an event reference only surfaces
|
||||
// once the triggers are gone. Track them so a refusal does not cost the user their triggers
|
||||
const removed: Trigger[] = [];
|
||||
|
||||
try {
|
||||
for (const trigger of blockingTriggers) {
|
||||
await deleteTrigger(trigger.id);
|
||||
removed.push(trigger);
|
||||
}
|
||||
await deleteAutomation(automation.id);
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
if (removed.length > 0) {
|
||||
await restoreTriggers(removed);
|
||||
// the restored triggers have new ids, the dialog needs them before a second attempt
|
||||
await onRefetch();
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -96,14 +63,15 @@ export default function DeleteAutomationDialog({
|
||||
<Info type='warning'>
|
||||
<Info.Title>
|
||||
{blockingTriggers.length === 1
|
||||
? 'One trigger will be deleted with it'
|
||||
: `${blockingTriggers.length} triggers will be deleted with it`}
|
||||
? 'One trigger points at this automation'
|
||||
: `${blockingTriggers.length} triggers point at this automation`}
|
||||
</Info.Title>
|
||||
<Info.Body>
|
||||
{blockingTriggers
|
||||
.map((trigger) => `${trigger.title} (${getLifecycleLabel(trigger.trigger)})`)
|
||||
.join(', ')}
|
||||
</Info.Body>
|
||||
<Info.Footer>Remove them from Global Triggers first, then delete the automation.</Info.Footer>
|
||||
</Info>
|
||||
)}
|
||||
|
||||
@@ -113,9 +81,6 @@ export default function DeleteAutomationDialog({
|
||||
<Info.Body>{error}</Info.Body>
|
||||
<Info.Footer>
|
||||
Automations attached to a single event have to be removed from that event first, in the event editor.
|
||||
{removeFailed
|
||||
? ' Your triggers could not be put back, you will need to recreate them.'
|
||||
: ' Nothing was deleted.'}
|
||||
</Info.Footer>
|
||||
</Info>
|
||||
)}
|
||||
@@ -127,7 +92,7 @@ export default function DeleteAutomationDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
|
||||
{blockingTriggers.length > 0 ? 'Delete triggers and automation' : 'Delete'}
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -25,11 +25,10 @@ import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||
import ProjectMergeForm from './ProjectMergeForm';
|
||||
import ProjectPartialCloneForm from './ProjectPartialCloneForm';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export type EditMode = 'rename' | 'duplicate' | 'merge' | 'template' | null;
|
||||
export type EditMode = 'rename' | 'duplicate' | 'merge' | null;
|
||||
|
||||
interface ProjectListItemProps {
|
||||
current?: boolean;
|
||||
@@ -121,7 +120,6 @@ export default function ProjectListItem({
|
||||
const isCurrentlyBeingEdited = filename === editingFilename;
|
||||
const showProjectForm = (editingMode === 'rename' || editingMode === 'duplicate') && filename === editingFilename;
|
||||
const showMergeForm = editingMode === 'merge' && isCurrentlyBeingEdited;
|
||||
const showTemplateForm = editingMode === 'template' && isCurrentlyBeingEdited;
|
||||
const classes = cx([current && !isCurrentlyBeingEdited && style.current, isCurrentlyBeingEdited && style.isEditing]);
|
||||
|
||||
return (
|
||||
@@ -154,16 +152,14 @@ export default function ProjectListItem({
|
||||
onChangeEditMode={handleToggleEditMode}
|
||||
onDelete={() => setDeleteOpen(true)}
|
||||
onLoad={handleLoad}
|
||||
isDisabled={loading || showMergeForm || showTemplateForm}
|
||||
isDisabled={loading || showMergeForm}
|
||||
onMerge={(filename) => handleToggleEditMode('merge', filename)}
|
||||
onSaveAsTemplate={(filename) => handleToggleEditMode('template', filename)}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
|
||||
{showTemplateForm && <ProjectPartialCloneForm onClose={handleCancel} onCreated={onRefetch} fileName={filename} />}
|
||||
<Dialog
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
@@ -194,10 +190,9 @@ interface ActionMenuProps {
|
||||
onDelete: () => void;
|
||||
onLoad: (filename: string) => Promise<void>;
|
||||
onMerge: (filename: string) => void;
|
||||
onSaveAsTemplate: (filename: string) => void;
|
||||
}
|
||||
function ActionMenu(props: ActionMenuProps) {
|
||||
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge, onSaveAsTemplate } = props;
|
||||
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge } = props;
|
||||
|
||||
const handleRename = () => {
|
||||
onChangeEditMode('rename', filename);
|
||||
@@ -232,13 +227,6 @@ function ActionMenu(props: ActionMenuProps) {
|
||||
},
|
||||
{ type: 'item', icon: IoPencilOutline, label: 'Rename', onClick: handleRename },
|
||||
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
|
||||
{
|
||||
type: 'item',
|
||||
icon: IoCopyOutline,
|
||||
label: 'Save as template',
|
||||
description: 'A new project with only the parts you pick',
|
||||
onClick: () => onSaveAsTemplate(filename),
|
||||
},
|
||||
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
|
||||
{ type: 'divider' },
|
||||
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: onDelete, disabled: current },
|
||||
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
import type { TemplateSection } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { partialDuplicateProject } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import { removeFileExtension } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
const formId = 'project-partial-clone-form';
|
||||
|
||||
type CloneFormValues = Record<TemplateSection, boolean> & { filename: string };
|
||||
|
||||
const sectionCopy: Array<{ key: TemplateSection; title: string; body: string }> = [
|
||||
{ key: 'project', title: 'Project data', body: 'Core project metadata and settings.' },
|
||||
{ key: 'rundowns', title: 'Rundown + Custom Fields', body: 'All rundowns and any associated custom fields.' },
|
||||
{ key: 'customFields', title: 'Custom Fields', body: 'Custom field definitions on their own.' },
|
||||
{ key: 'viewSettings', title: 'View Settings', body: 'View configuration, and display preferences.' },
|
||||
{ key: 'urlPresets', title: 'URL Presets', body: 'Saved links and preset launch parameters.' },
|
||||
{ key: 'automation', title: 'Automation Settings', body: 'Automations and the triggers that run them.' },
|
||||
];
|
||||
|
||||
interface ProjectPartialCloneFormProps {
|
||||
onClose: () => void;
|
||||
/** receives the name the file was actually given, which can differ if the name was taken */
|
||||
onCreated: (createdFilename: string) => Promise<void>;
|
||||
fileName: string;
|
||||
/** sections switched on when the form opens */
|
||||
preselected?: TemplateSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a template: a new project file holding only the selected sections of this one.
|
||||
* The inverse of the partial load in ProjectMergeForm, and the pair of them is what lets a
|
||||
* user share, say, a set of automations without handing over an entire show.
|
||||
*/
|
||||
export default function ProjectPartialCloneForm({
|
||||
onClose,
|
||||
onCreated,
|
||||
fileName,
|
||||
preselected,
|
||||
}: ProjectPartialCloneFormProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<CloneFormValues>({
|
||||
defaultValues: {
|
||||
filename: `${removeFileExtension(fileName)} template`,
|
||||
project: preselected?.includes('project') ?? false,
|
||||
rundowns: preselected?.includes('rundowns') ?? false,
|
||||
customFields: preselected?.includes('customFields') ?? false,
|
||||
viewSettings: preselected?.includes('viewSettings') ?? false,
|
||||
urlPresets: preselected?.includes('urlPresets') ?? false,
|
||||
automation: preselected?.includes('automation') ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = async (values: CloneFormValues) => {
|
||||
const sections = sectionCopy.map(({ key }) => key).filter((key) => values[key]);
|
||||
|
||||
if (sections.length === 0) {
|
||||
setError('At least one section must be selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const { filename } = await partialDuplicateProject(fileName, values.filename, sections);
|
||||
await onCreated(filename);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title='Save as template'
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(handleCreate)}>
|
||||
<Panel.Section className={style.mergeBody}>
|
||||
<div className={style.mergeIntro}>
|
||||
<Panel.Description>
|
||||
Create a new project containing only the selected parts of{' '}
|
||||
<span className={style.sourceFile}>{`"${fileName}"`}</span>. Use it as a starting point for new shows,
|
||||
or share it with someone who needs the same setup.
|
||||
</Panel.Description>
|
||||
<div className={style.mergeSummary}>
|
||||
The current project is not changed and stays loaded. Apply a template to another project with Partial
|
||||
Load.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Template name
|
||||
<Input
|
||||
{...register('filename', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Automations template'
|
||||
/>
|
||||
<Panel.Error>{errors.filename?.message}</Panel.Error>
|
||||
</label>
|
||||
|
||||
<Panel.ListGroup className={style.optionList}>
|
||||
{sectionCopy.map(({ key, title, body }) => (
|
||||
<Panel.ListItem key={key}>
|
||||
<label className={style.optionRow}>
|
||||
<div className={style.optionCopy}>
|
||||
<span className={style.optionTitle}>{title}</span>
|
||||
<span className={style.optionBody}>{body}</span>
|
||||
</div>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch(key)}
|
||||
onCheckedChange={(value: boolean) => setValue(key, value, { shouldDirty: true })}
|
||||
/>
|
||||
</label>
|
||||
</Panel.ListItem>
|
||||
))}
|
||||
</Panel.ListGroup>
|
||||
|
||||
<Info>
|
||||
<Info.Body>
|
||||
Automations attached to individual events live in the rundown, so the two halves travel separately. A
|
||||
template with only automations leaves behind the events that point at them, and a template with only
|
||||
rundowns carries events pointing at automations it does not include.
|
||||
</Info.Body>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<div className={style.footerContent}>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<div className={style.footerActions}>
|
||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} loading={isSubmitting} variant='primary'>
|
||||
Create template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { socket } from '../../../adapters/WebsocketAdapter.js';
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
|
||||
import { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
@@ -653,12 +652,10 @@ describe('testConditions()', () => {
|
||||
*/
|
||||
describe('automation reporting', () => {
|
||||
let logSpy = vi.spyOn(logger, 'info');
|
||||
let socketSpy = vi.spyOn(socket, 'sendAsJson');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
|
||||
logSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
|
||||
socketSpy = vi.spyOn(socket, 'sendAsJson').mockImplementation(() => {});
|
||||
|
||||
await deleteAllTriggers();
|
||||
resetAutomationLogState();
|
||||
@@ -753,18 +750,4 @@ describe('automation reporting', () => {
|
||||
triggerAutomations(TimerLifeCycle.onDanger);
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports a fire to the clients at most once a second, including on continuous lifecycles', async () => {
|
||||
vi.useFakeTimers();
|
||||
await bind('reporting-clock', TimerLifeCycle.onClock);
|
||||
socketSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
expect(socketSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
expect(socketSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type AutomationOutput,
|
||||
type FilterRule,
|
||||
LogOrigin,
|
||||
MessageTag,
|
||||
RuntimeStore,
|
||||
TimerLifeCycle,
|
||||
isHTTPOutput,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
} from 'ontime-types';
|
||||
import { getPropertyFromPath } from 'ontime-utils';
|
||||
|
||||
import { socket } from '../../adapters/WebsocketAdapter.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { isOntimeCloud } from '../../setup/environment.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
@@ -37,8 +35,6 @@ const reportThrottleMs = 1000;
|
||||
const suppressionNotices = new Set<string>();
|
||||
/** last time we logged a given automation + cycle pair */
|
||||
const lastLoggedAt = new Map<string, number>();
|
||||
/** last time we told the clients about a given automation */
|
||||
const lastReportedAt = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Clears the reporting state.
|
||||
@@ -48,7 +44,6 @@ const lastReportedAt = new Map<string, number>();
|
||||
export function resetAutomationLogState() {
|
||||
suppressionNotices.clear();
|
||||
lastLoggedAt.clear();
|
||||
lastReportedAt.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,19 +110,11 @@ function fireForCycle(cycle: TimerLifeCycle) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a successful automation visible, which it previously was not:
|
||||
* the log answers what happened, the socket message answers whether an automation is alive
|
||||
* Makes a successful automation fire visible in the log, which it previously was not
|
||||
*/
|
||||
function reportFired(automationId: string, automation: Automation, cycle: TimerLifeCycle) {
|
||||
const now = Date.now();
|
||||
|
||||
// the panel shows a last fired time, so continuous lifecycles still report, but at most once a second
|
||||
const lastReported = lastReportedAt.get(automationId);
|
||||
if (lastReported === undefined || now - lastReported >= reportThrottleMs) {
|
||||
lastReportedAt.set(automationId, now);
|
||||
socket.sendAsJson(MessageTag.AutomationFired, { automationId, cycle });
|
||||
}
|
||||
|
||||
if (continuousCycles.includes(cycle)) {
|
||||
// one notice per load is enough to explain why the log goes quiet from here
|
||||
if (!suppressionNotices.has(automationId)) {
|
||||
|
||||
@@ -231,30 +231,6 @@ export async function loadDemo(_req: Request, res: Response<MessageResponse | Er
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a template: a new project file containing only the selected sections of an existing one.
|
||||
* The result is not loaded, so making a template does not disturb the running show.
|
||||
*/
|
||||
export async function partialDuplicateProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
||||
const { filename } = req.params;
|
||||
const { newFilename, sections } = req.body;
|
||||
|
||||
try {
|
||||
// the created name can differ from what was asked for, generateUniqueFileName resolves collisions
|
||||
const created = await projectService.createProjectFromSections(filename, newFilename, sections);
|
||||
|
||||
res.status(201).send({ filename: created });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (message.startsWith('Project file')) {
|
||||
res.status(403).send({ message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates a project file.
|
||||
* Receives the original project filename (`filename`) from the request parameters
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
listProjects,
|
||||
loadDemo,
|
||||
loadProject,
|
||||
partialDuplicateProjectFile,
|
||||
patchPartialProjectFile,
|
||||
postProjectFile,
|
||||
projectDownload,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
validateNewProject,
|
||||
validatePatchProject,
|
||||
validateQuickProject,
|
||||
validateSectionsBody,
|
||||
} from './db.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
@@ -41,12 +39,5 @@ router.get('/all', listProjects);
|
||||
router.post('/load', validateFilenameBody, loadProject);
|
||||
router.post('/demo', loadDemo);
|
||||
router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
|
||||
router.post(
|
||||
'/:filename/partial-duplicate',
|
||||
validateFilenameParam,
|
||||
validateNewFilenameBody,
|
||||
validateSectionsBody,
|
||||
partialDuplicateProjectFile,
|
||||
);
|
||||
router.put('/:filename/rename', validateFilenameParam, validateNewFilenameBody, renameProjectFile);
|
||||
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { body, param } from 'express-validator';
|
||||
import { isTemplateSection, templateSections } from 'ontime-types';
|
||||
import sanitize from 'sanitize-filename';
|
||||
|
||||
import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
||||
@@ -68,21 +67,6 @@ export const validateNewFilenameBody = [
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates a request to clone selected sections of a project into a template.
|
||||
*/
|
||||
export const validateSectionsBody = [
|
||||
body('sections')
|
||||
.isArray({ min: 1 })
|
||||
.withMessage(`Select at least one of: ${templateSections.join(', ')}`)
|
||||
.custom((sections: unknown[]) =>
|
||||
sections.every((section) => typeof section === 'string' && isTemplateSection(section)),
|
||||
)
|
||||
.withMessage(`Sections must be any of: ${templateSections.join(', ')}`),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates request with filename in the body.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseModel, OntimeView, TimerLifeCycle } from 'ontime-types';
|
||||
import { DatabaseModel, OntimeView } from 'ontime-types';
|
||||
|
||||
import { backstageRundown, broadcastRundown, stageRundown } from './demoRundowns.js';
|
||||
|
||||
@@ -77,12 +77,14 @@ export const demoDb: DatabaseModel = {
|
||||
},
|
||||
},
|
||||
/**
|
||||
* The demo ships with working automations so the engine is visible the first time
|
||||
* The demo ships with a working automation so the engine is visible the first time
|
||||
* someone presses Play, rather than hidden behind an empty settings panel.
|
||||
*
|
||||
* Everything that actually fires is an Ontime action: the demo must not put traffic
|
||||
* on whatever network it happens to be opened on. The OSC entry is there to be read
|
||||
* and edited, and is deliberately left without a trigger.
|
||||
* It fires an Ontime action: the demo must not put traffic on whatever network it
|
||||
* happens to be opened on. It is attached via an event-level trigger rather than a
|
||||
* global one, so per-event triggers are also discoverable by browsing the rundown
|
||||
* instead of reading docs. The OSC entry is there to be read and edited, and is
|
||||
* deliberately left without a trigger of its own.
|
||||
*
|
||||
* The ids are hand written and must match the map keys. Ids are only generated for
|
||||
* automations created through the DAO, so literals are safe here.
|
||||
@@ -92,20 +94,7 @@ export const demoDb: DatabaseModel = {
|
||||
// never open a listening socket without the user asking for it
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [
|
||||
{
|
||||
id: 'demo-trigger-aux',
|
||||
title: 'Demo: aux timer on start',
|
||||
trigger: TimerLifeCycle.onStart,
|
||||
automationId: 'demo-aux-timer',
|
||||
},
|
||||
{
|
||||
id: 'demo-trigger-clear',
|
||||
title: 'Demo: clear the wrap up warning',
|
||||
trigger: TimerLifeCycle.onFinish,
|
||||
automationId: 'demo-clear-message',
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
automations: {
|
||||
'demo-aux-timer': {
|
||||
id: 'demo-aux-timer',
|
||||
@@ -117,23 +106,6 @@ export const demoDb: DatabaseModel = {
|
||||
{ type: 'ontime', action: 'aux1-start' },
|
||||
],
|
||||
},
|
||||
'demo-danger-message': {
|
||||
id: 'demo-danger-message',
|
||||
title: 'Demo: warn the stage at danger',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
// self labelled, so nobody mistakes it for something Ontime does on its own
|
||||
outputs: [{ type: 'ontime', action: 'message-set', text: 'Demo automation: please wrap up', visible: true }],
|
||||
},
|
||||
'demo-clear-message': {
|
||||
id: 'demo-clear-message',
|
||||
title: 'Demo: clear the wrap up warning',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
// the pair to the warning above. Without it the message would stay on the stage
|
||||
// timer for the rest of the session, blanking the countdown on every later event
|
||||
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
|
||||
},
|
||||
'demo-osc-example': {
|
||||
id: 'demo-osc-example',
|
||||
title: 'Demo: OSC to a lighting console (example, not wired up)',
|
||||
|
||||
@@ -125,9 +125,9 @@ export const stageRundown: Rundown = {
|
||||
triggers: [
|
||||
{
|
||||
id: 'demo-event-trigger',
|
||||
title: 'Wrap up warning',
|
||||
trigger: TimerLifeCycle.onDanger,
|
||||
automationId: 'demo-danger-message',
|
||||
title: 'Aux timer with the event',
|
||||
trigger: TimerLifeCycle.onStart,
|
||||
automationId: 'demo-aux-timer',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { copyFile, writeFile } from 'fs/promises';
|
||||
import { copyFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse, TemplateSection } from 'ontime-types';
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
|
||||
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
|
||||
|
||||
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
|
||||
@@ -9,7 +9,7 @@ import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
|
||||
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
||||
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
||||
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||
import { flushPendingWrites, getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
||||
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
||||
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { makeNewProject } from '../../models/dataModel.js';
|
||||
@@ -318,60 +318,6 @@ export async function createProjectWithPatch(fileName: string, initialData: Part
|
||||
return createProject(fileName, sanitisedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new project file containing only the given sections of an existing one.
|
||||
* This is how a user makes a template: a small project holding, say, only automations
|
||||
* or only custom fields, which can then be shared and applied with a partial load.
|
||||
*
|
||||
* Unlike createProjectWithPatch, this does NOT load the result. Saving a template must
|
||||
* not pull the operator out of the show they are running.
|
||||
*
|
||||
* @throws if the source does not exist or cannot be parsed
|
||||
*/
|
||||
export async function createProjectFromSections(
|
||||
sourceFilename: string,
|
||||
newFilename: string,
|
||||
sections: TemplateSection[],
|
||||
): Promise<string> {
|
||||
const projectFilePath = doesProjectExist(sourceFilename);
|
||||
if (projectFilePath === null) {
|
||||
throw new Error('Project file not found');
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
throw new Error('At least one section must be selected');
|
||||
}
|
||||
|
||||
// writes are debounced, and the natural flow here is "make some automations, then save them
|
||||
// as a template". Without this the template silently misses anything from the last few seconds
|
||||
await flushPendingWrites();
|
||||
|
||||
const fileData = await parseJsonFile(projectFilePath);
|
||||
const { data } = parseDatabaseModel(fileData);
|
||||
|
||||
const patch: Partial<DatabaseModel> = {};
|
||||
for (const section of sections) {
|
||||
// a rundown without its custom fields is not usable on the other side
|
||||
if (section === 'rundowns') {
|
||||
patch.customFields = data.customFields;
|
||||
}
|
||||
Object.assign(patch, { [section]: data[section] });
|
||||
}
|
||||
|
||||
const template = safeMerge(makeNewProject(), patch);
|
||||
|
||||
// makeNewProject seeds an empty rundown and safeMerge merges rundowns by key, so without
|
||||
// this the template would ship a phantom "Default" rundown alongside the real ones
|
||||
if (patch.rundowns !== undefined) {
|
||||
template.rundowns = patch.rundowns;
|
||||
}
|
||||
|
||||
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(newFilename));
|
||||
await writeFile(getPathToProject(fileNameWithExtension), JSON.stringify(template, null, 2), 'utf-8');
|
||||
|
||||
return fileNameWithExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a project file
|
||||
*/
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { writeFile } from 'fs/promises';
|
||||
|
||||
import { OntimeView, TimerLifeCycle } from 'ontime-types';
|
||||
import { Mock } from 'vitest';
|
||||
|
||||
import { makeNewProject } from '../../../models/dataModel.js';
|
||||
import { isLastLoadedProject } from '../../app-state-service/AppStateService.js';
|
||||
import {
|
||||
createProjectFromSections,
|
||||
deleteProjectFile,
|
||||
duplicateProjectFile,
|
||||
renameProjectFile,
|
||||
} from '../ProjectService.js';
|
||||
import { doesProjectExist, parseJsonFile } from '../projectServiceUtils.js';
|
||||
import { deleteProjectFile, duplicateProjectFile, renameProjectFile } from '../ProjectService.js';
|
||||
import { doesProjectExist } from '../projectServiceUtils.js';
|
||||
|
||||
// stop the database loading from initiating
|
||||
vi.mock('../../../setup/loadDb.js', () => {
|
||||
@@ -26,13 +17,7 @@ vi.mock('../../app-state-service/AppStateService.js', () => ({
|
||||
|
||||
vi.mock('../projectServiceUtils.js', () => ({
|
||||
doesProjectExist: vi.fn(),
|
||||
getPathToProject: vi.fn().mockImplementation((name: string) => `/projects/${name}`),
|
||||
parseJsonFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('fs/promises')>()),
|
||||
writeFile: vi.fn(),
|
||||
getPathToProject: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -80,54 +65,3 @@ describe('renameProjectFile', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProjectFromSections', () => {
|
||||
it('throws an error if origin project does not exist', async () => {
|
||||
(doesProjectExist as Mock).mockReturnValue(null);
|
||||
await expect(createProjectFromSections('does not exist', 'template', ['automation'])).rejects.toThrow(
|
||||
'Project file not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error if nothing was selected', async () => {
|
||||
(doesProjectExist as Mock).mockReturnValue('/projects/source.json');
|
||||
await expect(createProjectFromSections('source.json', 'template', [])).rejects.toThrow(
|
||||
'At least one section must be selected',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a project carrying only the selected sections', async () => {
|
||||
(doesProjectExist as Mock).mockReturnValue('/projects/source.json');
|
||||
(parseJsonFile as Mock).mockResolvedValue({
|
||||
...makeNewProject(),
|
||||
urlPresets: [{ target: OntimeView.Timer, enabled: true, alias: 'from-source', search: '', displayInNav: false }],
|
||||
automation: {
|
||||
enabledAutomations: true,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [{ id: 't1', title: 'on start', trigger: TimerLifeCycle.onStart, automationId: 'a1' }],
|
||||
automations: {
|
||||
a1: {
|
||||
id: 'a1',
|
||||
title: 'from source',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1/go' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createProjectFromSections('source.json', 'template.json', ['automation']);
|
||||
|
||||
expect(writeFile).toHaveBeenCalledOnce();
|
||||
const written = JSON.parse((writeFile as Mock).mock.calls[0][1] as string);
|
||||
|
||||
// the automations came across, triggers included
|
||||
expect(written.automation.automations.a1.title).toBe('from source');
|
||||
expect(written.automation.triggers).toHaveLength(1);
|
||||
|
||||
// and the url preset, which parses cleanly but was not selected, did not
|
||||
expect(written.urlPresets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,23 +2,12 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
const baseURL = 'http://localhost:4001';
|
||||
const automationsURL = `${baseURL}/data/automations`;
|
||||
const dbURL = `${baseURL}/data/db`;
|
||||
|
||||
const templateName = 'e2e-automations-template';
|
||||
|
||||
/**
|
||||
* Covers the loop that makes automations shareable:
|
||||
* create one, clone only the automations into a template project, and check that
|
||||
* the template carries the automation and its trigger while leaving the rundown behind.
|
||||
* Covers the automation delete flow: the server refuses to delete an automation that a
|
||||
* trigger still points at, and clears once the reference is removed.
|
||||
*/
|
||||
test.describe('automations', () => {
|
||||
/**
|
||||
* Everything created is torn down in afterEach rather than at the end of the test body:
|
||||
* a mid-test failure would otherwise leak an automation into the project, and CI retries twice.
|
||||
*
|
||||
* The template is tracked by the name the server actually used, since it resolves collisions.
|
||||
*/
|
||||
let createdTemplate: string | null = null;
|
||||
let createdTriggers: string[] = [];
|
||||
let createdAutomations: string[] = [];
|
||||
|
||||
@@ -31,63 +20,14 @@ test.describe('automations', () => {
|
||||
for (const id of createdAutomations) {
|
||||
await request.delete(`${automationsURL}/automation/${id}`);
|
||||
}
|
||||
if (createdTemplate !== null) {
|
||||
await request.delete(`${dbURL}/${createdTemplate}`);
|
||||
}
|
||||
} catch {
|
||||
// cleanup is best effort, it must not turn a passing test red
|
||||
} finally {
|
||||
createdTriggers = [];
|
||||
createdAutomations = [];
|
||||
createdTemplate = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('an automation and its trigger survive a round trip through a template project', async ({ request }) => {
|
||||
// 1. create an automation
|
||||
const createAutomation = await request.post(`${automationsURL}/automation`, {
|
||||
data: {
|
||||
title: 'e2e automation',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'aux1-start' }],
|
||||
},
|
||||
});
|
||||
expect(createAutomation.status()).toBe(201);
|
||||
const automation = await createAutomation.json();
|
||||
createdAutomations.push(automation.id);
|
||||
|
||||
// 2. bind it to a lifecycle
|
||||
const createTrigger = await request.post(`${automationsURL}/trigger`, {
|
||||
data: { title: 'e2e trigger', trigger: 'onStart', automationId: automation.id },
|
||||
});
|
||||
expect(createTrigger.status()).toBe(201);
|
||||
createdTriggers.push((await createTrigger.json()).id);
|
||||
|
||||
// 3. save the automations as a template, without touching the loaded project
|
||||
const projectList = await (await request.get(`${dbURL}/all`)).json();
|
||||
const currentProject = projectList.lastLoadedProject;
|
||||
|
||||
const makeTemplate = await request.post(`${dbURL}/${currentProject}/partial-duplicate`, {
|
||||
data: { newFilename: templateName, sections: ['automation'] },
|
||||
});
|
||||
expect(makeTemplate.status()).toBe(201);
|
||||
createdTemplate = (await makeTemplate.json()).filename;
|
||||
expect(createdTemplate).toBeTruthy();
|
||||
|
||||
// the running project is untouched
|
||||
const afterTemplate = await (await request.get(`${dbURL}/all`)).json();
|
||||
expect(afterTemplate.lastLoadedProject).toBe(currentProject);
|
||||
|
||||
// 4. the template carries the automation and its trigger, and nothing else
|
||||
const template = await (await request.post(`${dbURL}/download`, { data: { filename: createdTemplate } })).json();
|
||||
expect(Object.values(template.automation.automations)).toContainEqual(
|
||||
expect.objectContaining({ title: 'e2e automation' }),
|
||||
);
|
||||
expect(template.automation.triggers).toContainEqual(expect.objectContaining({ title: 'e2e trigger' }));
|
||||
expect(template.urlPresets).toEqual([]);
|
||||
});
|
||||
|
||||
test('refuses to delete an automation that a trigger still points at', async ({ request }) => {
|
||||
const automation = await (
|
||||
await request.post(`${automationsURL}/automation`, {
|
||||
|
||||
@@ -4,23 +4,3 @@ export interface QuickStartData {
|
||||
project: Pick<DatabaseModel['project'], 'title'>;
|
||||
settings: Pick<DatabaseModel['settings'], 'timeFormat' | 'language'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections of a project that can be cloned on their own into a template project:
|
||||
* a small project holding, say, only automations, which can be shared and then
|
||||
* applied to another project with a partial load
|
||||
*/
|
||||
export const templateSections = [
|
||||
'project',
|
||||
'rundowns',
|
||||
'customFields',
|
||||
'viewSettings',
|
||||
'urlPresets',
|
||||
'automation',
|
||||
] as const satisfies ReadonlyArray<keyof DatabaseModel>;
|
||||
|
||||
export type TemplateSection = (typeof templateSections)[number];
|
||||
|
||||
export function isTemplateSection(value: string): value is TemplateSection {
|
||||
return (templateSections as ReadonlyArray<string>).includes(value);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Client } from '../../definitions/Clients.type.js';
|
||||
import type { TimerLifeCycle } from '../../definitions/core/TimerLifecycle.type.js';
|
||||
import type { Log } from '../../definitions/runtime/Logger.type.js';
|
||||
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js';
|
||||
import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||
@@ -18,7 +17,6 @@ export enum MessageTag {
|
||||
Log = 'log',
|
||||
RuntimeData = 'runtime-data',
|
||||
Refetch = 'refetch',
|
||||
AutomationFired = 'automation-fired',
|
||||
}
|
||||
|
||||
// CLIENT TO SERVER
|
||||
@@ -38,15 +36,6 @@ type ListClientPacket = {
|
||||
};
|
||||
type RuntimePacket = { tag: MessageTag.RuntimeData; payload: Partial<RuntimeStore> };
|
||||
|
||||
/**
|
||||
* Reports that an automation ran, so clients can show it is alive.
|
||||
* Coalesced server side: high frequency lifecycles do not send one of these per fire.
|
||||
*/
|
||||
type AutomationFiredPacket = {
|
||||
tag: MessageTag.AutomationFired;
|
||||
payload: { automationId: string; cycle: TimerLifeCycle };
|
||||
};
|
||||
|
||||
type RefetchPacket = {
|
||||
tag: MessageTag.Refetch;
|
||||
payload: {
|
||||
@@ -69,5 +58,4 @@ export type WsPacketToClient =
|
||||
| LogPacket
|
||||
| ListClientPacket
|
||||
| RuntimePacket
|
||||
| RefetchPacket
|
||||
| AutomationFiredPacket;
|
||||
| RefetchPacket;
|
||||
|
||||
@@ -66,8 +66,7 @@ export type {
|
||||
} from './definitions/core/CustomFields.type.js';
|
||||
|
||||
// SERVER RESPONSES
|
||||
export type { QuickStartData, TemplateSection } from './api/db/db.type.js';
|
||||
export { templateSections, isTemplateSection } from './api/db/db.type.js';
|
||||
export type { QuickStartData } from './api/db/db.type.js';
|
||||
export type {
|
||||
AuthenticationStatus,
|
||||
NetworkInterface,
|
||||
|
||||
Reference in New Issue
Block a user