Browse Source

Merge pull request #15556 from thingsboard/feature/html-container-widget

HTML container widget
pull/15574/head
Igor Kulikov 4 weeks ago
committed by GitHub
parent
commit
2943b37cf9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  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. 9
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  5. 20
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html
  6. 62
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts
  7. 318
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts
  8. 63
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts
  9. 98
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html
  10. 28
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss
  11. 186
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts
  12. 9
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  13. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html
  14. 20
      ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html
  15. 65
      ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts
  16. 9
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  17. 7
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  18. 2
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  19. 1
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  20. 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.markdown_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": [
"cards.html_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

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

@ -150,6 +150,9 @@ import {
ValueStepperBasicConfigComponent
} 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 {
HtmlContainerBasicConfigComponent
} from '@home/components/widget/config/basic/html/html-container-basic-config.component';
@NgModule({
declarations: [
@ -201,7 +204,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent,
ScadaSymbolBasicConfigComponent,
MapBasicConfigComponent
MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
],
imports: [
CommonModule,
@ -255,7 +259,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelCardBasicConfigComponent,
LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent,
MapBasicConfigComponent
MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
]
})
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]
});
}
}

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

@ -267,6 +267,9 @@ import {
import {
ShapeFillStripeSettingsPanelComponent
} 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';
@NgModule({
declarations: [
@ -372,7 +375,8 @@ import {
DataKeysComponent,
DataKeyConfigDialogComponent,
DataKeyConfigComponent,
WidgetSettingsComponent
WidgetSettingsComponent,
HtmlContainerSettingsComponent
],
imports: [
CommonModule,
@ -453,7 +457,8 @@ import {
DataKeysComponent,
DataKeyConfigDialogComponent,
DataKeyConfigComponent,
WidgetSettingsComponent
WidgetSettingsComponent,
HtmlContainerSettingsComponent
],
providers: [
ColorSettingsComponentService,

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.
-->
<div [formGroup]="widgetSettingsFormGroup">
<div [formGroup]="widgetSettingsFormGroup" class="h-full">
<ng-container #definedSettingsContent></ng-container>
<div class="tb-settings-directive-error" *ngIf="definedDirectiveError">{{definedDirectiveError}}</div>
<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;
}
}

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

@ -375,6 +375,9 @@ import {
ValueStepperWidgetSettingsComponent
} 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 {
HtmlContainerWidgetSettingsComponent
} from '@home/components/widget/lib/settings/html/html-container-widget-settings.component';
@NgModule({
declarations: [
@ -508,7 +511,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings
LabelValueCardWidgetSettingsComponent,
UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent
MapWidgetSettingsComponent,
HtmlContainerWidgetSettingsComponent
],
imports: [
CommonModule,
@ -647,7 +651,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings
LabelValueCardWidgetSettingsComponent,
UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent
MapWidgetSettingsComponent,
HtmlContainerWidgetSettingsComponent
]
})
export class WidgetSettingsModule {

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

@ -94,6 +94,7 @@ import {
SelectMapEntityPanelComponent
} 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 { HtmlContainerWidgetComponent } from '@home/components/widget/lib/html/html-container-widget.component';
@NgModule({
declarations: [
@ -151,7 +152,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane
ScadaSymbolWidgetComponent,
SelectMapEntityPanelComponent,
MapTimelinePanelComponent,
MapWidgetComponent
MapWidgetComponent,
HtmlContainerWidgetComponent
],
imports: [
CommonModule,
@ -214,7 +216,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane
UnreadNotificationWidgetComponent,
NotificationTypeFilterPanelComponent,
ScadaSymbolWidgetComponent,
MapWidgetComponent
MapWidgetComponent,
HtmlContainerWidgetComponent
],
providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule},

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

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

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

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

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

@ -9511,6 +9511,18 @@
"how-to-create-customer-and-assign-dashboard": "How to create Customer and assign Dashboard"
}
}
},
"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"
}
},
"color": {

Loading…
Cancel
Save