diff --git a/packages/webui/package.json b/packages/webui/package.json
index 14dd16f95d..5f9359c6e3 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -45,6 +45,7 @@
"@sofie-automation/meteor-lib": "1.52.0-in-development",
"@sofie-automation/shared-lib": "1.52.0-in-development",
"@sofie-automation/sorensen": "^1.4.3",
+ "@testing-library/user-event": "^14.5.2",
"@types/sinon": "^10.0.20",
"classnames": "^2.5.1",
"cubic-spline": "^3.0.3",
@@ -85,6 +86,7 @@
"devDependencies": {
"@babel/preset-env": "^7.24.8",
"@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@types/classnames": "^2.3.1",
"@types/deep-extend": "^0.6.2",
diff --git a/packages/webui/src/client/__tests__/jest-setup.cjs b/packages/webui/src/client/__tests__/jest-setup.cjs
index bb36d10e75..8856ce9d6a 100644
--- a/packages/webui/src/client/__tests__/jest-setup.cjs
+++ b/packages/webui/src/client/__tests__/jest-setup.cjs
@@ -1,4 +1,5 @@
/* eslint-disable node/no-unpublished-require */
+require('@testing-library/jest-dom')
// used by code creating XML with the DOM API to return an XML string
global.XMLSerializer = require('@xmldom/xmldom').XMLSerializer
diff --git a/packages/webui/src/client/ui/UserEditOperations/__tests__/PropertiesPanel.test.tsx b/packages/webui/src/client/ui/UserEditOperations/__tests__/PropertiesPanel.test.tsx
new file mode 100644
index 0000000000..3e70ccb829
--- /dev/null
+++ b/packages/webui/src/client/ui/UserEditOperations/__tests__/PropertiesPanel.test.tsx
@@ -0,0 +1,370 @@
+// Mock the ReactiveDataHelper:
+jest.mock('../../../lib/reactiveData/ReactiveDataHelper', () => {
+ class MockReactiveDataHelper {
+ protected _subs: Array<{ stop: () => void }> = []
+
+ protected subscribe() {
+ const sub = { stop: jest.fn() }
+ this._subs.push(sub)
+ return sub
+ }
+
+ protected autorun(f: () => void) {
+ f()
+ return { stop: jest.fn() }
+ }
+
+ destroy() {
+ this._subs.forEach((sub) => sub.stop())
+ this._subs = []
+ }
+ }
+
+ class MockWithManagedTracker extends MockReactiveDataHelper {
+ constructor() {
+ super()
+ }
+ }
+
+ return {
+ __esModule: true,
+ WithManagedTracker: MockWithManagedTracker,
+ meteorSubscribe: jest.fn().mockReturnValue({
+ stop: jest.fn(),
+ }),
+ }
+})
+
+jest.mock('i18next', () => ({
+ use: jest.fn().mockReturnThis(),
+ init: jest.fn().mockImplementation(() => Promise.resolve()),
+ t: (key: string) => key,
+ changeLanguage: jest.fn().mockImplementation(() => Promise.resolve()),
+ language: 'en',
+ exists: jest.fn(),
+ on: jest.fn(),
+ off: jest.fn(),
+ options: {},
+}))
+
+// React-i18next with Promise support
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ i18n: {
+ changeLanguage: jest.fn().mockImplementation(() => Promise.resolve()),
+ language: 'en',
+ exists: jest.fn(),
+ use: jest.fn().mockReturnThis(),
+ init: jest.fn().mockImplementation(() => Promise.resolve()),
+ on: jest.fn(),
+ off: jest.fn(),
+ options: {},
+ },
+ }),
+ initReactI18next: {
+ type: '3rdParty',
+ init: jest.fn(),
+ },
+}))
+
+import React from 'react'
+// eslint-disable-next-line node/no-unpublished-import
+import { renderHook, act, render, screen, RenderResult } from '@testing-library/react'
+// eslint-disable-next-line node/no-unpublished-import
+import '@testing-library/jest-dom'
+import { MeteorCall } from '../../../lib/meteorApi'
+import { TFunction } from 'i18next'
+
+import userEvent from '@testing-library/user-event'
+import { protectString } from '@sofie-automation/corelib/dist/protectedString'
+import { UIParts } from '../../Collections'
+import { Segments } from '../../../../client/collections'
+import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment'
+import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part'
+import { UserEditingType, UserEditingButtonType } from '@sofie-automation/blueprints-integration'
+import { SelectedElementProvider, useSelection } from '../../RundownView/SelectedElementsContext'
+import { MongoMock } from '../../../../__mocks__/mongo'
+import { PropertiesPanel } from '../PropertiesPanel'
+import { UserAction } from '../../../lib/clientUserAction'
+
+const mockSegmentsCollection = MongoMock.getInnerMockCollection(Segments)
+const mockPartsCollection = MongoMock.getInnerMockCollection(UIParts)
+
+// Mock Client User Action:
+jest.mock('../../../lib/clientUserAction', () => ({
+ doUserAction: jest.fn((_t: TFunction, e: unknown, _action: UserAction, callback: Function) =>
+ callback(e, Date.now())
+ ),
+}))
+
+// Mock Userchange Operation:
+jest.mock('../../../lib/meteorApi', () => ({
+ __esModule: true,
+ MeteorCall: {
+ userAction: {
+ executeUserChangeOperation: jest.fn(),
+ },
+ },
+}))
+
+// Mock SchemaFormInPlace Component
+jest.mock('../../../lib/forms/SchemaFormInPlace', () => ({
+ SchemaFormInPlace: () =>
Schema Form
,
+}))
+
+describe('PropertiesPanel', () => {
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ )
+
+ beforeEach(() => {
+ mockSegmentsCollection.remove({})
+ mockPartsCollection.remove({})
+ jest.clearAllMocks()
+ })
+
+ const createMockSegment = (id: string): DBSegment => ({
+ _id: protectString(id),
+ _rank: 1,
+ name: `Segment ${id}`,
+ rundownId: protectString('rundown1'),
+ externalId: `ext_${id}`,
+ userEditOperations: [
+ {
+ id: 'operation1',
+ label: { key: 'TEST_LABEL' },
+ type: UserEditingType.ACTION,
+ buttonType: UserEditingButtonType.SWITCH,
+ isActive: false,
+ },
+ ],
+ })
+
+ const createMockPart = (id: string, segmentId: string): DBPart => ({
+ _id: protectString(id),
+ _rank: 1,
+ expectedDurationWithTransition: 0,
+ title: `Part ${id}`,
+ rundownId: protectString('rundown1'),
+ segmentId: protectString(segmentId),
+ externalId: `ext_${id}`,
+ userEditOperations: [
+ {
+ id: 'operation2',
+ label: { key: 'TEST_PART_LABEL' },
+ type: UserEditingType.ACTION,
+ buttonType: UserEditingButtonType.BUTTON,
+ isActive: true,
+ },
+ ],
+ })
+
+ test('renders empty when no element selected', () => {
+ const { container } = render(, { wrapper })
+ expect(container.querySelector('.properties-panel')).toBeTruthy()
+ expect(container.querySelector('.propertiespanel-pop-up__contents')).toBeFalsy()
+ })
+
+ test('renders segment properties when segment is selected', async () => {
+ const mockSegment = createMockSegment('segment1')
+ mockSegmentsCollection.insert(mockSegment)
+
+ // Create a custom wrapper that includes both providers
+ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+ )
+
+ // Render both the hook and component in the same provider tree
+ const { result } = renderHook(() => useSelection(), { wrapper: TestWrapper })
+ let rendered: RenderResult
+
+ await act(async () => {
+ rendered = render(, { wrapper: TestWrapper })
+ })
+
+ // Update selection
+ await act(async () => {
+ result.current.clearAndSetSelection({
+ type: 'segment',
+ elementId: mockSegment._id,
+ })
+ })
+ //@ts-expect-error error because avoiding an undefined type
+ if (!rendered) throw new Error('Component not rendered')
+
+ // Force a rerender
+ await act(async () => {
+ rendered.rerender()
+ })
+
+ // Wait for the header element to appear
+ await screen.findByText('SEGMENT : Segment segment1')
+
+ const header = rendered.container.querySelector('.propertiespanel-pop-up__header')
+ const switchButton = rendered.container.querySelector('.propertiespanel-pop-up__switchbutton')
+
+ expect(header).toHaveTextContent('SEGMENT : Segment segment1')
+ expect(switchButton).toBeTruthy()
+ })
+
+ test('renders part properties when part is selected', async () => {
+ const mockSegment = createMockSegment('segment1')
+ const mockPart = createMockPart('part1', String(mockSegment._id))
+
+ mockSegmentsCollection.insert(mockSegment)
+ mockPartsCollection.insert(mockPart)
+
+ // Create a custom wrapper that includes both providers
+ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+ )
+
+ // Render both the hook and component in the same provider tree
+ const { result } = renderHook(() => useSelection(), { wrapper: TestWrapper })
+ let rendered: RenderResult
+
+ await act(async () => {
+ rendered = render(, { wrapper: TestWrapper })
+ })
+
+ // Update selection
+ await act(async () => {
+ result.current.clearAndSetSelection({
+ type: 'part',
+ elementId: mockPart._id,
+ })
+ })
+
+ //@ts-expect-error error because avoiding an undefined type
+ if (!rendered) throw new Error('Component not rendered')
+
+ // Force a rerender
+ await act(async () => {
+ rendered.rerender()
+ })
+
+ // Wait for the header element to appear
+ await screen.findByText('PART : Part part1')
+
+ const header = rendered.container.querySelector('.propertiespanel-pop-up__header')
+ const button = rendered.container.querySelector('.propertiespanel-pop-up__button')
+
+ expect(header).toHaveTextContent('PART : Part part1')
+ expect(button).toBeTruthy()
+ })
+
+ test('handles user edit operations for segments', async () => {
+ const mockSegment = createMockSegment('segment1')
+ mockSegmentsCollection.insert(mockSegment)
+
+ // First render the selection hook
+ const { result } = renderHook(() => useSelection(), { wrapper })
+
+ // Then render the properties panel
+ const { container } = render(, { wrapper })
+
+ // Update selection using the hook result
+ act(() => {
+ result.current.clearAndSetSelection({
+ type: 'segment',
+ elementId: mockSegment._id,
+ })
+ })
+
+ const switchButton = container.querySelector('.propertiespanel-pop-up__switchbutton')
+ expect(switchButton).toBeTruthy()
+
+ // Toggle the switch
+ await userEvent.click(switchButton!)
+
+ // Check if commit button is enabled
+ const commitButton = screen.getByText('COMMIT CHANGES')
+ expect(commitButton).toBeEnabled()
+
+ // Commit changes
+ await userEvent.click(commitButton)
+
+ expect(MeteorCall.userAction.executeUserChangeOperation).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ protectString('rundown1'),
+ {
+ segmentExternalId: mockSegment.externalId,
+ partExternalId: undefined,
+ pieceExternalId: undefined,
+ },
+ {
+ id: 'operation1',
+ values: undefined,
+ }
+ )
+ })
+
+ test('handles revert changes', async () => {
+ const mockSegment = createMockSegment('segment1')
+ mockSegmentsCollection.insert(mockSegment)
+
+ // First render the selection hook
+ const { result } = renderHook(() => useSelection(), { wrapper })
+
+ // Then render the properties panel
+ const { container } = render(, { wrapper })
+
+ // Update selection using the hook result
+ act(() => {
+ result.current.clearAndSetSelection({
+ type: 'segment',
+ elementId: mockSegment._id,
+ })
+ })
+
+ // Make a change
+ const switchButton = container.querySelector('.propertiespanel-pop-up__switchbutton')
+ await userEvent.click(switchButton!)
+
+ // Click revert button
+ const revertButton = screen.getByText('REVERT CHANGES')
+ await userEvent.click(revertButton)
+
+ expect(MeteorCall.userAction.executeUserChangeOperation).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ protectString('rundown1'),
+ {
+ segmentExternalId: mockSegment.externalId,
+ partExternalId: undefined,
+ pieceExternalId: undefined,
+ },
+ {
+ id: 'REVERT_SEGMENT',
+ }
+ )
+ })
+
+ test('closes panel when close button is clicked', async () => {
+ const mockSegment = createMockSegment('segment1')
+ mockSegmentsCollection.insert(mockSegment)
+
+ // First render the selection hook
+ const { result } = renderHook(() => useSelection(), { wrapper })
+
+ // Then render the properties panel
+ const { container } = render(, { wrapper })
+
+ // Update selection using the hook result
+ act(() => {
+ result.current.clearAndSetSelection({
+ type: 'segment',
+ elementId: mockSegment._id,
+ })
+ })
+
+ const closeButton = container.querySelector('.propertiespanel-pop-up_close')
+ expect(closeButton).toBeTruthy()
+
+ await userEvent.click(closeButton!)
+
+ expect(container.querySelector('.propertiespanel-pop-up__contents')).toBeFalsy()
+ })
+})
diff --git a/packages/yarn.lock b/packages/yarn.lock
index 3b52f39466..538e14a02a 100644
--- a/packages/yarn.lock
+++ b/packages/yarn.lock
@@ -23,6 +23,13 @@ __metadata:
languageName: node
linkType: hard
+"@adobe/css-tools@npm:^4.4.0":
+ version: 4.4.0
+ resolution: "@adobe/css-tools@npm:4.4.0"
+ checksum: 1f08fb49bf17fc7f2d1a86d3e739f29ca80063d28168307f1b0a962ef37501c5667271f6771966578897f2e94e43c4770fd802728a6e6495b812da54112d506a
+ languageName: node
+ linkType: hard
+
"@algolia/autocomplete-core@npm:1.9.3":
version: 1.9.3
resolution: "@algolia/autocomplete-core@npm:1.9.3"
@@ -5315,7 +5322,9 @@ __metadata:
"@sofie-automation/shared-lib": 1.52.0-in-development
"@sofie-automation/sorensen": ^1.4.3
"@testing-library/dom": ^10.4.0
+ "@testing-library/jest-dom": ^6.6.3
"@testing-library/react": ^16.0.1
+ "@testing-library/user-event": ^14.5.2
"@types/classnames": ^2.3.1
"@types/deep-extend": ^0.6.2
"@types/react": ^18.3.3
@@ -5915,6 +5924,21 @@ __metadata:
languageName: node
linkType: hard
+"@testing-library/jest-dom@npm:^6.6.3":
+ version: 6.6.3
+ resolution: "@testing-library/jest-dom@npm:6.6.3"
+ dependencies:
+ "@adobe/css-tools": ^4.4.0
+ aria-query: ^5.0.0
+ chalk: ^3.0.0
+ css.escape: ^1.5.1
+ dom-accessibility-api: ^0.6.3
+ lodash: ^4.17.21
+ redent: ^3.0.0
+ checksum: c1dc4260b05309a0084416639006cd105849acc5b102bef682a3b19bd6fce07ff6762085fc7f2599546c995a2fc66fdb1d70e50e22a634a0098524056cc9e511
+ languageName: node
+ linkType: hard
+
"@testing-library/react@npm:^16.0.1":
version: 16.0.1
resolution: "@testing-library/react@npm:16.0.1"
@@ -5935,6 +5959,15 @@ __metadata:
languageName: node
linkType: hard
+"@testing-library/user-event@npm:^14.5.2":
+ version: 14.5.2
+ resolution: "@testing-library/user-event@npm:14.5.2"
+ peerDependencies:
+ "@testing-library/dom": ">=7.21.4"
+ checksum: d76937dffcf0082fbf3bb89eb2b81a31bf5448048dd61c33928c5f10e33a58e035321d39145cefd469bb5a499c68a5b4086b22f1a44e3e7c7e817dc5f6782867
+ languageName: node
+ linkType: hard
+
"@tokenizer/token@npm:^0.3.0":
version: 0.3.0
resolution: "@tokenizer/token@npm:0.3.0"
@@ -7848,6 +7881,13 @@ __metadata:
languageName: node
linkType: hard
+"aria-query@npm:^5.0.0":
+ version: 5.3.2
+ resolution: "aria-query@npm:5.3.2"
+ checksum: d971175c85c10df0f6d14adfe6f1292409196114ab3c62f238e208b53103686f46cc70695a4f775b73bc65f6a09b6a092fd963c4f3a5a7d690c8fc5094925717
+ languageName: node
+ linkType: hard
+
"array-buffer-byte-length@npm:^1.0.1":
version: 1.0.1
resolution: "array-buffer-byte-length@npm:1.0.1"
@@ -9198,6 +9238,16 @@ asn1@evs-broadcast/node-asn1:
languageName: node
linkType: hard
+"chalk@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "chalk@npm:3.0.0"
+ dependencies:
+ ansi-styles: ^4.1.0
+ supports-color: ^7.1.0
+ checksum: 8e3ddf3981c4da405ddbd7d9c8d91944ddf6e33d6837756979f7840a29272a69a5189ecae0ff84006750d6d1e92368d413335eab4db5476db6e6703a1d1e0505
+ languageName: node
+ linkType: hard
+
"char-regex@npm:^1.0.2":
version: 1.0.2
resolution: "char-regex@npm:1.0.2"
@@ -10475,6 +10525,13 @@ asn1@evs-broadcast/node-asn1:
languageName: node
linkType: hard
+"css.escape@npm:^1.5.1":
+ version: 1.5.1
+ resolution: "css.escape@npm:1.5.1"
+ checksum: f6d38088d870a961794a2580b2b2af1027731bb43261cfdce14f19238a88664b351cc8978abc20f06cc6bbde725699dec8deb6fe9816b139fc3f2af28719e774
+ languageName: node
+ linkType: hard
+
"cssesc@npm:^3.0.0":
version: 3.0.0
resolution: "cssesc@npm:3.0.0"
@@ -11614,6 +11671,13 @@ asn1@evs-broadcast/node-asn1:
languageName: node
linkType: hard
+"dom-accessibility-api@npm:^0.6.3":
+ version: 0.6.3
+ resolution: "dom-accessibility-api@npm:0.6.3"
+ checksum: c325b5144bb406df23f4affecffc117dbaec9af03daad9ee6b510c5be647b14d28ef0a4ea5ca06d696d8ab40bb777e5fed98b985976fdef9d8790178fa1d573f
+ languageName: node
+ linkType: hard
+
"dom-converter@npm:^0.2.0":
version: 0.2.0
resolution: "dom-converter@npm:0.2.0"