/// /// Copyright © 2016-2025 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. /// You may obtain a copy of the License at /// /// http://www.apache.org/licenses/LICENSE-2.0 /// /// Unless required by applicable law or agreed to in writing, software /// distributed under the License is distributed on an "AS IS" BASIS, /// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. /// See the License for the specific language governing permissions and /// limitations under the License. /// import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, NgZone, OnDestroy, OnInit, SecurityContext, ViewChild } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; import { PageComponent } from '@shared/components/page.component'; import { MatDialog } from '@angular/material/dialog'; import { DialogService } from '@core/services/dialog.service'; import { PageLink } from '@shared/models/page/page-link'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort } from '@angular/material/sort'; import { fromEvent, merge } from 'rxjs'; import { debounceTime, distinctUntilChanged, first, tap } from 'rxjs/operators'; import { toWidgetActionDescriptor, WidgetActionCallbacks, WidgetActionDescriptorInfo, WidgetActionsData, WidgetActionsDatasource } from '@home/components/widget/action/manage-widget-actions.component.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetActionDescriptor, WidgetActionSource, WidgetActionType, widgetType } from '@shared/models/widget.models'; import { WidgetActionDialogComponent, WidgetActionDialogData } from '@home/components/widget/action/widget-action-dialog.component'; import { deepClone } from '@core/utils'; import { hidePageSizePixelValue } from '@shared/models/constants'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { DomSanitizer } from '@angular/platform-browser'; @Component({ selector: 'tb-manage-widget-actions', templateUrl: './manage-widget-actions.component.html', styleUrls: ['./manage-widget-actions.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ManageWidgetActionsComponent), multi: true } ] }) export class ManageWidgetActionsComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy, ControlValueAccessor { @Input() disabled: boolean; @Input() widgetType: widgetType; @Input() defaultIconColor: string; @Input() callbacks: WidgetActionCallbacks; @Input() actionSources: {[actionSourceId: string]: WidgetActionSource}; @Input() additionalWidgetActionTypes: WidgetActionType[]; displayedColumns: string[]; pageLink: PageLink; textSearchMode = false; hidePageSize = false; dataSource: WidgetActionsDatasource; dragDisabled = true; private actionsMap: {[actionSourceId: string]: Array}; private viewsInited = false; private dirtyValue = false; private widgetResize$: ResizeObserver; private destroyed = false; @ViewChild('searchInput') searchInputField: ElementRef; @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; private propagateChange = (_: any) => {}; constructor(private translate: TranslateService, private utils: UtilsService, private dialog: MatDialog, private dialogs: DialogService, private cd: ChangeDetectorRef, private elementRef: ElementRef, private zone: NgZone, private sanitizer: DomSanitizer) { super(); const sortOrder: SortOrder = { property: 'actionSourceName', direction: Direction.ASC }; this.pageLink = new PageLink(10, 0, null, sortOrder); this.dataSource = new WidgetActionsDatasource(this.translate, this.utils); this.displayedColumns = ['actionSourceId', 'actionSourceName', 'name', 'icon', 'typeName', 'actions']; } ngOnInit(): void { this.widgetResize$ = new ResizeObserver(() => { this.zone.run(() => { const showHidePageSize = this.elementRef.nativeElement.offsetWidth < hidePageSizePixelValue; if (showHidePageSize !== this.hidePageSize) { this.hidePageSize = showHidePageSize; this.cd.markForCheck(); } }); }); this.widgetResize$.observe(this.elementRef.nativeElement); } ngOnDestroy(): void { this.destroyed = true; if (this.widgetResize$) { this.widgetResize$.disconnect(); } } ngAfterViewInit() { fromEvent(this.searchInputField.nativeElement, 'keyup') .pipe( debounceTime(150), distinctUntilChanged(), tap(() => { this.paginator.pageIndex = 0; this.updateData(); }) ) .subscribe(); this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); merge(this.sort.sortChange, this.paginator.page) .pipe( tap(() => this.updateData()) ) .subscribe(); this.viewsInited = true; if (this.dirtyValue) { this.dirtyValue = false; this.updateData(true); } } private updateData(reload: boolean = false) { this.pageLink.page = this.paginator.pageIndex; this.pageLink.pageSize = this.paginator.pageSize; this.pageLink.sortOrder.property = this.sort.active; this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; this.dataSource.loadActions(this.pageLink, reload); } dropAction(event: CdkDragDrop) { this.dragDisabled = true; const droppedAction: WidgetActionDescriptorInfo = event.item.data; this.dataSource.pageData$.pipe( first() ).subscribe((actions) => { const action = actions.data; let startActionSourceIndex = action.findIndex(element => element.actionSourceId === droppedAction.actionSourceId); const targetActions = this.getOrCreateTargetActions(droppedAction.actionSourceId); if (startActionSourceIndex === 0) { startActionSourceIndex -= targetActions.findIndex(element => element.id === action[0].id); } moveItemInArray(targetActions, event.previousIndex - startActionSourceIndex, event.currentIndex - startActionSourceIndex); this.onActionsUpdated(); }); } addAction($event: Event) { this.openWidgetActionDialog($event); } editAction($event: Event, action: WidgetActionDescriptorInfo) { this.openWidgetActionDialog($event, action); } private openWidgetActionDialog($event: Event, action: WidgetActionDescriptorInfo = null) { if ($event) { $event.stopPropagation(); } const isAdd = action === null; let prevActionSourceId = null; if (!isAdd) { prevActionSourceId = action.actionSourceId; } const availableActionSources: {[actionSourceId: string]: WidgetActionSource} = {}; for (const id of Object.keys(this.actionSources)) { const actionSource = this.actionSources[id]; if (actionSource.multiple) { availableActionSources[id] = actionSource; } else { if (!isAdd && action.actionSourceId === id) { availableActionSources[id] = actionSource; } else { const existing = this.actionsMap[id]; if (!existing || !existing.length) { availableActionSources[id] = actionSource; } } } } const actionsData: WidgetActionsData = { actionsMap: this.actionsMap, actionSources: availableActionSources }; this.dialog.open(WidgetActionDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { isAdd, callbacks: this.callbacks, actionsData, action: deepClone(action), widgetType: this.widgetType, defaultIconColor: this.defaultIconColor, additionalWidgetActionTypes: this.additionalWidgetActionTypes } }).afterClosed().subscribe( (res) => { if (res) { this.saveAction(res, isAdd, prevActionSourceId); } } ); } private saveAction(actionInfo: WidgetActionDescriptorInfo, isAdd: boolean, prevActionSourceId: string) { const actionSourceId = actionInfo.actionSourceId; const action = toWidgetActionDescriptor(actionInfo); if (isAdd) { const targetActions = this.getOrCreateTargetActions(actionSourceId); targetActions.push(action); } else { if (actionSourceId !== prevActionSourceId) { let targetActions = this.getOrCreateTargetActions(prevActionSourceId); const targetIndex = targetActions.findIndex((targetAction) => targetAction.id === action.id); if (targetIndex > -1) { targetActions.splice(targetIndex, 1); } targetActions = this.getOrCreateTargetActions(actionSourceId); targetActions.push(action); } else { const targetActions = this.getOrCreateTargetActions(actionSourceId); const targetIndex = targetActions.findIndex((targetAction) => targetAction.id === action.id); if (targetIndex > -1) { targetActions[targetIndex] = action; } } } this.onActionsUpdated(); } private getOrCreateTargetActions(actionSourceId: string): Array { const actionsMap = this.actionsMap; let targetActions = actionsMap[actionSourceId]; if (!targetActions) { targetActions = []; actionsMap[actionSourceId] = targetActions; } return targetActions; } deleteAction($event: Event, action: WidgetActionDescriptorInfo) { if ($event) { $event.stopPropagation(); } const title = this.translate.instant('widget-config.delete-action-title'); const content = this.translate.instant('widget-config.delete-action-text', {actionName: action.name}); const safeContent = this.sanitizer.sanitize(SecurityContext.HTML, content); this.dialogs.confirm(title, safeContent, this.translate.instant('action.no'), this.translate.instant('action.yes'), true).subscribe( (res) => { if (res) { const targetActions = this.getOrCreateTargetActions(action.actionSourceId); const targetIndex = targetActions.findIndex((targetAction) => targetAction.id === action.id); if (targetIndex > -1) { targetActions.splice(targetIndex, 1); this.onActionsUpdated(); } } }); } enterFilterMode() { this.textSearchMode = true; this.pageLink.textSearch = ''; setTimeout(() => { this.searchInputField.nativeElement.focus(); this.searchInputField.nativeElement.setSelectionRange(0, 0); }, 10); } exitFilterMode() { this.textSearchMode = false; this.pageLink.textSearch = null; this.paginator.pageIndex = 0; this.updateData(); } private resetSortAndFilter(update: boolean = true) { this.pageLink.textSearch = null; this.paginator.pageIndex = 0; const sortable = this.sort.sortables.get('actionSourceName'); this.sort.active = sortable.id; this.sort.direction = 'asc'; if (update) { this.updateData(true); } } registerOnChange(fn: any): void { this.propagateChange = fn; } registerOnTouched(_fn: any): void { } setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } writeValue(actions?: {[actionSourceId: string]: Array}): void { this.actionsMap = actions ?? {}; setTimeout(() => { if (!this.destroyed) { const actionData: WidgetActionsData = { actionsMap: this.actionsMap, actionSources: this.actionSources }; this.dataSource.setActions(actionData); if (this.viewsInited) { this.resetSortAndFilter(true); } else { this.dirtyValue = true; } } }, 0); } private onActionsUpdated() { this.updateData(true); this.propagateChange(this.actionsMap); } }