feat: allow renaming rundown

This commit is contained in:
Carlos Valente
2025-10-27 06:13:57 +01:00
committed by Carlos Valente
parent 812b17a221
commit 74e59dccdb
5 changed files with 177 additions and 5 deletions
+7
View File
@@ -45,6 +45,13 @@ export async function duplicateRundown(rundownId: RundownId): Promise<AxiosRespo
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
}
/**
* HTTP request to rename an existing rundown
*/
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.patch(`${rundownPath}/${rundownId}`, { title });
}
/**
* HTTP request to delete a rundown
*/
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
/**
* Project rundowns
@@ -40,6 +40,16 @@ export function useMutateProjectRundowns() {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: rename } = useMutation({
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown,
@@ -61,5 +71,5 @@ export function useMutateProjectRundowns() {
},
});
return { create, duplicate, remove, load };
return { create, duplicate, remove, load, rename };
}
@@ -1,5 +1,13 @@
import { useState } from 'react';
import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoDuplicateOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5';
import {
IoAdd,
IoDocumentOutline,
IoDownloadOutline,
IoDuplicateOutline,
IoEllipsisHorizontal,
IoPencilOutline,
IoTrash,
} from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel';
@@ -13,17 +21,19 @@ import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss';
export default function ManageRundowns() {
const { data } = useProjectRundowns();
const { duplicate, remove, load } = useMutateProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState('');
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => {
@@ -38,6 +48,11 @@ export default function ManageRundowns() {
deleteHandlers.open();
};
const openRename = (id: string) => {
setActionError(null);
setRenamingRundown(id);
};
const submitRundownLoad = async () => {
try {
await load(targetRundown);
@@ -60,6 +75,15 @@ export default function ManageRundowns() {
}
};
const submitRundownRename = async (id: string, newTitle: string) => {
try {
await rename([id, newTitle]);
setRenamingRundown(null);
} catch (error) {
setActionError(`Failed to rename rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownDelete = async () => {
try {
await remove(targetRundown);
@@ -106,6 +130,22 @@ export default function ManageRundowns() {
<tbody>
{data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id;
const isRenaming = renamingRundown === id;
if (isRenaming) {
return (
<tr key={id}>
<td colSpan={3}>
<RundownRenameForm
onCancel={() => setRenamingRundown(null)}
onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)}
initialTitle={title}
/>
</td>
</tr>
);
}
return (
<tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td>
@@ -116,6 +156,12 @@ export default function ManageRundowns() {
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
items={[
{
type: 'item',
icon: IoPencilOutline,
label: 'Rename',
onClick: () => openRename(id),
},
{
type: 'item',
icon: IoDownloadOutline,
@@ -137,7 +183,7 @@ export default function ManageRundowns() {
},
{ type: 'divider' },
{
type: 'item',
type: 'destructive',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
@@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { checkRegex } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
interface RundownRenameFormProps {
onSubmit: (newTitle: string) => Promise<void>;
onCancel: () => void;
initialTitle: string;
}
interface FormData {
title: string;
}
export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }: RundownRenameFormProps) {
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<FormData>({
defaultValues: { title: initialTitle },
mode: 'onChange',
});
const setupSubmit = async (values: FormData) => {
try {
await onSubmit(values.title);
} catch (error) {
setError('root', { type: 'custom', message: maybeAxiosError(error) });
}
};
// Give initial focus to the title input
useEffect(() => {
setFocus('title');
}, [setFocus]);
const canSubmit = isDirty && isValid;
return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input
{...register('title', {
required: { value: true, message: 'Title is required' },
validate: (value) => {
if (value.trim().length === 0) return 'Title cannot be empty';
if (checkRegex.isAlphanumericWithSpace(value) === false)
return 'Title can only contain alphanumeric characters, spaces and underscores';
return true;
},
})}
fluid
/>
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
</label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -113,6 +113,38 @@ router.post('/:id/duplicate', paramsWithId, async (req: Request, res: Response<P
res.status(400).send({ message });
}
});
/**
* Patches the data of an existing rundown
* Currently only the title can be changed
*/
router.patch('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(req.params.id);
if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`);
if (!req.body.title) throw new Error('No title provided');
await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title });
/**
* If loaded we re-init the rundown
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (req.params.id === getCurrentRundown().id) {
const rundown = dataProvider.getRundown(req.params.id);
const customField = dataProvider.getCustomFields();
await initRundown(rundown, customField);
}
const projectRundowns = getDataProvider().getProjectRundowns();
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
/**
* Deletes a rundown if not loaded
*/