Project Manager: Quick Start (#762)

This commit is contained in:
Ary
2024-02-10 06:30:33 -07:00
committed by GitHub
parent 7f78fac162
commit 12f81c63ce
11 changed files with 219 additions and 263 deletions
+8 -11
View File
@@ -247,13 +247,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
};
}
/**
* @description HTTP POST request to create a new project file with given project data
*/
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
/**
* @description HTTP request to get the list of available project files
*/
@@ -382,11 +375,15 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
/**
* @description HTTP request to create a project file
*/
export async function createProject(filename: string): Promise<MessageResponse> {
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const url = `${ontimeURL}/project`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
filename,
});
const res = await axios.post(decodedUrl, project);
return res.data;
}
@@ -0,0 +1,136 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Textarea } from '@chakra-ui/react';
import style from './ProjectPanel.module.scss';
export type ProjectCreateFormValues = {
title?: string;
description?: string;
publicInfo?: string;
publicUrl?: string;
backstageInfo?: string;
backstageUrl?: string;
};
interface ProjectCreateFormProps {
onCancel: () => void;
onSubmit: (values: ProjectCreateFormValues) => Promise<void>;
submitError: string | null;
}
export default function ProjectCreateForm({ onSubmit, onCancel, submitError }: ProjectCreateFormProps) {
const {
handleSubmit,
register,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
defaultValues: { title: '' },
values: { title: '' },
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
setFocus('title');
}, [setFocus]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className={style.createFormInputField}>
<label>
Project title
<Input
variant='ontime-filled'
size='sm'
maxLength={50}
placeholder='Your project name'
autoComplete='off'
{...register('title')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Project description
<Input
variant='ontime-filled'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
autoComplete='off'
{...register('description')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Public info
<Textarea
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Shows always start ontime'
autoComplete='off'
{...register('publicInfo')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Public QR code Url
<Input
variant='ontime-filled'
size='sm'
placeholder='www.getontime.no'
autoComplete='off'
{...register('publicUrl')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Backstage info
<Textarea
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Wi-Fi password: 1234'
autoComplete='off'
{...register('backstageInfo')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Backstage QR code Url
<Input
variant='ontime-filled'
size='sm'
placeholder='www.ontime.gitbook.io'
autoComplete='off'
{...register('backstageUrl')}
/>
</label>
</div>
<div className={style.createActionButtons}>
<Button onClick={onCancel} variant='ontime-ghosted' size='sm'>
Cancel
</Button>
<Button
isDisabled={!isValid}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Create
</Button>
</div>
{submitError && <span className={style.error}>{submitError}</span>}
</form>
);
}
@@ -9,7 +9,7 @@ export type ProjectFormValues = {
};
interface ProjectFormProps {
action: 'duplicate' | 'rename' | 'create';
action: 'duplicate' | 'rename';
filename: string;
onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>;
@@ -5,7 +5,7 @@ import { createProject } from '../../../../common/api/ontimeApi';
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm';
import ProjectCreateForm, { ProjectCreateFormValues } from './ProjectCreateForm';
import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss';
@@ -28,15 +28,15 @@ export default function ProjectList({ isCreatingProject, onToggleCreate }: Proje
setSubmitError(null);
};
const handleSubmitCreate = async (values: ProjectFormValues) => {
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
try {
setSubmitError(null);
const filename = values.filename.trim();
if (!filename) {
setSubmitError('Project name cannot be empty');
return;
}
await createProject(filename);
const filename = values.title?.trim();
await createProject({
...values,
filename,
});
await refetch();
handleToggleCreateMode();
} catch (error) {
@@ -82,13 +82,7 @@ export default function ProjectList({ isCreatingProject, onToggleCreate }: Proje
{isCreatingProject ? (
<tr className={style.createContainer}>
<td colSpan={99}>
<ProjectForm
action='create'
filename=''
onSubmit={handleSubmitCreate}
onCancel={handleToggleCreateMode}
submitError=''
/>
<ProjectCreateForm onSubmit={handleSubmitCreate} onCancel={handleToggleCreateMode} submitError='' />
{submitError && <span className={style.createSubmitError}>{submitError}</span>}
</td>
</tr>
@@ -19,6 +19,10 @@
flex: 2;
}
.createFormInputField {
padding-bottom: 0.5rem;
}
.actionButtons {
display: flex;
align-items: center;
@@ -26,6 +30,14 @@
margin-left: 1rem;
}
.createActionButtons {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: 1rem;
justify-content: flex-end;
}
.error {
color: $red-500;
}
@@ -5,7 +5,6 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import AppSettings from '../app-settings/AppSettings';
import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore';
import MenuBar from '../menu/MenuBar';
import QuickStart from '../modals/quick-start/QuickStart';
import SheetsModal from '../modals/sheets-modal/SheetsModal';
import UploadModal from '../modals/upload-modal/UploadModal';
import Overview from '../overview/Overview';
@@ -33,7 +32,6 @@ export default function Editor() {
onOpen: onIntegrationModalOpen,
onClose: onIntegrationModalClose,
} = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
// Set window title
@@ -46,7 +44,6 @@ export default function Editor() {
return (
<>
<ErrorBoundary>
<QuickStart onClose={onQuickStartClose} isOpen={isQuickStartOpen} />
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<SettingsModal isOpen={isOldSettingsOpen} onClose={onSettingsClose} />
@@ -62,8 +59,6 @@ export default function Editor() {
onUploadOpen={onUploadModalOpen}
isIntegrationOpen={isIntegrationModalOpen}
onIntegrationOpen={onIntegrationModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
openSettings={handleSettings}
isSettingsOpen={isSettingsOpen}
isSheetsOpen={isSheetsOpen}
-14
View File
@@ -3,7 +3,6 @@ import { IconButton, MenuButton, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoCloud } from '@react-icons/all-files/io5/IoCloud';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
@@ -30,8 +29,6 @@ interface MenuBarProps {
onUploadOpen: () => void;
isIntegrationOpen: boolean;
onIntegrationOpen: () => void;
isQuickStartOpen: boolean;
onQuickStartOpen: () => void;
isSheetsOpen: boolean;
onSheetsOpen: () => void;
openSettings: (newTab?: string) => void;
@@ -59,8 +56,6 @@ const MenuBar = (props: MenuBarProps) => {
onUploadOpen,
isIntegrationOpen,
onIntegrationOpen,
isQuickStartOpen,
onQuickStartOpen,
openSettings,
isSettingsOpen,
isSheetsOpen,
@@ -112,15 +107,6 @@ const MenuBar = (props: MenuBarProps) => {
<div className={style.menu}>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={<IoColorWand />}
className={isQuickStartOpen ? style.open : ''}
clickHandler={onQuickStartOpen}
tooltip='Quick start'
aria-label='Quick start'
/>
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
@@ -1,183 +0,0 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Textarea,
} from '@chakra-ui/react';
import type { ProjectData } from 'ontime-types';
import { PROJECT_DATA, RUNDOWN } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { ontimeQueryClient } from '../../../common/queryClient';
import styles from '../Modal.module.scss';
interface QuickStartProps {
onClose: () => void;
isOpen: boolean;
}
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const { data, status } = useProjectData();
const {
handleSubmit,
register,
reset,
formState: { isSubmitting },
} = useForm({
defaultValues: data,
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
if (data) reset(data);
}, [data, reset]);
const onSubmit = async (data: Partial<ProjectData>) => {
try {
await postNew(data);
await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_DATA });
await ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
onClose();
} catch (_) {
/* WE DO NOT HANDLE ERRORS */
}
};
const onReset = () => reset(projectDataPlaceholder);
const disableButtons = status !== 'success' || isSubmitting;
return (
<Modal
onClose={onClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Ontime quick start</ModalHeader>
<ModalCloseButton />
<ModalBody className={styles.pad}>
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer}>
<Alert status='info' variant='ontime-on-light-info'>
<AlertIcon />
<div className={styles.column}>
<AlertTitle>Note</AlertTitle>
<AlertDescription>
On submit, application options will be kept but rundown and project data will be reset
</AlertDescription>
</div>
</Alert>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Project title
<Input
variant='ontime-filled-on-light'
size='sm'
maxLength={50}
placeholder='Eurovision song contest'
{...register('title')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Project description
<Input
variant='ontime-filled-on-light'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
{...register('description')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public info
<Textarea
variant='ontime-filled-on-light'
size='sm'
maxLength={150}
placeholder='Shows always start ontime'
{...register('publicInfo')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
placeholder='www.getontime.no'
{...register('publicUrl')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage info
<Textarea
variant='ontime-filled-on-light'
size='sm'
maxLength={150}
placeholder='Wi-Fi password: 1234'
{...register('backstageInfo')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
placeholder='www.ontime.gitbook.io'
{...register('backstageUrl')}
/>
</label>
</div>
<ModalFooter className={styles.buttonSection}>
<Button onClick={onReset} isDisabled={disableButtons} variant='ontime-ghost-on-light' size='sm'>
Clear data
</Button>
<Button
type='submit'
isLoading={isSubmitting}
isDisabled={disableButtons}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
New project file
</Button>
</ModalFooter>
</form>
</ModalBody>
</ModalContent>
</Modal>
);
}
+26 -28
View File
@@ -32,7 +32,7 @@ import {
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
import { logger } from '../classes/Logger.js';
import { deleteAllEvents, notifyChanges, setRundown } from '../services/rundown-service/RundownService.js';
import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js';
import { integrationService } from '../services/integration-service/IntegrationService.js';
import { getProjectFiles } from '../utils/getFileListFromFolder.js';
import { configService } from '../services/ConfigService.js';
@@ -41,6 +41,8 @@ import { validateProjectFiles } from './ontimeController.validate.js';
import { dbModel } from '../models/dataModel.js';
import { sheet } from '../utils/sheetsAuth.js';
import { removeFileExtension } from '../utils/removeFileExtension.js';
import { ensureJsonExtension } from '../utils/ensureJsonExtension.js';
import { generateUniqueFileName } from '../utils/generateUniqueFilename.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
@@ -460,29 +462,6 @@ export async function previewExcel(req, res: Response) {
}
}
/**
* Meant to create a new project file, it will clear only fields which are specific to a project
* @param req
* @param res
*/
export const postNew: RequestHandler = async (req, res) => {
try {
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 ?? '',
};
const newData = await DataProvider.setProjectData(newProjectData);
await deleteAllEvents();
res.status(201).send(newData);
} catch (error) {
res.status(400).send({ message: String(error) });
}
};
/**
* Retrieves and lists all project files from the uploads directory.
* @param req
@@ -618,20 +597,39 @@ export const renameProjectFile: RequestHandler = async (req: Request, res: Respo
*/
export const createProjectFile: RequestHandler = async (req: Request, res: Response) => {
try {
const { filename } = req.body;
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
const filename = generateUniqueFileName(uploadsFolderPath, originalFilename);
const projectFilePath = join(uploadsFolderPath, filename);
const errors = validateProjectFiles({ newFilename: filename });
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 ?? '',
};
const data = {
...dbModel,
project: {
...dbModel.project,
...newProjectData,
},
};
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
return res.status(409).send({ message: 'Project with title already exists' });
}
await writeFile(projectFilePath, JSON.stringify(dbModel));
await writeFile(projectFilePath, JSON.stringify(data));
await parseAndApply(projectFilePath, req, res, {});
res.status(200).send({
message: `Created project ${filename}`,
filename,
});
} catch (error) {
res.status(500).send({ message: String(error) });
+1 -6
View File
@@ -13,7 +13,6 @@ import {
patchPartialProjectFile,
poll,
postAliases,
postNew,
postOSC,
postOscSubscriptions,
postSettings,
@@ -48,7 +47,6 @@ import {
validateProjectDuplicate,
validateLoadProjectFile,
validateProjectRename,
validateProjectCreate,
validateSheetid,
validateWorksheet,
validateSheetOptions,
@@ -115,9 +113,6 @@ router.get('/http', getHTTP);
// create route between controller and '/ontime/http' endpoint
router.post('/http', validateHTTP, postHTTP);
// create route between controller and '/ontime/new' endpoint
router.post('/new', projectSanitiser, postNew);
// create route between controller and '/ontime/projects' endpoint
router.get('/projects', listProjects);
@@ -131,7 +126,7 @@ router.post('/project/:filename/duplicate', validateProjectDuplicate, sanitizePr
router.put('/project/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile);
// create route between controller and '/ontime/project' endpoint
router.post('/project', validateProjectCreate, sanitizeProjectFilename, createProjectFile);
router.post('/project', projectSanitiser, createProjectFile);
// create route between controller and '/ontime/project/:filename' endpoint
router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile);
@@ -0,0 +1,26 @@
import { existsSync } from 'fs';
import path from 'path';
/**
* Generates a unique file name within the specified directory.
* If a file with the same name already exists, appends a counter to the filename.
*
* @param {string} directory - The directory to check for file existence.
* @param {string} filename - The original filename.
* @return {Promise<string>} A unique filename.
*/
export const generateUniqueFileName = (directory: string, filename: string) => {
const baseName = path.basename(filename, path.extname(filename));
const extension = path.extname(filename);
let counter = 0;
let uniqueFilename = filename;
while (existsSync(path.join(directory, uniqueFilename))) {
counter++;
// Append counter to filename if the file exists.
uniqueFilename = `${baseName} (${counter})${extension}`;
}
return uniqueFilename;
};