diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index 41de27a24..8acc42883 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -11,7 +11,7 @@ const dbPath = `${apiEntryUrl}/db`; /** * HTTP request to the current DB */ -async function getDb(filename: string): Promise> { +export function getDb(filename: string): Promise> { return axios.post(`${dbPath}/download/`, { filename }); } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx b/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx index 07cfdaed5..9184be12e 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx @@ -42,7 +42,7 @@ export default function ManageProjects() { const errorMessage = maybeAxiosError(error); setError(`Error uploading file: ${errorMessage}`); } finally { - invalidateAllCaches(); + await invalidateAllCaches(); } setLoading(null); diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx index 57fca85d7..cf932fe42 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx @@ -9,7 +9,7 @@ export type ProjectFormValues = { }; interface ProjectFormProps { - action: 'duplicate' | 'rename'; + action: 'duplicate' | 'rename' | 'merge'; filename: string; onCancel: () => void; onSubmit: (values: ProjectFormValues) => Promise; diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx index a97728d0c..8104270b8 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx @@ -11,13 +11,15 @@ import { renameProject, } from '../../../../common/api/db'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; +import { cx } from '../../../../common/utils/styleUtils'; import * as Panel from '../PanelUtils'; import ProjectForm, { ProjectFormValues } from './ProjectForm'; +import ProjectMergeForm from './ProjectMergeForm'; import style from './ProjectPanel.module.scss'; -export type EditMode = 'rename' | 'duplicate' | null; +export type EditMode = 'rename' | 'duplicate' | 'merge' | null; interface ProjectListItemProps { current?: boolean; @@ -100,8 +102,10 @@ export default function ProjectListItem({ handleToggleEditMode(null, null); }; - const isCurrentlyBeingEdited = editingMode && filename === editingFilename; - const classes = current && !isCurrentlyBeingEdited ? style.current : undefined; + const isCurrentlyBeingEdited = filename === editingFilename; + const showProjectForm = (editingMode === 'rename' || editingMode === 'duplicate') && filename === editingFilename; + const showMergeForm = editingMode === 'merge' && isCurrentlyBeingEdited; + const classes = cx([current && !isCurrentlyBeingEdited && style.current, isCurrentlyBeingEdited && style.isEditing]); return ( <> @@ -113,7 +117,7 @@ export default function ProjectListItem({ )} - {isCurrentlyBeingEdited ? ( + {showProjectForm ? ( {filename} - {new Date(updatedAt).toLocaleString()} + {current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()} handleToggleEditMode('merge', filename)} /> )} + {showMergeForm && ( + + + + + + )} ); } @@ -148,11 +160,12 @@ interface ActionMenuProps { filename: string; isDisabled: boolean; onChangeEditMode: (editMode: EditMode, filename: string) => void; - onDelete: (filename: string) => void; - onLoad: (filename: string) => void; + onDelete: (filename: string) => Promise; + onLoad: (filename: string) => Promise; + onMerge: (filename: string) => void; } function ActionMenu(props: ActionMenuProps) { - const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props; + const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge } = props; const handleRename = () => { onChangeEditMode('rename', filename); @@ -185,6 +198,9 @@ function ActionMenu(props: ActionMenuProps) { onLoad(filename)} isDisabled={current}> Load + onMerge(filename)} isDisabled={current}> + Partial Load + Rename Duplicate Download diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx new file mode 100644 index 000000000..a3fafb9d8 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { Button, Switch } from '@chakra-ui/react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { PROJECT_DATA } from '../../../../common/api/constants'; +import { getDb, patchData } from '../../../../common/api/db'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import * as Panel from '../PanelUtils'; + +import { makeProjectPatch } from './project.utils'; + +import style from './ProjectPanel.module.scss'; + +interface ProjectMergeFromProps { + onClose: () => void; + fileName: string; +} + +type ProjectMergeFormValues = { + project: boolean; + rundown: boolean; + viewSettings: boolean; + urlPresets: boolean; + osc: boolean; + http: boolean; +}; + +export default function ProjectMergeForm(props: ProjectMergeFromProps) { + const { onClose, fileName } = props; + const [error, setError] = useState(null); + const queryClient = useQueryClient(); + + const { + handleSubmit, + register, + formState: { isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { + project: false, + rundown: false, + viewSettings: false, + urlPresets: false, + osc: false, + http: false, + }, + resetOptions: { + keepDirtyValues: true, + }, + }); + + const handleSubmitCreate = async (values: ProjectMergeFormValues) => { + const allFalse = Object.values(values).every((value) => !value); + if (allFalse) { + setError('At least one option must be selected'); + return; + } + + try { + setError(null); + + // make patch object + const { data } = await getDb(fileName); + const patch = await makeProjectPatch(data, values); + + // request patch + await patchData(patch); + await queryClient.invalidateQueries({ queryKey: PROJECT_DATA }); + onClose(); + } catch (error) { + setError(maybeAxiosError(error)); + } + }; + + return ( + + + Merge {`"${fileName}"`} +
+ + +
+
+ {error && {error}} +
+ + Select partial data from {`"${fileName}"`} to merge into the current project. +
This process is irreversible. +
+ + + + + + +
+
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss index 784d86931..e1947a3cb 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss @@ -2,6 +2,10 @@ background-color: $blue-1100; } +.isEditing { + color: $blue-500; +} + .actionButton { flex: 1; text-align: right; @@ -49,4 +53,9 @@ display: flex; flex-direction: column; gap: 1em; + + label { + display: flex; + gap: 1em; + } } diff --git a/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts b/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts new file mode 100644 index 000000000..d3e6eddf9 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts @@ -0,0 +1,17 @@ +import { DatabaseModel, isKeyOfType } from 'ontime-types'; + +export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record) { + const patchObject: Partial = {}; + + for (const key in mergeKeys) { + if (isKeyOfType(key, data) && mergeKeys[key]) { + // if the rundown is merged we also need the custom fields + if (key === 'rundown') { + patchObject.customFields = data['customFields']; + } + Object.assign(patchObject, { [key]: data[key] }); + } + } + + return patchObject; +}