Browse Source

Merge remote-tracking branch 'upstream/lts-4.2' into lts-4.3

# Conflicts:
#	pom.xml
#	ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
#	ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
#	ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
#	ui-ngx/src/assets/locale/locale.constant-en_US.json
pull/15589/head
Vladyslav_Prykhodko 5 months ago
parent
commit
44b0eb4793
  1. 3
      application/src/main/data/json/system/widget_bundles/cards.json
  2. 3
      application/src/main/data/json/system/widget_bundles/html_widgets.json
  3. 52
      application/src/main/data/json/system/widget_types/html_container.json
  4. 47
      pom.xml
  5. 9
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  6. 20
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html
  7. 62
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts
  8. 318
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts
  9. 63
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts
  10. 98
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html
  11. 28
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss
  12. 186
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts
  13. 5
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  14. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html
  15. 20
      ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html
  16. 65
      ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts
  17. 5
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  18. 3
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  19. 2
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  20. 1
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  21. 12
      ui-ngx/src/assets/locale/locale.constant-en_US.json

3
application/src/main/data/json/system/widget_bundles/cards.json

@ -24,6 +24,7 @@
"cards.html_value_card", "cards.html_value_card",
"cards.markdown_card", "cards.markdown_card",
"cards.simple_card", "cards.simple_card",
"unread_notifications" "unread_notifications",
"html_container"
] ]
} }

3
application/src/main/data/json/system/widget_bundles/html_widgets.json

@ -11,6 +11,7 @@
"widgetTypeFqns": [ "widgetTypeFqns": [
"cards.html_card", "cards.html_card",
"cards.html_value_card", "cards.html_value_card",
"cards.markdown_card" "cards.markdown_card",
"html_container"
] ]
} }

52
application/src/main/data/json/system/widget_types/html_container.json

File diff suppressed because one or more lines are too long

47
pom.xml

@ -63,9 +63,13 @@
<pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder> <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder> <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<thingsboard.client.version>4.3.1.2</thingsboard.client.version> <thingsboard.client.version>4.3.1.2</thingsboard.client.version>
<spring-boot.version>3.5.13</spring-boot.version> <spring-boot.version>3.5.14</spring-boot.version>
<tomcat.version>10.1.54</tomcat.version> <!-- to fix CVE-2026-34487, CVE-2026-34486, CVE-2026-34483. TODO: remove when fixed in spring-boot-dependencies --> <!-- TODO: remove spring-boot-test.version override and the matching dependencyManagement entries below
once Spring Boot 3.5.15+ is released with a fix for the ImportsContextCustomizer regression in 3.5.14
that causes "Duplicate spy definition" failures on legacy @SpyBean fields (see PR #15557). -->
<spring-boot-test.version>3.5.13</spring-boot-test.version>
<commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies --> <commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies -->
<postgresql.version>42.7.11</postgresql.version> <!-- to fix CVE-2026-42198. TODO: remove when fixed in spring-boot-dependencies -->
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version> <javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jjwt.version>0.12.5</jjwt.version> <jjwt.version>0.12.5</jjwt.version>
<rat.version>0.10</rat.version> <!-- unused --> <rat.version>0.10</rat.version> <!-- unused -->
@ -90,7 +94,7 @@
<protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet--> <protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<grpc.version>1.76.0</grpc.version> <grpc.version>1.76.0</grpc.version>
<tbel.version>1.2.9</tbel.version> <tbel.version>1.2.9</tbel.version>
<lombok.version>1.18.44</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths --> <lombok.version>1.18.46</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths -->
<paho.client.version>1.2.5</paho.client.version> <paho.client.version>1.2.5</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version> <paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>
<os-maven-plugin.version>1.7.1</os-maven-plugin.version> <os-maven-plugin.version>1.7.1</os-maven-plugin.version>
@ -1002,25 +1006,6 @@
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<!-- Temporary tomcat version override to fix CVE-2026-34487, CVE-2026-34486, CVE-2026-34483.
Must be declared before the spring-boot-dependencies BOM import to take precedence.
TODO: remove when fixed in spring-boot-dependencies -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<!-- End of tomcat version override -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId> <artifactId>spring-boot-dependencies</artifactId>
@ -1273,6 +1258,19 @@
</exclusion> </exclusion>
</exclusions> </exclusions>
</dependency> </dependency>
<!-- TODO: remove these two pins once Spring Boot 3.5.15+ ships the fix for the
ImportsContextCustomizer regression in 3.5.14 (see PR #15557). Test artifacts are not
packaged in the runtime image, so pinning them does not affect the CVE fixes. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<version>${spring-boot-test.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test-autoconfigure</artifactId>
<version>${spring-boot-test.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.apache.kafka</groupId> <groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId> <artifactId>kafka-clients</artifactId>
@ -1351,6 +1349,11 @@
<artifactId>commons-lang3</artifactId> <artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version> <version>${commons-lang3.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<dependency> <dependency>
<groupId>commons-io</groupId> <groupId>commons-io</groupId>
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>

9
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts

@ -150,6 +150,9 @@ import {
ValueStepperBasicConfigComponent ValueStepperBasicConfigComponent
} from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component'; } from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component';
import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component'; import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component';
import {
HtmlContainerBasicConfigComponent
} from '@home/components/widget/config/basic/html/html-container-basic-config.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -201,7 +204,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelValueCardBasicConfigComponent, LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent, UnreadNotificationBasicConfigComponent,
ScadaSymbolBasicConfigComponent, ScadaSymbolBasicConfigComponent,
MapBasicConfigComponent MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -255,7 +259,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelCardBasicConfigComponent, LabelCardBasicConfigComponent,
LabelValueCardBasicConfigComponent, LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent, UnreadNotificationBasicConfigComponent,
MapBasicConfigComponent MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
] ]
}) })
export class BasicWidgetConfigModule { export class BasicWidgetConfigModule {

20
ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html

@ -0,0 +1,20 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerWidgetConfigForm">
<tb-html-container-settings formControlName="settings"></tb-html-container-settings>
</ng-container>

62
ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts

@ -0,0 +1,62 @@
///
/// 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.
///
import { Component, HostBinding } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import {
htmlContainerDefaultSettings,
HtmlContainerWidgetSettings
} from '@home/components/widget/lib/html/html-container-widget.models';
@Component({
selector: 'tb-html-container-basic-config',
templateUrl: './html-container-basic-config.component.html',
styleUrls: ['../basic-config.scss'],
standalone: false
})
export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent {
@HostBinding('style.height') height = '100%';
htmlContainerWidgetConfigForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.htmlContainerWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})};
this.htmlContainerWidgetConfigForm = this.fb.group({
settings: [settings, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings};
return this.widgetConfig;
}
}

318
ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts

@ -0,0 +1,318 @@
///
/// 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.
///
import {
Component,
ElementRef,
Inject,
Injector,
Input,
OnInit,
Optional,
Type,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { WidgetContext } from '@home/models/widget-component.models';
import {
htmlContainerDefaultSettings,
HtmlContainerWidgetSettings,
HtmlContainerWidgetType,
WidgetContainerAngularFunction,
WidgetContainerPlainFunction
} from '@home/components/widget/lib/html/html-container-widget.models';
import { hashCode, isNotEmptyStr, parseTbFunction } from '@core/utils';
import { CompiledTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models';
import { catchError, forkJoin, map, Observable, of, switchMap, throwError } from 'rxjs';
import cssjs from '@core/css/css';
import { SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service';
import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens';
import { ExceptionData } from '@shared/models/error.models';
import { UtilsService } from '@core/services/utils.service';
import {
flatModulesWithComponents,
ModulesWithComponents,
modulesWithComponentsToTypes,
ResourcesService
} from '@core/services/resources.service';
import { MODULES_MAP } from '@shared/models/constants';
import { IModulesMap } from '@modules/common/modules-map.models';
import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
@Component({
selector: 'tb-html-container-widget',
template: '<div #container class="tb-absolute-fill"><tb-anchor #angularContainer></tb-anchor></div>' +
'@if (widgetErrorData) { <div class="tb-absolute-fill tb-widget-error">\n' +
' <span [innerHtml]="(\'Widget Error:<br/><br/>\' + widgetErrorData.message) | safe:\'html\'"></span>\n' +
'</div> }',
styles: '.tb-widget-error {\n' +
' display: flex;\n' +
' align-items: center;\n' +
' justify-content: center;\n' +
' background: rgba(255, 255, 255, .5);\n' +
'\n' +
' span {\n' +
' color: #f00;\n' +
' }\n' +
' }',
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class HtmlContainerWidgetComponent implements OnInit {
@ViewChild('container', {static: true})
containerElmRef: ElementRef<HTMLElement>;
@ViewChild('angularContainer', {static: true})
angularContainer: TbAnchorComponent;
@Input()
ctx: WidgetContext;
private containerInstanceComponentType: Type<any>;
private settings: HtmlContainerWidgetSettings;
widgetErrorData: ExceptionData;
constructor(private elementRef: ElementRef<HTMLElement>,
@Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap,
@Inject(SHARED_MODULE_TOKEN) private sharedModule: Type<any>,
@Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type<any>,
@Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type<any>,
private dynamicComponentFactoryService: DynamicComponentFactoryService,
private utils: UtilsService,
private resources: ResourcesService) {}
ngOnInit(): void {
this.settings = {...htmlContainerDefaultSettings, ...(this.ctx.settings || {})};
this.loadWidgetResources().subscribe(
{
next: () => {
if (this.settings.type === HtmlContainerWidgetType.PLAIN) {
this.initPlain();
} else if (this.settings.type === HtmlContainerWidgetType.ANGULAR) {
this.initAngular();
}
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
}
private initPlain(): void {
try {
if (isNotEmptyStr(this.settings.css)) {
const cssParser = new cssjs();
cssParser.testMode = false;
const namespace = 'html-container-' + hashCode(this.settings.css);
cssParser.cssPreviewNamespace = namespace;
cssParser.createStyleElement(namespace, this.settings.css);
$(this.elementRef.nativeElement).addClass(namespace);
}
if (isNotEmptyStr(this.settings.html)) {
$(this.containerElmRef.nativeElement).html(this.settings.html);
}
this.compileAndExecutePlainFunction();
} catch (e) {
this.handleWidgetException(e);
}
}
private compileAndExecutePlainFunction(): void {
if (isNotEmptyTbFunction(this.settings.js)) {
const jsFunction: Observable<CompiledTbFunction<WidgetContainerPlainFunction>> = parseTbFunction(this.ctx.http, this.settings.js, ['ctx', 'container']);
jsFunction.subscribe({
next: (containerFunction) => {
try {
containerFunction.execute(this.ctx, this.containerElmRef.nativeElement);
} catch (e) {
this.handleWidgetException(e);
}
},
error: (e) => {
this.handleWidgetException(e);
}
});
}
}
private initAngular(): void {
this.loadAngularModules().subscribe(
{
next: (imports) => {
this.compileAngularFunction().subscribe(
{
next: (containerFunction) => {
try {
this.initAngularComponent(imports, containerFunction);
} catch (e) {
this.handleWidgetException(e);
}
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
}
private compileAngularFunction(): Observable<CompiledTbFunction<WidgetContainerAngularFunction>> {
if (isNotEmptyTbFunction(this.settings.js)) {
return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']);
} else {
return of(null);
}
}
private initAngularComponent(imports?: Type<any>[], containerFunction?: CompiledTbFunction<WidgetContainerAngularFunction>): void {
this.angularContainer.viewContainerRef.clear();
const destroyContainerInstanceResources = this.destroyContainerInstanceResources.bind(this);
const template = this.settings.html || '';
const styles: string[] = [];
if (isNotEmptyStr(this.settings.css)) {
styles.push(this.settings.css);
}
let compileModules = [this.sharedModule, this.widgetComponentsModule, this.homeComponentsModule];
if (imports && imports.length) {
compileModules = compileModules.concat(imports);
}
const self = () => this;
this.dynamicComponentFactoryService.createDynamicComponent(
class TbContainerInstance {
ngOnInit(): void {
if (containerFunction) {
const instance = self();
try {
containerFunction.apply(this, [instance.ctx]);
} catch (e) {
instance.handleWidgetException(e);
}
}
}
ngOnDestroy(): void {
destroyContainerInstanceResources();
}
},
template,
compileModules,
true, styles
).subscribe({
next: (componentType) => {
this.containerInstanceComponentType = componentType;
const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector});
try {
this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType,
{index: 0, injector});
} catch (error) {
this.handleWidgetException(error);
}
},
error: (e) => {
this.handleWidgetException(e);
}
});
}
private destroyContainerInstanceResources() {
if (this.containerInstanceComponentType) {
this.dynamicComponentFactoryService.destroyDynamicComponent(this.containerInstanceComponentType);
this.containerInstanceComponentType = null;
}
}
private handleWidgetException(e: any) {
console.error(e);
this.widgetErrorData = this.utils.processWidgetException(e);
this.ctx.detectChanges();
}
private loadWidgetResources(): Observable<any> {
const resourceTasks: Observable<string>[] = [];
this.settings.resources.filter(r => !r.isModule).forEach(
(resource) => {
resourceTasks.push(
this.resources.loadResource(resource.url).pipe(
catchError(() => of(`Failed to load widget resource: '${resource.url}'`))
)
);
}
);
if (resourceTasks.length) {
return forkJoin(resourceTasks).pipe(
switchMap(msgs => {
let errors: string[];
if (msgs && msgs.length) {
errors = msgs.filter(msg => msg && msg.length > 0);
}
if (errors && errors.length) {
return throwError(() => new Error(errors.join('<br/>')));
} else {
return of(null);
}
}
));
} else {
return of(null);
}
}
private loadAngularModules(): Observable<Type<any>[]> {
const modulesTasks: Observable<ModulesWithComponents | string>[] = [];
this.settings.resources.filter(r => r.isModule).forEach(
(resource) => {
modulesTasks.push(
this.resources.loadModulesWithComponents(resource.url, this.modulesMap).pipe(
catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`))
)
);
}
);
if (modulesTasks.length) {
return forkJoin(modulesTasks).pipe(
map(res => {
const msg = res.find(r => typeof r === 'string');
if (msg) {
return msg as string;
} else {
const modulesWithComponentsList = res as ModulesWithComponents[];
return flatModulesWithComponents(modulesWithComponentsList);
}
}),
switchMap(modulesWithComponentsList => {
if (typeof modulesWithComponentsList === 'string') {
return throwError(() => new Error(modulesWithComponentsList));
} else {
const modules = modulesWithComponentsToTypes(modulesWithComponentsList);
return of(modules);
}
})
);
} else {
return of(null);
}
}
}

63
ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts

@ -0,0 +1,63 @@
///
/// 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.
///
import { TbFunction } from '@shared/models/js-function.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models';
import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models';
import { WidgetResource } from '@shared/models/widget.models';
export enum HtmlContainerWidgetType {
PLAIN = 'PLAIN',
ANGULAR = 'ANGULAR'
}
export interface HtmlContainerWidgetSettings {
type: HtmlContainerWidgetType;
html: string;
css: string;
js: TbFunction;
resources: WidgetResource[];
}
export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = {
type: HtmlContainerWidgetType.PLAIN,
html: '',
css: '',
js: '',
resources: [],
};
export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void;
export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void;
const containerFunctionCompletions: TbEditorCompletions = {
...{
ctx: {
meta: 'argument',
type: widgetContextCompletions.ctx.type,
description: widgetContextCompletions.ctx.description,
children: widgetContextCompletions.ctx.children
},
container: {
meta: 'argument',
type: 'HTMLElement',
description: 'Container element of the widget'
},
}
};
export const ContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions);

98
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html

@ -0,0 +1,98 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerSettingsForm">
<div class="tb-form-panel no-padding no-border relative h-full">
<div class="flex flex-row items-center gap-4">
<div class="tb-form-panel-title" translate>widgets.html-container.container-type</div>
<tb-toggle-select formControlName="type">
<tb-toggle-option [value]="HtmlContainerWidgetType.PLAIN">{{ 'widgets.html-container.type-plain' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="HtmlContainerWidgetType.ANGULAR">{{ 'widgets.html-container.type-angular' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<mat-tab-group [mat-stretch-tabs]="false" selectedIndex="3" class="flex-1">
<mat-tab #resourceTab="matTab">
<ng-template mat-tab-label>
<div [matBadge]="resourcesFormArray.length" [matBadgeHidden]="resourceTab.isActive || !resourcesFormArray.length"
matBadgeSize="small">{{ 'widgets.html-container.resources' | translate }}</div>
</ng-template>
<div class="flex flex-col gap-2 pt-4">
@if (resourcesFormArray.length) {
@for (resourceControl of resourcesControls; track resourceControl; let i = $index) {
<div class="tb-form-row no-border no-padding" [formGroup]="resourceControl">
<tb-resource-autocomplete class="flex-1"
formControlName="url"
inlineField
hideRequiredMarker required
[allowAutocomplete]="resourceControl.get('isModule').value && htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR"
placeholder="{{ 'widget.resource-url' | translate }}">
</tb-resource-autocomplete>
@if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) {
<mat-checkbox formControlName="isModule">
{{ 'widget.resource-is-extension' | translate }}
</mat-checkbox>
}
<button mat-icon-button color="primary"
(click)="removeResource(i)"
matTooltip="{{'widget.remove-resource' | translate}}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
}
} @else {
<span translate
class="tb-prompt flex items-center justify-center">widgets.html-container.no-resources</span>
}
<div>
<button mat-raised-button color="primary"
(click)="addResource()"
matTooltip="{{'widget.add-resource' | translate}}"
matTooltipPosition="above">
<span translate>action.add</span>
</button>
</div>
</div>
</mat-tab>
<mat-tab label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}">
<tb-html class="flex-1"
[fillHeight]="true"
formControlName="html"
label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}">
</tb-html>
</mat-tab>
<mat-tab label="{{ 'widgets.html-container.css' | translate }}">
<tb-css class="flex-1"
[fillHeight]="true"
formControlName="css"
label="{{ 'widgets.html-container.css' | translate }}">
</tb-css>
</mat-tab>
<mat-tab label="{{ 'widgets.html-container.java-script' | translate }}">
<tb-js-func class="flex-1"
[fillHeight]="true"
formControlName="js"
[globalVariables]="functionScopeVariables"
[editorCompleter]="containerFunctionEditorCompleter"
[functionArgs]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']"
withModules
functionTitle="{{ 'widgets.html-container.js-function' | translate }}">
</tb-js-func>
</mat-tab>
</mat-tab-group>
</div>
</ng-container>

28
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss

@ -0,0 +1,28 @@
/**
* 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.
*/
:host {
&.tb-html-container-settings {
height: 100%;
::ng-deep {
.mat-mdc-tab-body-wrapper {
position: relative;
top: 0;
flex: 1;
}
}
}
}

186
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts

@ -0,0 +1,186 @@
///
/// 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.
///
import { Component, DestroyRef, forwardRef, HostBinding, Input, OnInit } from '@angular/core';
import { WidgetResource } from '@shared/models/widget.models';
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator,
Validators
} from '@angular/forms';
import {
ContainerFunctionEditorCompleter,
HtmlContainerWidgetSettings,
HtmlContainerWidgetType
} from '@home/components/widget/lib/html/html-container-widget.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { isJSResource } from '@shared/models/resource.models';
import { WidgetService } from '@core/http/widget.service';
@Component({
selector: 'tb-html-container-settings',
templateUrl: './html-container-settings.component.html',
styleUrls: ['./html-container-settings.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HtmlContainerSettingsComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => HtmlContainerSettingsComponent),
multi: true,
}
],
standalone: false
})
export class HtmlContainerSettingsComponent implements OnInit, ControlValueAccessor, Validator {
HtmlContainerWidgetType = HtmlContainerWidgetType;
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
containerFunctionEditorCompleter = ContainerFunctionEditorCompleter;
@HostBinding('class')
hostClass = 'tb-html-container-settings';
@Input()
disabled: boolean;
htmlContainerSettingsForm: UntypedFormGroup;
private modelValue: HtmlContainerWidgetSettings;
constructor(private fb: UntypedFormBuilder,
private widgetService: WidgetService,
private destroyRef: DestroyRef) {
}
get resourcesFormArray(): UntypedFormArray {
return this.htmlContainerSettingsForm.get('resources') as UntypedFormArray;
}
get resourcesControls(): UntypedFormGroup[] {
return this.resourcesFormArray.controls as UntypedFormGroup[];
}
ngOnInit(): void {
this.htmlContainerSettingsForm = this.fb.group({
type: [null, []],
html: [null, []],
css: [null, []],
js: [null, []],
resources: this.fb.array([])
});
this.htmlContainerSettingsForm.get('type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.updateResources());
this.htmlContainerSettingsForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.htmlContainerSettingsForm.disable({emitEvent: false});
} else {
this.htmlContainerSettingsForm.enable({emitEvent: false});
}
}
writeValue(value: HtmlContainerWidgetSettings): void {
this.modelValue = value;
this.htmlContainerSettingsForm.get('type').patchValue(value.type, {emitEvent: false});
this.htmlContainerSettingsForm.get('html').patchValue(value.html, {emitEvent: false});
this.htmlContainerSettingsForm.get('css').patchValue(value.css, {emitEvent: false});
this.htmlContainerSettingsForm.get('js').patchValue(value.js, {emitEvent: false});
this.resourcesFormArray.clear({emitEvent: false});
value.resources.forEach(r => {
this.resourcesFormArray.push(this.buildResourceFormGroup(r), {emitEvent: false});
});
}
validate(_c: UntypedFormControl) {
return this.htmlContainerSettingsForm.valid ? null : {
htmlContainerSettings: {
valid: false,
}
};
}
addResource() {
const newResource: WidgetResource = {
url: '',
isModule: false
};
this.resourcesFormArray.push(this.buildResourceFormGroup(newResource));
}
removeResource(index: number) {
this.resourcesFormArray.removeAt(index);
}
private propagateChange = (v: any) => { };
private updateModel() {
this.modelValue = this.htmlContainerSettingsForm.value;
this.propagateChange(this.modelValue);
}
private updateResources() {
if (this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.PLAIN) {
const resources: WidgetResource[] = this.resourcesFormArray.value;
const filtered = resources.filter(r => !isJSResource(r.url));
let updated = filtered.length !== resources.length;
filtered.forEach((r) => {
if (r.isModule) {
r.isModule = false;
updated = true;
}
});
if (updated) {
this.resourcesFormArray.clear();
filtered.forEach(r => {
this.resourcesFormArray.push(this.buildResourceFormGroup(r));
});
}
}
}
private buildResourceFormGroup(resource: WidgetResource): UntypedFormGroup {
return this.fb.group({
url: [resource.url, [Validators.required]],
isModule: [resource.isModule]
});
}
}

5
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts

@ -267,6 +267,9 @@ import {
import { import {
ShapeFillStripeSettingsPanelComponent ShapeFillStripeSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component'; } from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component';
import {
HtmlContainerSettingsComponent
} from '@home/components/widget/lib/settings/common/html/html-container-settings.component';
import { AxisScaleRowComponent } from './axis-scale-row.component'; import { AxisScaleRowComponent } from './axis-scale-row.component';
@NgModule({ @NgModule({
@ -374,6 +377,7 @@ import { AxisScaleRowComponent } from './axis-scale-row.component';
DataKeyConfigDialogComponent, DataKeyConfigDialogComponent,
DataKeyConfigComponent, DataKeyConfigComponent,
WidgetSettingsComponent, WidgetSettingsComponent,
HtmlContainerSettingsComponent,
AxisScaleRowComponent AxisScaleRowComponent
], ],
imports: [ imports: [
@ -456,6 +460,7 @@ import { AxisScaleRowComponent } from './axis-scale-row.component';
DataKeyConfigDialogComponent, DataKeyConfigDialogComponent,
DataKeyConfigComponent, DataKeyConfigComponent,
WidgetSettingsComponent, WidgetSettingsComponent,
HtmlContainerSettingsComponent,
AxisScaleRowComponent AxisScaleRowComponent
], ],
providers: [ providers: [

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div [formGroup]="widgetSettingsFormGroup"> <div [formGroup]="widgetSettingsFormGroup" class="h-full">
<ng-container #definedSettingsContent></ng-container> <ng-container #definedSettingsContent></ng-container>
<div class="tb-settings-directive-error" *ngIf="definedDirectiveError">{{definedDirectiveError}}</div> <div class="tb-settings-directive-error" *ngIf="definedDirectiveError">{{definedDirectiveError}}</div>
<tb-dynamic-form *ngIf="useDynamicForm()" <tb-dynamic-form *ngIf="useDynamicForm()"

20
ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html

@ -0,0 +1,20 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerWidgetSettingsForm">
<tb-html-container-settings formControlName="htmlContainerSettings"></tb-html-container-settings>
</ng-container>

65
ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts

@ -0,0 +1,65 @@
///
/// 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.
///
import { Component, HostBinding } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { htmlContainerDefaultSettings } from '@home/components/widget/lib/html/html-container-widget.models';
@Component({
selector: 'tb-html-container-widget-settings',
templateUrl: './html-container-widget-settings.component.html',
styleUrls: [],
standalone: false
})
export class HtmlContainerWidgetSettingsComponent extends WidgetSettingsComponent {
@HostBinding('height')
hostHeight = '100%';
htmlContainerWidgetSettingsForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.htmlContainerWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return htmlContainerDefaultSettings;
}
protected onSettingsSet(settings: WidgetSettings) {
this.htmlContainerWidgetSettingsForm = this.fb.group({
htmlContainerSettings: [settings.htmlContainerSettings, []]
});
}
protected prepareInputSettings(settings: WidgetSettings): WidgetSettings {
return {
htmlContainerSettings: settings
};
}
protected prepareOutputSettings(settings: any): WidgetSettings {
return settings.htmlContainerSettings;
}
}

5
ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts

@ -375,6 +375,9 @@ import {
ValueStepperWidgetSettingsComponent ValueStepperWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component'; } from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component';
import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component'; import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component';
import {
HtmlContainerWidgetSettingsComponent
} from '@home/components/widget/lib/settings/html/html-container-widget-settings.component';
import { import {
ApiUsageWidgetSettingsComponent ApiUsageWidgetSettingsComponent
} from "@home/components/widget/lib/settings/cards/api-usage-widget-settings.component"; } from "@home/components/widget/lib/settings/cards/api-usage-widget-settings.component";
@ -515,6 +518,7 @@ import {
UnreadNotificationWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent, MapWidgetSettingsComponent,
HtmlContainerWidgetSettingsComponent,
ApiUsageWidgetSettingsComponent, ApiUsageWidgetSettingsComponent,
ApiUsageDataKeyRowComponent ApiUsageDataKeyRowComponent
], ],
@ -656,6 +660,7 @@ import {
UnreadNotificationWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent, MapWidgetSettingsComponent,
HtmlContainerWidgetSettingsComponent,
ApiUsageWidgetSettingsComponent ApiUsageWidgetSettingsComponent
] ]
}) })

3
ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts

@ -94,6 +94,7 @@ import {
SelectMapEntityPanelComponent SelectMapEntityPanelComponent
} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; } from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component';
import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component';
import { HtmlContainerWidgetComponent } from '@home/components/widget/lib/html/html-container-widget.component';
import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-usage-widget.component"; import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-usage-widget.component";
@NgModule({ @NgModule({
@ -153,6 +154,7 @@ import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-u
SelectMapEntityPanelComponent, SelectMapEntityPanelComponent,
MapTimelinePanelComponent, MapTimelinePanelComponent,
MapWidgetComponent, MapWidgetComponent,
HtmlContainerWidgetComponent,
ApiUsageWidgetComponent ApiUsageWidgetComponent
], ],
imports: [ imports: [
@ -217,6 +219,7 @@ import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-u
NotificationTypeFilterPanelComponent, NotificationTypeFilterPanelComponent,
ScadaSymbolWidgetComponent, ScadaSymbolWidgetComponent,
MapWidgetComponent, MapWidgetComponent,
HtmlContainerWidgetComponent,
ApiUsageWidgetComponent ApiUsageWidgetComponent
], ],
providers: [ providers: [

2
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -322,7 +322,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div *ngIf="displayAdvancedAppearance" style="height: 100%;" [formGroup]="advancedSettings"> <div *ngIf="displayAdvancedAppearance" class="flex-1" [formGroup]="advancedSettings">
<tb-widget-settings <tb-widget-settings
[aliasController]="aliasController" [aliasController]="aliasController"
[callbacks]="widgetConfigCallbacks" [callbacks]="widgetConfigCallbacks"

1
ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss

@ -43,6 +43,7 @@
} }
.tb-widget-config-content { .tb-widget-config-content {
& > .mat-content { & > .mat-content {
height: 100%;
padding-top: 8px; padding-top: 8px;
@media #{$mat-xs} { @media #{$mat-xs} {
padding-left: 8px; padding-left: 8px;

12
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -10140,6 +10140,18 @@
} }
} }
}, },
"html-container": {
"java-script": "JavaScript",
"js-function": "JavaScript function",
"html": "HTML",
"angular-html-template": "Angular HTML template",
"css": "CSS",
"container-type": "Container type",
"type-plain": "Plain HTML",
"type-angular": "Angular",
"resources": "Resources",
"no-resources": "No resources configured"
},
"api-usage": { "api-usage": {
"api-usage": "API usage", "api-usage": "API usage",
"label": "Label", "label": "Label",

Loading…
Cancel
Save