From 3f025e718d379b3627c79d3dac820905a3594da8 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Sun, 17 Mar 2024 16:12:50 -0300 Subject: [PATCH 01/30] feat: start presence input component --- src/common/types/cdn.types.ts | 2 ++ src/components/index.ts | 1 + src/components/presence-input/index.test.ts | 0 src/components/presence-input/index.ts | 35 +++++++++++++++++++++ src/components/presence-input/types.ts | 0 src/components/types.ts | 3 ++ src/index.ts | 3 ++ 7 files changed, 44 insertions(+) create mode 100644 src/components/presence-input/index.test.ts create mode 100644 src/components/presence-input/index.ts create mode 100644 src/components/presence-input/types.ts diff --git a/src/common/types/cdn.types.ts b/src/common/types/cdn.types.ts index 23f131c0..399df547 100644 --- a/src/common/types/cdn.types.ts +++ b/src/common/types/cdn.types.ts @@ -6,6 +6,7 @@ import { Realtime, VideoConference, WhoIsOnline, + PresenceInput, } 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; + PresenceInput: typeof PresenceInput; RealtimeComponentState: typeof RealtimeComponentState; RealtimeComponentEvent: typeof RealtimeComponentEvent; } diff --git a/src/components/index.ts b/src/components/index.ts index fb8c9221..9210a342 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 { PresenceInput } from './presence-input'; diff --git a/src/components/presence-input/index.test.ts b/src/components/presence-input/index.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/presence-input/index.ts b/src/components/presence-input/index.ts new file mode 100644 index 00000000..ee9f9b42 --- /dev/null +++ b/src/components/presence-input/index.ts @@ -0,0 +1,35 @@ +import { Logger } from '../../common/utils'; +import { BaseComponent } from '../base'; +import { ComponentNames } from '../types'; + +export class PresenceInput extends BaseComponent { + public name: ComponentNames; + protected logger: Logger; + + constructor() { + super(); + this.name = ComponentNames.PRESENCE; + this.logger = new Logger('@superviz/sdk/presence-input-component'); + + console.log('created PresenceInput'); + } + + // ------- setup ------- + /** + * @function start + * @description starts the component + * @returns {void} + * */ + protected start(): void { + console.log('started component!'); + } + + /** + * @function destroy + * @description destroys the component + * @returns {void} + * */ + protected destroy(): void { + console.log('destroyed component!'); + } +} diff --git a/src/components/presence-input/types.ts b/src/components/presence-input/types.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/types.ts b/src/components/types.ts index 934dc670..6bf24878 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', + PRESENCE_INPUT = 'presenceInput', } export enum PresenceMap { @@ -18,7 +19,9 @@ export enum PresenceMap { 'presence3dThreejs' = 'presence', 'realtime' = 'presence', 'whoIsOnline' = 'presence', + 'presenceInput' = 'presence', } + export enum Comments3d { 'comments3dMatterport' = 'comments3d', 'comments3dAutodesk' = 'comments3d', diff --git a/src/index.ts b/src/index.ts index a8e7b191..cdbe5c7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { CanvasPin, HTMLPin, WhoIsOnline, + PresenceInput, } from './components'; import { RealtimeComponentEvent, RealtimeComponentState } from './components/realtime/types'; import init from './core'; @@ -69,6 +70,7 @@ if (window) { CanvasPin, HTMLPin, WhoIsOnline, + PresenceInput, ParticipantType, LayoutPosition, CamerasPosition, @@ -97,6 +99,7 @@ export { CommentEvent, ComponentLifeCycleEvent, WhoIsOnlineEvent, + PresenceInput, }; export default init; From c82a23a81e8353686065196d149487fea31e713d Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Sun, 17 Mar 2024 20:10:15 -0300 Subject: [PATCH 02/30] feat: validate fields and add listeners --- src/components/presence-input/index.ts | 215 ++++++++++++++++++++++++- src/components/presence-input/types.ts | 5 + 2 files changed, 214 insertions(+), 6 deletions(-) diff --git a/src/components/presence-input/index.ts b/src/components/presence-input/index.ts index ee9f9b42..339fa3ee 100644 --- a/src/components/presence-input/index.ts +++ b/src/components/presence-input/index.ts @@ -2,16 +2,65 @@ import { Logger } from '../../common/utils'; import { BaseComponent } from '../base'; import { ComponentNames } from '../types'; +import { Field, PresenceInputProps } from './types'; + export class PresenceInput extends BaseComponent { public name: ComponentNames; protected logger: Logger; - constructor() { + // HTML Elements + private fields: Record = {}; + + // Allowed tags and types + private readonly allowedTagNames = ['input', 'textarea']; + private readonly allowedInputTypes = ['text']; + + 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: PresenceInputProps = {}) { super(); this.name = ComponentNames.PRESENCE; this.logger = new Logger('@superviz/sdk/presence-input-component'); - console.log('created PresenceInput'); + const { fields } = props; + + if (typeof fields === 'string') { + this.registerField(fields); + } else if (Array.isArray(fields)) { + this.registerArrayOfFields(fields); + } else { + this.throwError.onInvalidFieldsProp(typeof fields); + } } // ------- setup ------- @@ -20,9 +69,7 @@ export class PresenceInput extends BaseComponent { * @description starts the component * @returns {void} * */ - protected start(): void { - console.log('started component!'); - } + protected start(): void {} /** * @function destroy @@ -30,6 +77,162 @@ export class PresenceInput extends BaseComponent { * @returns {void} * */ protected destroy(): void { - console.log('destroyed component!'); + this.deregisterAllFields(); + } + + // ------- 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 { + field.addEventListener('input', this.handleInput); + field.addEventListener('focus', this.handleFocus); + field.addEventListener('blur', this.handleBlur); + } + + /** + * @function removeListenersFromAllFields + * @description Removes listeners from all fields + * @returns {void} + */ + private removeListenersFromAllFields(): void { + Object.values(this.fields).forEach((field) => { + this.removeListenersFromField(field); + }); + } + + /** + * @function removeListenersFromField + * @description Removes listeners from a field + * @param {Field} field The field that will have the listeners removed + * @param field + */ + private removeListenersFromField(field: Field): void { + field.removeEventListener('input', this.handleInput); + field.removeEventListener('focus', this.handleFocus); + field.removeEventListener('blur', this.handleBlur); + } + + // ------- register & deregister ------- + /** + * @function registerArrayOfFields + * @description Registers multiple fields at once + * @param {string[]} fieldsIds The ids of the fields that will be registered + * @returns {void} + */ + private registerArrayOfFields(fieldsIds: string[]): void { + fieldsIds.forEach((id) => { + this.registerField(id); + }); + } + + /** + * @function registerField + * @description Registers an element; usually, something that serves a text field. + + 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 (or, at the very least, a field with the same id). + + 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) { + const field = document.getElementById(fieldId) as Field; + + if (!field) { + this.throwError.onFieldNotFound(fieldId); + } + + this.validateField(field); + this.fields[fieldId] = field; + this.addListenersToField(field); + } + + /** + * @function deregisterAllFields + * @description Deregisters an element. No interactions with the field will be shared after this. + * @returns {void} + */ + private deregisterAllFields() { + this.fields = {}; + this.removeListenersFromAllFields(); + } + + /** + * @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); + } + + console.log('deregistering field', fieldId); + this.removeListenersFromField(this.fields[fieldId]); + this.fields[fieldId] = undefined; + } + + // ------- callbacks ------- + private handleInput = (event: Event) => { + console.log(event); + }; + + private handleFocus = (event: Event) => { + console.log(event); + }; + + private handleBlur = (event: Event) => { + console.log(event); + }; + + // ------- validations ------- + /** + * @function validateField + * @description Verifies if an element can be registered + * @param {Field} field The element + * @returns {void} + */ + private validateField(field: Field): void { + 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 !== '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); + } } } diff --git a/src/components/presence-input/types.ts b/src/components/presence-input/types.ts index e69de29b..9b8ed40a 100644 --- a/src/components/presence-input/types.ts +++ b/src/components/presence-input/types.ts @@ -0,0 +1,5 @@ +export interface PresenceInputProps { + fields?: string[] | string; +} + +export type Field = HTMLInputElement | HTMLTextAreaElement; From f988204ae352468ef1a0278b7c3d835e2adac869 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Mon, 18 Mar 2024 09:58:36 -0300 Subject: [PATCH 03/30] feat: send input interactions to other participants --- src/components/base/index.ts | 2 + src/components/presence-input/index.ts | 182 ++++++++++++++++++++----- src/components/presence-input/types.ts | 27 +++- 3 files changed, 178 insertions(+), 33 deletions(-) diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 1b50943c..a21afeba 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -85,6 +85,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/presence-input/index.ts b/src/components/presence-input/index.ts index 339fa3ee..be1cc844 100644 --- a/src/components/presence-input/index.ts +++ b/src/components/presence-input/index.ts @@ -1,18 +1,25 @@ +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, PresenceInputProps } from './types'; +import { Field, Focus, IOFieldEvents, Payload, FormElementsProps } from './types'; -export class PresenceInput extends BaseComponent { +// PresenceInput --> Change to FormElements +export class FormElements extends BaseComponent { public name: ComponentNames; protected logger: Logger; + private localParticipant: Participant; // HTML Elements private fields: Record = {}; // Allowed tags and types private readonly allowedTagNames = ['input', 'textarea']; + // text, search, URL, tel, and password private readonly allowedInputTypes = ['text']; private readonly throwError = { @@ -47,7 +54,9 @@ export class PresenceInput extends BaseComponent { }, }; - constructor(props: PresenceInputProps = {}) { + private focusList: Record = {}; + + constructor(props: FormElementsProps = {}) { super(); this.name = ComponentNames.PRESENCE; this.logger = new Logger('@superviz/sdk/presence-input-component'); @@ -55,10 +64,20 @@ export class PresenceInput extends BaseComponent { const { fields } = props; if (typeof fields === 'string') { - this.registerField(fields); - } else if (Array.isArray(fields)) { - this.registerArrayOfFields(fields); - } else { + this.validateFieldId(fields); + this.fields[fields] = null; + return; + } + + if (Array.isArray(fields)) { + fields.forEach((fieldId) => { + this.validateFieldId(fieldId); + this.fields[fieldId] = null; + }); + return; + } + + if (fields !== undefined) { this.throwError.onInvalidFieldsProp(typeof fields); } } @@ -69,7 +88,16 @@ export class PresenceInput extends BaseComponent { * @description starts the component * @returns {void} * */ - protected start(): void {} + protected start(): void { + const { localParticipant } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe(); + + Object.entries(this.fields).forEach(([fieldId]) => { + this.registerField(fieldId); + }); + + this.subscribeToRealtimeEvents(); + } /** * @function destroy @@ -80,6 +108,71 @@ export class PresenceInput extends BaseComponent { this.deregisterAllFields(); } + private subscribeToRealtimeEvents() { + Object.entries(this.fields).forEach(([fieldId]) => { + this.addRealtimeListenersToField(fieldId); + }); + } + + private removeFieldColor = (fieldId: string) => { + return ({ presence }: SocketEvent) => { + if (this.focusList[fieldId]?.id !== presence.id || !this.fields[fieldId]) return; + + this.fields[fieldId].style.border = ''; + delete this.focusList[fieldId]; + }; + }; + + private updateFieldColor = (fieldId: string) => { + return ({ presence, data: { color }, 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 >= 10000; // ms; + const gainedFocusLongAgo = timestamp - participantInFocus.firstInteraction >= 100000; // ms; + const changeInputBorderColor = stoppedInteracting || gainedFocusLongAgo || thereIsNoFocus; + + if (!changeInputBorderColor) return; + + this.focusList[fieldId] = { + id: presence.id, + color, + firstInteraction: timestamp, + lastInteraction: timestamp, + }; + + const border = localParticipantEmittedEvent ? '' : `2px solid ${color}`; + this.fields[fieldId].style.border = border; + }; + }; + + private updateFieldContent = (fieldId: string) => { + return ({ presence, data: { content } }: SocketEvent) => { + if (presence.id === this.localParticipant.id) return; + this.fields[fieldId].value = content; + }; + }; + // ------- listeners ------- /** * @function addListenersToField @@ -88,11 +181,19 @@ export class PresenceInput extends BaseComponent { * @returns {void} */ private addListenersToField(field: Field): void { - field.addEventListener('input', this.handleInput); + field.addEventListener('input', this.handleInputEvent); field.addEventListener('focus', this.handleFocus); field.addEventListener('blur', this.handleBlur); } + private addRealtimeListenersToField(fieldId: string) { + if (!this.room) return; + this.room.on(IOFieldEvents.INPUT + fieldId, this.updateFieldContent(fieldId)); + this.room.on(IOFieldEvents.INPUT + fieldId, this.updateFieldColor(fieldId)); + this.room.on(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor(fieldId)); + this.room.on(IOFieldEvents.BLUR + fieldId, this.removeFieldColor(fieldId)); + } + /** * @function removeListenersFromAllFields * @description Removes listeners from all fields @@ -111,24 +212,18 @@ export class PresenceInput extends BaseComponent { * @param field */ private removeListenersFromField(field: Field): void { - field.removeEventListener('input', this.handleInput); field.removeEventListener('focus', this.handleFocus); field.removeEventListener('blur', this.handleBlur); } - // ------- register & deregister ------- - /** - * @function registerArrayOfFields - * @description Registers multiple fields at once - * @param {string[]} fieldsIds The ids of the fields that will be registered - * @returns {void} - */ - private registerArrayOfFields(fieldsIds: string[]): void { - fieldsIds.forEach((id) => { - this.registerField(id); - }); + private removeRealtimeListenersFromField(fieldId: string) { + this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldContent(fieldId)); + this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldColor(fieldId)); + this.room.off(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor(fieldId)); + this.room.off(IOFieldEvents.BLUR + fieldId, this.removeFieldColor(fieldId)); } + // ------- register & deregister ------- /** * @function registerField * @description Registers an element; usually, something that serves a text field. @@ -140,15 +235,13 @@ export class PresenceInput extends BaseComponent { * @returns {void} */ public registerField(fieldId: string) { - const field = document.getElementById(fieldId) as Field; + this.validateField(fieldId); - if (!field) { - this.throwError.onFieldNotFound(fieldId); - } - - this.validateField(field); + const field = document.getElementById(fieldId) as Field; this.fields[fieldId] = field; + this.addListenersToField(field); + this.addRealtimeListenersToField(fieldId); } /** @@ -174,20 +267,34 @@ export class PresenceInput extends BaseComponent { console.log('deregistering field', fieldId); this.removeListenersFromField(this.fields[fieldId]); + this.removeRealtimeListenersFromField(fieldId); this.fields[fieldId] = undefined; } // ------- callbacks ------- - private handleInput = (event: Event) => { - console.log(event); + private handleInputEvent = (event: any) => { + console.log('on input'); + const target = event.target as HTMLInputElement; + const payload: Payload = { + content: target.value, + color: this.localParticipant.slot.color, + }; + + this.room?.emit(IOFieldEvents.INPUT + target.id, payload); }; private handleFocus = (event: Event) => { - console.log(event); + const target = event.target as HTMLInputElement; + const payload: Payload = { + color: this.localParticipant.slot.color, + }; + + this.room?.emit(IOFieldEvents.FOCUS + target.id, payload); }; private handleBlur = (event: Event) => { - console.log(event); + const target = event.target as HTMLInputElement; + this.room?.emit(IOFieldEvents.BLUR + target.id, {}); }; // ------- validations ------- @@ -197,7 +304,10 @@ export class PresenceInput extends BaseComponent { * @param {Field} field The element * @returns {void} */ - private validateField(field: Field): void { + private validateField(fieldId: string): void { + this.validateFieldId(fieldId); + + const field = document.getElementById(fieldId) as Field; this.validateFieldTagName(field); this.validateFieldType(field); } @@ -235,4 +345,12 @@ export class PresenceInput extends BaseComponent { this.throwError.onInvalidInputType(inputType); } } + + private validateFieldId(fieldId: string): void { + const field = document.getElementById(fieldId); + + if (!field) { + this.throwError.onFieldNotFound(fieldId); + } + } } diff --git a/src/components/presence-input/types.ts b/src/components/presence-input/types.ts index 9b8ed40a..c6068aea 100644 --- a/src/components/presence-input/types.ts +++ b/src/components/presence-input/types.ts @@ -1,5 +1,30 @@ -export interface PresenceInputProps { +export interface FormElementsProps { fields?: string[] | string; } export type Field = HTMLInputElement | HTMLTextAreaElement; + +export enum IOFieldEvents { + INPUT = 'field.input', + BLUR = 'field.blur', + FOCUS = 'field.focus', +} + +// https://w3c.github.io/input-events/#interface-InputEvent-Attributes +export enum InputEvent { + INSERT_TEXT = 'insertText', + DELETE_CONTENT_BACKWARD = 'deleteContentBackward', + DELETE_CONTENT_FORWARD = 'deleteContentForward', +} + +export interface Payload { + content?: string | null; + color: string; +} + +export interface Focus { + firstInteraction: number; + lastInteraction: number; + color: string; + id: string; +} From 86701af3ed705bc837260fd0d9f767c126e273b5 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Mon, 25 Mar 2024 08:29:03 -0300 Subject: [PATCH 04/30] fix: change component name --- src/common/types/cdn.types.ts | 4 ++-- src/components/index.ts | 2 +- src/components/types.ts | 4 ++-- src/index.ts | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/common/types/cdn.types.ts b/src/common/types/cdn.types.ts index 399df547..5742c5a7 100644 --- a/src/common/types/cdn.types.ts +++ b/src/common/types/cdn.types.ts @@ -6,7 +6,7 @@ import { Realtime, VideoConference, WhoIsOnline, - PresenceInput, + FormElements, } from '../../components'; import { RealtimeComponentEvent, RealtimeComponentState } from '../../components/realtime/types'; import { LauncherFacade } from '../../core/launcher/types'; @@ -56,7 +56,7 @@ export interface SuperVizCdn { CanvasPin: typeof CanvasPin; HTMLPin: typeof HTMLPin; WhoIsOnline: typeof WhoIsOnline; - PresenceInput: typeof PresenceInput; + FormElements: typeof FormElements; RealtimeComponentState: typeof RealtimeComponentState; RealtimeComponentEvent: typeof RealtimeComponentEvent; } diff --git a/src/components/index.ts b/src/components/index.ts index 9210a342..1cf6be19 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,4 +5,4 @@ export { VideoConference } from './video'; export { MousePointers } from './presence-mouse'; export { Realtime } from './realtime'; export { WhoIsOnline } from './who-is-online'; -export { PresenceInput } from './presence-input'; +export { FormElements } from './form-elements'; diff --git a/src/components/types.ts b/src/components/types.ts index 6bf24878..dd2cdb64 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -10,7 +10,7 @@ export enum ComponentNames { COMMENTS_MATTERPORT = 'comments3dMatterport', COMMENTS_AUTODESK = 'comments3dAutodesk', COMMENTS_THREEJS = 'comments3dThreejs', - PRESENCE_INPUT = 'presenceInput', + FORM_ELEMENTS = 'formElements', } export enum PresenceMap { @@ -19,7 +19,7 @@ export enum PresenceMap { 'presence3dThreejs' = 'presence', 'realtime' = 'presence', 'whoIsOnline' = 'presence', - 'presenceInput' = 'presence', + 'formElements' = 'presence', } export enum Comments3d { diff --git a/src/index.ts b/src/index.ts index cfa460ac..a9b5d924 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import { CanvasPin, HTMLPin, WhoIsOnline, - PresenceInput, + FormElements, } from './components'; import { RealtimeComponentEvent, RealtimeComponentState } from './components/realtime/types'; import init from './core'; @@ -69,7 +69,7 @@ if (window) { CanvasPin, HTMLPin, WhoIsOnline, - PresenceInput, + FormElements, ParticipantType, LayoutPosition, CamerasPosition, @@ -98,7 +98,7 @@ export { CommentEvent, ComponentLifeCycleEvent, WhoIsOnlineEvent, - PresenceInput, + FormElements, Comments, CanvasPin, HTMLPin, From 3ea4194ac870ff1b1763f967419121c662c758af Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Mon, 25 Mar 2024 12:31:41 -0300 Subject: [PATCH 05/30] feat: write tests for form elements component --- src/components/form-elements/index.test.ts | 674 ++++++++++++++++++ .../index.ts | 236 +++--- .../types.ts | 4 + src/components/presence-input/index.test.ts | 0 src/services/stores/who-is-online/index.ts | 34 - 5 files changed, 830 insertions(+), 118 deletions(-) create mode 100644 src/components/form-elements/index.test.ts rename src/components/{presence-input => form-elements}/index.ts (71%) rename src/components/{presence-input => form-elements}/types.ts (84%) delete mode 100644 src/components/presence-input/index.test.ts delete mode 100644 src/services/stores/who-is-online/index.ts diff --git a/src/components/form-elements/index.test.ts b/src/components/form-elements/index.test.ts new file mode 100644 index 00000000..554c172e --- /dev/null +++ b/src/components/form-elements/index.test.ts @@ -0,0 +1,674 @@ +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 { 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.PRESENCE); + }); + + 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: ['date'] }); + 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); + }); + + test('should call subscribeToRealtimeEvents', () => { + const spy = jest.spyOn(instance, 'subscribeToRealtimeEvents'); + instance['start'](); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + 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('subscribeToRealtimeEvents', () => { + test('should call addRealtimeListenersToField for each field in fields', () => { + instance = new FormElements({ fields: ['field-1', 'field-2', 'field-3'] }); + const spy = jest.spyOn(instance, 'addRealtimeListenersToField'); + instance['subscribeToRealtimeEvents'](); + expect(spy).toHaveBeenCalledTimes(3); + }); + }); + + 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']); + }); + }); + + describe('addRealtimeListenersToField', () => { + test('should add realtime listeners to the field', () => { + instance['room'] = { + on: jest.fn(), + } as any; + + const updateContentSpy = jest.fn(); + const updateColorSpy = jest.fn(); + const removeColorSpy = jest.fn(); + + instance['updateFieldContent'] = jest.fn().mockReturnValue(updateContentSpy); + instance['updateFieldColor'] = jest.fn().mockReturnValue(updateColorSpy); + instance['removeFieldColor'] = jest.fn().mockReturnValue(removeColorSpy); + + instance['addRealtimeListenersToField']('field-1'); + + expect(instance['room'].on).toHaveBeenCalledTimes(4); + expect(instance['room'].on).toHaveBeenCalledWith('field.inputfield-1', updateContentSpy); + expect(instance['room'].on).toHaveBeenCalledWith('field.inputfield-1', updateColorSpy); + expect(instance['room'].on).toHaveBeenCalledWith('field.focusfield-1', updateColorSpy); + expect(instance['room'].on).toHaveBeenCalledWith('field.blurfield-1', removeColorSpy); + }); + + 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']); + }); + }); + + describe('removeRealtimeListenersFromField', () => { + test('should remove realtime listeners from the field', () => { + instance['room'] = { + off: jest.fn(), + } as any; + + const updateContentSpy = jest.fn(); + const updateColorSpy = jest.fn(); + const removeColorSpy = jest.fn(); + + instance['updateFieldContent'] = jest.fn().mockReturnValue(updateContentSpy); + instance['updateFieldColor'] = jest.fn().mockReturnValue(updateColorSpy); + instance['removeFieldColor'] = jest.fn().mockReturnValue(removeColorSpy); + + instance['removeRealtimeListenersFromField']('field-1'); + expect(instance['room'].off).toHaveBeenCalledTimes(4); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 1, + 'field.inputfield-1', + updateContentSpy, + ); + expect(instance['room'].off).toHaveBeenNthCalledWith(2, 'field.inputfield-1', updateColorSpy); + expect(instance['room'].off).toHaveBeenNthCalledWith(3, 'field.focusfield-1', updateColorSpy); + expect(instance['room'].off).toHaveBeenNthCalledWith(4, 'field.blurfield-1', removeColorSpy); + }); + + 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(); + }); + }); + + 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(); + }); + }); + + /** private handleInput = (event: any) => { + const target = event.target as HTMLInputElement; + const payload: Payload = { + content: target.value, + color: this.localParticipant.slot.color, + }; + + this.room?.emit(IOFieldEvents.INPUT + target.id, payload); + }; + */ + 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(1); + expect(instance['room'].emit).toHaveBeenCalledWith('field.inputfield-1', { + content: 'some value', + color: 'red', + }); + }); + }); + + /** private handleFocus = (event: any) => { + const target = event.target as HTMLInputElement; + this.room?.emit(IOFieldEvents.FOCUS + target.id, this.localParticipant.slot.color); + }; + */ + 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' }); + }); + }); + + /** private handleBlur = (event: any) => { + const target = event.target as HTMLInputElement; + this.room?.emit(IOFieldEvents.BLUR + target.id); + }; + */ + 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', {}); + }); + }); + + 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('date') 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(); + }); + }); + + /** private removeFieldColor = (fieldId: string): RealtimeCallback => { + return ({ presence }: SocketEvent) => { + if (this.focusList[fieldId]?.id !== presence.id || !this.fields[fieldId]) return; + + this.fields[fieldId].style.border = this.fieldsOriginalOutline[fieldId]; + delete this.focusList[fieldId]; + }; + }; */ + + 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' } }; + + const callback = instance['removeFieldColor']('field-1'); + + callback({ presence: { id: '123' } }); + + 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' } }; + + const callback = instance['removeFieldColor']('field-1'); + + callback({ presence: { id: '123' } }); + + 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', () => { + const callback = instance['removeFieldColor']('field-1'); + instance['fieldsOriginalOutline']['field-1'] = 'some value'; + callback({ presence: { id: '123' } }); + + expect(instance['fieldsOriginalOutline']['field-1']).toBe('some value'); + }); + }); + + 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'] = {}; + + const callback = instance['updateFieldColor']('field-1'); + + callback({ presence: { id: '123' }, data: { color: 'red' }, 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 } }; + + const callback = instance['updateFieldColor']('field-1'); + + callback({ presence: { id: '321' }, data: { color: 'red' }, 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 } }; + + const callback = instance['updateFieldColor']('field-1'); + + callback({ presence: { id: '321' }, data: { color: 'red' }, 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 }, + }; + + const callback = instance['updateFieldColor']('field-1'); + + callback({ presence: { id: '123' }, data: { color: 'red' }, 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'] = {}; + + const callback = instance['updateFieldColor']('field-1'); + + callback({ + presence: { id: MOCK_LOCAL_PARTICIPANT.id }, + data: { color: 'red' }, + 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 }, + }; + + const callback = instance['updateFieldColor']('field-1'); + + callback({ presence: { id: '321' }, data: { color: 'red' }, 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 }, + }; + + const callback = instance['updateFieldColor']('field-1'); + + callback({ + presence: { id: MOCK_LOCAL_PARTICIPANT.id }, + data: { color: 'red' }, + 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; + + const callback = instance['updateFieldContent']('field-1'); + + callback({ presence: { id: '321' }, data: { content: 'new content' } }); + + 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; + + const callback = instance['updateFieldContent']('field-1'); + + callback({ presence: { id: '123' }, data: { content: 'new content' } }); + + expect(field.value).toBe('old content'); + }); + + 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'); + }); + }); + }); +}); diff --git a/src/components/presence-input/index.ts b/src/components/form-elements/index.ts similarity index 71% rename from src/components/presence-input/index.ts rename to src/components/form-elements/index.ts index be1cc844..22cf485d 100644 --- a/src/components/presence-input/index.ts +++ b/src/components/form-elements/index.ts @@ -6,9 +6,8 @@ import { Logger } from '../../common/utils'; import { BaseComponent } from '../base'; import { ComponentNames } from '../types'; -import { Field, Focus, IOFieldEvents, Payload, FormElementsProps } from './types'; +import { Field, Focus, IOFieldEvents, Payload, FormElementsProps, RealtimeCallback } from './types'; -// PresenceInput --> Change to FormElements export class FormElements extends BaseComponent { public name: ComponentNames; protected logger: Logger; @@ -17,10 +16,12 @@ export class FormElements extends BaseComponent { // HTML Elements private fields: Record = {}; + private fieldsOriginalOutline: Record = {}; + // Allowed tags and types private readonly allowedTagNames = ['input', 'textarea']; // text, search, URL, tel, and password - private readonly allowedInputTypes = ['text']; + private readonly allowedInputTypes = ['text', 'email']; private readonly throwError = { onInvalidFieldsProp: (propType: string) => { @@ -64,14 +65,14 @@ export class FormElements extends BaseComponent { const { fields } = props; if (typeof fields === 'string') { - this.validateFieldId(fields); + this.validateField(fields); this.fields[fields] = null; return; } if (Array.isArray(fields)) { fields.forEach((fieldId) => { - this.validateFieldId(fieldId); + this.validateField(fieldId); this.fields[fieldId] = null; }); return; @@ -105,74 +106,23 @@ export class FormElements extends BaseComponent { * @returns {void} * */ protected destroy(): void { + this.restoreOutlines(); this.deregisterAllFields(); + + this.fieldsOriginalOutline = undefined; } - private subscribeToRealtimeEvents() { + /** + * @function subscribeToRealtimeEvents + * @description Subscribes all fields to realtime events + * @returns {void} + */ + private subscribeToRealtimeEvents(): void { Object.entries(this.fields).forEach(([fieldId]) => { this.addRealtimeListenersToField(fieldId); }); } - private removeFieldColor = (fieldId: string) => { - return ({ presence }: SocketEvent) => { - if (this.focusList[fieldId]?.id !== presence.id || !this.fields[fieldId]) return; - - this.fields[fieldId].style.border = ''; - delete this.focusList[fieldId]; - }; - }; - - private updateFieldColor = (fieldId: string) => { - return ({ presence, data: { color }, 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 >= 10000; // ms; - const gainedFocusLongAgo = timestamp - participantInFocus.firstInteraction >= 100000; // ms; - const changeInputBorderColor = stoppedInteracting || gainedFocusLongAgo || thereIsNoFocus; - - if (!changeInputBorderColor) return; - - this.focusList[fieldId] = { - id: presence.id, - color, - firstInteraction: timestamp, - lastInteraction: timestamp, - }; - - const border = localParticipantEmittedEvent ? '' : `2px solid ${color}`; - this.fields[fieldId].style.border = border; - }; - }; - - private updateFieldContent = (fieldId: string) => { - return ({ presence, data: { content } }: SocketEvent) => { - if (presence.id === this.localParticipant.id) return; - this.fields[fieldId].value = content; - }; - }; - // ------- listeners ------- /** * @function addListenersToField @@ -181,11 +131,17 @@ export class FormElements extends BaseComponent { * @returns {void} */ private addListenersToField(field: Field): void { - field.addEventListener('input', this.handleInputEvent); + field.addEventListener('input', this.handleInput); 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(IOFieldEvents.INPUT + fieldId, this.updateFieldContent(fieldId)); @@ -194,17 +150,6 @@ export class FormElements extends BaseComponent { this.room.on(IOFieldEvents.BLUR + fieldId, this.removeFieldColor(fieldId)); } - /** - * @function removeListenersFromAllFields - * @description Removes listeners from all fields - * @returns {void} - */ - private removeListenersFromAllFields(): void { - Object.values(this.fields).forEach((field) => { - this.removeListenersFromField(field); - }); - } - /** * @function removeListenersFromField * @description Removes listeners from a field @@ -212,11 +157,20 @@ export class FormElements extends BaseComponent { * @param field */ private removeListenersFromField(field: Field): void { + field.removeEventListener('input', this.handleInput); 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(IOFieldEvents.INPUT + fieldId, this.updateFieldContent(fieldId)); this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldColor(fieldId)); this.room.off(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor(fieldId)); @@ -242,6 +196,7 @@ export class FormElements extends BaseComponent { this.addListenersToField(field); this.addRealtimeListenersToField(fieldId); + this.fieldsOriginalOutline[fieldId] = field.style.outline; } /** @@ -250,8 +205,11 @@ export class FormElements extends BaseComponent { * @returns {void} */ private deregisterAllFields() { - this.fields = {}; - this.removeListenersFromAllFields(); + Object.keys(this.fields).forEach((fieldId) => { + this.deregisterField(fieldId); + }); + + this.fields = undefined; } /** @@ -265,15 +223,20 @@ export class FormElements extends BaseComponent { this.throwError.onDeregisterInvalidField(fieldId); } - console.log('deregistering field', fieldId); this.removeListenersFromField(this.fields[fieldId]); this.removeRealtimeListenersFromField(fieldId); + this.fields[fieldId].style.outline = this.fieldsOriginalOutline[fieldId]; this.fields[fieldId] = undefined; } // ------- callbacks ------- - private handleInputEvent = (event: any) => { - console.log('on input'); + /** + * @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: any) => { const target = event.target as HTMLInputElement; const payload: Payload = { content: target.value, @@ -283,7 +246,13 @@ export class FormElements extends BaseComponent { this.room?.emit(IOFieldEvents.INPUT + target.id, payload); }; - private handleFocus = (event: Event) => { + /** + * @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: Event): void => { const target = event.target as HTMLInputElement; const payload: Payload = { color: this.localParticipant.slot.color, @@ -292,6 +261,12 @@ export class FormElements extends BaseComponent { this.room?.emit(IOFieldEvents.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: Event) => { const target = event.target as HTMLInputElement; this.room?.emit(IOFieldEvents.BLUR + target.id, {}); @@ -335,7 +310,7 @@ export class FormElements extends BaseComponent { * @returns {void} */ private validateFieldType(field: Field): void { - if (field.tagName !== 'input') return; + if (field.tagName.toLowerCase() !== 'input') return; const inputType = field.getAttribute('type'); const hasValidInputType = this.allowedInputTypes.includes(inputType); @@ -353,4 +328,97 @@ export class FormElements extends BaseComponent { this.throwError.onFieldNotFound(fieldId); } } + + // ------- realtime callbacks ------- + /** + * @function removeFieldColor + * @description Resets the outline of a field to its original value + * @param {string} fieldId The id of the field that will have its outline reseted + * @returns {RealtimeCallback} A function that will be called when the event is triggered + */ + private removeFieldColor = (fieldId: string): RealtimeCallback => { + return ({ presence }: 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 {string} fieldId The id of the field that will have its outline changed + * @returns {RealtimeCallback} A function that will be called when the event is triggered + */ + private updateFieldColor = (fieldId: string): RealtimeCallback => { + return ({ presence, data: { color }, 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 {RealtimeCallback} A function that will be called when the event is triggered + */ + private updateFieldContent = (fieldId: string): RealtimeCallback => { + return ({ presence, data: { content } }: SocketEvent) => { + if (presence.id === this.localParticipant.id) return; + this.fields[fieldId].value = content; + }; + }; + + // ------- 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]; + }); + } } diff --git a/src/components/presence-input/types.ts b/src/components/form-elements/types.ts similarity index 84% rename from src/components/presence-input/types.ts rename to src/components/form-elements/types.ts index c6068aea..dc8d9276 100644 --- a/src/components/presence-input/types.ts +++ b/src/components/form-elements/types.ts @@ -1,3 +1,5 @@ +import { SocketEvent } from '@superviz/socket-client'; + export interface FormElementsProps { fields?: string[] | string; } @@ -28,3 +30,5 @@ export interface Focus { color: string; id: string; } + +export type RealtimeCallback = (data: SocketEvent) => void; diff --git a/src/components/presence-input/index.test.ts b/src/components/presence-input/index.test.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/services/stores/who-is-online/index.ts b/src/services/stores/who-is-online/index.ts deleted file mode 100644 index 4b93a0f2..00000000 --- a/src/services/stores/who-is-online/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Participant } from '../../../common/types/participant.types'; -import { Singleton } from '../common/types'; -import { CreateSingleton } from '../common/utils'; -import subject from '../subject'; - -const instance: Singleton = CreateSingleton(); - -export class WhoIsOnlineStore { - public participantHasJoinedPresence = subject(null); - - constructor() { - if (instance.value) { - throw new Error('CommentsStore is a singleton. There can only be one instance of it.'); - } - - instance.value = this; - } - - public destroy() { - this.participantHasJoinedPresence.destroy(); - instance.value = null; - } -} - -const store = new WhoIsOnlineStore(); -const participantHasJoinedPresence = store.participantHasJoinedPresence.expose(); -const destroy = store.destroy.bind(store); - -export function useWhoIsOnlineStore() { - return { - participantHasJoinedPresence, - destroy, - }; -} From c4c3402b4ba318523e1fc6403017e215f8e8c436 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Mon, 25 Mar 2024 13:39:33 -0300 Subject: [PATCH 06/30] fix: dont return arrow function to make possible to unsubscribe from event --- src/components/form-elements/index.test.ts | 182 ++++++++++----------- src/components/form-elements/index.ts | 148 +++++++++-------- src/components/form-elements/types.ts | 22 +-- 3 files changed, 179 insertions(+), 173 deletions(-) diff --git a/src/components/form-elements/index.test.ts b/src/components/form-elements/index.test.ts index 554c172e..48fbed31 100644 --- a/src/components/form-elements/index.test.ts +++ b/src/components/form-elements/index.test.ts @@ -97,12 +97,6 @@ describe('form elements', () => { instance['start'](); expect(spy).toHaveBeenCalledTimes(2); }); - - test('should call subscribeToRealtimeEvents', () => { - const spy = jest.spyOn(instance, 'subscribeToRealtimeEvents'); - instance['start'](); - expect(spy).toHaveBeenCalledTimes(1); - }); }); describe('destroy', () => { @@ -152,21 +146,29 @@ describe('form elements', () => { on: jest.fn(), } as any; - const updateContentSpy = jest.fn(); - const updateColorSpy = jest.fn(); - const removeColorSpy = jest.fn(); - - instance['updateFieldContent'] = jest.fn().mockReturnValue(updateContentSpy); - instance['updateFieldColor'] = jest.fn().mockReturnValue(updateColorSpy); - instance['removeFieldColor'] = jest.fn().mockReturnValue(removeColorSpy); + instance['updateFieldContent'] = jest.fn(); + instance['updateFieldColor'] = jest.fn(); + instance['removeFieldColor'] = jest.fn(); instance['addRealtimeListenersToField']('field-1'); expect(instance['room'].on).toHaveBeenCalledTimes(4); - expect(instance['room'].on).toHaveBeenCalledWith('field.inputfield-1', updateContentSpy); - expect(instance['room'].on).toHaveBeenCalledWith('field.inputfield-1', updateColorSpy); - expect(instance['room'].on).toHaveBeenCalledWith('field.focusfield-1', updateColorSpy); - expect(instance['room'].on).toHaveBeenCalledWith('field.blurfield-1', removeColorSpy); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.inputfield-1', + instance['updateFieldContent'], + ); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.inputfield-1', + instance['updateFieldColor'], + ); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.focusfield-1', + instance['updateFieldColor'], + ); + expect(instance['room'].on).toHaveBeenCalledWith( + 'field.blurfield-1', + instance['removeFieldColor'], + ); }); test('should not add realtime listeners if room is not defined', () => { @@ -201,24 +203,32 @@ describe('form elements', () => { off: jest.fn(), } as any; - const updateContentSpy = jest.fn(); - const updateColorSpy = jest.fn(); - const removeColorSpy = jest.fn(); - - instance['updateFieldContent'] = jest.fn().mockReturnValue(updateContentSpy); - instance['updateFieldColor'] = jest.fn().mockReturnValue(updateColorSpy); - instance['removeFieldColor'] = jest.fn().mockReturnValue(removeColorSpy); + instance['updateFieldContent'] = jest.fn(); + instance['updateFieldColor'] = jest.fn(); + instance['removeFieldColor'] = jest.fn(); instance['removeRealtimeListenersFromField']('field-1'); expect(instance['room'].off).toHaveBeenCalledTimes(4); expect(instance['room'].off).toHaveBeenNthCalledWith( 1, 'field.inputfield-1', - updateContentSpy, + instance['updateFieldContent'], + ); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 2, + 'field.inputfield-1', + instance['updateFieldColor'], + ); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 3, + 'field.focusfield-1', + instance['updateFieldColor'], + ); + expect(instance['room'].off).toHaveBeenNthCalledWith( + 4, + 'field.blurfield-1', + instance['removeFieldColor'], ); - expect(instance['room'].off).toHaveBeenNthCalledWith(2, 'field.inputfield-1', updateColorSpy); - expect(instance['room'].off).toHaveBeenNthCalledWith(3, 'field.focusfield-1', updateColorSpy); - expect(instance['room'].off).toHaveBeenNthCalledWith(4, 'field.blurfield-1', removeColorSpy); }); test('should not remove realtime listeners if room is not defined', () => { @@ -292,16 +302,6 @@ describe('form elements', () => { }); }); - /** private handleInput = (event: any) => { - const target = event.target as HTMLInputElement; - const payload: Payload = { - content: target.value, - color: this.localParticipant.slot.color, - }; - - this.room?.emit(IOFieldEvents.INPUT + target.id, payload); - }; - */ describe('handleInput', () => { test('should emit an event with the field content and local participant color', () => { instance['room'] = { @@ -321,15 +321,11 @@ describe('form elements', () => { expect(instance['room'].emit).toHaveBeenCalledWith('field.inputfield-1', { content: 'some value', color: 'red', + fieldId: 'field-1', }); }); }); - /** private handleFocus = (event: any) => { - const target = event.target as HTMLInputElement; - this.room?.emit(IOFieldEvents.FOCUS + target.id, this.localParticipant.slot.color); - }; - */ describe('handleFocus', () => { test('should emit an event with the local participant color', () => { instance['room'] = { @@ -346,15 +342,13 @@ describe('form elements', () => { instance['handleFocus'](event); expect(instance['room'].emit).toHaveBeenCalledTimes(1); - expect(instance['room'].emit).toHaveBeenCalledWith('field.focusfield-1', { color: 'red' }); + expect(instance['room'].emit).toHaveBeenCalledWith('field.focusfield-1', { + color: 'red', + fieldId: 'field-1', + }); }); }); - /** private handleBlur = (event: any) => { - const target = event.target as HTMLInputElement; - this.room?.emit(IOFieldEvents.BLUR + target.id); - }; - */ describe('handleBlur', () => { test('should emit an event', () => { instance['room'] = { @@ -368,7 +362,9 @@ describe('form elements', () => { instance['handleBlur'](event); expect(instance['room'].emit).toHaveBeenCalledTimes(1); - expect(instance['room'].emit).toHaveBeenCalledWith('field.blurfield-1', {}); + expect(instance['room'].emit).toHaveBeenCalledWith('field.blurfield-1', { + fieldId: 'field-1', + }); }); }); @@ -432,15 +428,6 @@ describe('form elements', () => { }); }); - /** private removeFieldColor = (fieldId: string): RealtimeCallback => { - return ({ presence }: SocketEvent) => { - if (this.focusList[fieldId]?.id !== presence.id || !this.fields[fieldId]) return; - - this.fields[fieldId].style.border = this.fieldsOriginalOutline[fieldId]; - delete this.focusList[fieldId]; - }; - }; */ - describe('removeFieldColor', () => { test('should remove field color', () => { const field = document.getElementById('field-1') as HTMLInputElement; @@ -449,9 +436,7 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['focusList'] = { 'field-1': { id: '123' } }; - const callback = instance['removeFieldColor']('field-1'); - - callback({ presence: { 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); @@ -466,9 +451,7 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['focusList'] = { 'field-1': { id: '321' } }; - const callback = instance['removeFieldColor']('field-1'); - - callback({ presence: { id: '123' } }); + 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'); @@ -477,9 +460,8 @@ describe('form elements', () => { }); test('should not remove field color if field is not registered', () => { - const callback = instance['removeFieldColor']('field-1'); instance['fieldsOriginalOutline']['field-1'] = 'some value'; - callback({ presence: { id: '123' } }); + instance['removeFieldColor']({ presence: { id: '123' }, data: { fieldId: 'field-1' } }); expect(instance['fieldsOriginalOutline']['field-1']).toBe('some value'); }); @@ -495,9 +477,11 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['focusList'] = {}; - const callback = instance['updateFieldColor']('field-1'); - - callback({ presence: { id: '123' }, data: { color: 'red' }, timestamp: 1000 }); + 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({ @@ -513,9 +497,11 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['focusList'] = { 'field-1': { id: '123', lastInteraction: 0 } }; - const callback = instance['updateFieldColor']('field-1'); - - callback({ presence: { id: '321' }, data: { color: 'red' }, timestamp: 5000 }); + 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({ @@ -531,9 +517,11 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['focusList'] = { 'field-1': { id: '123', firstInteraction: 0 } }; - const callback = instance['updateFieldColor']('field-1'); - - callback({ presence: { id: '321' }, data: { color: 'red' }, timestamp: 15000 }); + 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({ @@ -551,9 +539,11 @@ describe('form elements', () => { 'field-1': { color: 'red', id: '123', firstInteraction: 0, lastInteraction: 0 }, }; - const callback = instance['updateFieldColor']('field-1'); - - callback({ presence: { id: '123' }, data: { color: 'red' }, timestamp: 5000 }); + instance['updateFieldColor']({ + presence: { id: '123' }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 5000, + }); expect(instance['focusList']['field-1']).toEqual({ id: '123', @@ -570,11 +560,9 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['focusList'] = {}; - const callback = instance['updateFieldColor']('field-1'); - - callback({ + instance['updateFieldColor']({ presence: { id: MOCK_LOCAL_PARTICIPANT.id }, - data: { color: 'red' }, + data: { color: 'red', fieldId: 'field-1' }, timestamp: 5000, }); @@ -597,9 +585,11 @@ describe('form elements', () => { 'field-1': { id: '123', color: 'blue', firstInteraction: 0, lastInteraction: 0 }, }; - const callback = instance['updateFieldColor']('field-1'); - - callback({ presence: { id: '321' }, data: { color: 'red' }, timestamp: 500 }); + instance['updateFieldColor']({ + presence: { id: '321' }, + data: { color: 'red', fieldId: 'field-1' }, + timestamp: 500, + }); expect(field.style.outline).toBe('1px solid red'); }); @@ -614,11 +604,9 @@ describe('form elements', () => { 'field-1': { id: '123', color: 'blue', firstInteraction: 0, lastInteraction: 0 }, }; - const callback = instance['updateFieldColor']('field-1'); - - callback({ + instance['updateFieldColor']({ presence: { id: MOCK_LOCAL_PARTICIPANT.id }, - data: { color: 'red' }, + data: { color: 'red', fieldId: 'field-1' }, timestamp: 5000, }); @@ -633,9 +621,10 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['localParticipant'] = { id: '123' } as any; - const callback = instance['updateFieldContent']('field-1'); - - callback({ presence: { id: '321' }, data: { content: 'new content' } }); + instance['updateFieldContent']({ + presence: { id: '321' }, + data: { content: 'new content', fieldId: 'field-1' }, + }); expect(field.value).toBe('new content'); }); @@ -647,9 +636,10 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['localParticipant'] = { id: '123' } as any; - const callback = instance['updateFieldContent']('field-1'); - - callback({ presence: { id: '123' }, data: { content: 'new content' } }); + instance['updateFieldContent']({ + presence: { id: '123' }, + data: { content: 'new content', fieldId: 'field-1' }, + }); expect(field.value).toBe('old content'); }); diff --git a/src/components/form-elements/index.ts b/src/components/form-elements/index.ts index 22cf485d..90205198 100644 --- a/src/components/form-elements/index.ts +++ b/src/components/form-elements/index.ts @@ -6,7 +6,15 @@ import { Logger } from '../../common/utils'; import { BaseComponent } from '../base'; import { ComponentNames } from '../types'; -import { Field, Focus, IOFieldEvents, Payload, FormElementsProps, RealtimeCallback } from './types'; +import { + Field, + Focus, + IOFieldEvents, + InputPayload, + FormElementsProps, + FocusPayload, + BlurPayload, +} from './types'; export class FormElements extends BaseComponent { public name: ComponentNames; @@ -96,8 +104,6 @@ export class FormElements extends BaseComponent { Object.entries(this.fields).forEach(([fieldId]) => { this.registerField(fieldId); }); - - this.subscribeToRealtimeEvents(); } /** @@ -144,17 +150,18 @@ export class FormElements extends BaseComponent { */ private addRealtimeListenersToField(fieldId: string) { if (!this.room) return; - this.room.on(IOFieldEvents.INPUT + fieldId, this.updateFieldContent(fieldId)); - this.room.on(IOFieldEvents.INPUT + fieldId, this.updateFieldColor(fieldId)); - this.room.on(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor(fieldId)); - this.room.on(IOFieldEvents.BLUR + fieldId, this.removeFieldColor(fieldId)); + + this.room.on(IOFieldEvents.INPUT + fieldId, this.updateFieldContent); + this.room.on(IOFieldEvents.INPUT + fieldId, this.updateFieldColor); + this.room.on(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor); + this.room.on(IOFieldEvents.BLUR + fieldId, this.removeFieldColor); } /** * @function removeListenersFromField * @description Removes listeners from a field * @param {Field} field The field that will have the listeners removed - * @param field + * @returns {void} */ private removeListenersFromField(field: Field): void { field.removeEventListener('input', this.handleInput); @@ -171,10 +178,10 @@ export class FormElements extends BaseComponent { private removeRealtimeListenersFromField(fieldId: string) { if (!this.room) return; - this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldContent(fieldId)); - this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldColor(fieldId)); - this.room.off(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor(fieldId)); - this.room.off(IOFieldEvents.BLUR + fieldId, this.removeFieldColor(fieldId)); + this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldContent); + this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldColor); + this.room.off(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor); + this.room.off(IOFieldEvents.BLUR + fieldId, this.removeFieldColor); } // ------- register & deregister ------- @@ -236,11 +243,12 @@ export class FormElements extends BaseComponent { * @param {Event} event The event that triggered the function * @returns {void} */ - private handleInput = (event: any) => { + private handleInput = (event: InputEvent) => { const target = event.target as HTMLInputElement; - const payload: Payload = { + const payload: InputPayload & FocusPayload = { content: target.value, color: this.localParticipant.slot.color, + fieldId: target.id, }; this.room?.emit(IOFieldEvents.INPUT + target.id, payload); @@ -252,10 +260,11 @@ export class FormElements extends BaseComponent { * @param {Event} event The event that triggered the function * @returns {void} */ - private handleFocus = (event: Event): void => { + private handleFocus = (event: InputEvent): void => { const target = event.target as HTMLInputElement; - const payload: Payload = { + const payload: FocusPayload = { color: this.localParticipant.slot.color, + fieldId: target.id, }; this.room?.emit(IOFieldEvents.FOCUS + target.id, payload); @@ -267,9 +276,13 @@ export class FormElements extends BaseComponent { * @param {Event} event The event that triggered the function * @returns {void} */ - private handleBlur = (event: Event) => { + private handleBlur = (event: InputEvent) => { const target = event.target as HTMLInputElement; - this.room?.emit(IOFieldEvents.BLUR + target.id, {}); + const payload: BlurPayload = { + fieldId: target.id, + }; + + this.room?.emit(IOFieldEvents.BLUR + target.id, payload); }; // ------- validations ------- @@ -333,56 +346,34 @@ export class FormElements extends BaseComponent { /** * @function removeFieldColor * @description Resets the outline of a field to its original value - * @param {string} fieldId The id of the field that will have its outline reseted - * @returns {RealtimeCallback} A function that will be called when the event is triggered + * @param {SocketEvent} event The payload from the event + * @returns {void} A function that will be called when the event is triggered */ - private removeFieldColor = (fieldId: string): RealtimeCallback => { - return ({ presence }: SocketEvent) => { - if (this.focusList[fieldId]?.id !== presence.id || !this.fields[fieldId]) return; + 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]; - }; + 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 {string} fieldId The id of the field that will have its outline changed - * @returns {RealtimeCallback} A function that will be called when the event is triggered + * @param {SocketEvent} event The payload from the event + * @returns {void} A function that will be called when the event is triggered */ - private updateFieldColor = (fieldId: string): RealtimeCallback => { - return ({ presence, data: { color }, 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; - } + private updateFieldColor = ({ + presence, + data: { color, fieldId }, + timestamp, + }: SocketEvent) => { + const participantInFocus = this.focusList[fieldId] ?? ({} as Focus); - const stoppedInteracting = timestamp - participantInFocus.lastInteraction >= 3000; // ms; - const gainedFocusLongAgo = timestamp - participantInFocus.firstInteraction >= 10000; // ms; - const changeInputBorderColor = stoppedInteracting || gainedFocusLongAgo || thereIsNoFocus; - - if (!changeInputBorderColor) return; + const thereIsNoFocus = !participantInFocus.id; + const localParticipantEmittedEvent = presence.id === this.localParticipant.id; + if (thereIsNoFocus && localParticipantEmittedEvent) { this.focusList[fieldId] = { id: presence.id, color, @@ -390,11 +381,33 @@ export class FormElements extends BaseComponent { lastInteraction: timestamp, }; - const outline = localParticipantEmittedEvent - ? this.fieldsOriginalOutline[fieldId] - : `1px solid ${color}`; - this.fields[fieldId].style.outline = outline; + 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; }; /** @@ -403,11 +416,12 @@ export class FormElements extends BaseComponent { * @param {string} fieldId The id of the field that will have its content updated * @returns {RealtimeCallback} A function that will be called when the event is triggered */ - private updateFieldContent = (fieldId: string): RealtimeCallback => { - return ({ presence, data: { content } }: SocketEvent) => { - if (presence.id === this.localParticipant.id) return; - this.fields[fieldId].value = content; - }; + private updateFieldContent = ({ + presence, + data: { content, fieldId }, + }: SocketEvent) => { + if (presence.id === this.localParticipant.id) return; + this.fields[fieldId].value = content; }; // ------- utils ------- diff --git a/src/components/form-elements/types.ts b/src/components/form-elements/types.ts index dc8d9276..2680d927 100644 --- a/src/components/form-elements/types.ts +++ b/src/components/form-elements/types.ts @@ -12,23 +12,25 @@ export enum IOFieldEvents { FOCUS = 'field.focus', } -// https://w3c.github.io/input-events/#interface-InputEvent-Attributes -export enum InputEvent { - INSERT_TEXT = 'insertText', - DELETE_CONTENT_BACKWARD = 'deleteContentBackward', - DELETE_CONTENT_FORWARD = 'deleteContentForward', +export interface InputPayload { + content: string | null; + fieldId: string; } -export interface Payload { - content?: string | null; +export interface FocusPayload { color: string; + fieldId: string; } -export interface Focus { +export type Focus = { firstInteraction: number; lastInteraction: number; - color: string; id: string; -} + color: string; +}; export type RealtimeCallback = (data: SocketEvent) => void; + +export type BlurPayload = { + fieldId: string; +}; From a26ea895355566bcf3f191a7e743b8366259c409 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Wed, 27 Mar 2024 07:05:10 -0300 Subject: [PATCH 07/30] feat: add features to form elements component --- src/components/form-elements/index.ts | 269 ++++++++++++++++++++++---- src/components/form-elements/types.ts | 22 ++- 2 files changed, 250 insertions(+), 41 deletions(-) diff --git a/src/components/form-elements/index.ts b/src/components/form-elements/index.ts index 90205198..80662f92 100644 --- a/src/components/form-elements/index.ts +++ b/src/components/form-elements/index.ts @@ -9,13 +9,17 @@ import { ComponentNames } from '../types'; import { Field, Focus, - IOFieldEvents, + FieldEvents, InputPayload, FormElementsProps, FocusPayload, BlurPayload, + Flags, } from './types'; +// não synchar (flag pra desativar de todos no começo, métodos pra desativar ou ativar de 1, métodos pra ativar ou desativar de todos) +// emitir evento quando pessoa escrever + export class FormElements extends BaseComponent { public name: ComponentNames; protected logger: Logger; @@ -23,13 +27,35 @@ export class FormElements extends BaseComponent { // HTML Elements private fields: Record = {}; - private fieldsOriginalOutline: Record = {}; + private focusList: Record = {}; + private enabledOutlineFields: Record = {}; + private enabledRealtimeSynchFields: 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']; + 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) => { @@ -63,14 +89,14 @@ export class FormElements extends BaseComponent { }, }; - private focusList: Record = {}; - constructor(props: FormElementsProps = {}) { super(); this.name = ComponentNames.PRESENCE; this.logger = new Logger('@superviz/sdk/presence-input-component'); - const { fields } = props; + const { fields, flags } = props; + + this.flags = flags ?? {}; if (typeof fields === 'string') { this.validateField(fields); @@ -116,17 +142,7 @@ export class FormElements extends BaseComponent { this.deregisterAllFields(); this.fieldsOriginalOutline = undefined; - } - - /** - * @function subscribeToRealtimeEvents - * @description Subscribes all fields to realtime events - * @returns {void} - */ - private subscribeToRealtimeEvents(): void { - Object.entries(this.fields).forEach(([fieldId]) => { - this.addRealtimeListenersToField(fieldId); - }); + this.focusList = undefined; } // ------- listeners ------- @@ -137,7 +153,16 @@ export class FormElements extends BaseComponent { * @returns {void} */ private addListenersToField(field: Field): void { - field.addEventListener('input', this.handleInput); + 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); } @@ -151,10 +176,16 @@ export class FormElements extends BaseComponent { private addRealtimeListenersToField(fieldId: string) { if (!this.room) return; - this.room.on(IOFieldEvents.INPUT + fieldId, this.updateFieldContent); - this.room.on(IOFieldEvents.INPUT + fieldId, this.updateFieldColor); - this.room.on(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor); - this.room.on(IOFieldEvents.BLUR + fieldId, this.removeFieldColor); + this.room.on(FieldEvents.INPUT + fieldId, this.updateFieldContent); + this.room.on( + FieldEvents.KEYBOARD_INTERACTION + fieldId, + this.publishKeyboardInteraction, + ); + + if (this.flags.disableOutline) return; + + this.room.on(FieldEvents.FOCUS + fieldId, this.updateFieldColor); + this.room.on(FieldEvents.BLUR + fieldId, this.removeFieldColor); } /** @@ -164,7 +195,14 @@ export class FormElements extends BaseComponent { * @returns {void} */ private removeListenersFromField(field: Field): void { - field.removeEventListener('input', this.handleInput); + 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); } @@ -178,10 +216,16 @@ export class FormElements extends BaseComponent { private removeRealtimeListenersFromField(fieldId: string) { if (!this.room) return; - this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldContent); - this.room.off(IOFieldEvents.INPUT + fieldId, this.updateFieldColor); - this.room.off(IOFieldEvents.FOCUS + fieldId, this.updateFieldColor); - this.room.off(IOFieldEvents.BLUR + fieldId, this.removeFieldColor); + this.room.off(FieldEvents.INPUT + fieldId, this.updateFieldContent); + this.room.off( + FieldEvents.KEYBOARD_INTERACTION + fieldId, + this.publishKeyboardInteraction, + ); + + if (this.flags.disableOutline) return; + + this.room.off(FieldEvents.FOCUS + fieldId, this.updateFieldColor); + this.room.off(FieldEvents.BLUR + fieldId, this.removeFieldColor); } // ------- register & deregister ------- @@ -234,6 +278,15 @@ export class FormElements extends BaseComponent { this.removeRealtimeListenersFromField(fieldId); this.fields[fieldId].style.outline = this.fieldsOriginalOutline[fieldId]; this.fields[fieldId] = undefined; + + if (this.enabledOutlineFields[fieldId] === undefined) return; + + delete this.enabledOutlineFields[fieldId]; + delete this.enabledRealtimeSynchFields[fieldId]; + delete this.fieldsOriginalOutline[fieldId]; + delete this.focusList[fieldId]; + + this.room?.emit(FieldEvents.BLUR + fieldId, { fieldId }); } // ------- callbacks ------- @@ -245,13 +298,49 @@ export class FormElements extends BaseComponent { */ 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 canSynch = this.canSynchContent(target.id); + if (!canSynch) return; + const payload: InputPayload & FocusPayload = { - content: target.value, + value: target.value, color: this.localParticipant.slot.color, fieldId: target.id, + showOutline: this.canUpdateColor(target.id), + synchContent: canSynch, + 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 canSynch = this.canSynchContent(target.id); + if (!canSynch) return; + + const payload: InputPayload & FocusPayload = { + fieldId: target.id, + value: target.checked, + color: this.localParticipant.slot.color, + showOutline: this.canUpdateColor(target.id), + synchContent: this.canSynchContent(target.id), + attribute: 'checked', }; - this.room?.emit(IOFieldEvents.INPUT + target.id, payload); + this.room?.emit(FieldEvents.INPUT + target.id, payload); }; /** @@ -267,7 +356,7 @@ export class FormElements extends BaseComponent { fieldId: target.id, }; - this.room?.emit(IOFieldEvents.FOCUS + target.id, payload); + this.room?.emit(FieldEvents.FOCUS + target.id, payload); }; /** @@ -282,7 +371,7 @@ export class FormElements extends BaseComponent { fieldId: target.id, }; - this.room?.emit(IOFieldEvents.BLUR + target.id, payload); + this.room?.emit(FieldEvents.BLUR + target.id, payload); }; // ------- validations ------- @@ -361,7 +450,7 @@ export class FormElements extends BaseComponent { * @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} A function that will be called when the event is triggered + * @returns {void} */ private updateFieldColor = ({ presence, @@ -414,14 +503,44 @@ export class FormElements extends BaseComponent { * @function updateFieldContent * @description Updates the content of a field * @param {string} fieldId The id of the field that will have its content updated - * @returns {RealtimeCallback} A function that will be called when the event is triggered + * @returns {void} */ private updateFieldContent = ({ presence, - data: { content, fieldId }, + data: { value, fieldId, color, showOutline, synchContent, attribute }, + timestamp, + ...params }: SocketEvent) => { if (presence.id === this.localParticipant.id) return; - this.fields[fieldId].value = content; + + this.publish(`${FieldEvents.INPUT}-${fieldId}`, { + value, + fieldId, + attribute, + userId: presence.id, + userName: presence.name, + timestamp, + }); + + if (synchContent && this.canSynchContent(fieldId)) this.fields[fieldId][attribute] = value; + + if (showOutline && this.canUpdateColor(fieldId)) { + this.updateFieldColor({ presence, data: { color, fieldId }, timestamp, ...params }); + } + }; + + private publishKeyboardInteraction = ({ + presence, + data: { fieldId, color }, + }: SocketEvent) => { + if (presence.id === this.localParticipant.id) return; + + this.publish(`${FieldEvents.KEYBOARD_INTERACTION}-${fieldId}`, { + fieldId, + userId: presence.id, + userName: presence.name, + color, + }); }; // ------- utils ------- @@ -435,4 +554,84 @@ export class FormElements extends BaseComponent { 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 canSynchContent(fieldId: string): boolean { + return ( + (!this.flags.disableRealtimeSynch && this.enabledRealtimeSynchFields[fieldId] !== false) || + this.enabledRealtimeSynchFields[fieldId] + ); + } + + // ---------------------------- + 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); + } + + 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 }); + } + + public enableRealtimeSynch(fieldId: string): void { + this.enabledRealtimeSynchFields[fieldId] = true; + } + + public disableRealtimeSynch(fieldId: string): void { + this.enabledRealtimeSynchFields[fieldId] = false; + } + + public synch = (fieldId: string): void => { + const field = this.fields[fieldId]; + + const value = this.hasCheckedProperty(field) + ? (field as HTMLInputElement).checked + : field.value; + + const payload: InputPayload & FocusPayload = { + value, + color: this.localParticipant.slot.color, + fieldId, + showOutline: this.canUpdateColor(fieldId), + synchContent: this.canSynchContent(fieldId), + attribute: 'value', + }; + + this.room?.emit(FieldEvents.INPUT + fieldId, payload); + }; } diff --git a/src/components/form-elements/types.ts b/src/components/form-elements/types.ts index 2680d927..b1a6ff52 100644 --- a/src/components/form-elements/types.ts +++ b/src/components/form-elements/types.ts @@ -2,19 +2,22 @@ import { SocketEvent } from '@superviz/socket-client'; export interface FormElementsProps { fields?: string[] | string; + flags?: Flags; } +export type Flags = { + disableOutline?: boolean; + disableRealtimeSynch?: boolean; +}; + export type Field = HTMLInputElement | HTMLTextAreaElement; -export enum IOFieldEvents { +export enum FieldEvents { INPUT = 'field.input', BLUR = 'field.blur', FOCUS = 'field.focus', -} - -export interface InputPayload { - content: string | null; - fieldId: string; + CHANGE = 'field.change', + KEYBOARD_INTERACTION = 'field.keyboard-interaction', } export interface FocusPayload { @@ -22,6 +25,13 @@ export interface FocusPayload { fieldId: string; } +export type InputPayload = { + value: string | boolean; + showOutline: boolean; + synchContent: boolean; + attribute: 'value' | 'checked'; +} & FocusPayload; + export type Focus = { firstInteraction: number; lastInteraction: number; From 7dd8fbd2ea36cf896b06695f2f7b80d0dea37451 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Mon, 1 Apr 2024 07:27:57 -0300 Subject: [PATCH 08/30] feat: create public methods for form elements component --- src/components/form-elements/index.test.ts | 497 +++++++++++++++++++-- src/components/form-elements/index.ts | 293 ++++++------ src/components/form-elements/types.ts | 4 +- src/services/realtime/ably/index.ts | 2 - 4 files changed, 633 insertions(+), 163 deletions(-) diff --git a/src/components/form-elements/index.test.ts b/src/components/form-elements/index.test.ts index 48fbed31..a5cf516c 100644 --- a/src/components/form-elements/index.test.ts +++ b/src/components/form-elements/index.test.ts @@ -6,6 +6,8 @@ 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', () => { @@ -16,7 +18,7 @@ describe('form elements', () => { - + `; @@ -80,7 +82,7 @@ describe('form elements', () => { }); test('should throw error if trying to register input with invalid type', () => { - const fields = () => new FormElements({ fields: ['date'] }); + const fields = () => new FormElements({ fields: ['hidden'] }); expect(fields).toThrowError(); }); }); @@ -119,15 +121,6 @@ describe('form elements', () => { }); }); - describe('subscribeToRealtimeEvents', () => { - test('should call addRealtimeListenersToField for each field in fields', () => { - instance = new FormElements({ fields: ['field-1', 'field-2', 'field-3'] }); - const spy = jest.spyOn(instance, 'addRealtimeListenersToField'); - instance['subscribeToRealtimeEvents'](); - expect(spy).toHaveBeenCalledTimes(3); - }); - }); - describe('addListenersToField', () => { test('should add event listeners to the field', () => { const field = document.getElementById('field-1') as HTMLInputElement; @@ -138,6 +131,32 @@ describe('form elements', () => { 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', () => { @@ -149,6 +168,7 @@ describe('form elements', () => { instance['updateFieldContent'] = jest.fn(); instance['updateFieldColor'] = jest.fn(); instance['removeFieldColor'] = jest.fn(); + instance['flags'].disableOutline = false; instance['addRealtimeListenersToField']('field-1'); @@ -158,8 +178,8 @@ describe('form elements', () => { instance['updateFieldContent'], ); expect(instance['room'].on).toHaveBeenCalledWith( - 'field.inputfield-1', - instance['updateFieldColor'], + 'field.keyboard-interactionfield-1', + instance['publishTypedEvent'], ); expect(instance['room'].on).toHaveBeenCalledWith( 'field.focusfield-1', @@ -171,6 +191,29 @@ describe('form elements', () => { ); }); + 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(); @@ -195,6 +238,32 @@ describe('form elements', () => { 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', () => { @@ -207,6 +276,8 @@ describe('form elements', () => { 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( @@ -216,8 +287,8 @@ describe('form elements', () => { ); expect(instance['room'].off).toHaveBeenNthCalledWith( 2, - 'field.inputfield-1', - instance['updateFieldColor'], + 'field.keyboard-interactionfield-1', + instance['publishTypedEvent'], ); expect(instance['room'].off).toHaveBeenNthCalledWith( 3, @@ -243,6 +314,31 @@ describe('form elements', () => { 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', () => { @@ -300,6 +396,23 @@ describe('form elements', () => { 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', () => { @@ -317,13 +430,93 @@ describe('form elements', () => { 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', { - content: 'some value', + 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', () => { @@ -398,7 +591,7 @@ describe('form elements', () => { describe('validateFieldType', () => { test('should throw error if input type is not allowed', () => { - const field = document.getElementById('date') as HTMLInputElement; + const field = document.getElementById('hidden') as HTMLInputElement; const validate = () => instance['validateFieldType'](field); expect(validate).toThrowError(); }); @@ -465,6 +658,17 @@ describe('form elements', () => { 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', () => { @@ -621,9 +825,16 @@ describe('form elements', () => { instance['fields'] = { 'field-1': field }; instance['localParticipant'] = { id: '123' } as any; + instance['flags'].disableRealtimeSync = false; + instance['updateFieldContent']({ presence: { id: '321' }, - data: { content: 'new content', fieldId: 'field-1' }, + data: { + value: 'new content', + fieldId: 'field-1', + syncContent: true, + attribute: 'value', + }, }); expect(field.value).toBe('new content'); @@ -644,21 +855,249 @@ describe('form elements', () => { expect(field.value).toBe('old content'); }); - 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'; + 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}`, + { + 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['fields'] = { 'field-1': field1, 'field-2': field2 }; - instance['fieldsOriginalOutline'] = { 'field-1': 'old-outline', 'field-2': 'old-outline' }; + instance['disableOutline']('field-1'); - instance['restoreOutlines'](); + expect(instance['fields']['field-1'].style.outline).toBe(''); + }); + }); - expect(field1.style.outline).toBe('old-outline'); - expect(field2.style.outline).toBe('old-outline'); + 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 index 80662f92..258c588c 100644 --- a/src/components/form-elements/index.ts +++ b/src/components/form-elements/index.ts @@ -17,9 +17,6 @@ import { Flags, } from './types'; -// não synchar (flag pra desativar de todos no começo, métodos pra desativar ou ativar de 1, métodos pra ativar ou desativar de todos) -// emitir evento quando pessoa escrever - export class FormElements extends BaseComponent { public name: ComponentNames; protected logger: Logger; @@ -30,7 +27,7 @@ export class FormElements extends BaseComponent { private fieldsOriginalOutline: Record = {}; private focusList: Record = {}; private enabledOutlineFields: Record = {}; - private enabledRealtimeSynchFields: Record = {}; + private enabledRealtimeSyncFields: Record = {}; // Flags private flags: Flags = {}; @@ -117,6 +114,154 @@ export class FormElements extends BaseComponent { } } + // ------- 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 @@ -177,10 +322,7 @@ export class FormElements extends BaseComponent { if (!this.room) return; this.room.on(FieldEvents.INPUT + fieldId, this.updateFieldContent); - this.room.on( - FieldEvents.KEYBOARD_INTERACTION + fieldId, - this.publishKeyboardInteraction, - ); + this.room.on(FieldEvents.KEYBOARD_INTERACTION + fieldId, this.publishTypedEvent); if (this.flags.disableOutline) return; @@ -217,10 +359,7 @@ export class FormElements extends BaseComponent { if (!this.room) return; this.room.off(FieldEvents.INPUT + fieldId, this.updateFieldContent); - this.room.off( - FieldEvents.KEYBOARD_INTERACTION + fieldId, - this.publishKeyboardInteraction, - ); + this.room.off(FieldEvents.KEYBOARD_INTERACTION + fieldId, this.publishTypedEvent); if (this.flags.disableOutline) return; @@ -229,27 +368,6 @@ export class FormElements extends BaseComponent { } // ------- register & deregister ------- - /** - * @function registerField - * @description Registers an element; usually, something that serves a text field. - - 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 (or, at the very least, a field with the same id). - - 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 deregisterAllFields * @description Deregisters an element. No interactions with the field will be shared after this. @@ -263,32 +381,6 @@ export class FormElements extends BaseComponent { this.fields = undefined; } - /** - * @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; - - if (this.enabledOutlineFields[fieldId] === undefined) return; - - delete this.enabledOutlineFields[fieldId]; - delete this.enabledRealtimeSynchFields[fieldId]; - delete this.fieldsOriginalOutline[fieldId]; - delete this.focusList[fieldId]; - - this.room?.emit(FieldEvents.BLUR + fieldId, { fieldId }); - } - // ------- callbacks ------- /** * @function handleInput @@ -304,15 +396,15 @@ export class FormElements extends BaseComponent { color: this.localParticipant.slot.color, }); - const canSynch = this.canSynchContent(target.id); - if (!canSynch) return; + 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), - synchContent: canSynch, + syncContent: canSync, attribute: 'value', }; @@ -328,15 +420,15 @@ export class FormElements extends BaseComponent { private handleChange = (event: Event): void => { const target = event.target as HTMLInputElement; - const canSynch = this.canSynchContent(target.id); - if (!canSynch) return; + 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), - synchContent: this.canSynchContent(target.id), + syncContent: this.canSyncContent(target.id), attribute: 'checked', }; @@ -507,7 +599,7 @@ export class FormElements extends BaseComponent { */ private updateFieldContent = ({ presence, - data: { value, fieldId, color, showOutline, synchContent, attribute }, + data: { value, fieldId, color, showOutline, syncContent, attribute }, timestamp, ...params }: SocketEvent) => { @@ -522,14 +614,14 @@ export class FormElements extends BaseComponent { timestamp, }); - if (synchContent && this.canSynchContent(fieldId)) this.fields[fieldId][attribute] = value; + if (syncContent && this.canSyncContent(fieldId)) this.fields[fieldId][attribute] = value; if (showOutline && this.canUpdateColor(fieldId)) { this.updateFieldColor({ presence, data: { color, fieldId }, timestamp, ...params }); } }; - private publishKeyboardInteraction = ({ + private publishTypedEvent = ({ presence, data: { fieldId, color }, }: SocketEvent) => { @@ -569,69 +661,10 @@ export class FormElements extends BaseComponent { ); } - private canSynchContent(fieldId: string): boolean { + private canSyncContent(fieldId: string): boolean { return ( - (!this.flags.disableRealtimeSynch && this.enabledRealtimeSynchFields[fieldId] !== false) || - this.enabledRealtimeSynchFields[fieldId] + (!this.flags.disableRealtimeSync && this.enabledRealtimeSyncFields[fieldId] !== false) || + this.enabledRealtimeSyncFields[fieldId] ); } - - // ---------------------------- - 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); - } - - 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 }); - } - - public enableRealtimeSynch(fieldId: string): void { - this.enabledRealtimeSynchFields[fieldId] = true; - } - - public disableRealtimeSynch(fieldId: string): void { - this.enabledRealtimeSynchFields[fieldId] = false; - } - - public synch = (fieldId: string): void => { - const field = this.fields[fieldId]; - - const value = this.hasCheckedProperty(field) - ? (field as HTMLInputElement).checked - : field.value; - - const payload: InputPayload & FocusPayload = { - value, - color: this.localParticipant.slot.color, - fieldId, - showOutline: this.canUpdateColor(fieldId), - synchContent: this.canSynchContent(fieldId), - attribute: 'value', - }; - - this.room?.emit(FieldEvents.INPUT + fieldId, payload); - }; } diff --git a/src/components/form-elements/types.ts b/src/components/form-elements/types.ts index b1a6ff52..e351ea3e 100644 --- a/src/components/form-elements/types.ts +++ b/src/components/form-elements/types.ts @@ -7,7 +7,7 @@ export interface FormElementsProps { export type Flags = { disableOutline?: boolean; - disableRealtimeSynch?: boolean; + disableRealtimeSync?: boolean; }; export type Field = HTMLInputElement | HTMLTextAreaElement; @@ -28,7 +28,7 @@ export interface FocusPayload { export type InputPayload = { value: string | boolean; showOutline: boolean; - synchContent: boolean; + syncContent: boolean; attribute: 'value' | 'checked'; } & FocusPayload; diff --git a/src/services/realtime/ably/index.ts b/src/services/realtime/ably/index.ts index 47fa6303..549bd3d6 100644 --- a/src/services/realtime/ably/index.ts +++ b/src/services/realtime/ably/index.ts @@ -2,13 +2,11 @@ import Ably from 'ably'; import throttle from 'lodash/throttle'; import { RealtimeEvent, TranscriptState } from '../../../common/types/events.types'; -import { Nullable } from '../../../common/types/global.types'; import { MeetingColors } from '../../../common/types/meeting-colors.types'; import { Participant, ParticipantType } from '../../../common/types/participant.types'; import { RealtimeStateTypes } from '../../../common/types/realtime.types'; import { Annotation } from '../../../components/comments/types'; import { ParticipantMouse } from '../../../components/presence-mouse/types'; -import { ComponentNames } from '../../../components/types'; import { DrawingData } from '../../video-conference-manager/types'; import { RealtimeService } from '../base'; import { ParticipantInfo, StartRealtimeType } from '../base/types'; From e5f52d79edaf864fc19395569a22a80bcdbd6dab Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Wed, 10 Apr 2024 21:59:41 -0300 Subject: [PATCH 09/30] fix: hide mouse if participant goes out of container --- src/components/presence-mouse/html/index.test.ts | 16 ++++++++-------- src/components/presence-mouse/html/index.ts | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/presence-mouse/html/index.test.ts b/src/components/presence-mouse/html/index.test.ts index 2b2a3d06..710ad63a 100644 --- a/src/components/presence-mouse/html/index.test.ts +++ b/src/components/presence-mouse/html/index.test.ts @@ -458,16 +458,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 +476,8 @@ describe('MousePointers on HTML', () => { updatePresenceMouseSpy.mockClear(); const mouseEvent2 = { - x: 5, - y: 5, + x: 30, + y: 40, } as any; presenceMouseComponent['onMyParticipantMouseLeave'](mouseEvent2); diff --git a/src/components/presence-mouse/html/index.ts b/src/components/presence-mouse/html/index.ts index 4a17da03..549afcf9 100644 --- a/src/components/presence-mouse/html/index.ts +++ b/src/components/presence-mouse/html/index.ts @@ -191,8 +191,8 @@ 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(); + if (event.x > left && event.y > top && event.x < right && event.y < bottom) return; this.room.presence.update({ visible: false }); }; From a47357fa3263b403b1f1575d5351678984d76b20 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Thu, 11 Apr 2024 09:00:15 -0300 Subject: [PATCH 10/30] fix: hide mouse when it goes out of screen --- src/components/presence-mouse/html/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/presence-mouse/html/index.ts b/src/components/presence-mouse/html/index.ts index 549afcf9..e1e82410 100644 --- a/src/components/presence-mouse/html/index.ts +++ b/src/components/presence-mouse/html/index.ts @@ -192,7 +192,12 @@ export class PointersHTML extends BaseComponent { */ private onMyParticipantMouseLeave = (event: MouseEvent): void => { const { left, top, right, bottom } = this.container.getBoundingClientRect(); - if (event.x > left && event.y > top && event.x < right && event.y < bottom) return; + 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 }); }; From fb1f6a06498bf6287fefdba570940edf42c7b019 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Thu, 11 Apr 2024 11:40:38 -0300 Subject: [PATCH 11/30] fix: remove flags attribute from constructor --- src/components/form-elements/index.ts | 2 +- src/components/form-elements/types.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/form-elements/index.ts b/src/components/form-elements/index.ts index 258c588c..9faf0e82 100644 --- a/src/components/form-elements/index.ts +++ b/src/components/form-elements/index.ts @@ -91,7 +91,7 @@ export class FormElements extends BaseComponent { this.name = ComponentNames.PRESENCE; this.logger = new Logger('@superviz/sdk/presence-input-component'); - const { fields, flags } = props; + const { fields, ...flags } = props; this.flags = flags ?? {}; diff --git a/src/components/form-elements/types.ts b/src/components/form-elements/types.ts index e351ea3e..c627427c 100644 --- a/src/components/form-elements/types.ts +++ b/src/components/form-elements/types.ts @@ -1,9 +1,8 @@ import { SocketEvent } from '@superviz/socket-client'; -export interface FormElementsProps { +export type FormElementsProps = { fields?: string[] | string; - flags?: Flags; -} +} & Flags; export type Flags = { disableOutline?: boolean; From 2ad793e7f39feb879cfa15a305967817506c97ab Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Thu, 11 Apr 2024 13:50:26 -0300 Subject: [PATCH 12/30] fix: change form elements this.name --- src/components/form-elements/index.test.ts | 2 +- src/components/form-elements/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/form-elements/index.test.ts b/src/components/form-elements/index.test.ts index a5cf516c..db0d13e4 100644 --- a/src/components/form-elements/index.test.ts +++ b/src/components/form-elements/index.test.ts @@ -39,7 +39,7 @@ describe('form elements', () => { }); test('should set the name of the component', () => { - expect(instance.name).toBe(ComponentNames.PRESENCE); + expect(instance.name).toBe(ComponentNames.FORM_ELEMENTS); }); test('should set the logger', () => { diff --git a/src/components/form-elements/index.ts b/src/components/form-elements/index.ts index 9faf0e82..443fae2f 100644 --- a/src/components/form-elements/index.ts +++ b/src/components/form-elements/index.ts @@ -88,7 +88,7 @@ export class FormElements extends BaseComponent { constructor(props: FormElementsProps = {}) { super(); - this.name = ComponentNames.PRESENCE; + this.name = ComponentNames.FORM_ELEMENTS; this.logger = new Logger('@superviz/sdk/presence-input-component'); const { fields, ...flags } = props; From 8cb85d8c9fd3aecbe2062f71cac4d957085d1b32 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Thu, 11 Apr 2024 22:33:50 -0300 Subject: [PATCH 13/30] feat: sync the current translate and scale in the mouse canvas --- .../presence-mouse/canvas/index.test.ts | 29 ++++++++++++- src/components/presence-mouse/canvas/index.ts | 43 +++++++++++++------ .../presence-mouse/html/index.test.ts | 7 ++- src/components/presence-mouse/types.ts | 7 +++ 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/components/presence-mouse/canvas/index.test.ts b/src/components/presence-mouse/canvas/index.test.ts index b2a01cfa..8a6cc97a 100644 --- a/src/components/presence-mouse/canvas/index.test.ts +++ b/src/components/presence-mouse/canvas/index.test.ts @@ -21,6 +21,11 @@ const MOCK_MOUSE: ParticipantMouse = { timestamp: 1710448079918, }, visible: true, + camera: { + x: 0, + y: 0, + scale: 1, + }, }; const participant1 = { ...MOCK_MOUSE }; @@ -90,6 +95,11 @@ 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, + }; const presenceContainerId = document.createElement('div'); presenceMouseComponent['containerId'] = 'container'; @@ -100,6 +110,11 @@ describe('MousePointers on Canvas', () => { expect(spy).toHaveBeenCalledWith({ ...MOCK_LOCAL_PARTICIPANT, + camera: { + x: 0, + y: 0, + scale: 1, + }, x: event.x, y: event.y, visible: true, @@ -206,6 +221,11 @@ describe('MousePointers on Canvas', () => { timestamp: 1710448079918, }, visible: true, + camera: { + x: 0, + y: 0, + scale: 1, + }, }; presenceMouseComponent['onPresenceLeftRoom']({ @@ -234,6 +254,11 @@ describe('MousePointers on Canvas', () => { timestamp: 1710448079918, }, visible: true, + camera: { + x: 0, + y: 0, + scale: 1, + }, }; const participant2 = MOCK_MOUSE; @@ -267,8 +292,8 @@ 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, }); }); }); diff --git a/src/components/presence-mouse/canvas/index.ts b/src/components/presence-mouse/canvas/index.ts index 12963c50..f6bd734b 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; @@ -21,6 +21,11 @@ export class PointersCanvas extends BaseComponent { private isPrivate: boolean; private localParticipant: Participant; private transformation: Transform = { translate: { x: 0, y: 0 }, scale: 1 }; + private camera: Camera = { + x: 0, + y: 0, + scale: 1, + }; constructor(canvasId: string, options?: PresenceMouseProps) { super(); @@ -42,6 +47,7 @@ export class PointersCanvas extends BaseComponent { const { localParticipant } = this.useStore(StoreType.GLOBAL); localParticipant.subscribe(); + this.getCamera(); } /** @@ -165,6 +171,7 @@ export class PointersCanvas extends BaseComponent { * @returns {void} */ private animate = (): void => { + this.getCamera(); this.renderDivWrapper(); this.updateParticipantsMouses(); @@ -181,18 +188,7 @@ 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 }); + if (this.goToMouseCallback) this.goToMouseCallback({ x: mouse.camera.x, y: mouse.camera.y }); }; /** Presence Mouse Events */ @@ -221,6 +217,7 @@ export class PointersCanvas extends BaseComponent { ...this.localParticipant, ...coordinates, visible: !this.isPrivate, + camera: this.camera, }); }, 30); @@ -271,6 +268,26 @@ 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 = { + x: currentTranslateX, + y: currentTranslateY, + scale: currentScale, + }; + }; + /** * @function renderPresenceMouses * @description add presence mouses to screen diff --git a/src/components/presence-mouse/html/index.test.ts b/src/components/presence-mouse/html/index.test.ts index 710ad63a..1e699d26 100644 --- a/src/components/presence-mouse/html/index.test.ts +++ b/src/components/presence-mouse/html/index.test.ts @@ -43,6 +43,11 @@ describe('MousePointers on HTML', () => { timestamp: 1710448079918, }, visible: true, + camera: { + x: 0, + y: 0, + scale: 1, + }, }; const participant1 = { ...MOCK_MOUSE }; @@ -461,7 +466,7 @@ describe('MousePointers on HTML', () => { left: 10, right: 100, top: 20, - bottom: 90 + bottom: 90, } as any), ); diff --git a/src/components/presence-mouse/types.ts b/src/components/presence-mouse/types.ts index d21752f8..98fc990a 100644 --- a/src/components/presence-mouse/types.ts +++ b/src/components/presence-mouse/types.ts @@ -4,6 +4,13 @@ export interface ParticipantMouse extends Participant { x: number; y: number; visible: boolean; + camera: Camera; +} + +export interface Camera { + x: number; + y: number; + scale: number; } export interface Transform { From 416b70bfb508a40fdc71155b7074c9f5cee2c3ae Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Mon, 15 Apr 2024 15:09:59 -0300 Subject: [PATCH 14/30] feat: send scale to client to calc the right zoom --- .../presence-mouse/canvas/index.test.ts | 22 ++++++++++ src/components/presence-mouse/canvas/index.ts | 40 ++++++++++++++----- .../presence-mouse/html/index.test.ts | 4 ++ src/components/presence-mouse/types.ts | 13 +++++- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/components/presence-mouse/canvas/index.test.ts b/src/components/presence-mouse/canvas/index.test.ts index 8a6cc97a..17c14612 100644 --- a/src/components/presence-mouse/canvas/index.test.ts +++ b/src/components/presence-mouse/canvas/index.test.ts @@ -24,6 +24,10 @@ const MOCK_MOUSE: ParticipantMouse = { camera: { x: 0, y: 0, + screen: { + width: 1920, + height: 1080, + }, scale: 1, }, }; @@ -99,6 +103,10 @@ describe('MousePointers on Canvas', () => { x: 0, y: 0, scale: 1, + screen: { + width: 1920, + height: 1080, + }, }; const presenceContainerId = document.createElement('div'); @@ -114,6 +122,10 @@ describe('MousePointers on Canvas', () => { x: 0, y: 0, scale: 1, + screen: { + width: 1920, + height: 1080, + }, }, x: event.x, y: event.y, @@ -225,6 +237,10 @@ describe('MousePointers on Canvas', () => { x: 0, y: 0, scale: 1, + screen: { + width: 1920, + height: 1080, + }, }, }; @@ -258,6 +274,10 @@ describe('MousePointers on Canvas', () => { x: 0, y: 0, scale: 1, + screen: { + width: 1920, + height: 1080, + }, }, }; @@ -294,6 +314,8 @@ describe('MousePointers on Canvas', () => { expect(presenceMouseComponent['goToMouseCallback']).toHaveBeenCalledWith({ 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 f6bd734b..2d4169f1 100644 --- a/src/components/presence-mouse/canvas/index.ts +++ b/src/components/presence-mouse/canvas/index.ts @@ -20,10 +20,13 @@ 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, }; @@ -188,7 +191,20 @@ export class PointersCanvas extends BaseComponent { if (!mouse) return; - if (this.goToMouseCallback) this.goToMouseCallback({ x: mouse.camera.x, y: mouse.camera.y }); + 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 */ @@ -209,8 +225,8 @@ 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({ @@ -274,7 +290,7 @@ export class PointersCanvas extends BaseComponent { * The camera information includes the current translation (x, y) and scale. */ private getCamera = () => { - const context = this.canvas.getContext('2d'); + const context = this.canvas?.getContext('2d'); const transform = context?.getTransform(); const currentTranslateX = transform?.e; @@ -282,6 +298,10 @@ export class PointersCanvas extends BaseComponent { const currentScale = transform?.a; this.camera = { + screen: { + width: this.divWrapper.clientHeight, + height: this.divWrapper.clientWidth, + }, x: currentTranslateX, y: currentTranslateY, scale: currentScale, @@ -325,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; @@ -393,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 only available for HTML component.'); } } diff --git a/src/components/presence-mouse/html/index.test.ts b/src/components/presence-mouse/html/index.test.ts index 1e699d26..92ed6422 100644 --- a/src/components/presence-mouse/html/index.test.ts +++ b/src/components/presence-mouse/html/index.test.ts @@ -47,6 +47,10 @@ describe('MousePointers on HTML', () => { x: 0, y: 0, scale: 1, + screen: { + width: 1920, + height: 1080, + }, }, }; diff --git a/src/components/presence-mouse/types.ts b/src/components/presence-mouse/types.ts index 98fc990a..a0e11a1b 100644 --- a/src/components/presence-mouse/types.ts +++ b/src/components/presence-mouse/types.ts @@ -10,6 +10,10 @@ export interface ParticipantMouse extends Participant { export interface Camera { x: number; y: number; + screen: { + width: number; + height: number; + }; scale: number; } @@ -21,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; }; } From c8add12bb4b1aa94f187af0ba7b3d8f1df0dd808 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Mon, 15 Apr 2024 15:20:18 -0300 Subject: [PATCH 15/30] chore(test): add jest canvas mock --- jest.setup.js | 1 + package.json | 1 + yarn.lock | 22 +++++++++++++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/jest.setup.js b/jest.setup.js index 3d92fb96..0dee5043 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,4 +1,5 @@ const fs = require('fs'); +require('jest-canvas-mock'); const { MOCK_CONFIG } = require('./__mocks__/config.mock'); const config = require('./src/services/config'); 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/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" From ce42f0c36531b6b50310b5468be2059a4029db24 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Wed, 17 Apr 2024 14:37:11 -0300 Subject: [PATCH 16/30] fix: canvas warning --- src/components/presence-mouse/canvas/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/presence-mouse/canvas/index.ts b/src/components/presence-mouse/canvas/index.ts index 2d4169f1..9968c0cb 100644 --- a/src/components/presence-mouse/canvas/index.ts +++ b/src/components/presence-mouse/canvas/index.ts @@ -413,6 +413,6 @@ export class PointersCanvas extends BaseComponent { * @param {Transform} transformation Which transformations to apply */ public transform(transformation: Transform) { - console.warn('[SuperViz] - Transform only available for HTML component.'); + console.warn('[SuperViz] - transform method not available when container is a canvas element.'); } } From ddd5caee6c68f0017a2966d8ee249963386e93b0 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Wed, 17 Apr 2024 14:37:56 -0300 Subject: [PATCH 17/30] feat: sync the current translate and scale in the mouse html --- .../presence-mouse/html/index.test.ts | 13 ++++- src/components/presence-mouse/html/index.ts | 52 +++++++++++++++---- src/components/presence-mouse/types.ts | 4 +- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/components/presence-mouse/html/index.test.ts b/src/components/presence-mouse/html/index.test.ts index 92ed6422..29aa3b09 100644 --- a/src/components/presence-mouse/html/index.test.ts +++ b/src/components/presence-mouse/html/index.test.ts @@ -430,7 +430,16 @@ describe('MousePointers on HTML', () => { x: 30, y: 30, visible: true, - }); + camera: { + scale: 1, + x: 10, + y: 10, + screen: { + height: 0, + width: 0, + }, + }, + } as ParticipantMouse); }); test('should not call room.presence.update if isPrivate', () => { @@ -648,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 e1e82410..2dcf0795 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, }); }; @@ -209,17 +222,30 @@ 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 translatedX = presence.camera.x; + const translatedY = presence.camera.y; + const screenScaleX = this.container.clientHeight / presence.camera.screen.height; + const scaleToAllowVisibilityX = Math.min(screenScaleX, 2); + const screenScaleY = this.container.clientWidth / presence.camera.screen.width; + const scaleToAllowVisibilityY = Math.min(screenScaleY, 2); + + this.goToPresenceCallback({ + x: translatedX, + y: translatedY, + scaleX: scaleToAllowVisibilityX, + scaleY: scaleToAllowVisibilityY, + }); - this.goToPresenceCallback({ x, y }); return; } + if (!pointer) return; + pointer.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); }; @@ -502,6 +528,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.container.clientWidth, + height: this.container.clientHeight, + }, + }; + this.updateParticipantsMouses(true); } @@ -599,11 +635,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]) { diff --git a/src/components/presence-mouse/types.ts b/src/components/presence-mouse/types.ts index a0e11a1b..1d6e428a 100644 --- a/src/components/presence-mouse/types.ts +++ b/src/components/presence-mouse/types.ts @@ -28,8 +28,8 @@ export interface Transform { export interface Position { x: number; y: number; - scaleX?: number; - scaleY?: number; + scaleX: number; + scaleY: number; } export interface PresenceMouseProps { From 003270b127a092f9598f11fde7f2cbd06406e018 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Wed, 17 Apr 2024 19:54:54 -0300 Subject: [PATCH 18/30] fix: use wrapper size instead of container --- src/components/presence-mouse/html/index.test.ts | 4 ++-- src/components/presence-mouse/html/index.ts | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/presence-mouse/html/index.test.ts b/src/components/presence-mouse/html/index.test.ts index 29aa3b09..9dde4d02 100644 --- a/src/components/presence-mouse/html/index.test.ts +++ b/src/components/presence-mouse/html/index.test.ts @@ -435,8 +435,8 @@ describe('MousePointers on HTML', () => { x: 10, y: 10, screen: { - height: 0, - width: 0, + height: 1, + width: 1, }, }, } as ParticipantMouse); diff --git a/src/components/presence-mouse/html/index.ts b/src/components/presence-mouse/html/index.ts index 2dcf0795..9d20f960 100644 --- a/src/components/presence-mouse/html/index.ts +++ b/src/components/presence-mouse/html/index.ts @@ -229,9 +229,9 @@ export class PointersHTML extends BaseComponent { if (this.goToPresenceCallback) { const translatedX = presence.camera.x; const translatedY = presence.camera.y; - const screenScaleX = this.container.clientHeight / presence.camera.screen.height; + const screenScaleX = this.wrapper.clientHeight / presence.camera.screen.height; const scaleToAllowVisibilityX = Math.min(screenScaleX, 2); - const screenScaleY = this.container.clientWidth / presence.camera.screen.width; + const screenScaleY = this.wrapper.clientWidth / presence.camera.screen.width; const scaleToAllowVisibilityY = Math.min(screenScaleY, 2); this.goToPresenceCallback({ @@ -533,8 +533,8 @@ export class PointersHTML extends BaseComponent { y: transformation.translate.y, scale: transformation.scale, screen: { - width: this.container.clientWidth, - height: this.container.clientHeight, + width: this.wrapper?.clientWidth || 1, + height: this.wrapper?.clientHeight || 1, }, }; @@ -738,10 +738,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`; From 561461f031afcda6cda95163b57014e72288b637 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Wed, 17 Apr 2024 19:57:26 -0300 Subject: [PATCH 19/30] chore(release-config): disable release notes automatic generation --- .releaserc | 1 - 1 file changed, 1 deletion(-) 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" ] From 07aab275621de387f0edda22e1aa2db4c47a34d1 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Wed, 17 Apr 2024 20:24:49 -0300 Subject: [PATCH 20/30] chore(test-runner): mock io library --- __mocks__/io.mock.ts | 22 ++++++++++++++-------- jest.setup.js | 4 ++++ src/services/io/index.test.ts | 2 -- 3 files changed, 18 insertions(+), 10 deletions(-) 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 0dee5043..ff4045e5 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,7 +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); @@ -33,3 +35,5 @@ global.DOMPoint = class { return this; } }; + +jest.mock('@superviz/socket-client', () => MOCK_IO); 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; From ca7665187db60f3ea7075845fddb53b5233bf22d Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Thu, 18 Apr 2024 10:07:16 -0300 Subject: [PATCH 21/30] fix: add scale factor to goto presence calcs --- src/components/presence-mouse/html/index.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/presence-mouse/html/index.ts b/src/components/presence-mouse/html/index.ts index 9d20f960..e0929f7e 100644 --- a/src/components/presence-mouse/html/index.ts +++ b/src/components/presence-mouse/html/index.ts @@ -227,18 +227,20 @@ export class PointersHTML extends BaseComponent { if (!presence) return; if (this.goToPresenceCallback) { - const translatedX = presence.camera.x; - const translatedY = presence.camera.y; - const screenScaleX = this.wrapper.clientHeight / presence.camera.screen.height; - const scaleToAllowVisibilityX = Math.min(screenScaleX, 2); - const screenScaleY = this.wrapper.clientWidth / presence.camera.screen.width; - const scaleToAllowVisibilityY = Math.min(screenScaleY, 2); + 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: scaleToAllowVisibilityX, - scaleY: scaleToAllowVisibilityY, + scaleX: screenScaleX, + scaleY: screenScaleY, }); return; From b5489b172b5f8cb0bd9abdbd59a126745ea1581c Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Thu, 18 Apr 2024 16:06:52 -0300 Subject: [PATCH 22/30] fix: destroy every element of stores --- src/services/stores/global/index.ts | 2 ++ src/services/stores/who-is-online/index.ts | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) 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; } } From 451cf675ca182e3902050161f42bac44c357db8f Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Thu, 18 Apr 2024 16:08:15 -0300 Subject: [PATCH 23/30] feat: improve type and suggestions of stores --- src/common/types/stores.types.ts | 17 +++++--- src/common/utils/use-store.ts | 9 ++-- src/components/base/index.ts | 1 + src/components/comments/index.ts | 2 +- src/components/form-elements/index.ts | 4 +- src/components/who-is-online/index.ts | 43 ++++++++++--------- .../comments/components/annotation-pin.ts | 2 +- .../who-is-online/components/dropdown.test.ts | 26 +++++------ .../who-is-online/who-is-online.test.ts | 2 +- .../who-is-online/who-is-online.ts | 10 ++--- 10 files changed, 62 insertions(+), 54 deletions(-) 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..19afc101 100644 --- a/src/common/utils/use-store.ts +++ b/src/common/utils/use-store.ts @@ -38,21 +38,20 @@ 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) { 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 a21afeba..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; 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.ts b/src/components/form-elements/index.ts index 443fae2f..bfd898be 100644 --- a/src/components/form-elements/index.ts +++ b/src/components/form-elements/index.ts @@ -605,7 +605,7 @@ export class FormElements extends BaseComponent { }: SocketEvent) => { if (presence.id === this.localParticipant.id) return; - this.publish(`${FieldEvents.INPUT}-${fieldId}`, { + this.publish(FieldEvents.INPUT, { value, fieldId, attribute, @@ -627,7 +627,7 @@ export class FormElements extends BaseComponent { }: SocketEvent) => { if (presence.id === this.localParticipant.id) return; - this.publish(`${FieldEvents.KEYBOARD_INTERACTION}-${fieldId}`, { + this.publish(FieldEvents.KEYBOARD_INTERACTION, { fieldId, userId: presence.id, userName: presence.name, 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/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, From 5e22c2a983d1e168c912a1e9af9b68ed9b0e9d60 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Thu, 18 Apr 2024 16:25:12 -0300 Subject: [PATCH 24/30] fix: return destroy method in store --- src/common/utils/use-store.ts | 2 ++ src/components/form-elements/index.test.ts | 2 +- src/components/who-is-online/index.test.ts | 25 +++++++++++++--------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/common/utils/use-store.ts b/src/common/utils/use-store.ts index 19afc101..26796a1c 100644 --- a/src/common/utils/use-store.ts +++ b/src/common/utils/use-store.ts @@ -43,6 +43,8 @@ export function useStore(name: T): Store { const proxy = new Proxy(storeData, { get(store, valueName: string) { + if (valueName === 'destroy') return store.destroy; + return { subscribe(callback?) { bindedSubscribeTo(valueName, store[valueName], callback); diff --git a/src/components/form-elements/index.test.ts b/src/components/form-elements/index.test.ts index db0d13e4..a62a5efd 100644 --- a/src/components/form-elements/index.test.ts +++ b/src/components/form-elements/index.test.ts @@ -907,7 +907,7 @@ describe('form elements', () => { instance['publishTypedEvent']({ presence, data }); expect(instance['publish']).toHaveBeenCalledWith( - `${FieldEvents.KEYBOARD_INTERACTION}-${fieldId}`, + FieldEvents.KEYBOARD_INTERACTION, { fieldId, userId: '123', 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', From 60e3e9f724963eb56bceaecf4d60c55ccc3c17ad Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Fri, 26 Apr 2024 08:12:20 -0300 Subject: [PATCH 25/30] fix: change facade type --- src/core/launcher/index.ts | 4 ++-- src/core/launcher/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/launcher/index.ts b/src/core/launcher/index.ts index f37d71fe..4daa15a6 100644 --- a/src/core/launcher/index.ts +++ b/src/core/launcher/index.ts @@ -79,7 +79,7 @@ export class Launcher extends Observable implements DefaultLauncher { * @param component - component to add * @returns {void} */ - public addComponent = (component: any): void => { + public addComponent = (component: Partial): void => { if (!this.canAddComponent(component)) return; if (!this.realtime.isJoinedRoom) { @@ -133,7 +133,7 @@ export class Launcher extends Observable implements DefaultLauncher { * @param component - component to remove * @returns {void} */ - public removeComponent = (component: any): void => { + public removeComponent = (component: Partial): void => { if (!this.activeComponents.includes(component.name)) { const message = `Component ${component.name} is not initialized yet.`; this.logger.log(message); diff --git a/src/core/launcher/types.ts b/src/core/launcher/types.ts index 6052caf4..2a2edd14 100644 --- a/src/core/launcher/types.ts +++ b/src/core/launcher/types.ts @@ -11,6 +11,6 @@ export interface LauncherFacade { subscribe: typeof Observable.prototype.subscribe; unsubscribe: typeof Observable.prototype.unsubscribe; destroy: () => void; - addComponent: (component: Partial) => void; - removeComponent: (component: Partial) => void; + addComponent: (component: any) => void; + removeComponent: (component: any) => void; } From 663dd188ee988304e288431690419eb5807bd073 Mon Sep 17 00:00:00 2001 From: Ian Silva Date: Tue, 7 May 2024 09:24:01 -0300 Subject: [PATCH 26/30] feat: change event and emit event to local participant --- src/components/form-elements/index.test.ts | 95 +++++++++++----------- src/components/form-elements/index.ts | 44 +++++----- src/components/form-elements/types.ts | 5 +- 3 files changed, 72 insertions(+), 72 deletions(-) diff --git a/src/components/form-elements/index.test.ts b/src/components/form-elements/index.test.ts index a62a5efd..a046b999 100644 --- a/src/components/form-elements/index.test.ts +++ b/src/components/form-elements/index.test.ts @@ -2,6 +2,7 @@ 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 { StoreType } from '../../common/types/stores.types'; import { useStore } from '../../common/utils/use-store'; import { IOC } from '../../services/io'; import { ComponentNames } from '../types'; @@ -26,7 +27,7 @@ describe('form elements', () => { instance.attach({ ioc: new IOC(MOCK_LOCAL_PARTICIPANT), - realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: true }), + realtime: Object.assign({}, ABLY_REALTIME_MOCK, { hasJoinedRoom: true }), config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, useStore, @@ -174,19 +175,19 @@ describe('form elements', () => { expect(instance['room'].on).toHaveBeenCalledTimes(4); expect(instance['room'].on).toHaveBeenCalledWith( - 'field.inputfield-1', + `${FieldEvents.CONTENT_CHANGE}field-1`, instance['updateFieldContent'], ); expect(instance['room'].on).toHaveBeenCalledWith( - 'field.keyboard-interactionfield-1', + `${FieldEvents.INTERACTION}field-1`, instance['publishTypedEvent'], ); expect(instance['room'].on).toHaveBeenCalledWith( - 'field.focusfield-1', + `${FieldEvents.FOCUS}field-1`, instance['updateFieldColor'], ); expect(instance['room'].on).toHaveBeenCalledWith( - 'field.blurfield-1', + `${FieldEvents.BLUR}field-1`, instance['removeFieldColor'], ); }); @@ -205,11 +206,11 @@ describe('form elements', () => { expect(instance['room'].on).toHaveBeenCalledTimes(2); expect(instance['room'].on).toHaveBeenCalledWith( - 'field.inputfield-1', + `${FieldEvents.CONTENT_CHANGE}field-1`, instance['updateFieldContent'], ); expect(instance['room'].on).toHaveBeenCalledWith( - 'field.keyboard-interactionfield-1', + `${FieldEvents.INTERACTION}field-1`, instance['publishTypedEvent'], ); }); @@ -282,22 +283,22 @@ describe('form elements', () => { expect(instance['room'].off).toHaveBeenCalledTimes(4); expect(instance['room'].off).toHaveBeenNthCalledWith( 1, - 'field.inputfield-1', + `${FieldEvents.CONTENT_CHANGE}field-1`, instance['updateFieldContent'], ); expect(instance['room'].off).toHaveBeenNthCalledWith( 2, - 'field.keyboard-interactionfield-1', + `${FieldEvents.INTERACTION}field-1`, instance['publishTypedEvent'], ); expect(instance['room'].off).toHaveBeenNthCalledWith( 3, - 'field.focusfield-1', + `${FieldEvents.FOCUS}field-1`, instance['updateFieldColor'], ); expect(instance['room'].off).toHaveBeenNthCalledWith( 4, - 'field.blurfield-1', + `${FieldEvents.BLUR}field-1`, instance['removeFieldColor'], ); }); @@ -330,12 +331,12 @@ describe('form elements', () => { expect(instance['room'].off).toHaveBeenCalledTimes(2); expect(instance['room'].off).toHaveBeenNthCalledWith( 1, - 'field.inputfield-1', + `${FieldEvents.CONTENT_CHANGE}field-1`, instance['updateFieldContent'], ); expect(instance['room'].off).toHaveBeenNthCalledWith( 2, - 'field.keyboard-interactionfield-1', + `${FieldEvents.INTERACTION}field-1`, instance['publishTypedEvent'], ); }); @@ -409,7 +410,7 @@ describe('form elements', () => { instance['deregisterField']('field-1'); expect(instance['room'].emit).toHaveBeenCalledTimes(1); - expect(instance['room'].emit).toHaveBeenCalledWith('field.blurfield-1', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.BLUR}field-1`, { fieldId: 'field-1', }); }); @@ -432,12 +433,12 @@ describe('form elements', () => { expect(instance['room'].emit).toHaveBeenCalledTimes(2); - expect(instance['room'].emit).toHaveBeenCalledWith('field.keyboard-interactionfield-1', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.INTERACTION}field-1`, { fieldId: 'field-1', color: 'red', }); - expect(instance['room'].emit).toHaveBeenCalledWith('field.inputfield-1', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.CONTENT_CHANGE}field-1`, { value: 'some value', color: 'red', fieldId: 'field-1', @@ -465,7 +466,7 @@ describe('form elements', () => { expect(instance['room'].emit).toHaveBeenCalledTimes(1); - expect(instance['room'].emit).toHaveBeenCalledWith('field.keyboard-interactionfield-1', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.INTERACTION}field-1`, { fieldId: 'field-1', color: 'red', }); @@ -489,7 +490,7 @@ describe('form elements', () => { expect(instance['room'].emit).toHaveBeenCalledTimes(1); - expect(instance['room'].emit).toHaveBeenCalledWith('field.inputfield-1', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.CONTENT_CHANGE}field-1`, { value: true, color: 'red', fieldId: 'field-1', @@ -535,7 +536,7 @@ describe('form elements', () => { instance['handleFocus'](event); expect(instance['room'].emit).toHaveBeenCalledTimes(1); - expect(instance['room'].emit).toHaveBeenCalledWith('field.focusfield-1', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.FOCUS}field-1`, { color: 'red', fieldId: 'field-1', }); @@ -555,7 +556,7 @@ describe('form elements', () => { instance['handleBlur'](event); expect(instance['room'].emit).toHaveBeenCalledTimes(1); - expect(instance['room'].emit).toHaveBeenCalledWith('field.blurfield-1', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.BLUR}field-1`, { fieldId: 'field-1', }); }); @@ -906,28 +907,12 @@ describe('form elements', () => { 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(); + expect(instance['publish']).toHaveBeenCalledWith(FieldEvents.INTERACTION, { + fieldId, + userId: '123', + userName: undefined, + color, + }); }); }); @@ -1045,9 +1030,18 @@ describe('form elements', () => { instance['hasCheckedProperty'] = jest.fn().mockReturnValue(false); instance['sync']('field-1'); - expect(instance['room'].emit).toHaveBeenCalledTimes(1); + expect(instance['room'].emit).toHaveBeenCalledTimes(2); + + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.CONTENT_CHANGE}field-1`, { + value: 'some value', + color: 'red', + fieldId: 'field-1', + showOutline: true, + syncContent: true, + attribute: 'value', + }); - expect(instance['room'].emit).toHaveBeenCalledWith('field.inputfield-1', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.INTERACTION}field-1`, { value: 'some value', color: 'red', fieldId: 'field-1', @@ -1072,9 +1066,18 @@ describe('form elements', () => { instance['hasCheckedProperty'] = jest.fn().mockReturnValue(true); instance['sync']('checkbox'); - expect(instance['room'].emit).toHaveBeenCalledTimes(1); + expect(instance['room'].emit).toHaveBeenCalledTimes(2); + + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.CONTENT_CHANGE}checkbox`, { + value: true, + color: 'red', + fieldId: 'checkbox', + showOutline: true, + syncContent: true, + attribute: 'checked', + }); - expect(instance['room'].emit).toHaveBeenCalledWith('field.inputcheckbox', { + expect(instance['room'].emit).toHaveBeenCalledWith(`${FieldEvents.INTERACTION}checkbox`, { value: true, color: 'red', fieldId: 'checkbox', diff --git a/src/components/form-elements/index.ts b/src/components/form-elements/index.ts index bfd898be..8b470498 100644 --- a/src/components/form-elements/index.ts +++ b/src/components/form-elements/index.ts @@ -214,7 +214,8 @@ export class FormElements extends BaseComponent { attribute: hasCheckedProperty ? 'checked' : 'value', }; - this.room?.emit(FieldEvents.INPUT + fieldId, payload); + this.room?.emit(FieldEvents.CONTENT_CHANGE + fieldId, payload); + this.room?.emit(FieldEvents.INTERACTION + fieldId, payload); }; /** @@ -321,8 +322,8 @@ export class FormElements extends BaseComponent { 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); + this.room.on(FieldEvents.CONTENT_CHANGE + fieldId, this.updateFieldContent); + this.room.on(FieldEvents.INTERACTION + fieldId, this.publishTypedEvent); if (this.flags.disableOutline) return; @@ -358,8 +359,8 @@ export class FormElements extends BaseComponent { 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); + this.room.off(FieldEvents.CONTENT_CHANGE + fieldId, this.updateFieldContent); + this.room.off(FieldEvents.INTERACTION + fieldId, this.publishTypedEvent); if (this.flags.disableOutline) return; @@ -391,7 +392,7 @@ export class FormElements extends BaseComponent { private handleInput = (event: InputEvent) => { const target = event.target as HTMLInputElement; - this.room?.emit(FieldEvents.KEYBOARD_INTERACTION + target.id, { + this.room?.emit(FieldEvents.INTERACTION + target.id, { fieldId: target.id, color: this.localParticipant.slot.color, }); @@ -408,7 +409,7 @@ export class FormElements extends BaseComponent { attribute: 'value', }; - this.room?.emit(FieldEvents.INPUT + target.id, payload); + this.room?.emit(FieldEvents.CONTENT_CHANGE + target.id, payload); }; /** @@ -432,7 +433,7 @@ export class FormElements extends BaseComponent { attribute: 'checked', }; - this.room?.emit(FieldEvents.INPUT + target.id, payload); + this.room?.emit(FieldEvents.CONTENT_CHANGE + target.id, payload); }; /** @@ -603,18 +604,17 @@ export class FormElements extends BaseComponent { 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 (syncContent && this.canSyncContent(fieldId)) { + this.fields[fieldId][attribute] = value; + this.publish(FieldEvents.CONTENT_CHANGE, { + value, + fieldId, + attribute, + userId: presence.id, + userName: presence.name, + timestamp, + }); + } if (showOutline && this.canUpdateColor(fieldId)) { this.updateFieldColor({ presence, data: { color, fieldId }, timestamp, ...params }); @@ -625,9 +625,7 @@ export class FormElements extends BaseComponent { presence, data: { fieldId, color }, }: SocketEvent) => { - if (presence.id === this.localParticipant.id) return; - - this.publish(FieldEvents.KEYBOARD_INTERACTION, { + this.publish(FieldEvents.INTERACTION, { fieldId, userId: presence.id, userName: presence.name, diff --git a/src/components/form-elements/types.ts b/src/components/form-elements/types.ts index c627427c..5d07277f 100644 --- a/src/components/form-elements/types.ts +++ b/src/components/form-elements/types.ts @@ -12,11 +12,10 @@ export type Flags = { 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', + CONTENT_CHANGE = 'field.content-change', + INTERACTION = 'field.interaction', } export interface FocusPayload { From 8eeeb0afd0e79f381c4c9b8146a395660a4589e9 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Tue, 7 May 2024 18:24:26 -0300 Subject: [PATCH 27/30] fix: esm build --- .esbuild/config.js | 2 -- .esbuild/watch.js | 1 - 2 files changed, 3 deletions(-) diff --git a/.esbuild/config.js b/.esbuild/config.js index 85c16c5b..55375571 100644 --- a/.esbuild/config.js +++ b/.esbuild/config.js @@ -1,6 +1,5 @@ require('dotenv').config(); const { style } = require('./plugins/style-loader'); -const { dependencies } = require('../package.json'); const entries = Object.entries(process.env).filter((key) => key[0].startsWith('SDK_')); const env = Object.fromEntries(entries); @@ -19,7 +18,6 @@ module.exports = { bundle: true, target: 'es6', format: 'esm', - external: Object.keys(dependencies), define: { 'process.env': JSON.stringify(env), }, diff --git a/.esbuild/watch.js b/.esbuild/watch.js index 45551a36..58659784 100644 --- a/.esbuild/watch.js +++ b/.esbuild/watch.js @@ -18,7 +18,6 @@ require('esbuild') require('esbuild') .build({ ...config, - platform: 'neutral', // for ESM format: 'esm', outfile: 'dist/index.esm.js', }) From dbc0ef75d23f009bbd7a1dc5d5150548a80a760e Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Tue, 7 May 2024 18:30:28 -0300 Subject: [PATCH 28/30] fix: esm build --- .esbuild/build.js | 13 ------------- .esbuild/watch.js | 12 ------------ package.json | 1 - 3 files changed, 26 deletions(-) diff --git a/.esbuild/build.js b/.esbuild/build.js index 5c6e8dbb..aa0a0fd1 100644 --- a/.esbuild/build.js +++ b/.esbuild/build.js @@ -8,22 +8,9 @@ const config = Object.assign({}, baseConfig, { require('esbuild') .build({ ...config, - platform: 'node', // for CJS outfile: 'lib/index.js', }) .catch((error) => { console.error(error); process.exit(1); }); - -require('esbuild') - .build({ - ...config, - platform: 'neutral', // for ESM - format: 'esm', - outfile: 'lib/index.esm.js', - }) - .catch((error) => { - console.error(error); - process.exit(1); - }); diff --git a/.esbuild/watch.js b/.esbuild/watch.js index 58659784..3f1748a0 100644 --- a/.esbuild/watch.js +++ b/.esbuild/watch.js @@ -7,21 +7,9 @@ const config = Object.assign({}, baseConfig, { require('esbuild') .build({ ...config, - platform: 'node', // for CJS outfile: 'dist/index.js', }) .catch((error) => { console.error(error); process.exit(1); }); - -require('esbuild') - .build({ - ...config, - format: 'esm', - outfile: 'dist/index.esm.js', - }) - .catch((error) => { - console.error(error); - process.exit(1); - }); diff --git a/package.json b/package.json index 8e501a22..f5277002 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.0.0-development", "description": "SuperViz SDK", "main": "./lib/index.js", - "module": "./lib/index.esm.js", "types": "./lib/index.d.ts", "files": [ "lib" From a991c71fe2542c13ae0af6035b66a7a39c1235ae Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Tue, 7 May 2024 18:40:23 -0300 Subject: [PATCH 29/30] fix: pin adapter type --- src/components/comments/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/comments/index.ts b/src/components/comments/index.ts index f53c4f04..872380d0 100644 --- a/src/components/comments/index.ts +++ b/src/components/comments/index.ts @@ -37,7 +37,7 @@ export class Comments extends BaseComponent { private localParticipantId: string; private offset: Offset; - constructor(pinAdapter: PinAdapter, options?: CommentsOptions) { + constructor(pinAdapter: any, options?: CommentsOptions) { super(); this.name = ComponentNames.COMMENTS; this.logger = new Logger('@superviz/sdk/comments-component'); From dd9662cc266934458f15b79d91d9a8e01863e50f Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Wed, 8 May 2024 13:11:14 -0300 Subject: [PATCH 30/30] fix: ensure that video frame is destroyed --- src/components/base/index.ts | 2 +- src/components/video/index.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 02e10aea..50d987a4 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -8,10 +8,10 @@ import config from '../../services/config'; import { EventBus } from '../../services/event-bus'; import { IOC } from '../../services/io'; import { AblyRealtimeService } from '../../services/realtime'; +import { useGlobalStore } from '../../services/stores'; import { ComponentNames } from '../types'; import { DefaultAttachComponentOptions } from './types'; -import { useGlobalStore } from '../../services/stores'; export abstract class BaseComponent extends Observable { public abstract name: ComponentNames; diff --git a/src/components/video/index.ts b/src/components/video/index.ts index ecf6fbc1..1d6cbac5 100644 --- a/src/components/video/index.ts +++ b/src/components/video/index.ts @@ -175,8 +175,8 @@ export class VideoConference extends BaseComponent { this.unsubscribeFromRealtimeEvents(); this.unsubscribeFromVideoEvents(); - this.videoManager.leave(); - this.connectionService.removeListeners(); + this.videoManager?.leave(); + this.connectionService?.removeListeners(); } /** @@ -253,6 +253,8 @@ export class VideoConference extends BaseComponent { * @returns {void} * */ private unsubscribeFromVideoEvents = (): void => { + if (!this.videoManager) return; + this.logger.log('video conference @ unsubscribe from video events'); this.videoManager.meetingConnectionObserver.unsubscribe( @@ -503,7 +505,15 @@ export class VideoConference extends BaseComponent { private onParticipantLeft = (_: Participant): void => { this.logger.log('video conference @ on participant left', this.localParticipant); + this.videoManager.leave(); + this.connectionService.removeListeners(); + this.publish(MeetingEvent.DESTROY); this.publish(MeetingEvent.MY_PARTICIPANT_LEFT, this.localParticipant); + + this.unsubscribeFromVideoEvents(); + this.videoManager = undefined; + this.connectionService = undefined; + this.detach(); };