diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json index 25b9e5ac5d..a27996593b 100644 --- a/application/src/main/data/json/system/widget_types/html_container.json +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n", + "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'javaScript': {\n name: 'JavaScript',\n multiple: true\n }\n };\n}", "settingsDirective": "tb-html-container-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-html-container-basic-config", diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 5572730923..9ef4a4461b 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -110,6 +110,7 @@ export interface WidgetActionsApi { elementClick: ($event: Event) => void; cardClick: ($event: Event) => void; click: ($event: Event) => void; + invokeAction: ($event: Event, actionName: string, additionalParams?: any) => void; getActiveEntityInfo: () => SubscriptionEntityInfo; openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string, hideDashboardToolbar?: boolean, dialogWidth?: number, dialogHeight?: number) => MatDialogRef; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html index 72f4b0f1d8..500d974726 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
widget-config.actions
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts index 0320dc7fd1..389c780e4c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts @@ -26,6 +26,7 @@ import { import { deepClone } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-widget-actions-panel', @@ -45,6 +46,10 @@ export class WidgetActionsPanelComponent implements ControlValueAccessor, OnInit @Input() disabled: boolean; + @Input() + @coerceBoolean() + strokedPanel = false; + actionsFormGroup: UntypedFormGroup; private propagateChange = (_val: any) => {}; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html index 298bdb6e61..907da3e14b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html @@ -17,4 +17,8 @@ --> + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts index d057acf057..3872e07853 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts @@ -51,12 +51,14 @@ export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponen protected onConfigSet(configData: WidgetConfigComponentData) { const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; this.htmlContainerWidgetConfigForm = this.fb.group({ - settings: [settings, []] + settings: [settings, []], + actions: [configData.config.actions || {}, []] }); } protected prepareOutputConfig(config: any): WidgetConfigComponentData { this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; + this.widgetConfig.config.actions = config.actions; return this.widgetConfig; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts index f7bae926bc..51ee9871f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts @@ -15,8 +15,10 @@ /// import { - Component, + ChangeDetectorRef, + Component, ComponentRef, ElementRef, + inject, Inject, Injector, Input, @@ -96,6 +98,7 @@ export class HtmlContainerWidgetComponent implements OnInit { @Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type, private dynamicComponentFactoryService: DynamicComponentFactoryService, private utils: UtilsService, + private cdr: ChangeDetectorRef, private resources: ResourcesService) {} ngOnInit(): void { @@ -160,11 +163,13 @@ export class HtmlContainerWidgetComponent implements OnInit { this.compileAngularFunction().subscribe( { next: (containerFunction) => { - try { - this.initAngularComponent(imports, containerFunction); - } catch (e) { - this.handleWidgetException(e); - } + setTimeout(() => { + try { + this.initAngularComponent(imports, containerFunction); + } catch (e) { + this.handleWidgetException(e); + } + }); }, error: (e) => { this.handleWidgetException(e); @@ -200,9 +205,16 @@ export class HtmlContainerWidgetComponent implements OnInit { compileModules = compileModules.concat(imports); } const self = () => this; + + let containerRef: ComponentRef; + this.dynamicComponentFactoryService.createDynamicComponent( class TbContainerInstance { + + private cdr = inject(ChangeDetectorRef); + ngOnInit(): void { + this.cdr.detach(); if (containerFunction) { const instance = self(); try { @@ -212,6 +224,15 @@ export class HtmlContainerWidgetComponent implements OnInit { } } } + ngDoCheck(): void { + const instance = self(); + try { + this.cdr.detectChanges() + } catch (error) { + containerRef.destroy(); + instance.handleWidgetException(error) + } + } ngOnDestroy(): void { destroyContainerInstanceResources(); } @@ -224,7 +245,7 @@ export class HtmlContainerWidgetComponent implements OnInit { this.containerInstanceComponentType = componentType; const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector}); try { - this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, + containerRef = this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, {index: 0, injector}); } catch (error) { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index cb96a493a1..fc7ad9f64b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -276,6 +276,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, elementClick: this.elementClick.bind(this), cardClick: this.cardClick.bind(this), click: this.click.bind(this), + invokeAction: this.invokeAction.bind(this), getActiveEntityInfo: this.getActiveEntityInfo.bind(this), openDashboardStateInSeparateDialog: this.openDashboardStateInSeparateDialog.bind(this), openDashboardStateInPopover: this.openDashboardStateInPopover.bind(this), @@ -1590,6 +1591,16 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, } } + private invokeAction($event: Event, actionName: string, additionalParams?: any) { + const descriptors = this.getActionDescriptors('javaScript'); + if (descriptors?.length) { + const found = descriptors.find(d => d.name === actionName); + if (found) { + this.handleWidgetAction($event, found, null, null, additionalParams); + } + } + } + private onWidgetAction($event: Event, action: WidgetAction) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 1840bcce9d..d72587812c 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -149,6 +149,8 @@ export interface IDashboardWidget { updateWidgetParams(): void; } +export type WidgetDestroyCallback = () => void; + export class WidgetContext { constructor(public dashboard: IDashboardComponent, @@ -343,6 +345,8 @@ export class WidgetContext { ...RxJSOperators }; + private destroyCallbacks: WidgetDestroyCallback[] = []; + registerPopoverComponent(popoverComponent: TbPopoverComponent) { this.popoverComponents.push(popoverComponent); popoverComponent.tbDestroy.subscribe(() => { @@ -382,6 +386,10 @@ export class WidgetContext { } } + registerDestroyCallback(destroyCallback: WidgetDestroyCallback) { + this.destroyCallbacks.push(destroyCallback); + } + showSuccessToast(message: string, duration: number = 1000, verticalPosition: NotificationVerticalPosition = 'bottom', horizontalPosition: NotificationHorizontalPosition = 'left', @@ -494,6 +502,13 @@ export class WidgetContext { labelPattern.destroy(); } this.labelPatterns.clear(); + this.destroyCallbacks.forEach((destroyCallback) => { + try { + destroyCallback() + } catch (_ignoredError) { /* empty */ } + } + ); + this.destroyCallbacks.length = 0; this.width = undefined; this.height = undefined; this.destroyed = true; diff --git a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts index e6ce4b3ef7..757677fadd 100644 --- a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts +++ b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts @@ -632,6 +632,28 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi optional: true } ] + }, + invokeAction: { + description: 'Invoke action with JavaScript action source.', + meta: 'function', + args: [ + { + name: '$event', + description: 'DOM event object associated with action.', + type: 'Event' + }, + { + name: 'actionName', + description: 'Name of the configured action with JavaScript action source.', + type: 'string' + }, + { + name: 'additionalParams', + description: 'Optional payload merged into the action context and forwarded to the configured JavaScript action function as its additionalParams argument. Use it to pass row data, button state, or any extra values the action handler should react to.', + type: 'object', + optional: true + } + ] } } }, @@ -736,6 +758,17 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi } } } + }, + registerDestroyCallback: { + description: 'Registers a teardown callback that will be invoked exactly once when the widget is about to be destroyed (dashboard navigation, edit/view switch, layout change, etc.). Use it to release resources acquired during widget setup so they don\'t leak across widget reloads — e.g. unsubscribe RxJS subscriptions, detach DOM/window event listeners, clear setInterval / setTimeout timers, abort outstanding HTTP requests, destroy third-party plugin instances. Multiple callbacks may be registered; they are executed in registration order.', + meta: 'function', + args: [ + { + description: 'Zero-argument function executed when the widget is destroyed. Should be idempotent — synchronously dispose of one specific resource (e.g. one subscription or one listener) and avoid throwing; throwing here may prevent later cleanup callbacks from running.', + name: 'destroyCallback', + type: '() => void', + } + ] } }, ...serviceCompletions