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:
Claude
2026-08-08 15:41:57 +00:00
parent fc8819cea6
commit 32734de679
12 changed files with 517 additions and 11 deletions
+26 -1
View File
@@ -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 },
@@ -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>
}
/>
);
}
@@ -231,6 +231,31 @@ 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<MessageResponse | ErrorResponse>) {
const { filename } = req.params;
const { newFilename, sections } = req.body;
try {
const created = await projectService.createProjectFromSections(filename, newFilename, sections);
res.status(201).send({
message: `Created template ${created} from ${filename}`,
});
} 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
+9
View File
@@ -8,6 +8,7 @@ import {
listProjects,
loadDemo,
loadProject,
partialDuplicateProjectFile,
patchPartialProjectFile,
postProjectFile,
projectDownload,
@@ -22,6 +23,7 @@ import {
validateNewProject,
validatePatchProject,
validateQuickProject,
validateSectionsBody,
} from './db.validation.js';
export const router: Router = express.Router();
@@ -39,5 +41,12 @@ 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,4 +1,5 @@
import { body, param } from 'express-validator';
import { isTemplateSection, templateSections } from 'ontime-types';
import sanitize from 'sanitize-filename';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
@@ -67,6 +68,19 @@ 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,7 +1,7 @@
import { copyFile } from 'fs/promises';
import { copyFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, TemplateSection } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
@@ -318,6 +318,49 @@ 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');
}
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);
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,8 +1,17 @@
import { writeFile } from 'fs/promises';
import { OntimeView, TimerLifeCycle } from 'ontime-types';
import { Mock } from 'vitest';
import { isLastLoadedProject } from '../../app-state-service/AppStateService.js';
import { deleteProjectFile, duplicateProjectFile, renameProjectFile } from '../ProjectService.js';
import { doesProjectExist } from '../projectServiceUtils.js';
import {
createProjectFromSections,
deleteProjectFile,
duplicateProjectFile,
renameProjectFile,
} from '../ProjectService.js';
import { doesProjectExist, parseJsonFile } from '../projectServiceUtils.js';
import { makeNewProject } from '../../../models/dataModel.js';
// stop the database loading from initiating
vi.mock('../../../setup/loadDb.js', () => {
@@ -17,7 +26,13 @@ vi.mock('../../app-state-service/AppStateService.js', () => ({
vi.mock('../projectServiceUtils.js', () => ({
doesProjectExist: vi.fn(),
getPathToProject: 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(),
}));
/**
@@ -65,3 +80,50 @@ 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([]);
});
});
@@ -0,0 +1,94 @@
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.
*/
test.describe('automations', () => {
test.afterEach(async ({ request }) => {
try {
await request.delete(`${dbURL}/${templateName}.json`);
} catch {
/** nothing to do here */
}
});
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();
// 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);
const trigger = await createTrigger.json();
// 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);
// 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: templateName } })).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([]);
// 5. clean up: the trigger has to go first, the server refuses to delete a referenced automation
expect((await request.delete(`${automationsURL}/trigger/${trigger.id}`)).status()).toBe(204);
expect((await request.delete(`${automationsURL}/automation/${automation.id}`)).status()).toBe(204);
});
test('refuses to delete an automation that a trigger still points at', async ({ request }) => {
const automation = await (
await request.post(`${automationsURL}/automation`, {
data: {
title: 'e2e referenced automation',
filterRule: 'all',
filters: [],
outputs: [{ type: 'ontime', action: 'aux1-stop' }],
},
})
).json();
const trigger = await (
await request.post(`${automationsURL}/trigger`, {
data: { title: 'e2e blocking trigger', trigger: 'onFinish', automationId: automation.id },
})
).json();
const refused = await request.delete(`${automationsURL}/automation/${automation.id}`);
expect(refused.status()).toBe(400);
expect((await refused.json()).message).toContain('e2e blocking trigger');
await request.delete(`${automationsURL}/trigger/${trigger.id}`);
expect((await request.delete(`${automationsURL}/automation/${automation.id}`)).status()).toBe(204);
});
});
+20
View File
@@ -4,3 +4,23 @@ 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);
}
+2 -1
View File
@@ -66,7 +66,8 @@ export type {
} from './definitions/core/CustomFields.type.js';
// SERVER RESPONSES
export type { QuickStartData } from './api/db/db.type.js';
export type { QuickStartData, TemplateSection } from './api/db/db.type.js';
export { templateSections, isTemplateSection } from './api/db/db.type.js';
export type {
AuthenticationStatus,
NetworkInterface,