Browse Source
Introduce support of reusable JavaScript modules for UI JavaScript functions.pull/12172/head
committed by
GitHub
84 changed files with 3619 additions and 1316 deletions
@ -0,0 +1,171 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 { Injectable } from '@angular/core'; |
||||
|
import { |
||||
|
checkBoxCell, |
||||
|
DateEntityTableColumn, |
||||
|
EntityTableColumn, |
||||
|
EntityTableConfig |
||||
|
} from '@home/models/entity/entities-table-config.models'; |
||||
|
import { Router } from '@angular/router'; |
||||
|
import { |
||||
|
Resource, |
||||
|
ResourceInfo, |
||||
|
ResourceSubType, |
||||
|
ResourceSubTypeTranslationMap, |
||||
|
ResourceType |
||||
|
} from '@shared/models/resource.models'; |
||||
|
import { EntityType, entityTypeResources } from '@shared/models/entity-type.models'; |
||||
|
import { NULL_UUID } from '@shared/models/id/has-uuid'; |
||||
|
import { DatePipe } from '@angular/common'; |
||||
|
import { TranslateService } from '@ngx-translate/core'; |
||||
|
import { ResourceService } from '@core/http/resource.service'; |
||||
|
import { getCurrentAuthUser } from '@core/auth/auth.selectors'; |
||||
|
import { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { Authority } from '@shared/models/authority.enum'; |
||||
|
import { PageLink } from '@shared/models/page/page-link'; |
||||
|
import { EntityAction } from '@home/models/entity/entity-component.models'; |
||||
|
import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-library-table-header.component'; |
||||
|
import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component'; |
||||
|
import { switchMap } from 'rxjs/operators'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class JsLibraryTableConfigResolver { |
||||
|
|
||||
|
private readonly config: EntityTableConfig<Resource, PageLink, ResourceInfo> = new EntityTableConfig<Resource, PageLink, ResourceInfo>(); |
||||
|
|
||||
|
constructor(private store: Store<AppState>, |
||||
|
private resourceService: ResourceService, |
||||
|
private translate: TranslateService, |
||||
|
private router: Router, |
||||
|
private datePipe: DatePipe) { |
||||
|
|
||||
|
this.config.entityType = EntityType.TB_RESOURCE; |
||||
|
this.config.entityComponent = JsResourceComponent; |
||||
|
this.config.entityTranslations = { |
||||
|
details: 'javascript.javascript-resource-details', |
||||
|
add: 'javascript.add', |
||||
|
noEntities: 'javascript.no-javascript-resource-text', |
||||
|
search: 'javascript.search', |
||||
|
selectedEntities: 'javascript.selected-javascript-resources' |
||||
|
}; |
||||
|
this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE); |
||||
|
this.config.headerComponent = JsLibraryTableHeaderComponent; |
||||
|
|
||||
|
this.config.entityTitle = (resource) => resource ? |
||||
|
resource.title : ''; |
||||
|
|
||||
|
this.config.columns.push( |
||||
|
new DateEntityTableColumn<ResourceInfo>('createdTime', 'common.created-time', this.datePipe, '150px'), |
||||
|
new EntityTableColumn<ResourceInfo>('title', 'resource.title', '60%'), |
||||
|
new EntityTableColumn<ResourceInfo>('resourceSubType', 'javascript.javascript-type', '40%', |
||||
|
entity => this.translate.instant(ResourceSubTypeTranslationMap.get(entity.resourceSubType))), |
||||
|
new EntityTableColumn<ResourceInfo>('tenantId', 'resource.system', '60px', |
||||
|
entity => checkBoxCell(entity.tenantId.id === NULL_UUID)), |
||||
|
); |
||||
|
|
||||
|
this.config.cellActionDescriptors.push( |
||||
|
{ |
||||
|
name: this.translate.instant('javascript.download'), |
||||
|
icon: 'file_download', |
||||
|
isEnabled: () => true, |
||||
|
onAction: ($event, entity) => this.downloadResource($event, entity) |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
this.config.deleteEntityTitle = resource => this.translate.instant('javascript.delete-javascript-resource-title', |
||||
|
{ resourceTitle: resource.title }); |
||||
|
this.config.deleteEntityContent = () => this.translate.instant('javascript.delete-javascript-resource-text'); |
||||
|
this.config.deleteEntitiesTitle = count => this.translate.instant('javascript.delete-javascript-resources-title', {count}); |
||||
|
this.config.deleteEntitiesContent = () => this.translate.instant('javascript.delete-javascript-resources-text'); |
||||
|
|
||||
|
this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, ResourceType.JS_MODULE, this.config.componentsData.resourceSubType); |
||||
|
this.config.loadEntity = id => { |
||||
|
const current = this.config.getTable()?.dataSource?.currentEntity as ResourceInfo; |
||||
|
if (!current || current?.resourceSubType === ResourceSubType.MODULE) { |
||||
|
return this.resourceService.getResource(id.id); |
||||
|
} else { |
||||
|
return this.resourceService.getResourceInfoById(id.id) |
||||
|
} |
||||
|
}; |
||||
|
this.config.saveEntity = resource => { |
||||
|
resource.resourceType = ResourceType.JS_MODULE; |
||||
|
let saveObservable = this.resourceService.saveResource(resource); |
||||
|
if (resource.resourceSubType === ResourceSubType.MODULE) { |
||||
|
saveObservable = saveObservable.pipe( |
||||
|
switchMap((saved) => this.resourceService.getResource(saved.id.id)) |
||||
|
); |
||||
|
} |
||||
|
return saveObservable; |
||||
|
}; |
||||
|
this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); |
||||
|
|
||||
|
this.config.onEntityAction = action => this.onResourceAction(action); |
||||
|
} |
||||
|
|
||||
|
resolve(): EntityTableConfig<Resource, PageLink, ResourceInfo> { |
||||
|
this.config.tableTitle = this.translate.instant('javascript.javascript-library'); |
||||
|
this.config.componentsData = { |
||||
|
resourceSubType: '' |
||||
|
}; |
||||
|
const authUser = getCurrentAuthUser(this.store); |
||||
|
this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority); |
||||
|
this.config.entitySelectionEnabled = (resource) => this.isResourceEditable(resource, authUser.authority); |
||||
|
this.config.detailsReadonly = (resource) => this.detailsReadonly(resource, authUser.authority); |
||||
|
return this.config; |
||||
|
} |
||||
|
|
||||
|
private openResource($event: Event, resourceInfo: ResourceInfo) { |
||||
|
if ($event) { |
||||
|
$event.stopPropagation(); |
||||
|
} |
||||
|
const url = this.router.createUrlTree(['resources', 'javascript-library', resourceInfo.id.id]); |
||||
|
this.router.navigateByUrl(url).then(() => {}); |
||||
|
} |
||||
|
|
||||
|
downloadResource($event: Event, resource: ResourceInfo) { |
||||
|
if ($event) { |
||||
|
$event.stopPropagation(); |
||||
|
} |
||||
|
this.resourceService.downloadResource(resource.id.id).subscribe(); |
||||
|
} |
||||
|
|
||||
|
onResourceAction(action: EntityAction<ResourceInfo>): boolean { |
||||
|
switch (action.action) { |
||||
|
case 'open': |
||||
|
this.openResource(action.event, action.entity); |
||||
|
return true; |
||||
|
case 'downloadResource': |
||||
|
this.downloadResource(action.event, action.entity); |
||||
|
return true; |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private detailsReadonly(resource: ResourceInfo, authority: Authority): boolean { |
||||
|
return !this.isResourceEditable(resource, authority); |
||||
|
} |
||||
|
|
||||
|
private isResourceEditable(resource: ResourceInfo, authority: Authority): boolean { |
||||
|
if (authority === Authority.TENANT_ADMIN) { |
||||
|
return resource && resource.tenantId && resource.tenantId.id !== NULL_UUID; |
||||
|
} else { |
||||
|
return authority === Authority.SYS_ADMIN; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2024 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. |
||||
|
|
||||
|
--> |
||||
|
<mat-form-field class="mat-block" subscriptSizing="dynamic"> |
||||
|
<mat-label translate>javascript.javascript-type</mat-label> |
||||
|
<mat-select [ngModel]="entitiesTableConfig.componentsData.resourceSubType" |
||||
|
(ngModelChange)="jsResourceSubTypeChanged($event)" |
||||
|
placeholder="{{ 'javascript.javascript-type' | translate }}"> |
||||
|
<mat-option value=""> |
||||
|
{{ "javascript.all-types" | translate }} |
||||
|
</mat-option> |
||||
|
<mat-option *ngFor="let jsResourceSubType of jsResourceSubTypes" [value]="jsResourceSubType"> |
||||
|
{{ resourceSubTypesTranslationMap.get(jsResourceSubType) | translate }} |
||||
|
</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
@ -0,0 +1,42 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; |
||||
|
import { Resource, ResourceInfo, ResourceSubType, ResourceSubTypeTranslationMap } from '@shared/models/resource.models'; |
||||
|
import { PageLink } from '@shared/models/page/page-link'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-js-library-table-header', |
||||
|
templateUrl: './js-library-table-header.component.html', |
||||
|
styleUrls: [] |
||||
|
}) |
||||
|
export class JsLibraryTableHeaderComponent extends EntityTableHeaderComponent<Resource, PageLink, ResourceInfo> { |
||||
|
|
||||
|
readonly jsResourceSubTypes: ResourceSubType[] = [ResourceSubType.EXTENSION, ResourceSubType.MODULE]; |
||||
|
readonly resourceSubTypesTranslationMap = ResourceSubTypeTranslationMap; |
||||
|
|
||||
|
constructor(protected store: Store<AppState>) { |
||||
|
super(store); |
||||
|
} |
||||
|
|
||||
|
jsResourceSubTypeChanged(resourceSubType: ResourceSubType) { |
||||
|
this.entitiesTableConfig.componentsData.resourceSubType = resourceSubType; |
||||
|
this.entitiesTableConfig.getTable().resetSortAndFilter(true); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,117 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2024 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. |
||||
|
|
||||
|
--> |
||||
|
<div class="tb-details-buttons xs:flex xs:flex-col"> |
||||
|
<button mat-raised-button color="primary" |
||||
|
[disabled]="(isLoading$ | async)" |
||||
|
(click)="onEntityAction($event, 'open')" |
||||
|
[class.!hidden]="isEdit || isDetailsPage"> |
||||
|
{{'common.open-details-page' | translate }} |
||||
|
</button> |
||||
|
<button mat-raised-button color="primary" class="xs:flex-1" |
||||
|
[disabled]="(isLoading$ | async)" |
||||
|
(click)="onEntityAction($event, 'downloadResource')" |
||||
|
[class.!hidden]="isEdit"> |
||||
|
{{ 'javascript.download' | translate }} |
||||
|
</button> |
||||
|
<button mat-raised-button color="primary" class="xs:flex-1" |
||||
|
[disabled]="(isLoading$ | async)" |
||||
|
(click)="onEntityAction($event, 'delete')" |
||||
|
[class.!hidden]="hideDelete() || isEdit"> |
||||
|
{{ 'javascript.delete' | translate }} |
||||
|
</button> |
||||
|
<div class="flex flex-row xs:flex-col"> |
||||
|
<button mat-raised-button |
||||
|
ngxClipboard |
||||
|
(cbOnSuccess)="onResourceIdCopied()" |
||||
|
[cbContent]="entity?.id?.id" |
||||
|
[class.!hidden]="isEdit"> |
||||
|
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon> |
||||
|
<span translate>resource.copyId</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="mat-padding flex flex-col"> |
||||
|
<form [formGroup]="entityForm"> |
||||
|
<fieldset [disabled]="(isLoading$ | async) || !isEdit"> |
||||
|
<mat-form-field class="mat-block"> |
||||
|
<mat-label translate>javascript.javascript-type</mat-label> |
||||
|
<mat-select formControlName="resourceSubType" required> |
||||
|
<mat-option *ngFor="let resourceSubType of jsResourceSubTypes" [value]="resourceSubType"> |
||||
|
{{ ResourceSubTypeTranslationMap.get(resourceSubType) | translate }} |
||||
|
</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
<mat-form-field class="mat-block"> |
||||
|
<mat-label translate>resource.title</mat-label> |
||||
|
<input matInput formControlName="title" required> |
||||
|
<mat-error *ngIf="entityForm.get('title').hasError('required')"> |
||||
|
{{ 'resource.title-required' | translate }} |
||||
|
</mat-error> |
||||
|
<mat-error *ngIf="entityForm.get('title').hasError('maxlength')"> |
||||
|
{{ 'resource.title-max-length' | translate }} |
||||
|
</mat-error> |
||||
|
</mat-form-field> |
||||
|
<tb-file-input *ngIf="(isAdd || isEdit) && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION" |
||||
|
formControlName="data" |
||||
|
[required]="isAdd" |
||||
|
label="{{ 'javascript.resource-file' | translate }}" |
||||
|
[readAsBinary]="true" |
||||
|
[maxSizeByte]="maxResourceSize" |
||||
|
[allowedExtensions]="getAllowedExtensions()" |
||||
|
[contentConvertFunction]="convertToBase64File" |
||||
|
[accept]="getAcceptType()" |
||||
|
dropLabel="{{'javascript.drop-resource-file-or' | translate}}" |
||||
|
[existingFileName]="entityForm.get('fileName')?.value" |
||||
|
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)"> |
||||
|
</tb-file-input> |
||||
|
<tb-js-func *ngIf="entityForm.get('resourceSubType').value === ResourceSubType.MODULE" |
||||
|
formControlName="content" |
||||
|
required |
||||
|
hideBrackets |
||||
|
hideLabel |
||||
|
minHeight="300px"> |
||||
|
<div toolbarStartButton |
||||
|
class="flex flex-row gap-4"> |
||||
|
<label class="tb-title no-padding tb-required" |
||||
|
[class.tb-error]="entityForm.get('content').invalid && entityForm.get('content').touched" |
||||
|
style="font-size: 16px;"> |
||||
|
{{ 'javascript.module-script' | translate }} |
||||
|
</label> |
||||
|
<tb-file-input *ngIf="(isAdd || isEdit)" |
||||
|
asButton |
||||
|
uploadButtonText="{{ 'javascript.upload-from-file' | translate }}" |
||||
|
uploadButtonClass="tb-ignore-browse-file-button-style" |
||||
|
[maxSizeByte]="maxResourceSize" |
||||
|
[allowedExtensions]="getAllowedExtensions()" |
||||
|
[accept]="getAcceptType()" |
||||
|
[ngModel]="" |
||||
|
[ngModelOptions]="{ standalone: true }" |
||||
|
(ngModelChange)="uploadContentFromFile($event)" |
||||
|
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)"> |
||||
|
</tb-file-input> |
||||
|
</div> |
||||
|
</tb-js-func> |
||||
|
<div *ngIf="!isAdd && !isEdit && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION" class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2"> |
||||
|
<mat-form-field class="flex-1"> |
||||
|
<mat-label translate>resource.file-name</mat-label> |
||||
|
<input matInput formControlName="fileName" type="text"> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</fieldset> |
||||
|
</form> |
||||
|
</div> |
||||
@ -0,0 +1,176 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
import { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { TranslateService } from '@ngx-translate/core'; |
||||
|
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; |
||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; |
||||
|
import { EntityComponent } from '@home/components/entity/entity.component'; |
||||
|
import { |
||||
|
Resource, |
||||
|
ResourceSubType, |
||||
|
ResourceSubTypeTranslationMap, |
||||
|
ResourceType, |
||||
|
ResourceTypeExtension, |
||||
|
ResourceTypeMIMETypes |
||||
|
} from '@shared/models/resource.models'; |
||||
|
import { startWith, takeUntil } from 'rxjs/operators'; |
||||
|
import { ActionNotificationShow } from '@core/notification/notification.actions'; |
||||
|
import { isDefinedAndNotNull } from '@core/utils'; |
||||
|
import { getCurrentAuthState } from '@core/auth/auth.selectors'; |
||||
|
import { scadaSymbolGeneralStateHighlightRules } from '@home/pages/scada-symbol/scada-symbol-editor.models'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-js-resource', |
||||
|
templateUrl: './js-resource.component.html' |
||||
|
}) |
||||
|
export class JsResourceComponent extends EntityComponent<Resource> implements OnInit, OnDestroy { |
||||
|
|
||||
|
readonly ResourceSubType = ResourceSubType; |
||||
|
readonly jsResourceSubTypes: ResourceSubType[] = [ResourceSubType.EXTENSION, ResourceSubType.MODULE]; |
||||
|
readonly ResourceSubTypeTranslationMap = ResourceSubTypeTranslationMap; |
||||
|
readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize; |
||||
|
|
||||
|
private destroy$ = new Subject<void>(); |
||||
|
|
||||
|
constructor(protected store: Store<AppState>, |
||||
|
protected translate: TranslateService, |
||||
|
@Inject('entity') protected entityValue: Resource, |
||||
|
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<Resource>, |
||||
|
public fb: FormBuilder, |
||||
|
protected cd: ChangeDetectorRef) { |
||||
|
super(store, fb, entityValue, entitiesTableConfigValue, cd); |
||||
|
} |
||||
|
|
||||
|
ngOnInit(): void { |
||||
|
super.ngOnInit(); |
||||
|
if (this.isAdd) { |
||||
|
this.observeResourceSubTypeChange(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
ngOnDestroy(): void { |
||||
|
super.ngOnDestroy(); |
||||
|
this.destroy$.next(); |
||||
|
this.destroy$.complete(); |
||||
|
} |
||||
|
|
||||
|
hideDelete(): boolean { |
||||
|
if (this.entitiesTableConfig) { |
||||
|
return !this.entitiesTableConfig.deleteEnabled(this.entity); |
||||
|
} else { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
buildForm(entity: Resource): FormGroup { |
||||
|
return this.fb.group({ |
||||
|
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], |
||||
|
resourceSubType: [entity?.resourceSubType ? entity.resourceSubType : ResourceSubType.EXTENSION, Validators.required], |
||||
|
fileName: [entity ? entity.fileName : null, Validators.required], |
||||
|
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []], |
||||
|
content: [entity?.data?.length ? window.atob(entity.data) : '', Validators.required] |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
updateForm(entity: Resource): void { |
||||
|
this.entityForm.patchValue(entity); |
||||
|
const content = entity.resourceSubType === ResourceSubType.MODULE && entity?.data?.length ? window.atob(entity.data) : ''; |
||||
|
this.entityForm.get('content').patchValue(content); |
||||
|
} |
||||
|
|
||||
|
override updateFormState(): void { |
||||
|
super.updateFormState(); |
||||
|
if (this.isEdit && this.entityForm && !this.isAdd) { |
||||
|
this.entityForm.get('resourceSubType').disable({ emitEvent: false }); |
||||
|
this.updateResourceSubTypeFieldsState(this.entityForm.get('resourceSubType').value); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
prepareFormValue(formValue: Resource): Resource { |
||||
|
if (this.isEdit && !isDefinedAndNotNull(formValue.data)) { |
||||
|
delete formValue.data; |
||||
|
} |
||||
|
if (formValue.resourceSubType === ResourceSubType.MODULE) { |
||||
|
if (!formValue.fileName) { |
||||
|
formValue.fileName = formValue.title + '.js'; |
||||
|
} |
||||
|
formValue.data = window.btoa((formValue as any).content); |
||||
|
delete (formValue as any).content; |
||||
|
} |
||||
|
return super.prepareFormValue(formValue); |
||||
|
} |
||||
|
|
||||
|
getAllowedExtensions(): string { |
||||
|
return ResourceTypeExtension.get(ResourceType.JS_MODULE); |
||||
|
} |
||||
|
|
||||
|
getAcceptType(): string { |
||||
|
return ResourceTypeMIMETypes.get(ResourceType.JS_MODULE); |
||||
|
} |
||||
|
|
||||
|
convertToBase64File(data: string): string { |
||||
|
return window.btoa(data); |
||||
|
} |
||||
|
|
||||
|
onResourceIdCopied(): void { |
||||
|
this.store.dispatch(new ActionNotificationShow( |
||||
|
{ |
||||
|
message: this.translate.instant('resource.idCopiedMessage'), |
||||
|
type: 'success', |
||||
|
duration: 750, |
||||
|
verticalPosition: 'bottom', |
||||
|
horizontalPosition: 'right' |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
uploadContentFromFile(content: string) { |
||||
|
this.entityForm.get('content').patchValue(content); |
||||
|
this.entityForm.markAsDirty(); |
||||
|
} |
||||
|
|
||||
|
private observeResourceSubTypeChange(): void { |
||||
|
this.entityForm.get('resourceSubType').valueChanges.pipe( |
||||
|
startWith(ResourceSubType.EXTENSION), |
||||
|
takeUntil(this.destroy$) |
||||
|
).subscribe((subType: ResourceSubType) => this.onResourceSubTypeChange(subType)); |
||||
|
} |
||||
|
|
||||
|
private onResourceSubTypeChange(subType: ResourceSubType): void { |
||||
|
this.updateResourceSubTypeFieldsState(subType); |
||||
|
this.entityForm.patchValue({ |
||||
|
data: null, |
||||
|
fileName: null |
||||
|
}, {emitEvent: false}); |
||||
|
} |
||||
|
|
||||
|
private updateResourceSubTypeFieldsState(subType: ResourceSubType) { |
||||
|
if (subType === ResourceSubType.EXTENSION) { |
||||
|
this.entityForm.get('data').enable({ emitEvent: false }); |
||||
|
this.entityForm.get('fileName').enable({ emitEvent: false }); |
||||
|
this.entityForm.get('content').disable({ emitEvent: false }); |
||||
|
} else { |
||||
|
this.entityForm.get('data').disable({ emitEvent: false }); |
||||
|
this.entityForm.get('fileName').disable({ emitEvent: false }); |
||||
|
this.entityForm.get('content').enable({ emitEvent: false }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected readonly highlightRules = scadaSymbolGeneralStateHighlightRules; |
||||
|
} |
||||
@ -0,0 +1,54 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2024 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. |
||||
|
|
||||
|
--> |
||||
|
<div [formGroup]="moduleRowFormGroup" class="tb-form-table-row tb-js-func-module-row"> |
||||
|
<mat-form-field class="tb-inline-field tb-alias-field" appearance="outline" subscriptSizing="dynamic"> |
||||
|
<input required matInput formControlName="alias" placeholder="{{ 'widget-config.set' | translate }}"> |
||||
|
</mat-form-field> |
||||
|
<tb-resource-autocomplete class="tb-module-link-field" |
||||
|
#resourceAutocomplete |
||||
|
formControlName="moduleLink" |
||||
|
inlineField |
||||
|
hideRequiredMarker required |
||||
|
[subType]="ResourceSubType.MODULE" |
||||
|
[allowAutocomplete]="true" |
||||
|
placeholder="{{ 'widget-config.set' | translate }}"> |
||||
|
</tb-resource-autocomplete> |
||||
|
<div class="tb-form-table-row-cell-buttons"> |
||||
|
<div [tb-help-popup-async-content]="this.moduleRowFormGroup.get('moduleLink').value ? moduleDescription : null" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{marginTop: '8px'}" |
||||
|
help-icon-button-class="" |
||||
|
help-icon="info_outline" |
||||
|
help-opened-icon="info" |
||||
|
help-icon-tooltip="{{ 'js-func.show-module-info' | translate }}"></div> |
||||
|
<div [tb-help-popup-async-content]="this.moduleRowFormGroup.get('moduleLink').value ? moduleSourceCode : null" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{marginTop: '8px'}" |
||||
|
help-icon-button-class="" |
||||
|
help-icon="mdi:application-brackets-outline" |
||||
|
help-opened-icon="mdi:application-brackets" |
||||
|
help-icon-tooltip="{{ 'js-func.show-module-source-code' | translate }}"></div> |
||||
|
<button type="button" |
||||
|
mat-icon-button |
||||
|
(click)="moduleRemoved.emit()" |
||||
|
matTooltip="{{ 'js-func.remove-module' | translate }}" |
||||
|
matTooltipPosition="above"> |
||||
|
<mat-icon>delete</mat-icon> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,23 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2024 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. |
||||
|
*/ |
||||
|
.tb-js-func-module-row { |
||||
|
.tb-alias-field { |
||||
|
flex: 1 1 25%; |
||||
|
} |
||||
|
.tb-module-link-field { |
||||
|
flex: 1 1 75%; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,190 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 { |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
EventEmitter, |
||||
|
forwardRef, |
||||
|
Input, |
||||
|
OnInit, |
||||
|
Output, ViewChild, |
||||
|
ViewEncapsulation |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
ControlValueAccessor, |
||||
|
NG_VALIDATORS, |
||||
|
NG_VALUE_ACCESSOR, |
||||
|
UntypedFormBuilder, |
||||
|
UntypedFormControl, |
||||
|
UntypedFormGroup, |
||||
|
Validator, |
||||
|
ValidatorFn, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component'; |
||||
|
import { ResourceSubType } from '@shared/models/resource.models'; |
||||
|
import { Observable, of } from 'rxjs'; |
||||
|
import { ResourceAutocompleteComponent } from '@shared/components/resource/resource-autocomplete.component'; |
||||
|
import { HttpClient } from '@angular/common/http'; |
||||
|
import { loadModuleMarkdownDescription, loadModuleMarkdownSourceCode } from '@shared/models/js-function.models'; |
||||
|
import { TranslateService } from '@ngx-translate/core'; |
||||
|
|
||||
|
export interface JsFuncModuleRow { |
||||
|
alias: string; |
||||
|
moduleLink: string; |
||||
|
} |
||||
|
|
||||
|
export const moduleValid = (module: JsFuncModuleRow): boolean => !(!module.alias || !module.moduleLink); |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-js-func-module-row', |
||||
|
templateUrl: './js-func-module-row.component.html', |
||||
|
styleUrls: ['./js-func-module-row.component.scss'], |
||||
|
providers: [ |
||||
|
{ |
||||
|
provide: NG_VALUE_ACCESSOR, |
||||
|
useExisting: forwardRef(() => JsFuncModuleRowComponent), |
||||
|
multi: true |
||||
|
}, |
||||
|
{ |
||||
|
provide: NG_VALIDATORS, |
||||
|
useExisting: forwardRef(() => JsFuncModuleRowComponent), |
||||
|
multi: true |
||||
|
} |
||||
|
], |
||||
|
encapsulation: ViewEncapsulation.None |
||||
|
}) |
||||
|
export class JsFuncModuleRowComponent implements ControlValueAccessor, OnInit, Validator { |
||||
|
|
||||
|
ResourceSubType = ResourceSubType; |
||||
|
|
||||
|
@ViewChild('resourceAutocomplete') |
||||
|
resourceAutocomplete: ResourceAutocompleteComponent; |
||||
|
|
||||
|
@Input() |
||||
|
index: number; |
||||
|
|
||||
|
@Output() |
||||
|
moduleRemoved = new EventEmitter(); |
||||
|
|
||||
|
moduleRowFormGroup: UntypedFormGroup; |
||||
|
|
||||
|
modelValue: JsFuncModuleRow; |
||||
|
|
||||
|
moduleDescription = this.loadModuleDescription.bind(this); |
||||
|
|
||||
|
moduleSourceCode = this.loadModuleSourceCode.bind(this); |
||||
|
|
||||
|
private propagateChange = (_val: any) => {}; |
||||
|
|
||||
|
constructor(private fb: UntypedFormBuilder, |
||||
|
private cd: ChangeDetectorRef, |
||||
|
private modulesComponent: JsFuncModulesComponent, |
||||
|
private http: HttpClient, |
||||
|
private translate: TranslateService) {} |
||||
|
|
||||
|
ngOnInit() { |
||||
|
this.moduleRowFormGroup = this.fb.group({ |
||||
|
alias: [null, [this.moduleAliasValidator()]], |
||||
|
moduleLink: [null, [Validators.required]] |
||||
|
}); |
||||
|
this.moduleRowFormGroup.valueChanges.subscribe( |
||||
|
() => this.updateModel() |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
registerOnChange(fn: any): void { |
||||
|
this.propagateChange = fn; |
||||
|
} |
||||
|
|
||||
|
registerOnTouched(_fn: any): void { |
||||
|
} |
||||
|
|
||||
|
writeValue(value: JsFuncModuleRow): void { |
||||
|
this.modelValue = value; |
||||
|
this.moduleRowFormGroup.patchValue( |
||||
|
{ |
||||
|
alias: value?.alias, |
||||
|
moduleLink: value?.moduleLink |
||||
|
}, {emitEvent: false} |
||||
|
); |
||||
|
this.cd.markForCheck(); |
||||
|
} |
||||
|
|
||||
|
public validate(_c: UntypedFormControl) { |
||||
|
const aliasControl = this.moduleRowFormGroup.get('alias'); |
||||
|
if (aliasControl.hasError('moduleAliasNotUnique')) { |
||||
|
aliasControl.updateValueAndValidity({onlySelf: false, emitEvent: false}); |
||||
|
} |
||||
|
if (aliasControl.hasError('moduleAliasNotUnique')) { |
||||
|
this.moduleRowFormGroup.get('alias').markAsTouched(); |
||||
|
return { |
||||
|
moduleAliasNotUnique: true |
||||
|
}; |
||||
|
} |
||||
|
const module: JsFuncModuleRow = {...this.modelValue, ...this.moduleRowFormGroup.value}; |
||||
|
if (!moduleValid(module)) { |
||||
|
return { |
||||
|
module: true |
||||
|
}; |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
private loadModuleDescription(): Observable<string> | null { |
||||
|
const moduleLink = this.moduleRowFormGroup.get('moduleLink').value; |
||||
|
if (moduleLink) { |
||||
|
const resource = this.resourceAutocomplete.resource; |
||||
|
return loadModuleMarkdownDescription(this.http, this.translate, resource); |
||||
|
} else { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private loadModuleSourceCode(): Observable<string> | null { |
||||
|
const moduleLink = this.moduleRowFormGroup.get('moduleLink').value; |
||||
|
if (moduleLink) { |
||||
|
const resource = this.resourceAutocomplete.resource; |
||||
|
return loadModuleMarkdownSourceCode(this.http, this.translate, resource); |
||||
|
} else { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private moduleAliasValidator(): ValidatorFn { |
||||
|
return control => { |
||||
|
if (!control.value) { |
||||
|
return { |
||||
|
required: true |
||||
|
}; |
||||
|
} |
||||
|
if (!this.modulesComponent.moduleAliasUnique(control.value, this.index)) { |
||||
|
return { |
||||
|
moduleAliasNotUnique: true |
||||
|
}; |
||||
|
} |
||||
|
return null; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private updateModel() { |
||||
|
const value: JsFuncModuleRow = this.moduleRowFormGroup.value; |
||||
|
this.modelValue = {...this.modelValue, ...value}; |
||||
|
this.propagateChange(this.modelValue); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2024 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. |
||||
|
|
||||
|
--> |
||||
|
<div class="tb-js-func-modules-panel"> |
||||
|
<div class="tb-js-func-modules-panel-title" translate>js-func.modules</div> |
||||
|
<div class="tb-js-func-modules-panel-content"> |
||||
|
<div class="tb-form-panel no-border no-padding tb-js-func-modules"> |
||||
|
<div class="tb-form-table"> |
||||
|
<div class="tb-form-table-header"> |
||||
|
<div class="tb-form-table-header-cell tb-alias-header" translate>js-func.module-alias</div> |
||||
|
<div class="tb-form-table-header-cell tb-module-link-header" translate>js-func.module-resource</div> |
||||
|
<div class="tb-form-table-header-cell tb-actions-header"></div> |
||||
|
</div> |
||||
|
<div *ngIf="modulesFormArray().controls.length; else noModules" class="tb-form-table-body"> |
||||
|
<div *ngFor="let moduleControl of modulesFormArray().controls; trackBy: trackByModule; let $index = index;"> |
||||
|
<tb-js-func-module-row class="flex-1" |
||||
|
[index]="$index" |
||||
|
[formControl]="moduleControl" |
||||
|
(moduleRemoved)="removeModule($index)"> |
||||
|
</tb-js-func-module-row> |
||||
|
</div> |
||||
|
</div> |
||||
|
<tb-error *ngIf="modulesFormGroup.hasError('moduleAliasNotUnique')" |
||||
|
noMargin [error]="'js-func.not-unique-module-aliases-error' | translate" style="padding-left: 12px;"></tb-error> |
||||
|
</div> |
||||
|
<div> |
||||
|
<button type="button" mat-stroked-button color="primary" (click)="addModule()"> |
||||
|
{{ 'js-func.add-module' | translate }} |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-js-func-modules-panel-buttons"> |
||||
|
<button mat-button |
||||
|
color="primary" |
||||
|
type="button" |
||||
|
(click)="cancel()"> |
||||
|
{{ 'action.cancel' | translate }} |
||||
|
</button> |
||||
|
<button mat-raised-button |
||||
|
color="primary" |
||||
|
type="button" |
||||
|
(click)="applyModules()" |
||||
|
[disabled]="modulesFormGroup.invalid || !modulesFormGroup.dirty"> |
||||
|
{{ 'action.apply' | translate }} |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<ng-template #noModules> |
||||
|
<span class="tb-prompt flex items-center justify-center">{{ 'js-func.no-modules' | translate }}</span> |
||||
|
</ng-template> |
||||
@ -0,0 +1,74 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2024 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 '../../../scss/constants'; |
||||
|
|
||||
|
.tb-js-func-modules-panel { |
||||
|
width: 540px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 16px; |
||||
|
@media #{$mat-lt-md} { |
||||
|
width: 90vw; |
||||
|
} |
||||
|
.tb-js-func-modules-panel-content { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 16px; |
||||
|
overflow: auto; |
||||
|
margin: -10px; |
||||
|
padding: 10px; |
||||
|
} |
||||
|
.tb-js-func-modules-panel-title { |
||||
|
font-size: 16px; |
||||
|
font-weight: 500; |
||||
|
line-height: 24px; |
||||
|
letter-spacing: 0.25px; |
||||
|
color: rgba(0, 0, 0, 0.87); |
||||
|
} |
||||
|
.tb-js-func-modules-panel-buttons { |
||||
|
height: 40px; |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
gap: 16px; |
||||
|
justify-content: flex-end; |
||||
|
align-items: flex-end; |
||||
|
} |
||||
|
.tb-js-func-modules { |
||||
|
flex: 1; |
||||
|
margin: 12px; |
||||
|
.tb-form-table-header-cell { |
||||
|
&.tb-alias-header { |
||||
|
flex: 1 1 25%; |
||||
|
} |
||||
|
&.tb-module-link-header { |
||||
|
flex: 1 1 75%; |
||||
|
} |
||||
|
&.tb-actions-header { |
||||
|
width: 120px; |
||||
|
min-width: 120px; |
||||
|
} |
||||
|
} |
||||
|
.tb-form-table { |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
.tb-form-table-body { |
||||
|
overflow: auto; |
||||
|
tb-js-func-module-row { |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,135 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; |
||||
|
import { TbPopoverComponent } from '@shared/components/popover.component'; |
||||
|
import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, ValidatorFn } from '@angular/forms'; |
||||
|
import { JsFuncModuleRow, moduleValid } from '@shared/components/js-func-module-row.component'; |
||||
|
|
||||
|
const modulesValidator: ValidatorFn = control => { |
||||
|
const modulesArray = control.get('modules') as UntypedFormArray; |
||||
|
const notUniqueControls = |
||||
|
modulesArray.controls.filter(moduleControl => moduleControl.hasError('moduleAliasNotUnique')); |
||||
|
if (notUniqueControls.length) { |
||||
|
return { |
||||
|
moduleAliasNotUnique: true |
||||
|
}; |
||||
|
} |
||||
|
let valid = !modulesArray.controls.some(c => !c.valid); |
||||
|
valid = valid && control.valid; |
||||
|
return valid ? null : { |
||||
|
modules: { |
||||
|
valid: false, |
||||
|
}, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-js-func-modules', |
||||
|
templateUrl: './js-func-modules.component.html', |
||||
|
styleUrls: ['./js-func-modules.component.scss'], |
||||
|
encapsulation: ViewEncapsulation.None |
||||
|
}) |
||||
|
export class JsFuncModulesComponent implements OnInit { |
||||
|
|
||||
|
@Input() |
||||
|
modules: {[alias: string]: string }; |
||||
|
|
||||
|
@Input() |
||||
|
popover: TbPopoverComponent<JsFuncModulesComponent>; |
||||
|
|
||||
|
@Output() |
||||
|
modulesApplied = new EventEmitter<{[alias: string]: string }>(); |
||||
|
|
||||
|
modulesFormGroup: UntypedFormGroup; |
||||
|
|
||||
|
constructor(private fb: UntypedFormBuilder, |
||||
|
private cd: ChangeDetectorRef) { |
||||
|
} |
||||
|
|
||||
|
ngOnInit(): void { |
||||
|
const modulesControls: Array<AbstractControl> = []; |
||||
|
if (this.modules && Object.keys(this.modules).length) { |
||||
|
Object.keys(this.modules).forEach((alias) => { |
||||
|
const moduleRow: JsFuncModuleRow = { |
||||
|
alias, |
||||
|
moduleLink: this.modules[alias] |
||||
|
}; |
||||
|
modulesControls.push(this.fb.control(moduleRow, [])); |
||||
|
}); |
||||
|
} |
||||
|
this.modulesFormGroup = this.fb.group({ |
||||
|
modules: this.fb.array(modulesControls) |
||||
|
}, {validators: modulesValidator}); |
||||
|
} |
||||
|
|
||||
|
cancel() { |
||||
|
this.popover?.hide(); |
||||
|
} |
||||
|
|
||||
|
applyModules() { |
||||
|
let moduleRows: JsFuncModuleRow[] = this.modulesFormGroup.get('modules').value; |
||||
|
if (moduleRows) { |
||||
|
moduleRows = moduleRows.filter(m => moduleValid(m)); |
||||
|
} |
||||
|
if (moduleRows?.length) { |
||||
|
const modules: {[alias: string]: string } = {}; |
||||
|
moduleRows.forEach(row => { |
||||
|
modules[row.alias] = row.moduleLink; |
||||
|
}); |
||||
|
this.modulesApplied.emit(modules); |
||||
|
} else { |
||||
|
this.modulesApplied.emit(null); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public moduleAliasUnique(alias: string, index: number): boolean { |
||||
|
const modulesArray = this.modulesFormGroup.get('modules') as UntypedFormArray; |
||||
|
for (let i = 0; i < modulesArray.controls.length; i++) { |
||||
|
if (i !== index) { |
||||
|
const otherControl = modulesArray.controls[i]; |
||||
|
if (alias === otherControl.value.alias) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
modulesFormArray(): UntypedFormArray { |
||||
|
return this.modulesFormGroup.get('modules') as UntypedFormArray; |
||||
|
} |
||||
|
|
||||
|
trackByModule(_index: number, moduleControl: AbstractControl): any { |
||||
|
return moduleControl; |
||||
|
} |
||||
|
|
||||
|
removeModule(index: number, emitEvent = true) { |
||||
|
(this.modulesFormGroup.get('modules') as UntypedFormArray).removeAt(index, {emitEvent}); |
||||
|
} |
||||
|
|
||||
|
addModule() { |
||||
|
const moduleRow: JsFuncModuleRow = { |
||||
|
alias: '', |
||||
|
moduleLink: '' |
||||
|
}; |
||||
|
const modulesArray = this.modulesFormGroup.get('modules') as UntypedFormArray; |
||||
|
const moduleControl = this.fb.control(moduleRow, []); |
||||
|
modulesArray.push(moduleControl); |
||||
|
this.cd.detectChanges(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,310 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 { forkJoin, from, map, mergeMap, Observable, of, ReplaySubject, switchMap } from 'rxjs'; |
||||
|
import { removeTbResourcePrefix, ResourceInfo } from '@shared/models/resource.models'; |
||||
|
import { HttpClient } from '@angular/common/http'; |
||||
|
import { defaultHttpOptionsFromConfig } from '@core/http/http-utils'; |
||||
|
import { TbEditorCompleter, TbEditorCompletion } from '@shared/models/ace/completion.models'; |
||||
|
import { blobToText } from '@core/utils'; |
||||
|
import { catchError, finalize } from 'rxjs/operators'; |
||||
|
import { parseError } from '@shared/models/error.models'; |
||||
|
import { TranslateService } from '@ngx-translate/core'; |
||||
|
|
||||
|
export interface TbFunctionWithModules { |
||||
|
body: string; |
||||
|
modules: {[alias: string]: string }; |
||||
|
} |
||||
|
|
||||
|
export type TbFunction = string | TbFunctionWithModules; |
||||
|
|
||||
|
export const isNotEmptyTbFunction = (tbFunction: TbFunction): boolean => { |
||||
|
if (tbFunction) { |
||||
|
if (typeof tbFunction === 'string') { |
||||
|
return tbFunction.trim().length > 0; |
||||
|
} else { |
||||
|
return tbFunction.body && tbFunction.body.trim().length > 0; |
||||
|
} |
||||
|
} else { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const compileTbFunction = <T extends GenericFunction>(http: HttpClient, tbFunction: TbFunction, ...args: string[]): Observable<CompiledTbFunction<T>> => { |
||||
|
let functionBody: string; |
||||
|
let functionArgs: string[]; |
||||
|
let modules: {[alias: string]: string }; |
||||
|
if (typeof tbFunction === 'string') { |
||||
|
functionBody = tbFunction; |
||||
|
functionArgs = args; |
||||
|
} else { |
||||
|
functionBody = tbFunction.body; |
||||
|
modules = tbFunction.modules; |
||||
|
const modulesArgs = Object.keys(tbFunction.modules); |
||||
|
functionArgs = args.concat(modulesArgs); |
||||
|
} |
||||
|
return loadFunctionModules(http, modules).pipe( |
||||
|
map((compiledModules) => { |
||||
|
const compiledFunction = new Function(...functionArgs, functionBody); |
||||
|
return new CompiledTbFunction<T>(compiledFunction, compiledModules); |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export const loadModulesCompleter = (http: HttpClient, modules: {[alias: string]: string }): Observable<TbEditorCompleter | null> => { |
||||
|
if (!modules || !Object.keys(modules).length) { |
||||
|
return of(null); |
||||
|
} else { |
||||
|
const modulesDescription: {[alias: string]: Observable<TbEditorCompletion>} = {}; |
||||
|
for (const alias of Object.keys(modules)) { |
||||
|
modulesDescription[alias] = loadModuleCompletion(http, modules[alias]); |
||||
|
} |
||||
|
return forkJoin(modulesDescription).pipe( |
||||
|
map((completions) => { |
||||
|
return new TbEditorCompleter(completions); |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export const loadModuleMarkdownDescription = (http: HttpClient, translate: TranslateService, resource: ResourceInfo): Observable<string> => { |
||||
|
let description = `<div class="flex flex-col !pl-4 !pr-4"><h6>${resource.title}</h6><small>${translate.instant('js-func.module-members')}</small></div>\n\n`; |
||||
|
description += '<div class="divider !pt-2"></div>\n' + |
||||
|
'<br/>\n\n'; |
||||
|
return loadFunctionModuleWithSource(http, resource.link).pipe( |
||||
|
map((moduleWithSource) => { |
||||
|
const module = moduleWithSource.module; |
||||
|
const propertiesData: { type: 'function' | 'const', propName: string, description: string }[] = []; |
||||
|
for (const propName of Object.keys(module)) { |
||||
|
let propDescription = ''; |
||||
|
const prop = module[propName]; |
||||
|
const type = typeof prop; |
||||
|
if (type === 'function') { |
||||
|
const funcArgs = getFunctionArguments(prop); |
||||
|
propDescription += `<p class="!pl-4 !pr-4"><em>function</em> <strong>${propName}</strong> <em>(${funcArgs.join(', ')})</em>: <code>any</code></p>`; |
||||
|
} else { |
||||
|
propDescription += `<p class="!pl-4 !pr-4"><em>const</em> <strong>${propName}</strong>: <code>${type}</code>`; |
||||
|
if (type !== 'object') { |
||||
|
propDescription += ` = ${prop}`; |
||||
|
} |
||||
|
propDescription += '</p>'; |
||||
|
} |
||||
|
propDescription += '\n\n'; |
||||
|
const propertyData: { type: 'function' | 'const', propName: string, description: string } = { |
||||
|
type: type === 'function' ? 'function' : 'const', |
||||
|
propName, |
||||
|
description: propDescription |
||||
|
} |
||||
|
propertiesData.push(propertyData); |
||||
|
} |
||||
|
propertiesData.sort((a, b) => { |
||||
|
if (a.type === b.type) { |
||||
|
return a.propName.localeCompare(b.propName); |
||||
|
} else if (a.type === 'const') return -1; |
||||
|
else return 1; |
||||
|
}); |
||||
|
if (!propertiesData.length) { |
||||
|
description += `<div class="!pl-4 !pr-4">${translate.instant('js-func.module-no-members')}</div>\n\n`; |
||||
|
} else { |
||||
|
propertiesData.forEach((pData) => { |
||||
|
description += pData.description; |
||||
|
}); |
||||
|
} |
||||
|
return description; |
||||
|
}), |
||||
|
catchError(err => { |
||||
|
const errorText = parseError(err); |
||||
|
description += `<div class="!pl-4 !pr-4">${translate.instant('js-func.module-load-error')}:<br/><span style="color: red;">${errorText}</span></div>\n\n`; |
||||
|
return of(description); |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export const loadModuleMarkdownSourceCode = (http: HttpClient, translate: TranslateService, resource: ResourceInfo): Observable<string> => { |
||||
|
let sourceCode = `<div class="flex flex-col !pl-4"><h6>${resource.title}</h6><small>${translate.instant('js-func.source-code')}</small></div>\n\n`; |
||||
|
return loadFunctionModuleSource(http, resource.link).pipe( |
||||
|
map((source) => { |
||||
|
sourceCode += '```javascript\n{:code-style="margin-left: -16px; margin-right: -16px;"}\n' + source + '\n```'; |
||||
|
return sourceCode; |
||||
|
}), |
||||
|
catchError(err => { |
||||
|
const errorText = parseError(err); |
||||
|
sourceCode += `<div class="!pl-4 !pr-4">${translate.instant('js-func.source-code-load-error')}:<br/><span style="color: red;">${errorText}</span></div>\n\n`; |
||||
|
return of(sourceCode); |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const loadModuleCompletion = (http: HttpClient, moduleLink: string): Observable<TbEditorCompletion> => { |
||||
|
return loadFunctionModule(http, moduleLink).pipe( |
||||
|
map((module) => { |
||||
|
const completion: TbEditorCompletion = { |
||||
|
meta: 'module', |
||||
|
type: 'module', |
||||
|
children: {} |
||||
|
}; |
||||
|
for (const propName of Object.keys(module)) { |
||||
|
const prop = module[propName]; |
||||
|
const type = typeof prop; |
||||
|
const propertyCompletion: TbEditorCompletion = { |
||||
|
meta: type === 'function' ? 'function' : 'constant', |
||||
|
type |
||||
|
}; |
||||
|
if (type === 'function') { |
||||
|
propertyCompletion.args = getFunctionArguments(prop).map(functionArg => { |
||||
|
return {name: functionArg} |
||||
|
}); |
||||
|
propertyCompletion.return = { type: 'any'}; |
||||
|
} else if (type !== 'object') { |
||||
|
propertyCompletion.description = `<div class="tb-api-title">Constant value:</div><code class="title">${prop}</code>`; |
||||
|
} |
||||
|
completion.children[propName] = propertyCompletion; |
||||
|
} |
||||
|
return completion; |
||||
|
}), |
||||
|
catchError(err => { |
||||
|
const completion: TbEditorCompletion = { |
||||
|
meta: 'module', |
||||
|
type: 'module', |
||||
|
children: {} |
||||
|
}; |
||||
|
const errorText = parseError(err); |
||||
|
completion.description = `<div>Module load error:<br/><span style="color: red;">${errorText}</span></div>`; |
||||
|
return of(completion); |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export type GenericFunction = (...args: any[]) => any; |
||||
|
|
||||
|
export class CompiledTbFunction<T extends GenericFunction> { |
||||
|
|
||||
|
public execute: T = this.executeImpl.bind(this); |
||||
|
|
||||
|
constructor(private compiledFunction: Function, |
||||
|
private compiledModules: System.Module[]) { |
||||
|
} |
||||
|
|
||||
|
private executeImpl(...args: any[]): any { |
||||
|
let functionArgs: any[]; |
||||
|
if (this.compiledModules?.length) { |
||||
|
functionArgs = args ? args.concat(this.compiledModules) : this.compiledModules; |
||||
|
} else { |
||||
|
functionArgs = args; |
||||
|
} |
||||
|
return this.compiledFunction(...functionArgs); |
||||
|
} |
||||
|
|
||||
|
apply(thisArg: any, argArray?: any): any { |
||||
|
let functionArgs: any[]; |
||||
|
if (this.compiledModules?.length) { |
||||
|
functionArgs = argArray ? argArray.concat(this.compiledModules) : this.compiledModules; |
||||
|
} else { |
||||
|
functionArgs = argArray; |
||||
|
} |
||||
|
return this.compiledFunction.apply(thisArg, functionArgs); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const loadFunctionModules = (http: HttpClient, modules: {[alias: string]: string }): Observable<System.Module[]> => { |
||||
|
if (modules && Object.keys(modules).length) { |
||||
|
const moduleObservables: Observable<System.Module>[] = []; |
||||
|
for (const alias of Object.keys(modules)) { |
||||
|
moduleObservables.push(loadFunctionModule(http, modules[alias])); |
||||
|
} |
||||
|
return forkJoin(moduleObservables); |
||||
|
} else { |
||||
|
return of([]); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const modulesLoading: {[url: string]: ReplaySubject<System.Module>} = {}; |
||||
|
|
||||
|
const loadFunctionModule = (http: HttpClient, moduleLink: string): Observable<System.Module> => { |
||||
|
const url = removeTbResourcePrefix(moduleLink); |
||||
|
let request: ReplaySubject<System.Module>; |
||||
|
if (modulesLoading[url]) { |
||||
|
request = modulesLoading[url]; |
||||
|
} else { |
||||
|
request = new ReplaySubject<System.Module>(1); |
||||
|
modulesLoading[url] = request; |
||||
|
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true}); |
||||
|
http.get(url, {...options, ...{ observe: 'response', responseType: 'blob' } }).pipe( |
||||
|
mergeMap((response) => { |
||||
|
const objectURL = URL.createObjectURL(response.body); |
||||
|
const asyncModule = from(import(/* @vite-ignore */objectURL)); |
||||
|
URL.revokeObjectURL(objectURL); |
||||
|
return asyncModule; |
||||
|
}), |
||||
|
finalize(() => { |
||||
|
delete modulesLoading[url]; |
||||
|
}) |
||||
|
).subscribe( |
||||
|
{ |
||||
|
next: (value) => { |
||||
|
request.next(value); |
||||
|
request.complete(); |
||||
|
}, |
||||
|
error: err => { |
||||
|
request.error(err); |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
return request; |
||||
|
} |
||||
|
|
||||
|
interface TbModuleWithSource { |
||||
|
module: System.Module; |
||||
|
source: string; |
||||
|
} |
||||
|
|
||||
|
const loadFunctionModuleWithSource = (http: HttpClient, moduleLink: string): Observable<TbModuleWithSource> => { |
||||
|
const url = removeTbResourcePrefix(moduleLink); |
||||
|
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true}); |
||||
|
return http.get(url, {...options, ...{ observe: 'response', responseType: 'blob' } }).pipe( |
||||
|
switchMap((response) => { |
||||
|
const objectURL = URL.createObjectURL(response.body); |
||||
|
const asyncModule = from(import(/* @vite-ignore */objectURL)); |
||||
|
URL.revokeObjectURL(objectURL); |
||||
|
const asyncSource = blobToText(response.body); |
||||
|
return forkJoin({ |
||||
|
module: asyncModule, |
||||
|
source: asyncSource |
||||
|
}); |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
const loadFunctionModuleSource = (http: HttpClient, moduleLink: string): Observable<string> => { |
||||
|
const url = removeTbResourcePrefix(moduleLink); |
||||
|
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true}); |
||||
|
return http.get(url, {...options, ...{ responseType: 'text' } }); |
||||
|
} |
||||
|
|
||||
|
const getFunctionArguments = (func: Function): string[] => { |
||||
|
const fnStr = func.toString().replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg, ''); |
||||
|
const firstBracketIndex = fnStr.indexOf('('); |
||||
|
const secondBracketIndex = fnStr.indexOf(')'); |
||||
|
if (firstBracketIndex === -1 || secondBracketIndex === -1 || (secondBracketIndex - firstBracketIndex) <= 1) { |
||||
|
return []; |
||||
|
} |
||||
|
const match = fnStr.slice(firstBracketIndex+1, secondBracketIndex).match(/([^\s,]+)/g); |
||||
|
if (match) { |
||||
|
return new Array<string>(...match); |
||||
|
} else { |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue