From 74e59dccdb708e068d18cfe18b4d8e9a1a823ba1 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 27 Oct 2025 06:13:57 +0100 Subject: [PATCH] feat: allow renaming rundown --- apps/client/src/common/api/rundown.ts | 7 ++ .../common/hooks-query/useProjectRundowns.ts | 14 +++- .../panel/manage-panel/ManageRundowns.tsx | 52 ++++++++++++- .../composite/RundownRenameForm.tsx | 77 +++++++++++++++++++ .../src/api-data/rundown/rundown.router.ts | 32 ++++++++ 5 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/manage-panel/composite/RundownRenameForm.tsx diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 46ad2110b..a96fd3446 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -45,6 +45,13 @@ export async function duplicateRundown(rundownId: RundownId): Promise> { + return axios.patch(`${rundownPath}/${rundownId}`, { title }); +} + /** * HTTP request to delete a rundown */ diff --git a/apps/client/src/common/hooks-query/useProjectRundowns.ts b/apps/client/src/common/hooks-query/useProjectRundowns.ts index 7e8dc4284..fda4a2616 100644 --- a/apps/client/src/common/hooks-query/useProjectRundowns.ts +++ b/apps/client/src/common/hooks-query/useProjectRundowns.ts @@ -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) => 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 }; } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx index 673ab940a..4b67aaee5 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx @@ -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(null); const [actionError, setActionError] = useState(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() { {data?.rundowns?.map(({ id, numEntries, title }) => { const isLoaded = data.loaded === id; + const isRenaming = renamingRundown === id; + + if (isRenaming) { + return ( + + + setRenamingRundown(null)} + onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)} + initialTitle={title} + /> + + + ); + } + return ( {numEntries} @@ -116,6 +156,12 @@ export default function ManageRundowns() { } 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), diff --git a/apps/client/src/features/app-settings/panel/manage-panel/composite/RundownRenameForm.tsx b/apps/client/src/features/app-settings/panel/manage-panel/composite/RundownRenameForm.tsx new file mode 100644 index 000000000..d541a57fc --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/composite/RundownRenameForm.tsx @@ -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; + 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({ + 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 ( + preventEscape(event, onCancel)}> + + {errors.root && {errors.root.message}} + + + + + + ); +} diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 1c1a26668..099ccd665 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -113,6 +113,38 @@ router.post('/:id/duplicate', paramsWithId, async (req: Request, res: Response

) => { + 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 */