mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 06:04:05 +00:00
feat(project): save part of a project as a template
Automations were already portable, contrary to the original framing: project download carries them and the partial load already had an "Automation Settings" toggle. What was missing is the other half of that loop. To share a set of automations you had to hand over an entire show. Save as template is the inverse of partial load: it writes a new project file holding only the sections you pick. Automations, custom fields, URL presets and view settings all become templates, and a template is an ordinary project file, so it downloads, uploads and partial loads with no new format and no new concepts. createProjectWithPatch already builds exactly the right object, but it routes through loadProject and would switch the running project. Saving a template must not pull an operator out of the show they are running, so this writes the file without loading it, the way duplicateProjectFile already does. The automations panel gets the loop as a Share menu: save these automations as a template, or go load one. The form says plainly that applying an automation template replaces rather than merges, since triggers and automations are coupled and safeMerge swaps the whole block, and that a template without rundowns does not carry the events that point at its automations. Adds the first e2e coverage automations have had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
import {
|
||||
DatabaseModel,
|
||||
MessageResponse,
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
QuickStartData,
|
||||
TemplateSection,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
@@ -117,6 +124,24 @@ 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<MessageResponse> {
|
||||
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,16 +1,20 @@
|
||||
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoSparklesOutline, IoTrash } from 'react-icons/io5';
|
||||
import { IoAdd, IoPencil, IoShareOutline, IoSparklesOutline, IoTrash } from 'react-icons/io5';
|
||||
|
||||
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 { 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 } from './automationUtils';
|
||||
import DeleteAutomationDialog from './DeleteAutomationDialog';
|
||||
@@ -36,7 +40,12 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
|
||||
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.
|
||||
@@ -83,9 +92,37 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
{showTemplateForm && (
|
||||
<ProjectPartialCloneForm
|
||||
fileName={lastLoadedProject}
|
||||
preselected={['automation']}
|
||||
onClose={() => setShowTemplateForm(false)}
|
||||
onCreated={async () => 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>
|
||||
|
||||
@@ -25,10 +25,11 @@ 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' | null;
|
||||
export type EditMode = 'rename' | 'duplicate' | 'merge' | 'template' | null;
|
||||
|
||||
interface ProjectListItemProps {
|
||||
current?: boolean;
|
||||
@@ -120,6 +121,7 @@ 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 (
|
||||
@@ -152,14 +154,18 @@ export default function ProjectListItem({
|
||||
onChangeEditMode={handleToggleEditMode}
|
||||
onDelete={() => setDeleteOpen(true)}
|
||||
onLoad={handleLoad}
|
||||
isDisabled={loading || showMergeForm}
|
||||
isDisabled={loading || showMergeForm || showTemplateForm}
|
||||
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)}
|
||||
@@ -190,9 +196,10 @@ 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 } = props;
|
||||
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge, onSaveAsTemplate } = props;
|
||||
|
||||
const handleRename = () => {
|
||||
onChangeEditMode('rename', filename);
|
||||
@@ -227,6 +234,13 @@ 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 },
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
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;
|
||||
onCreated: () => 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);
|
||||
await partialDuplicateProject(fileName, values.filename, sections);
|
||||
await onCreated();
|
||||
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. A template without rundowns carries the
|
||||
automations themselves, but not the events that point at them.
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user