UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1 lines 125 kB
{"version":3,"file":"c8y-ngx-components-events.mjs","sources":["../../events/events.model.ts","../../events/events.service.ts","../../events/event-details.component.ts","../../events/event-details.component.html","../../events/event-icon.pipe.ts","../../events/event-is-image.pipe.ts","../../events/events-view.service.ts","../../events/event-router-link.pipe.ts","../../events/events-date-filter.component.ts","../../events/events-date-filter.component.html","../../events/events-interval-refresh.component.ts","../../events/events-interval-refresh.component.html","../../events/events-list.component.ts","../../events/events-list.component.html","../../events/events-type-filter.component.ts","../../events/events-type-filter.component.html","../../events/events.component.ts","../../events/events.component.html","../../events/c8y-ngx-components-events.ts"],"sourcesContent":["import { QueryParamsHandling } from '@angular/router';\nimport { DateTimeContext } from '@c8y/ngx-components';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport {\n AlarmFilterInterval,\n INTERVALS,\n INTERVAL_TITLES\n} from '@c8y/ngx-components/interval-picker';\n\nexport interface EventsFilter {\n source?: string | number;\n type?: string;\n dateFrom?: Date;\n dateTo?: Date;\n pageSize?: number;\n withSourceChildren?: boolean;\n}\n\nexport const EVENT_RESERVED_KEYS = [\n 'creationTime',\n 'id',\n 'lastUpdated',\n 'self',\n 'source',\n 'text',\n 'time',\n 'type',\n 'c8y_IsBinary'\n];\n\nexport const EVENT_STANDARD_KEYS = {\n type: gettext('Type'),\n text: gettext('Text'),\n lastUpdated: gettext('Last updated')\n};\n\nexport const EVENTS_PATH = 'events';\n\n/**\n * Extended interval titles with an additional title for the case when no date is selected.\n */\nexport const INTERVAL_TITLES_EXTENDED: Record<AlarmFilterInterval['id'], string> = {\n ...INTERVAL_TITLES,\n none: gettext('No date filter')\n};\n\nexport const INTERVALS_EXTENDED: AlarmFilterInterval[] = [\n {\n id: 'none',\n title: gettext('No date filter')\n },\n ...INTERVALS\n];\n\nexport type WidgetTimeContextStateExtended = {\n date: DateTimeContext;\n interval: AlarmFilterInterval['id'];\n};\n\nexport type EventListFormFilters = {\n selectedDates?: DateTimeContext;\n interval?: AlarmFilterInterval['id'];\n};\n\n/**\n * Represents the navigation options for the events list component.\n */\nexport type EventNavigationOptions = {\n /**\n * Defines if the event should navigate to a detail view when clicked.\n */\n allowNavigationToEventsView: boolean;\n /**\n * Defines if the component should try to determine the context to navigate\n * to the correct event detail view or not. If set to true, the component will\n * not try to determine the context and will always navigate to the all events view.\n */\n alwaysNavigateToAllEvents: boolean;\n /**\n * Determines how query parameters should be handled during navigation.\n *\n * - `\"merge\"` : Merge new parameters with current parameters.\n * - `\"preserve\"` : Preserve current parameters.\n * - `\"\"` : Replace current parameters with new parameters. This is the default behavior.\n */\n queryParamsHandling: QueryParamsHandling;\n};\n","import { inject, Injectable } from '@angular/core';\nimport { IEvent, IEventBinary } from '@c8y/client';\nimport { FilesService, GenericFileIconPipe } from '@c8y/ngx-components';\nimport { difference, has, includes, keys, pickBy, without } from 'lodash-es';\nimport {\n EVENT_RESERVED_KEYS as RESERVED_KEYS,\n EVENT_STANDARD_KEYS as STANDARD_KEYS\n} from './events.model';\n\nconst IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];\n\n@Injectable({ providedIn: 'root' })\nexport class EventsService {\n private filesService = inject(FilesService);\n private fileIconPipe = new GenericFileIconPipe(this.filesService);\n\n resolveFileIcon(fileName: string): string {\n const extension = this.filesService.getFileExtension(fileName);\n if (!extension) {\n return 'file-o';\n }\n const genericType = this.fileIconPipe.getGenericType({ name: fileName } as File);\n return this.fileIconPipe.getIcon(genericType);\n }\n\n getStandardKeys(event: IEvent) {\n return pickBy(STANDARD_KEYS, (_, key) => has(event, key));\n }\n\n getNonStandardKeys(event: IEvent, excluding: string[] = []) {\n return without(\n difference(this.getKeys(event), keys(this.getStandardKeys(event))),\n ...excluding\n );\n }\n\n isImageBinary(binaryInfo: IEventBinary): boolean {\n if (!binaryInfo?.name) {\n return false;\n }\n const extension = this.filesService.getFileExtension(binaryInfo.name);\n return extension ? IMAGE_EXTENSIONS.includes(extension) : false;\n }\n\n getCustomFragments(event: IEvent): Record<string, unknown> | null {\n const nonStandardKeys = this.getNonStandardKeys(event);\n if (nonStandardKeys.length === 0) {\n return null;\n }\n\n const fragments: Record<string, unknown> = {};\n for (const key of nonStandardKeys) {\n fragments[key] = event[key];\n }\n return fragments;\n }\n\n arrayBufferToBase64(buffer: ArrayBuffer): string {\n const bytes = new Uint8Array(buffer);\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return btoa(chunks.join(''));\n }\n\n private getKeys(managedObject: IEvent) {\n return Object.keys({ ...managedObject }).filter(key => !includes(RESERVED_KEYS, key));\n }\n}\n","import { JsonPipe, KeyValuePipe } from '@angular/common';\nimport {\n ChangeDetectionStrategy,\n Component,\n computed,\n DestroyRef,\n inject,\n OnInit,\n signal\n} from '@angular/core';\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\nimport { ActivatedRoute, RouterLink } from '@angular/router';\nimport {\n EventBinaryService,\n EventService,\n IEvent,\n IEventBinary,\n IManagedObject,\n InventoryService\n} from '@c8y/client';\nimport {\n AlertService,\n AssetLinkPipe,\n C8yTranslatePipe,\n ColorService,\n DatePipe,\n HumanizePipe,\n IconDirective,\n IconPanelComponent,\n LoadingComponent\n} from '@c8y/ngx-components';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport { saveAs } from 'file-saver';\nimport { PopoverDirective } from 'ngx-bootstrap/popover';\nimport { EventsService } from './events.service';\n\ntype CustomFragment = Record<string, unknown> | null;\n\n@Component({\n selector: 'c8y-event-details',\n templateUrl: './event-details.component.html',\n imports: [\n AssetLinkPipe,\n C8yTranslatePipe,\n DatePipe,\n HumanizePipe,\n IconDirective,\n IconPanelComponent,\n JsonPipe,\n KeyValuePipe,\n LoadingComponent,\n PopoverDirective,\n RouterLink\n ],\n changeDetection: ChangeDetectionStrategy.OnPush\n})\nexport class EventDetailsComponent implements OnInit {\n readonly CREATION_TIME_HELP_TEXT = gettext(\n \"Time in which the event was created on the server. The time shown corresponds to the server's time.\"\n );\n readonly EVENT_ICON = 'online1';\n readonly TIME_HELP_TEXT = gettext(\n 'Time in which the event was created on the device. Device time can be different from server time.'\n );\n\n binaryPreviewLoading = signal(false);\n binaryPreviewUrl = signal<string | null>(null);\n binaryPreviewFailed = signal(false);\n customFragments = signal<CustomFragment>(null);\n isDownloading = signal(false);\n isLoading = signal(true);\n selectedEvent = signal<IEvent | null>(null);\n selectedEventSource = signal<IManagedObject | null>(null);\n typeColor = signal<string>('');\n\n binaryInfo = computed<IEventBinary | null>(() => this.selectedEvent()?.['c8y_IsBinary'] ?? null);\n hasBinary = computed(() => !!this.binaryInfo());\n canDownload = computed(() => this.binaryInfo()?.length !== undefined);\n binaryIcon = computed(() => {\n const info = this.binaryInfo();\n if (!info?.name) {\n return 'file-o';\n }\n return this.eventsService.resolveFileIcon(info.name);\n });\n\n private activatedRoute = inject(ActivatedRoute);\n private alertService = inject(AlertService);\n private colorService = inject(ColorService);\n private destroyRef = inject(DestroyRef);\n private eventBinaryService = inject(EventBinaryService);\n private eventService = inject(EventService);\n private eventsService = inject(EventsService);\n private inventoryService = inject(InventoryService);\n\n ngOnInit(): void {\n this.activatedRoute.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {\n if (params['id']) {\n void this.loadEventDetails(params['id']);\n }\n });\n }\n\n isComplexValue(value: unknown): boolean {\n return typeof value === 'object' && value !== null;\n }\n\n async downloadBinary(): Promise<void> {\n const event = this.selectedEvent();\n const binaryInfo = this.binaryInfo();\n if (!event || !binaryInfo) {\n return;\n }\n\n try {\n this.isDownloading.set(true);\n const response = await this.eventBinaryService.download(event);\n const arrayBuffer = await response.arrayBuffer();\n const contentType = response.headers.get('Content-Type') || 'application/octet-stream';\n const blob = new Blob([arrayBuffer], { type: contentType });\n saveAs(blob, binaryInfo.name || 'attachment');\n } catch (error: unknown) {\n this.alertService.addServerFailure(error);\n } finally {\n this.isDownloading.set(false);\n }\n }\n\n private async loadEventDetails(eventId: string): Promise<void> {\n try {\n this.isLoading.set(true);\n this.binaryPreviewUrl.set(null);\n this.binaryPreviewFailed.set(false);\n\n const { data: event } = await this.eventService.detail(eventId);\n this.selectedEvent.set(event);\n\n const [source, color] = await Promise.all([\n this.loadEventSource(event.source?.id),\n this.colorService.generateColor(event.type)\n ]);\n\n this.selectedEventSource.set(source);\n this.typeColor.set(color);\n this.customFragments.set(this.eventsService.getCustomFragments(event));\n\n if (event['c8y_IsBinary'] && this.eventsService.isImageBinary(event['c8y_IsBinary'])) {\n void this.loadBinaryPreview(event);\n }\n } catch (error: unknown) {\n this.alertService.addServerFailure(error);\n } finally {\n this.isLoading.set(false);\n }\n }\n\n private async loadEventSource(sourceId: string | number): Promise<IManagedObject | null> {\n if (!sourceId) {\n return null;\n }\n try {\n const { data } = await this.inventoryService.detail(sourceId);\n return data;\n } catch {\n return null;\n }\n }\n\n private async loadBinaryPreview(event: IEvent): Promise<void> {\n try {\n this.binaryPreviewLoading.set(true);\n const response = await this.eventBinaryService.download(event);\n const arrayBuffer = await response.arrayBuffer();\n const contentType = response.headers.get('Content-Type') || 'image/png';\n const base64 = this.eventsService.arrayBufferToBase64(arrayBuffer);\n this.binaryPreviewUrl.set(`data:${contentType};base64,${base64}`);\n } catch {\n this.binaryPreviewFailed.set(true);\n } finally {\n this.binaryPreviewLoading.set(false);\n }\n }\n}\n","@if (isLoading()) {\n <c8y-loading />\n} @else if (selectedEvent(); as event) {\n <!-- Header with event text -->\n <div\n class=\"card-header p-24 m-b-16 bg-component separator-bottom sticky-top\"\n style=\"margin: 0 -24px\"\n >\n <h4\n class=\"m-0\"\n data-cy=\"c8y-event-details-title\"\n >\n {{ event.text | translate }}\n </h4>\n </div>\n\n <c8y-icon-panel [sections]=\"[]\">\n <!-- Source section -->\n <div\n class=\"col-xs-12 col-md-6 d-flex p-b-8\"\n data-cy=\"c8y-event-details--source-wrapper\"\n >\n <div class=\"border-all fit-w d-flex\">\n <div class=\"p-8\">\n <i\n class=\"icon-24 m-t-4 stroked-icon status\"\n c8yIcon=\"contactless-payment\"\n ></i>\n </div>\n <div class=\"p-t-8 p-b-8 p-r-8 min-width-0\">\n <p class=\"text-label-small m-b-0 m-r-8\">{{ 'Source' | translate }}</p>\n <p class=\"small\">\n @if (selectedEventSource(); as source) {\n <button\n class=\"btn-link p-0 m-r-8 text-left text-truncate\"\n [title]=\"source.name || event.source?.id\"\n type=\"button\"\n [routerLink]=\"source | assetLink\"\n >\n <i c8yIcon=\"exchange\"></i>\n {{ source.name || event.source?.id }}\n </button>\n } @else {\n <span class=\"text-muted\">{{ event.source?.id }}</span>\n }\n </p>\n </div>\n </div>\n </div>\n\n <!-- Type section -->\n <div\n class=\"col-xs-12 col-md-6 d-flex p-b-8\"\n data-cy=\"c8y-event-details--type-wrapper\"\n >\n <div class=\"border-all fit-w d-flex\">\n <div class=\"p-8\">\n <span\n class=\"circle-icon-wrapper\"\n [style.background-color]=\"typeColor()\"\n >\n <i\n class=\"stroked-icon\"\n [c8yIcon]=\"EVENT_ICON\"\n ></i>\n </span>\n </div>\n <div class=\"p-t-8 p-b-8 p-r-8 min-width-0\">\n <p class=\"text-label-small m-b-0 m-r-8\">{{ 'Type' | translate }}</p>\n <p\n class=\"small text-truncate\"\n [title]=\"event.type\"\n >\n <code>{{ event.type }}</code>\n </p>\n </div>\n </div>\n </div>\n\n <!-- Time, Server creation time, and Last updated -->\n <div class=\"col-xs-12 col-md-12 p-b-16\">\n <div class=\"border-all fit-w d-flex\">\n <div class=\"p-8\">\n <i\n class=\"icon-24 text-gray-dark m-t-4\"\n c8yIcon=\"calendar\"\n data-cy=\"c8y-event-details--calendar-icon\"\n ></i>\n </div>\n <div class=\"p-t-8 p-b-0 p-r-8 flex-grow\">\n <div class=\"content-flex-50\">\n <!-- Device time -->\n <div\n class=\"col-4 p-b-8\"\n data-cy=\"c8y-event-details--time-wrapper\"\n >\n <p class=\"text-label-small m-b-0 m-r-8\">{{ 'Time' | translate }}</p>\n <p class=\"small\">\n {{ event.time | c8yDate: 'medium' }}\n <button\n class=\"btn-help btn-help--sm\"\n [attr.aria-label]=\"'Help' | translate\"\n [popover]=\"TIME_HELP_TEXT | translate\"\n placement=\"right\"\n triggers=\"focus\"\n container=\"body\"\n type=\"button\"\n ></button>\n </p>\n </div>\n <!-- Server creation time -->\n <div\n class=\"col-4 p-b-8\"\n data-cy=\"c8y-event-details--creation-time-wrapper\"\n >\n <p class=\"text-label-small m-b-0 m-r-8\">\n {{ 'Server creation time' | translate }}\n </p>\n <p class=\"small\">\n {{ event.creationTime | c8yDate: 'medium' }}\n <button\n class=\"btn-help btn-help--sm\"\n [attr.aria-label]=\"'Help' | translate\"\n [popover]=\"CREATION_TIME_HELP_TEXT | translate\"\n placement=\"right\"\n triggers=\"focus\"\n container=\"body\"\n type=\"button\"\n ></button>\n </p>\n </div>\n <!-- Last updated -->\n @if (event['lastUpdated']) {\n <div\n class=\"col-4 p-b-8\"\n data-cy=\"c8y-event-details--last-updated-wrapper\"\n >\n <p class=\"text-label-small m-b-0 m-r-8\">\n {{ 'Last updated' | translate }}\n </p>\n <p class=\"small\">\n {{ event['lastUpdated'] | c8yDate: 'medium' }}\n </p>\n </div>\n }\n </div>\n </div>\n </div>\n </div>\n\n <!-- Custom fragments section -->\n @if (customFragments(); as fragments) {\n <div\n class=\"col-xs-12 col-md-12 p-b-16\"\n data-cy=\"c8y-event-details--custom-fragments-wrapper\"\n >\n <div class=\"border-all fit-w d-flex\">\n <div class=\"p-8\">\n <i\n class=\"icon-24 text-gray-dark m-t-4\"\n c8yIcon=\"outgoing-data\"\n ></i>\n </div>\n <div\n class=\"p-t-8 p-b-0 p-r-8 flex-grow\"\n data-cy=\"event-details-custom-data\"\n >\n <ul class=\"list-unstyled small m-b-0\">\n @for (item of fragments | keyvalue; track item.key) {\n <li\n class=\"p-t-4 p-b-4 separator-bottom\"\n data-cy=\"event-details-custom-data-item\"\n >\n <label class=\"small m-b-4 text-label-small d-block\">\n {{ item.key | humanize }}\n </label>\n @if (isComplexValue(item.value)) {\n <pre class=\"m-b-0\"><code>{{ item.value | json }}</code></pre>\n } @else {\n <span>{{ item.value }}</span>\n }\n </li>\n }\n </ul>\n </div>\n </div>\n </div>\n }\n\n <!-- Attachment section -->\n @if (hasBinary()) {\n <div\n class=\"col-xs-12 col-md-12 p-b-16\"\n data-cy=\"c8y-event-details--attachment-wrapper\"\n >\n <div class=\"border-all fit-w d-flex\">\n <div class=\"p-8\">\n <i\n class=\"icon-24 text-gray-dark m-t-4\"\n [c8yIcon]=\"binaryIcon()\"\n ></i>\n </div>\n <div class=\"p-t-8 p-b-8 p-r-8 flex-grow\">\n <p class=\"text-label-small m-b-4 m-r-8\">{{ 'Attachment' | translate }}</p>\n\n <!-- Image preview -->\n @if (binaryPreviewLoading()) {\n <div class=\"m-b-16\">\n <c8y-loading></c8y-loading>\n </div>\n } @else if (binaryPreviewUrl(); as previewUrl) {\n @let imgAltText = 'Attachment preview' | translate;\n <div class=\"m-b-16\">\n <img\n class=\"max-width-100\"\n [src]=\"previewUrl\"\n [alt]=\"binaryInfo()?.name || imgAltText\"\n data-cy=\"c8y-event-details--attachment-preview\"\n />\n </div>\n } @else if (binaryPreviewFailed()) {\n <p\n class=\"text-muted small m-b-8\"\n translate\n >\n Preview unavailable.\n </p>\n }\n\n <!-- Download button -->\n @if (canDownload()) {\n <button\n class=\"btn btn-primary btn-xs\"\n [title]=\"'Download' | translate\"\n [attr.aria-busy]=\"isDownloading()\"\n type=\"button\"\n [disabled]=\"isDownloading()\"\n (click)=\"downloadBinary()\"\n data-cy=\"c8y-event-details--download-btn\"\n >\n <i [c8yIcon]=\"isDownloading() ? 'spinner' : 'download'\"></i>\n <span>{{ 'Download' | translate }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n }\n </c8y-icon-panel>\n}\n","import { Pipe, PipeTransform, inject } from '@angular/core';\nimport { IEvent, IEventBinary } from '@c8y/client';\nimport { EventsService } from './events.service';\n\nconst DEFAULT_EVENT_ICON = 'online1';\n\n@Pipe({\n name: 'eventIcon',\n standalone: true\n})\nexport class EventIconPipe implements PipeTransform {\n private eventsService = inject(EventsService);\n\n transform(event: IEvent): string {\n const binaryInfo: IEventBinary | undefined = event['c8y_IsBinary'];\n return binaryInfo?.name\n ? this.eventsService.resolveFileIcon(binaryInfo.name)\n : DEFAULT_EVENT_ICON;\n }\n}\n","import { Pipe, PipeTransform, inject } from '@angular/core';\nimport { IEvent } from '@c8y/client';\nimport { EventsService } from './events.service';\n\n@Pipe({\n name: 'eventIsImage',\n standalone: true\n})\nexport class EventIsImagePipe implements PipeTransform {\n private eventsService = inject(EventsService);\n\n transform(event: IEvent): boolean {\n return this.eventsService.isImageBinary(event['c8y_IsBinary']);\n }\n}\n","import { Injectable } from '@angular/core';\nimport { EventService, IEvent, IResultList } from '@c8y/client';\nimport { ContextData, DateTimeContext, ViewContext } from '@c8y/ngx-components';\nimport { DateTimeContextPickerService, TimeInterval } from '@c8y/ngx-components/global-context';\nimport { AlarmFilterInterval } from '@c8y/ngx-components/interval-picker';\nimport { Subject } from 'rxjs';\nimport { EVENTS_PATH, EventsFilter } from './events.model';\n\nexport interface BuildEventsFilterParams {\n source?: string | number | null;\n type?: string | null;\n dateRange?: DateTimeContext | null;\n dateTimeContext?: { dateFrom?: Date | string | null; dateTo?: Date | string | null } | null;\n withSourceChildren?: boolean;\n}\n\n@Injectable({\n providedIn: 'root'\n})\nexport class EventsViewService {\n readonly DEFAULT_PAGE_SIZE = 50;\n readonly DEFAULT_REFRESH_INTERVAL = 30_000;\n\n reloadEventsList$ = new Subject<void>();\n\n constructor(\n private eventService: EventService,\n private dateTimeContextPickerService: DateTimeContextPickerService\n ) {}\n\n /**\n * Emits a subject to trigger events list reload.\n */\n updateEventsList(): void {\n this.reloadEventsList$.next();\n }\n\n /**\n * Retrieves a list of events with optional filters.\n */\n async retrieveEvents(filter?: EventsFilter): Promise<IResultList<IEvent>> {\n const queryFilter: Record<string, unknown> = {\n pageSize: filter?.pageSize ?? this.DEFAULT_PAGE_SIZE,\n withTotalPages: true\n };\n\n if (filter?.source) {\n queryFilter['source'] = filter.source;\n queryFilter['withSourceChildren'] = filter.withSourceChildren ?? true;\n }\n\n if (filter?.type) {\n queryFilter['type'] = filter.type;\n }\n\n if (filter?.dateFrom) {\n queryFilter['dateFrom'] = filter.dateFrom.toISOString();\n }\n\n if (filter?.dateTo) {\n queryFilter['dateTo'] = filter.dateTo.toISOString();\n }\n\n return this.eventService.list(queryFilter);\n }\n\n /**\n * Returns the correct link based on the provided context data.\n */\n getRouterLink(contextData?: ContextData, event?: IEvent): string {\n let detailUrl = `/${EVENTS_PATH}`;\n if (event) {\n detailUrl = `/${EVENTS_PATH}/${event.id}`;\n }\n if (!contextData) {\n return detailUrl;\n }\n\n switch (contextData.context) {\n case ViewContext.Device:\n return `/device/${contextData.contextData.id}${detailUrl}`;\n case ViewContext.Group:\n return `/group/${contextData.contextData.id}${detailUrl}`;\n default:\n return detailUrl;\n }\n }\n\n /**\n * Returns the correct from and to dates based on the selected interval\n * @param intervalId the selected interval. E.g. 'none', 'hours', 'custom' ...\n * @returns The calculated date context based on the selected interval.\n */\n buildEventsFilter(params: BuildEventsFilterParams): EventsFilter {\n const dateFrom = params.dateTimeContext?.dateFrom ?? params.dateRange?.[0];\n const dateTo = params.dateTimeContext?.dateTo ?? params.dateRange?.[1];\n return {\n ...(params.source && {\n source: params.source,\n withSourceChildren: params.withSourceChildren ?? true\n }),\n ...(params.type && { type: params.type }),\n ...(dateFrom && { dateFrom: new Date(dateFrom) }),\n ...(dateTo && { dateTo: new Date(dateTo) })\n };\n }\n\n getDateTimeContextByInterval(intervalId: AlarmFilterInterval['id']): DateTimeContext {\n return this.dateTimeContextPickerService.getDateTimeContextByInterval(\n intervalId as TimeInterval\n );\n }\n}\n","import { Pipe, PipeTransform, inject } from '@angular/core';\nimport { ActivatedRoute } from '@angular/router';\nimport { IEvent } from '@c8y/client';\nimport { ContextRouteService } from '@c8y/ngx-components';\nimport { EventsViewService } from './events-view.service';\n\n@Pipe({\n name: 'eventRouterLink',\n standalone: true\n})\nexport class EventRouterLinkPipe implements PipeTransform {\n private activatedRoute = inject(ActivatedRoute);\n private contextRouteService = inject(ContextRouteService);\n private eventsViewService = inject(EventsViewService);\n\n transform(event: IEvent, alwaysNavigateToAllEvents = false): string {\n if (alwaysNavigateToAllEvents) {\n return this.eventsViewService.getRouterLink(null, event);\n }\n const contextData = this.contextRouteService.getContextData(this.activatedRoute);\n return this.eventsViewService.getRouterLink(contextData, event);\n }\n}\n","import {\n ChangeDetectionStrategy,\n Component,\n DestroyRef,\n inject,\n input,\n OnInit,\n output,\n signal,\n viewChild\n} from '@angular/core';\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\nimport { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';\nimport { ActivatedRoute, Router } from '@angular/router';\nimport {\n C8yTranslateDirective,\n C8yTranslatePipe,\n DatePipe,\n DateTimePickerComponent,\n FormGroupComponent,\n IconDirective,\n MessageDirective,\n MessagesComponent\n} from '@c8y/ngx-components';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport { AlarmFilterInterval, IntervalPickerComponent } from '@c8y/ngx-components/interval-picker';\nimport {\n BsDropdownDirective,\n BsDropdownMenuDirective,\n BsDropdownToggleDirective\n} from 'ngx-bootstrap/dropdown';\nimport { TooltipDirective } from 'ngx-bootstrap/tooltip';\nimport { take } from 'rxjs';\nimport { EventsViewService } from './events-view.service';\nimport {\n EventListFormFilters,\n INTERVAL_TITLES_EXTENDED,\n INTERVALS_EXTENDED,\n WidgetTimeContextStateExtended\n} from './events.model';\n\n@Component({\n selector: 'c8y-events-date-filter',\n templateUrl: './events-date-filter.component.html',\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n FormsModule,\n ReactiveFormsModule,\n BsDropdownDirective,\n BsDropdownToggleDirective,\n TooltipDirective,\n IconDirective,\n BsDropdownMenuDirective,\n IntervalPickerComponent,\n FormGroupComponent,\n C8yTranslateDirective,\n DateTimePickerComponent,\n MessagesComponent,\n MessageDirective,\n C8yTranslatePipe,\n DatePipe\n ]\n})\nexport class EventsDateFilterComponent implements OnInit {\n readonly INTERVALS = INTERVALS_EXTENDED;\n readonly INTERVAL_TITLES = INTERVAL_TITLES_EXTENDED;\n readonly DATE_FORMAT = 'short';\n\n readonly defaultInterval = input<AlarmFilterInterval['id']>('none');\n readonly updateQueryParams = input(true);\n date = signal<[string, string] | undefined>(undefined);\n noFilterLabel = gettext('No date filter');\n\n dateFilterChange = output<EventListFormFilters>();\n dropdown = viewChild(BsDropdownDirective);\n\n private activatedRoute = inject(ActivatedRoute);\n private destroyRef = inject(DestroyRef);\n private eventsViewService = inject(EventsViewService);\n private formBuilder = inject(FormBuilder);\n private router = inject(Router);\n\n form: ReturnType<EventsDateFilterComponent['createForm']>;\n\n ngOnInit() {\n const context = this.getDefaultContext();\n this.form = this.createForm(context);\n this.date.set([\n this.form.value.currentDateContextFromDate,\n this.form.value.currentDateContextToDate\n ]);\n this.activatedRoute.queryParams\n .pipe(take(1), takeUntilDestroyed(this.destroyRef))\n .subscribe(params => {\n if (!params.interval) {\n return;\n }\n if (params.interval !== 'custom') {\n this.updateDateTime(params.interval);\n } else {\n this.form.patchValue({\n currentDateContextInterval: params.interval,\n temporaryUserSelectedFromDate: params.dateFrom,\n temporaryUserSelectedToDate: params.dateTo\n });\n this.date.set([params.dateFrom, params.dateTo]);\n }\n });\n\n this.subscribeToIntervalChange();\n }\n\n applyDateFilter(): void {\n const interval = this.form.value.currentDateContextInterval;\n const isNoDateFilter = interval === 'none';\n\n const combinedFormEvent: EventListFormFilters = {\n interval,\n selectedDates: isNoDateFilter\n ? undefined\n : [\n new Date(this.form.value.temporaryUserSelectedFromDate),\n new Date(this.form.value.temporaryUserSelectedToDate)\n ]\n };\n\n // needed for custom interval\n this.date.set([\n this.form.value.temporaryUserSelectedFromDate,\n this.form.value.temporaryUserSelectedToDate\n ]);\n\n this.router.navigate([], {\n relativeTo: this.activatedRoute,\n queryParams: {\n interval,\n dateFrom: isNoDateFilter ? null : combinedFormEvent.selectedDates[0].toISOString(),\n dateTo: isNoDateFilter ? null : combinedFormEvent.selectedDates[1].toISOString()\n },\n queryParamsHandling: 'merge'\n });\n this.dateFilterChange.emit(combinedFormEvent);\n }\n\n private updateDateTime(interval: AlarmFilterInterval['id']): void {\n const date = this.eventsViewService.getDateTimeContextByInterval(interval);\n const dropdown = this.dropdown();\n if (dropdown) {\n dropdown.isOpen = false;\n }\n this.date.set(date.map(d => d.toISOString()) as [string, string]);\n this.form.patchValue(\n {\n temporaryUserSelectedFromDate: date[0].toISOString(),\n temporaryUserSelectedToDate: date[1].toISOString(),\n currentDateContextInterval: interval\n },\n { emitEvent: false }\n );\n this.applyDateFilter();\n }\n\n private getDefaultContext() {\n return {\n date: this.eventsViewService.getDateTimeContextByInterval(this.defaultInterval()),\n interval: this.defaultInterval()\n };\n }\n\n private subscribeToIntervalChange(): void {\n this.form.controls.currentDateContextInterval.valueChanges\n .pipe(takeUntilDestroyed(this.destroyRef))\n .subscribe(interval => {\n if (interval === 'custom') {\n this.form.patchValue(\n {\n temporaryUserSelectedFromDate:\n this.form.controls.temporaryUserSelectedFromDate.value === new Date(0).toISOString()\n ? this.form.controls.currentDateContextToDate.value\n : this.form.controls.temporaryUserSelectedFromDate.value,\n currentDateContextInterval: interval\n },\n { emitEvent: false }\n );\n return;\n }\n this.updateDateTime(interval);\n });\n }\n\n private createForm(context: WidgetTimeContextStateExtended) {\n return this.formBuilder.group({\n temporaryUserSelectedFromDate: context.date[0].toISOString(),\n temporaryUserSelectedToDate: context.date[1].toISOString(),\n currentDateContextFromDate: context.date[0].toISOString(),\n currentDateContextToDate: context.date[1].toISOString(),\n currentDateContextInterval: context.interval || 'custom'\n });\n }\n}\n","<form\n class=\"d-flex gap-16 p-l-xs-16 p-r-xs-16 m-t-xs-8 m-b-xs-8\"\n [formGroup]=\"form\"\n>\n <div\n class=\"dropdown flex-grow\"\n #dropDirection=\"bs-dropdown\"\n dropdown\n [insideClick]=\"true\"\n >\n <button\n class=\"dropdown-toggle form-control l-h-tight d-flex a-i-center\"\n attr.aria-label=\"{{\n (form.value.currentDateContextInterval === 'none'\n ? noFilterLabel\n : (date()?.[0] | c8yDate: DATE_FORMAT) + ' — ' + (date()?.[1] | c8yDate: DATE_FORMAT)\n ) | translate\n }}\"\n tooltip=\"{{\n (form.value.currentDateContextInterval === 'none'\n ? noFilterLabel\n : (date()?.[0] | c8yDate: DATE_FORMAT) + ' — ' + (date()?.[1] | c8yDate: DATE_FORMAT)\n ) | translate\n }}\"\n placement=\"top\"\n container=\"body\"\n data-cy=\"events-date-filter--date-picker-dropdown-button\"\n [adaptivePosition]=\"false\"\n [delay]=\"500\"\n dropdownToggle\n >\n <i\n class=\"m-r-4\"\n c8yIcon=\"schedule1\"\n ></i>\n <div class=\"d-col text-left fit-w\">\n <span\n class=\"text-12\"\n data-cy=\"widget-time-context--selected-interval\"\n >\n {{\n INTERVAL_TITLES[form.controls.currentDateContextInterval.value ?? 'none'] | translate\n }}\n </span>\n @if (form.controls.currentDateContextInterval.value !== 'none') {\n <span\n class=\"text-10 text-muted text-truncate\"\n data-cy=\"events-date-filter--selected-time-range\"\n >\n {{ date()?.[0] | c8yDate: DATE_FORMAT }} — {{ date()?.[1] | c8yDate: DATE_FORMAT }}\n </span>\n }\n </div>\n <span class=\"caret m-r-16 m-l-4\"></span>\n </button>\n\n <ul\n class=\"dropdown-menu dropdown-menu--date-range\"\n *dropdownMenu\n >\n <c8y-interval-picker\n class=\"d-contents\"\n formControlName=\"currentDateContextInterval\"\n [INTERVALS]=\"INTERVALS\"\n ></c8y-interval-picker>\n\n @if (form.controls.currentDateContextInterval.value === 'custom') {\n <div class=\"p-l-16 p-r-16\">\n <c8y-form-group\n class=\"m-b-8\"\n [class.has-error]=\"form.controls.temporaryUserSelectedFromDate.errors\"\n >\n <label\n [title]=\"'From`date`' | translate\"\n for=\"temporaryUserSelectedFromDate\"\n translate\n >\n From`date`\n </label>\n <c8y-date-time-picker\n [class.has-error]=\"form.controls.temporaryUserSelectedFromDate.errors\"\n id=\"temporaryUserSelectedFromDate\"\n [maxDate]=\"form.value.temporaryUserSelectedToDate ?? ''\"\n [placeholder]=\"'From`date`' | translate\"\n [formControl]=\"form.controls.temporaryUserSelectedFromDate\"\n ></c8y-date-time-picker>\n <c8y-messages [show]=\"form.controls.temporaryUserSelectedFromDate.errors ?? {}\">\n <c8y-message\n name=\"dateAfterRangeMax\"\n [text]=\"'This date is after the latest allowed date.' | translate\"\n ></c8y-message>\n <c8y-message\n name=\"invalidDateTime\"\n [text]=\"'This date is invalid.' | translate\"\n ></c8y-message>\n </c8y-messages>\n </c8y-form-group>\n\n <c8y-form-group\n class=\"m-b-8\"\n [class.has-error]=\"form.controls.temporaryUserSelectedToDate.errors\"\n >\n <label\n [title]=\"'To`date`' | translate\"\n for=\"temporaryUserSelectedToDate\"\n translate\n >\n To`date`\n </label>\n <c8y-date-time-picker\n [class.has-error]=\"form.controls.temporaryUserSelectedToDate.errors\"\n id=\"temporaryUserSelectedToDate\"\n [minDate]=\"form.value.temporaryUserSelectedFromDate ?? ''\"\n [placeholder]=\"'To`date`' | translate\"\n [formControl]=\"form.controls.temporaryUserSelectedToDate\"\n ></c8y-date-time-picker>\n <c8y-messages [show]=\"form.controls.temporaryUserSelectedToDate.errors ?? {}\">\n <c8y-message\n name=\"dateBeforeRangeMin\"\n [text]=\"'This date is before the earliest allowed date.' | translate\"\n ></c8y-message>\n <c8y-message\n name=\"invalidDateTime\"\n [text]=\"'This date is invalid.' | translate\"\n ></c8y-message>\n </c8y-messages>\n </c8y-form-group>\n </div>\n\n <div class=\"p-16 d-flex gap-8 separator-top\">\n <button\n class=\"btn btn-primary btn-sm flex-grow\"\n title=\"{{ 'Apply' | translate }}\"\n type=\"button\"\n data-cy=\"events-date-filter--apply-button\"\n (click)=\"applyDateFilter(); dropDirection.isOpen = false\"\n [disabled]=\"(form.pristine && form.untouched) || form.invalid\"\n translate\n >\n Apply\n </button>\n </div>\n }\n </ul>\n </div>\n</form>\n","import {\n AfterViewInit,\n ChangeDetectionStrategy,\n Component,\n DestroyRef,\n effect,\n inject,\n input,\n output,\n untracked,\n viewChild\n} from '@angular/core';\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\nimport { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';\nimport { C8yTranslatePipe, CountdownIntervalComponent, IconDirective } from '@c8y/ngx-components';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport { TooltipDirective } from 'ngx-bootstrap/tooltip';\nimport { filter } from 'rxjs/operators';\n\n@Component({\n selector: 'c8y-events-interval-refresh',\n templateUrl: './events-interval-refresh.component.html',\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n FormsModule,\n ReactiveFormsModule,\n TooltipDirective,\n CountdownIntervalComponent,\n IconDirective,\n C8yTranslatePipe\n ]\n})\nexport class EventsIntervalRefreshComponent implements AfterViewInit {\n readonly DEFAULT_INTERVAL_VALUES = [5_000, 10_000, 15_000, 30_000, 60_000];\n readonly DEFAULT_INTERVAL_VALUE = 30_000;\n readonly DISABLE_AUTO_REFRESH = gettext('Disable auto refresh');\n readonly ENABLE_AUTO_REFRESH = gettext('Enable auto refresh');\n readonly SECONDS_UNTIL_REFRESH = gettext('{{ seconds }} s');\n\n isLoading = input(false);\n isDisabled = input(false);\n isIntervalToggleEnabled = input(true);\n\n onCountdownEnded = output<void>();\n\n countdownIntervalComponent = viewChild(CountdownIntervalComponent);\n\n private fb = inject(FormBuilder);\n toggleIntervalForm = this.initForm();\n\n private destroyRef = inject(DestroyRef);\n private doesUserCheckedIntervalToggle = false;\n\n constructor() {\n effect(() => {\n const externalValue = this.isIntervalToggleEnabled();\n const intervalEnabledControl = this.toggleIntervalForm.get('intervalEnabled');\n\n const shouldUpdate =\n !intervalEnabledControl.dirty || (this.doesUserCheckedIntervalToggle && externalValue);\n\n if (shouldUpdate && intervalEnabledControl.value !== externalValue) {\n intervalEnabledControl.setValue(externalValue);\n }\n });\n\n effect(() => {\n const loading = this.isLoading();\n untracked(() => {\n if (loading) {\n this.countdownIntervalComponent()?.stop();\n } else {\n this.countdownIntervalComponent()?.reset();\n }\n });\n });\n }\n\n get isToggleEnabled(): boolean {\n return !this.isDisabled() && this.toggleIntervalForm.get('intervalEnabled').value;\n }\n\n ngAfterViewInit(): void {\n this.onIntervalToggleChange();\n this.listenToRefreshIntervalChange();\n }\n\n resetCountdown(): void {\n this.countdownIntervalComponent()?.reset();\n }\n\n trackUserClickOnIntervalToggle(target: EventTarget): void {\n this.doesUserCheckedIntervalToggle = (target as HTMLInputElement).checked;\n }\n\n getTooltip(): string {\n return this.isDisabled()\n ? gettext('Disabled')\n : this.isToggleEnabled\n ? this.DISABLE_AUTO_REFRESH\n : this.ENABLE_AUTO_REFRESH;\n }\n\n private startCountdown(): void {\n this.countdownIntervalComponent()?.start();\n }\n\n private onIntervalToggleChange(): void {\n this.toggleIntervalForm\n .get('intervalEnabled')\n .valueChanges.pipe(takeUntilDestroyed(this.destroyRef), filter(Boolean))\n .subscribe(() => setTimeout(() => this.startCountdown()));\n }\n\n private initForm() {\n return this.fb.group({\n intervalEnabled: true,\n refreshInterval: this.DEFAULT_INTERVAL_VALUE\n });\n }\n\n private listenToRefreshIntervalChange(): void {\n this.toggleIntervalForm\n .get('refreshInterval')\n .valueChanges.pipe(takeUntilDestroyed(this.destroyRef))\n .subscribe(() => this.resetCountdown());\n }\n}\n","<form\n class=\"d-flex a-i-center fit-w fit-h\"\n [formGroup]=\"toggleIntervalForm\"\n>\n <label class=\"m-b-0 m-r-8 text-normal text-muted flex-no-shrink\">\n {{ 'Auto refresh' | translate }}\n </label>\n <div class=\"input-group\">\n <label\n class=\"toggle-countdown\"\n [class.toggle-countdown-disabled]=\"isDisabled()\"\n [attr.aria-label]=\"getTooltip() | translate\"\n [tooltip]=\"getTooltip() | translate\"\n placement=\"bottom\"\n [adaptivePosition]=\"false\"\n [delay]=\"500\"\n data-cy=\"c8y-events-interval-refresh--toggle-countdown\"\n >\n <input\n type=\"checkbox\"\n data-cy=\"c8y-events-interval-toggle\"\n formControlName=\"intervalEnabled\"\n (click)=\"trackUserClickOnIntervalToggle($event.target)\"\n />\n @if (isToggleEnabled) {\n <c8y-countdown-interval\n [countdownInterval]=\"toggleIntervalForm.value.refreshInterval ?? DEFAULT_INTERVAL_VALUE\"\n (countdownEnded)=\"onCountdownEnded.emit()\"\n />\n } @else {\n <i\n c8yIcon=\"pause\"\n data-cy=\"c8y-events-interval-refresh--pause\"\n ></i>\n }\n </label>\n @if (!isDisabled()) {\n <div class=\"c8y-select-wrapper\">\n <select\n class=\"form-control text-12\"\n [attr.aria-label]=\"'Refresh interval in seconds' | translate\"\n [tooltip]=\"'Refresh interval in seconds' | translate\"\n placement=\"bottom\"\n [adaptivePosition]=\"false\"\n [delay]=\"500\"\n [container]=\"'body'\"\n formControlName=\"refreshInterval\"\n data-cy=\"c8y-events-interval-refresh--selector\"\n >\n @for (refreshInterval of DEFAULT_INTERVAL_VALUES; track refreshInterval) {\n <option\n [disabled]=\"isDisabled()\"\n [ngValue]=\"refreshInterval\"\n [attr.data-cy]=\"'c8y-interval-' + refreshInterval\"\n >\n {{ SECONDS_UNTIL_REFRESH | translate: { seconds: refreshInterval / 1000 } }}\n </option>\n }\n </select>\n <span></span>\n </div>\n }\n <div class=\"input-group-btn\">\n <button\n class=\"btn btn-default\"\n style=\"border-left: 0\"\n [attr.aria-label]=\"'Refresh' | translate\"\n [tooltip]=\"'Refresh' | translate\"\n placement=\"bottom\"\n type=\"button\"\n [adaptivePosition]=\"false\"\n [delay]=\"500\"\n [disabled]=\"isDisabled() || isLoading()\"\n (click)=\"onCountdownEnded.emit()\"\n data-cy=\"c8y-events-interval-refresh--btn\"\n >\n <i\n [class.icon-spin]=\"isLoading()\"\n c8yIcon=\"refresh\"\n ></i>\n </button>\n </div>\n </div>\n</form>\n","import {\n AfterViewInit,\n ChangeDetectionStrategy,\n Component,\n computed,\n DestroyRef,\n effect,\n inject,\n input,\n output,\n signal,\n untracked,\n viewChild\n} from '@angular/core';\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\nimport {\n ActivatedRoute,\n NavigationEnd,\n Router,\n RouterLink,\n RouterLinkActive\n} from '@angular/router';\nimport {\n EventBinaryService,\n IEvent,\n IEventBinary,\n IFetchResponse,\n IManagedObjectBinary,\n IResultList\n} from '@c8y/client';\nimport {\n C8yTranslateDirective,\n C8yTranslatePipe,\n DatePipe,\n ForOfDirective,\n IconDirective,\n ListGroupComponent,\n ListItemBodyComponent,\n ListItemComponent,\n ListItemIconComponent,\n ListItemTimelineComponent,\n LoadMoreMode,\n SplitViewAlertsComponent,\n SplitViewHeaderActionsComponent,\n SplitViewListComponent\n} from '@c8y/ngx-components';\nimport { FilePreviewModule } from '@c8y/ngx-components/file-preview';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport { BsModalService } from 'ngx-bootstrap/modal';\nimport { fromEvent, timer } from 'rxjs';\nimport { debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';\nimport { EventIconPipe } from './event-icon.pipe';\nimport { EventIsImagePipe } from './event-is-image.pipe';\nimport { EventRouterLinkPipe } from './event-router-link.pipe';\nimport { EventNavigationOptions } from './events.model';\n\n@Component({\n selector: 'c8y-events-list',\n templateUrl: './events-list.component.html',\n imports: [\n C8yTranslateDirective,\n C8yTranslatePipe,\n DatePipe,\n EventIconPipe,\n EventIsImagePipe,\n EventRouterLinkPipe,\n ForOfDirective,\n IconDirective,\n ListGroupComponent,\n ListItemBodyComponent,\n ListItemComponent,\n ListItemIconComponent,\n ListItemTimelineComponent,\n RouterLink,\n RouterLinkActive,\n FilePreviewModule,\n SplitViewAlertsComponent,\n SplitViewHeaderActionsComponent,\n SplitViewListComponent\n ],\n changeDetection: ChangeDetectionStrategy.OnPush\n})\nexport class EventsListComponent implements AfterViewInit {\n readonly EMPTY_STATE_TITLE = gettext('No events to display.');\n readonly LIST_TITLE = gettext('Events list');\n\n /**\n * The paginated result list of events to display.\n */\n events = input<IResultList<IEvent>>();\n\n /**\n * Whether the events are currently being fetched.\n */\n isLoading = input(false);\n\n /**\n * Controls the \"load more\" button behavior at the bottom of the list.\n */\n loadMoreMode = input<LoadMoreMode>('hidden');\n\n /**\n * Whether to show a file preview button for image events.\n */\n showPreview = input(false);\n\n /**\n * Defines options for how the events list should navigate when a user clicks on an event.\n */\n navigationOptions = input<EventNavigationOptions>({\n allowNavigationToEventsView: true,\n alwaysNavigateToAllEvents: false,\n queryParamsHandling: 'merge'\n });\n\n /**\n * Emits `true` when the list is scrolled past the threshold, `false` when scrolled back.\n * Used to hide the countdown interval refresh control.\n */\n onScrollingStateChange = output<boolean>();\n\n /**\n * Emits `true` when a file preview is opened, `false` when closed.\n */\n onPreviewStateChange = output<boolean>();\n\n /**\n * Emits the event that was clicked by the user.\n */\n onSelectedEvent = output<IEvent>();\n\n activeEvent = signal<IEvent | null>(null);\n activeChildParamId = signal<string | null>(null);\n pendingActiveCheck = signal(true);\n showEmptyState = computed(\n () => !!this.events() && !this.events()?.data?.length && !this.isLoading()\n );\n showFilterWarning = computed(\n () =>\n !this.pendingActiveCheck() &&\n !!this.activeChildParamId() &&\n this.activeEvent()?.id !== this.activeChildParamId()\n );\n\n svListComponent = viewChild<SplitViewListComponent>('scrollWrapper');\n\n private isPreviewOpen = signal(false);\n\n /** Scroll threshold in pixels after which the countdown interval is hidden. */\n private readonly HIDE_INTERVAL_COUNTDOWN_SCROLL = 50;\n private readonly activatedRoute = inject(ActivatedRoute);\n private readonly bsModalService = inject(BsModalService);\n private readonly destroyRef = inject(DestroyRef);\n private readonly eventBinaryService = inject(EventBinaryService);\n private readonly router = inject(Router);\n\n constructor() {\n this.setupActiveChildParamTracking();\n this.setupPreviewModalCloseListener();\n\n effect(() => {\n this.events();\n untracked(() => {\n this.activeEvent.set(null);\n this.pendingActiveCheck.set(true);\n if (this.activeChildParamId()) {\n // Wait for routerLinkActive to confirm the match via activeRouteChanged.\n // Fallback clears pending state so warning can show if event is not in the list.\n timer(500)\n .pipe(takeUntilDestroyed(this.destroyRef))\n .subscribe(() => this.pendingActiveCheck.set(false));\n } else {\n this.pendingActiveCheck.set(false);\n }\n });\n });\n }\n\n ngAfterViewInit(): void {\n this.setupScrollListener();\n }\n\n onEventClick(event: IEvent): void {\n this.onSelectedEvent.emit(event);\n }\n\n activeRouteChanged(isActive: boolean, scrollAnchor: ListItemComponent, event: IEvent): void {\n if (isActive) {\n scrollAnchor.element.nativeElement.scrollIntoView({\n behavior: 'smooth',\n block: 'nearest'\n });\n this.activeEvent.set(event);\n this.pendingActiveCheck.set(false);\n }\n }\n\n onPreviewClick(mouseEvent: MouseEvent): void {\n mouseEvent.stopPropagation();\n this.isPreviewOpen.set(true);\n th