diff --git a/packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx index 1c1f5515e1..700393f7ea 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx @@ -7,7 +7,7 @@ import { WrappedOverridableItemDeleted, WrappedOverridableItemNormal, } from '../../util/OverrideOpHelper.js' -import { faCheck, faPencilAlt, faSync, faTrash } from '@fortawesome/free-solid-svg-icons' +import { faCheck, faPencilAlt, faSync, faTrash, faSave, faBan } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { JSONBlob, JSONBlobParse, JSONSchema } from '@sofie-automation/blueprints-integration' import { DropdownInputControl, DropdownInputOption } from '../../../../lib/Components/DropdownInput.js' @@ -32,12 +32,24 @@ interface PeripheralDeviceTranslated { export interface SubDevicesTableProps { subDevices: WrappedOverridableItem[] overrideHelper: OverrideOpHelper + instantSaveOverrideHelper: OverrideOpHelper peripheralDevices: PeripheralDevice[] + hasUnsavedChanges: boolean + saveChanges: () => void + discardChanges: () => void + updateObjectId: (oldId: string, newId: string) => void + updatedIds: Map } export function GenericSubDevicesTable({ subDevices, overrideHelper, peripheralDevices, + hasUnsavedChanges, + instantSaveOverrideHelper, + saveChanges, + discardChanges, + updateObjectId, + updatedIds, }: Readonly): JSX.Element { const { t } = useTranslation() const { toggleExpanded, isExpanded } = useToggleExpandHelper() @@ -59,7 +71,6 @@ export function GenericSubDevicesTable({ return devicesMap }, [peripheralDevices]) - const confirmRemove = useCallback( (subdeviceId: string) => { doModalDialog({ @@ -67,7 +78,7 @@ export function GenericSubDevicesTable({ no: t('Cancel'), yes: t('Remove'), onAccept: () => { - overrideHelper().deleteItem(subdeviceId).commit() + instantSaveOverrideHelper().deleteItem(subdeviceId).commit() }, message: ( @@ -82,7 +93,7 @@ export function GenericSubDevicesTable({ ), }) }, - [t, overrideHelper] + [t, instantSaveOverrideHelper] ) const peripheralDeviceOptions = useMemo(() => { @@ -145,6 +156,7 @@ export function GenericSubDevicesTable({ isEdited={isExpanded(item.id)} editItemWithId={toggleExpanded} removeItemWithId={confirmRemove} + updatedIds={updatedIds} /> {isExpanded(item.id) && ( )} @@ -170,6 +187,7 @@ interface SummaryRowProps { isEdited: boolean editItemWithId: (itemId: string) => void removeItemWithId: (itemId: string) => void + updatedIds: Map } function SummaryRow({ item, @@ -177,6 +195,7 @@ function SummaryRow({ isEdited, editItemWithId, removeItemWithId, + updatedIds, }: Readonly): JSX.Element { const editItem = useCallback(() => editItemWithId(item.id), [editItemWithId, item.id]) const removeItem = useCallback(() => removeItemWithId(item.id), [removeItemWithId, item.id]) @@ -185,13 +204,17 @@ function SummaryRow({ ? (peripheralDevice.subdeviceManifest?.[item.computed.options.type]?.displayName ?? '-') : '-' + const idChanged = Array.from(updatedIds?.entries() || []).some(([key, value]) => value === item.id || key === item.id) + return ( - {item.id} + + {item.id} {idChanged && '(pending save)'} + {peripheralDevice?.name || item.computed.peripheralDeviceId || '-'} @@ -252,6 +275,11 @@ interface SubDeviceEditRowProps { editItemWithId: (subdeviceId: string, forceState?: boolean) => void item: WrappedOverridableItemNormal overrideHelper: OverrideOpHelper + hasUnsavedChanges: boolean + saveChanges: () => void + discardChanges: () => void + updateObjectId: (oldId: string, newId: string) => void + updatedIds: Map } function SubDeviceEditRow({ peripheralDevice, @@ -259,24 +287,29 @@ function SubDeviceEditRow({ editItemWithId, item, overrideHelper, + hasUnsavedChanges, + saveChanges, + discardChanges, + updateObjectId, + updatedIds, }: Readonly) { const { t } = useTranslation() const finishEditItem = useCallback(() => editItemWithId(item.id, false), [editItemWithId, item.id]) - const updateObjectId = useCallback( + const handleUpdateId = useCallback( (newId: string) => { - if (item.id === newId) return - - overrideHelper().changeItemId(item.id, newId).commit() + updateObjectId(item.id, newId) // toggle ui visibility editItemWithId(item.id, false) editItemWithId(newId, true) }, - [item.id, overrideHelper, editItemWithId] + [item.id, updateObjectId] ) + const idToShowInInput = updatedIds?.get(item.id) || item.id + return ( @@ -294,7 +327,7 @@ function SubDeviceEditRow({ {!item.computed.peripheralDeviceId && ( @@ -308,9 +341,23 @@ function SubDeviceEditRow({ )}
- + {hasUnsavedChanges ? ( + <> + + + + + ) : ( + + )}
diff --git a/packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx index 80cfda1c82..3ac0565953 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { Studios } from '../../../../collections/index.js' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData.js' @@ -30,6 +30,13 @@ export function StudioIngestSubDevices({ const studio = useTracker(() => Studios.findOne(studioId), [studioId]) + const [unsavedOverrides, setUnsavedOverrides] = useState(undefined) + + const baseSettings = useMemo( + () => studio?.peripheralDeviceSettings?.ingestDevices ?? wrapDefaultObject({}), + [studio?.peripheralDeviceSettings?.ingestDevices] + ) + const saveOverrides = useCallback( (newOps: SomeObjectOverrideOp[]) => { if (studio?._id) { @@ -43,17 +50,25 @@ export function StudioIngestSubDevices({ [studio?._id] ) - const baseSettings = useMemo( - () => studio?.peripheralDeviceSettings?.ingestDevices ?? wrapDefaultObject({}), - [studio?.peripheralDeviceSettings?.ingestDevices] - ) + const settingsWithOverrides = useMemo(() => { + if (unsavedOverrides) { + return { + ...baseSettings, + overrides: unsavedOverrides, + } + } + return baseSettings + }, [baseSettings, unsavedOverrides]) - const overrideHelper = useOverrideOpHelper(saveOverrides, baseSettings) + const batchedOverrideHelper = useOverrideOpHelper(setUnsavedOverrides, settingsWithOverrides) + const instantSaveOverrideHelper = useOverrideOpHelper(saveOverrides, settingsWithOverrides) const wrappedSubDevices = useMemo( () => - getAllCurrentAndDeletedItemsFromOverrides(baseSettings, (a, b) => a[0].localeCompare(b[0])), - [baseSettings] + getAllCurrentAndDeletedItemsFromOverrides(settingsWithOverrides, (a, b) => + a[0].localeCompare(b[0]) + ), + [settingsWithOverrides] ) const filteredPeripheralDevices = useMemo( @@ -85,7 +100,42 @@ export function StudioIngestSubDevices({ 'peripheralDeviceSettings.ingestDevices.overrides': addOp, }, }) - }, [studioId, wrappedSubDevices]) + }, [wrappedSubDevices, settingsWithOverrides.overrides]) + + // key is subDevice's old id, value is it's new id if it was changed + const [updatedIds, setUpdatedIds] = useState(new Map()) + + const updateObjectId = useCallback( + (oldId: string, newId: string) => { + if (oldId === newId) return + + batchedOverrideHelper().changeItemId(oldId, newId).commit() + setUpdatedIds((prev) => new Map(prev).set(oldId, newId)) + }, + [batchedOverrideHelper, setUpdatedIds] + ) + + const discardChanges = useCallback(() => { + setUnsavedOverrides(undefined) + setUpdatedIds(new Map()) + }, []) + + const saveChanges = useCallback(() => { + if (studio?._id && unsavedOverrides) { + Studios.update(studio._id, { + $set: { + 'peripheralDeviceSettings.ingestDevices.overrides': unsavedOverrides, + }, + }) + setUnsavedOverrides(undefined) + } + + if (updatedIds.size > 0) { + setUpdatedIds(new Map()) + } + }, [studio?._id, unsavedOverrides]) + + const hasUnsavedChanges = useMemo(() => !!unsavedOverrides || updatedIds.size > 0, [unsavedOverrides, updatedIds]) return (
@@ -101,8 +151,14 @@ export function StudioIngestSubDevices({
diff --git a/packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx index 825d21ed96..aa832a1ef1 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { Studios } from '../../../../collections/index.js' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData.js' @@ -27,6 +27,13 @@ export function StudioInputSubDevices({ studioId, studioDevices }: Readonly Studios.findOne(studioId), [studioId]) + const [unsavedOverrides, setUnsavedOverrides] = useState(undefined) + + const baseSettings = useMemo( + () => studio?.peripheralDeviceSettings?.inputDevices ?? wrapDefaultObject({}), + [studio?.peripheralDeviceSettings?.inputDevices] + ) + const saveOverrides = useCallback( (newOps: SomeObjectOverrideOp[]) => { if (studio?._id) { @@ -40,17 +47,25 @@ export function StudioInputSubDevices({ studioId, studioDevices }: Readonly studio?.peripheralDeviceSettings?.inputDevices ?? wrapDefaultObject({}), - [studio?.peripheralDeviceSettings?.inputDevices] - ) + const settingsWithOverrides = useMemo(() => { + if (unsavedOverrides) { + return { + ...baseSettings, + overrides: unsavedOverrides, + } + } + return baseSettings + }, [baseSettings, unsavedOverrides]) - const overrideHelper = useOverrideOpHelper(saveOverrides, baseSettings) + const batchedOverrideHelper = useOverrideOpHelper(setUnsavedOverrides, settingsWithOverrides) + const instantSaveOverrideHelper = useOverrideOpHelper(saveOverrides, settingsWithOverrides) const wrappedSubDevices = useMemo( () => - getAllCurrentAndDeletedItemsFromOverrides(baseSettings, (a, b) => a[0].localeCompare(b[0])), - [baseSettings] + getAllCurrentAndDeletedItemsFromOverrides(settingsWithOverrides, (a, b) => + a[0].localeCompare(b[0]) + ), + [settingsWithOverrides] ) const filteredPeripheralDevices = useMemo( @@ -82,7 +97,41 @@ export function StudioInputSubDevices({ studioId, studioDevices }: Readonly()) + + const updateObjectId = useCallback( + (oldId: string, newId: string) => { + if (oldId === newId) return + + batchedOverrideHelper().changeItemId(oldId, newId).commit() + setUpdatedIds((prev) => new Map(prev).set(oldId, newId)) + }, + [batchedOverrideHelper, setUpdatedIds] + ) + + const discardChanges = useCallback(() => { + setUnsavedOverrides(undefined) + setUpdatedIds(new Map()) + }, []) + + const saveChanges = useCallback(() => { + if (studio?._id && unsavedOverrides) { + Studios.update(studio._id, { + $set: { + 'peripheralDeviceSettings.inputDevices.overrides': unsavedOverrides, + }, + }) + setUnsavedOverrides(undefined) + } + + if (updatedIds.size > 0) { + setUpdatedIds(new Map()) + } + }, [studio?._id, unsavedOverrides]) + + const hasUnsavedChanges = useMemo(() => !!unsavedOverrides || updatedIds.size > 0, [unsavedOverrides, updatedIds]) return (
@@ -98,8 +147,14 @@ export function StudioInputSubDevices({ studioId, studioDevices }: Readonly
diff --git a/packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx index d89e45c7c7..3a917af71c 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTranslation } from 'react-i18next' import { @@ -9,7 +9,7 @@ import { WrappedOverridableItemDeleted, WrappedOverridableItemNormal, } from '../../util/OverrideOpHelper.js' -import { faCheck, faPencilAlt, faPlus, faSync, faTrash } from '@fortawesome/free-solid-svg-icons' +import { faCheck, faPencilAlt, faPlus, faSync, faTrash, faSave, faBan } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { JSONBlob, JSONBlobParse, JSONSchema } from '@sofie-automation/blueprints-integration' import { DropdownInputControl, DropdownInputOption } from '../../../../lib/Components/DropdownInput.js' @@ -44,26 +44,22 @@ export function StudioParentDevices({ studioId }: Readonly Studios.findOne(studioId), [studioId]) - const saveOverrides = useCallback( - (newOps: SomeObjectOverrideOp[]) => { - if (studio?._id) { - Studios.update(studio._id, { - $set: { - 'peripheralDeviceSettings.deviceSettings.overrides': newOps, - }, - }) - } - }, - [studio?._id] - ) + const [unsavedOverrides, setUnsavedOverrides] = useState(undefined) + const [unsavedAssignments, setUnsavedAssignments] = useState>({}) - const deviceSettings = useMemo( - () => - studio?.peripheralDeviceSettings?.deviceSettings ?? wrapDefaultObject>({}), - [studio?.peripheralDeviceSettings?.deviceSettings] - ) + const deviceSettings = useMemo(() => { + const base = + studio?.peripheralDeviceSettings?.deviceSettings ?? wrapDefaultObject>({}) + if (unsavedOverrides) { + return { + ...base, + overrides: unsavedOverrides, + } + } + return base + }, [studio?.peripheralDeviceSettings?.deviceSettings, unsavedOverrides]) - const overrideHelper = useOverrideOpHelper(saveOverrides, deviceSettings) + const overrideHelper = useOverrideOpHelper(setUnsavedOverrides, deviceSettings) const wrappedDeviceSettings = useMemo( () => @@ -88,15 +84,44 @@ export function StudioParentDevices({ studioId }: Readonly addNewItem(), [studioId]) + const addNewItemClick = useCallback(() => addNewItem(), [addNewItem]) + + const changeAssignment = useCallback((configId: string, deviceId: PeripheralDeviceId | undefined) => { + setUnsavedAssignments((prev) => ({ + ...prev, + [configId]: deviceId, + })) + }, []) + + const discardChanges = useCallback(() => { + setUnsavedOverrides(undefined) + setUnsavedAssignments({}) + }, []) + + const saveChanges = useCallback(() => { + if (studio?._id && unsavedOverrides) { + Studios.update(studio._id, { + $set: { + 'peripheralDeviceSettings.deviceSettings.overrides': unsavedOverrides, + }, + }) + setUnsavedOverrides(undefined) + } + if (Object.keys(unsavedAssignments).length > 0) { + Promise.all( + Object.entries(unsavedAssignments).map(async ([configId, deviceId]) => { + return MeteorCall.studio.assignConfigToPeripheralDevice(studioId, configId, deviceId ?? null) + }) + ).catch((e) => { + console.error('Failed to save assignments', e) + }) + setUnsavedAssignments({}) + } + }, [studio?._id, unsavedOverrides, unsavedAssignments, studioId]) const hasCurrentDevice = wrappedDeviceSettings.find((d) => d.type === 'normal') @@ -117,6 +142,11 @@ export function StudioParentDevices({ studioId }: Readonly 0} + saveChanges={saveChanges} + discardChanges={discardChanges} + unsavedAssignments={unsavedAssignments} + changeAssignment={changeAssignment} />
@@ -142,12 +172,22 @@ interface ParentDevicesTableProps { devices: WrappedOverridableItem[] overrideHelper: OverrideOpHelper createItemWithId: (id: string) => void + hasUnsavedChanges: boolean + saveChanges: () => void + discardChanges: () => void + unsavedAssignments: Record + changeAssignment: (configId: string, deviceId: PeripheralDeviceId | undefined) => void } function GenericParentDevicesTable({ studioId, devices, overrideHelper, createItemWithId, + hasUnsavedChanges, + saveChanges, + discardChanges, + unsavedAssignments, + changeAssignment, }: Readonly): JSX.Element { const { t } = useTranslation() const { toggleExpanded, isExpanded } = useToggleExpandHelper() @@ -251,7 +291,6 @@ function GenericParentDevicesTable({ return } else { const peripheralDevice = peripheralDevicesByConfigIdMap.get(item.id) - return ( )} @@ -398,14 +444,23 @@ interface ParentDeviceEditRowProps { editItemWithId: (parentdeviceId: string, forceState?: boolean) => void item: WrappedOverridableItemNormal overrideHelper: OverrideOpHelper + hasUnsavedChanges: boolean + saveChanges: () => void + discardChanges: () => void + currentAssignment: PeripheralDeviceId | undefined + changeAssignment: (configId: string, deviceId: PeripheralDeviceId | undefined) => void } function ParentDeviceEditRow({ - studioId, peripheralDevice, peripheralDeviceOptions, editItemWithId, item, overrideHelper, + hasUnsavedChanges, + saveChanges, + discardChanges, + currentAssignment, + changeAssignment, }: Readonly) { const { t } = useTranslation() @@ -425,10 +480,10 @@ function ParentDeviceEditRow({ {!peripheralDevice &&

{t('A device must be assigned to the config to edit the settings')}

} @@ -438,9 +493,23 @@ function ParentDeviceEditRow({ )}
- + {hasUnsavedChanges ? ( + <> + + + + + ) : ( + + )}
@@ -448,25 +517,23 @@ function ParentDeviceEditRow({ } interface AssignPeripheralDeviceConfigIdProps { - studioId: StudioId configId: string value: PeripheralDeviceId | undefined peripheralDeviceOptions: DropdownInputOption[] + onChange: (configId: string, deviceId: PeripheralDeviceId | undefined) => void } function AssignPeripheralDeviceConfigId({ - studioId, configId, value, peripheralDeviceOptions, + onChange, }: AssignPeripheralDeviceConfigIdProps) { const handleUpdate = useCallback( (peripheralDeviceId: PeripheralDeviceId | undefined) => { - MeteorCall.studio.assignConfigToPeripheralDevice(studioId, configId, peripheralDeviceId ?? null).catch((e) => { - console.error('assignConfigToPeripheralDevice failed', e) - }) + onChange(configId, peripheralDeviceId) }, - [configId] + [configId, onChange] ) return ( diff --git a/packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx index b015c8a8be..057c7b5fef 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { Studios } from '../../../../collections/index.js' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData.js' @@ -30,6 +30,7 @@ export function StudioPlayoutSubDevices({ const { t } = useTranslation() const studio = useTracker(() => Studios.findOne(studioId), [studioId]) + const [unsavedOverrides, setUnsavedOverrides] = useState(undefined) const saveOverrides = useCallback( (newOps: SomeObjectOverrideOp[]) => { @@ -49,12 +50,26 @@ export function StudioPlayoutSubDevices({ [studio?.peripheralDeviceSettings?.playoutDevices] ) - const overrideHelper = useOverrideOpHelper(saveOverrides, baseSettings) + const deviceSettings = useMemo(() => { + if (unsavedOverrides) { + return { + ...baseSettings, + overrides: unsavedOverrides, + } + } + return baseSettings + }, [baseSettings, unsavedOverrides]) + + const batchedOverridesHelper = useOverrideOpHelper(setUnsavedOverrides, deviceSettings) + + const instantSaveOverrideHelper = useOverrideOpHelper(saveOverrides, deviceSettings) const wrappedSubDevices = useMemo( () => - getAllCurrentAndDeletedItemsFromOverrides(baseSettings, (a, b) => a[0].localeCompare(b[0])), - [baseSettings] + getAllCurrentAndDeletedItemsFromOverrides(deviceSettings, (a, b) => + a[0].localeCompare(b[0]) + ), + [deviceSettings] ) const filteredPeripheralDevices = useMemo( @@ -90,6 +105,40 @@ export function StudioPlayoutSubDevices({ }) }, [studioId, wrappedSubDevices]) + const [updatedIds, setUpdatedIds] = useState(new Map()) + + const updateObjectId = useCallback( + (oldId: string, newId: string) => { + if (oldId === newId) return + + batchedOverridesHelper().changeItemId(oldId, newId).commit() + setUpdatedIds((prev) => new Map(prev).set(oldId, newId)) + }, + [batchedOverridesHelper, setUpdatedIds] + ) + + const discardChanges = useCallback(() => { + setUnsavedOverrides(undefined) + setUpdatedIds(new Map()) + }, []) + + const saveChanges = useCallback(() => { + if (studio?._id && unsavedOverrides) { + Studios.update(studio._id, { + $set: { + 'peripheralDeviceSettings.playoutDevices.overrides': unsavedOverrides, + }, + }) + setUnsavedOverrides(undefined) + } + + if (updatedIds.size > 0) { + setUpdatedIds(new Map()) + } + }, [studio?._id, unsavedOverrides]) + + const hasUnsavedChanges = useMemo(() => !!unsavedOverrides || updatedIds.size > 0, [unsavedOverrides, updatedIds]) + return (

@@ -104,8 +153,14 @@ export function StudioPlayoutSubDevices({