From 910be7d6114b0bdfa0bb86a6199e8bff73bdc6d7 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 4 May 2026 11:24:40 +0300 Subject: [PATCH] feat(widgets): add HTML Container widget settings and metadata - Fill description and tags for the HTML Container widget type JSON. - Add basic config component (plain HTML / Angular mode editor). - Add advanced settings component and shared common settings. --- .../system/widget_types/html_container.json | 6 +- ...html-container-basic-config.component.html | 79 +------- .../html-container-basic-config.component.ts | 85 +------- .../html-container-settings.component.html | 97 ++++++++++ .../html/html-container-settings.component.ts | 183 ++++++++++++++++++ .../common/widget-settings-common.module.ts | 9 +- ...l-container-widget-settings.component.html | 20 ++ ...tml-container-widget-settings.component.ts | 62 ++++++ .../lib/settings/widget-settings.module.ts | 9 +- 9 files changed, 385 insertions(+), 165 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts 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 01ddb4a2fc..00c0c8590d 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 @@ -3,7 +3,7 @@ "name": "HTML Container", "deprecated": false, "image": "tb-image;/api/images/system/html_card_system_widget_image.png", - "description": null, + "description": "Configurable HTML, CSS and JavaScript widget with access to the Widget API via WidgetContext and the ability to load external resources or modules. Supports two modes: plain HTML (regular HTML/JS bound to the widget container) and Angular (Angular template with bound variables and functions). Use for custom complex visualizations or actions when system widgets are not enough.", "descriptor": { "type": "static", "sizeX": 9.5, @@ -12,7 +12,7 @@ "templateHtml": "\n", "templateCss": "", "controllerScript": "self.onInit = function() {\n \n}\n", - "settingsDirective": "", + "settingsDirective": "tb-html-container-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-html-container-basic-config", "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgba(255, 255, 255, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0\",\"settings\":{\"type\":\"PLAIN\",\"html\":\"\",\"css\":\"\",\"js\":\"\",\"resources\":[]},\"title\":\"HTML Container\",\"dropShadow\":false,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\"}" @@ -31,5 +31,5 @@ } ], "scada": false, - "tags": null + "tags": ["html", "css", "javascript", "custom", "script", "code", "container", "angular", "template", "external resources", "widget api", "advanced", "custom visualization", "custom action", "web", "markup"] } \ No newline at end of file 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 5025ab4d56..298bdb6e61 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 @@ -16,82 +16,5 @@ --> -
-
-
widgets.html-container.container-type
- - {{ 'widgets.html-container.type-plain' | translate }} - {{ 'widgets.html-container.type-angular' | translate }} - -
-
- - - -
{{ 'widgets.html-container.resources' | translate }}
-
-
- - @if (resourcesFormArray.length) { - @for (resourceControl of resourcesControls; track resourceControl; let i = $index) { -
- - - @if (htmlContainerWidgetConfigForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { - - {{ 'widget.resource-is-extension' | translate }} - - } - -
- } - } @else { - widgets.html-container.no-resources - } -
- -
-
-
-
-
- - -
-
- - -
-
- - -
-
+
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 349f339910..5c367697a2 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 @@ -14,22 +14,17 @@ /// limitations under the License. /// -import { ChangeDetectorRef, Component, Injector } from '@angular/core'; -import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Component } 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 { - ContainerFunctionEditorCompleter, htmlContainerDefaultSettings, - HtmlContainerWidgetSettings, HtmlContainerWidgetType + HtmlContainerWidgetSettings } from '@home/components/widget/lib/html/html-container-widget.models'; -import { WidgetService } from '@core/http/widget.service'; -import { WidgetResource } from '@shared/models/widget.models'; -import { isJSResource, ResourceSubType } from '@shared/models/resource.models'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-html-container-basic-config', @@ -39,27 +34,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; }) export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent { - HtmlContainerWidgetType = HtmlContainerWidgetType; - htmlContainerWidgetConfigForm: UntypedFormGroup; - functionScopeVariables = this.widgetService.getWidgetScopeVariables(); - - containerFunctionEditorCompleter = ContainerFunctionEditorCompleter; - - get resourcesFormArray(): UntypedFormArray { - return this.htmlContainerWidgetConfigForm.get('resources') as UntypedFormArray; - } - - get resourcesControls(): UntypedFormGroup[] { - return this.resourcesFormArray.controls as UntypedFormGroup[]; - } - constructor(protected store: Store, protected widgetConfigComponent: WidgetConfigComponent, - private widgetService: WidgetService, - private cd: ChangeDetectorRef, - private $injector: Injector, private fb: UntypedFormBuilder) { super(store, widgetConfigComponent); } @@ -71,65 +49,12 @@ export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponen protected onConfigSet(configData: WidgetConfigComponentData) { const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; this.htmlContainerWidgetConfigForm = this.fb.group({ - type: [settings.type, []], - html: [settings.html, []], - css: [settings.css, []], - js: [settings.js, []], - resources: this.fb.array(settings.resources.map(r => this.buildResourceFormGroup(r))) + settings: [settings, []] }); - this.htmlContainerWidgetConfigForm.get('type').valueChanges.pipe( - takeUntilDestroyed(this.destroyRef) - ).subscribe(() => this.updateResources()); } protected prepareOutputConfig(config: any): WidgetConfigComponentData { - this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; - this.widgetConfig.config.settings.type = config.type; - this.widgetConfig.config.settings.html = config.html; - this.widgetConfig.config.settings.css = config.css; - this.widgetConfig.config.settings.js = config.js; - this.widgetConfig.config.settings.resources = config.resources; + this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; return this.widgetConfig; } - - private updateResources() { - if (this.htmlContainerWidgetConfigForm.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)); - }); - } - } - } - - addResource() { - const newResource: WidgetResource = { - url: '', - isModule: false - }; - this.resourcesFormArray.push(this.buildResourceFormGroup(newResource)); - } - - removeResource(index: number) { - this.resourcesFormArray.removeAt(index); - } - - private buildResourceFormGroup(resource: WidgetResource): UntypedFormGroup { - return this.fb.group({ - url: [resource.url, [Validators.required]], - isModule: [resource.isModule] - }); - } - - protected readonly ResourceSubType = ResourceSubType; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html new file mode 100644 index 0000000000..985bd1bcaa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html @@ -0,0 +1,97 @@ + + +
+
+
widgets.html-container.container-type
+ + {{ 'widgets.html-container.type-plain' | translate }} + {{ 'widgets.html-container.type-angular' | translate }} + +
+
+ + + +
{{ 'widgets.html-container.resources' | translate }}
+
+
+ + @if (resourcesFormArray.length) { + @for (resourceControl of resourcesControls; track resourceControl; let i = $index) { +
+ + + @if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { + + {{ 'widget.resource-is-extension' | translate }} + + } + +
+ } + } @else { + widgets.html-container.no-resources + } +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts new file mode 100644 index 0000000000..9850339761 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts @@ -0,0 +1,183 @@ +/// +/// 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, 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: [], + 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; + + @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] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 9004928078..28c5f3d13b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/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, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html new file mode 100644 index 0000000000..826c71abe9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html @@ -0,0 +1,20 @@ + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts new file mode 100644 index 0000000000..46a235c0ee --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.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 } 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 { + + htmlContainerWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + 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; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index af8694d24c..dbb7258823 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/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 {