UNPKG

@o3r/components

Version:

This module contains component-related features (Component replacement, CMS compatibility, helpers, pipes, debugging developer tools...) It comes with an integrated ng builder to help you generate components compatible with Otter features (CMS integration

1,109 lines (1,083 loc) 65.4 kB
import { of, from, observeOn, animationFrameScheduler, BehaviorSubject, firstValueFrom, fromEvent, Subject, ReplaySubject, sample } from 'rxjs'; import { mergeMap, bufferCount, concatMap, delay, scan, tap, filter, distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators'; import * as i0 from '@angular/core'; import { InjectionToken, NgModule, inject, DestroyRef, Optional, Inject, Injectable, SimpleChange, forwardRef, Input, Directive, makeEnvironmentProviders, Pipe, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import * as i1 from '@ngrx/store'; import { createAction, props, on, createReducer, StoreModule, createFeatureSelector, createSelector } from '@ngrx/store'; import { asyncProps, createEntityAsyncRequestAdapter, asyncStoreItemAdapter, asyncEntitySerializer, otterComponentInfoPropertyName, sendOtterMessage, filterMessageContent } from '@o3r/core'; import * as i1$1 from '@o3r/logger'; import { createEntityAdapter } from '@ngrx/entity'; import { NgControl, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms'; import * as i2 from '@angular/common'; import { CommonModule } from '@angular/common'; /** * Buffers and emits data for lazy/progressive rendering of big lists * That could solve issues with long-running tasks when trying to render an array * of similar components. * @param delayMs Delay between data emits * @param concurrency Amount of elements that should be emitted at once */ function lazyArray(delayMs = 0, concurrency = 2) { let isFirstEmission = true; return (source$) => { return source$.pipe(mergeMap((items) => { if (!isFirstEmission) { return of(items); } const items$ = from(items); return items$.pipe(bufferCount(concurrency), concatMap((value, index) => { return of(value).pipe(observeOn(animationFrameScheduler), delay(index * delayMs)); }), scan((acc, steps) => { return [...acc, ...steps]; }, []), tap((scannedItems) => { const scanDidComplete = scannedItems.length === items.length; if (scanDidComplete) { isFirstEmission = false; } })); })); }; } /** * Determine if the given message is a Components message * @param message message to check */ const isComponentsMessage = (message) => { return message && (message.dataType === 'requestMessages' || message.dataType === 'connect' || message.dataType === 'selectedComponentInfo' || message.dataType === 'isComponentSelectionAvailable' || message.dataType === 'placeholderMode' || message.dataType === 'toggleInspector'); }; const ACTION_FAIL_ENTITIES = '[PlaceholderRequest] fail entities'; const ACTION_SET_ENTITY_FROM_URL = '[PlaceholderRequest] set entity from url'; const ACTION_CANCEL_REQUEST = '[PlaceholderRequest] cancel request'; const ACTION_UPDATE_ENTITY = '[PlaceholderRequest] update entity'; const ACTION_UPDATE_ENTITY_SYNC = '[PlaceholderRequest] update entity sync'; /** Action to cancel a Request ID registered in the store. Can happen from effect based on a switchMap for instance */ const cancelPlaceholderRequest = createAction(ACTION_CANCEL_REQUEST, props()); /** Action to update failureStatus for PlaceholderRequestModels */ const failPlaceholderRequestEntity = createAction(ACTION_FAIL_ENTITIES, props()); /** Action to update an entity */ const updatePlaceholderRequestEntity = createAction(ACTION_UPDATE_ENTITY, props()); /** Action to update an entity without impact on request id */ const updatePlaceholderRequestEntitySync = createAction(ACTION_UPDATE_ENTITY_SYNC, props()); /** Action to update PlaceholderRequest with known IDs, will create the entity with only the url, the call will be created in the effect */ const setPlaceholderRequestEntityFromUrl = createAction(ACTION_SET_ENTITY_FROM_URL, asyncProps()); /** * PlaceholderRequest Store adapter */ const placeholderRequestAdapter = createEntityAsyncRequestAdapter(createEntityAdapter({ selectId: (model) => model.id })); /** * PlaceholderRequest Store initial value */ const placeholderRequestInitialState = placeholderRequestAdapter.getInitialState({ requestIds: [] }); /** * Reducers of Placeholder request store that handles the call to the placeholder template URL */ const placeholderRequestReducerFeatures = [ on(cancelPlaceholderRequest, (state, action) => { const id = action.id; if (!id || !state.entities[id]) { return state; } return placeholderRequestAdapter.updateOne({ id: action.id, changes: asyncStoreItemAdapter.resolveRequest(state.entities[id], action.requestId) }, asyncStoreItemAdapter.resolveRequest(state, action.requestId)); }), on(updatePlaceholderRequestEntity, (state, action) => { const currentEntity = state.entities[action.entity.id]; const newEntity = asyncStoreItemAdapter.resolveRequest({ ...action.entity, ...asyncStoreItemAdapter.extractAsyncStoreItem(currentEntity) }, action.requestId); return placeholderRequestAdapter.updateOne({ id: newEntity.id, changes: newEntity }, asyncStoreItemAdapter.resolveRequest(state, action.requestId)); }), on(updatePlaceholderRequestEntitySync, (state, action) => { return placeholderRequestAdapter.updateOne({ id: action.entity.id, changes: { ...action.entity } }, state); }), on(setPlaceholderRequestEntityFromUrl, (state, payload) => { const currentEntity = state.entities[payload.id]; // Nothing to update if resolved URLs already match if (currentEntity && currentEntity.resolvedUrl === payload.resolvedUrl) { return state; } let newEntity = { id: payload.id, resolvedUrl: payload.resolvedUrl, used: true }; if (currentEntity) { newEntity = { ...asyncStoreItemAdapter.extractAsyncStoreItem(currentEntity), ...newEntity }; } return placeholderRequestAdapter.addOne(asyncStoreItemAdapter.addRequest(asyncStoreItemAdapter.initialize(newEntity), payload.requestId), asyncStoreItemAdapter.addRequest(state, payload.requestId)); }), on(failPlaceholderRequestEntity, (state, payload) => { return placeholderRequestAdapter.failRequestMany(asyncStoreItemAdapter.resolveRequest(state, payload.requestId), payload && payload.ids, payload.requestId); }) ]; /** * PlaceholderRequest Store reducer */ const placeholderRequestReducer = createReducer(placeholderRequestInitialState, ...placeholderRequestReducerFeatures); /** * Name of the PlaceholderRequest Store */ const PLACEHOLDER_REQUEST_STORE_NAME = 'placeholderRequest'; /** Token of the PlaceholderRequest reducer */ const PLACEHOLDER_REQUEST_REDUCER_TOKEN = new InjectionToken('Feature PlaceholderRequest Reducer'); /** Provide default reducer for PlaceholderRequest store */ function getDefaultplaceholderRequestReducer() { return placeholderRequestReducer; } class PlaceholderRequestStoreModule { static forRoot(reducerFactory) { return { ngModule: PlaceholderRequestStoreModule, providers: [ { provide: PLACEHOLDER_REQUEST_REDUCER_TOKEN, useFactory: reducerFactory } ] }; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: PlaceholderRequestStoreModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } /** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.13", ngImport: i0, type: PlaceholderRequestStoreModule, imports: [i1.StoreFeatureModule] }); } /** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: PlaceholderRequestStoreModule, providers: [ { provide: PLACEHOLDER_REQUEST_REDUCER_TOKEN, useFactory: getDefaultplaceholderRequestReducer } ], imports: [StoreModule.forFeature(PLACEHOLDER_REQUEST_STORE_NAME, PLACEHOLDER_REQUEST_REDUCER_TOKEN)] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: PlaceholderRequestStoreModule, decorators: [{ type: NgModule, args: [{ imports: [ StoreModule.forFeature(PLACEHOLDER_REQUEST_STORE_NAME, PLACEHOLDER_REQUEST_REDUCER_TOKEN) ], providers: [ { provide: PLACEHOLDER_REQUEST_REDUCER_TOKEN, useFactory: getDefaultplaceholderRequestReducer } ] }] }] }); const selectPlaceholderRequestState = createFeatureSelector(PLACEHOLDER_REQUEST_STORE_NAME); const { selectEntities: selectEntities$1 } = placeholderRequestAdapter.getSelectors(); /** Select the dictionary of PlaceholderRequest entities */ const selectPlaceholderRequestEntities = createSelector(selectPlaceholderRequestState, (state) => state && selectEntities$1(state)); /** * Select a specific PlaceholderRequest entity using a raw url as id * @param rawUrl */ const selectPlaceholderRequestEntityUsage = (rawUrl) => createSelector(selectPlaceholderRequestState, (state) => { return state?.entities[rawUrl] ? state.entities[rawUrl].used : undefined; }); const placeholderRequestStorageSerializer = asyncEntitySerializer; const placeholderRequestStorageDeserializer = (rawObject) => { if (!rawObject || !rawObject.ids) { return placeholderRequestInitialState; } const storeObject = placeholderRequestAdapter.getInitialState(rawObject); for (const id of rawObject.ids) { storeObject.entities[id] = rawObject.entities[id]; } return storeObject; }; const placeholderRequestStorageSync = { serialize: placeholderRequestStorageSerializer, deserialize: placeholderRequestStorageDeserializer }; const ACTION_DELETE_ENTITY = '[PlaceholderTemplate] delete entity'; const ACTION_SET_ENTITY = '[PlaceholderTemplate] set entity'; const ACTION_TOGGLE_MODE = '[PlaceholderTemplate] toggle mode'; /** Action to delete a specific entity */ const deletePlaceholderTemplateEntity = createAction(ACTION_DELETE_ENTITY, props()); /** Action to clear all placeholderTemplate and fill the store with the payload */ const setPlaceholderTemplateEntity = createAction(ACTION_SET_ENTITY, props()); const togglePlaceholderModeTemplate = createAction(ACTION_TOGGLE_MODE, props()); /** * PlaceholderTemplate Store adapter */ const placeholderTemplateAdapter = createEntityAdapter({ selectId: (model) => model.id }); /** * PlaceholderTemplate Store initial value */ const placeholderTemplateInitialState = placeholderTemplateAdapter.getInitialState({ mode: 'normal' }); /** * List of basic actions for PlaceholderTemplate Store */ const placeholderTemplateReducerFeatures = [ on(setPlaceholderTemplateEntity, (state, payload) => placeholderTemplateAdapter.addOne(payload.entity, placeholderTemplateAdapter.removeOne(payload.entity.id, state))), on(deletePlaceholderTemplateEntity, (state, payload) => { const id = payload.id; if (!id || !state.entities[id]) { return state; } return placeholderTemplateAdapter.removeOne(id, state); }), on(togglePlaceholderModeTemplate, (state, payload) => { return { ...state, mode: payload.mode }; }) ]; /** * PlaceholderTemplate Store reducer */ const placeholderTemplateReducer = createReducer(placeholderTemplateInitialState, ...placeholderTemplateReducerFeatures); /** * Name of the PlaceholderTemplate Store */ const PLACEHOLDER_TEMPLATE_STORE_NAME = 'placeholderTemplate'; /** Token of the PlaceholderTemplate reducer */ const PLACEHOLDER_TEMPLATE_REDUCER_TOKEN = new InjectionToken('Feature PlaceholderTemplate Reducer'); /** Provide default reducer for PlaceholderTemplate store */ function getDefaultPlaceholderTemplateReducer() { return placeholderTemplateReducer; } class PlaceholderTemplateStoreModule { static forRoot(reducerFactory) { return { ngModule: PlaceholderTemplateStoreModule, providers: [ { provide: PLACEHOLDER_TEMPLATE_REDUCER_TOKEN, useFactory: reducerFactory } ] }; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: PlaceholderTemplateStoreModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } /** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.13", ngImport: i0, type: PlaceholderTemplateStoreModule, imports: [i1.StoreFeatureModule] }); } /** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: PlaceholderTemplateStoreModule, providers: [ { provide: PLACEHOLDER_TEMPLATE_REDUCER_TOKEN, useFactory: getDefaultPlaceholderTemplateReducer } ], imports: [StoreModule.forFeature(PLACEHOLDER_TEMPLATE_STORE_NAME, PLACEHOLDER_TEMPLATE_REDUCER_TOKEN)] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: PlaceholderTemplateStoreModule, decorators: [{ type: NgModule, args: [{ imports: [ StoreModule.forFeature(PLACEHOLDER_TEMPLATE_STORE_NAME, PLACEHOLDER_TEMPLATE_REDUCER_TOKEN) ], providers: [ { provide: PLACEHOLDER_TEMPLATE_REDUCER_TOKEN, useFactory: getDefaultPlaceholderTemplateReducer } ] }] }] }); const { selectEntities } = placeholderTemplateAdapter.getSelectors(); const selectPlaceholderTemplateState = createFeatureSelector(PLACEHOLDER_TEMPLATE_STORE_NAME); /** Select the dictionary of PlaceholderTemplate entities */ const selectPlaceholderTemplateEntities = createSelector(selectPlaceholderTemplateState, (state) => state && selectEntities(state)); /** * Select a specific PlaceholderTemplate * @param placeholderId */ const selectPlaceholderTemplateEntity = (placeholderId) => createSelector(selectPlaceholderTemplateState, (state) => state?.entities[placeholderId]); /** * Select the ordered rendered placeholder template full data (url, priority etc.) for a given placeholderId * Return undefined if the placeholder is not found * Returns {orderedRenderedTemplates: undefined, isPending: true} if any of the request is still pending * @param placeholderId */ const selectSortedTemplates = (placeholderId) => createSelector(selectPlaceholderTemplateEntity(placeholderId), selectPlaceholderRequestState, (placeholderTemplate, placeholderRequestState) => { if (!placeholderTemplate || !placeholderRequestState) { return; } // The isPending will be considered true if any of the Url is still pending let isPending = false; const templates = []; placeholderTemplate.urlsWithPriority.forEach((urlWithPriority) => { const placeholderRequest = placeholderRequestState.entities[urlWithPriority.rawUrl]; if (placeholderRequest) { // If one of the items is pending, we will wait to display all contents at the same time isPending = isPending || placeholderRequest.isPending; // Templates in failure will be ignored from the list if (!placeholderRequest.isFailure) { templates.push({ rawUrl: urlWithPriority.rawUrl, resolvedUrl: placeholderRequest.resolvedUrl, priority: urlWithPriority.priority, renderedTemplate: placeholderRequest.renderedTemplate }); } } }); // No need to perform sorting if still pending if (isPending) { return { orderedTemplates: undefined, isPending }; } // Sort templates by priority const orderedTemplates = templates.sort((template1, template2) => { return (template2.priority - template1.priority) || 1; }).filter((templateData) => !!templateData.renderedTemplate); return { orderedTemplates, isPending }; }); /** * Select the ordered rendered templates for a given placeholderId * Return undefined if the placeholder is not found * Returns {orderedRenderedTemplates: undefined, isPending: true} if any of the request is still pending * @param placeholderId * @deprecated Please use {@link selectSortedTemplates} instead */ const selectPlaceholderRenderedTemplates = (placeholderId) => createSelector(selectSortedTemplates(placeholderId), (placeholderData) => { if (!placeholderData) { return; } return { orderedRenderedTemplates: placeholderData.orderedTemplates?.map((placeholder) => placeholder.renderedTemplate), isPending: placeholderData.isPending }; }); const selectPlaceholderTemplateMode = createSelector(selectPlaceholderTemplateState, (state) => state.mode); const placeholderTemplateStorageDeserializer = (rawObject) => { if (!rawObject || !rawObject.ids) { return placeholderTemplateInitialState; } const storeObject = placeholderTemplateAdapter.getInitialState(rawObject); for (const id of rawObject.ids) { storeObject.entities[id] = rawObject.entities[id]; } return storeObject; }; const placeholderTemplateStorageSync = { deserialize: placeholderTemplateStorageDeserializer }; const OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS = { isActivatedOnBootstrap: false }; const OTTER_COMPONENTS_DEVTOOLS_OPTIONS = new InjectionToken('Otter Components Devtools options'); /** * Otter inspector css class */ const INSPECTOR_CLASS = 'otter-devtools-inspector'; /** * Determine if a node is an Otter container * @param node Element to check * @returns true if the node is an Otter container */ const isContainer = (node) => { return !!node?.tagName.toLowerCase().endsWith('cont'); }; /** * Determine the config id of a component instance * @param instance component instance * @returns the config id of the component instance */ const getConfigId = (instance) => { return instance[otterComponentInfoPropertyName]?.configId; }; /** * Recursive method to determin the translations of a node * @param node HTMLElement to check * @param rec recursive method * @returns the trasnslations associated to their component name */ function getTranslationsRec(node, rec) { const angularDevTools = window.ng; const o3rInfoProperty = '__otter-info__'; if (!node || !angularDevTools) { return; } const component = angularDevTools.getComponent(node); const subTranslations = Array.from(node.children).map((child) => rec(child, rec)); const translations = {}; subTranslations.forEach((s) => { Object.entries(s || {}).forEach(([k, v]) => { if (v.length > 0) { translations[k] = v; } }); }); if (component) { const componentName = component.constructor.name; const componentTranslations = Object.values(component[o3rInfoProperty]?.translations || {}).filter((t) => typeof t === 'string'); if (componentTranslations.length > 0) { translations[componentName] = componentTranslations; } } return Object.keys(translations).length > 0 ? translations : undefined; } /** * Determine the translations of a node * @param node HTMLElement to check * @returns the translations associated to their component name */ const getTranslations = (node) => getTranslationsRec(node, getTranslations); /** * Recursive method to determine the analytics of a node * @param node Element to check * @param rec recursive method * @returns the analytics associated to their component name */ function getAnalyticEventsRec(node, rec) { const angularDevTools = window.ng; const o3rInfoProperty = '__otter-info__'; if (!node || !angularDevTools) { return; } const component = angularDevTools.getComponent(node); const subEvents = Array.from(node.children).map((child) => rec(child, rec)); const events = {}; subEvents.forEach((s) => { Object.entries(s || {}).forEach(([k, v]) => { if (v.length > 0) { events[k] = v; } }); }); if (component && component[o3rInfoProperty]) { const componentName = component.constructor.name; const componentEvents = Object.values(component.analyticsEvents || {}).map((eventConstructor) => eventConstructor.name); if (componentEvents.length > 0) { events[componentName] = componentEvents; } } return Object.keys(events).length > 0 ? events : undefined; } /** * Determine the analytics of a node * @param node Element to check * @returns the analytics associated to their component name */ const getAnalyticEvents = (node) => getAnalyticEventsRec(node, getAnalyticEvents); /** * Determine all info from an Otter component * @param componentClassInstance component instance * @param host HTML element hosting the component * @returns all info from an Otter component */ const getOtterLikeComponentInfo = (componentClassInstance, host) => { const configId = getConfigId(componentClassInstance); const translations = getTranslations(host); const analytics = getAnalyticEvents(host); return { // Cannot use anymore `constructor.name` else all components are named `_a` componentName: componentClassInstance.constructor.ɵcmp?.debugInfo?.className || componentClassInstance.constructor.name, configId, translations, analytics }; }; /** * Service to handle the custom inspector for the Otter Devtools Chrome extension. */ class OtterInspectorService { constructor() { this.elementMouseOverCallback = this.elementMouseOver.bind(this); this.elementClickCallback = this.elementClick.bind(this); this.cancelEventCallback = this.cancelEvent.bind(this); this.inspectorDiv = null; this.otterLikeComponentInfoToBeSent = new BehaviorSubject(undefined); /** * Stream of component info to be sent to the extension app. */ this.otterLikeComponentInfoToBeSent$ = this.otterLikeComponentInfoToBeSent.asObservable(); this.angularDevTools = window.ng; } startInspecting() { window.addEventListener('mouseover', this.elementMouseOverCallback, true); window.addEventListener('click', this.elementClickCallback, true); window.addEventListener('mouseout', this.cancelEventCallback, true); } elementClick(e) { e.stopImmediatePropagation(); e.stopPropagation(); e.preventDefault(); if (!this.selectedComponent || !this.angularDevTools) { return; } const parentElement = this.selectedComponent.host.parentElement; const parent = parentElement && (this.angularDevTools.getComponent(parentElement) || this.angularDevTools.getOwningComponent(parentElement)); const parentHost = parent && this.angularDevTools.getHostElement(parent); const container = isContainer(parentHost) ? getOtterLikeComponentInfo(parent, parentHost) : undefined; const { component, host, ...infoToBeSent } = this.selectedComponent; this.otterLikeComponentInfoToBeSent.next({ ...infoToBeSent, container }); } isOtterLikeComponent(info) { const hasConfigId = !!info.configId; const hasTranslations = !!info.translations?.length; const hasAnalytics = Object.keys(info.analytics || {}).length > 0; return hasConfigId || hasTranslations || hasAnalytics; } findComponentInfo(node) { if (!this.angularDevTools) { return; } let componentClassInstance = this.angularDevTools.getComponent(node) || this.angularDevTools.getOwningComponent(node); let o3rLikeComponentInfo; let isO3rLike; if (!componentClassInstance) { return; } do { o3rLikeComponentInfo = getOtterLikeComponentInfo(componentClassInstance, this.angularDevTools.getHostElement(componentClassInstance)); isO3rLike = this.isOtterLikeComponent(o3rLikeComponentInfo); if (!isO3rLike) { componentClassInstance = this.angularDevTools.getOwningComponent(componentClassInstance); } } while (!isO3rLike && componentClassInstance); if (isO3rLike) { return { component: componentClassInstance, host: this.angularDevTools.getHostElement(componentClassInstance), ...o3rLikeComponentInfo }; } } elementMouseOver(e) { this.cancelEvent(e); const el = e.target; if (el) { const selectedComponent = this.findComponentInfo(el); if (selectedComponent?.host !== this.selectedComponent?.host) { this.unHighlight(); this.highlight(selectedComponent); } } } highlight(selectedComponent) { this.selectedComponent = selectedComponent; if (this.selectedComponent?.host && this.inspectorDiv) { const rect = this.selectedComponent.host.getBoundingClientRect(); this.inspectorDiv.style.width = `${rect.width}px`; this.inspectorDiv.style.height = `${rect.height}px`; this.inspectorDiv.style.top = `${rect.top}px`; this.inspectorDiv.style.left = `${rect.left}px`; this.inspectorDiv.firstChild.textContent = `<${this.selectedComponent.componentName}>`; } } unHighlight() { if (this.selectedComponent?.host && this.inspectorDiv) { this.inspectorDiv.style.width = '0'; this.inspectorDiv.style.height = '0'; this.inspectorDiv.style.top = '0'; this.inspectorDiv.style.left = '0'; } this.selectedComponent = undefined; } cancelEvent(e) { e.stopPropagation(); e.stopImmediatePropagation(); e.preventDefault(); } /** * Prepare the inspector div and add it to the DOM. */ prepareInspector() { if (this.inspectorDiv) { return; } const inspectorCss = document.createElement('style'); inspectorCss.textContent = ` .${INSPECTOR_CLASS} { z-index: 9999999; width: 0; height: 0; background: rgba(104, 182, 255, 0.35); position: fixed; left: 0; top: 0; pointer-events: none; } .${INSPECTOR_CLASS} > span { bottom: -25px; position: absolute; right: 10px; background: rgba(104, 182, 255, 0.9);; padding: 5px; border-radius: 5px; color: white; }`; const inspectorDiv = document.createElement('div'); const inspectorSpan = document.createElement('span'); inspectorDiv.append(inspectorSpan); inspectorDiv.classList.add(INSPECTOR_CLASS); document.head.append(inspectorCss); document.body.append(inspectorDiv); this.inspectorDiv = inspectorDiv; } /** * Toggle the inspector. * @param isRunning true if the inspector is running */ toggleInspector(isRunning) { if (isRunning) { this.startInspecting(); } else { this.stopInspecting(); } } stopInspecting() { this.unHighlight(); window.removeEventListener('mouseover', this.elementMouseOverCallback, true); window.removeEventListener('click', this.elementClickCallback, true); window.removeEventListener('mouseout', this.cancelEventCallback, true); } } class ComponentsDevtoolsMessageService { constructor(logger, store, options) { this.logger = logger; this.store = store; this.sendMessage = (sendOtterMessage); this.destroyRef = inject(DestroyRef); this.options = { ...OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS, ...options }; this.inspectorService = new OtterInspectorService(); if (this.options.isActivatedOnBootstrap) { this.activate(); } } /** * Function to connect the plugin to the Otter DevTools extension */ async connectPlugin() { this.logger.debug('Otter DevTools is plugged to components service of the application'); const selectComponentInfo = await firstValueFrom(this.inspectorService.otterLikeComponentInfoToBeSent$); if (selectComponentInfo) { await this.sendCurrentSelectedComponent(); } } async sendCurrentSelectedComponent() { const selectComponentInfo = await firstValueFrom(this.inspectorService.otterLikeComponentInfoToBeSent$); if (selectComponentInfo) { this.sendMessage('selectedComponentInfo', selectComponentInfo); } } sendIsComponentSelectionAvailable() { this.sendMessage('isComponentSelectionAvailable', { available: !!window.ng }); } /** * Function to trigger a re-send a requested messages to the Otter Chrome DevTools extension * @param only restricted list of messages to re-send */ handleReEmitRequest(only) { if (!only) { void this.sendCurrentSelectedComponent(); this.sendIsComponentSelectionAvailable(); return; } if (only.includes('selectedComponentInfo')) { void this.sendCurrentSelectedComponent(); } if (only.includes('isComponentSelectionAvailable')) { this.sendIsComponentSelectionAvailable(); } } /** * Function to handle the incoming messages from Otter Chrome DevTools extension * @param message message coming from the Otter Chrome DevTools extension */ async handleEvents(message) { this.logger.debug('Message handling by the components service', message); switch (message.dataType) { case 'connect': { await this.connectPlugin(); break; } case 'requestMessages': { this.handleReEmitRequest(message.only); break; } case 'toggleInspector': { this.inspectorService.toggleInspector(message.isRunning); break; } case 'placeholderMode': { this.store.dispatch(togglePlaceholderModeTemplate({ mode: message.mode })); break; } default: { this.logger.warn('Message ignored by the components service', message); } } } /** @inheritDoc */ activate() { fromEvent(window, 'message').pipe(takeUntilDestroyed(this.destroyRef), filterMessageContent(isComponentsMessage)).subscribe((e) => this.handleEvents(e)); this.inspectorService.prepareInspector(); this.inspectorService.otterLikeComponentInfoToBeSent$ .pipe(takeUntilDestroyed(this.destroyRef), filter((info) => !!info)).subscribe((info) => this.sendMessage('selectedComponentInfo', info)); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: ComponentsDevtoolsMessageService, deps: [{ token: i1$1.LoggerService }, { token: i1.Store }, { token: OTTER_COMPONENTS_DEVTOOLS_OPTIONS, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: ComponentsDevtoolsMessageService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: ComponentsDevtoolsMessageService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1$1.LoggerService }, { type: i1.Store }, { type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [OTTER_COMPONENTS_DEVTOOLS_OPTIONS] }] }] }); class ComponentsDevtoolsModule { /** * Initialize Otter Devtools * @param options */ static instrument(options) { return { ngModule: ComponentsDevtoolsModule, providers: [ { provide: OTTER_COMPONENTS_DEVTOOLS_OPTIONS, useValue: { ...OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS, ...options }, multi: false }, ComponentsDevtoolsMessageService ] }; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: ComponentsDevtoolsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } /** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.13", ngImport: i0, type: ComponentsDevtoolsModule, imports: [StoreModule, PlaceholderTemplateStoreModule] }); } /** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: ComponentsDevtoolsModule, providers: [ { provide: OTTER_COMPONENTS_DEVTOOLS_OPTIONS, useValue: OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS }, ComponentsDevtoolsMessageService ], imports: [StoreModule, PlaceholderTemplateStoreModule] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: ComponentsDevtoolsModule, decorators: [{ type: NgModule, args: [{ imports: [ StoreModule, PlaceholderTemplateStoreModule ], providers: [ { provide: OTTER_COMPONENTS_DEVTOOLS_OPTIONS, useValue: OTTER_COMPONENTS_DEVTOOLS_DEFAULT_OPTIONS }, ComponentsDevtoolsMessageService ] }] }] }); class C11nDirective { /** The input setter */ set inputs(value) { this._inputs = value; if (!this.differInputs && value) { // eslint-disable-next-line unicorn/no-array-callback-reference -- KeyValueDiffers.find is not an array function this.differInputs = this.differsService.find(value).create(); } } /** The input getter */ get inputs() { return this._inputs; } constructor(viewContainerRef, differsService, injector) { this.viewContainerRef = viewContainerRef; this.differsService = differsService; this.injector = injector; this.componentSubscriptions = []; /** Set of inputs when the component was created. */ this.uninitializedInputs = new Set(); } /** * Type guard for component implementing CVA * @param _cmp Component instance */ componentImplementsCva(_cmp) { return !!this.formControl; } updateInputs(record, inputChanges) { const recordKey = record.key; const isFirstChange = this.uninitializedInputs.has(recordKey); this.uninitializedInputs.delete(recordKey); inputChanges[recordKey] = new SimpleChange(record.previousValue, record.currentValue, isFirstChange); } /** * called when data-bound property change * @param changes The changes that occur */ ngOnChanges(changes) { const inputChanges = {}; if (changes.component && changes.component.currentValue) { if (this.componentRef) { this.componentSubscriptions.forEach((s) => s.unsubscribe()); this.componentSubscriptions = []; this.componentRef.destroy(); } const ngControl = !!this.formControl && this.injector.get(NgControl); this.viewContainerRef.clear(); this.componentRef = this.viewContainerRef.createComponent(changes.component.currentValue); Object.keys(this.componentRef.instance) .filter((prop) => !(this.outputs && Object.keys(this.outputs).includes(prop))) .forEach((prop) => { this.uninitializedInputs.add(prop); }); if (ngControl && this.componentImplementsCva(this.componentRef.instance)) { ngControl.valueAccessor = this.componentRef.instance; } // Initialize outputs if (this.outputs) { const subscriptions = Object.keys(this.outputs).map((outputName) => this.componentRef.instance[outputName].subscribe((val) => this.outputs[outputName](val))); this.componentSubscriptions.push(...subscriptions); } // In case of async component change keep the inputs if (!changes.inputs && this.inputs) { Object.keys(this.inputs).forEach((inputName) => { const currentInputValue = this.inputs[inputName]; inputChanges[inputName] = new SimpleChange(undefined, currentInputValue, true); this.uninitializedInputs.delete(inputName); }); } // In case of lazy loaded component keep the config if (!changes.config && this.config) { inputChanges.config = new SimpleChange(this.componentRef.instance.config, this.config, true); this.uninitializedInputs.delete('config'); } } if (this.componentRef && this.differInputs) { const changesInInputs = this.differInputs.diff(this.inputs); if (changesInInputs) { changesInInputs.forEachAddedItem((record) => this.updateInputs(record, inputChanges)); changesInInputs.forEachChangedItem((record) => this.updateInputs(record, inputChanges)); changesInInputs.forEachRemovedItem((record) => this.updateInputs(record, inputChanges)); } } if (this.componentRef && changes.config) { inputChanges.config = new SimpleChange(this.componentRef.instance.config, changes.config.currentValue, this.uninitializedInputs.has('config')); this.uninitializedInputs.delete('config'); } if (this.componentRef && Object.keys(inputChanges).length > 0) { Object.entries(inputChanges).forEach(([inputName, value]) => { this.componentRef.setInput(inputName, value.currentValue); }); } } /** * returns validation errors from component instance if validate method exists else returns null * @param control Form control */ validate(control) { if (!this.componentRef?.instance.validate) { return null; } return this.componentRef.instance.validate(control); } /** * ngOnDestroy */ ngOnDestroy() { this.componentSubscriptions.forEach((s) => s.unsubscribe()); if (this.componentRef) { this.componentRef.destroy(); } } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nDirective, deps: [{ token: i0.ViewContainerRef }, { token: i0.KeyValueDiffers }, { token: i0.Injector }], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.13", type: C11nDirective, isStandalone: true, selector: "[c11n]", inputs: { component: "component", config: "config", formControl: "formControl", inputs: "inputs", outputs: "outputs" }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef((() => C11nDirective)), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef((() => C11nDirective)), multi: true } ], usesOnChanges: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nDirective, decorators: [{ type: Directive, args: [{ selector: '[c11n]', providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef((() => C11nDirective)), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef((() => C11nDirective)), multi: true } ] }] }], ctorParameters: () => [{ type: i0.ViewContainerRef }, { type: i0.KeyValueDiffers }, { type: i0.Injector }], propDecorators: { component: [{ type: Input }], config: [{ type: Input }], formControl: [{ type: Input }], inputs: [{ type: Input }], outputs: [{ type: Input }] } }); /** * Register a custom component * @param customComponentsMap an object containing the already registered custom component * @param customComponentKey * @param customComponent */ function registerCustomComponent(customComponentsMap, customComponentKey, customComponent) { customComponentsMap.set(customComponentKey, customComponent); return customComponentsMap; } /** The C11n injection token */ const C11N_PRESENTERS_MAP_TOKEN = new InjectionToken('C11n injection token'); /** Function used to register custom components */ const C11N_REGISTER_FUNC_TOKEN = new InjectionToken('C11n register presenters token'); class C11nService { constructor(presentersMap) { this.presentersMap = presentersMap; } /** * Add a presenter * @param presKey The presenter key to set * @param presenter The new presenter */ addPresenter(presKey, presenter) { this.presentersMap.set(presKey, presenter); } /** * Operator to retrieve the presenter based on a given presKey * @param defaultPres The default presenter * @param presKey The presenter key to retrieve */ getPresenter(defaultPres, presKey = 'customPresKey') { return (source) => source.pipe(distinctUntilChanged((p, q) => p[presKey] === q[presKey]), map((config) => { const presenterConfig = config[presKey]; return typeof presenterConfig === 'string' && presenterConfig !== '' ? (this.presentersMap.get(presenterConfig) || defaultPres) : defaultPres; })); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nService, deps: [{ token: C11N_PRESENTERS_MAP_TOKEN }], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nService, decorators: [{ type: Injectable }], ctorParameters: () => [{ type: Map, decorators: [{ type: Inject, args: [C11N_PRESENTERS_MAP_TOKEN] }] }] }); /** C11n directive mock */ class MockC11nDirective { /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: MockC11nDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.13", type: MockC11nDirective, isStandalone: false, selector: "[c11n]", inputs: { config: "config", component: "component", inputs: "inputs", outputs: "outputs" }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: MockC11nDirective, decorators: [{ type: Directive, args: [{ selector: '[c11n]', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property -- mocked directive inputs: ['config', 'component', 'inputs', 'outputs'], standalone: false }] }] }); /** C11n service mock */ class C11nMockService { addPresenter(_presKey, _presType) { } getPresenter(_defaultPres, _presKey) { return (source) => source.pipe(mapTo(null)); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nMockService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nMockService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nMockService, decorators: [{ type: Injectable }] }); /** * The purpose of this module is to be imported in the unit tests of the components which are using c11n directive */ class C11nMockModule { /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nMockModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } /** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.13", ngImport: i0, type: C11nMockModule, declarations: [MockC11nDirective], exports: [MockC11nDirective] }); } /** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nMockModule, providers: [{ provide: C11nService, useClass: C11nMockService }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nMockModule, decorators: [{ type: NgModule, args: [{ declarations: [MockC11nDirective], exports: [MockC11nDirective], providers: [{ provide: C11nService, useClass: C11nMockService }] }] }] }); /** * Customization service factory * @param config -> registerCompFunc - a function which returns the map of custom components which will be injected in c11n service * @param config.registerCompFunc */ function createC11nService(config) { if (!config) { return new C11nService(new Map()); } const custoComp = config.registerCompFunc(); return new C11nService(custoComp); } /** * @deprecated Will be removed in v14. */ class C11nModule { /** * Get the module with providers for the root component * @param config -> registerCompFunc - a function which returns the map of custom components which will be injected in c11n service * @param config.registerCompFunc * @deprecated Please use {@link provideCustomComponents} instead. Will be removed in v14. */ static forRoot(config) { return { ngModule: C11nModule, providers: [{ provide: C11N_REGISTER_FUNC_TOKEN, useValue: config }, { provide: C11nService, useFactory: createC11nService, deps: [C11N_REGISTER_FUNC_TOKEN] }] }; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } /** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.13", ngImport: i0, type: C11nModule, imports: [C11nDirective], exports: [C11nDirective] }); } /** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nModule }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: C11nModule, decorators: [{ type: NgModule, args: [{ imports: [C11nDirective], exports: [C11nDirective] }] }] }); /** * Returns a tuple of the key and the component * @note should be used with {@link provideCustomComponents} * @example * ```typescript * provideCustomComponents( * new Map(), * withComponent('example1', MyFirstComponent), * withComponent('example2', MySecondComponent), * ) * ``` * @param customComponentKey * @param customComponent */ function withComponent(customComponentKey, customComponent) { return [customComponentKey, customComponent]; } /** * Provide custom components which will be injected in c11n service * @param customComponents * @param additionalComponents */ function provideCustomComponents(customComponents = new Map(), ...additionalComponents) { additionalComponents.forEach(([customComponentKey, customComponent]) => customComponents.set(customComponentKey, customComponent)); return makeEnvironmentProviders([ C11nService, { provide: C11N_PRESEN