Compare commits

...

4 Commits

Author SHA1 Message Date
arc-alex d89309a120 lock cusheet edits 2025-05-27 15:33:39 +02:00
Shobhit Nagpal 0dcea4f2d7 fix: pass empty string fallback when parsing fields for export (#1616) 2025-05-25 19:58:41 +02:00
Shobhit Nagpal 696c016c90 feat: custom data for projects (#1571) 2025-05-23 09:04:57 +02:00
Shobhit Nagpal eed6373dbf refactor: change offset classes depending on playback state (#1611)
* refactor: assign offsetClasses based on playback state
2025-05-23 09:04:57 +02:00
35 changed files with 338 additions and 49 deletions
@@ -25,4 +25,4 @@ export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknow
{...props} {...props}
/> />
); );
}; };
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}; };
+1
View File
@@ -31,6 +31,7 @@ declare module '@tanstack/react-table' {
options: { options: {
showDelayedTimes: boolean; showDelayedTimes: boolean;
hideTableSeconds: boolean; hideTableSeconds: boolean;
allowEdits: boolean;
}; };
} }
} }
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
publicUrl?: string; publicUrl?: string;
backstageInfo?: string; backstageInfo?: string;
backstageUrl?: string; backstageUrl?: string;
custom?: { title: string; value: string }[];
}; };
export default function ProjectCreateForm(props: ProjectCreateFromProps) { export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
control,
formState: { isSubmitting, isValid }, formState: { isSubmitting, isValid },
setFocus, setFocus,
} = useForm<ProjectCreateFormValues>({ } = useForm<ProjectCreateFormValues>({
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}, },
}); });
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field // set focus to first field
useEffect(() => { useEffect(() => {
setFocus('title'); setFocus('title');
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values, ...values,
filename, filename,
}); });
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST }); await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
} }
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
@@ -1,6 +1,6 @@
import { ChangeEvent, useEffect, useRef } from 'react'; import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoDownloadOutline, IoTrash } from 'react-icons/io5'; import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types'; import { type ProjectData } from 'ontime-types';
@@ -25,6 +25,7 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors }, formState: { isSubmitting, isValid, isDirty, errors },
setError, setError,
watch, watch,
control,
setValue, setValue,
} = useForm({ } = useForm({
defaultValues: data, defaultValues: data,
@@ -32,6 +33,12 @@ export default function ProjectData() {
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
}); });
// reset form values if data changes // reset form values if data changes
@@ -77,6 +84,10 @@ export default function ProjectData() {
}); });
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => { const onSubmit = async (formData: ProjectData) => {
try { try {
await postProjectData(formData); await postProjectData(formData);
@@ -231,6 +242,69 @@ export default function ProjectData() {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div>
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -57,3 +57,18 @@
height: auto; height: auto;
} }
} }
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -45,6 +45,10 @@
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.offset {
color: $muted-gray;
}
.ahead { .ahead {
color: $playback-ahead; color: $playback-ahead;
} }
@@ -1,4 +1,5 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react'; import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -163,10 +164,10 @@ function ProgressOverview() {
} }
function RuntimeOverview() { function RuntimeOverview() {
const { clock, offset } = useRuntimePlaybackOverview(); const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset); const offsetText = getOffsetText(offset);
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead; const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return ( return (
<> <>
@@ -100,7 +100,7 @@ describe('makeTable()', () => {
[ [
"00:00:00", "00:00:00",
"00:00:00", "00:00:00",
"...", "",
"", "",
"", "",
"", "",
@@ -1,4 +1,5 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types'; import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types';
@@ -25,6 +26,9 @@ export default function CuesheetTable(props: CuesheetTableProps) {
const { data, columns, showModal } = props; const { data, columns, showModal } = props;
const { updateEvent, updateTimer } = useEventAction(); const { updateEvent, updateTimer } = useEventAction();
const [searchParams] = useSearchParams();
const blockEdits = searchParams.get('locked') ?? false;
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions(); const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } = const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns); useColumnManager(columns);
@@ -77,6 +81,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
options: { options: {
showDelayedTimes, showDelayedTimes,
hideTableSeconds, hideTableSeconds,
allowEdits: !blockEdits,
}, },
}, },
}); });
@@ -6,12 +6,13 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
interface MultiLineCellProps { interface MultiLineCellProps {
initialValue: string; initialValue: string;
handleUpdate: (newValue: string) => void; handleUpdate: (newValue: string) => void;
allowEdits?: boolean;
} }
export default memo(MultiLineCell); export default memo(MultiLineCell);
function MultiLineCell(props: MultiLineCellProps) { function MultiLineCell(props: MultiLineCellProps) {
const { initialValue, handleUpdate } = props; const { initialValue, handleUpdate, allowEdits } = props;
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]); const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -38,6 +39,7 @@ function MultiLineCell(props: MultiLineCellProps) {
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
spellCheck={false} spellCheck={false}
isDisabled={!allowEdits}
/> />
); );
} }
@@ -1,17 +1,18 @@
import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react'; import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react';
import { Input } from '@chakra-ui/react'; import { Input, Text } from '@chakra-ui/react';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
interface SingleLineCellProps { interface SingleLineCellProps {
initialValue: string; initialValue: string;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
allowEdits?: boolean;
handleUpdate: (newValue: string) => void; handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void; handleCancelUpdate?: () => void;
} }
const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => { const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
const { initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate } = props; const { initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate, allowEdits } = props;
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]); const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -38,6 +39,14 @@ const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
}; };
}, [ref]); }, [ref]);
if (allowEdits === false) {
return (
<Text ref={ref} size='sm' variant='ontime-transparent' padding={0} fontSize='md'>
{initialValue}
</Text>
);
}
return ( return (
<Input <Input
ref={ref} ref={ref}
@@ -8,6 +8,7 @@ interface TimeInputDurationProps {
initialValue: number; initialValue: number;
lockedValue: boolean; lockedValue: boolean;
delayed?: boolean; delayed?: boolean;
allowEdits?: boolean;
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
} }
@@ -18,7 +19,7 @@ interface ParentFocusableInput extends HTMLInputElement {
export default memo(TimeInputDuration); export default memo(TimeInputDuration);
function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) { function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
const { initialValue, lockedValue, delayed, onSubmit, children } = props; const { initialValue, lockedValue, delayed, onSubmit, children, allowEdits } = props;
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
@@ -86,7 +87,7 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
const timeString = millisToString(value); const timeString = millisToString(value);
return isEditing ? ( return isEditing && allowEdits ? (
<SingleLineCell <SingleLineCell
ref={inputRef} ref={inputRef}
initialValue={timeString} initialValue={timeString}
@@ -32,7 +32,13 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeRundownEntry, unk
} }
return ( return (
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={delayValue !== 0}> <TimeInput
initialValue={startTime}
onSubmit={update}
lockedValue={isStartLocked}
delayed={delayValue !== 0}
allowEdits={table.options.meta?.options.allowEdits}
>
{formattedTime} {formattedTime}
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(startTime)} /> <DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(startTime)} />
</TimeInput> </TimeInput>
@@ -60,7 +66,13 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeRundownEntry, unkno
} }
return ( return (
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={delayValue !== 0}> <TimeInput
initialValue={endTime}
onSubmit={update}
lockedValue={isEndLocked}
delayed={delayValue !== 0}
allowEdits={table.options.meta?.options.allowEdits}
>
{formattedTime} {formattedTime}
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(endTime)} /> <DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(endTime)} />
</TimeInput> </TimeInput>
@@ -81,7 +93,12 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeRundownEntry,
const formattedDuration = formatDuration(duration, false); const formattedDuration = formatDuration(duration, false);
return ( return (
<TimeInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}> <TimeInput
initialValue={duration}
onSubmit={update}
lockedValue={isDurationLocked}
allowEdits={table.options.meta?.options.allowEdits}
>
{formattedDuration} {formattedDuration}
</TimeInput> </TimeInput>
); );
@@ -103,7 +120,13 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEnt
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? ''; const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return (
<MultiLineCell
initialValue={initialValue}
handleUpdate={update}
allowEdits={table.options.meta?.options.allowEdits}
/>
);
} }
function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
@@ -140,7 +163,13 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEn
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? ''; const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
return <SingleLineCell initialValue={initialValue} handleUpdate={update} />; return (
<SingleLineCell
initialValue={initialValue}
handleUpdate={update}
allowEdits={table.options.meta?.options.allowEdits}
/>
);
} }
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
@@ -158,7 +187,13 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
} }
const initialValue = event.custom[column.id] ?? ''; const initialValue = event.custom[column.id] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return (
<MultiLineCell
initialValue={initialValue}
handleUpdate={update}
allowEdits={table.options.meta?.options.allowEdits}
/>
);
} }
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] { export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
@@ -20,7 +20,7 @@ type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
export const parseField = (field: CsvHeaderKey, data: unknown): string => { export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') { if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber); return millisToString(data as MaybeNumber, { fallback: '' });
} }
if (field === 'isPublic' || field === 'skip') { if (field === 'isPublic' || field === 'skip') {
@@ -25,7 +25,9 @@
flex: 1; flex: 1;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 800px); width: min(calc(100vw - 4rem), 960px);
padding-bottom: 10vh;
} }
.info__label { .info__label {
@@ -33,11 +35,15 @@
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
margin-top: $view-element-gap; margin-top: $view-element-gap;
white-space: pre; }
.info__value {
white-space: break-spaces;
} }
a.info__value { a.info__value {
color: $action-text-color; color: $action-text-color;
&:hover { &:hover {
color: $ontime-color; color: $ontime-color;
} }
@@ -8,6 +8,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
import BackstageInfo from './backstage-info/BackstageInfo'; import BackstageInfo from './backstage-info/BackstageInfo';
import CustomInfo from './custom-info/CustomInfo';
import PublicInfo from './public-info/PublicInfo'; import PublicInfo from './public-info/PublicInfo';
import { projectInfoOptions } from './projectInfo.options'; import { projectInfoOptions } from './projectInfo.options';
@@ -66,6 +67,7 @@ export default function ProjectInfo(props: ProjectInfoProps) {
)} )}
<BackstageInfo general={general} /> <BackstageInfo general={general} />
<PublicInfo general={general} /> <PublicInfo general={general} />
<CustomInfo general={general} />
</div> </div>
</div> </div>
); );
@@ -0,0 +1,36 @@
import { Fragment } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ProjectData } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface CustomInfoProps {
general: ProjectData;
}
export default function CustomInfo(props: CustomInfoProps) {
const { general } = props;
const [searchParams] = useSearchParams();
const showCustom = isStringBoolean(searchParams.get('showCustom'));
if (!showCustom || general.custom === undefined || general.custom.length === 0) {
return null;
}
return (
<>
{general.custom.map((info, idx) => {
if (!info.title || !info.value) {
return null;
}
return (
<Fragment key={`${info.title}-${idx}`}>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</Fragment>
);
})}
</>
);
}
@@ -9,14 +9,21 @@ export const projectInfoOptions: ViewOption[] = [
{ {
id: 'showBackstage', id: 'showBackstage',
title: 'Show backstage Data', title: 'Show backstage Data',
description: 'Weather to show fields related to the backstage views', description: 'Whether to show fields related to the backstage views',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
{ {
id: 'showPublic', id: 'showPublic',
title: 'Show Public Data', title: 'Show Public Data',
description: 'Weather to show fields related to the public views', description: 'Whether to show fields related to the public views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showCustom',
title: 'Show Custom Data',
description: 'Whether to show fields related to the custom data',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
+2 -1
View File
@@ -58,6 +58,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
backstageUrl: req.body?.backstageUrl ?? '', backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '', backstageInfo: req.body?.backstageInfo ?? '',
projectLogo: req.body?.projectLogo ?? null, projectLogo: req.body?.projectLogo ?? null,
custom: req.body?.custom ?? [],
}, },
}); });
@@ -203,7 +204,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
/** /**
* Loads the demo project * Loads the demo project
*/ */
export async function loadDemo(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function loadDemo(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try { try {
const projectName = await projectService.loadDemoProject(); const projectName = await projectService.loadDemoProject();
@@ -16,6 +16,7 @@ export const validateNewProject = [
body('backstageInfo').optional().isString().trim(), body('backstageInfo').optional().isString().trim(),
body('projectLogo').optional().isString().trim(), body('projectLogo').optional().isString().trim(),
body('endMessage').optional().isString().trim(), body('endMessage').optional().isString().trim(),
body('custom').optional().isArray(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
@@ -5,11 +5,11 @@ import type { Request, Response } from 'express';
import { removeUndefined } from '../../utils/parserUtils.js'; import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js'; import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
import * as projectDao from './project.dao.js';
export function getProjectData(_req: Request, res: Response<ProjectData>) { export function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(getDataProvider().getProjectData()); res.json(projectDao.getProjectData());
} }
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) { export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
@@ -27,6 +27,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageInfo: req.body?.backstageInfo, backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage, endMessage: req.body?.endMessage,
projectLogo: req.body?.projectLogo, projectLogo: req.body?.projectLogo,
custom: req.body?.custom,
}); });
const updatedData = await editCurrentProjectData(newData); const updatedData = await editCurrentProjectData(newData);
@@ -0,0 +1,9 @@
import { ProjectData } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
* Gets a copy of the stored project data
*/
export function getProjectData(): ProjectData {
return structuredClone(getDataProvider().getProjectData());
}
@@ -10,6 +10,9 @@ export const projectSanitiser = [
body('backstageInfo').optional().isString().trim(), body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(), body('endMessage').optional().isString().trim(),
body('projectLogo').optional({ nullable: true }).isString().trim(), body('projectLogo').optional({ nullable: true }).isString().trim(),
body('custom').optional().isArray(),
body('custom.*.title').optional().isString().trim().notEmpty(),
body('custom.*.value').optional().isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
@@ -63,7 +63,7 @@ function getData(): Readonly<DatabaseModel> {
} }
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> { async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
db.data.project = { ...db.data.project, ...newData }; db.data.project = { ...structuredClone(db.data.project), ...structuredClone(newData) }; // Performing deep copy as we're updating / merging data
await persist(); await persist();
return db.data.project; return db.data.project;
} }
@@ -4,24 +4,27 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project * Merges a partial ontime project into a given ontime project
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const deepExisting = structuredClone(existing);
const deepNewData = structuredClone(newData);
const { const {
rundown = existing.rundown, rundown = deepExisting.rundown,
project = {}, project = {},
settings = {}, settings = {},
viewSettings = {}, viewSettings = {},
urlPresets = existing.urlPresets, urlPresets = deepExisting.urlPresets,
customFields = existing.customFields, customFields = deepExisting.customFields,
automation = existing.automation, automation = deepExisting.automation,
} = newData; } = deepNewData;
return { return {
...existing, ...deepExisting,
rundown, rundown,
project: { ...existing.project, ...project }, project: { ...deepExisting.project, ...project },
settings: { ...existing.settings, ...settings }, settings: { ...deepExisting.settings, ...settings },
viewSettings: { ...existing.viewSettings, ...viewSettings }, viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
urlPresets: urlPresets ?? existing.urlPresets, urlPresets: urlPresets ?? deepExisting.urlPresets,
customFields: customFields ?? existing.customFields, customFields: customFields ?? deepExisting.customFields,
automation: { ...existing.automation, ...automation }, automation: { ...deepExisting.automation, ...automation },
}; };
} }
@@ -12,6 +12,12 @@ describe('safeMerge', () => {
publicInfo: 'existing backstageInfo', publicInfo: 'existing backstageInfo',
backstageInfo: 'existing backstageInfo', backstageInfo: 'existing backstageInfo',
projectLogo: null, projectLogo: null,
custom: [
{
title: 'existing custom title',
value: 'existing custom value',
},
],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
@@ -62,6 +68,12 @@ describe('safeMerge', () => {
project: { project: {
title: 'new title', title: 'new title',
publicInfo: 'new public info', publicInfo: 'new public info',
custom: [
{
title: 'new custom title',
value: 'new custom value',
},
],
}, },
}; };
// @ts-expect-error -- just testing // @ts-expect-error -- just testing
@@ -74,6 +86,12 @@ describe('safeMerge', () => {
backstageUrl: 'existing backstageUrl', backstageUrl: 'existing backstageUrl',
backstageInfo: 'existing backstageInfo', backstageInfo: 'existing backstageInfo',
projectLogo: null, projectLogo: null,
custom: [
{
title: 'new custom title',
value: 'new custom value',
},
],
}); });
}); });
@@ -107,6 +125,7 @@ describe('safeMerge', () => {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
+1
View File
@@ -11,6 +11,7 @@ export const dbModel: DatabaseModel = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
+1
View File
@@ -413,6 +413,7 @@ export const demoDb: DatabaseModel = {
backstageUrl: 'www.github.com/cpvalente/ontime', backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal', backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
@@ -314,17 +314,13 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
} }
/** /**
* Changes the title of a project * Patches the current project data
* it handles invalidating the necessary data * Handles deleting the local logo if the logo has been removed
*/ */
export async function editCurrentProjectData(newData: Partial<ProjectData>) { export async function editCurrentProjectData(newData: Partial<ProjectData>) {
const currentProjectData = getDataProvider().getProjectData(); const currentProjectData = getDataProvider().getProjectData();
const updatedProjectData = await getDataProvider().setProjectData(newData); const updatedProjectData = await getDataProvider().setProjectData(newData);
if (currentProjectData.title !== updatedProjectData.title) {
// something
}
// Delete the old logo if the logo has been removed // Delete the old logo if the logo has been removed
if (!updatedProjectData.projectLogo && currentProjectData.projectLogo) { if (!updatedProjectData.projectLogo && currentProjectData.projectLogo) {
const filePath = join(publicDir.logoDir, currentProjectData.projectLogo); const filePath = join(publicDir.logoDir, currentProjectData.projectLogo);
@@ -65,6 +65,7 @@ describe('parseProject()', () => {
publicInfo: 'publicInfo', publicInfo: 'publicInfo',
backstageUrl: 'backstageUrl', backstageUrl: 'backstageUrl',
backstageInfo: 'backstageInfo', backstageInfo: 'backstageInfo',
custom: [],
}, },
}, },
errorEmitter, errorEmitter,
@@ -77,6 +78,7 @@ describe('parseProject()', () => {
backstageUrl: 'backstageUrl', backstageUrl: 'backstageUrl',
backstageInfo: 'backstageInfo', backstageInfo: 'backstageInfo',
projectLogo: null, projectLogo: null,
custom: [],
}); });
expect(errorEmitter).not.toHaveBeenCalled(); expect(errorEmitter).not.toHaveBeenCalled();
}); });
+1
View File
@@ -114,6 +114,7 @@ export function parseProject(data: Partial<DatabaseModel>, emitError?: ErrorEmit
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl, backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo, backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo, projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo,
custom: data.project.custom ?? dbModel.project.custom,
}; };
} }
+3 -2
View File
@@ -410,7 +410,8 @@
"publicInfo": "Rehearsal Schedule - Turin 2022", "publicInfo": "Rehearsal Schedule - Turin 2022",
"backstageUrl": "www.github.com/cpvalente/ontime", "backstageUrl": "www.github.com/cpvalente/ontime",
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal", "backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal",
"projectLogo": null "projectLogo": null,
"custom": []
}, },
"settings": { "settings": {
"app": "ontime", "app": "ontime",
@@ -455,4 +456,4 @@
"label": "artist" "label": "artist"
} }
} }
} }
+3 -2
View File
@@ -410,7 +410,8 @@
"publicInfo": "Rehearsal Schedule - Turin 2022", "publicInfo": "Rehearsal Schedule - Turin 2022",
"backstageUrl": "www.github.com/cpvalente/ontime", "backstageUrl": "www.github.com/cpvalente/ontime",
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal", "backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal",
"projectLogo": null "projectLogo": null,
"custom": []
}, },
"settings": { "settings": {
"app": "ontime", "app": "ontime",
@@ -455,4 +456,4 @@
"label": "artist" "label": "artist"
} }
} }
} }
@@ -6,4 +6,5 @@ export type ProjectData = {
backstageUrl: string; backstageUrl: string;
backstageInfo: string; backstageInfo: string;
projectLogo: string | null; projectLogo: string | null;
custom: { title: string; value: string }[];
}; };