diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index cae17c3fa..1cab1e4db 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -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 { + 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 */ diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index fa9053336..f156ed73e 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -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(null); const [showRecipes, setShowRecipes] = useState(false); + const [showTemplateForm, setShowTemplateForm] = useState(false); const [deleteTarget, setDeleteTarget] = useState(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 && ( + setShowTemplateForm(false)} + onCreated={async () => setLocation('project__list')} + /> + )} Manage automations + } + 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 + diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx index 703067a70..270428313 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx @@ -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)} /> )} {showMergeForm && } + {showTemplateForm && ( + + )} setDeleteOpen(false)} @@ -190,9 +196,10 @@ interface ActionMenuProps { onDelete: () => void; onLoad: (filename: string) => Promise; 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 }, diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPartialCloneForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectPartialCloneForm.tsx new file mode 100644 index 000000000..f89f9c15c --- /dev/null +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPartialCloneForm.tsx @@ -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 & { 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; + 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(null); + + const { + handleSubmit, + register, + watch, + setValue, + formState: { isSubmitting, errors }, + } = useForm({ + 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 ( + + +
+ + Create a new project containing only the selected parts of{' '} + {`"${fileName}"`}. Use it as a starting point for new shows, + or share it with someone who needs the same setup. + +
+ The current project is not changed and stays loaded. Apply a template to another project with Partial + Load. +
+
+ + + + + {sectionCopy.map(({ key, title, 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. + + +
+ + } + footerElements={ +
+ {error && {error}} +
+ + +
+
+ } + /> + ); +} diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index c45f2df63..ee4e2fba1 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -231,6 +231,31 @@ export async function loadDemo(_req: Request, res: Response) { + 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 diff --git a/apps/server/src/api-data/db/db.router.ts b/apps/server/src/api-data/db/db.router.ts index cdac334c3..e6c0cc953 100644 --- a/apps/server/src/api-data/db/db.router.ts +++ b/apps/server/src/api-data/db/db.router.ts @@ -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); diff --git a/apps/server/src/api-data/db/db.validation.ts b/apps/server/src/api-data/db/db.validation.ts index 40e91fdcd..ba735a22c 100644 --- a/apps/server/src/api-data/db/db.validation.ts +++ b/apps/server/src/api-data/db/db.validation.ts @@ -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. */ diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 3cad948c3..e52e0f95b 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -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 { + 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 = {}; + 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 */ diff --git a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts index ba79327e8..6c02eff7e 100644 --- a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts +++ b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts @@ -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()), + 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([]); + }); +}); diff --git a/e2e/tests/features/215-automations.spec.ts b/e2e/tests/features/215-automations.spec.ts new file mode 100644 index 000000000..b356f61ce --- /dev/null +++ b/e2e/tests/features/215-automations.spec.ts @@ -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); + }); +}); diff --git a/packages/types/src/api/db/db.type.ts b/packages/types/src/api/db/db.type.ts index cae4cbca6..e559d1d05 100644 --- a/packages/types/src/api/db/db.type.ts +++ b/packages/types/src/api/db/db.type.ts @@ -4,3 +4,23 @@ export interface QuickStartData { project: Pick; settings: Pick; } + +/** + * 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; + +export type TemplateSection = (typeof templateSections)[number]; + +export function isTemplateSection(value: string): value is TemplateSection { + return (templateSections as ReadonlyArray).includes(value); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77e98fd91..e82e4f666 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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,