diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index e1ec97bea5..329a977a3b 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -367,6 +367,7 @@ export function studioSettingsFrom(apiStudioSettings: APIStudioSettings): Comple enableQuickLoop: apiStudioSettings.enableQuickLoop, forceQuickLoopAutoNext: forceQuickLoopAutoNextFrom(apiStudioSettings.forceQuickLoopAutoNext), fallbackPartDuration: apiStudioSettings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION, + enableUserEdits: apiStudioSettings.enableUserEdits, allowAdlibTestingSegment: apiStudioSettings.allowAdlibTestingSegment, allowHold: apiStudioSettings.allowHold ?? true, // Backwards compatible allowPieceDirectPlay: apiStudioSettings.allowPieceDirectPlay ?? true, // Backwards compatible @@ -390,6 +391,7 @@ export function APIStudioSettingsFrom(settings: IStudioSettings): Complete */ userEditOperations?: UserEditingDefinition[] + /** + * Properties that are user editable from the properties panel in the Sofie UI, if the user saves changes to these + * it will trigger a user edit operation of type DefaultUserOperationEditProperties + */ + userEditProperties?: UserEditingProperties + /** * Whether to stop this piece before the 'keepalive' period of the part */ diff --git a/packages/blueprints-integration/src/documents/segment.ts b/packages/blueprints-integration/src/documents/segment.ts index 0253b43e8c..36502a893a 100644 --- a/packages/blueprints-integration/src/documents/segment.ts +++ b/packages/blueprints-integration/src/documents/segment.ts @@ -1,4 +1,4 @@ -import type { UserEditingDefinition } from '../userEditing' +import { UserEditingDefinition, UserEditingProperties } from '../userEditing' export enum SegmentDisplayMode { Timeline = 'timeline', @@ -52,6 +52,12 @@ export interface IBlueprintSegment diff --git a/packages/blueprints-integration/src/ingest.ts b/packages/blueprints-integration/src/ingest.ts index f2511241fd..7ca21420af 100644 --- a/packages/blueprints-integration/src/ingest.ts +++ b/packages/blueprints-integration/src/ingest.ts @@ -124,11 +124,41 @@ export interface UserOperationTarget { pieceExternalId: string | undefined } -export type DefaultUserOperations = { - id: '__sofie-move-segment' // Future: define properly +export enum DefaultUserOperationsTypes { + REVERT_SEGMENT = '__sofie-revert-segment', + REVERT_PART = '__sofie-revert-part', + REVERT_RUNDOWN = '__sofie-revert-rundown', + UPDATE_PROPS = '__sofie-update-props', +} + +export interface DefaultUserOperationRevertRundown { + id: DefaultUserOperationsTypes.REVERT_RUNDOWN + payload: Record +} + +export interface DefaultUserOperationRevertSegment { + id: DefaultUserOperationsTypes.REVERT_SEGMENT payload: Record } +export interface DefaultUserOperationRevertPart { + id: DefaultUserOperationsTypes.REVERT_PART +} + +export interface DefaultUserOperationEditProperties { + id: DefaultUserOperationsTypes.UPDATE_PROPS + payload: { + pieceTypeProperties: { type: string; value: Record } + globalProperties: Record + } +} + +export type DefaultUserOperations = + | DefaultUserOperationRevertRundown + | DefaultUserOperationRevertSegment + | DefaultUserOperationRevertPart + | DefaultUserOperationEditProperties + export interface UserOperationChange { /** Indicate that this change is from user operations */ source: IngestChangeType.User diff --git a/packages/blueprints-integration/src/userEditing.ts b/packages/blueprints-integration/src/userEditing.ts index 7d2c2718ea..607998cef7 100644 --- a/packages/blueprints-integration/src/userEditing.ts +++ b/packages/blueprints-integration/src/userEditing.ts @@ -1,6 +1,7 @@ import type { JSONBlob } from '@sofie-automation/shared-lib/dist/lib/JSONBlob' import type { ITranslatableMessage } from './translations' -import type { JSONSchema } from '@sofie-automation/shared-lib/dist/lib/JSONSchemaTypes' +import { JSONSchema } from '@sofie-automation/shared-lib/dist/lib/JSONSchemaTypes' +import { SourceLayerType } from './content' /** * Description of a user performed editing operation allowed on an document @@ -16,8 +17,10 @@ export interface UserEditingDefinitionAction { id: string /** Label to show to the user for this operation */ label: ITranslatableMessage - /** Icon to show to when this action is 'active' */ + /** Icon to show when this action is 'active' */ svgIcon?: string + /** Icon to show when this action is 'disabled' */ + svgIconInactive?: string /** Whether this action should be indicated as being active */ isActive?: boolean } @@ -40,6 +43,53 @@ export interface UserEditingDefinitionForm { export enum UserEditingType { /** Action */ ACTION = 'action', - /** Form of selections */ + /** Form */ FORM = 'form', } + +export interface UserEditingSourceLayer { + sourceLayerLabel: string + sourceLayerType: SourceLayerType + schema: JSONBlob + defaultValue?: Record +} + +export interface UserEditingProperties { + /** + * These properties are dependent on the (primary) piece type, the user will get the option + * to select the type of piece (from the SourceLayerTypes i.e. Camera or Split etc.) and then + * be presented the corresponding form + * + * example: + * { + * schema: { + * camera: '{ "type": "object", "properties": { "input": { "type": "number" } } }', + * split: '{ "type": "object", ... }', + * }, + * currentValue: { + * type: 'camera', + * value: { + * input: 3 + * }, + * } + * } + */ + pieceTypeProperties?: { + schema: Record + currentValue: { type: string; value: Record } + } + + /** + * These are properties that are available to edit regardless of the piece type, examples + * could be whether it an element is locked from NRCS updates + * + * if you do not want the piece type to be changed, then use only this field. + */ + globalProperties?: { schema: JSONBlob; currentValue: Record } + + /** + * A list of id's of operations to be exposed on the properties panel as buttons. These operations + * must be available on the element + */ + operations?: UserEditingDefinitionAction[] +} diff --git a/packages/corelib/src/dataModel/Part.ts b/packages/corelib/src/dataModel/Part.ts index 5194cb98b8..9712dbe43d 100644 --- a/packages/corelib/src/dataModel/Part.ts +++ b/packages/corelib/src/dataModel/Part.ts @@ -3,7 +3,7 @@ import { ITranslatableMessage } from '../TranslatableMessage' import { PartId, RundownId, SegmentId } from './Ids' import { PartNote } from './Notes' import { ReadonlyDeep } from 'type-fest' -import { CoreUserEditingDefinition } from './UserEditingDefinitions' +import { CoreUserEditingDefinition, CoreUserEditingProperties } from './UserEditingDefinitions' export interface PartInvalidReason { message: ITranslatableMessage @@ -41,6 +41,12 @@ export interface DBPart extends Omit { * User editing definitions for this part */ userEditOperations?: CoreUserEditingDefinition[] + + /** + * Properties that are user editable from the properties panel in the Sofie UI, if the user saves changes to these + * it will trigger a user edit operation of type DefaultUserOperationEditProperties + */ + userEditProperties?: CoreUserEditingProperties } export function isPartPlayable(part: Pick, 'invalid' | 'floated'>): boolean { diff --git a/packages/corelib/src/dataModel/Piece.ts b/packages/corelib/src/dataModel/Piece.ts index 88d8e95865..17a864341b 100644 --- a/packages/corelib/src/dataModel/Piece.ts +++ b/packages/corelib/src/dataModel/Piece.ts @@ -7,7 +7,7 @@ import { } from '@sofie-automation/blueprints-integration' import { ProtectedString, protectString, unprotectString } from '../protectedString' import { PieceId, RundownId, SegmentId, PartId } from './Ids' -import { CoreUserEditingDefinition } from './UserEditingDefinitions' +import { CoreUserEditingDefinition, CoreUserEditingProperties } from './UserEditingDefinitions' /** A generic list of playback availability statuses for a Piece */ export enum PieceStatusCode { @@ -50,7 +50,9 @@ export interface PieceGeneric extends Omit { /** Stringified timelineObjects */ timelineObjectsString: PieceTimelineObjectsBlob } -export interface Piece extends PieceGeneric, Omit { +export interface Piece + extends PieceGeneric, + Omit { /** * This is the id of the rundown this piece starts playing in. * Currently this is the only rundown the piece could be playing in @@ -77,6 +79,12 @@ export interface Piece extends PieceGeneric, Omit diff --git a/packages/corelib/src/dataModel/Segment.ts b/packages/corelib/src/dataModel/Segment.ts index f755b57efb..f07e59706d 100644 --- a/packages/corelib/src/dataModel/Segment.ts +++ b/packages/corelib/src/dataModel/Segment.ts @@ -1,7 +1,7 @@ import { SegmentDisplayMode, SegmentTimingInfo } from '@sofie-automation/blueprints-integration' import { SegmentId, RundownId } from './Ids' import { SegmentNote } from './Notes' -import { CoreUserEditingDefinition } from './UserEditingDefinitions' +import { CoreUserEditingDefinition, CoreUserEditingProperties } from './UserEditingDefinitions' export enum SegmentOrphanedReason { /** Segment is deleted from the NRCS but we still need it */ @@ -51,4 +51,10 @@ export interface DBSegment { * User editing definitions for this segment */ userEditOperations?: CoreUserEditingDefinition[] + + /** + * Properties that are user editable from the properties panel in the Sofie UI, if the user saves changes to these + * it will trigger a user edit operation of type DefaultUserOperationEditProperties + */ + userEditProperties?: CoreUserEditingProperties } diff --git a/packages/corelib/src/dataModel/UserEditingDefinitions.ts b/packages/corelib/src/dataModel/UserEditingDefinitions.ts index 4930fbfbda..194b604054 100644 --- a/packages/corelib/src/dataModel/UserEditingDefinitions.ts +++ b/packages/corelib/src/dataModel/UserEditingDefinitions.ts @@ -1,4 +1,9 @@ -import type { UserEditingType, JSONBlob, JSONSchema } from '@sofie-automation/blueprints-integration' +import type { + UserEditingType, + JSONBlob, + JSONSchema, + UserEditingSourceLayer, +} from '@sofie-automation/blueprints-integration' import type { ITranslatableMessage } from '../TranslatableMessage' export type CoreUserEditingDefinition = CoreUserEditingDefinitionAction | CoreUserEditingDefinitionForm @@ -9,12 +14,17 @@ export interface CoreUserEditingDefinitionAction { id: string /** Label to show to the user for this operation */ label: ITranslatableMessage - /** Icon to show to when this action is 'active' */ + /** Icon to show when this action is 'active' */ svgIcon?: string + /** Icon to show when this action is 'disabled' */ + svgIconInactive?: string /** Whether this action should be indicated as being active */ isActive?: boolean } +/** + * A simple form based operation + */ export interface CoreUserEditingDefinitionForm { type: UserEditingType.FORM /** Id of this operation */ @@ -28,3 +38,48 @@ export interface CoreUserEditingDefinitionForm { /** Translation namespaces to use when rendering this form */ translationNamespaces: string[] } + +export interface CoreUserEditingProperties { + /** + * These properties are dependent on the (primary) piece type, the user will get the option + * to select the type of piece (from the SourceLayerTypes i.e. Camera or Split etc.) and then + * be presented the corresponding form + * + * example: + * { + * schema: { + * camera: '{ "type": "object", "properties": { "input": { "type": "number" } } }', + * split: '{ "type": "object", ... }', + * }, + * currentValue: { + * type: 'camera', + * value: { + * input: 3 + * }, + * } + * } + */ + pieceTypeProperties?: { + schema: Record + currentValue: { type: string; value: Record } + } + + /** + * These are properties that are available to edit regardless of the piece type, examples + * could be whether it an element is locked from NRCS updates + * + * if you do not want the piece type to be changed, then use only this field. + */ + globalProperties?: { schema: JSONBlob; currentValue: Record } + + /** + * A list of id's of operations to be exposed on the properties panel as buttons. These operations + * must be available on the element + * + * note - perhaps these should have their own full definitions? + */ + operations?: CoreUserEditingDefinitionAction[] + + /** Translation namespaces to use when rendering this form */ + translationNamespaces: string[] +} diff --git a/packages/documentation/docs/for-developers/json-config-schema.md b/packages/documentation/docs/for-developers/json-config-schema.md index b56e6e6ee7..862a5dd31f 100644 --- a/packages/documentation/docs/for-developers/json-config-schema.md +++ b/packages/documentation/docs/for-developers/json-config-schema.md @@ -43,7 +43,12 @@ If an integer property, whether to treat it as zero-based ### `ui:displayType` Override the presentation with a special mode. -Currently only valid for string properties. Valid values are 'json'. + +Currently only valid for: + +- object properties. Valid values are 'json'. +- string properties. Valid values are 'base64-image'. +- boolean properties. Valid values are 'switch'. ### `tsEnumNames` diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index 8159c41c82..60539616ec 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -13,6 +13,7 @@ import { CoreUserEditingDefinition, CoreUserEditingDefinitionAction, CoreUserEditingDefinitionForm, + CoreUserEditingProperties, } from '@sofie-automation/corelib/dist/dataModel/UserEditingDefinitions' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { assertNever, clone, Complete, literal, omit } from '@sofie-automation/corelib/dist/lib' @@ -56,6 +57,7 @@ import { UserEditingDefinition, UserEditingDefinitionAction, UserEditingDefinitionForm, + UserEditingProperties, UserEditingType, } from '@sofie-automation/blueprints-integration/dist/userEditing' import type { PlayoutMutatablePart } from '../../playout/model/PlayoutPartInstanceModel' @@ -97,6 +99,7 @@ export const IBlueprintPieceObjectsSampleKeys = allKeysOfObject notInVision: true, abSessions: true, userEditOperations: true, + userEditProperties: true, excludeDuringPartKeepalive: true, }) @@ -121,6 +124,7 @@ export const PlayoutMutatablePartSampleKeys = allKeysOfObject extendOnHold: piece.extendOnHold, notInVision: piece.notInVision, userEditOperations: translateUserEditsToBlueprint(piece.userEditOperations), + userEditProperties: translateUserEditPropertiesToBlueprint(piece.userEditProperties), excludeDuringPartKeepalive: piece.excludeDuringPartKeepalive, } @@ -282,6 +287,7 @@ export function convertPartToBlueprints(part: ReadonlyDeep): IBlueprintP part.hackListenToMediaObjectUpdates ), userEditOperations: translateUserEditsToBlueprint(part.userEditOperations), + userEditProperties: translateUserEditPropertiesToBlueprint(part.userEditProperties), } return obj @@ -351,6 +357,7 @@ export function convertSegmentToBlueprints(segment: ReadonlyDeep): IB showShelf: segment.showShelf, segmentTiming: segment.segmentTiming, userEditOperations: translateUserEditsToBlueprint(segment.userEditOperations), + userEditProperties: translateUserEditPropertiesToBlueprint(segment.userEditProperties), } return obj @@ -514,6 +521,7 @@ function translateUserEditsToBlueprint( id: userEdit.id, label: omit(userEdit.label, 'namespaces'), svgIcon: userEdit.svgIcon, + svgIconInactive: userEdit.svgIconInactive, isActive: userEdit.isActive, } satisfies Complete case UserEditingType.FORM: @@ -532,6 +540,29 @@ function translateUserEditsToBlueprint( ) } +function translateUserEditPropertiesToBlueprint( + props: ReadonlyDeep | undefined +): UserEditingProperties | undefined { + if (!props) return undefined + + return { + globalProperties: props.globalProperties, + pieceTypeProperties: props.pieceTypeProperties, + + operations: props.operations?.map( + (userEdit) => + ({ + type: UserEditingType.ACTION, + id: userEdit.id, + label: omit(userEdit.label, 'namespaces'), + svgIcon: userEdit.svgIcon, + svgIconInactive: userEdit.svgIconInactive, + isActive: userEdit.isActive, + } satisfies Complete) + ), + } +} + export function translateUserEditsFromBlueprint( userEdits: UserEditingDefinition[] | undefined, blueprintIds: BlueprintId[] @@ -547,6 +578,7 @@ export function translateUserEditsFromBlueprint( id: userEdit.id, label: wrapTranslatableMessageFromBlueprints(userEdit.label, blueprintIds), svgIcon: userEdit.svgIcon, + svgIconInactive: userEdit.svgIconInactive, isActive: userEdit.isActive, } satisfies Complete case UserEditingType.FORM: @@ -566,6 +598,32 @@ export function translateUserEditsFromBlueprint( ) } +export function translateUserEditPropertiesFromBlueprint( + props: UserEditingProperties | undefined, + blueprintIds: BlueprintId[] +): CoreUserEditingProperties | undefined { + if (!props) return undefined + + return { + globalProperties: clone(props.globalProperties), + pieceTypeProperties: clone(props.pieceTypeProperties), + + operations: props.operations?.map( + (userEdit) => + ({ + type: UserEditingType.ACTION, + id: userEdit.id, + label: wrapTranslatableMessageFromBlueprints(userEdit.label, blueprintIds), + svgIcon: userEdit.svgIcon, + svgIconInactive: userEdit.svgIconInactive, + isActive: userEdit.isActive, + } satisfies Complete) + ), + + translationNamespaces: blueprintIds.map((id) => `blueprint_${id}`), + } +} + /** * Converts a BlueprintMutatablePart into a PlayoutMutatablePart */ @@ -586,6 +644,14 @@ export function convertPartialBlueprintMutablePartToCore( delete playoutUpdatePart.userEditOperations } + if ('userEditProperties' in updatePart) { + playoutUpdatePart.userEditProperties = translateUserEditPropertiesFromBlueprint(updatePart.userEditProperties, [ + blueprintId, + ]) + } else { + delete playoutUpdatePart.userEditOperations + } + return playoutUpdatePart } diff --git a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts index 849b95c2b0..66dd99cbfb 100644 --- a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts +++ b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts @@ -394,6 +394,7 @@ export class PartAndPieceInstanceActionService { floated: false, expectedDurationWithTransition: undefined, // Filled in later userEditOperations: [], // Adlibbed parts can't be edited by ingest + userEditProperties: undefined, } const pieces = postProcessPieces( diff --git a/packages/job-worker/src/blueprints/postProcess.ts b/packages/job-worker/src/blueprints/postProcess.ts index 52889f9189..90afdb2fc9 100644 --- a/packages/job-worker/src/blueprints/postProcess.ts +++ b/packages/job-worker/src/blueprints/postProcess.ts @@ -44,7 +44,7 @@ import { setDefaultIdOnExpectedPackages } from '../ingest/expectedPackages' import { logger } from '../logging' import { validateTimeline } from 'superfly-timeline' import { ReadonlyDeep } from 'type-fest' -import { translateUserEditsFromBlueprint } from './context/lib' +import { translateUserEditPropertiesFromBlueprint, translateUserEditsFromBlueprint } from './context/lib' function getIdHash(docType: string, usedIds: Map, uniqueId: string): string { const count = usedIds.get(uniqueId) @@ -110,6 +110,7 @@ export function postProcessPieces( invalid: setInvalid ?? false, timelineObjectsString: EmptyPieceTimelineObjectsBlob, userEditOperations: translateUserEditsFromBlueprint(orgPiece.userEditOperations, [blueprintId]), + userEditProperties: translateUserEditPropertiesFromBlueprint(orgPiece.userEditProperties, [blueprintId]), } if (piece.pieceType !== IBlueprintPieceType.Normal) { diff --git a/packages/job-worker/src/ingest/generationSegment.ts b/packages/job-worker/src/ingest/generationSegment.ts index 2898cef866..5842047f3c 100644 --- a/packages/job-worker/src/ingest/generationSegment.ts +++ b/packages/job-worker/src/ingest/generationSegment.ts @@ -24,7 +24,7 @@ import { IngestReplacePartType, IngestSegmentModel } from './model/IngestSegment import { ReadonlyDeep } from 'type-fest' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { WrappedShowStyleBlueprint } from '../blueprints/cache' -import { translateUserEditsFromBlueprint } from '../blueprints/context/lib' +import { translateUserEditPropertiesFromBlueprint, translateUserEditsFromBlueprint } from '../blueprints/context/lib' async function getWatchedPackagesHelper( context: JobContext, @@ -293,6 +293,9 @@ function updateModelWithGeneratedSegment( userEditOperations: translateUserEditsFromBlueprint(blueprintSegment.segment.userEditOperations, [ blueprintId, ]), + userEditProperties: translateUserEditPropertiesFromBlueprint(blueprintSegment.segment.userEditProperties, [ + blueprintId, + ]), }) ) @@ -376,6 +379,9 @@ function updateModelWithGeneratedPart( } : undefined, userEditOperations: translateUserEditsFromBlueprint(blueprintPart.part.userEditOperations, [blueprintId]), + userEditProperties: translateUserEditPropertiesFromBlueprint(blueprintPart.part.userEditProperties, [ + blueprintId, + ]), }) // Update pieces diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts index 319b3644bb..79454df589 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts @@ -539,14 +539,17 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { const trimmedProps: Partial = filterPropsToAllowed(props) if (Object.keys(trimmedProps).length === 0) return false - this.#compareAndSetPartInstanceValue( - 'part', - { - ...this.partInstanceImpl.part, - ...trimmedProps, - }, - true - ) + const newPart: DBPart = { + ...this.partInstanceImpl.part, + ...trimmedProps, + userEditOperations: this.partInstanceImpl.part.userEditOperations, // Replaced below if changed + userEditProperties: this.partInstanceImpl.part.userEditProperties, + } + + // Only replace `userEditOperations` if new values were provided + if ('userEditOperations' in trimmedProps) newPart.userEditOperations = props.userEditOperations + + this.#compareAndSetPartInstanceValue('part', newPart, true) return true } diff --git a/packages/shared-lib/src/core/model/StudioSettings.ts b/packages/shared-lib/src/core/model/StudioSettings.ts index f964362679..a4b2ec5aad 100644 --- a/packages/shared-lib/src/core/model/StudioSettings.ts +++ b/packages/shared-lib/src/core/model/StudioSettings.ts @@ -81,4 +81,9 @@ export interface IStudioSettings { * Enable buckets - the default behavior is to have buckets. */ enableBuckets: boolean + + /** + * Doubleclick changes behaviour as selector for userediting + */ + enableUserEdits?: boolean } diff --git a/packages/shared-lib/src/lib/JSONSchemaUtil.ts b/packages/shared-lib/src/lib/JSONSchemaUtil.ts index 02feb95420..49e6380de0 100644 --- a/packages/shared-lib/src/lib/JSONSchemaUtil.ts +++ b/packages/shared-lib/src/lib/JSONSchemaUtil.ts @@ -29,6 +29,7 @@ export enum SchemaFormUIField { * Currently only valid for: * - object properties. Valid values are 'json'. * - string properties. Valid values are 'base64-image'. + * - boolean properties. Valid values are 'switch'. */ DisplayType = 'ui:displayType', /** diff --git a/packages/webui/jest.config.cjs b/packages/webui/jest.config.cjs index 060062dd60..6458660efb 100644 --- a/packages/webui/jest.config.cjs +++ b/packages/webui/jest.config.cjs @@ -1,7 +1,11 @@ module.exports = { - setupFilesAfterEnv: ['./src/__mocks__/_setupMocks.ts', '/src/client/__tests__/jest-setup.cjs'], + setupFilesAfterEnv: [ + './src/__mocks__/_setupMocks.ts', + '/src/client/__tests__/jest-setup.cjs', + '@testing-library/jest-dom', + ], globals: {}, - moduleFileExtensions: ['js', 'ts'], + moduleFileExtensions: ['js', 'ts', 'tsx'], moduleNameMapper: { 'meteor/(.*)': '/src/meteor/$1', }, @@ -15,7 +19,7 @@ module.exports = { '^.+\\.(js|jsx)$': ['babel-jest', { presets: ['@babel/preset-env'] }], }, transformIgnorePatterns: ['node_modules/(?!(nanoid)/)', '\\.pnp\\.[^\\/]+$'], - testMatch: ['**/__tests__/**/*.(spec|test).(ts|js)'], + testMatch: ['**/__tests__/**/*.(spec|test).(ts|tsx|js)'], testPathIgnorePatterns: ['integrationTests'], testEnvironment: 'jsdom', // coverageThreshold: { diff --git a/packages/webui/package.json b/packages/webui/package.json index 0a1ee41445..1865b82dfb 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -46,6 +46,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,9 @@ }, "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", "@types/react": "^18.3.3", 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/lib/Components/DropdownInput.tsx b/packages/webui/src/client/lib/Components/DropdownInput.tsx index cdc0769042..a35294aca1 100644 --- a/packages/webui/src/client/lib/Components/DropdownInput.tsx +++ b/packages/webui/src/client/lib/Components/DropdownInput.tsx @@ -112,7 +112,7 @@ export function DropdownInputControl({ const newOptions = [ ...options, { - name: 'Value: ' + value, + name: String(value), value: value, i: options.length, }, diff --git a/packages/webui/src/client/lib/forms/SchemaFormInPlace.scss b/packages/webui/src/client/lib/forms/SchemaFormInPlace.scss new file mode 100644 index 0000000000..bb18be6023 --- /dev/null +++ b/packages/webui/src/client/lib/forms/SchemaFormInPlace.scss @@ -0,0 +1,75 @@ +// This styling has a lot of force added, and should be seen as a workaround +// to get styling on the current Sofie UI schema/overrides components +// without changing current look and behavior in other places of Sofie +.styled-schema-form { + width: 100%; + + // Force the base input-l class + .input-l { + width: 100% !important; + max-width: none !important; + margin-left: 0px; + border: none; + } + + // Force the select/text-input defaults + .select, + .inline-select, + .text-input { + display: block !important; + position: relative; + width: 100% !important; + } + + .input { + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + width: 100%; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 1px #3b82f6; + background-color: unset !important; // origo >.> + } + } + + .label-text { + &:before { + content: none !important; + } + } + + .dropdown { + background: white; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + width: 100%; + max-width: 100%; + + .input, + .input-l { + border: none; + outline: none; + box-shadow: none; + } + + &:focus-within { + border-color: #3b82f6; + box-shadow: 0 0 0 1px #3b82f6; + } + } + + // Force the label over selector + .label-actual { + margin-bottom: 0.5rem !important; // Increased spacing between label and selector + } + + .label { + font-size: 0.875rem; + font-weight: 500; + color: #374151; + display: block; + } +} diff --git a/packages/webui/src/client/lib/forms/SchemaFormInPlace.tsx b/packages/webui/src/client/lib/forms/SchemaFormInPlace.tsx index 9afc265469..8e3f4d74f2 100644 --- a/packages/webui/src/client/lib/forms/SchemaFormInPlace.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormInPlace.tsx @@ -6,6 +6,7 @@ import { } from '../../ui/Settings/util/OverrideOpHelper' import { SchemaFormCommonProps } from './schemaFormUtil' import { SchemaFormWithOverrides } from './SchemaFormWithOverrides' +import './SchemaFormInPlace.scss' interface SchemaFormInPlaceProps extends Omit { /** The object to be modified in place */ diff --git a/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx b/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx index 2e9779a076..64c2213981 100644 --- a/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx @@ -24,6 +24,7 @@ import { SchemaFormObjectTable } from './SchemaFormTable/ObjectTable' import { getSchemaUIField, SchemaFormUIField } from '@sofie-automation/blueprints-integration' import { SchemaFormSectionHeader } from './SchemaFormSectionHeader' import { Base64ImageInputControl } from '../Components/Base64ImageInput' +import { ToggleSwitchControl } from '../Components/ToggleSwitch' interface SchemaFormWithOverridesProps extends SchemaFormCommonProps { /** Base path of the schema within the document */ @@ -56,7 +57,7 @@ interface FormComponentProps { function useChildPropsForFormComponent(props: Readonly): FormComponentProps { return useMemo(() => { - const title = getSchemaUIField(props.schema, SchemaFormUIField.Title) || props.attr + const title = getSchemaUIField(props.schema, SchemaFormUIField.Title) || props.schema.title || props.attr const description = getSchemaUIField(props.schema, SchemaFormUIField.Description) return { @@ -113,7 +114,11 @@ export function SchemaFormWithOverrides(props: Readonly case TypeName.Boolean: - return + if (getSchemaUIField(props.schema, SchemaFormUIField.DisplayType) === 'switch') { + return + } else { + return + } case TypeName.String: if (getSchemaUIField(props.schema, SchemaFormUIField.DisplayType) === 'base64-image') { return @@ -349,6 +354,17 @@ const BooleanFormWithOverrides = ({ commonAttrs }: Readonly) ) } +const SwitchFormWithOverrides = ({ commonAttrs }: Readonly) => { + return ( + + {(value, handleUpdate) => ( + // + + )} + + ) +} + const StringFormWithOverrides = ({ schema, commonAttrs }: Readonly) => { return ( diff --git a/packages/webui/src/client/lib/forms/SchemaFormWithState.tsx b/packages/webui/src/client/lib/forms/SchemaFormWithState.tsx new file mode 100644 index 0000000000..77e84b75ee --- /dev/null +++ b/packages/webui/src/client/lib/forms/SchemaFormWithState.tsx @@ -0,0 +1,72 @@ +import { useCallback, useMemo } from 'react' +import { + OverrideOpHelperForItemContentsBatcher, + WrappedOverridableItemNormal, +} from '../../ui/Settings/util/OverrideOpHelper' +import { SchemaFormCommonProps } from './schemaFormUtil' +import { SchemaFormWithOverrides } from './SchemaFormWithOverrides' +import { literal, objectPathSet } from '@sofie-automation/corelib/dist/lib' +import { AnyARecord } from 'dns' + +interface SchemaFormWithStateProps extends Omit { + object: any + + onUpdate: (object: any) => void +} + +export function SchemaFormWithState({ + object, + onUpdate, + ...commonProps +}: Readonly): JSX.Element { + const helper = useCallback( + () => + new OverrideOpHelperWithState(object, (object) => { + onUpdate(object) + }), + [object, onUpdate] + ) + + const wrappedItem = useMemo( + () => + literal>({ + type: 'normal', + id: 'not-used', + computed: object, + defaults: undefined, + overrideOps: [], + }), + [object] + ) + + return +} + +/** + * An alternate OverrideOpHelper designed to directly mutate an object, instead of using the `ObjectWithOverrides` system. + * This allows us to have one SchemaForm implementation that can handle working with `ObjectWithOverrides`, and simpler options + */ +class OverrideOpHelperWithState implements OverrideOpHelperForItemContentsBatcher { + readonly #object: any + readonly #onUpdate: (object: any) => void + + constructor(object: AnyARecord, onUpdate: (object: any) => void) { + this.#object = object + this.#onUpdate = onUpdate + } + + clearItemOverrides(_itemId: string, _subPath: string): this { + // Not supported as this is faking an item with overrides + + return this + } + setItemValue(_itemId: string, subPath: string, value: any): this { + objectPathSet(this.#object, subPath, value) + + return this + } + + commit(): void { + this.#onUpdate(this.#object) + } +} diff --git a/packages/webui/src/client/lib/ui/icons/useredits.tsx b/packages/webui/src/client/lib/ui/icons/useredits.tsx new file mode 100644 index 0000000000..79d913d1c9 --- /dev/null +++ b/packages/webui/src/client/lib/ui/icons/useredits.tsx @@ -0,0 +1,23 @@ +import { JSX } from 'react' + +export function UserEditsIcon(): JSX.Element { + return ( + + + + ) +} + +export function UserEditsCloseIcon(): JSX.Element { + return ( + + + + ) +} diff --git a/packages/webui/src/client/lib/ui/pieceUiClassNames.ts b/packages/webui/src/client/lib/ui/pieceUiClassNames.ts index 4d91cb014f..a66a68e8fc 100644 --- a/packages/webui/src/client/lib/ui/pieceUiClassNames.ts +++ b/packages/webui/src/client/lib/ui/pieceUiClassNames.ts @@ -11,6 +11,7 @@ export function pieceUiClassNames( pieceInstance: PieceUi, contentStatus: ReadonlyDeep | undefined, baseClassName: string, + selected: boolean, layerType?: SourceLayerType, partId?: PartId, highlight?: boolean, @@ -54,5 +55,7 @@ export function pieceUiClassNames( disabled: pieceInstance.instance.disabled, 'invert-flash': highlight, + + 'element-selected': selected, }) } diff --git a/packages/webui/src/client/styles/_variables.scss b/packages/webui/src/client/styles/_variables.scss index 3db82ffacc..060ea24d42 100644 --- a/packages/webui/src/client/styles/_variables.scss +++ b/packages/webui/src/client/styles/_variables.scss @@ -3,6 +3,7 @@ $fullscreen-controls__button--radius: 3.125rem; $statusbar-width: 3.6rem; $notification-center-width: 25rem; +$properties-panel-width: 550px; $browser-context: 16px; // Default browser font size in pixels diff --git a/packages/webui/src/client/styles/elementSelected.scss b/packages/webui/src/client/styles/elementSelected.scss new file mode 100644 index 0000000000..ed37ae296c --- /dev/null +++ b/packages/webui/src/client/styles/elementSelected.scss @@ -0,0 +1,18 @@ +@import '_variables'; + +$glow-color: rgba(255, 255, 255, 0.58); + +.element-selected { + box-shadow: inset 0 0 15px $glow-color; + animation: subtle-glow 1s ease-in-out infinite; + + @keyframes subtle-glow { + 0%, 100% { + box-shadow: inset 0 0 15px $glow-color; + } + 50% { + box-shadow: inset 0 0 25px $glow-color, + inset 0 0 35px $glow-color; + } + } +} \ No newline at end of file diff --git a/packages/webui/src/client/styles/main.scss b/packages/webui/src/client/styles/main.scss index a1d77edf9c..ee7be076bb 100644 --- a/packages/webui/src/client/styles/main.scss +++ b/packages/webui/src/client/styles/main.scss @@ -27,6 +27,7 @@ input { @import 'modalDialog'; @import 'multiSelect'; @import 'notifications'; +@import 'propertiesPanel'; @import 'organization'; @import 'overflowingContainer'; @import 'pieceStatusIcon'; @@ -43,6 +44,7 @@ input { @import 'testtools'; @import 'tooltips'; @import 'utils'; +@import 'elementSelected'; @import 'countdown/overlay'; @import 'countdown/presenter'; diff --git a/packages/webui/src/client/styles/notifications.scss b/packages/webui/src/client/styles/notifications.scss index 3feba6fd81..2b63a0dc9f 100644 --- a/packages/webui/src/client/styles/notifications.scss +++ b/packages/webui/src/client/styles/notifications.scss @@ -397,7 +397,7 @@ .rundown-view .notification-center-panel { background: #acacad; color: #000; - top: 4rem; + top: 3.1rem; &::before { content: ' '; diff --git a/packages/webui/src/client/styles/propertiesPanel.scss b/packages/webui/src/client/styles/propertiesPanel.scss new file mode 100644 index 0000000000..e8ecd5d9ce --- /dev/null +++ b/packages/webui/src/client/styles/propertiesPanel.scss @@ -0,0 +1,255 @@ +@import '../styles/itemTypeColors'; +@import '_colorScheme'; +@import '_variables'; + +.properties-panel { + position: fixed; + background: #7b7b7b; + color: #000; + top: 4rem; + right: 0; + bottom: 0; + width: $properties-panel-width; + z-index: 292; + + transform: translateX(100%); + animation: show 200ms 0ms cubic-bezier(0.38, 0.97, 0.56, 0.76) forwards; + + @keyframes show { + 100% { + transform: unset; + } + } + + &::before { + content: ' '; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: -0.625rem; + width: 0.625rem; + background: linear-gradient(to right, transparent 0%, rgba(0, 0, 0, 0.15) 100%); + } + + .propertiespanel-pop-up { + background: #2e2e2e; + border-radius: 1px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.7); + height: 100%; + width: calc(100% - 3.75rem); + position: relative; + + display: flex; + flex-direction: column; + + > .propertiespanel-pop-up__header { + background: #0a20ed; + color: #ddd; + + max-width: 100%; + + display: flex; + padding: 1em; + align-items: center; + gap: 0.2em; + align-self: stretch; + + text-shadow: 0.5px 0.5px 8px rgba(0, 0, 0, 0.8); + font-family: Roboto; + font-size: 1em; + font-style: normal; + font-weight: 500; + line-height: normal; + letter-spacing: 0.5px; + + > .svg { + width: 1em; + height: 1.2em; + flex-shrink: 0; + } + > .title { + flex-grow: 1; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + min-width: 0; + } + > .properties { + color: #fff; + font-family: Roboto; + font-size: 16px; + font-style: normal; + font-weight: 300; + line-height: normal; + letter-spacing: -0.1px; + flex-shrink: 0; + } + > .propertiespanel-pop-up_close { + height: 1em; + margin-left: 1em; + background-color: unset; + border: none; + } + } + + > .propertiespanel-pop-up__footer { + flex: 1; + flex: 0 0 0; + padding: 10px; + display: flex; + justify-content: space-between; + gap: 12px; + + > .propertiespanel-pop-up__button-group { + display: inherit; + gap: inherit; + } + + > .propertiespanel-pop-up__button, + .propertiespanel-pop-up__button-group .propertiespanel-pop-up__button { + display: block; + + border-radius: 5px; + border: 1px solid #7f7f7f; + background: #636363; + padding: 10px; + + color: #dfdfdf; + font-size: 0.875em; + font-weight: 500; + + &:active { + transform: scale(0.95); + top: 2px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.3; + + &:active { + transform: none; + top: 0; + } + } + + > svg { + margin-top: -0.1em; + vertical-align: middle; + margin-right: -0.4em; + width: 1em; + height: 1em; + } + + svg { + width: 1.3em; + height: 0.875em; + } + + .label { + margin-left: 10px; + margin-right: 10px; + margin-top: 2px; + line-height: inherit; + } + + &.start { + justify-self: start; + } + &.end { + justify-self: end; + } + } + } + + > .propertiespanel-pop-up__contents { + flex: 1; + padding: 0.525rem 0.6375rem; + cursor: default; + overflow: hidden auto; + overflow-wrap: break-word; + + flex: 1 0; + + > hr { + margin-left: 0px; + width: 100%; + border-color: #7f7f7f; + } + + > .propertiespanel-pop-up__groupselector { + display: flex; + flex-wrap: wrap; + margin-top: 0.5em; + margin-bottom: 0.5em; + + > .propertiespanel-pop-up__groupselector__button { + @include item-type-colors(); + + width: 50px; + height: 30px; + border: 0px; + margin: 3px; + gap: 10px; + color: #ddd; + opacity: 0.2; + + &.splits { + background: linear-gradient( + to right, + $segment-layer-background-camera 50%, + $segment-layer-background-remote 50.0001% + ); + } + + &.active { + color: #fff; + opacity: 1; + } + } + } + + > .propertiespanel-pop-up__action { + margin-top: 15px; + color: #ddd; + padding: 4px 4px; + } + + > .properties-panel-pop-up__form { + color: #ddd; + } + + .propertiespanel-pop-up__button { + // margin-top: 10px; + background: #636363; + padding: 10px; + gap: 10px; + border-radius: 5px; + border: 1px solid #7f7f7f; + color: #dfdfdf; + + font-size: 0.875em; + font-weight: 500; + + &:active { + transform: scale(0.95); + top: 2px; + } + + svg { + width: 1em; + height: 0.875em; + } + + .label { + margin-left: 10px; + margin-right: 10px; + margin-top: 2px; + line-height: inherit; + } + } + } + } +} diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index b454a90287..d73a06c36f 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -154,6 +154,16 @@ $break-width: 35rem; transition: 0s padding-right 1s; } } + + &.properties-panel-open { + padding-right: $properties-panel-width; + transition: 0s padding-right 1s; + + > .header .rundown-overview { + padding-right: calc(#{$properties-panel-width} + 1.5em); + transition: 0s padding-right 1s; + } + } } body.vertical-overflow-only { diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 8dd1c411a6..502d3d19f7 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -170,6 +170,8 @@ import { UserPermissionsContext, UserPermissions } from './UserPermissions' import * as RundownResolver from '../lib/RundownResolver' import { MAGIC_TIME_SCALE_FACTOR } from './SegmentTimeline/Constants' +import { SelectedElementProvider, SelectedElementsContext } from './RundownView/SelectedElementsContext' +import { PropertiesPanel } from './UserEditOperations/PropertiesPanel' const REHEARSAL_MARGIN = 1 * 60 * 1000 const HIDE_NOTIFICATIONS_AFTER_MOUNT: number | undefined = 5000 @@ -3016,221 +3018,242 @@ const RundownViewContent = translateWithTracker -
- {this.renderSegmentsList()} - - {this.props.matchedSegments && - this.props.matchedSegments.length > 0 && - this.props.userPermissions.studio && } - - - r._id)} - firstRundown={this.props.rundowns[0]} - onActivate={this.onActivate} - userPermissions={this.props.userPermissions} - inActiveRundownView={this.props.inActiveRundownView} - currentRundown={this.state.currentRundown || this.props.rundowns[0]} - layout={this.state.rundownHeaderLayout} - showStyleBase={showStyleBase} - showStyleVariant={showStyleVariant} - /> - - - {this.props.userPermissions.studio && !Settings.disableBlurBorder && ( - + + + {(selectionContext) => { + return (
0, })} - >
-
- )} -
- - - - {this.renderSorensenContext()} - - - {this.state.isNotificationsCenterOpen && ( - - )} - - - {this.state.isSupportPanelOpen && ( - -
- -
- - {t('Take a Snapshot')} - -
- {this.props.userPermissions.studio && ( - <> - -
- - )} - {this.props.userPermissions.studio && - this.props.casparCGPlayoutDevices && - this.props.casparCGPlayoutDevices.map((i) => ( - - -
-
- ))} -
- )} -
-
- - {this.props.userPermissions.studio && ( - - )} - - - - - - - - - {this.state.isClipTrimmerOpen && - this.state.selectedPiece && - RundownUtils.isPieceInstance(this.state.selectedPiece) && - (selectedPieceRundown === undefined ? ( - this.setState({ selectedPiece: undefined })} - title={t('Rundown not found')} - acceptText={t('Close')} + style={this.getStyle()} + onWheelCapture={this.onWheel} + onContextMenu={this.onContextMenuTop} > - {t('Rundown for piece "{{pieceLabel}}" could not be found.', { - pieceLabel: this.state.selectedPiece.instance.piece.name, - })} - - ) : ( - this.setState({ isClipTrimmerOpen: false })} - /> - ))} - - - - - - - - - {this.props.playlist && this.props.studio && this.props.showStyleBase && ( - - )} - -
- { - // USE IN CASE OF DEBUGGING EMERGENCY - /* getDeveloperMode() &&
+ {this.props.matchedSegments && + this.props.matchedSegments.length > 0 && + this.props.userPermissions.studio && } + + + r._id)} + firstRundown={this.props.rundowns[0]} + onActivate={this.onActivate} + userPermissions={this.props.userPermissions} + inActiveRundownView={this.props.inActiveRundownView} + currentRundown={this.state.currentRundown || this.props.rundowns[0]} + layout={this.state.rundownHeaderLayout} + showStyleBase={showStyleBase} + showStyleVariant={showStyleVariant} + /> + + + {this.props.userPermissions.studio && !Settings.disableBlurBorder && ( + +
+
+ )} +
+ + + + {this.renderSorensenContext()} + + + {this.state.isNotificationsCenterOpen && ( + + )} + + {!this.state.isNotificationsCenterOpen && + selectionContext.listSelectedElements().length > 0 && ( +
+ +
+ )} + + {this.state.isSupportPanelOpen && ( + +
+ +
+ + {t('Take a Snapshot')} + +
+ {this.props.userPermissions.studio && ( + <> + +
+ + )} + {this.props.userPermissions.studio && + this.props.casparCGPlayoutDevices && + this.props.casparCGPlayoutDevices.map((i) => ( + + +
+
+ ))} +
+ )} +
+
+ + {this.props.userPermissions.studio && ( + + )} + + + + + + + selectionContext.clearAndSetSelection({ type: 'segment', elementId: id }) + } + onEditPartProps={(id) => + selectionContext.clearAndSetSelection({ type: 'part', elementId: id }) + } + studioMode={this.props.userPermissions.studio} + enablePlayFromAnywhere={!!studio.settings.enablePlayFromAnywhere} + enableQuickLoop={!!studio.settings.enableQuickLoop} + enableUserEdits={!!studio.settings.enableUserEdits} + /> + + + {this.state.isClipTrimmerOpen && + this.state.selectedPiece && + RundownUtils.isPieceInstance(this.state.selectedPiece) && + (selectedPieceRundown === undefined ? ( + this.setState({ selectedPiece: undefined })} + title={t('Rundown not found')} + acceptText={t('Close')} + > + {t('Rundown for piece "{{pieceLabel}}" could not be found.', { + pieceLabel: this.state.selectedPiece.instance.piece.name, + })} + + ) : ( + this.setState({ isClipTrimmerOpen: false })} + /> + ))} + + + + + + + + + {this.props.playlist && this.props.studio && this.props.showStyleBase && ( + + )} + +
+ ) + }} + { + // USE IN CASE OF DEBUGGING EMERGENCY + /* getDeveloperMode() &&
*/ - } + } + +
) diff --git a/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx b/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx index 7063581d2b..974415f5fd 100644 --- a/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx @@ -24,6 +24,8 @@ import { SegmentViewMode } from '../../lib/ui/icons/listView' import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { MediaStatusPopUp } from './MediaStatusPopUp' import { MediaStatusIcon } from '../../lib/ui/icons/mediaStatus' +import { SelectedElementsContext } from './SelectedElementsContext' +import { UserEditsCloseIcon, UserEditsIcon } from '../../lib/ui/icons/useredits' interface IProps { playlistId: RundownPlaylistId @@ -39,6 +41,7 @@ interface IProps { isNotificationCenterOpen: NoticeLevel | undefined isSupportPanelOpen: boolean isStudioMode: boolean + isUserEditsEnabled: boolean onToggleNotifications?: (e: React.MouseEvent, filter: NoticeLevel) => void onToggleSupportPanel?: (e: React.MouseEvent) => void onTake?: (e: React.MouseEvent) => void @@ -153,6 +156,9 @@ export function RundownRightHandControls(props: Readonly): JSX.Element { className="type-notification" title={t('Notes')} /> + {props.isUserEditsEnabled && ( + + )} + ) + }} + + ) +} diff --git a/packages/webui/src/client/ui/RundownView/SelectedElementsContext.tsx b/packages/webui/src/client/ui/RundownView/SelectedElementsContext.tsx new file mode 100644 index 0000000000..679ef0a067 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/SelectedElementsContext.tsx @@ -0,0 +1,242 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useState } from 'react' +import { + AdLibActionId, + PartId, + PartInstanceId, + PieceId, + RundownId, + SegmentId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { assertNever } from '@sofie-automation/corelib/dist/lib' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { Tracker } from 'meteor/tracker' +import { Pieces, Segments } from '../../collections' +import { UIParts } from '../Collections' + +interface RundownElement { + type: 'rundown' + elementId: RundownId +} + +interface SegmentElement { + type: 'segment' + elementId: SegmentId +} + +interface PartElement { + type: 'part' + elementId: PartId +} + +interface PartInstanceElement { + type: 'partInstance' + elementId: PartInstanceId +} + +interface PieceElement { + type: 'piece' + elementId: PieceId +} + +interface AdlibActionElement { + type: 'adlibAction' + elementId: AdLibActionId +} + +// Union types for all possible elements +export type SelectedElement = + | RundownElement + | SegmentElement + | PartElement + | PartInstanceElement + | PieceElement + | AdlibActionElement +type ElementId = SelectedElement['elementId'] + +export interface SelectionContextType { + isSelected: (elementId: ElementId) => boolean + listSelectedElements: () => SelectedElement[] + clearAndSetSelection: (element: SelectedElement) => void + toggleSelection: (element: SelectedElement) => void + addSelection: (element: SelectedElement) => void + removeSelection: (elementId: ElementId) => void + clearSelections: () => void + getSelectedCount: () => number +} + +type SelectionAction = + | { type: 'CLEAR_AND_SET_SELECTION'; payload: SelectedElement } + | { type: 'TOGGLE_SELECTION'; payload: SelectedElement } + | { type: 'ADD_SELECTION'; payload: SelectedElement } + | { type: 'REMOVE_SELECTION'; payload: ElementId } + | { type: 'CLEAR_SELECTIONS' } + +// Reducer function +const selectionReducer = ( + state: Map, + action: SelectionAction, + maxSelections: number +): Map => { + switch (action.type) { + case 'CLEAR_AND_SET_SELECTION': { + const newMap = new Map([[action.payload.elementId, action.payload]]) + return newMap + } + case 'TOGGLE_SELECTION': { + const next = new Map(state) + if (next.has(action.payload.elementId)) { + next.delete(action.payload.elementId) + } else if (next.size < maxSelections) { + next.set(action.payload.elementId, action.payload) + } + return next + } + case 'ADD_SELECTION': { + if (state.size >= maxSelections) return state + const next = new Map(state) + next.set(action.payload.elementId, action.payload) + return next + } + case 'REMOVE_SELECTION': { + const next = new Map(state) + next.delete(action.payload) + return next + } + case 'CLEAR_SELECTIONS': { + return new Map() + } + default: + assertNever(action) + return state + } +} + +const defaultSelectionContext: SelectionContextType = { + isSelected: () => false, + listSelectedElements: () => [], + clearAndSetSelection: () => { + throw new Error('Method "clearAndSetSelection" not implemented on default SelectedElementsContext') + }, + toggleSelection: () => { + throw new Error('Method "toggleSelection" not implemented on default SelectedElementsContext') + }, + addSelection: () => { + throw new Error('Method "addSelection" not implemented on default SelectedElementsContext') + }, + removeSelection: () => { + throw new Error('Method "removeSelection" not implemented on default SelectedElementsContext') + }, + clearSelections: () => { + throw new Error('Method "clearSelections" not implemented on default SelectedElementsContext') + }, + getSelectedCount: () => 0, +} + +export const SelectedElementsContext = createContext(defaultSelectionContext) + +export const SelectedElementProvider: React.FC<{ + children: React.ReactNode + maxSelections?: number // Optional prop to limit maximum selections +}> = ({ children, maxSelections = 10 }) => { + const [selectedElements, dispatch] = useReducer( + (state: Map, action: SelectionAction) => selectionReducer(state, action, maxSelections), + new Map() + ) + + const value = useMemo( + () => ({ + isSelected: (elementId: ElementId) => { + return selectedElements.has(elementId) + }, + + listSelectedElements: () => { + return Array.from(selectedElements.values()) + }, + + clearAndSetSelection: (element: SelectedElement) => { + dispatch({ type: 'CLEAR_AND_SET_SELECTION', payload: element }) + }, + + toggleSelection: (element: SelectedElement) => { + dispatch({ type: 'TOGGLE_SELECTION', payload: element }) + }, + + addSelection: (element: SelectedElement) => { + dispatch({ type: 'ADD_SELECTION', payload: element }) + }, + + removeSelection: (elementId: ElementId) => { + dispatch({ type: 'REMOVE_SELECTION', payload: elementId }) + }, + + clearSelections: () => { + dispatch({ type: 'CLEAR_SELECTIONS' }) + }, + getSelectedCount: () => { + return selectedElements.size + }, + }), + [selectedElements, maxSelections] + ) + + return {children} +} + +// Custom hook for using the selection context +export const useSelectedElementsContext = (): SelectionContextType => { + const context = useContext(SelectedElementsContext) + + return context +} + +// Helper hook for common selection patterns +export const useElementSelection = (element: SelectedElement): { isSelected: boolean; toggleSelection: () => void } => { + const { isSelected, toggleSelection } = useSelectedElementsContext() + + return { + isSelected: useMemo(() => isSelected(element.elementId), [isSelected, element.elementId]), + toggleSelection: useCallback(() => toggleSelection(element), [toggleSelection, element]), + } +} + +export function useSelectedElements( + selectedElement: SelectedElement, + clearPendingChange: () => void +): { + piece: Piece | undefined + part: DBPart | undefined + segment: DBSegment | undefined + rundownId: RundownId | undefined +} { + const [piece, setPiece] = useState(undefined) + const [part, setPart] = useState(undefined) + const [segment, setSegment] = useState(undefined) + const rundownId = piece ? piece.startRundownId : part ? part.rundownId : segment?.rundownId + + useEffect(() => { + clearPendingChange() // element id changed so any pending change is for an old element + + const computation = Tracker.nonreactive(() => + Tracker.autorun(() => { + const piece = Pieces.findOne(selectedElement?.elementId) + const part = UIParts.findOne({ _id: piece ? piece.startPartId : selectedElement?.elementId }) + const segment = Segments.findOne({ _id: part ? part.segmentId : selectedElement?.elementId }) + + setPiece(piece) + setPart(part) + setSegment(segment) + }) + ) + + return () => computation.stop() + }, [selectedElement?.elementId]) + + return { + piece, + part, + segment, + rundownId, + } +} diff --git a/packages/webui/src/client/ui/RundownView/__tests__/selectedElementsContext.test.tsx b/packages/webui/src/client/ui/RundownView/__tests__/selectedElementsContext.test.tsx new file mode 100644 index 0000000000..d17952dfc8 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/__tests__/selectedElementsContext.test.tsx @@ -0,0 +1,132 @@ +import React from 'react' +// eslint-disable-next-line node/no-unpublished-import +import { renderHook, act } from '@testing-library/react' +import { SelectedElementProvider, useSelectedElementsContext, useElementSelection } from '../SelectedElementsContext' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { RundownId, SegmentId, PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +describe('SelectedElementProvider', () => { + const createRundownElement = (id: string) => ({ + type: 'rundown' as const, + elementId: protectString(id), + }) + + const createSegmentElement = (id: string) => ({ + type: 'segment' as const, + elementId: protectString(id), + }) + + const createPartInstanceElement = (id: string) => ({ + type: 'partInstance' as const, + elementId: protectString(id), + }) + + describe('useSelection hook', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + test('init with no selections', () => { + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + + expect(result.current.listSelectedElements().length).toBe(0) + expect(result.current.getSelectedCount()).toBe(0) + }) + + test('clearAndSetSelection', () => { + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + const element1 = createRundownElement('rundown1') + const element2 = createRundownElement('rundown2') + + act(() => { + result.current.clearAndSetSelection(element1) + }) + expect(result.current.listSelectedElements().length).toBe(1) + expect(result.current.isSelected(element1.elementId)).toBe(true) + + act(() => { + result.current.clearAndSetSelection(element2) + }) + expect(result.current.listSelectedElements().length).toBe(1) + expect(result.current.isSelected(element1.elementId)).toBe(false) + expect(result.current.isSelected(element2.elementId)).toBe(true) + }) + + test('toggleSelection', () => { + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + const element = createSegmentElement('segment1') + + act(() => { + result.current.toggleSelection(element) + }) + expect(result.current.isSelected(element.elementId)).toBe(true) + + act(() => { + result.current.toggleSelection(element) + }) + expect(result.current.isSelected(element.elementId)).toBe(false) + }) + + test('respect maxSelections limit', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + + const elements = [ + createRundownElement('rundown1'), + createRundownElement('rundown2'), + createRundownElement('rundown3'), + ] + + act(() => { + elements.forEach((element) => { + result.current.addSelection(element) + }) + }) + + expect(result.current.getSelectedCount()).toBe(2) + expect(result.current.isSelected(elements[0].elementId)).toBe(true) + expect(result.current.isSelected(elements[1].elementId)).toBe(true) + expect(result.current.isSelected(elements[2].elementId)).toBe(false) + }) + + test('clearSelections removes all selections', () => { + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + + act(() => { + result.current.addSelection(createRundownElement('rundown1')) + result.current.addSelection(createSegmentElement('segment1')) + }) + expect(result.current.getSelectedCount()).toBe(2) + + act(() => { + result.current.clearSelections() + }) + expect(result.current.getSelectedCount()).toBe(0) + }) + }) + + describe('useElementSelection hook', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + test('provide isSelected and toggleSelection for specific element', () => { + const element = createPartInstanceElement('part1') + const { result } = renderHook(() => useElementSelection(element), { wrapper }) + + expect(result.current.isSelected).toBe(false) + + act(() => { + result.current.toggleSelection() + }) + expect(result.current.isSelected).toBe(true) + + act(() => { + result.current.toggleSelection() + }) + expect(result.current.isSelected).toBe(false) + }) + }) +}) diff --git a/packages/webui/src/client/ui/SegmentContainer/PieceElement.tsx b/packages/webui/src/client/ui/SegmentContainer/PieceElement.tsx index cc14b3b9e2..99cdf9f13e 100644 --- a/packages/webui/src/client/ui/SegmentContainer/PieceElement.tsx +++ b/packages/webui/src/client/ui/SegmentContainer/PieceElement.tsx @@ -45,6 +45,7 @@ export const PieceElement = React.forwardRef void onSetQuickLoopStart: (marker: QuickLoopMarker | null, e: any) => void onSetQuickLoopEnd: (marker: QuickLoopMarker | null, e: any) => void + onEditSegmentProps: (id: SegmentId) => void + onEditPartProps: (id: PartId) => void playlist?: DBRundownPlaylist studioMode: boolean contextMenuContext: IContextMenuContext | null enablePlayFromAnywhere: boolean enableQuickLoop: boolean + enableUserEdits: boolean } interface IState {} @@ -40,7 +43,12 @@ export const SegmentContextMenu = withTranslation()( render(): JSX.Element | null { const { t } = this.props - if (!this.props.studioMode || !this.props.playlist || !this.props.playlist.activationId) return null + if ( + !this.props.studioMode || + !this.props.playlist || + (!this.props.enableUserEdits && !this.props.playlist.activationId) + ) + return null const part = this.getPartFromContext() const segment = this.getSegmentFromContext() @@ -98,6 +106,14 @@ export const SegmentContextMenu = withTranslation()( /> )}
+ {this.props.enableUserEdits && ( + <> +
+ this.props.onEditSegmentProps(part.instance.segmentId)}> + {t('Edit Segment Properties')} + + + )} )} {part && !part.instance.part.invalid && timecode !== null && ( @@ -178,6 +194,18 @@ export const SegmentContextMenu = withTranslation()( userEditOperations={part.instance.part.userEditOperations} isFormEditable={isPartEditAble} /> + + {this.props.enableUserEdits && ( + <> +
+ this.props.onEditSegmentProps(part.instance.segmentId)}> + {t('Edit Segment Properties')} + + this.props.onEditPartProps(part.instance.part._id)}> + {t('Edit Part Properties')} + + + )} )} diff --git a/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx index 57a64c969e..ba0f018fe6 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -56,6 +56,7 @@ import { SegmentTimeAnchorTime } from '../RundownView/RundownTiming/SegmentTimeA import { logger } from '../../lib/logging' import * as RundownResolver from '../../lib/RundownResolver' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { SelectedElementsContext } from '../RundownView/SelectedElementsContext' interface IProps { id: string @@ -91,8 +92,8 @@ interface IProps { onFollowLiveLine?: (state: boolean, event: any) => void onShowEntireSegment?: (event: React.MouseEvent | undefined) => void onContextMenu?: (contextMenuContext: IContextMenuContext) => void - onItemClick?: (piece: PieceUi, e: React.MouseEvent) => void - onItemDoubleClick?: (item: PieceUi, e: React.MouseEvent) => void + onPieceClick?: (piece: PieceUi, e: React.MouseEvent) => void + onPieceDoubleClick?: (piece: PieceUi, e: React.MouseEvent) => void onHeaderNoteClick?: (segmentId: SegmentId, level: NoteSeverity) => void onSwitchViewMode?: (newViewMode: SegmentViewMode) => void segmentRef?: (el: SegmentTimelineClass, segmentId: SegmentId) => void @@ -115,6 +116,7 @@ interface IStateHeader { } > useTimeOfDayCountdowns: boolean + // isSelected: boolean } interface IZoomPropsHeader { @@ -266,6 +268,7 @@ export class SegmentTimelineClass extends React.Component - -

- {this.props.segment.name} -

- {(criticalNotes > 0 || warningNotes > 0) && ( -
- {criticalNotes > 0 && ( -
- this.props.onHeaderNoteClick && - this.props.onHeaderNoteClick(this.props.segment._id, NoteSeverity.ERROR) - } - aria-label={t('Critical problems')} - > - -
{criticalNotes}
-
- )} - {warningNotes > 0 && ( -
- this.props.onHeaderNoteClick && - this.props.onHeaderNoteClick(this.props.segment._id, NoteSeverity.WARNING) + + {(selectElementContext) => ( + +
{ + if (this.props.studio.settings.enableUserEdits) { + if (!selectElementContext.isSelected(this.props.segment._id)) { + selectElementContext.clearAndSetSelection({ type: 'segment', elementId: this.props.segment._id }) + } else { + selectElementContext.clearSelections() + } } - aria-label={t('Warnings')} - > - -
{warningNotes}
-
- )} -
- )} - {identifiers.length > 0 && ( -
- {identifiers.map((ident) => ( -
this.onClickPartIdent(ident.partId)} + }} + > +

- {ident.ident} -

- ))} -
+ {this.props.segment.name} + + {(criticalNotes > 0 || warningNotes > 0) && ( +
+ {criticalNotes > 0 && ( +
+ this.props.onHeaderNoteClick && + this.props.onHeaderNoteClick(this.props.segment._id, NoteSeverity.ERROR) + } + aria-label={t('Critical problems')} + > + +
{criticalNotes}
+
+ )} + {warningNotes > 0 && ( +
+ this.props.onHeaderNoteClick && + this.props.onHeaderNoteClick(this.props.segment._id, NoteSeverity.WARNING) + } + aria-label={t('Warnings')} + > + +
{warningNotes}
+
+ )} +
+ )} + {identifiers.length > 0 && ( +
+ {identifiers.map((ident) => ( +
this.onClickPartIdent(ident.partId)} + > + {ident.ident} +
+ ))} +
+ )} + +
+
)} - - +
{this.props.playlist && this.props.parts && @@ -1241,7 +1264,7 @@ function HeaderEditStates({ userEditOperations }: HeaderEditStatesProps) {
{userEditOperations && userEditOperations.map((operation) => { - if (operation.type === UserEditingType.FORM || !operation.svgIcon || !operation.isActive) return null + if (operation.type !== UserEditingType.ACTION || !operation.svgIcon || !operation.isActive) return null return (
{ + class SourceLayerItem extends React.Component< + ISourceLayerItemProps & WithTranslation & ISourceLayerItemProps, + ISourceLayerItemState + > { animFrameHandle: number | undefined - constructor(props: ISourceLayerItemProps & WithTranslation) { + constructor(props: ISourceLayerItemProps & WithTranslation & ISourceLayerItemProps) { super(props) this.state = { showMiniInspector: false, @@ -663,42 +668,70 @@ export const SourceLayerItem = withTranslation()( const elementWidth = this.getElementAbsoluteWidth() return ( -
- {this.renderInsideItem(typeClass)} - {DEBUG_MODE && this.props.studio && ( -
- {innerPiece.enable.start} /{' '} - {RundownUtils.formatTimeToTimecode(this.props.studio.settings, this.props.partDuration).substr(-5)} /{' '} - {piece.renderedDuration - ? RundownUtils.formatTimeToTimecode(this.props.studio.settings, piece.renderedDuration).substr(-5) - : 'X'}{' '} - /{' '} - {typeof innerPiece.enable.duration === 'number' - ? RundownUtils.formatTimeToTimecode(this.props.studio.settings, innerPiece.enable.duration).substr(-5) - : ''} + + {(selectElementContext) => ( +
{ + if (this.props.studio?.settings.enableUserEdits) { + const pieceId = this.props.piece.instance.piece._id + if (!selectElementContext.isSelected(pieceId)) { + selectElementContext.clearAndSetSelection({ type: 'piece', elementId: pieceId }) + } else { + selectElementContext.clearSelections() + } + // Until a proper data structure, the only reference is a part. + // const partId = this.props.part.instance.part._id + // if (!selectElementContext.isSelected(partId)) { + // selectElementContext.clearAndSetSelection({ type: 'part', elementId: partId }) + // } else { + // selectElementContext.clearSelections() + // } + } else { + this.itemDblClick(e) + } + }} + onMouseUp={this.itemMouseUp} + onMouseMove={this.moveMiniInspector} + onMouseEnter={this.toggleMiniInspectorOn} + onMouseLeave={this.toggleMiniInspectorOff} + style={this.getItemStyle()} + > + {this.renderInsideItem(typeClass)} + {DEBUG_MODE && this.props.studio && ( +
+ {innerPiece.enable.start} /{' '} + {RundownUtils.formatTimeToTimecode(this.props.studio.settings, this.props.partDuration).substr(-5)}{' '} + /{' '} + {piece.renderedDuration + ? RundownUtils.formatTimeToTimecode(this.props.studio.settings, piece.renderedDuration).substr(-5) + : 'X'}{' '} + /{' '} + {typeof innerPiece.enable.duration === 'number' + ? RundownUtils.formatTimeToTimecode( + this.props.studio.settings, + innerPiece.enable.duration + ).substr(-5) + : ''} +
+ )}
)} -
+ ) } else { // render a placeholder diff --git a/packages/webui/src/client/ui/Settings/Forms.scss b/packages/webui/src/client/ui/Settings/Forms.scss index 9d5849c0aa..a31b5f5453 100644 --- a/packages/webui/src/client/ui/Settings/Forms.scss +++ b/packages/webui/src/client/ui/Settings/Forms.scss @@ -7,6 +7,14 @@ &:not(:first-child) { margin-top: 20px; } + + &.form-dark { + color: white; + + > .text-input.bghl { + color: black !important; + } + } } .properties-grid > :not(.field) { diff --git a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx index bb2302f832..8faf2747b0 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx @@ -380,6 +380,16 @@ function StudioSettings({ studio }: { studio: DBStudio }): JSX.Element { {(value, handleUpdate) => } + + {(value, handleUpdate) => } + + (undefined) + const hasPendingChanges = !!pendingChange + + const { piece, part, segment, rundownId } = useSelectedElements(selectedElement, () => setPendingChange(undefined)) + + const handleCommitChanges = async (e: React.MouseEvent) => { + if (!rundownId || !selectedElement || !pendingChange) return + + doUserAction( + t, + e, + UserAction.EXECUTE_USER_OPERATION, + (e, ts) => + MeteorCall.userAction.executeUserChangeOperation( + e, + ts, + rundownId, + { + segmentExternalId: segment?.externalId, + partExternalId: part?.externalId, + pieceExternalId: piece?.externalId, + }, + literal({ + id: DefaultUserOperationsTypes.UPDATE_PROPS, + payload: pendingChange, + }) + ), + () => setPendingChange(undefined) + ) + } + + const handleRevertChanges = (e: React.MouseEvent) => { + if (!rundownId || !selectedElement) return + setPendingChange(undefined) + doUserAction(t, e, UserAction.EXECUTE_USER_OPERATION, (e, ts) => + MeteorCall.userAction.executeUserChangeOperation( + e, + ts, + rundownId, + { + segmentExternalId: segment?.externalId, + partExternalId: part?.externalId, + pieceExternalId: undefined, + }, + { + id: + selectedElement.type === 'segment' + ? DefaultUserOperationsTypes.REVERT_SEGMENT + : DefaultUserOperationsTypes.REVERT_PART, + } + ) + ) + } + + const handleCancel = () => { + setPendingChange(undefined) + clearSelections() + } + + const executeAction = (e: React.MouseEvent, id: string) => { + if (!rundownId || !selectedElement) return + doUserAction(t, e, UserAction.EXECUTE_USER_OPERATION, (e, ts) => + MeteorCall.userAction.executeUserChangeOperation( + e, + ts, + rundownId, + { + segmentExternalId: segment?.externalId, + partExternalId: part?.externalId, + pieceExternalId: piece?.externalId, + }, + { + id, + } + ) + ) + } + + const userEditOperations = + selectedElement?.type === 'piece' + ? piece?.userEditOperations + : selectedElement?.type === 'part' + ? part?.userEditOperations + : selectedElement?.type === 'segment' + ? segment?.userEditOperations + : undefined + const userEditProperties = + selectedElement?.type === 'piece' + ? piece?.userEditProperties + : selectedElement?.type === 'part' + ? part?.userEditProperties + : selectedElement?.type === 'segment' + ? segment?.userEditProperties + : undefined + const change = pendingChange ?? { + pieceTypeProperties: userEditProperties?.pieceTypeProperties?.currentValue ?? { type: '', value: {} }, + globalProperties: userEditProperties?.globalProperties?.currentValue ?? {}, + } + + const title = + selectedElement?.type === 'piece' + ? piece?.name + : selectedElement?.type === 'part' + ? part?.title + : selectedElement?.type === 'segment' + ? segment?.name + : undefined + + return ( +
+
+
+ {userEditOperations && + userEditOperations.map((operation) => { + if (operation.type !== UserEditingType.ACTION || !operation.svgIcon || !operation.isActive) return null + return ( +
+ ) + })} +
{title}
+ {t('Properties')} + +
+ +
+ {userEditProperties?.pieceTypeProperties && ( + + )} + {userEditProperties?.globalProperties && ( + + )} + {userEditProperties?.operations && ( + + )} +
+ +
+ + +
+ + +
+
+
+
+ ) +} + +function PropertiesEditor({ + properties, + change, + setChange, + translationNamespace, +}: { + properties: UserEditingProperties['pieceTypeProperties'] + change: PendingChange + setChange: React.Dispatch> + translationNamespace: string[] +}): JSX.Element { + if (!properties) return <> + + const selectedGroupId = change.pieceTypeProperties.type + const selectedGroupSchema = properties.schema[selectedGroupId]?.schema + const parsedSchema = useMemo( + () => (selectedGroupSchema ? JSONBlobParse(selectedGroupSchema) : undefined), + [selectedGroupSchema] + ) + + const updateGroup = useCallback( + (key: string) => { + setChange({ + ...change, + pieceTypeProperties: { + type: key, + value: properties.schema[key]?.defaultValue ?? {}, + }, + }) + }, + [change] + ) + const onUpdate = useCallback( + (update: Record) => { + setChange({ + ...change, + pieceTypeProperties: { + type: change.pieceTypeProperties.type, + value: update, + }, + }) + }, + [change] + ) + const value = change.pieceTypeProperties.value + + return ( + <> +
+ {Object.entries(properties.schema).map(([key, group]) => { + return ( + + ) + })} +
+
+ {parsedSchema && ( +
+ +
+ )} +
+ + ) +} + +function GlobalPropertiesEditor({ + schema, + change, + setChange, + translationNamespace, +}: { + schema: JSONBlob + change: PendingChange + setChange: React.Dispatch> + translationNamespace: string[] +}): JSX.Element { + const parsedSchema = schema ? JSONBlobParse(schema) : undefined + const currentValue = change.globalProperties + + const onUpdate = useCallback( + (update: Record) => { + setChange({ + ...change, + globalProperties: update, + }) + }, + [change] + ) + + return ( +
+ {parsedSchema ? ( + + ) : ( + <> + )} +
+ ) +} + +function ActionList({ + actions, + executeAction, +}: { + actions: UserEditingDefinitionAction[] + executeAction: (e: any, id: string) => void +}) { + const { t } = useTranslation() + + return ( +
+ {actions.map((action) => ( + + ))} +
+ ) +} diff --git a/packages/webui/src/client/ui/UserEditOperations/RenderUserEditOperations.tsx b/packages/webui/src/client/ui/UserEditOperations/RenderUserEditOperations.tsx index a9cfcda12f..f33ab76b4e 100644 --- a/packages/webui/src/client/ui/UserEditOperations/RenderUserEditOperations.tsx +++ b/packages/webui/src/client/ui/UserEditOperations/RenderUserEditOperations.tsx @@ -21,7 +21,6 @@ interface UserEditOperationMenuItemsProps { export function UserEditOperationMenuItems({ rundownId, - targetName, operationTarget, userEditOperations, isFormEditable, @@ -62,9 +61,8 @@ export function UserEditOperationMenuItems({ const schema = JSONBlobParse(userEditOperation.schema) const values = clone(userEditOperation.currentValues) - // TODO: doModalDialog({ - title: t(`Edit {{targetName}}`, { targetName }), + title: translateMessage(userEditOperation.label, t), message: ( ), - // acceptText: 'OK', yes: t('Save Changes'), no: t('Cancel'), onAccept: () => { 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..a35e8b3f04 --- /dev/null +++ b/packages/webui/src/client/ui/UserEditOperations/__tests__/PropertiesPanel.test.tsx @@ -0,0 +1,433 @@ +import React from 'react' +// eslint-disable-next-line node/no-unpublished-import +import { renderHook, act, render, screen, waitFor, RenderOptions } 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 } from '@sofie-automation/blueprints-integration' +import { + SelectedElementProvider, + SelectedElementsContext, + SelectionContextType, + useSelectedElementsContext, +} from '../../RundownView/SelectedElementsContext' +import { MongoMock } from '../../../../__mocks__/mongo' +import { PropertiesPanel } from '../PropertiesPanel' +import { UserAction } from '../../../lib/clientUserAction' + +jest.mock('meteor/tracker', (...args) => require('../../../../__mocks__/tracker').setup(args), { virtual: true }) + +jest.mock('react-i18next', () => ({ + // this mock makes sure any components using the translate hook can use it without a warning being shown + useTranslation: () => { + return { + t: (str: string) => str, + i18n: { + changeLanguage: () => + new Promise(() => { + // satisfy linter - by making it uglier? ¯\_(ツ)_/¯ + }), + }, + } + }, + initReactI18next: { + type: '3rdParty', + init: () => { + // satisfy linter - by making it uglier? ¯\_(ツ)_/¯ + }, + }, +})) + +// Mock the ReactiveDataHelper: +jest.mock('../../../lib/reactiveData/reactiveDataHelper', () => { + interface MockSubscription { + stop: () => void + ready: () => boolean + } + + class MockReactiveDataHelper { + protected _subs: MockSubscription[] = [] + protected _computations: any[] = [] + + protected subscribe(_name: string, ..._args: any[]): MockSubscription { + const sub: MockSubscription = { + stop: jest.fn(), + ready: jest.fn().mockReturnValue(true), + } + this._subs.push(sub) + return sub + } + + protected autorun(f: () => void) { + // Execute the function immediately + f() + const computation = { + stop: jest.fn(), + _recompute: () => f(), + invalidate: function () { + this._recompute() + }, + onInvalidate: jest.fn(), + } + this._computations.push(computation) + return computation + } + + destroy() { + this._subs.forEach((sub) => sub.stop()) + this._computations.forEach((comp) => comp.stop()) + this._subs = [] + this._computations = [] + } + } + + class MockWithManagedTracker extends MockReactiveDataHelper { + constructor() { + super() + } + + triggerUpdate() { + this._computations.forEach((comp) => comp.invalidate()) + } + } + + return { + __esModule: true, + WithManagedTracker: MockWithManagedTracker, + meteorSubscribe: jest.fn().mockReturnValue({ + stop: jest.fn(), + ready: jest.fn().mockReturnValue(true), + }), + } +}) + +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(), + }, +})) + +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()) + ), + UserAction: { + EXECUTE_USER_OPERATION: 51, + }, +})) + +// 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
, +})) +jest.mock('../../../lib/forms/SchemaFormWithState', () => ({ + SchemaFormWithState: () =>
Schema Form
, +})) + +describe('PropertiesPanel', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + const renderWithContext = ( + ui: React.ReactNode, + { ctxValue, ...renderOptions }: RenderOptions & { ctxValue: SelectionContextType } + ) => { + return render( + {ui}, + renderOptions + ) + } + + beforeEach(() => { + mockSegmentsCollection.remove({}) + mockPartsCollection.remove({}) + jest.clearAllMocks() + // jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + 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', namespaces: ['blueprint_main-showstyle'] }, + type: UserEditingType.ACTION, + isActive: false, + svgIcon: '', + }, + ], + userEditProperties: { + operations: [ + { + id: 'operation1', + label: { key: 'TEST_LABEL', namespaces: ['blueprint_main-showstyle'] }, + type: UserEditingType.ACTION, + isActive: false, + svgIcon: '', + }, + ], + translationNamespaces: ['blueprint_main-showstyle'], + }, + isHidden: 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', namespaces: ['blueprint_main-showstyle'] }, + type: UserEditingType.ACTION, + isActive: true, + }, + ], + }) + + test('renders empty when no element selected', () => { + const { container } = render(, { wrapper }) + expect(container.querySelector('.properties-panel')).toBeTruthy() + expect(container.querySelector('.properties-panel-pop-up__form')).toBeFalsy() + }) + + test('renders segment properties when segment is selected', async () => { + const mockSegment = createMockSegment('segment1') + + const mockId = mockSegmentsCollection.insert(mockSegment) + const protectedMockId = protectString(mockId) + + const verifySegment = mockSegmentsCollection.findOne({ _id: protectedMockId }) + expect(verifySegment).toBeTruthy() + expect(mockSegmentsCollection.findOne({ _id: protectedMockId })).toBeTruthy() + + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + + // Update selection and wait for component to update + await act(async () => { + result.current.clearAndSetSelection({ + type: 'segment', + elementId: protectedMockId, + }) + }) + + expect(result.current.listSelectedElements()).toHaveLength(1) + expect(result.current.listSelectedElements()).toEqual([{ type: 'segment', elementId: mockId }]) + + // Open component after segment is selected (as used in rundownview) + const { container } = renderWithContext(, { ctxValue: result.current }) + + expect(screen.getByText(`${mockSegment.name.slice(0, 30)}`)).toBeInTheDocument() + + const button = container.querySelector('.propertiespanel-pop-up__button') + expect(button).toBeInTheDocument() + }) + + test('renders part properties when part is selected', async () => { + const mockSegment = createMockSegment('segment1') + const mockPart = createMockPart('part1', String(mockSegment._id)) + + mockSegmentsCollection.insert(mockSegment) + const mockId = mockPartsCollection.insert(mockPart) + + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + + await act(async () => { + result.current.clearAndSetSelection({ + type: 'part', + elementId: protectString(mockId), + }) + }) + // Open component after part is selected (as used in rundownview) + const { container } = renderWithContext(, { ctxValue: result.current }) + + await waitFor( + () => { + expect(screen.getByText(mockPart.title.slice(0, 30))).toBeInTheDocument() + }, + { timeout: 1000 } + ) + + const button = container.querySelector('.propertiespanel-pop-up__button') + expect(button).toBeInTheDocument() + }) + + test('handles user edit operations for segments', async () => { + const mockSegment = createMockSegment('segment1') + mockSegmentsCollection.insert(mockSegment) + + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + + await act(async () => { + result.current.clearAndSetSelection({ + type: 'segment', + elementId: mockSegment._id, + }) + }) + + // Wait for the switch button to be available + renderWithContext(, { ctxValue: result.current }) + const switchButton = await waitFor(() => screen.getByText('TEST_LABEL')) + expect(switchButton).toBeTruthy() + + if (!switchButton) return // above would have thrown - this is a type guard + + // Toggle the switch + await userEvent.click(switchButton) + + // Check if commit button is enabled + const commitButton = screen.getByText('Save') + expect(commitButton).toBeEnabled() + + // Commit changes + await act(async () => { + 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) + + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + + await act(async () => { + result.current.clearAndSetSelection({ + type: 'segment', + elementId: mockSegment._id, + }) + }) + + const { container } = renderWithContext(, { ctxValue: result.current }) + + // Wait for the switch button to be available + const switchButton = await waitFor(() => container.querySelector('.propertiespanel-pop-up__switchbutton')) + + // Make a change + await act(async () => { + await userEvent.click(switchButton!) + }) + + // Click revert button + const revertButton = screen.getByText('Restore Segment from NRCS') + await act(async () => { + await userEvent.click(revertButton) + }) + + expect(MeteorCall.userAction.executeUserChangeOperation).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + protectString('rundown1'), + { + segmentExternalId: mockSegment.externalId, + partExternalId: undefined, + pieceExternalId: undefined, + }, + { + id: '__sofie-revert-segment', + } + ) + }) + + test('closes panel when close button is clicked', async () => { + const mockSegment = createMockSegment('segment1') + mockSegmentsCollection.insert(mockSegment) + + const { result } = renderHook(() => useSelectedElementsContext(), { wrapper }) + const { container } = render(, { wrapper }) + + await act(async () => { + result.current.clearAndSetSelection({ + type: 'segment', + elementId: mockSegment._id, + }) + }) + + const closeButton = await waitFor(() => container.querySelector('.propertiespanel-pop-up_close')) + expect(closeButton).toBeTruthy() + + await act(async () => { + await userEvent.click(closeButton!) + }) + + // expect(container.querySelector('.propertiespanel-pop-up__contents')).toBeFalsy() + expect(container.querySelector('.properties-panel-pop-up__form')).toBeFalsy() + }) +}) diff --git a/packages/yarn.lock b/packages/yarn.lock index 5928b1d4cc..ad6e1c286d 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" @@ -5312,6 +5319,10 @@ __metadata: "@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/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 @@ -5895,6 +5906,66 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^10.4.0": + version: 10.4.0 + resolution: "@testing-library/dom@npm:10.4.0" + dependencies: + "@babel/code-frame": ^7.10.4 + "@babel/runtime": ^7.12.5 + "@types/aria-query": ^5.0.1 + aria-query: 5.3.0 + chalk: ^4.1.0 + dom-accessibility-api: ^0.5.9 + lz-string: ^1.5.0 + pretty-format: ^27.0.2 + checksum: bb128b90be0c8cd78c5f5e67aa45f53de614cc048a2b50b230e736ec710805ac6c73375af354b83c74d710b3928d52b83a273a4cb89de4eb3efe49e91e706837 + 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" + dependencies: + "@babel/runtime": ^7.12.5 + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 + "@types/react-dom": ^18.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 1837db473ea018cf2b5d0cbfffb7a30d0d759e5a7f23aad431441c77bcc3d2533250cd003a61878fd908267df47404cedcb5914f12d79e413002c659652b37fd + 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" @@ -6005,6 +6076,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: ad8b87e4ad64255db5f0a73bc2b4da9b146c38a3a8ab4d9306154334e0fc67ae64e76bfa298eebd1e71830591fb15987e5de7111bdb36a2221bdc379e3415fb0 + languageName: node + linkType: hard + "@types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -7794,6 +7872,22 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: ^2.0.3 + checksum: 305bd73c76756117b59aba121d08f413c7ff5e80fa1b98e217a3443fcddb9a232ee790e24e432b59ae7625aebcf4c47cb01c2cac872994f0b426f5bdfcd96ba9 + 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" @@ -9142,6 +9236,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" @@ -10426,6 +10530,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" @@ -11375,7 +11486,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"dequal@npm:^2.0.0": +"dequal@npm:^2.0.0, dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" checksum: 8679b850e1a3d0ebbc46ee780d5df7b478c23f335887464023a631d1b9af051ad4a6595a44220f9ff8ff95a8ddccf019b5ad778a976fd7bbf77383d36f412f90 @@ -11558,6 +11669,20 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248 + 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" @@ -17724,6 +17849,15 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 1ee98b4580246fd90dd54da6e346fb1caefcf05f677c686d9af237a157fdea3fd7c83a4bc58f858cd5b10a34d27afe0fdcbd0505a47e0590726a873dc8b8f65d + languageName: node + linkType: hard + "magic-string@npm:^0.30.3": version: 0.30.10 resolution: "magic-string@npm:0.30.10" @@ -22228,6 +22362,17 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: ^5.0.1 + ansi-styles: ^5.0.0 + react-is: ^17.0.1 + checksum: cf610cffcb793885d16f184a62162f2dd0df31642d9a18edf4ca298e909a8fe80bdbf556d5c9573992c102ce8bf948691da91bf9739bee0ffb6e79c8a8a6e088 + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -23000,6 +23145,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8 + languageName: node + linkType: hard + "react-is@npm:^18.0.0, react-is@npm:^18.2.0": version: 18.3.1 resolution: "react-is@npm:18.3.1"