committed by
GitHub
20 changed files with 957 additions and 12 deletions
File diff suppressed because one or more lines are too long
@ -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> |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
@ -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> |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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] |
|||
}); |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue