diff --git a/.releaserc b/.releaserc index 0c835b13..7ed05098 100644 --- a/.releaserc +++ b/.releaserc @@ -7,7 +7,6 @@ "plugins": [ "@semantic-release/commit-analyzer", "semantic-release-version-file", - "@semantic-release/release-notes-generator", "@semantic-release/github", "@semantic-release/npm" ] diff --git a/__mocks__/io.mock.ts b/__mocks__/io.mock.ts index eaf00978..9b94519d 100644 --- a/__mocks__/io.mock.ts +++ b/__mocks__/io.mock.ts @@ -1,32 +1,38 @@ import { jest } from '@jest/globals'; -import * as Socket from '@superviz/socket-client'; export const MOCK_IO = { + PresenceEvents: { + JOINED_ROOM: 'presence.joined-room', + LEAVE: 'presence.leave', + ERROR: 'presence.error', + UPDATE: 'presence.update', + }, Realtime: class { - public connection: { - on: (state: string) => void; - off: () => void; - }; + connection; - constructor(apiKey: string, environment: string, participant: any) { + constructor(apiKey, environment, participant) { this.connection = { on: jest.fn(), off: jest.fn(), }; } - public connect() { + connect() { return { on: jest.fn(), off: jest.fn(), emit: jest.fn(), + disconnect: jest.fn(), + history: jest.fn(), presence: { on: jest.fn(), off: jest.fn(), + get: jest.fn(), + update: jest.fn(), }, }; } - public destroy() {} + destroy() {} }, }; diff --git a/jest.setup.js b/jest.setup.js index 3d92fb96..ff4045e5 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,6 +1,9 @@ +/* eslint-disable no-undef */ const fs = require('fs'); +require('jest-canvas-mock'); const { MOCK_CONFIG } = require('./__mocks__/config.mock'); +const { MOCK_IO } = require('./__mocks__/io.mock'); const config = require('./src/services/config'); config.default.setConfig(MOCK_CONFIG); @@ -32,3 +35,5 @@ global.DOMPoint = class { return this; } }; + +jest.mock('@superviz/socket-client', () => MOCK_IO); diff --git a/package.json b/package.json index 2bea9c7a..8e501a22 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "husky": "^8.0.3", "jest": "^29.7.0", "jest-browser-globals": "^25.1.0-beta", + "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", "jest-fetch-mock": "^3.0.3", "rimraf": "^5.0.5", diff --git a/src/common/types/cdn.types.ts b/src/common/types/cdn.types.ts index 23f131c0..5742c5a7 100644 --- a/src/common/types/cdn.types.ts +++ b/src/common/types/cdn.types.ts @@ -6,6 +6,7 @@ import { Realtime, VideoConference, WhoIsOnline, + FormElements, } from '../../components'; import { RealtimeComponentEvent, RealtimeComponentState } from '../../components/realtime/types'; import { LauncherFacade } from '../../core/launcher/types'; @@ -55,6 +56,7 @@ export interface SuperVizCdn { CanvasPin: typeof CanvasPin; HTMLPin: typeof HTMLPin; WhoIsOnline: typeof WhoIsOnline; + FormElements: typeof FormElements; RealtimeComponentState: typeof RealtimeComponentState; RealtimeComponentEvent: typeof RealtimeComponentEvent; } diff --git a/src/common/types/stores.types.ts b/src/common/types/stores.types.ts index 01cf29f1..8c8ec02b 100644 --- a/src/common/types/stores.types.ts +++ b/src/common/types/stores.types.ts @@ -8,14 +8,19 @@ export enum StoreType { WHO_IS_ONLINE = 'who-is-online-store', } -type StoreApi any> = { +type Subject any, K extends keyof ReturnType> = ReturnType[K] + +type StoreApiWithoutDestroy any> = { [K in keyof ReturnType]: { - subscribe(callback?: (value: keyof T) => void): void; - subject: PublicSubject; - publish(value: T): void; - value: any; + subscribe(callback?: (value: Subject['value']) => void): void; + subject: Subject; + publish(value: Subject['value']): void; + value: Subject['value']; }; -}; +} + +type StoreApi any> = Omit, 'destroy'> & { destroy(): void }; + // When creating new Stores, expand the ternary with the new Store. For example: // ...T extends StoreType.GLOBAL ? StoreApi : T extends StoreType.WHO_IS_ONLINE ? StoreApi : never; diff --git a/src/common/utils/use-store.ts b/src/common/utils/use-store.ts index 85fc90f4..26796a1c 100644 --- a/src/common/utils/use-store.ts +++ b/src/common/utils/use-store.ts @@ -38,21 +38,22 @@ function subscribeTo( * @description Returns a proxy of the global store data and a subscribe function to be used in the components */ export function useStore(name: T): Store { - // @TODO - Improve types to get better sugestions when writing code const storeData = stores[name as StoreType](); const bindedSubscribeTo = subscribeTo.bind(this); const proxy = new Proxy(storeData, { - get(store: Store, valueName: string) { + get(store, valueName: string) { + if (valueName === 'destroy') return store.destroy; + return { - subscribe>(callback?: (value: K) => void) { + subscribe(callback?) { bindedSubscribeTo(valueName, store[valueName], callback); }, - subject: store[valueName] as typeof storeData, + subject: store[valueName], get value() { return this.subject.value; }, - publish(newValue: keyof Store) { + publish(newValue) { this.subject.value = newValue; }, }; diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 1b50943c..02e10aea 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -11,6 +11,7 @@ import { AblyRealtimeService } from '../../services/realtime'; import { ComponentNames } from '../types'; import { DefaultAttachComponentOptions } from './types'; +import { useGlobalStore } from '../../services/stores'; export abstract class BaseComponent extends Observable { public abstract name: ComponentNames; @@ -85,6 +86,8 @@ export abstract class BaseComponent extends Observable { this.logger.log('detached'); this.publish(ComponentLifeCycleEvent.UNMOUNT); this.destroy(); + this.room.disconnect(); + this.unsubscribeFrom.forEach((unsubscribe) => unsubscribe(this)); Object.values(this.observers).forEach((observer) => { diff --git a/src/components/comments/index.ts b/src/components/comments/index.ts index f3aabd3b..f53c4f04 100644 --- a/src/components/comments/index.ts +++ b/src/components/comments/index.ts @@ -61,7 +61,7 @@ export class Comments extends BaseComponent { const { group, localParticipant } = this.useStore(StoreType.GLOBAL); group.subscribe(); - localParticipant.subscribe((participant: Participant) => { + localParticipant.subscribe((participant) => { this.localParticipantId = participant.id; }); } diff --git a/src/components/form-elements/index.test.ts b/src/components/form-elements/index.test.ts new file mode 100644 index 00000000..a62a5efd --- /dev/null +++ b/src/components/form-elements/index.test.ts @@ -0,0 +1,1103 @@ +import { MOCK_CONFIG } from '../../../__mocks__/config.mock'; +import { EVENT_BUS_MOCK } from '../../../__mocks__/event-bus.mock'; +import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; +import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.mock'; +import { useStore } from '../../common/utils/use-store'; +import { IOC } from '../../services/io'; +import { ComponentNames } from '../types'; + +import { FieldEvents } from './types'; + +import { FormElements } from '.'; + +describe('form elements', () => { + let instance: any; + + beforeEach(() => { + document.body.innerHTML = ` + + + + + + `; + + instance = new FormElements(); + + instance.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), + realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: true }), + config: MOCK_CONFIG, + eventBus: EVENT_BUS_MOCK, + useStore, + }); + }); + + describe('constructor', () => { + test('should create an instance of FormElements', () => { + expect(instance).toBeInstanceOf(FormElements); + }); + + test('should set the name of the component', () => { + expect(instance.name).toBe(ComponentNames.FORM_ELEMENTS); + }); + + test('should set the logger', () => { + expect(instance['logger']).toBeDefined(); + }); + + test('should throw error if fields is not a string or an array', () => { + const fields = () => new FormElements({ fields: 123 as any }); + expect(fields).toThrowError(); + }); + + test('should throw error if fields is a string and not a valid field id', () => { + const fields = () => new FormElements({ fields: 'non-existent-field-id' }); + expect(fields).toThrowError(); + }); + + test('should throw error if fields is an array and does not contain a valid field id', () => { + const fields = () => new FormElements({ fields: ['non-existent-field-id'] }); + expect(fields).toThrowError(); + }); + + test('should set fields to an empty object if fields is not provided', () => { + const fields = new FormElements(); + expect(fields['fields']).toEqual({}); + }); + + test('should set field to store fields ids if an valid array of ids is provided', () => { + const fields = new FormElements({ fields: ['field-1', 'field-2'] }); + expect(fields['fields']).toEqual({ 'field-1': null, 'field-2': null }); + }); + + test('should set field to store fields ids if an valid string id is provided', () => { + const fields = new FormElements({ fields: 'field-1' }); + expect(fields['fields']).toEqual({ 'field-1': null }); + }); + + test('should throw error if trying to register an element that is not an input or textarea', () => { + const fields = () => new FormElements({ fields: ['button'] }); + expect(fields).toThrowError(); + }); + + test('should throw error if trying to register input with invalid type', () => { + const fields = () => new FormElements({ fields: ['hidden'] }); + expect(fields).toThrowError(); + }); + }); + + describe('start', () => { + test('should set localParticipant', () => { + instance['start'](); + expect(instance['localParticipant']).toBeDefined(); + }); + + test('should call registerField for each field ID passed to the constructor', () => { + instance = new FormElements({ fields: ['field-1', 'field-2'] }); + const spy = jest.spyOn(instance, 'registerField'); + instance['start'](); + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + + describe('destroy', () => { + test('should call restoreOutlines', () => { + const spy = jest.spyOn(instance, 'restoreOutlines'); + instance['destroy'](); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('should call deregisterAllFields', () => { + const spy = jest.spyOn(instance, 'deregisterAllFields'); + instance['destroy'](); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('should set fieldsOriginalOutline to undefined', () => { + instance['fieldsOriginalOutline'] = 'some value'; + instance['destroy'](); + expect(instance['fieldsOriginalOutline']).toBeUndefined(); + }); + }); + + describe('addListenersToField', () => { + test('should add event listeners to the field', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.addEventListener = jest.fn(); + instance['addListenersToField'](field); + expect(field.addEventListener).toHaveBeenCalledTimes(3); + expect(field.addEventListener).toHaveBeenCalledWith('input', instance['handleInput']); + expect(field.addEventListener).toHaveBeenCalledWith('focus', instance['handleFocus']); + expect(field.addEventListener).toHaveBeenCalledWith('blur', instance['handleBlur']); + }); + + test('should add proper listener to checkbox and radio input types', () => { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = 'checkbox'; + document.body.appendChild(checkbox); + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.id = 'radio'; + document.body.appendChild(radio); + + const radioSpy = jest.spyOn(checkbox, 'addEventListener'); + const checkboxSpy = jest.spyOn(radio, 'addEventListener'); + + instance['flags'].disableOutline = true; + + instance['addListenersToField'](checkbox); + instance['addListenersToField'](radio); + + expect(radioSpy).toHaveBeenCalledTimes(1); + expect(checkboxSpy).toHaveBeenCalledTimes(1); + + expect(radioSpy).toHaveBeenCalledWith('change', instance['handleChange']); + expect(checkboxSpy).toHaveBeenCalledWith('change', instance['handleChange']); + }); + }); + + describe('addRealtimeListenersToField', () => { + test('should add realtime listeners to the field', () => { + instance['room'] = { + on: jest.fn(), + } as any; + + instance['updateFieldContent'] = jest.fn(); + instance['updateFieldColor'] = jest.fn(); + instance['removeFieldColor'] = jest.fn(); + instance['flags'].disableOutline = false; + + instance['addRealtimeListenersToField']('field-1'); + + expect(instance['room'].on).toHaveBeenCalledTimes(4); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.inputfield-1', + instance['updateFieldContent'], + ); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.keyboard-interactionfield-1', + instance['publishTypedEvent'], + ); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.focusfield-1', + instance['updateFieldColor'], + ); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.blurfield-1', + instance['removeFieldColor'], + ); + }); + + test('should not add listeners to focus and blur if flag disableOutline is true', () => { + instance['room'] = { + on: jest.fn(), + } as any; + + instance['updateFieldContent'] = jest.fn(); + instance['updateFieldColor'] = jest.fn(); + instance['removeFieldColor'] = jest.fn(); + instance['flags'].disableOutline = true; + + instance['addRealtimeListenersToField']('field-1'); + + expect(instance['room'].on).toHaveBeenCalledTimes(2); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.inputfield-1', + instance['updateFieldContent'], + ); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.keyboard-interactionfield-1', + instance['publishTypedEvent'], + ); + }); + + test('should not add realtime listeners if room is not defined', () => { + instance['updateFieldContent'] = jest.fn(); + instance['updateFieldColor'] = jest.fn(); + instance['removeFieldColor'] = jest.fn(); + + instance['room'] = undefined; + instance['addRealtimeListenersToField']('field-1'); + + expect(instance['updateFieldContent']).not.toHaveBeenCalled(); + expect(instance['updateFieldColor']).not.toHaveBeenCalled(); + expect(instance['removeFieldColor']).not.toHaveBeenCalled(); + }); + }); + + describe('removeListenersFromField', () => { + test('should remove event listeners from the field', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.removeEventListener = jest.fn(); + instance['removeListenersFromField'](field); + expect(field.removeEventListener).toHaveBeenCalledTimes(3); + expect(field.removeEventListener).toHaveBeenCalledWith('input', instance['handleInput']); + expect(field.removeEventListener).toHaveBeenCalledWith('focus', instance['handleFocus']); + expect(field.removeEventListener).toHaveBeenCalledWith('blur', instance['handleBlur']); + }); + + test('should remove proper listener to checkbox and radio input types', () => { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = 'checkbox'; + document.body.appendChild(checkbox); + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.id = 'radio'; + document.body.appendChild(radio); + + const radioSpy = jest.spyOn(checkbox, 'removeEventListener'); + const checkboxSpy = jest.spyOn(radio, 'removeEventListener'); + + instance['flags'].disableOutline = true; + + instance['removeListenersFromField'](checkbox); + instance['removeListenersFromField'](radio); + + expect(radioSpy).toHaveBeenCalledTimes(1); + expect(checkboxSpy).toHaveBeenCalledTimes(1); + + expect(radioSpy).toHaveBeenCalledWith('change', instance['handleChange']); + expect(checkboxSpy).toHaveBeenCalledWith('change', instance['handleChange']); + }); + }); + + describe('removeRealtimeListenersFromField', () => { + test('should remove realtime listeners from the field', () => { + instance['room'] = { + off: jest.fn(), + } as any; + + instance['updateFieldContent'] = jest.fn(); + instance['updateFieldColor'] = jest.fn(); + instance['removeFieldColor'] = jest.fn(); + + instance['flags'].disableShowOutline = false; + + instance['removeRealtimeListenersFromField']('field-1'); + expect(instance['room'].off).toHaveBeenCalledTimes(4); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 1, + 'field.inputfield-1', + instance['updateFieldContent'], + ); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 2, + 'field.keyboard-interactionfield-1', + instance['publishTypedEvent'], + ); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 3, + 'field.focusfield-1', + instance['updateFieldColor'], + ); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 4, + 'field.blurfield-1', + instance['removeFieldColor'], + ); + }); + + test('should not remove realtime listeners if room is not defined', () => { + instance['updateFieldContent'] = jest.fn(); + instance['updateFieldColor'] = jest.fn(); + instance['removeFieldColor'] = jest.fn(); + instance['room'] = undefined; + + instance['removeRealtimeListenersFromField']('field-1'); + + expect(instance['updateFieldContent']).not.toHaveBeenCalled(); + expect(instance['updateFieldColor']).not.toHaveBeenCalled(); + expect(instance['removeFieldColor']).not.toHaveBeenCalled(); + }); + + test('should not remove listeners to focus and blur if flag disableOutline is true', () => { + instance['room'] = { + off: jest.fn(), + } as any; + + instance['updateFieldContent'] = jest.fn(); + instance['updateFieldColor'] = jest.fn(); + instance['removeFieldColor'] = jest.fn(); + + instance['flags'].disableOutline = true; + + instance['removeRealtimeListenersFromField']('field-1'); + expect(instance['room'].off).toHaveBeenCalledTimes(2); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 1, + 'field.inputfield-1', + instance['updateFieldContent'], + ); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 2, + 'field.keyboard-interactionfield-1', + instance['publishTypedEvent'], + ); + }); + }); + + describe('registerField', () => { + test('should register a field', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + + instance['validateField'] = jest.fn(); + instance['addListenersToField'] = jest.fn(); + instance['addRealtimeListenersToField'] = jest.fn(); + instance['fieldsOriginalOutline'] = {}; + + instance['registerField']('field-1'); + + expect(instance['validateField']).toHaveBeenCalledTimes(1); + expect(instance['fields']['field-1']).toBe(field); + expect(instance['addListenersToField']).toHaveBeenCalledTimes(1); + expect(instance['addRealtimeListenersToField']).toHaveBeenCalledTimes(1); + expect(instance['fieldsOriginalOutline']['field-1']).toBe(field.style.outline); + }); + }); + + describe('deregisterAllFields', () => { + test('should call deregisterField for each field in fields', () => { + instance['deregisterField'] = jest.fn(); + instance['fields'] = { + 'field-1': document.getElementById('field-1') as HTMLInputElement, + 'field-2': document.getElementById('field-2') as HTMLInputElement, + }; + + instance['deregisterAllFields'](); + + expect(instance['deregisterField']).toHaveBeenCalledTimes(2); + expect(instance['fields']).toBeUndefined(); + }); + }); + + describe('deregisterField', () => { + test('should deregister a field', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + + instance['removeListenersFromField'] = jest.fn(); + instance['removeRealtimeListenersFromField'] = jest.fn(); + instance['fieldsOriginalOutline'] = { 'field-1': 'some value' }; + instance['fields']['field-1'] = field; + + instance['deregisterField']('field-1'); + + expect(instance['removeListenersFromField']).toHaveBeenCalledTimes(1); + expect(instance['removeRealtimeListenersFromField']).toHaveBeenCalledTimes(1); + expect(field.style.outline).toBe('some value'); + expect(instance['fields']['field-1']).toBeUndefined(); + }); + + test('should throw error if field is not registered', () => { + const deregister = () => instance['deregisterField']('non-existent-field-id'); + expect(deregister).toThrowError(); + }); + + test('should emit blur event', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + instance['fields']['field-1'] = field; + + instance['room'] = { + emit: jest.fn(), + off: jest.fn(), + } as any; + + instance['deregisterField']('field-1'); + + expect(instance['room'].emit).toHaveBeenCalledTimes(1); + expect(instance['room'].emit).toHaveBeenCalledWith('field.blurfield-1', { + fieldId: 'field-1', + }); + }); + }); + + describe('handleInput', () => { + test('should emit an event with the field content and local participant color', () => { + instance['room'] = { + emit: jest.fn(), + } as any; + instance['localParticipant'] = { + slot: { color: 'red' }, + } as any; + + const event = { + target: { value: 'some value', id: 'field-1' }, + } as any; + + instance['handleInput'](event); + + expect(instance['room'].emit).toHaveBeenCalledTimes(2); + + expect(instance['room'].emit).toHaveBeenCalledWith('field.keyboard-interactionfield-1', { + fieldId: 'field-1', + color: 'red', + }); + + expect(instance['room'].emit).toHaveBeenCalledWith('field.inputfield-1', { + value: 'some value', + color: 'red', + fieldId: 'field-1', + showOutline: true, + syncContent: true, + attribute: 'value', + }); + }); + + test('should not emit input event if can not sync', () => { + instance['room'] = { + emit: jest.fn(), + } as any; + instance['localParticipant'] = { + slot: { color: 'red' }, + } as any; + + const event = { + target: { value: 'some value', id: 'field-1' }, + } as any; + + instance['flags'].disableRealtimeSync = true; + + instance['handleInput'](event); + + expect(instance['room'].emit).toHaveBeenCalledTimes(1); + + expect(instance['room'].emit).toHaveBeenCalledWith('field.keyboard-interactionfield-1', { + fieldId: 'field-1', + color: 'red', + }); + }); + }); + + describe('handleChange', () => { + test('should emit an event with the field content and local participant color', () => { + instance['room'] = { + emit: jest.fn(), + } as any; + instance['localParticipant'] = { + slot: { color: 'red' }, + } as any; + + const event = { + target: { checked: true, id: 'field-1' }, + } as any; + + instance['handleChange'](event); + + expect(instance['room'].emit).toHaveBeenCalledTimes(1); + + expect(instance['room'].emit).toHaveBeenCalledWith('field.inputfield-1', { + value: true, + color: 'red', + fieldId: 'field-1', + showOutline: true, + syncContent: true, + attribute: 'checked', + }); + }); + + test('should not emit input event if can not sync', () => { + instance['room'] = { + emit: jest.fn(), + } as any; + instance['localParticipant'] = { + slot: { color: 'red' }, + } as any; + + const event = { + target: { checked: true, id: 'field-1' }, + } as any; + + instance['flags'].disableRealtimeSync = true; + + instance['handleChange'](event); + + expect(instance['room'].emit).toHaveBeenCalledTimes(0); + }); + }); + + describe('handleFocus', () => { + test('should emit an event with the local participant color', () => { + instance['room'] = { + emit: jest.fn(), + } as any; + instance['localParticipant'] = { + slot: { color: 'red' }, + } as any; + + const event = { + target: { id: 'field-1' }, + } as any; + + instance['handleFocus'](event); + + expect(instance['room'].emit).toHaveBeenCalledTimes(1); + expect(instance['room'].emit).toHaveBeenCalledWith('field.focusfield-1', { + color: 'red', + fieldId: 'field-1', + }); + }); + }); + + describe('handleBlur', () => { + test('should emit an event', () => { + instance['room'] = { + emit: jest.fn(), + } as any; + + const event = { + target: { id: 'field-1' }, + } as any; + + instance['handleBlur'](event); + + expect(instance['room'].emit).toHaveBeenCalledTimes(1); + expect(instance['room'].emit).toHaveBeenCalledWith('field.blurfield-1', { + fieldId: 'field-1', + }); + }); + }); + + describe('validateField', () => { + test('should call validation methods', () => { + instance['validateFieldId'] = jest.fn(); + instance['validateFieldTagName'] = jest.fn(); + instance['validateFieldType'] = jest.fn(); + + instance['validateField']('field-1'); + + expect(instance['validateFieldId']).toHaveBeenCalledTimes(1); + expect(instance['validateFieldTagName']).toHaveBeenCalledTimes(1); + expect(instance['validateFieldType']).toHaveBeenCalledTimes(1); + }); + }); + + describe('validateFieldTagName', () => { + test('should throe error if field tag name is not allowed', () => { + const field = document.getElementById('button') as HTMLButtonElement; + const validate = () => instance['validateFieldTagName'](field); + expect(validate).toThrowError(); + }); + + test('should not throw error if field tag name is allowed', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + const validate = () => instance['validateFieldTagName'](field); + expect(validate).not.toThrowError(); + }); + }); + + describe('validateFieldType', () => { + test('should throw error if input type is not allowed', () => { + const field = document.getElementById('hidden') as HTMLInputElement; + const validate = () => instance['validateFieldType'](field); + expect(validate).toThrowError(); + }); + + test('should not throw error if input type is allowed', () => { + const field = document.getElementById('field-2') as HTMLInputElement; + const validate = () => instance['validateFieldType'](field); + expect(validate).not.toThrowError(); + }); + + test('should not throw error if field is not an input', () => { + const field = document.getElementById('field-3') as HTMLTextAreaElement; + const validate = () => instance['validateFieldType'](field); + expect(validate).not.toThrowError(); + }); + }); + + describe('validateFieldId', () => { + test('should throw error if field is not found', () => { + const validate = () => instance['validateFieldId']('non-existent-field-id'); + expect(validate).toThrowError(); + }); + + test('should not throw error if field is found', () => { + const validate = () => instance['validateFieldId']('field-1'); + expect(validate).not.toThrowError(); + }); + }); + + describe('removeFieldColor', () => { + test('should remove field color', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.style.outline = '1px solid red'; + instance['fieldsOriginalOutline'] = { 'field-1': 'some value' }; + instance['fields'] = { 'field-1': field }; + instance['focusList'] = { 'field-1': { id: '123' } }; + + instance['removeFieldColor']({ presence: { id: '123' }, data: { fieldId: 'field-1' } }); + + expect(field.style.outline).toBe('some value'); + expect(instance['fields']['field-1']).toBe(field); + expect(instance['fieldsOriginalOutline']['field-1']).toBeUndefined(); + expect(instance['focusList']['field-1']).toBeUndefined(); + }); + + test('should not remove field color if focusList id does not match presence id', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.style.outline = '1px solid red'; + instance['fieldsOriginalOutline'] = { 'field-1': 'some value' }; + instance['fields'] = { 'field-1': field }; + instance['focusList'] = { 'field-1': { id: '321' } }; + + instance['removeFieldColor']({ presence: { id: '123' }, data: { fieldId: 'field-1' } }); + + expect(field.style.outline).toBe('1px solid red'); + expect(instance['fieldsOriginalOutline']['field-1']).toBe('some value'); + expect(instance['fields']['field-1']).toBe(field); + expect(instance['focusList']).toStrictEqual({ 'field-1': { id: '321' } }); + }); + + test('should not remove field color if field is not registered', () => { + instance['fieldsOriginalOutline']['field-1'] = 'some value'; + instance['removeFieldColor']({ presence: { id: '123' }, data: { fieldId: 'field-1' } }); + + expect(instance['fieldsOriginalOutline']['field-1']).toBe('some value'); + }); + + test('should set outline to empty string if there is no saved original outline', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.style.outline = '1px solid red'; + instance['fields'] = { 'field-1': field }; + instance['focusList'] = { 'field-1': { id: '123' } }; + + instance['removeFieldColor']({ presence: { id: '123' }, data: { fieldId: 'field-1' } }); + + expect(field.style.outline).toBe(''); + }); + }); + + describe('updateFieldColor', () => { + beforeEach(() => { + instance['localParticipant'] = MOCK_LOCAL_PARTICIPANT; + }); + + test('should update field color if there was no focus on it', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + instance['fields'] = { 'field-1': field }; + instance['focusList'] = {}; + + instance['updateFieldColor']({ + presence: { id: '123' }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 1000, + }); + + expect(field.style.outline).toBe('1px solid red'); + expect(instance['focusList']['field-1']).toEqual({ + id: '123', + color: 'red', + firstInteraction: 1000, + lastInteraction: 1000, + }); + }); + + test('should update field color if last interaction was a while ago', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + instance['fields'] = { 'field-1': field }; + instance['focusList'] = { 'field-1': { id: '123', lastInteraction: 0 } }; + + instance['updateFieldColor']({ + presence: { id: '321' }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 5000, + }); + + expect(field.style.outline).toBe('1px solid red'); + expect(instance['focusList']['field-1']).toEqual({ + id: '321', + color: 'red', + firstInteraction: 5000, + lastInteraction: 5000, + }); + }); + + test('should update field color is first interaction was long ago', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + instance['fields'] = { 'field-1': field }; + instance['focusList'] = { 'field-1': { id: '123', firstInteraction: 0 } }; + + instance['updateFieldColor']({ + presence: { id: '321' }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 15000, + }); + + expect(field.style.outline).toBe('1px solid red'); + expect(instance['focusList']['field-1']).toEqual({ + id: '321', + color: 'red', + firstInteraction: 15000, + lastInteraction: 15000, + }); + }); + + test('should update last interaction timestamp when participant in focus interacts again', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + instance['fields'] = { 'field-1': field }; + instance['focusList'] = { + 'field-1': { color: 'red', id: '123', firstInteraction: 0, lastInteraction: 0 }, + }; + + instance['updateFieldColor']({ + presence: { id: '123' }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 5000, + }); + + expect(instance['focusList']['field-1']).toEqual({ + id: '123', + color: 'red', + firstInteraction: 0, + lastInteraction: 5000, + }); + }); + + test('should update focus with local participant information without changing outline if they interact and there was no previous focus', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.style.outline = 'old-outline'; + + instance['fields'] = { 'field-1': field }; + instance['focusList'] = {}; + + instance['updateFieldColor']({ + presence: { id: MOCK_LOCAL_PARTICIPANT.id }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 5000, + }); + + expect(instance['focusList']['field-1']).toEqual({ + id: MOCK_LOCAL_PARTICIPANT.id, + color: 'red', + firstInteraction: 5000, + lastInteraction: 5000, + }); + + expect(field.style.outline).toBe('old-outline'); + }); + + test('should not update outline color if first and last interaction were recent', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.style.outline = '1px solid red'; + + instance['fields'] = { 'field-1': field }; + instance['focusList'] = { + 'field-1': { id: '123', color: 'blue', firstInteraction: 0, lastInteraction: 0 }, + }; + + instance['updateFieldColor']({ + presence: { id: '321' }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 500, + }); + + expect(field.style.outline).toBe('1px solid red'); + }); + + test('should remove outline if local participant emitted event and input was previously focused', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.style.outline = '1px solid green'; + instance['fieldsOriginalOutline'] = { 'field-1': '1px solid green' }; + + instance['fields'] = { 'field-1': field }; + instance['focusList'] = { + 'field-1': { id: '123', color: 'blue', firstInteraction: 0, lastInteraction: 0 }, + }; + + instance['updateFieldColor']({ + presence: { id: MOCK_LOCAL_PARTICIPANT.id }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 5000, + }); + + expect(field.style.outline).toBe('1px solid green'); + }); + }); + + describe('updateFieldContent', () => { + test('should update field content', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.value = 'old content'; + instance['fields'] = { 'field-1': field }; + instance['localParticipant'] = { id: '123' } as any; + + instance['flags'].disableRealtimeSync = false; + + instance['updateFieldContent']({ + presence: { id: '321' }, + data: { + value: 'new content', + fieldId: 'field-1', + syncContent: true, + attribute: 'value', + }, + }); + + expect(field.value).toBe('new content'); + }); + + test('should not update field content if presence id is local participant id', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.value = 'old content'; + + instance['fields'] = { 'field-1': field }; + instance['localParticipant'] = { id: '123' } as any; + + instance['updateFieldContent']({ + presence: { id: '123' }, + data: { content: 'new content', fieldId: 'field-1' }, + }); + + expect(field.value).toBe('old content'); + }); + + test('should update outline color if flag is active and can update color', () => { + const field = document.getElementById('field-1') as HTMLInputElement; + field.style.outline = '1px solid red'; + instance['fields'] = { 'field-1': field }; + instance['localParticipant'] = { id: '123' } as any; + + instance['flags'].disableOutline = false; + instance['updateFieldColor'] = jest.fn(); + instance['updateFieldContent']({ + presence: { id: '321' }, + data: { + value: 'new content', + fieldId: 'field-1', + syncContent: true, + attribute: 'value', + showOutline: true, + }, + }); + + expect(instance['updateFieldColor']).toHaveBeenCalled(); + }); + }); + + describe('restoreOutlines', () => { + test('should restore outlines of all fields', () => { + const field1 = document.getElementById('field-1') as HTMLInputElement; + const field2 = document.getElementById('field-2') as HTMLInputElement; + field1.style.outline = '1px solid red'; + field2.style.outline = '1px solid blue'; + + instance['fields'] = { 'field-1': field1, 'field-2': field2 }; + instance['fieldsOriginalOutline'] = { 'field-1': 'old-outline', 'field-2': 'old-outline' }; + + instance['restoreOutlines'](); + + expect(field1.style.outline).toBe('old-outline'); + expect(field2.style.outline).toBe('old-outline'); + }); + }); + + describe('publishTypedEvent', () => { + test('should publish event', () => { + const fieldId = 'field-1'; + const color = 'red'; + const presence = { id: '123' }; + const data = { fieldId, color }; + instance['localParticipant'] = { id: '321' } as any; + instance['publish'] = jest.fn(); + + instance['publishTypedEvent']({ presence, data }); + + expect(instance['publish']).toHaveBeenCalledWith( + FieldEvents.KEYBOARD_INTERACTION, + { + fieldId, + userId: '123', + userName: undefined, + color, + }, + ); + }); + + test('should not publish event if presence id is local participant id', () => { + const fieldId = 'field-1'; + const color = 'red'; + const presence = { id: '123' }; + const data = { fieldId, color }; + instance['localParticipant'] = { id: '123' } as any; + instance['publish'] = jest.fn(); + + instance['publishTypedEvent']({ presence, data }); + + expect(instance['publish']).not.toHaveBeenCalled(); + }); + }); + + describe('canUpdateColor', () => { + test('should return false if disableOutline flag is true', () => { + instance['flags'].disableOutline = true; + expect(instance['canUpdateColor']('field-1')).toBeFalsy(); + }); + + test('should return false if field is disabled', () => { + instance['enabledOutlineFields'] = { 'field-1': false }; + expect(instance['canUpdateColor']('field-1')).toBeFalsy(); + }); + + test('should return true if field is not disabled', () => { + instance['enabledOutlineFields'] = { 'field-1': true }; + expect(instance['canUpdateColor']('field-1')).toBeTruthy(); + }); + }); + + describe('enableOutline', () => { + test('should enable outline color changes for a field', () => { + instance['fields'] = { 'field-1': document.getElementById('field-1') as HTMLInputElement }; + instance['fields']['field-1'].style.outline = '1px solid black'; + instance['enabledOutlineFields'] = {}; + instance['fieldsOriginalOutline'] = {}; + + const addEventListenerSpy = jest.spyOn(instance['fields']['field-1'], 'addEventListener'); + const onSpy = jest.spyOn(instance['room'], 'on'); + + instance['enableOutline']('field-1'); + + expect(instance['enabledOutlineFields']['field-1']).toBeTruthy(); + expect(instance['fieldsOriginalOutline']['field-1']).toBe('1px solid black'); + expect(addEventListenerSpy).toHaveBeenCalledTimes(2); + expect(onSpy).toHaveBeenCalledTimes(2); + }); + + test('should not enable outline color changes if field is not found', () => { + instance['fields'] = {}; + instance['enabledOutlineFields'] = {}; + instance['fieldsOriginalOutline'] = {}; + + const onSpy = jest.spyOn(instance['room'], 'on'); + + instance['enableOutline']('field-1'); + + expect(instance['enabledOutlineFields']['field-1']).toBeUndefined(); + expect(instance['fieldsOriginalOutline']['field-1']).toBeUndefined(); + expect(onSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('disableOutline', () => { + test('should disable outline color changes for a field', () => { + instance['fields'] = { 'field-1': document.getElementById('field-1') as HTMLInputElement }; + instance['fields']['field-1'].style.outline = '2px green'; + instance['enabledOutlineFields'] = {}; + instance['fieldsOriginalOutline'] = { + 'field-1': '1px solid black', + }; + + const removeEventListenerSpy = jest.spyOn( + instance['fields']['field-1'], + 'removeEventListener', + ); + const offSpy = jest.spyOn(instance['room'], 'off'); + + instance['disableOutline']('field-1'); + + expect(instance['enabledOutlineFields']['field-1']).toBe(false); + expect(instance['fieldsOriginalOutline']['field-1']).toBe('1px solid black'); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); + expect(offSpy).toHaveBeenCalledTimes(2); + }); + + test('should not disable outline color changes if field is not found', () => { + instance['fields'] = {}; + instance['enabledOutlineFields'] = {}; + instance['fieldsOriginalOutline'] = {}; + + const offSpy = jest.spyOn(instance['room'], 'off'); + + instance['disableOutline']('field-1'); + + expect(instance['enabledOutlineFields']['field-1']).toBe(undefined); + expect(offSpy).toHaveBeenCalledTimes(0); + }); + + test('should set outlien to empty string if there is no saved original outline', () => { + instance['fields'] = { 'field-1': document.getElementById('field-1') as HTMLInputElement }; + instance['fields']['field-1'].style.outline = '2px green'; + instance['enabledOutlineFields'] = {}; + instance['fieldsOriginalOutline'] = {}; + + instance['disableOutline']('field-1'); + + expect(instance['fields']['field-1'].style.outline).toBe(''); + }); + }); + + describe('sync', () => { + test('should emit an event with the field content and local participant color', () => { + instance['room'] = { + emit: jest.fn(), + } as any; + instance['localParticipant'] = { + slot: { color: 'red' }, + } as any; + + const field = document.getElementById('field-1') as HTMLInputElement; + field.value = 'some value'; + + instance['fields'] = { 'field-1': field }; + instance['hasCheckedProperty'] = jest.fn().mockReturnValue(false); + instance['sync']('field-1'); + + expect(instance['room'].emit).toHaveBeenCalledTimes(1); + + expect(instance['room'].emit).toHaveBeenCalledWith('field.inputfield-1', { + value: 'some value', + color: 'red', + fieldId: 'field-1', + showOutline: true, + syncContent: true, + attribute: 'value', + }); + }); + + test('should emit an event with the field checked value and local participant color', () => { + instance['room'] = { + emit: jest.fn(), + } as any; + instance['localParticipant'] = { + slot: { color: 'red' }, + } as any; + + const field = document.createElement('checkbox') as HTMLInputElement; + field.checked = true; + + instance['fields'] = { checkbox: field }; + instance['hasCheckedProperty'] = jest.fn().mockReturnValue(true); + instance['sync']('checkbox'); + + expect(instance['room'].emit).toHaveBeenCalledTimes(1); + + expect(instance['room'].emit).toHaveBeenCalledWith('field.inputcheckbox', { + value: true, + color: 'red', + fieldId: 'checkbox', + showOutline: true, + syncContent: true, + attribute: 'checked', + }); + }); + }); + + describe('enableRealtimeSync', () => { + test('should add field to enabled realtime sync list', () => { + instance['enabledRealtimeSyncFields'] = {}; + instance['enableRealtimeSync']('field-1'); + expect(instance['enabledRealtimeSyncFields']['field-1']).toBe(true); + }); + }); + + describe('disableRealtimeSync', () => { + test('should remove field from enabled realtime sync list', () => { + instance['enabledRealtimeSyncFields'] = { 'field-1': true }; + instance['disableRealtimeSync']('field-1'); + expect(instance['enabledRealtimeSyncFields']['field-1']).toBe(false); + }); + }); +}); diff --git a/src/components/form-elements/index.ts b/src/components/form-elements/index.ts new file mode 100644 index 00000000..bfd898be --- /dev/null +++ b/src/components/form-elements/index.ts @@ -0,0 +1,670 @@ +import { SocketEvent } from '@superviz/socket-client'; + +import { Participant } from '../../common/types/participant.types'; +import { StoreType } from '../../common/types/stores.types'; +import { Logger } from '../../common/utils'; +import { BaseComponent } from '../base'; +import { ComponentNames } from '../types'; + +import { + Field, + Focus, + FieldEvents, + InputPayload, + FormElementsProps, + FocusPayload, + BlurPayload, + Flags, +} from './types'; + +export class FormElements extends BaseComponent { + public name: ComponentNames; + protected logger: Logger; + private localParticipant: Participant; + + // HTML Elements + private fields: Record = {}; + private fieldsOriginalOutline: Record = {}; + private focusList: Record = {}; + private enabledOutlineFields: Record = {}; + private enabledRealtimeSyncFields: Record = {}; + + // Flags + private flags: Flags = {}; + + // Allowed tags and types + private readonly allowedTagNames = ['input', 'textarea']; + // text, search, URL, tel, and password + private readonly allowedInputTypes = [ + 'text', + 'email', + 'date', + 'color', + 'datetime-local', + 'month', + 'number', + 'password', + 'range', + 'search', + 'tel', + 'time', + 'url', + 'week', + 'checkbox', + 'radio', + ]; + + private readonly throwError = { + onInvalidFieldsProp: (propType: string) => { + throw new Error( + `"Fields" property must be either a string or an array of strings. Received type: ${propType}`, + ); + }, + onFieldNotFound: (invalidId: string) => { + throw new Error( + `You must pass the id of an existing element. Element with id ${invalidId} not found`, + ); + }, + onInvalidTagName: (tagName: string) => { + throw new Error( + `You can't register an element with tag ${tagName}. Only the tags "${this.allowedTagNames.join( + ', ', + )}" are allowed`, + ); + }, + onInvalidInputType: (type: string) => { + throw new Error( + `You can't register an input element of type ${type}. Only the types "${this.allowedInputTypes.join( + ', ', + )}" are allowed`, + ); + }, + onDeregisterInvalidField: (fieldId: string) => { + throw new Error( + `You can't deregister field of id ${fieldId}. No field was registered with id ${fieldId} `, + ); + }, + }; + + constructor(props: FormElementsProps = {}) { + super(); + this.name = ComponentNames.FORM_ELEMENTS; + this.logger = new Logger('@superviz/sdk/presence-input-component'); + + const { fields, ...flags } = props; + + this.flags = flags ?? {}; + + if (typeof fields === 'string') { + this.validateField(fields); + this.fields[fields] = null; + return; + } + + if (Array.isArray(fields)) { + fields.forEach((fieldId) => { + this.validateField(fieldId); + this.fields[fieldId] = null; + }); + return; + } + + if (fields !== undefined) { + this.throwError.onInvalidFieldsProp(typeof fields); + } + } + + // ------- public methods ------- + /** + * @function enableOutline + * @description Enables changes in the color of the outline of a field. Color changes are triggered when, in general, another participant interacts with the field on their side AND they also have color changes enabled. + * + * Enabling this feature through this method overrides the global flag "disableOutline" set in the constructor for this particular input. + * @param fieldId The id of the input field or textarea that will have its outline color changed + * @returns {void} + */ + public enableOutline(fieldId: string): void { + const field = this.fields[fieldId]; + + if (!field) return; + + this.enabledOutlineFields[fieldId] = true; + this.fieldsOriginalOutline[fieldId] = field.style.outline; + + field.addEventListener('focus', this.handleFocus); + field.addEventListener('blur', this.handleBlur); + + this.room.on(FieldEvents.FOCUS + fieldId, this.updateFieldColor); + this.room.on(FieldEvents.BLUR + fieldId, this.removeFieldColor); + } + + /** + * @function disableOutline + * @description Disables changes in the color of the outline of a field. + * + * Disabling this feature through this method overrides the global flag "disableOutline" set in the constructor for this particular input. + * @param fieldId The id of the input field or textarea that will have its outline color changed + * @returns {void} + */ + public disableOutline(fieldId: string): void { + const field = this.fields[fieldId]; + if (!field) return; + + this.enabledOutlineFields[fieldId] = false; + field.style.outline = this.fieldsOriginalOutline[fieldId] ?? ''; + + field.removeEventListener('focus', this.handleFocus); + field.removeEventListener('blur', this.handleBlur); + + this.room.off(FieldEvents.FOCUS + fieldId, this.updateFieldColor); + this.room.off(FieldEvents.BLUR + fieldId, this.removeFieldColor); + + this.room?.emit(FieldEvents.BLUR + fieldId, { fieldId }); + } + + /** + * @function enableRealtimeSync + * @description Enables the synchronization of the content of a field in real time. The content of the field will be updated in real time when another participant interacts with the field on their side AND they also have content synchronization enabled. + * + * "Content" may refer to the value the user has typed or selected, or the status of the field (checked or not), depending on the type of field. + * + * Enabling this feature through this method overrides the global flag "disableRealtimeSync" set in the constructor for this particular input. + * @param fieldId The id of the input field or textarea that will have its content synchronized + * @returns {void} + */ + public enableRealtimeSync(fieldId: string): void { + this.enabledRealtimeSyncFields[fieldId] = true; + } + + /** + * @function disableRealtimeSync + * @description Disables the synchronization of the content of a field in real time. + * + * Disabling this feature through this method overrides the global flag "disableRealtimeSync" set in the constructor for this particular input. + * + * @param fieldId The id of the input field or textarea that will have its content synchronized + * @returns {void} + */ + public disableRealtimeSync(fieldId: string): void { + this.enabledRealtimeSyncFields[fieldId] = false; + } + + /** + * @function sync + * @description Sends the value of the field to every other participant with the realtime sync enabled for this field. + * + * This method is useful when you want to update the content of a field without waiting for the user to interact with it. + * + * If realtime sync is disabled for the field, even though the content won't be updated, every other participant receives an event with details about the sync attempt. + * @param fieldId + */ + public sync = (fieldId: string): void => { + const field = this.fields[fieldId]; + + const hasCheckedProperty = this.hasCheckedProperty(field); + + const value = hasCheckedProperty ? (field as HTMLInputElement).checked : field.value; + + const payload: InputPayload & FocusPayload = { + value, + color: this.localParticipant.slot.color, + fieldId, + showOutline: this.canUpdateColor(fieldId), + syncContent: this.canSyncContent(fieldId), + attribute: hasCheckedProperty ? 'checked' : 'value', + }; + + this.room?.emit(FieldEvents.INPUT + fieldId, payload); + }; + + /** + * @function registerField + * @description Registers a field element. + + A registered field will be monitored and most interactions with it will be shared with every other user in the room that is tracking the same field. + + Examples of common interactions that will be monitored include typing, focusing, and blurring, but more may apply. + * @param {string} fieldId The id of the field that will be registered + * @returns {void} + */ + public registerField(fieldId: string) { + this.validateField(fieldId); + + const field = document.getElementById(fieldId) as Field; + this.fields[fieldId] = field; + + this.addListenersToField(field); + this.addRealtimeListenersToField(fieldId); + this.fieldsOriginalOutline[fieldId] = field.style.outline; + } + + /** + * @function deregisterField + * @description Deregisters a single field + * @param {string} fieldId The id of the field that will be deregistered + * @returns {void} + */ + public deregisterField(fieldId: string) { + if (!this.fields[fieldId]) { + this.throwError.onDeregisterInvalidField(fieldId); + } + + this.removeListenersFromField(this.fields[fieldId]); + this.removeRealtimeListenersFromField(fieldId); + this.fields[fieldId].style.outline = this.fieldsOriginalOutline[fieldId]; + this.fields[fieldId] = undefined; + + delete this.enabledOutlineFields[fieldId]; + delete this.enabledRealtimeSyncFields[fieldId]; + delete this.fieldsOriginalOutline[fieldId]; + delete this.focusList[fieldId]; + + this.room?.emit(FieldEvents.BLUR + fieldId, { fieldId }); + } + + // ------- setup ------- + /** + * @function start + * @description starts the component + * @returns {void} + * */ + protected start(): void { + const { localParticipant } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe(); + + Object.entries(this.fields).forEach(([fieldId]) => { + this.registerField(fieldId); + }); + } + + /** + * @function destroy + * @description destroys the component + * @returns {void} + * */ + protected destroy(): void { + this.restoreOutlines(); + this.deregisterAllFields(); + + this.fieldsOriginalOutline = undefined; + this.focusList = undefined; + } + + // ------- listeners ------- + /** + * @function addListenersToField + * @description Adds listeners to a field + * @param {Field} field The field that will have the listeners added + * @returns {void} + */ + private addListenersToField(field: Field): void { + const { type } = field; + + if (this.hasCheckedProperty(field)) { + field.addEventListener('change', this.handleChange); + } else { + field.addEventListener('input', this.handleInput); + } + + if (this.flags.disableOutline) return; + + field.addEventListener('focus', this.handleFocus); + field.addEventListener('blur', this.handleBlur); + } + + /** + * @function addRealtimeListenersToField + * @description Adds realtime listeners to a field + * @param {string} fieldId The id of the field that will have the listeners added + * @returns {void} + */ + private addRealtimeListenersToField(fieldId: string) { + if (!this.room) return; + + this.room.on(FieldEvents.INPUT + fieldId, this.updateFieldContent); + this.room.on(FieldEvents.KEYBOARD_INTERACTION + fieldId, this.publishTypedEvent); + + if (this.flags.disableOutline) return; + + this.room.on(FieldEvents.FOCUS + fieldId, this.updateFieldColor); + this.room.on(FieldEvents.BLUR + fieldId, this.removeFieldColor); + } + + /** + * @function removeListenersFromField + * @description Removes listeners from a field + * @param {Field} field The field that will have the listeners removed + * @returns {void} + */ + private removeListenersFromField(field: Field): void { + if (this.hasCheckedProperty(field)) { + field.removeEventListener('change', this.handleChange); + } else { + field.removeEventListener('input', this.handleInput); + } + + if (this.flags.disableOutline) return; + + field.removeEventListener('focus', this.handleFocus); + field.removeEventListener('blur', this.handleBlur); + } + + /** + * @function removeRealtimeListenersFromField + * @description Removes realtime listeners from a field + * @param {string} fieldId The id of the field that will have the listeners removed + * @returns {void} + */ + private removeRealtimeListenersFromField(fieldId: string) { + if (!this.room) return; + + this.room.off(FieldEvents.INPUT + fieldId, this.updateFieldContent); + this.room.off(FieldEvents.KEYBOARD_INTERACTION + fieldId, this.publishTypedEvent); + + if (this.flags.disableOutline) return; + + this.room.off(FieldEvents.FOCUS + fieldId, this.updateFieldColor); + this.room.off(FieldEvents.BLUR + fieldId, this.removeFieldColor); + } + + // ------- register & deregister ------- + /** + * @function deregisterAllFields + * @description Deregisters an element. No interactions with the field will be shared after this. + * @returns {void} + */ + private deregisterAllFields() { + Object.keys(this.fields).forEach((fieldId) => { + this.deregisterField(fieldId); + }); + + this.fields = undefined; + } + + // ------- callbacks ------- + /** + * @function handleInput + * @description Handles the input event on an input element + * @param {Event} event The event that triggered the function + * @returns {void} + */ + private handleInput = (event: InputEvent) => { + const target = event.target as HTMLInputElement; + + this.room?.emit(FieldEvents.KEYBOARD_INTERACTION + target.id, { + fieldId: target.id, + color: this.localParticipant.slot.color, + }); + + const canSync = this.canSyncContent(target.id); + if (!canSync) return; + + const payload: InputPayload & FocusPayload = { + value: target.value, + color: this.localParticipant.slot.color, + fieldId: target.id, + showOutline: this.canUpdateColor(target.id), + syncContent: canSync, + attribute: 'value', + }; + + this.room?.emit(FieldEvents.INPUT + target.id, payload); + }; + + /** + * @function handleChange + * @description Handles the change event on an input element + * @param {Event} event The event that triggered the function + * @returns {void} + */ + private handleChange = (event: Event): void => { + const target = event.target as HTMLInputElement; + + const canSync = this.canSyncContent(target.id); + if (!canSync) return; + + const payload: InputPayload & FocusPayload = { + fieldId: target.id, + value: target.checked, + color: this.localParticipant.slot.color, + showOutline: this.canUpdateColor(target.id), + syncContent: this.canSyncContent(target.id), + attribute: 'checked', + }; + + this.room?.emit(FieldEvents.INPUT + target.id, payload); + }; + + /** + * @function handleFocus + * @description Handles the focus event on an input element + * @param {Event} event The event that triggered the function + * @returns {void} + */ + private handleFocus = (event: InputEvent): void => { + const target = event.target as HTMLInputElement; + const payload: FocusPayload = { + color: this.localParticipant.slot.color, + fieldId: target.id, + }; + + this.room?.emit(FieldEvents.FOCUS + target.id, payload); + }; + + /** + * @function handleBlur + * @description Handles the blur event on an input element + * @param {Event} event The event that triggered the function + * @returns {void} + */ + private handleBlur = (event: InputEvent) => { + const target = event.target as HTMLInputElement; + const payload: BlurPayload = { + fieldId: target.id, + }; + + this.room?.emit(FieldEvents.BLUR + target.id, payload); + }; + + // ------- validations ------- + /** + * @function validateField + * @description Verifies if an element can be registered + * @param {Field} field The element + * @returns {void} + */ + private validateField(fieldId: string): void { + this.validateFieldId(fieldId); + + const field = document.getElementById(fieldId) as Field; + this.validateFieldTagName(field); + this.validateFieldType(field); + } + + /** + * @function validateFieldTagName + * @description Verifies if the element has one of the allowed tag names that can be registered + * @param {Field} field The element that will be checked + * @returns {void} + */ + private validateFieldTagName(field: Field): void { + const tagName = field.tagName.toLowerCase(); + const hasValidTagName = this.allowedTagNames.includes(tagName); + + if (!hasValidTagName) { + this.logger.log('invalid element tagname'); + this.throwError.onInvalidTagName(tagName); + } + } + + /** + * @function validateFieldType + * @description Checks if an input element has one of the allowed types that can be registered + * @param {Field} field The element that will be checked + * @returns {void} + */ + private validateFieldType(field: Field): void { + if (field.tagName.toLowerCase() !== 'input') return; + + const inputType = field.getAttribute('type'); + const hasValidInputType = this.allowedInputTypes.includes(inputType); + + if (inputType && !hasValidInputType) { + this.logger.log('invalid input type'); + this.throwError.onInvalidInputType(inputType); + } + } + + private validateFieldId(fieldId: string): void { + const field = document.getElementById(fieldId); + + if (!field) { + this.throwError.onFieldNotFound(fieldId); + } + } + + // ------- realtime callbacks ------- + /** + * @function removeFieldColor + * @description Resets the outline of a field to its original value + * @param {SocketEvent} event The payload from the event + * @returns {void} A function that will be called when the event is triggered + */ + private removeFieldColor = ({ presence, data: { fieldId } }: SocketEvent) => { + if (this.focusList[fieldId]?.id !== presence.id || !this.fields[fieldId]) return; + + this.fields[fieldId].style.outline = this.fieldsOriginalOutline[fieldId] ?? ''; + delete this.fieldsOriginalOutline[fieldId]; + delete this.focusList[fieldId]; + }; + + /** + * @function updateFieldColor + * @description Changes the outline of a field to the color of the participant that is interacting with it, following the rules defined in the function + * @param {SocketEvent} event The payload from the event + * @returns {void} + */ + private updateFieldColor = ({ + presence, + data: { color, fieldId }, + timestamp, + }: SocketEvent) => { + const participantInFocus = this.focusList[fieldId] ?? ({} as Focus); + + const thereIsNoFocus = !participantInFocus.id; + const localParticipantEmittedEvent = presence.id === this.localParticipant.id; + + if (thereIsNoFocus && localParticipantEmittedEvent) { + this.focusList[fieldId] = { + id: presence.id, + color, + firstInteraction: timestamp, + lastInteraction: timestamp, + }; + + return; + } + + const alreadyHasFocus = participantInFocus.id === presence.id; + + if (alreadyHasFocus) { + this.focusList[fieldId].lastInteraction = timestamp; + return; + } + + const stoppedInteracting = timestamp - participantInFocus.lastInteraction >= 3000; // ms; + const gainedFocusLongAgo = timestamp - participantInFocus.firstInteraction >= 10000; // ms; + const changeInputBorderColor = stoppedInteracting || gainedFocusLongAgo || thereIsNoFocus; + + if (!changeInputBorderColor) return; + + this.focusList[fieldId] = { + id: presence.id, + color, + firstInteraction: timestamp, + lastInteraction: timestamp, + }; + + const outline = localParticipantEmittedEvent + ? this.fieldsOriginalOutline[fieldId] + : `1px solid ${color}`; + this.fields[fieldId].style.outline = outline; + }; + + /** + * @function updateFieldContent + * @description Updates the content of a field + * @param {string} fieldId The id of the field that will have its content updated + * @returns {void} + */ + private updateFieldContent = ({ + presence, + data: { value, fieldId, color, showOutline, syncContent, attribute }, + timestamp, + ...params + }: SocketEvent) => { + if (presence.id === this.localParticipant.id) return; + + this.publish(FieldEvents.INPUT, { + value, + fieldId, + attribute, + userId: presence.id, + userName: presence.name, + timestamp, + }); + + if (syncContent && this.canSyncContent(fieldId)) this.fields[fieldId][attribute] = value; + + if (showOutline && this.canUpdateColor(fieldId)) { + this.updateFieldColor({ presence, data: { color, fieldId }, timestamp, ...params }); + } + }; + + private publishTypedEvent = ({ + presence, + data: { fieldId, color }, + }: SocketEvent) => { + if (presence.id === this.localParticipant.id) return; + + this.publish(FieldEvents.KEYBOARD_INTERACTION, { + fieldId, + userId: presence.id, + userName: presence.name, + color, + }); + }; + + // ------- utils ------- + /** + * @function restoreOutlines + * @description Restores the outline of all fields to their original value + * @returns {void} + */ + private restoreOutlines(): void { + Object.entries(this.fields).forEach(([fieldId]) => { + this.fields[fieldId].style.outline = this.fieldsOriginalOutline[fieldId]; + }); + } + + private hasCheckedProperty(field: Field): boolean { + const tag = field.tagName.toLowerCase(); + const type = field.getAttribute('type'); + + return tag === 'input' && (type === 'radio' || type === 'checkbox'); + } + + private canUpdateColor(fieldId: string): boolean { + return ( + (!this.flags.disableOutline && this.enabledOutlineFields[fieldId] !== false) || + this.enabledOutlineFields[fieldId] + ); + } + + private canSyncContent(fieldId: string): boolean { + return ( + (!this.flags.disableRealtimeSync && this.enabledRealtimeSyncFields[fieldId] !== false) || + this.enabledRealtimeSyncFields[fieldId] + ); + } +} diff --git a/src/components/form-elements/types.ts b/src/components/form-elements/types.ts new file mode 100644 index 00000000..c627427c --- /dev/null +++ b/src/components/form-elements/types.ts @@ -0,0 +1,45 @@ +import { SocketEvent } from '@superviz/socket-client'; + +export type FormElementsProps = { + fields?: string[] | string; +} & Flags; + +export type Flags = { + disableOutline?: boolean; + disableRealtimeSync?: boolean; +}; + +export type Field = HTMLInputElement | HTMLTextAreaElement; + +export enum FieldEvents { + INPUT = 'field.input', + BLUR = 'field.blur', + FOCUS = 'field.focus', + CHANGE = 'field.change', + KEYBOARD_INTERACTION = 'field.keyboard-interaction', +} + +export interface FocusPayload { + color: string; + fieldId: string; +} + +export type InputPayload = { + value: string | boolean; + showOutline: boolean; + syncContent: boolean; + attribute: 'value' | 'checked'; +} & FocusPayload; + +export type Focus = { + firstInteraction: number; + lastInteraction: number; + id: string; + color: string; +}; + +export type RealtimeCallback = (data: SocketEvent) => void; + +export type BlurPayload = { + fieldId: string; +}; diff --git a/src/components/index.ts b/src/components/index.ts index fb8c9221..1cf6be19 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,3 +5,4 @@ export { VideoConference } from './video'; export { MousePointers } from './presence-mouse'; export { Realtime } from './realtime'; export { WhoIsOnline } from './who-is-online'; +export { FormElements } from './form-elements'; diff --git a/src/components/presence-mouse/canvas/index.test.ts b/src/components/presence-mouse/canvas/index.test.ts index b2a01cfa..17c14612 100644 --- a/src/components/presence-mouse/canvas/index.test.ts +++ b/src/components/presence-mouse/canvas/index.test.ts @@ -21,6 +21,15 @@ const MOCK_MOUSE: ParticipantMouse = { timestamp: 1710448079918, }, visible: true, + camera: { + x: 0, + y: 0, + screen: { + width: 1920, + height: 1080, + }, + scale: 1, + }, }; const participant1 = { ...MOCK_MOUSE }; @@ -90,6 +99,15 @@ describe('MousePointers on Canvas', () => { describe('onMyParticipantMouseMove', () => { test('should update my participant mouse position', () => { const spy = jest.spyOn(presenceMouseComponent['room']['presence'], 'update'); + presenceMouseComponent['camera'] = { + x: 0, + y: 0, + scale: 1, + screen: { + width: 1920, + height: 1080, + }, + }; const presenceContainerId = document.createElement('div'); presenceMouseComponent['containerId'] = 'container'; @@ -100,6 +118,15 @@ describe('MousePointers on Canvas', () => { expect(spy).toHaveBeenCalledWith({ ...MOCK_LOCAL_PARTICIPANT, + camera: { + x: 0, + y: 0, + scale: 1, + screen: { + width: 1920, + height: 1080, + }, + }, x: event.x, y: event.y, visible: true, @@ -206,6 +233,15 @@ describe('MousePointers on Canvas', () => { timestamp: 1710448079918, }, visible: true, + camera: { + x: 0, + y: 0, + scale: 1, + screen: { + width: 1920, + height: 1080, + }, + }, }; presenceMouseComponent['onPresenceLeftRoom']({ @@ -234,6 +270,15 @@ describe('MousePointers on Canvas', () => { timestamp: 1710448079918, }, visible: true, + camera: { + x: 0, + y: 0, + scale: 1, + screen: { + width: 1920, + height: 1080, + }, + }, }; const participant2 = MOCK_MOUSE; @@ -267,8 +312,10 @@ describe('MousePointers on Canvas', () => { expect(presenceMouseComponent['goToMouseCallback']).toHaveBeenCalledTimes(1); expect(presenceMouseComponent['goToMouseCallback']).toHaveBeenCalledWith({ - x: 20, - y: 20, + x: participant2.camera.x, + y: participant2.camera.y, + scaleX: 0, + scaleY: 0, }); }); }); diff --git a/src/components/presence-mouse/canvas/index.ts b/src/components/presence-mouse/canvas/index.ts index 12963c50..9968c0cb 100644 --- a/src/components/presence-mouse/canvas/index.ts +++ b/src/components/presence-mouse/canvas/index.ts @@ -7,7 +7,7 @@ import { StoreType } from '../../../common/types/stores.types'; import { Logger } from '../../../common/utils'; import { BaseComponent } from '../../base'; import { ComponentNames } from '../../types'; -import { ParticipantMouse, PresenceMouseProps, Transform } from '../types'; +import { Camera, ParticipantMouse, PresenceMouseProps, Transform } from '../types'; export class PointersCanvas extends BaseComponent { public name: ComponentNames; @@ -20,7 +20,15 @@ export class PointersCanvas extends BaseComponent { private following: string; private isPrivate: boolean; private localParticipant: Participant; - private transformation: Transform = { translate: { x: 0, y: 0 }, scale: 1 }; + private camera: Camera = { + x: 0, + y: 0, + screen: { + width: 0, + height: 0, + }, + scale: 1, + }; constructor(canvasId: string, options?: PresenceMouseProps) { super(); @@ -42,6 +50,7 @@ export class PointersCanvas extends BaseComponent { const { localParticipant } = this.useStore(StoreType.GLOBAL); localParticipant.subscribe(); + this.getCamera(); } /** @@ -165,6 +174,7 @@ export class PointersCanvas extends BaseComponent { * @returns {void} */ private animate = (): void => { + this.getCamera(); this.renderDivWrapper(); this.updateParticipantsMouses(); @@ -181,18 +191,20 @@ export class PointersCanvas extends BaseComponent { if (!mouse) return; - const rect = this.canvas.getBoundingClientRect(); - const { width, height } = rect; - - const { x, y } = mouse; - - const widthHalf = width / 2; - const heightHalf = height / 2; - - const translateX = widthHalf - x; - const translateY = heightHalf - y; - - if (this.goToMouseCallback) this.goToMouseCallback({ x: translateX, y: translateY }); + const translatedX = mouse.camera.x; + const translatedY = mouse.camera.y; + const screenScaleX = this.divWrapper.clientHeight / mouse.camera.screen.height; + const scaleToAllowVisibilityX = Math.min(screenScaleX, 1); + const screenScaleY = this.divWrapper.clientWidth / mouse.camera.screen.width; + const scaleToAllowVisibilityY = Math.min(screenScaleY, 1); + + if (this.goToMouseCallback) + this.goToMouseCallback({ + x: translatedX, + y: translatedY, + scaleX: scaleToAllowVisibilityX, + scaleY: scaleToAllowVisibilityY, + }); }; /** Presence Mouse Events */ @@ -213,14 +225,15 @@ export class PointersCanvas extends BaseComponent { const transformedPoint = new DOMPoint(x, y).matrixTransform(invertedMatrix); const coordinates = { - x: (transformedPoint.x - this.transformation.translate.x) / this.transformation.scale, - y: (transformedPoint.y - this.transformation.translate.y) / this.transformation.scale, + x: transformedPoint.x, + y: transformedPoint.y, }; this.room.presence.update({ ...this.localParticipant, ...coordinates, visible: !this.isPrivate, + camera: this.camera, }); }, 30); @@ -271,6 +284,30 @@ export class PointersCanvas extends BaseComponent { }); }; + /** + * @function getCamera + * @description - retrieves the camera information from the canvas context's transform. + * The camera information includes the current translation (x, y) and scale. + */ + private getCamera = () => { + const context = this.canvas?.getContext('2d'); + const transform = context?.getTransform(); + + const currentTranslateX = transform?.e; + const currentTranslateY = transform?.f; + const currentScale = transform?.a; + + this.camera = { + screen: { + width: this.divWrapper.clientHeight, + height: this.divWrapper.clientWidth, + }, + x: currentTranslateX, + y: currentTranslateY, + scale: currentScale, + }; + }; + /** * @function renderPresenceMouses * @description add presence mouses to screen @@ -308,11 +345,11 @@ export class PointersCanvas extends BaseComponent { const currentTranslateX = transform?.e; const currentTranslateY = transform?.f; + const currentScaleWidth = transform.a; + const currentScaleHeight = transform.d; - const x = - this.transformation.translate.x + (savedX + currentTranslateX) * this.transformation.scale; - const y = - this.transformation.translate.y + (savedY + currentTranslateY) * this.transformation.scale; + const x = savedX * currentScaleWidth + currentTranslateX; + const y = savedY * currentScaleHeight + currentTranslateY; const isVisible = this.divWrapper.clientWidth > x && this.divWrapper.clientHeight > y && mouse.visible; @@ -376,6 +413,6 @@ export class PointersCanvas extends BaseComponent { * @param {Transform} transformation Which transformations to apply */ public transform(transformation: Transform) { - this.transformation = transformation; + console.warn('[SuperViz] - transform method not available when container is a canvas element.'); } } diff --git a/src/components/presence-mouse/html/index.test.ts b/src/components/presence-mouse/html/index.test.ts index 2b2a3d06..9dde4d02 100644 --- a/src/components/presence-mouse/html/index.test.ts +++ b/src/components/presence-mouse/html/index.test.ts @@ -43,6 +43,15 @@ describe('MousePointers on HTML', () => { timestamp: 1710448079918, }, visible: true, + camera: { + x: 0, + y: 0, + scale: 1, + screen: { + width: 1920, + height: 1080, + }, + }, }; const participant1 = { ...MOCK_MOUSE }; @@ -421,7 +430,16 @@ describe('MousePointers on HTML', () => { x: 30, y: 30, visible: true, - }); + camera: { + scale: 1, + x: 10, + y: 10, + screen: { + height: 1, + width: 1, + }, + }, + } as ParticipantMouse); }); test('should not call room.presence.update if isPrivate', () => { @@ -458,16 +476,16 @@ describe('MousePointers on HTML', () => { presenceMouseComponent['container'].getBoundingClientRect = jest.fn( () => ({ - x: 10, - y: 10, - width: 10, - height: 10, + left: 10, + right: 100, + top: 20, + bottom: 90, } as any), ); const mouseEvent1 = { - x: -50, - y: -50, + x: 5, + y: 5, } as any; presenceMouseComponent['onMyParticipantMouseLeave'](mouseEvent1); @@ -476,8 +494,8 @@ describe('MousePointers on HTML', () => { updatePresenceMouseSpy.mockClear(); const mouseEvent2 = { - x: 5, - y: 5, + x: 30, + y: 40, } as any; presenceMouseComponent['onMyParticipantMouseLeave'](mouseEvent2); @@ -639,7 +657,7 @@ describe('MousePointers on HTML', () => { presenceMouseComponent['goToPresenceCallback'] = callback; presenceMouseComponent['goToMouse']('unit-test-participant2-id'); - expect(callback).toHaveBeenCalledWith({ x, y }); + expect(callback).toHaveBeenCalledWith({ x, y, scaleX: 0, scaleY: 0 }); }); }); diff --git a/src/components/presence-mouse/html/index.ts b/src/components/presence-mouse/html/index.ts index 4a17da03..e0929f7e 100644 --- a/src/components/presence-mouse/html/index.ts +++ b/src/components/presence-mouse/html/index.ts @@ -9,6 +9,7 @@ import { Logger } from '../../../common/utils'; import { BaseComponent } from '../../base'; import { ComponentNames } from '../../types'; import { + Camera, ParticipantMouse, PresenceMouseProps, SVGElements, @@ -36,6 +37,7 @@ export class PointersHTML extends BaseComponent { private isPrivate: boolean; private containerTagname: string; private transformation: Transform = { translate: { x: 0, y: 0 }, scale: 1 }; + private camera: Camera; private pointerMoveObserver: Subscription; // callbacks @@ -59,6 +61,16 @@ export class PointersHTML extends BaseComponent { throw new Error(message); } + this.camera = { + x: 0, + y: 0, + scale: 1, + screen: { + width: this.container.clientWidth, + height: this.container.clientHeight, + }, + }; + this.name = ComponentNames.PRESENCE; const { localParticipant } = this.useStore(StoreType.GLOBAL); localParticipant.subscribe(); @@ -178,11 +190,12 @@ export class PointersHTML extends BaseComponent { const x = (event.x - left - this.transformation.translate.x) / this.transformation.scale; const y = (event.y - top - this.transformation.translate.y) / this.transformation.scale; - this.room.presence.update({ + this.room.presence.update({ ...this.localParticipant, x, y, visible: true, + camera: this.camera, }); }; @@ -191,8 +204,13 @@ export class PointersHTML extends BaseComponent { * @returns {void} */ private onMyParticipantMouseLeave = (event: MouseEvent): void => { - const { x, y, width, height } = this.container.getBoundingClientRect(); - if (event.x > 0 && event.y > 0 && event.x < x + width && event.y < y + height) return; + const { left, top, right, bottom } = this.container.getBoundingClientRect(); + const isInsideContainer = + event.x > left && event.y > top && event.x < right && event.y < bottom; + const isInsideScreen = + event.x > 0 && event.y > 0 && event.x < window.innerWidth && event.y < window.innerHeight; + if (isInsideContainer && isInsideScreen) return; + this.room.presence.update({ visible: false }); }; @@ -204,17 +222,32 @@ export class PointersHTML extends BaseComponent { */ private goToMouse = (id: string): void => { const pointer = this.mouses.get(id); - if (!pointer) return; + const presence = this.presences.get(id); + + if (!presence) return; if (this.goToPresenceCallback) { - const mouse = this.mouses.get(id); - const x = Number(mouse.style.left.replace('px', '')); - const y = Number(mouse.style.top.replace('px', '')); + const scaleFactorX = this.camera.screen.width / presence.camera.screen.width; + const scaleFactorY = this.camera.screen.height / presence.camera.screen.height; + + const translatedX = presence.camera.x * scaleFactorX; + const translatedY = presence.camera.y * scaleFactorY; + + const screenScaleX = presence.camera.scale * scaleFactorX; + const screenScaleY = presence.camera.scale * scaleFactorY; + + this.goToPresenceCallback({ + x: translatedX, + y: translatedY, + scaleX: screenScaleX, + scaleY: screenScaleY, + }); - this.goToPresenceCallback({ x, y }); return; } + if (!pointer) return; + pointer.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); }; @@ -497,6 +530,16 @@ export class PointersHTML extends BaseComponent { */ public transform(transformation: Transform) { this.transformation = transformation; + this.camera = { + x: transformation.translate.x, + y: transformation.translate.y, + scale: transformation.scale, + screen: { + width: this.wrapper?.clientWidth || 1, + height: this.wrapper?.clientHeight || 1, + }, + }; + this.updateParticipantsMouses(true); } @@ -594,11 +637,9 @@ export class PointersHTML extends BaseComponent { /** * @function renderWrapper * @description prepares, creates and renders a wrapper for the specified element - * @param {HTMLElement} element the element to be wrapped - * @param {string} id the id of the element * @returns {void} */ - private renderWrapper() { + private renderWrapper(): void { if (this.wrapper) return; if (VoidElements[this.containerTagname]) { @@ -699,10 +740,7 @@ export class PointersHTML extends BaseComponent { */ private renderVoidElementWrapper = (): void => { const wrapper = document.createElement('div'); - const container = this.container as HTMLElement; const { width, height, left, top } = this.container.getBoundingClientRect(); - const x = container.offsetLeft - left; - const y = container.offsetTop - top; wrapper.style.position = 'absolute'; wrapper.style.width = `${width}px`; diff --git a/src/components/presence-mouse/types.ts b/src/components/presence-mouse/types.ts index d21752f8..1d6e428a 100644 --- a/src/components/presence-mouse/types.ts +++ b/src/components/presence-mouse/types.ts @@ -4,6 +4,17 @@ export interface ParticipantMouse extends Participant { x: number; y: number; visible: boolean; + camera: Camera; +} + +export interface Camera { + x: number; + y: number; + screen: { + width: number; + height: number; + }; + scale: number; } export interface Transform { @@ -14,9 +25,16 @@ export interface Transform { scale?: number; } +export interface Position { + x: number; + y: number; + scaleX: number; + scaleY: number; +} + export interface PresenceMouseProps { callbacks: { - onGoToPresence?: (position: { x: number; y: number }) => void; + onGoToPresence?: (position: Position) => void; }; } diff --git a/src/components/types.ts b/src/components/types.ts index 934dc670..dd2cdb64 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -10,6 +10,7 @@ export enum ComponentNames { COMMENTS_MATTERPORT = 'comments3dMatterport', COMMENTS_AUTODESK = 'comments3dAutodesk', COMMENTS_THREEJS = 'comments3dThreejs', + FORM_ELEMENTS = 'formElements', } export enum PresenceMap { @@ -18,7 +19,9 @@ export enum PresenceMap { 'presence3dThreejs' = 'presence', 'realtime' = 'presence', 'whoIsOnline' = 'presence', + 'formElements' = 'presence', } + export enum Comments3d { 'comments3dMatterport' = 'comments3d', 'comments3dAutodesk' = 'comments3d', diff --git a/src/components/who-is-online/index.test.ts b/src/components/who-is-online/index.test.ts index 13f20c4c..7f1a1a51 100644 --- a/src/components/who-is-online/index.test.ts +++ b/src/components/who-is-online/index.test.ts @@ -278,7 +278,7 @@ describe('Who Is Online', () => { name: MOCK_ABLY_PARTICIPANT_DATA_2.name, }; const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); - following.publish(followingData); + following.publish(followingData); whoIsOnlineComponent['setFollow'](MOCK_ABLY_PARTICIPANT); @@ -294,7 +294,7 @@ describe('Who Is Online', () => { }; const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); - following.publish(followingData); + following.publish(followingData); whoIsOnlineComponent['setFollow']({ ...MOCK_ABLY_PARTICIPANT, @@ -330,7 +330,7 @@ describe('Who Is Online', () => { describe('stopFollowing', () => { test('should do nothing if participant leaving is not being followed', () => { const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); - following.publish({ + following.publish({ color: MOCK_ABLY_PARTICIPANT_DATA_2.color, id: MOCK_ABLY_PARTICIPANT_DATA_2.id, name: MOCK_ABLY_PARTICIPANT_DATA_2.name, @@ -339,12 +339,12 @@ describe('Who Is Online', () => { whoIsOnlineComponent['stopFollowing'](MOCK_ABLY_PARTICIPANT); expect(following.value).toBeDefined(); - expect(following.value.id).toBe(MOCK_ABLY_PARTICIPANT_DATA_2.id); + expect(following.value!.id).toBe(MOCK_ABLY_PARTICIPANT_DATA_2.id); }); test('should set following to undefined if following the participant who is leaving', () => { const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); - following.publish({ + following.publish({ color: MOCK_ABLY_PARTICIPANT_DATA_1.color, id: MOCK_ABLY_PARTICIPANT_DATA_1.id, name: MOCK_ABLY_PARTICIPANT_DATA_1.name, @@ -406,7 +406,7 @@ describe('Who Is Online', () => { name: MOCK_ABLY_PARTICIPANT_DATA_2.name, }; const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); - following.publish(followingData); + following.publish(followingData); whoIsOnlineComponent['followMousePointer']({ detail: { id: 'unit-test-id' }, @@ -438,7 +438,7 @@ describe('Who Is Online', () => { test('should publish "stop following" event when stopFollowing is called', () => { const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); - following.publish({ + following.publish({ color: MOCK_ABLY_PARTICIPANT_DATA_2.color, id: MOCK_ABLY_PARTICIPANT_DATA_2.id, name: MOCK_ABLY_PARTICIPANT_DATA_2.name, @@ -481,7 +481,7 @@ describe('Who Is Online', () => { name: MOCK_ABLY_PARTICIPANT_DATA_2.name, }; - following.publish(followingData); + following.publish(followingData); whoIsOnlineComponent['follow']({ detail: { id: 'unit-test-id' }, @@ -546,6 +546,7 @@ describe('Who Is Online', () => { disableGoToParticipant: { value: false }, disableGatherAll: { value: false }, disablePrivateMode: { value: false }, + destroy: jest.fn(), }); expect( @@ -565,6 +566,7 @@ describe('Who Is Online', () => { disableGoToParticipant: { value: false }, disableGatherAll: { value: false }, disablePrivateMode: { value: false }, + destroy: jest.fn(), }); expect( @@ -584,6 +586,7 @@ describe('Who Is Online', () => { disableGatherAll: { value: true }, disableFollowParticipant: { value: true }, disableGoToParticipant: { value: true }, + destroy: jest.fn(), }); expect( @@ -603,6 +606,7 @@ describe('Who Is Online', () => { disableGoToParticipant: { value: false }, disableGatherAll: { value: false }, disablePrivateMode: { value: false }, + destroy: jest.fn(), }); expect( @@ -622,6 +626,7 @@ describe('Who Is Online', () => { disableGoToParticipant: { value: false }, disableGatherAll: { value: false }, disablePrivateMode: { value: false }, + destroy: jest.fn(), }); expect( @@ -865,7 +870,7 @@ describe('Who Is Online', () => { ](StoreType.WHO_IS_ONLINE); disableGoToParticipant.publish(false); disableFollowParticipant.publish(false); - following.publish({ color: 'red', id: 'participant123', name: 'name' }); + following.publish({ color: 'red', id: 'participant123', name: 'name' }); const controls = whoIsOnlineComponent['getOtherParticipantsControls']('participant123'); @@ -1053,7 +1058,7 @@ describe('Who Is Online', () => { participants.publish(participantsList); extras.publish([participant5]); - following.publish({ + following.publish({ color: 'red', id: 'test id 5', name: 'participant 5', diff --git a/src/components/who-is-online/index.ts b/src/components/who-is-online/index.ts index 60fd5480..0f4510ab 100644 --- a/src/components/who-is-online/index.ts +++ b/src/components/who-is-online/index.ts @@ -72,8 +72,8 @@ export class WhoIsOnline extends BaseComponent { */ protected start(): void { const { localParticipant } = this.useStore(StoreType.GLOBAL); - localParticipant.subscribe((value: { id: string }) => { - this.localParticipantId = value.id; + localParticipant.subscribe((participant) => { + this.localParticipantId = participant.id; }); this.subscribeToRealtimeEvents(); @@ -94,6 +94,9 @@ export class WhoIsOnline extends BaseComponent { this.removeListeners(); this.element.remove(); this.element = null; + + const { destroy } = this.useStore(StoreType.WHO_IS_ONLINE); + destroy(); } /** @@ -623,24 +626,24 @@ export class WhoIsOnline extends BaseComponent { private updateParticipantsControls(participantId: string | undefined): void { const { participants } = this.useStore(StoreType.WHO_IS_ONLINE); - participants.publish( - participants.value.map((participant: Participant) => { - if (participantId && participant.id !== participantId) return participant; - - const { id } = participant; - const disableDropdown = this.shouldDisableDropdown({ - activeComponents: participant.activeComponents, - participantId: id, - }); - const presenceEnabled = !disableDropdown; - const controls = this.getControls({ participantId: id, presenceEnabled }) ?? []; - - return { - ...participant, - controls, - }; - }), - ); + const newParticipantsList = participants.value.map((participant: Participant) => { + if (participantId && participant.id !== participantId) return participant; + + const { id } = participant; + const disableDropdown = this.shouldDisableDropdown({ + activeComponents: participant.activeComponents, + participantId: id, + }); + const presenceEnabled = !disableDropdown; + const controls = this.getControls({ participantId: id, presenceEnabled }) ?? []; + + return { + ...participant, + controls, + }; + }) + + participants.publish(newParticipantsList); } /** diff --git a/src/index.ts b/src/index.ts index 890f42bc..67788f48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { CanvasPin, HTMLPin, WhoIsOnline, + FormElements, } from './components'; import { Transform } from './components/presence-mouse/types'; import { @@ -73,6 +74,7 @@ if (window) { CanvasPin, HTMLPin, WhoIsOnline, + FormElements, ParticipantType, LayoutPosition, CamerasPosition, @@ -101,6 +103,7 @@ export { CommentEvent, ComponentLifeCycleEvent, WhoIsOnlineEvent, + FormElements, Transform, Comments, CanvasPin, diff --git a/src/services/io/index.test.ts b/src/services/io/index.test.ts index 914f85d8..167b1b72 100644 --- a/src/services/io/index.test.ts +++ b/src/services/io/index.test.ts @@ -3,8 +3,6 @@ import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; import { IOC } from '.'; -jest.mock('@superviz/socket-client', () => MOCK_IO); - describe('io', () => { let instance: IOC | null = null; diff --git a/src/services/stores/global/index.ts b/src/services/stores/global/index.ts index 7b1306c0..f328e9e9 100644 --- a/src/services/stores/global/index.ts +++ b/src/services/stores/global/index.ts @@ -20,6 +20,8 @@ export class GlobalStore { public destroy() { this.localParticipant.destroy(); + this.participants.destroy(); + this.group.destroy(); instance.value = null; } } diff --git a/src/services/stores/who-is-online/index.ts b/src/services/stores/who-is-online/index.ts index 271cf65a..a4f22ecc 100644 --- a/src/services/stores/who-is-online/index.ts +++ b/src/services/stores/who-is-online/index.ts @@ -14,14 +14,11 @@ export class WhoIsOnlineStore { public disablePrivateMode = subject(false); public disableGatherAll = subject(false); public disableFollowMe = subject(false); - public participants = subject([]); public extras = subject([]); - public joinedPresence = subject(undefined); public everyoneFollowsMe = subject(false); public privateMode = subject(false); - public following = subject(undefined); constructor() { @@ -33,7 +30,19 @@ export class WhoIsOnlineStore { } public destroy() { - this.disablePresenceControls.destroy(); + this.disableGoToParticipant.destroy() + this.disablePresenceControls.destroy() + this.disableFollowParticipant.destroy() + this.disablePrivateMode.destroy() + this.disableGatherAll.destroy() + this.disableFollowMe.destroy() + this.participants.destroy() + this.extras.destroy() + this.joinedPresence.destroy() + this.everyoneFollowsMe.destroy() + this.privateMode.destroy() + this.following.destroy() + instance.value = null; } } diff --git a/src/web-components/comments/components/annotation-pin.ts b/src/web-components/comments/components/annotation-pin.ts index f17edaee..c11ce7a3 100644 --- a/src/web-components/comments/components/annotation-pin.ts +++ b/src/web-components/comments/components/annotation-pin.ts @@ -152,7 +152,7 @@ export class CommentsAnnotationPin extends WebComponentsBaseElement { if (this.type !== PinMode.ADD) return; const { localParticipant } = this.useStore(StoreType.GLOBAL); - localParticipant.subscribe((participant: Participant) => { + localParticipant.subscribe((participant) => { this.localAvatar = participant?.avatar?.imageUrl; this.localName = participant?.name; }); diff --git a/src/web-components/who-is-online/components/dropdown.test.ts b/src/web-components/who-is-online/components/dropdown.test.ts index 9139aa68..582268e2 100644 --- a/src/web-components/who-is-online/components/dropdown.test.ts +++ b/src/web-components/who-is-online/components/dropdown.test.ts @@ -148,7 +148,7 @@ describe('who-is-online-dropdown', () => { test('should render dropdown', () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); const element = document.querySelector('superviz-who-is-online-dropdown'); @@ -158,7 +158,7 @@ describe('who-is-online-dropdown', () => { test('should open dropdown when click on it', async () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); await sleep(); @@ -172,7 +172,7 @@ describe('who-is-online-dropdown', () => { test('should close dropdown when click on it', async () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); await sleep(); dropdownContent()?.click(); @@ -194,7 +194,7 @@ describe('who-is-online-dropdown', () => { test('should open another dropdown when click on participant', async () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); await sleep(); @@ -217,7 +217,7 @@ describe('who-is-online-dropdown', () => { test('should listen click event when click out', async () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); await sleep(); @@ -240,7 +240,7 @@ describe('who-is-online-dropdown', () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish([MOCK_PARTICIPANTS[2]]); + extras.publish([MOCK_PARTICIPANTS[2]]); await sleep(); const letter = element()?.shadowRoot?.querySelector('.who-is-online__participant__avatar'); @@ -283,7 +283,7 @@ describe('who-is-online-dropdown', () => { test('should render participants when there is participant', async () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish([MOCK_PARTICIPANTS[0]]); + extras.publish([MOCK_PARTICIPANTS[0]]); await sleep(); @@ -295,7 +295,7 @@ describe('who-is-online-dropdown', () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish([ + extras.publish([ { avatar: { imageUrl: '', @@ -329,7 +329,7 @@ describe('who-is-online-dropdown', () => { test('should not change selected participant when click on it if disableDropdown is true', async () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish([ + extras.publish([ { ...MOCK_PARTICIPANTS[0], disableDropdown: true, @@ -353,7 +353,7 @@ describe('who-is-online-dropdown', () => { test('should call reposition methods if is open', () => { const el = createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); el['open'] = true; @@ -369,7 +369,7 @@ describe('who-is-online-dropdown', () => { test('should do nothing if is not open', () => { const el = createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); el['open'] = false; @@ -392,7 +392,7 @@ describe('who-is-online-dropdown', () => { test('should set bottom and top styles when dropdownVerticalMidpoint is greater than windowVerticalMidpoint', async () => { const el = createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); await sleep(); @@ -415,7 +415,7 @@ describe('who-is-online-dropdown', () => { test('should set top and bottom styles when dropdownVerticalMidpoint is less than windowVerticalMidpoint', async () => { const el = createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); await sleep(); diff --git a/src/web-components/who-is-online/who-is-online.test.ts b/src/web-components/who-is-online/who-is-online.test.ts index 55975087..61354703 100644 --- a/src/web-components/who-is-online/who-is-online.test.ts +++ b/src/web-components/who-is-online/who-is-online.test.ts @@ -355,7 +355,7 @@ describe('Who Is Online', () => { element.addEventListener(RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, spy); const { following } = useStore(StoreType.WHO_IS_ONLINE); - following.publish({ color: 'red', id: '1', name: 'John' }); + following.publish({ color: 'red', id: '1', name: 'John' }); element['following'] = { participantId: 1, slotIndex: 1 }; await sleep(); diff --git a/src/web-components/who-is-online/who-is-online.ts b/src/web-components/who-is-online/who-is-online.ts index 77b9488f..364c96ae 100644 --- a/src/web-components/who-is-online/who-is-online.ts +++ b/src/web-components/who-is-online/who-is-online.ts @@ -52,11 +52,11 @@ export class WhoIsOnline extends WebComponentsBaseElement { const { participants, following, extras } = this.useStore(StoreType.WHO_IS_ONLINE); participants.subscribe(); following.subscribe(); - extras.subscribe((participants: Participant[]) => { + extras.subscribe((participants) => { this.amountOfExtras = participants.length; }); - localParticipant.subscribe((value: Participant) => { + localParticipant.subscribe((value) => { const joinedPresence = value.activeComponents?.some((component) => component.toLowerCase().includes('presence'), ); @@ -249,7 +249,7 @@ export class WhoIsOnline extends WebComponentsBaseElement { this.emitEvent(RealtimeEvent.REALTIME_GO_TO_PARTICIPANT, { id: participantId }); } - private handleLocalFollow(participantId: string, source: string) { + private handleLocalFollow(participantId: string, source: 'participants' | 'extras') { const { following } = this.useStore(StoreType.WHO_IS_ONLINE); const participants = this.useStore(StoreType.WHO_IS_ONLINE)[source].value; @@ -286,12 +286,12 @@ export class WhoIsOnline extends WebComponentsBaseElement { this.isPrivate = false; } - private handleFollow(participantId: string, source: string) { + private handleFollow(participantId: string, source: 'participants' | 'extras') { if (this.isPrivate) { this.cancelPrivate(); } - const participants: Participant[] = this.useStore(StoreType.WHO_IS_ONLINE)[source].value; + const participants = this.useStore(StoreType.WHO_IS_ONLINE)[source].value; const { id, diff --git a/yarn.lock b/yarn.lock index b9ca051e..7ac6e982 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4487,7 +4487,7 @@ color-name@1.1.3: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@~1.1.4: +color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -4783,6 +4783,11 @@ cssesc@^3.0.0: resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssfontparser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3" + integrity sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg== + cssom@^0.5.0: version "0.5.0" resolved "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz" @@ -7198,6 +7203,14 @@ jest-browser-globals@^25.1.0-beta: resolved "https://registry.yarnpkg.com/jest-browser-globals/-/jest-browser-globals-25.1.0-beta.tgz#a687907cb2ce0009be91e0c6a71992a696023af1" integrity sha512-SlkemP7lm37mIWrFhIdQzenoivV9dmj5nSkDi5g3xeiBaulLiieFs5puaF9KMnzREIQRY/tfVgNQNr7uvGcLvg== +jest-canvas-mock@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz#7e21ebd75e05ab41c890497f6ba8a77f915d2ad6" + integrity sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A== + dependencies: + cssfontparser "^1.2.1" + moo-color "^1.0.2" + jest-changed-files@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" @@ -8598,6 +8611,13 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +moo-color@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" + integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ== + dependencies: + color-name "^1.1.4" + ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"