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/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index 7f21f8d2e7..a837b5ebde 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -67,6 +67,8 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { ObjectNode valuesNode = JacksonUtil.newObjectNode(); if (result instanceof Double doubleValue) { valuesNode.put(outputName, doubleValue); + } else if (result instanceof Long longValue) { + valuesNode.put(outputName, longValue); } else if (result instanceof Integer integerValue) { valuesNode.put(outputName, integerValue); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java index 87c1aaa707..4e42b41dd2 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfi import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @@ -45,6 +46,7 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private final QueueService queueService; private final TbClusterService tbClusterService; private final TbQueueAdmin tbQueueAdmin; + private final TopicService topicService; @Override public Queue saveQueue(Queue queue) { @@ -173,9 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private void createTopicsIfNeeded(Queue queue, Queue oldQueue) { int newPartitions = queue.getPartitions(); int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0; + String topic = topicService.buildTopicName(queue.getTopic()); for (int i = oldPartitions; i < newPartitions; i++) { tbQueueAdmin.createTopicIfNotExists( - new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(), + new TopicPartitionInfo(topic, queue.getTenantId(), i, false).getFullTopicName(), queue.getCustomProperties(), true); // forcing topic creation because the topic may still be cached on some nodes } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index 4454d41e6e..35f637dc09 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -154,7 +154,7 @@ public class SimpleCalculatedFieldStateTest { Output output = getCalculatedFieldConfig().getOutput(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49))); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49L))); } @Test @@ -184,7 +184,7 @@ public class SimpleCalculatedFieldStateTest { Output output = getCalculatedFieldConfig().getOutput(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35))); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35L))); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java new file mode 100644 index 0000000000..81cc325bdb --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java @@ -0,0 +1,141 @@ +/** + * Copyright © 2016-2026 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. + */ +package org.thingsboard.server.service.entitiy.queue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbQueueServiceTest { + + @Mock + private QueueService queueServiceMock; + @Mock + private TbClusterService tbClusterServiceMock; + @Mock + private TbQueueAdmin tbQueueAdminMock; + + private TopicService topicService; + private DefaultTbQueueService tbQueueService; + + private final TenantId tenantId = TenantId.SYS_TENANT_ID; + + @BeforeEach + public void setUp() { + topicService = new TopicService(); + tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService); + } + + private Queue newQueue(int partitions) { + Queue queue = new Queue(); + queue.setTenantId(tenantId); + queue.setName("testQueue"); + queue.setTopic("tb_rule_engine.testQueue"); + queue.setPartitions(partitions); + return queue; + } + + @Test + public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() { + // queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set) + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + // All created topics must carry the prefix - this is the fix. + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.0", + "thingsboard.tb_rule_engine.testQueue.1"); + // No unprefixed (orphan-prone) topic must ever be created. + assertThat(topicCaptor.getAllValues()) + .noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0") + || topic.equals("tb_rule_engine.testQueue.1")); + } + + @Test + public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() { + // queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved + ReflectionTestUtils.setField(topicService, "prefix", ""); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "tb_rule_engine.testQueue.0", + "tb_rule_engine.testQueue.1"); + } + + @Test + public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() { + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue oldQueue = newQueue(2); + oldQueue.setId(new QueueId(UUID.randomUUID())); + Queue updatedQueue = newQueue(4); + updatedQueue.setId(oldQueue.getId()); + + when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue); + when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue); + + tbQueueService.saveQueue(updatedQueue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.2", + "thingsboard.tb_rule_engine.testQueue.3"); + verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean()); + } + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java b/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java index 6681557c87..293120954d 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java +++ b/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java @@ -36,12 +36,16 @@ public class NumberUtils { return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue(); } + public static long toLong(double value) { + return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).longValue(); + } + public static Object roundResult(double value, Integer precision) { if (precision == null) { return value; } if (precision.equals(0)) { - return toInt(value); + return toLong(value); } return toFixed(value, precision); } diff --git a/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java b/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java index 1a1bf326e6..dabfe9cffc 100644 --- a/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java +++ b/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java @@ -51,11 +51,20 @@ public class NumberUtilsTest { assertThat(NumberUtils.toInt(28.0)).isEqualTo(28); } + @Test + public void toLong() { + assertThat(NumberUtils.toLong(doubleVal)).isEqualTo(1729L); + assertThat(NumberUtils.toLong(12.8)).isEqualTo(13L); + assertThat(NumberUtils.toLong(28.0)).isEqualTo(28L); + assertThat(NumberUtils.toLong(3_980_173_734.0)).isEqualTo(3_980_173_734L); + } + @Test public void roundResult() { assertThat(NumberUtils.roundResult(doubleVal, null)).isEqualTo(1729.1729); - assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729); + assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729L); assertThat(NumberUtils.roundResult(doubleVal, 2)).isEqualTo(1729.17); + assertThat(NumberUtils.roundResult(3_980_173_734.0, 0)).isEqualTo(3_980_173_734L); } } 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 12f5bc60d8..f7c719cc0c 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -119,6 +119,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 b493696d4e..043edd53f4 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 b04fad8caf..29b51230b0 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 @@ -278,6 +278,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), @@ -1617,6 +1618,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 a9d57a0d41..92cfd901c1 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 @@ -159,6 +159,8 @@ export interface IDashboardWidget { updateParamsFromData(detectChanges?: boolean): void; } +export type WidgetDestroyCallback = () => void; + export class WidgetContext { constructor(public dashboard: IDashboardComponent, @@ -363,6 +365,8 @@ export class WidgetContext { ...RxJSOperators }; + private destroyCallbacks: WidgetDestroyCallback[] = []; + registerPopoverComponent(popoverComponent: TbPopoverComponent) { this.popoverComponents.push(popoverComponent); popoverComponent.tbDestroy.subscribe(() => { @@ -402,6 +406,10 @@ export class WidgetContext { } } + registerDestroyCallback(destroyCallback: WidgetDestroyCallback) { + this.destroyCallbacks.push(destroyCallback); + } + showSuccessToast(message: string, duration: number = 1000, verticalPosition: NotificationVerticalPosition = 'bottom', horizontalPosition: NotificationHorizontalPosition = 'left', @@ -518,6 +526,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