UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1,299 lines (1,286 loc) 630 kB
import { gettext } from '@c8y/ngx-components/gettext'; import { aggregationType } from '@c8y/client'; import * as i0 from '@angular/core'; import { computed, signal, Injectable, inject, input, forwardRef, ChangeDetectionStrategy, Component, DestroyRef, output, Input, ChangeDetectorRef, ViewChild, EventEmitter, viewChild, effect, HostListener, Output, TemplateRef, untracked, ContentChild, NgModule } from '@angular/core'; import * as i4 from '@c8y/ngx-components'; import { ViewContext, I18nModule, IconDirective, FormGroupComponent, MessagesComponent, MessageDirective, DateTimePickerModule, C8yTranslatePipe, DatePipe, TabsOutletComponent, FormsModule as FormsModule$1, CountdownIntervalModule, CountdownIntervalComponent, AlertService, ContextRouteService, DashboardChildComponent, hookActionBar } from '@c8y/ngx-components'; import { BehaviorSubject, map, distinctUntilChanged, shareReplay, fromEvent, filter, debounceTime, startWith, tap as tap$1, firstValueFrom, combineLatest, Subject, take, skip, throttleTime, merge as merge$1, EMPTY } from 'rxjs'; import * as i1 from '@angular/forms'; import { FormBuilder, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormControl } from '@angular/forms'; import { pick, omit, isObject, isEqual, transform, some, cloneDeep, merge, isEmpty } from 'lodash-es'; import { ActivatedRoute, Router, NavigationStart, NavigationEnd, ActivationEnd } from '@angular/router'; import * as i1$1 from '@angular/common'; import { NgClass, CommonModule, AsyncPipe, TitleCasePipe, Location, NgTemplateOutlet } from '@angular/common'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ContextDashboardStateService } from '@c8y/ngx-components/context-dashboard-state'; import * as i2 from 'ngx-bootstrap/tooltip'; import { TooltipModule } from 'ngx-bootstrap/tooltip'; import * as i1$2 from 'ngx-bootstrap/collapse'; import { CollapseModule } from 'ngx-bootstrap/collapse'; import * as i2$2 from 'ngx-bootstrap/popover'; import { PopoverModule } from 'ngx-bootstrap/popover'; import * as i2$1 from 'ngx-bootstrap/dropdown'; import { BsDropdownModule, BsDropdownDirective } from 'ngx-bootstrap/dropdown'; import { A11yModule } from '@angular/cdk/a11y'; import { withLatestFrom, filter as filter$1, tap, distinctUntilChanged as distinctUntilChanged$1, map as map$1, catchError } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; const GLOBAL_CONTEXT_EVENTS = { STATE_CHANGE: 'GLOBAL_CONTEXT_STATE_CHANGE', REFRESH: 'REFRESH', REFRESH_OPTION_CHANGE: 'GLOBAL_CONTEXT_REFRESH_OPTION_CHANGE', UPDATE_GLOBAL_CONTEXT_LIVE: 'UPDATE_GLOBAL_CONTEXT_LIVE', UPDATE_GLOBAL_CONTEXT_HISTORY: 'UPDATE_GLOBAL_CONTEXT_HISTORY', INIT_GLOBAL_CONTEXT: 'INIT_GLOBAL_CONTEXT' }; var GLOBAL_CONTEXT_DISPLAY_MODE; (function (GLOBAL_CONTEXT_DISPLAY_MODE) { GLOBAL_CONTEXT_DISPLAY_MODE["DASHBOARD"] = "dashboard"; GLOBAL_CONTEXT_DISPLAY_MODE["CONFIG"] = "config"; GLOBAL_CONTEXT_DISPLAY_MODE["VIEW_AND_CONFIG"] = "view_and_config"; })(GLOBAL_CONTEXT_DISPLAY_MODE || (GLOBAL_CONTEXT_DISPLAY_MODE = {})); var DateContextQueryParamNames; (function (DateContextQueryParamNames) { DateContextQueryParamNames["DATE_CONTEXT_FROM"] = "dateContextFrom"; DateContextQueryParamNames["DATE_CONTEXT_TO"] = "dateContextTo"; DateContextQueryParamNames["DATE_CONTEXT_INTERVAL"] = "dateContextInterval"; DateContextQueryParamNames["DATE_CONTEXT_AGGREGATION"] = "dateContextAggregation"; DateContextQueryParamNames["DATE_CONTEXT_AUTO_REFRESH"] = "globalContextAutoRefresh"; DateContextQueryParamNames["DATE_CONTEXT_REFRESH_MODE"] = "globalContextRefreshMode"; })(DateContextQueryParamNames || (DateContextQueryParamNames = {})); const GLOBAL_CONTEXT_SOURCE = { WIDGET: 'widget', DASHBOARD: 'dashboard' }; const WIDGET_DISPLAY_MODE = { INLINE: 'inline', CONFIG: 'config', PREVIEW: 'preview' }; const REFRESH_OPTION = { LIVE: 'live', HISTORY: 'history' }; const todayDate = new Date(); const TIME_SPAN_MS = { MINUTE: 1000 * 60, HOUR: 1000 * 60 * 60, DAY: 1000 * 60 * 60 * 24, WEEK: 1000 * 60 * 60 * 24 * 7, MONTH: todayDate.valueOf() - new Date(todayDate.setMonth(todayDate.getMonth() - 1)).valueOf() }; const TIME_INTERVAL = { NONE: 'none', MINUTES: 'minutes', HOURS: 'hours', DAYS: 'days', WEEKS: 'weeks', MONTHS: 'months', CUSTOM: 'custom' }; const INTERVALS = [ { id: 'minutes', title: gettext('Last minute'), timespanInMs: TIME_SPAN_MS.MINUTE }, { id: 'hours', title: gettext('Last hour'), timespanInMs: TIME_SPAN_MS.HOUR }, { id: 'days', title: gettext('Last day'), timespanInMs: TIME_SPAN_MS.DAY }, { id: 'weeks', title: gettext('Last week'), timespanInMs: TIME_SPAN_MS.WEEK }, { id: 'months', title: gettext('Last month'), timespanInMs: TIME_SPAN_MS.MONTH }, { id: 'custom', title: gettext('Custom') } ]; const INTERVAL_TITLES = { none: gettext('No date filter'), minutes: gettext('Last minute'), hours: gettext('Last hour'), days: gettext('Last day'), weeks: gettext('Last week'), months: gettext('Last month'), custom: gettext('Custom') }; /** * Default values for global context configuration * Single source of truth for all default values across the global context feature */ const GLOBAL_CONTEXT_DEFAULTS = { /** Default refresh option - live mode */ REFRESH_OPTION: REFRESH_OPTION.LIVE, /** Default auto-refresh state */ IS_AUTO_REFRESH_ENABLED: true, /** Default refresh interval in milliseconds (5 seconds) */ REFRESH_INTERVAL: 5000, /** Default aggregation type */ AGGREGATION: null, /** Default display mode */ DISPLAY_MODE: GLOBAL_CONTEXT_DISPLAY_MODE.DASHBOARD, /** Default date range duration in milliseconds (1 hour) */ DATE_RANGE_DURATION_MS: 60 * 60 * 1000, /** Default time interval for custom ranges */ TIME_INTERVAL: TIME_INTERVAL.HOURS }; /** * Define the mapping between GlobalContextState keys and their equivalent in GlobalContextSettings */ const LINK_BTNS_CONFIG = { dateTimeContext: { formControlName: 'dateTimeContext', settingKey: 'showTimeContext', label: gettext('date/time'), cssClass: 'time-context', icon: 'calendar' }, isAutoRefreshEnabled: { formControlName: 'isAutoRefreshEnabled', settingKey: 'showAutoRefresh', label: gettext('auto refresh'), cssClass: 'auto-refresh', icon: 'refresh' }, aggregation: { formControlName: 'aggregation', settingKey: 'showAggregation', label: gettext('aggregation'), cssClass: 'aggregation', icon: 'input' } }; const AGGREGATIONS = [ { id: null, title: gettext('None') }, { id: aggregationType.MINUTELY, title: gettext('Minutely') }, { id: aggregationType.HOURLY, title: gettext('Hourly') }, { id: aggregationType.DAILY, title: gettext('Daily') } ]; const AGGREGATION_LIMITS = { MINUTELY_LIMIT: TIME_SPAN_MS.MINUTE * 10, HOURLY_LIMIT: TIME_SPAN_MS.DAY * 1, DAILY_LIMIT: TIME_SPAN_MS.DAY * 4 }; const AGGREGATION_ICON_TYPE = { UNDEFINED: 'line-chart', MINUTELY: 'hourglass', HOURLY: 'clock-o', DAILY: 'calendar-o' }; const AGGREGATION_ICONS = { undefined: AGGREGATION_ICON_TYPE.UNDEFINED, MINUTELY: AGGREGATION_ICON_TYPE.MINUTELY, HOURLY: AGGREGATION_ICON_TYPE.HOURLY, DAILY: AGGREGATION_ICON_TYPE.DAILY }; const AGGREGATION_TEXTS = { disabled: gettext('No aggregation with realtime enabled'), undefined: gettext('No aggregation'), null: gettext('No aggregation'), MINUTELY: gettext('Minutely aggregation'), HOURLY: gettext('Hourly aggregation'), DAILY: gettext('Daily aggregation') }; const AGGREGATION_VALUES = { none: 'NONE', minutely: aggregationType.MINUTELY, hourly: aggregationType.HOURLY, daily: aggregationType.DAILY }; const AGGREGATION_VALUES_ARR = [ AGGREGATION_VALUES.none, AGGREGATION_VALUES.minutely, AGGREGATION_VALUES.hourly, AGGREGATION_VALUES.daily ]; const AGGREGATION_LABELS = { NONE: AGGREGATIONS[0].title, [aggregationType.MINUTELY]: AGGREGATIONS[1].title, [aggregationType.HOURLY]: AGGREGATIONS[2].title, [aggregationType.DAILY]: AGGREGATIONS[3].title }; /** * Time duration constants (ms) */ const TIME_DURATION = { /** One hour in milliseconds */ ONE_HOUR_MS: 60 * 60 * 1000, /** One minute in milliseconds */ ONE_MINUTE_MS: 60 * 1000, /** One second in milliseconds */ ONE_SECOND_MS: 1000 }; /** * Timing constants for debouncing and throttling */ const TIMING = { /** Debounce time for form value changes (ms) */ FORM_DEBOUNCE: 100, /** Throttle time for context changes (ms) */ CONTEXT_THROTTLE: 200, /** Tooltip delay time (ms) */ TOOLTIP_DELAY: 500, /** Default debounce time (ms) */ DEFAULT_DEBOUNCE: 100 }; /** * UI priority constants */ const UI_PRIORITIES = { /** High priority for configuration controls */ CONFIGURATION_PRIORITY: 1000 }; /** * Central utility for all DateTimeContext operations. * Single source of truth for date validation, conversion, and context normalization. */ class DateTimeContextUtil { /** * Converts a value to a Date object, handling various input types. * Allows JavaScript's natural type coercion for compatibility with existing tests. */ static toDate(value) { // Handle undefined explicitly - new Date(undefined) is Invalid Date if (value === undefined) { return null; } // Already a Date object if (value instanceof Date) { return isNaN(value.getTime()) ? null : value; } // Let JavaScript's natural type coercion handle all other cases // This allows null, false, true, numbers, strings to be handled like new Date() would const date = new Date(value); return isNaN(date.getTime()) ? null : date; } /** * Converts a date value to ISO string format. */ static toIso(value) { const date = this.toDate(value); return date ? date.toISOString() : null; } /** * Normalizes an ISO string to remove milliseconds (sets to .000Z). * This ensures date comparisons ignore millisecond precision. * * @param isoString - ISO date string (e.g., "2025-10-27T15:10:04.405Z") * @returns ISO string with milliseconds zeroed (e.g., "2025-10-27T15:10:04.000Z") */ static normalizeIsoToSeconds(isoString) { if (!isoString || typeof isoString !== 'string') { return null; } // Parse the ISO string to a Date const date = this.toDate(isoString); if (!date) { return null; } // Set milliseconds to 0 date.setMilliseconds(0); return date.toISOString(); } /** * Validates if a single date value is valid. * Only accepts Date objects and valid date strings (not numbers, null, etc.) * This is the strict validation used for form inputs and user-facing APIs. */ static isValidDate(value) { // Only accept Date instances or strings if (value instanceof Date) { return !isNaN(value.getTime()); } if (typeof value === 'string') { const date = new Date(value); return !isNaN(date.getTime()); } return false; } /** * Validates if a value can be converted to a valid date. * Uses JavaScript's natural type coercion, accepting null, numbers, booleans, etc. * This is used internally for range validation. */ static isCoercibleToDate(value) { return this.toDate(value) !== null; } /** * Validates if a date range is valid (from < to). */ static isValidRange(from, to) { const fromDate = this.toDate(from); const toDate = this.toDate(to); // Both dates must be valid and fromDate must be before toDate return fromDate !== null && toDate !== null && fromDate < toDate; } /** * Validates if a date range is valid, allowing equal dates (from <= to). */ static isValidRangeOrEqual(from, to) { const fromDate = this.toDate(from); const toDate = this.toDate(to); // Both dates must be valid and fromDate must be before or equal to toDate return fromDate !== null && toDate !== null && fromDate <= toDate; } /** * Calculates date range from an interval. * Pure implementation based on INTERVALS configuration. */ static dateRangeFromInterval(interval, now = new Date()) { // Special case: 'none' returns epoch to current time if (interval === 'none') { return [new Date(0), now]; } // Special case: 'custom' has no default range if (interval === TIME_INTERVAL.CUSTOM) { return null; } // Find the interval configuration const intervalConfig = INTERVALS.find(({ id }) => id === interval); if (!intervalConfig || !intervalConfig.timespanInMs) { return null; } // Calculate date range: current time minus interval timespan const dateTo = new Date(now); const dateFrom = new Date(dateTo.valueOf() - intervalConfig.timespanInMs); return [dateFrom, dateTo]; } /** * Ensures all dates in a DateTimeContext are ISO strings with milliseconds normalized to .000Z. */ static ensureIsoContext(ctx) { const result = { interval: ctx.interval || TIME_INTERVAL.CUSTOM, dateFrom: null, dateTo: null }; if (ctx.dateFrom) { const iso = this.toIso(ctx.dateFrom); result.dateFrom = this.normalizeIsoToSeconds(iso); } if (ctx.dateTo) { const iso = this.toIso(ctx.dateTo); result.dateTo = this.normalizeIsoToSeconds(iso); } return result; } /** * Normalizes a DateTimeContext for live mode. * - For non-custom intervals: recomputes range based on current time * - For custom interval: updates dateTo to current time */ static normalizeForLive(ctx, now = new Date()) { const interval = ctx.interval || TIME_INTERVAL.CUSTOM; if (interval !== TIME_INTERVAL.CUSTOM) { // Recompute range from interval const range = this.dateRangeFromInterval(interval, now); if (range) { return this.ensureIsoContext({ interval, dateFrom: range[0], dateTo: range[1] }); } } // For custom interval, keep dateFrom but update dateTo to now return this.ensureIsoContext({ interval: TIME_INTERVAL.CUSTOM, dateFrom: ctx.dateFrom, dateTo: now }); } /** * Normalizes a DateTimeContext for history mode. * Forces interval to CUSTOM and preserves explicit date range. */ static normalizeForHistory(ctx) { return this.ensureIsoContext({ interval: TIME_INTERVAL.CUSTOM, dateFrom: ctx.dateFrom, dateTo: ctx.dateTo }); } /** * Type guard to check if a value is a valid DateTimeContext object. */ static isValidDateTimeContext(context) { if (!context || typeof context !== 'object') return false; const ctx = context; const hasDates = 'dateFrom' in ctx && 'dateTo' in ctx && 'interval' in ctx; if (!hasDates) return false; return (typeof ctx.interval === 'string' && ctx.interval.length > 0 && this.isValidDate(ctx.dateFrom) && this.isValidDate(ctx.dateTo)); } /** * Checks if a partial DateTimeContext has all required fields. * Business logic: Allow single field updates or complete updates (all 3 fields), * but reject incomplete updates with 2 fields. */ static isCompleteDateTimeContext(diff) { // If no dateTimeContext in diff, it's considered complete if (!('dateTimeContext' in diff) || !diff.dateTimeContext) { return true; } const keys = Object.keys(diff.dateTimeContext); // Allow single field updates (e.g., just interval) or complete updates (all 3 fields) // Reject incomplete updates with 2 fields return keys.length === 1 || keys.length === 3; } /** * Formats a context for emission, ensuring ISO dates and filtering by supported fields. */ static formatForEmission(context, supports) { if (!supports.includes('dateTimeContext') || !context.dateTimeContext) { return {}; } const dateTimeContext = context.dateTimeContext; // For live mode with custom interval, default dateTo to now if missing if (dateTimeContext.interval === TIME_INTERVAL.CUSTOM && !dateTimeContext.dateTo) { return { dateTimeContext: this.ensureIsoContext({ ...dateTimeContext, dateTo: new Date() }) }; } return { dateTimeContext: this.ensureIsoContext(dateTimeContext) }; } } /** * Creates a complete default global context state * * @returns A complete GlobalContextState with all required properties set to sensible defaults */ function createDefaultGlobalContextState() { const now = new Date(); const dateFrom = new Date(now.getTime() - GLOBAL_CONTEXT_DEFAULTS.DATE_RANGE_DURATION_MS); return { dateTimeContext: { dateFrom: dateFrom.toISOString(), dateTo: now.toISOString(), interval: GLOBAL_CONTEXT_DEFAULTS.TIME_INTERVAL }, refreshOption: GLOBAL_CONTEXT_DEFAULTS.REFRESH_OPTION, isAutoRefreshEnabled: GLOBAL_CONTEXT_DEFAULTS.IS_AUTO_REFRESH_ENABLED, refreshInterval: GLOBAL_CONTEXT_DEFAULTS.REFRESH_INTERVAL, aggregation: GLOBAL_CONTEXT_DEFAULTS.AGGREGATION, displayMode: GLOBAL_CONTEXT_DEFAULTS.DISPLAY_MODE }; } /** * Fills missing or invalid properties in a partial global context state with defaults * * @param state - Partial global context state that may have missing or invalid values * @returns Complete GlobalContextState with all required properties */ function fillMissingDefaults(state) { const defaults = createDefaultGlobalContextState(); // Use central utility to ensure ISO formatting for dateTimeContext const dateTimeContext = state.dateTimeContext ? DateTimeContextUtil.ensureIsoContext({ ...defaults.dateTimeContext, ...state.dateTimeContext, // Use defaults for invalid dates dateFrom: DateTimeContextUtil.isValidDate(state.dateTimeContext?.dateFrom) ? state.dateTimeContext.dateFrom : defaults.dateTimeContext.dateFrom, dateTo: DateTimeContextUtil.isValidDate(state.dateTimeContext?.dateTo) ? state.dateTimeContext.dateTo : defaults.dateTimeContext.dateTo, interval: state.dateTimeContext?.interval || defaults.dateTimeContext.interval }) : defaults.dateTimeContext; return { ...defaults, ...state, dateTimeContext, // Always override refreshInterval to use the fixed 5s default // This ensures old dashboards with custom intervals are migrated to the new fixed interval refreshInterval: GLOBAL_CONTEXT_DEFAULTS.REFRESH_INTERVAL }; } /** * Validates if a value is a valid date (either Date object or ISO string) * Delegates to central utility for consistency. * * @param value - Value to validate (string, Date, or undefined) * @returns true if the value represents a valid date */ function isValidDateValue(value) { return DateTimeContextUtil.isValidDate(value); } /** * Checks if a date time context object has all required valid properties * Delegates to central utility for consistency. * * @param context - DateTimeContext to validate * @returns true if the context is complete and valid */ function isValidDateTimeContext(context) { return DateTimeContextUtil.isValidDateTimeContext(context); } /** * Checks if a dateTimeContext diff object is complete (has all 3 required fields). * Delegates to central utility for consistency. * * @param diff - Partial state that may contain dateTimeContext * @returns true if dateTimeContext is absent, has only 1 field, or has all 3 fields; false if it has 2 fields (incomplete) */ function isCompleteDateTimeContext(diff) { return DateTimeContextUtil.isCompleteDateTimeContext(diff); } /** * Formats dateTimeContext for emission, handling date conversions and defaults. * Delegates to central utility for consistency. * * @param context - Global context state to format * @param supports - Array of supported fields from widget template * @returns Formatted context with properly formatted dateTimeContext */ function formatDateTimeContextForEmission(context, supports) { const result = DateTimeContextUtil.formatForEmission(context, supports); // Merge back with original context to preserve other fields return { ...context, ...result }; } /** * Default settings applied when no widgets have registered any settings. */ const DEFAULT_GLOBAL_CONTEXT_SETTINGS = { showTimeContext: false, showAggregation: false, showAutoRefresh: false, showRefresh: false, showRefreshInterval: false }; /** * GlobalContextService manages widget settings registration and loading states. * * This service is responsible for: * - Registering and managing widget-specific global context settings * - Consolidating settings from multiple widgets using OR logic * - Tracking loading states across multiple widgets * - Providing default configurations for the global context * * @example * ```typescript * // Register widget settings * globalContextService.register('widget-1', { showTimeContext: true }); * * // Get consolidated settings from all widgets * globalContextService.getConsolidatedSettings().subscribe(settings => { * console.log(settings); // { showTimeContext: true, ... } * }); * * // Track loading state * globalContextService.registerLoading('widget-1'); * console.log(globalContextService.isLoading()); // true * ``` */ class GlobalContextService { constructor() { /** * Computed signal that indicates if any widgets are currently in a loading state. * Returns true if at least one widget has registered a loading state. */ this.isLoading = computed(() => this.loadingIds().size > 0, ...(ngDevMode ? [{ debugName: "isLoading" }] : [])); /** Target context for the global context service */ this.targetContext = ViewContext.Group; /** Observable stream of all registered widget settings */ this.settings$ = new BehaviorSubject([]); /** Signal storing unique identifiers of widgets currently in loading state */ this.loadingIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "loadingIds" }] : [])); } /** * Registers global context settings for a specific widget. * If settings for the same widget ID already exist, they will be updated. * * @param id - Unique identifier for the widget * @param settings - Partial global context settings to register * * @throws {Error} When id is empty or null * * @example * ```typescript * globalContextService.register('my-widget', { * showTimeContext: true, * showAutoRefresh: false * }); * ``` */ register(id, settings) { this.validateWidgetId(id); const registeredSettings = { ...settings, id }; const currentSettings = this.settings$.value; const existingIndex = this.findSettingsIndex(id, currentSettings); if (existingIndex >= 0) { this.updateExistingSettings(existingIndex, registeredSettings, currentSettings); } else { this.addNewSettings(registeredSettings, currentSettings); } } /** * Retrieves all currently registered widget settings. * * @returns Array of all registered settings with their widget IDs */ getSettings() { return this.settings$.value; } /** * Removes all registered widget settings. * This will cause the consolidated settings to return to default values. */ resetSettings() { this.settings$.next([]); } /** * Unregisters settings for a specific widget. * * @param id - Unique identifier of the widget to unregister * * @example * ```typescript * globalContextService.unregister('my-widget'); * ``` */ unregister(id) { if (!id) return; // Silently ignore invalid IDs const currentSettings = this.settings$.value; const filteredSettings = currentSettings.filter(s => s.id !== id); if (filteredSettings.length !== currentSettings.length) { this.settings$.next(filteredSettings); } } /** * Returns an observable of consolidated global context settings. * * Settings are consolidated using OR logic - if any widget requires a setting to be shown, * it will be included in the consolidated result. The observable emits distinct values only * and is shared among all subscribers for optimal performance. * * @returns Observable that emits consolidated settings whenever they change * * @example * ```typescript * globalContextService.getConsolidatedSettings().subscribe(settings => { * if (settings.showTimeContext) { * // Show time context controls * } * }); * ``` */ getConsolidatedSettings() { return this.settings$.pipe(map(settingsArray => this.consolidateSettings(settingsArray)), distinctUntilChanged(this.areSettingsEqual), shareReplay(1)); } /** * Returns the default global context settings. * These settings are used when no widgets have registered any settings. * * @returns Default settings object with all options disabled */ getDefaultSettings() { return { ...DEFAULT_GLOBAL_CONTEXT_SETTINGS }; } /** * Registers a loading state for a specific widget. * This will cause the `isLoading` computed signal to return true. * * @param id - Unique identifier of the widget in loading state * * @example * ```typescript * globalContextService.registerLoading('my-widget'); * console.log(globalContextService.isLoading()); // true * ``` */ registerLoading(id) { if (!id) return; // Silently ignore invalid IDs this.loadingIds.update(ids => { const newIds = new Set(ids); newIds.add(id); return newIds; }); } /** * Unregisters a loading state for a specific widget. * When no widgets are in loading state, `isLoading` will return false. * * @param id - Unique identifier of the widget to remove from loading state * * @example * ```typescript * globalContextService.unregisterLoading('my-widget'); * ``` */ unregisterLoading(id) { if (!id) return; // Silently ignore invalid IDs this.loadingIds.update(ids => { const newIds = new Set(ids); newIds.delete(id); return newIds; }); } /** * Returns the default global context state. * This state is used as the initial configuration for new global context instances. * * @returns Default global context state with live refresh enabled and 1-minute time range * * @example * ```typescript * const defaultState = globalContextService.getDefaultState(); * console.log(defaultState.refreshOption); // 'live' * ``` */ getDefaultState() { return createDefaultGlobalContextState(); } /** * Validates that a widget ID is not empty or null. * * @param id - Widget ID to validate * @throws {Error} When ID is invalid */ validateWidgetId(id) { if (!id || id.trim().length === 0) { throw new Error('Widget ID cannot be empty or null'); } } /** * Finds the index of settings for a given widget ID. * * @param id - Widget ID to search for * @param settings - Array of settings to search in * @returns Index of the settings or -1 if not found */ findSettingsIndex(id, settings) { return settings.findIndex(s => s.id === id); } /** * Updates existing settings for a widget. * * @param index - Index of the settings to update * @param newSettings - New settings to apply * @param currentSettings - Current settings array */ updateExistingSettings(index, newSettings, currentSettings) { const updatedSettings = [...currentSettings]; updatedSettings[index] = newSettings; this.settings$.next(updatedSettings); } /** * Adds new settings to the settings array. * * @param newSettings - Settings to add * @param currentSettings - Current settings array */ addNewSettings(newSettings, currentSettings) { this.settings$.next([...currentSettings, newSettings]); } /** * Consolidates multiple widget settings using OR logic. * * @param settingsArray - Array of widget settings to consolidate * @returns Consolidated settings object */ consolidateSettings(settingsArray) { return settingsArray.reduce((consolidated, current) => ({ showTimeContext: consolidated.showTimeContext || !!current.showTimeContext, showAggregation: consolidated.showAggregation || !!current.showAggregation, showAutoRefresh: consolidated.showAutoRefresh || !!current.showAutoRefresh, showRefresh: consolidated.showRefresh || !!current.showRefresh, showRefreshInterval: consolidated.showRefreshInterval || !!current.showRefreshInterval }), { ...DEFAULT_GLOBAL_CONTEXT_SETTINGS }); } /** * Compares two settings objects for deep equality. * * @param prev - Previous settings * @param curr - Current settings * @returns True if settings are equal, false otherwise */ areSettingsEqual(prev, curr) { return (prev.showTimeContext === curr.showTimeContext && prev.showAggregation === curr.showAggregation && prev.showAutoRefresh === curr.showAutoRefresh && prev.showRefresh === curr.showRefresh && prev.showRefreshInterval === curr.showRefreshInterval); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Service responsible for building and managing form groups for global context configuration. */ class GlobalContextFormService { constructor() { this.fb = inject(FormBuilder); } /** * Creates a FormGroup for global context configuration. * @param config Optional existing configuration to initialize form values * @param defaults Optional override values for specific controls * @returns A fully configured FormGroup */ buildForm(config = {}, defaults, skipProp) { // Get base default values const defaultValues = this.getDefaultValues(); // Extract valid config values const validConfigKeys = Object.keys(defaultValues); const configValues = pick(config, validConfigKeys); // Merge configurations with appropriate precedence const mergedValues = structuredClone(this.mergeValues(defaultValues, configValues, defaults)); const filtered = omit(mergedValues, skipProp); return skipProp ? this.fb.group(filtered) : this.fb.group(mergedValues); } /** * Extracts form values into a configuration object * @param form The form to extract values from * @returns GlobalContextConfig object */ getConfigFromForm(form) { if (!form) return null; // Get raw form values const formValue = form.getRawValue(); return { dateTimeContext: formValue.dateTimeContext, aggregation: formValue.aggregation, isAutoRefreshEnabled: formValue.isAutoRefreshEnabled, // Always use fixed 5s interval, ignore any form value refreshInterval: GLOBAL_CONTEXT_DEFAULTS.REFRESH_INTERVAL, refreshOption: formValue.refreshOption }; } /** * Gets the default values for the global context form */ getDefaultValues() { return createDefaultGlobalContextState(); } /** * Merges default values with config and explicit overrides */ mergeValues(defaultValues, configValues, overrideValues) { // First merge config values over defaults const mergedBasicValues = { ...defaultValues, ...configValues }; // Special handling for dateTimeContext if (configValues.dateTimeContext) { mergedBasicValues.dateTimeContext = { ...defaultValues.dateTimeContext, ...configValues.dateTimeContext }; } // Apply explicit overrides if present if (overrideValues) { const result = { ...mergedBasicValues, ...overrideValues }; if (overrideValues.dateTimeContext) { result.dateTimeContext = { ...mergedBasicValues.dateTimeContext, ...overrideValues.dateTimeContext }; } return result; } return mergedBasicValues; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextFormService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextFormService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextFormService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Configuration constants for the event service. */ const EVENT_CONFIG = { /** Name of the custom event dispatched on the window object */ EVENT_NAME: 'global-context', /** Maximum number of cached event subjects to prevent memory leaks */ MAX_CACHED_SUBJECTS: 50 }; /** * GlobalContextEventService manages global context events using the browser's native event system. * * This service provides a type-safe wrapper around window custom events for global context communication. * It maintains event subjects for efficient observable-based event handling while ensuring proper * cleanup and memory management. * * Key features: * - Type-safe event emission and subscription * - Latest value caching for each event type * - Memory-efficient subject management * - Integration with browser's native event system * * @example * ```typescript * // Emit an event * eventService.emit('GLOBAL_CONTEXT_STATE_CHANGE', { * refreshOption: 'live', * isAutoRefreshEnabled: true * }); * * // Subscribe to events * eventService.on('GLOBAL_CONTEXT_STATE_CHANGE').subscribe(state => { * console.log('State changed:', state); * }); * * // Get the latest value * const latestState = eventService.getLatestValue('GLOBAL_CONTEXT_STATE_CHANGE'); * ``` */ class GlobalContextEventService { constructor() { /** Map storing BehaviorSubjects for each event type to cache latest values */ this.eventSubjects = new Map(); /** Observable stream of all global context events from the window */ this.events$ = fromEvent(window, EVENT_CONFIG.EVENT_NAME).pipe(map(event => event.detail)); } /** * Emits a global context event with type-safe payload. * * The event is both stored locally for latest value retrieval and dispatched * as a browser custom event for cross-component communication. * * @param type - The specific event type to emit (must be a valid GlobalContextEventType) * @param payload - Type-safe payload matching the event type requirements * * @throws {Error} When event type is invalid or payload validation fails * * @example * ```typescript * // Emit a state change event * eventService.emit('GLOBAL_CONTEXT_STATE_CHANGE', { * refreshOption: 'live', * isAutoRefreshEnabled: true * }); * * // Emit a refresh event * eventService.emit('GLOBAL_CONTEXT_REFRESH', { * dateFrom: '2024-01-01', * dateTo: '2024-01-02' * }); * ``` */ emit(type, payload) { const payloadCopy = structuredClone(payload); try { this.validateEventType(type); // Update the local subject with the new value const subject = this.getOrCreateEventSubject(type); subject.next(payloadCopy ?? null); // Dispatch the browser event for cross-component communication this.dispatchBrowserEvent(type, payloadCopy); } catch (error) { console.error(`Failed to emit global context event '${type}':`, error); throw error; } } /** * Creates an observable that emits whenever a specific event type occurs. * * The observable filters the global event stream to only emit events of the specified type. * This provides efficient, reactive event handling without manual event listener management. * * @param eventType - The specific event type to observe * @returns Observable that emits payloads of the specified event type * * @throws {Error} When event type is invalid * * @example * ```typescript * // Subscribe to state changes * eventService.on('GLOBAL_CONTEXT_STATE_CHANGE') * .pipe(takeUntilDestroyed()) * .subscribe(state => { * console.log('New state:', state); * }); * * // Subscribe to refresh events with filtering * eventService.on('GLOBAL_CONTEXT_REFRESH') * .pipe( * filter(data => data?.interval === 'MINUTES'), * takeUntilDestroyed() * ) * .subscribe(refreshData => { * console.log('Minute-based refresh:', refreshData); * }); * ``` */ on(eventType) { try { this.validateEventType(eventType); return this.events$.pipe(filter((event) => event.type === eventType), map(event => event.payload)); } catch (error) { console.error(`Failed to create observable for event '${eventType}':`, error); throw error; } } /** * Retrieves the most recently emitted value for a specific event type. * * This method provides synchronous access to the latest event data without * requiring a subscription. Returns null if no event of the specified type * has been emitted yet. * * @param eventType - The event type to retrieve the latest value for * @returns The latest emitted value or null if none exists * * @throws {Error} When event type is invalid * * @example * ```typescript * // Get the current state * const currentState = eventService.getLatestValue('GLOBAL_CONTEXT_STATE_CHANGE'); * if (currentState) { * console.log('Current refresh option:', currentState.refreshOption); * } * * // Check for latest refresh data * const refreshData = eventService.getLatestValue('GLOBAL_CONTEXT_REFRESH'); * if (refreshData?.dateFrom) { * console.log('Last refresh from:', refreshData.dateFrom); * } * ``` */ getLatestValue(eventType) { try { this.validateEventType(eventType); const value = this.getOrCreateEventSubject(eventType).getValue(); return structuredClone(value); } catch (error) { console.error(`Failed to get latest value for event '${eventType}':`, error); throw error; } } /** * Synchronizes dateTimeContext from REFRESH event to STATE_CHANGE event. * * This method ensures that when a REFRESH event contains dateTimeContext data, * it updates the STATE_CHANGE event subject's value directly without emitting a new event. * This keeps the internal state synchronized without triggering listeners. * * @param refreshDateTimeContext - The dateTimeContext from the REFRESH event to sync * * @example * ```typescript * // When a refresh occurs with new dateTimeContext * const refreshData = { dateFrom: '2024-01-01', dateTo: '2024-01-02', interval: 'HOURLY' }; * eventService.emit(GLOBAL_CONTEXT_EVENTS.REFRESH, refreshData); * * // Sync it to STATE_CHANGE internal state * eventService.syncRefreshToStateChange(refreshData); * ``` */ syncRefreshToStateChange(refreshDateTimeContext) { if (!refreshDateTimeContext) { return; } // Only update if we have all required properties for DateTimeContext if (refreshDateTimeContext.dateFrom && refreshDateTimeContext.dateTo && refreshDateTimeContext.interval) { // Get the STATE_CHANGE subject const stateChangeSubject = this.getOrCreateEventSubject(GLOBAL_CONTEXT_EVENTS.STATE_CHANGE); const currentState = stateChangeSubject.getValue() || {}; // Merge refresh dateTimeContext into current state const updatedState = { ...currentState, dateTimeContext: { dateFrom: refreshDateTimeContext.dateFrom, dateTo: refreshDateTimeContext.dateTo, interval: refreshDateTimeContext.interval } }; // Update the subject's value directly without emitting an event stateChangeSubject.next(updatedState); } } /** * Validates that an event type is not empty or invalid. * * @param eventType - The event type to validate * @throws {Error} When event type is invalid */ validateEventType(eventType) { if (!eventType || typeof eventType !== 'string' || eventType.trim().length === 0) { throw new Error('Event type must be a non-empty string'); } } /** * Dispatches a browser custom event for cross-component communication. * * @param type - Event type * @param payload - Event payload */ dispatchBrowserEvent(type, payload) { const customEvent = new CustomEvent(EVENT_CONFIG.EVENT_NAME, { detail: { type, payload, timestamp: Date.now() } }); window.dispatchEvent(customEvent); } /** * Gets or creates a BehaviorSubject for a specific event type. * Implements memory management by limiting the number of cached subjects. * * @param type - The event type * @returns BehaviorSubject for the event type */ getOrCreateEventSubject(type) { if (!this.eventSubjects.has(type)) { // Implement basic memory management this.ensureSubjectCacheLimit(); const newSubject = new BehaviorSubject(null); this.eventSubjects.set(type, newSubject); } return this.eventSubjects.get(type); } /** * Ensures the event subject cache doesn't exceed the maximum limit. * Removes the oldest subjects when limit is reached. */ ensureSubjectCacheLimit() { if (this.eventSubjects.size >= EVENT_CONFIG.MAX_CACHED_SUBJECTS) { const firstKey = this.eventSubjects.keys().next().value; if (firstKey) { const subject = this.eventSubjects.get(firstKey); if (subject && !subject.closed) { subject.complete(); } this.eventSubjects.delete(firstKey); } } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextEventService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextEventService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GlobalContextEventService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Service responsible for managing widget-level control settings and their interaction * with dashboard-level controls. Handles the resolution of settings based on display mode, * link states, and refresh options. * * Key responsibilities: * - Widget settings resolution for different display modes * - Link state management between inline and dashboard controls * - Dashboard control calculations based on link states * - Settings toggle operations for individual and bulk link changes */ class WidgetControlService { // ======================================== // PUBLIC API METHODS // ======================================== /** * Resolves widget settings for inline controls based on the specified mode and refresh option. * For dashboard mode, calculates the split between inline and dashboard controls. * * @param controls - Widget control configuration containing settings and links * @param mode - Display mode determining which settings to use * @param refreshOption - Refresh mode (live/history) for settings selection * @returns Resolved settings with inline controls, links, and dashboard controls */ resolveInlineControlSettings(controls, mode, refreshOption) { const baseSettings = this.getInlineSettingsFromWidgetControls(controls, mode, refreshOption); const baseLinks = this.getDefaultLinksFromControls(controls, mode, refreshOption); if (mode === GLOBAL_CONTEXT_DISPLAY_MODE.DASHBOARD) { // Calculate dashboard controls with the merged settings and links const { newSettings: newInlineSettings, dashboardSettings } = this.calculateDashboardSettings(baseSettings, baseLinks); return { settings: newInlineSettings, links: baseLinks, dashboardControls: dashboardSettings }; } return { settings: baseSettings, links: baseLinks, dashboardControls: {} }; } /** * Resolves widget settings for configuration controls based on the specified mode and refresh option. * Configuration controls are simpler and don't require dashboard control calculations. * * @param controls - Widget control configuration containing config settings * @param mode - Display mode determining which config settings to use * @param refreshOption - Refresh mode (live/history) for settings selection * @returns Resolved configuration settings with empty links and dashboard controls */ resolveConfigControlSettings(controls, mode, refreshOption) { const baseSettings = this.getConfigSettingsFromWidgetControls(controls, mode, refreshOption); return { settings: baseSettings, links: {}, dashboardControls: {} }; } /** * Extracts inline settings from widget controls for the specified display mode and refresh option. * Applies fallback logic for invalid refresh options, defaulting to LIVE mode. * * @param controls - Widget control configuration * @param displayMode - Display mode (dashboard, config, view_and_config) * @param refreshOption - Refresh mode, may be invalid and will fallback to LIVE * @returns Inline settings for the specified mode and refresh option */ getInlineSettingsFromWidgetControls(controls, displayMode, refreshOption) { const validatedRefresh = this.validateRefreshOption(refreshOption); const templateSettings = controls?.settings?.[displayMode]?.[validatedRefresh] || {}; return { ...templateSettings }; } /** * Extracts configuration settings from widget controls for the specified display mode and refresh option. * Applies fallback logic for invalid refresh optio