Skip to content

feat: add a 'save' button in peripheral device settings, instead of autosave#1572

Open
anteeek wants to merge 1 commit intomainfrom
feat/SOFIE-40
Open

feat: add a 'save' button in peripheral device settings, instead of autosave#1572
anteeek wants to merge 1 commit intomainfrom
feat/SOFIE-40

Conversation

@anteeek
Copy link
Contributor

@anteeek anteeek commented Dec 9, 2025

About the Contributor

Type of Contribution

This is a:

Feature

Current Behavior

Peripheral device settings are autosaved on every input edit, potentially causing problems on air

New Behavior

Peripheral device settings are only saved upon clicking a "save" button

Testing

  • I have added one or more unit tests for this PR
  • I have updated the relevant unit tests
  • No unit test changes are needed for this PR

Affected areas

Peripheral device settings UI

Time Frame

Not urgent, but we would like to get this merged into the in-development release.

Other Information

Status

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

Summary

This PR replaces autosave behavior in peripheral device settings with an explicit save workflow. Instead of persisting changes immediately on each input edit, users must now click a "save" button to commit configuration changes, preventing accidental on-air modifications.

Changes Overview

The implementation introduces a consistent staged-changes pattern across five peripheral device setting components. Each component now maintains local state for unsaved changes before persisting to the database.

Core Architecture Changes

Staged Override Management:

  • Introduces unsavedOverrides state in each device component (Ingest, Input, Playout, Parent) to batch configuration changes
  • Derives settingsWithOverrides by merging base settings with unsaved changes, ensuring UI reflects pending modifications
  • Replaces direct studio updates with batched override helpers for in-memory staging

Change Tracking:

  • Adds updatedIds map to track device ID renames during edits
  • Provides updateObjectId handler to apply ID changes through batched helpers
  • Derives hasUnsavedChanges flag from unsaved overrides and updated IDs

Persistence Control:

  • Implements saveChanges handler to persist all staged changes to the database in a single operation
  • Implements discardChanges handler to revert all unsaved modifications
  • Clears unsaved state after successful save

Component-Level Changes

GenericSubDevices.tsx: Extended public interfaces and component props:

  • SubDevicesTableProps, SummaryRowProps, SubDeviceEditRowProps now include change-tracking properties
  • Added: instantSaveOverrideHelper, hasUnsavedChanges, saveChanges, discardChanges, updateObjectId, updatedIds
  • SummaryRow displays pending-save indicator when device ID has changed
  • SubDeviceEditRow conditionally renders Discard/Save buttons when unsaved changes exist; otherwise shows original Confirm flow

IngestSubDevices.tsx (+63/-10):

  • Introduces unsavedOverrides state and baseSettings memo
  • Derives settingsWithOverrides for staged changes
  • Propagates new change-tracking props to GenericSubDevicesTable

InputSubDevices.tsx (+65/-10):

  • Similar staged override implementation with unsavedOverrides and settingsWithOverrides
  • Recomputes wrappedSubDevices based on staged settings
  • Extends addNewItem logic to work with staged changes

ParentDevices.tsx (+107/-40):

  • Tracks unsaved device config assignments alongside overrides
  • AssignPeripheralDeviceConfigId refactored: removed studioId parameter, added onChange callback for staged updates
  • New props in GenericParentDevicesTableProps and ParentDeviceEditRowProps for assignment state management

PlayoutSubDevices.tsx (+60/-5):

  • Implements unsavedOverrides and updatedIds tracking
  • Derives deviceSettings conditionally from unsaved or base settings
  • Passes control props to GenericSubDevicesTable

Benefits

  • Prevents accidental on-air configuration changes from unintended edits
  • Allows users to review all pending changes before committing
  • Enables atomic persistence of related configuration changes
  • Reduces risk of partial or concurrent edits affecting live systems

@codecov-commenter
Copy link

codecov-commenter commented Dec 9, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)
33.6% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@Saftret Saftret added the Contribution from SuperFly.tv Contributions sponsored by SuperFly.tv label Dec 10, 2025
@anteeek anteeek marked this pull request as ready for review December 16, 2025 09:18
@anteeek anteeek requested a review from a team as a code owner December 16, 2025 09:18
@anteeek anteeek changed the title SOFIE-40 | add a 'save' button in peripheral device settings, instead… feat: add a 'save' button in peripheral device settings, instead of autosave Jan 12, 2026
@nytamin nytamin requested a review from tsorbo January 12, 2026 10:02
@jstarpl
Copy link
Contributor

jstarpl commented Jan 12, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link

coderabbitai bot commented Jan 12, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review

Walkthrough

The pull request introduces a batched override management system across device settings components. Changes enable users to make multiple edits to device configurations before committing to the backend, with state tracking for unsaved overrides and ID changes, plus save/discard handlers throughout the component hierarchy.

Changes

Cohort / File(s) Summary
Core Component Extension
packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx
Extended SubDevicesTableProps, SummaryRowProps, and SubDeviceEditRowProps interfaces with unsaved changes tracking props: instantSaveOverrideHelper, hasUnsavedChanges, saveChanges, discardChanges, updateObjectId, updatedIds. Added pending-save indicator rendering and reworked edit action controls to show Save/Discard buttons when unsaved changes exist.
Sub-Device Batched Overrides
packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx,
packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx,
packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx
Introduced local unsavedOverrides state and updatedIds Map to batch changes before persistence. Replaced direct override handling with batchedOverrideHelper and added instantSaveOverrideHelper. Derived settingsWithOverrides to layer staged changes. Implemented saveChanges and discardChanges handlers; propagated new props to GenericSubDevicesTable.
Parent Device Assignment Tracking
packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx
Introduced unsaved overrides and per-config device assignment state. Replaced direct database updates with centralized handlers; refactored AssignPeripheralDeviceConfigId to use callback-based updates. Extended GenericParentDevicesTableProps, ParentDevicesTableProps, ParentDeviceEditRowProps, and AssignPeripheralDeviceConfigIdProps with hasUnsavedChanges, saveChanges, discardChanges, and assignment tracking props.
Package Dependencies
package.json
Updated dependency versions and build configuration (+61/-14 lines).

Sequence Diagram

sequenceDiagram
    actor User
    participant UI as Edit UI Component
    participant State as Local State Manager
    participant Backend as Database/Backend

    User->>UI: Make changes (e.g., edit ID, settings)
    activate UI
    UI->>State: Update unsavedOverrides via batchedOverrideHelper
    State->>State: Store staged changes in memory
    State->>UI: Reflect updates (show Save/Discard buttons)
    deactivate UI
    
    User->>UI: Click Save or Discard
    activate UI
    alt Save Changes
        UI->>State: Invoke saveChanges()
        State->>Backend: Persist unsavedOverrides via Studios.update/MeteorCall
        Backend->>Backend: Commit changes to database
        State->>State: Clear unsavedOverrides & updatedIds
        State->>UI: Update UI (hide Save/Discard, show edit controls)
    else Discard Changes
        UI->>State: Invoke discardChanges()
        State->>State: Reset unsavedOverrides & updatedIds to empty
        State->>UI: Revert UI to last-saved state
    end
    deactivate UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes


Poem

🐰 Hops excitedly

Once scattered, now batched—changes bloom unsaved,
A rabbit's delight: edit, then decide!
Save or discard with a flick of the ear,
No instant commits when mistakes appear.
Staged edits hop closer to perfection's care!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: replacing autosave with a manual save button for peripheral device settings.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/SOFIE-40

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx (1)

76-100: Missing studioId in dependency array.

The addNewItem callback uses studioId in the Studios.update call but it's not included in the dependency array. Additionally, settingsWithOverrides.overrides is in the dependency array but isn't used in the callback.

Proposed fix
 	const addNewItem = useCallback(() => {
 		const existingDevices = new Set(wrappedSubDevices.map((d) => d.id))
 		let idx = 0
 		while (existingDevices.has(`device${idx}`)) {
 			idx++
 		}
 
 		const newId = `device${idx}`
 		const newDevice = literal<StudioInputDevice>({
 			peripheralDeviceId: undefined,
 			options: {},
 		})
 
 		const addOp = literal<ObjectOverrideSetOp>({
 			op: 'set',
 			path: newId,
 			value: newDevice,
 		})
 
 		Studios.update(studioId, {
 			$push: {
 				'peripheralDeviceSettings.inputDevices.overrides': addOp,
 			},
 		})
-	}, [wrappedSubDevices, settingsWithOverrides.overrides])
+	}, [studioId, wrappedSubDevices])
packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx (1)

79-103: Missing studioId in dependency array.

Same issue as in InputSubDevices.tsx — the callback uses studioId but it's not in the dependency array.

Proposed fix
-	}, [wrappedSubDevices, settingsWithOverrides.overrides])
+	}, [studioId, wrappedSubDevices])
🤖 Fix all issues with AI agents
In @packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx:
- Around line 300-309: handleUpdateId captures editItemWithId but the hook
dependency array only lists [item.id, updateObjectId], risking stale closures;
update the useCallback dependencies to include editItemWithId (i.e., change the
dependency array for handleUpdateId to [item.id, updateObjectId,
editItemWithId]) so the callback updates when editItemWithId changes.

In @packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx:
- Around line 122-135: The saveChanges callback is missing updatedIds in its
dependency array, which can cause stale closures; update the useCallback
declaration for saveChanges to include updatedIds (and setUpdatedIds if not
already stable) in its dependency array so the hook rebinds when updatedIds
changes; locate the saveChanges function and modify its dependency list to
include updatedIds (and ensure setUpdatedIds is referenced correctly) so the
reset logic using setUpdatedIds(new Map<string, string>()) runs with the latest
state.
- Line 153: Compute and pass a memoized hasUnsavedChanges that includes both
unsavedOverrides and updatedIds like the other components: create a useMemo that
returns (!!unsavedOverrides || updatedIds.size > 0) and use that value for the
hasUnsavedChanges prop instead of the current !!unsavedOverrides; reference the
existing updatedIds and unsavedOverrides variables and the hasUnsavedChanges
prop in the component so Save/Discard appear when an ID was renamed.

In @packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx:
- Around line 119-132: The saveChanges callback references updatedIds.size but
updatedIds is not included in its dependency array, which can cause stale reads;
update the dependency array for saveChanges to include updatedIds (and keep
setUpdatedIds and other existing deps like studio?._id and unsavedOverrides) so
the hook correctly re-registers when updatedIds changes; locate the saveChanges
function and add updatedIds to its useCallback dependency list.

In @packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx:
- Around line 125-138: The saveChanges callback uses updatedIds.size but
updatedIds is not included in the dependency array, which can produce a stale
closure; update the dependency array for saveChanges to include updatedIds (and
keep studio?._id and unsavedOverrides) so the callback sees the current Map,
ensuring the branch that checks updatedIds.size runs correctly and that
setUpdatedIds(new Map<string,string>()) resets state as intended; locate the
saveChanges function and modify its dependency array to include updatedIds.
🧹 Nitpick comments (1)
packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx (1)

114-123: Consider surfacing assignment save failures to the user.

The Promise.all(...).catch() only logs to console.error. If any assignment fails, the user won't receive feedback, and unsavedAssignments is cleared regardless of success or failure. Consider adding user-facing error notification.

Proposed improvement sketch
 		if (Object.keys(unsavedAssignments).length > 0) {
 			Promise.all(
 				Object.entries<PeripheralDeviceId | undefined>(unsavedAssignments).map(async ([configId, deviceId]) => {
 					return MeteorCall.studio.assignConfigToPeripheralDevice(studioId, configId, deviceId ?? null)
 				})
-			).catch((e) => {
+			).then(() => {
+				setUnsavedAssignments({})
+			}).catch((e) => {
 				console.error('Failed to save assignments', e)
+				// TODO: Show user-facing error notification
 			})
-			setUnsavedAssignments({})
 		}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 24b7c79 and 2f87e4f.

📒 Files selected for processing (5)
  • packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx
  • packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx
  • packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx
  • packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx
  • packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx
🧰 Additional context used
🧬 Code graph analysis (5)
packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx (3)
packages/corelib/src/settings/objectWithOverrides.ts (2)
  • SomeObjectOverrideOp (19-19)
  • wrapDefaultObject (48-53)
packages/webui/src/client/ui/Settings/util/OverrideOpHelper.tsx (1)
  • useOverrideOpHelper (14-29)
packages/corelib/src/dataModel/Studio.ts (1)
  • StudioInputDevice (113-121)
packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx (3)
packages/corelib/src/settings/objectWithOverrides.ts (2)
  • SomeObjectOverrideOp (19-19)
  • wrapDefaultObject (48-53)
packages/webui/src/client/ui/Settings/util/OverrideOpHelper.tsx (1)
  • useOverrideOpHelper (14-29)
packages/corelib/src/dataModel/Studio.ts (1)
  • StudioIngestDevice (103-111)
packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx (3)
packages/corelib/src/settings/objectWithOverrides.ts (1)
  • SomeObjectOverrideOp (19-19)
packages/webui/src/client/ui/Settings/util/OverrideOpHelper.tsx (1)
  • useOverrideOpHelper (14-29)
packages/corelib/src/dataModel/Studio.ts (1)
  • StudioPlayoutDevice (123-131)
packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx (2)
packages/corelib/src/overrideOpHelper.ts (1)
  • OverrideOpHelper (174-174)
packages/webui/src/client/lib/Components/TextInput.tsx (1)
  • TextInputControl (27-121)
packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx (4)
packages/corelib/src/settings/objectWithOverrides.ts (2)
  • SomeObjectOverrideOp (19-19)
  • wrapDefaultObject (48-53)
packages/corelib/src/dataModel/Studio.ts (1)
  • StudioDeviceSettings (133-140)
packages/webui/src/client/ui/Settings/util/OverrideOpHelper.tsx (1)
  • useOverrideOpHelper (14-29)
packages/webui/src/client/lib/Components/DropdownInput.tsx (1)
  • DropdownInputOption (5-9)
🔇 Additional comments (4)
packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx (1)

344-360: LGTM!

The conditional rendering for Save/Discard buttons vs. the Confirm button is well structured. The UI correctly surfaces unsaved changes state and provides clear action buttons.

packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx (1)

53-73: LGTM!

The batched override pattern is correctly implemented. The deviceSettings memo properly layers unsavedOverrides on top of baseSettings, and wrappedSubDevices correctly depends on the merged settings.

packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx (2)

47-62: LGTM!

The state management for unsavedOverrides and unsavedAssignments is well structured. The deviceSettings memo correctly merges unsaved changes with the base settings.


491-507: LGTM!

The conditional Save/Discard UI in ParentDeviceEditRow follows the same pattern as the other device edit rows and provides a consistent user experience.

Copy link
Contributor

@tsorbo tsorbo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not review the code, but Henrik and I at NRK like the idea of adding a save button when editing peripheral device settings.

Copy link
Contributor

@jstarpl jstarpl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the things coderabbit pointed out, I have these suggestions:

}, [studioId, wrappedSubDevices])
}, [wrappedSubDevices, settingsWithOverrides.overrides])

const [updatedIds, setUpdatedIds] = useState(new Map<string, string>())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would really appreciate a comment here describing what the strings in key and value of this Map actually mean, that the key is the oldId for a sub-device, and the value is the newId.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +102 to +134
const [updatedIds, setUpdatedIds] = useState(new Map<string, string>())

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<string, string>())
}, [])

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<string, string>())
}
}, [studio?._id, unsavedOverrides])

const hasUnsavedChanges = useMemo(() => !!unsavedOverrides || updatedIds.size > 0, [unsavedOverrides, updatedIds])
Copy link
Contributor

@jstarpl jstarpl Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole section seems to be repeated for all of these *Devices.tsx files. Do you think it would make sense to maybe wrap them in a React hook that takes the one variable part (I think the saveChanges callback, perhaps even just the key for the $set object?), and just warp it up in a reusable code block?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good point - tried to do that and IMO it turned out even worse - same amount of lines of code but with more abstraction and didnt turn out well

@anteeek anteeek force-pushed the feat/SOFIE-40 branch 2 times, most recently from 9f98714 to 04edca7 Compare January 26, 2026 11:03
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
33.8% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

@anteeek
Copy link
Contributor Author

anteeek commented Jan 26, 2026

@jstarpl applied changes & responded to remarks, please let me know if it's ok now

@PeterC89 PeterC89 changed the base branch from release53 to main February 4, 2026 12:37
@Saftret Saftret requested a review from jstarpl February 10, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Contribution from SuperFly.tv Contributions sponsored by SuperFly.tv

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants