From ed2efe183bb435e925dc1c3885ad3aabea3b24ed Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 26 Oct 2024 21:51:31 +0200 Subject: [PATCH] feat: quick project wizard --- apps/client/package.json | 2 +- apps/client/src/common/api/db.ts | 10 +- .../src/features/app-settings/AppSettings.tsx | 4 +- .../panel/general-panel/ViewSettingsForm.tsx | 4 +- .../panel/project-panel/ProjectPanel.tsx | 13 +- .../quick-start/QuickStart.module.scss | 8 + .../app-settings/quick-start/QuickStart.tsx | 186 ++++++++++++++++++ .../quick-start/quickStart.utils.ts | 15 ++ .../app-settings/useAppSettingsMenu.tsx | 1 + apps/server/src/api-data/db/db.controller.ts | 37 ++-- apps/server/src/api-data/db/db.router.ts | 3 + apps/server/src/api-data/db/db.validation.ts | 22 +++ .../data-provider/DataProvider.utils.ts | 13 +- .../project-service/ProjectService.ts | 13 +- packages/types/src/api/db/db.type.ts | 7 + packages/types/src/index.ts | 1 + pnpm-lock.yaml | 41 +++- 17 files changed, 344 insertions(+), 36 deletions(-) create mode 100644 apps/client/src/features/app-settings/quick-start/QuickStart.module.scss create mode 100644 apps/client/src/features/app-settings/quick-start/QuickStart.tsx create mode 100644 apps/client/src/features/app-settings/quick-start/quickStart.utils.ts create mode 100644 packages/types/src/api/db/db.type.ts diff --git a/apps/client/package.json b/apps/client/package.json index e25e86c95..c6d7f62e7 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -26,7 +26,7 @@ "react-colorful": "^5.6.1", "react-dom": "^18.3.1", "react-fast-compare": "^3.2.2", - "react-hook-form": "^7.49.2", + "react-hook-form": "^7.53.1", "react-qr-code": "^2.0.12", "react-router-dom": "^6.3.0", "web-vitals": "^3.1.1", diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index f402a3be7..fd31dc5b0 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from 'axios'; -import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types'; +import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils'; @@ -86,6 +86,14 @@ export async function createProject( return res.data; } +/** + * HTTP request to create a project file + */ +export async function quickProject(data: QuickStartData): Promise { + const res = await axios.post(`${dbPath}/quick`, data); + return res.data; +} + /** * HTTP request to get the list of available project files */ diff --git a/apps/client/src/features/app-settings/AppSettings.tsx b/apps/client/src/features/app-settings/AppSettings.tsx index 8b74c0c53..28c0098f1 100644 --- a/apps/client/src/features/app-settings/AppSettings.tsx +++ b/apps/client/src/features/app-settings/AppSettings.tsx @@ -18,7 +18,7 @@ import useAppSettingsNavigation from './useAppSettingsNavigation'; import style from './AppSettings.module.scss'; export default function AppSettings() { - const { close, panel, location } = useAppSettingsNavigation(); + const { close, panel, location, setLocation } = useAppSettingsNavigation(); useKeyDown(close, 'Escape'); return ( @@ -26,7 +26,7 @@ export default function AppSettings() { - {panel === 'project' && } + {panel === 'project' && } {panel === 'general' && } {panel === 'feature_settings' && } {panel === 'sources' && } diff --git a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx index f6745d8fa..b2ee41910 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx @@ -126,7 +126,7 @@ export default function ViewSettingsForm() { void; +} + +export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) { const projectRef = useScrollIntoView('data', location); const manageRef = useScrollIntoView('manage', location); + const handleQuickClose = () => { + setLocation('project'); + }; + return ( <> Project +
diff --git a/apps/client/src/features/app-settings/quick-start/QuickStart.module.scss b/apps/client/src/features/app-settings/quick-start/QuickStart.module.scss new file mode 100644 index 000000000..2f20ed9fa --- /dev/null +++ b/apps/client/src/features/app-settings/quick-start/QuickStart.module.scss @@ -0,0 +1,8 @@ +.scrollContainer { + max-height: 70vh; + overflow: auto; + + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/apps/client/src/features/app-settings/quick-start/QuickStart.tsx b/apps/client/src/features/app-settings/quick-start/QuickStart.tsx new file mode 100644 index 000000000..20bc4509f --- /dev/null +++ b/apps/client/src/features/app-settings/quick-start/QuickStart.tsx @@ -0,0 +1,186 @@ +import { Controller, useForm } from 'react-hook-form'; +import { + Button, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Select, + Switch, +} from '@chakra-ui/react'; +import { QuickStartData } from 'ontime-types'; +import { parseUserTime } from 'ontime-utils'; + +import { quickProject } from '../../../common/api/db'; +import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/utils'; +import TimeInput from '../../../common/components/input/time-input/TimeInput'; +import { editorSettingsDefaults, useEditorSettings } from '../../../common/stores/editorSettings'; +import * as Panel from '../panel-utils/PanelUtils'; + +import { quickStartDefaults } from './quickStart.utils'; + +import style from './QuickStart.module.scss'; + +interface QuickStartProps { + isOpen: boolean; + onClose: () => void; +} + +export default function QuickStart(props: QuickStartProps) { + const { isOpen, onClose } = props; + const { defaultWarnTime, defaultDangerTime, setDangerTime, setWarnTime } = useEditorSettings(); + + const { + control, + handleSubmit, + register, + formState: { errors, isSubmitting, isValid }, + setError, + } = useForm({ + defaultValues: quickStartDefaults, + values: quickStartDefaults, + resetOptions: { + keepDirtyValues: true, + }, + }); + + const onSubmit = async (formData: QuickStartData) => { + try { + if (formData.project.title === '') { + formData.project.title = 'untitled'; + } + await quickProject(formData); + await invalidateAllCaches(); + onClose(); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + } + }; + + const warnTimeInMs = parseUserTime(defaultWarnTime); + const dangerTimeInMs = parseUserTime(defaultDangerTime); + + return ( + + + + +
+ Create new project... + + + + + + + + + + + + + + + + + + + + + + name='warnTime' + submitHandler={(_field, value) => setWarnTime(value)} + time={warnTimeInMs} + placeholder={editorSettingsDefaults.warnTime} + /> + + + + + name='dangerTime' + submitHandler={(_field, value) => setDangerTime(value)} + time={dangerTimeInMs} + placeholder={editorSettingsDefaults.dangerTime} + /> + + + + + + + ( + + )} + /> + + + + + + + + + {errors?.root && {errors.root.message}} + + + +
+
+
+ ); +} diff --git a/apps/client/src/features/app-settings/quick-start/quickStart.utils.ts b/apps/client/src/features/app-settings/quick-start/quickStart.utils.ts new file mode 100644 index 000000000..5b05c850f --- /dev/null +++ b/apps/client/src/features/app-settings/quick-start/quickStart.utils.ts @@ -0,0 +1,15 @@ +import type { QuickStartData } from 'ontime-types'; + +export const quickStartDefaults: QuickStartData = { + project: { + title: '', + }, + settings: { + timeFormat: '24', + language: 'en', + }, + viewSettings: { + freezeEnd: false, + endMessage: '', + }, +}; diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 1badd22eb..7f87b4d2d 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -15,6 +15,7 @@ const staticOptions = [ id: 'project', label: 'Project', secondary: [ + { id: 'project__create', label: 'Create...' }, { id: 'project__data', label: 'Project data' }, { id: 'project__manage', label: 'Manage projects' }, ], diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index c58ebad01..886e2bd7e 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -1,4 +1,4 @@ -import { DatabaseModel, ErrorResponse, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types'; +import { DatabaseModel, ErrorResponse, MessageResponse, ProjectFileListResponse } from 'ontime-types'; import { getErrorMessage } from 'ontime-utils'; import type { Request, Response } from 'express'; @@ -30,17 +30,17 @@ export async function patchPartialProjectFile(req: Request, res: Response) { - const newProjectData: ProjectData = { - title: req.body?.title ?? '', - description: req.body?.description ?? '', - publicUrl: req.body?.publicUrl ?? '', - publicInfo: req.body?.publicInfo ?? '', - backstageUrl: req.body?.backstageUrl ?? '', - backstageInfo: req.body?.backstageInfo ?? '', - }; - try { - const newFileName = await projectService.createProject(req.body.filename, newProjectData); + const newFileName = await projectService.createProject(req.body.filename, { + project: { + title: req.body?.title ?? '', + description: req.body?.description ?? '', + publicUrl: req.body?.publicUrl ?? '', + publicInfo: req.body?.publicInfo ?? '', + backstageUrl: req.body?.backstageUrl ?? '', + backstageInfo: req.body?.backstageInfo ?? '', + }, + }); res.status(200).send({ filename: newFileName, @@ -51,6 +51,21 @@ export async function createProjectFile(req: Request, res: Response<{ filename: } } +/** + * Creates and loads a new project with partial DataBase data + */ +export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) { + try { + const filename = await projectService.createProject(req.body.project.title, req.body); + res.status(200).send({ + filename, + }); + } catch (error) { + const message = getErrorMessage(error); + res.status(500).send({ message }); + } +} + /** * Allows downloading of current project file */ diff --git a/apps/server/src/api-data/db/db.router.ts b/apps/server/src/api-data/db/db.router.ts index 6d6719a62..280229d47 100644 --- a/apps/server/src/api-data/db/db.router.ts +++ b/apps/server/src/api-data/db/db.router.ts @@ -10,6 +10,7 @@ import { patchPartialProjectFile, postProjectFile, projectDownload, + quickProjectFile, renameProjectFile, } from './db.controller.js'; import { uploadProjectFile } from './db.middleware.js'; @@ -19,6 +20,7 @@ import { validateFilenameBody, validateFilenameParam, validateNewFilenameBody, + validateQuickProject, } from './db.validation.js'; export const router = express.Router(); @@ -29,6 +31,7 @@ router.post('/upload', uploadProjectFile, postProjectFile); router.patch('/', validatePatchProject, patchPartialProjectFile); router.post('/new', validateFilenameBody, validateNewProject, createProjectFile); +router.post('/quick', validateQuickProject, quickProjectFile); router.get('/all', listProjects); diff --git a/apps/server/src/api-data/db/db.validation.ts b/apps/server/src/api-data/db/db.validation.ts index 8d160d2e4..6ca86fa42 100644 --- a/apps/server/src/api-data/db/db.validation.ts +++ b/apps/server/src/api-data/db/db.validation.ts @@ -22,6 +22,28 @@ export const validateNewProject = [ }, ]; +/** + * @description Validates request for a quick project. + */ +export const validateQuickProject = [ + // Project fields + body('project.title').isString().trim(), + + // Settings fields + body('settings.timeFormat').optional().isIn(['12', '24']), + body('settings.language').optional().isString().trim(), + + // ViewSettings fields + body('viewSettings.freezeEnd').optional().isBoolean(), + body('viewSettings.endMessage').optional().isString().trim(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + /** * @description Validates request for pathing data in the project. */ diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts index 64eb642f2..07b1a897f 100644 --- a/apps/server/src/classes/data-provider/DataProvider.utils.ts +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -4,11 +4,20 @@ import { DatabaseModel } from 'ontime-types'; * Merges a partial ontime project into a given ontime project */ export function safeMerge(existing: DatabaseModel, newData: Partial): DatabaseModel { - const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {}; + const { + rundown = existing.rundown, + project = {}, + settings = {}, + viewSettings = {}, + urlPresets = existing.urlPresets, + customFields = existing.customFields, + osc = {}, + http = {}, + } = newData; return { ...existing, - rundown: rundown ?? existing.rundown, + rundown, project: { ...existing.project, ...project }, settings: { ...existing.settings, ...settings }, viewSettings: { ...existing.viewSettings, ...viewSettings }, diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 4b7a7ae88..72a52f680 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -1,4 +1,4 @@ -import { DatabaseModel, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types'; +import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types'; import { getErrorMessage } from 'ontime-utils'; import { copyFile, rename } from 'fs/promises'; @@ -38,6 +38,7 @@ import { moveCorruptFile, parseJsonFile, } from './projectServiceUtils.js'; +import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js'; // init dependencies init(); @@ -263,14 +264,8 @@ export async function renameProjectFile(originalFile: string, newFilename: strin /** * Creates a new project file and applies its result */ -export async function createProject(filename: string, projectData: ProjectData) { - const data: DatabaseModel = { - ...dbModel, - project: { - ...dbModel.project, - ...projectData, - }, - }; +export async function createProject(filename: string, initialData: Partial) { + const data = safeMerge(dbModel, initialData); const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, filename); const newFile = getPathToProject(uniqueFileName); diff --git a/packages/types/src/api/db/db.type.ts b/packages/types/src/api/db/db.type.ts new file mode 100644 index 000000000..321ecedea --- /dev/null +++ b/packages/types/src/api/db/db.type.ts @@ -0,0 +1,7 @@ +import { type DatabaseModel } from '../../definitions/DataModel.type.js'; + +export interface QuickStartData { + project: Pick; + settings: Pick; + viewSettings: Pick; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index bd0b6620d..207ae8284 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -53,6 +53,7 @@ export type { RundownPaginated, SessionStats, } from './api/ontime-controller/BackendResponse.type.js'; +export type { QuickStartData } from './api/db/db.type.js'; export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js'; // SERVER RUNTIME diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36ac7bc62..2b2cff25f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,33 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@types/node': + specifier: 20.14.10 + version: 20.14.10 + '@typescript-eslint/eslint-plugin': + specifier: 7.16.1 + version: 7.16.1 + '@typescript-eslint/parser': + specifier: 7.16.1 + version: 7.16.1 + eslint: + specifier: 8.56.0 + version: 8.56.0 + eslint-config-prettier: + specifier: 9.1.0 + version: 9.1.0 + eslint-plugin-prettier: + specifier: 5.1.3 + version: 5.1.3 + prettier: + specifier: 3.3.1 + version: 3.3.1 + typescript: + specifier: 5.5.3 + version: 5.5.3 + importers: .: @@ -123,8 +150,8 @@ importers: specifier: ^3.2.2 version: 3.2.2 react-hook-form: - specifier: ^7.49.2 - version: 7.49.2(react@18.3.1) + specifier: ^7.53.1 + version: 7.53.1(react@18.3.1) react-qr-code: specifier: ^2.0.12 version: 2.0.12(react@18.3.1) @@ -4484,11 +4511,11 @@ packages: '@types/react': optional: true - react-hook-form@7.49.2: - resolution: {integrity: sha512-TZcnSc17+LPPVpMRIDNVITY6w20deMdNi6iehTFLV1x8SqThXGwu93HjlUVU09pzFgZH7qZOvLMM7UYf2ShAHA==} - engines: {node: '>=18', pnpm: '8'} + react-hook-form@7.53.1: + resolution: {integrity: sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==} + engines: {node: '>=18.0.0'} peerDependencies: - react: ^16.8.0 || ^17 || ^18 + react: ^16.8.0 || ^17 || ^18 || ^19 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -10070,7 +10097,7 @@ snapshots: optionalDependencies: '@types/react': 18.0.26 - react-hook-form@7.49.2(react@18.3.1): + react-hook-form@7.53.1(react@18.3.1): dependencies: react: 18.3.1