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