Browse Source

Implement JS Module resources support.

pull/12171/head
Igor Kulikov 2 years ago
parent
commit
6b6bbebab8
  1. 8
      application/src/main/data/upgrade/3.8.1/schema_update.sql
  2. 1
      application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
  3. 8
      application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
  4. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java
  5. 7
      ui-ngx/src/app/core/http/resource.service.ts
  6. 13
      ui-ngx/src/app/core/services/menu.models.ts
  7. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html
  8. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html
  9. 59
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  10. 39
      ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
  11. 20
      ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
  12. 170
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts
  13. 30
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html
  14. 42
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.ts
  15. 117
      ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html
  16. 176
      ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts
  17. 6
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html
  18. 10
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts
  19. 2
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts
  20. 10
      ui-ngx/src/app/shared/components/file-input.component.html
  21. 41
      ui-ngx/src/app/shared/components/file-input.component.scss
  22. 10
      ui-ngx/src/app/shared/components/file-input.component.ts
  23. 39
      ui-ngx/src/app/shared/components/js-func-module-row.component.html
  24. 23
      ui-ngx/src/app/shared/components/js-func-module-row.component.scss
  25. 156
      ui-ngx/src/app/shared/components/js-func-module-row.component.ts
  26. 66
      ui-ngx/src/app/shared/components/js-func-modules.component.html
  27. 74
      ui-ngx/src/app/shared/components/js-func-modules.component.scss
  28. 135
      ui-ngx/src/app/shared/components/js-func-modules.component.ts
  29. 14
      ui-ngx/src/app/shared/components/js-func.component.html
  30. 138
      ui-ngx/src/app/shared/components/js-func.component.ts
  31. 8
      ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.html
  32. 12
      ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts
  33. 126
      ui-ngx/src/app/shared/models/js-function.models.ts
  34. 13
      ui-ngx/src/app/shared/models/resource.models.ts
  35. 3
      ui-ngx/src/app/shared/models/widget.models.ts
  36. 6
      ui-ngx/src/app/shared/shared.module.ts
  37. 38
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  38. 9
      ui-ngx/src/form.scss

8
application/src/main/data/upgrade/3.8.1/schema_update.sql

@ -176,4 +176,10 @@ $$
END IF;
ALTER TABLE qr_code_settings DROP COLUMN IF EXISTS android_config, DROP COLUMN IF EXISTS ios_config;
END;
$$;
$$;
-- UPDATE RESOURCE JS_MODULE SUB TYPE START
UPDATE resource SET resource_sub_type = 'EXTENSION' WHERE resource_type = 'JS_MODULE' AND resource_sub_type IS NULL;
-- UPDATE RESOURCE JS_MODULE SUB TYPE END

1
application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java

@ -125,6 +125,7 @@ public class ControllerConstants {
protected static final String RESOURCE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the resource title.";
protected static final String RESOURCE_TYPE = "A string value representing the resource type.";
protected static final String RESOURCE_SUB_TYPE = "A string value representing the resource sub-type.";
protected static final String LWM2M_OBJECT_DESCRIPTION = "LwM2M Object is a object that includes information about the LwM2M model which can be used in transport configuration for the LwM2M device profile. ";

8
application/src/main/java/org/thingsboard/server/controller/TbResourceController.java

@ -38,6 +38,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
@ -70,6 +71,7 @@ import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DE
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_INFO_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_SUB_TYPE;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TYPE;
import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION;
@ -229,6 +231,8 @@ public class TbResourceController extends BaseController {
@RequestParam int page,
@Parameter(description = RESOURCE_TYPE, schema = @Schema(allowableValues = {"LWM2M_MODEL", "JKS", "PKCS_12", "JS_MODULE"}))
@RequestParam(required = false) String resourceType,
@Parameter(description = RESOURCE_SUB_TYPE, schema = @Schema(allowableValues = {"EXTENSION", "MODULE"}))
@RequestParam(required = false) String resourceSubType,
@Parameter(description = RESOURCE_TEXT_SEARCH_DESCRIPTION)
@RequestParam(required = false) String textSearch,
@Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "title", "resourceType", "tenantId"}))
@ -241,8 +245,12 @@ public class TbResourceController extends BaseController {
Set<ResourceType> resourceTypes = new HashSet<>();
if (StringUtils.isNotEmpty(resourceType)) {
resourceTypes.add(ResourceType.valueOf(resourceType));
if (StringUtils.isNotEmpty(resourceSubType)) {
filter.resourceSubTypes(Set.of(ResourceSubType.valueOf(resourceSubType)));
}
} else {
Collections.addAll(resourceTypes, ResourceType.values());
resourceTypes.remove(ResourceType.JS_MODULE);
resourceTypes.remove(ResourceType.IMAGE);
resourceTypes.remove(ResourceType.DASHBOARD);
}

4
common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java

@ -17,5 +17,7 @@ package org.thingsboard.server.common.data;
public enum ResourceSubType {
IMAGE,
SCADA_SYMBOL
SCADA_SYMBOL,
EXTENSION,
MODULE
}

7
ui-ngx/src/app/core/http/resource.service.ts

@ -20,7 +20,7 @@ import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { forkJoin, Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data';
import { Resource, ResourceInfo, ResourceType, TBResourceScope } from '@shared/models/resource.models';
import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope } from '@shared/models/resource.models';
import { catchError, mergeMap } from 'rxjs/operators';
import { isNotEmptyStr } from '@core/utils';
import { ResourcesService } from '@core/services/resources.service';
@ -36,11 +36,14 @@ export class ResourceService {
}
public getResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
public getResources(pageLink: PageLink, resourceType?: ResourceType, resourceSubType?: ResourceSubType, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
let url = `/api/resource${pageLink.toQuery()}`;
if (isNotEmptyStr(resourceType)) {
url += `&resourceType=${resourceType}`;
}
if (isNotEmptyStr(resourceSubType)) {
url += `&resourceSubType=${resourceSubType}`;
}
return this.http.get<PageData<ResourceInfo>>(url, defaultHttpOptionsFromConfig(config));
}

13
ui-ngx/src/app/core/services/menu.models.ts

@ -59,6 +59,7 @@ export enum MenuId {
images = 'images',
scada_symbols = 'scada_symbols',
resources_library = 'resources_library',
javascript_library = 'javascript_library',
notifications_center = 'notifications_center',
notification_inbox = 'notification_inbox',
notification_sent = 'notification_sent',
@ -209,6 +210,16 @@ export const menuSectionMap = new Map<MenuId, MenuSection>([
icon: 'mdi:rhombus-split'
}
],
[
MenuId.javascript_library,
{
id: MenuId.javascript_library,
name: 'javascript.javascript-library',
type: 'link',
path: '/resources/javascript-library',
icon: 'mdi:language-javascript'
}
],
[
MenuId.notifications_center,
{
@ -707,6 +718,7 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
},
{id: MenuId.images},
{id: MenuId.scada_symbols},
{id: MenuId.javascript_library},
{id: MenuId.resources_library}
]
},
@ -803,6 +815,7 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
},
{id: MenuId.images},
{id: MenuId.scada_symbols},
{id: MenuId.javascript_library},
{id: MenuId.resources_library}
]
},

2
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html

@ -51,7 +51,7 @@
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
<img class="preview" [src]="item.image | image: {emptyUrl: '/assets/widget-preview-empty.svg'} | async" alt="{{ item.title }}">
<img class="preview" [src]="item.image | image: {emptyUrl: '/assets/widget-preview-empty.svg', preview: item.scada} | async" alt="{{ item.title }}">
</div>
</mat-card>
</ng-template>

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html

@ -237,6 +237,7 @@
[globalVariables]="functionScopeVariables"
[validationArgs]="[]"
[editorCompleter]="customActionEditorCompleter"
withModules
helpId="widget/action/custom_action_fn"
></tb-js-func>
</ng-template>

59
ui-ngx/src/app/modules/home/components/widget/widget.component.ts

@ -120,6 +120,7 @@ import { DASHBOARD_PAGE_COMPONENT_TOKEN } from '@home/components/tokens';
import { MODULES_MAP } from '@shared/models/constants';
import { IModulesMap } from '@modules/common/modules-map.models';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
import { compileTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models';
@Component({
selector: 'tb-widget',
@ -1108,17 +1109,25 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
break;
case WidgetActionType.custom:
const customFunction = descriptor.customFunction;
if (customFunction && customFunction.length > 0) {
try {
if (!additionalParams) {
additionalParams = {};
if (isNotEmptyTbFunction(customFunction)) {
compileTbFunction(this.widgetContext.http, customFunction, '$event', 'widgetContext', 'entityId',
'entityName', 'additionalParams', 'entityLabel').subscribe(
{
next: (compiled) => {
try {
if (!additionalParams) {
additionalParams = {};
}
compiled.execute($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
} catch (e) {
console.error(e);
}
},
error: (err) => {
console.error(err);
}
}
const customActionFunction = new Function('$event', 'widgetContext', 'entityId',
'entityName', 'additionalParams', 'entityLabel', customFunction);
customActionFunction($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
} catch (e) {
console.error(e);
}
)
}
break;
case WidgetActionType.customPretty:
@ -1133,18 +1142,26 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
}
this.loadCustomActionResources(actionNamespace, customCss, customResources, descriptor).subscribe({
next: () => {
if (isDefined(customPrettyFunction) && customPrettyFunction.length > 0) {
try {
if (!additionalParams) {
additionalParams = {};
if (isNotEmptyTbFunction(customPrettyFunction)) {
compileTbFunction(this.widgetContext.http, customPrettyFunction, '$event', 'widgetContext', 'entityId',
'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel').subscribe(
{
next: (compiled) => {
try {
if (!additionalParams) {
additionalParams = {};
}
this.widgetContext.customDialog.setAdditionalImports(descriptor.customImports);
compiled.execute($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel);
} catch (e) {
console.error(e);
}
},
error: (err) => {
console.error(err);
}
}
const customActionPrettyFunction = new Function('$event', 'widgetContext', 'entityId',
'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel', customPrettyFunction);
this.widgetContext.customDialog.setAdditionalImports(descriptor.customImports);
customActionPrettyFunction($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel);
} catch (e) {
console.error(e);
}
)
}
},
error: (errorMessages: string[]) => {

39
ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts

@ -45,6 +45,7 @@ import { ImageService } from '@core/http/image.service';
import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol-editor.models';
import { MenuId } from '@core/services/menu.models';
import { catchError } from 'rxjs/operators';
import { JsLibraryTableConfigResolver } from '@home/pages/admin/resource/js-library-table-config.resolver';
export const scadaSymbolResolver: ResolveFn<ScadaSymbolData> =
(route: ActivatedRouteSnapshot,
@ -177,6 +178,43 @@ const routes: Routes = [
}
}
]
},
{
path: 'javascript-library',
data: {
breadcrumb: {
menuId: MenuId.javascript_library
}
},
children: [
{
path: '',
component: EntitiesTableComponent,
data: {
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'javascript.javascript-library',
},
resolve: {
entitiesTableConfig: JsLibraryTableConfigResolver
}
},
{
path: ':entityId',
component: EntityDetailsPageComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
breadcrumb: {
labelFunction: entityDetailsPageBreadcrumbLabelFunction,
icon: 'mdi:language-javascript'
} as BreadCrumbConfig<EntityDetailsPageComponent>,
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'javascript.javascript-library'
},
resolve: {
entitiesTableConfig: JsLibraryTableConfigResolver
}
}
]
}
]
},
@ -393,6 +431,7 @@ const routes: Routes = [
exports: [RouterModule],
providers: [
ResourcesLibraryTableConfigResolver,
JsLibraryTableConfigResolver,
QueuesTableConfigResolver
]
})

20
ui-ngx/src/app/modules/home/pages/admin/admin.module.ts

@ -33,6 +33,9 @@ import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-a
import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component';
import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component';
import { OAuth2Module } from '@home/pages/admin/oauth2/oauth2.module';
import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-library-table-header.component';
import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component';
import { NgxFlowModule } from '@flowjs/ngx-flow';
@NgModule({
declarations:
@ -45,17 +48,20 @@ import { OAuth2Module } from '@home/pages/admin/oauth2/oauth2.module';
HomeSettingsComponent,
ResourcesLibraryComponent,
ResourcesTableHeaderComponent,
JsResourceComponent,
JsLibraryTableHeaderComponent,
QueueComponent,
RepositoryAdminSettingsComponent,
AutoCommitAdminSettingsComponent,
TwoFactorAuthSettingsComponent
],
imports: [
CommonModule,
SharedModule,
HomeComponentsModule,
AdminRoutingModule,
OAuth2Module
]
imports: [
CommonModule,
SharedModule,
HomeComponentsModule,
AdminRoutingModule,
OAuth2Module,
NgxFlowModule
]
})
export class AdminModule { }

170
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts

@ -0,0 +1,170 @@
///
/// 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 => {
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;
}
}
}

30
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html

@ -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>

42
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.ts

@ -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);
}
}

117
ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html

@ -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>

176
ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts

@ -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;
}

6
ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html

@ -66,9 +66,9 @@
{{ 'resource.title-max-length' | translate }}
</mat-error>
</mat-form-field>
<tb-file-input *ngIf="isAdd || (isEdit && entityForm.get('resourceType').value === resourceType.JS_MODULE)"
<tb-file-input *ngIf="isAdd"
formControlName="data"
[required]="isAdd"
required
label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}"
[readAsBinary]="true"
[maxSizeByte]="maxResourceSize"
@ -80,7 +80,7 @@
[existingFileName]="entityForm.get('fileName')?.value"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
<div *ngIf="!isAdd && !(isEdit && entityForm.get('resourceType').value === resourceType.JS_MODULE)" class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<div *ngIf="!isAdd" 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">

10
ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts

@ -41,7 +41,7 @@ import { getCurrentAuthState } from '@core/auth/auth.selectors';
export class ResourcesLibraryComponent extends EntityComponent<Resource> implements OnInit, OnDestroy {
readonly resourceType = ResourceType;
readonly resourceTypes: ResourceType[] = Object.values(this.resourceType);
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize;
@ -80,7 +80,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
buildForm(entity: Resource): FormGroup {
return this.fb.group({
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]],
resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.JS_MODULE, Validators.required],
resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required],
fileName: [entity ? entity.fileName : null, Validators.required],
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []]
});
@ -94,9 +94,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
super.updateFormState();
if (this.isEdit && this.entityForm && !this.isAdd) {
this.entityForm.get('resourceType').disable({ emitEvent: false });
if (this.entityForm.get('resourceType').value !== ResourceType.JS_MODULE) {
this.entityForm.get('fileName').disable({ emitEvent: false });
}
this.entityForm.get('fileName').disable({ emitEvent: false });
}
}
@ -140,7 +138,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
private observeResourceTypeChange(): void {
this.entityForm.get('resourceType').valueChanges.pipe(
startWith(ResourceType.JS_MODULE),
startWith(ResourceType.LWM2M_MODEL),
takeUntil(this.destroy$)
).subscribe((type: ResourceType) => this.onResourceTypeChange(type));
}

2
ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts

@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link';
})
export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent<Resource, PageLink, ResourceInfo> {
readonly resourceTypes: ResourceType[] = Object.values(ResourceType);
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
constructor(protected store: Store<AppState>) {

10
ui-ngx/src/app/shared/components/file-input.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-container">
<div class="tb-container" [class.tb-button]="asButton">
<label class="tb-title" *ngIf="label"
[class.tb-required]="!disabled && required"
[class.pointer-event]="hint"
@ -38,11 +38,11 @@
flowDrop
[flow]="flow.flowJs">
<div class="upload-label">
<mat-icon class="tb-mat-32">cloud_upload</mat-icon>
<span>{{ dropLabel }}</span>
<button type="button" mat-button color="primary" class="browse-file">
<mat-icon class="tb-mat-32 drop-label-icon">cloud_upload</mat-icon>
<span class="drop-label-text">{{ dropLabel }}</span>
<button type="button" mat-button color="primary" class="browse-file" [class]="uploadButtonClass">
<label
for="{{inputId}}">{{ (multipleFile ? 'file-input.browse-files' : 'file-input.browse-file') | translate}}</label>
for="{{inputId}}">{{ uploadButtonText ? uploadButtonText : ((multipleFile ? 'file-input.browse-files' : 'file-input.browse-file') | translate) }}</label>
</button>
<input class="file-input" flowButton #flowInput type="file" [flow]="flow.flowJs"
[flowAttributes]="{accept: accept}" id="{{inputId}}">

41
ui-ngx/src/app/shared/components/file-input.component.scss

@ -29,6 +29,37 @@ $previewSize: 100px !default;
display: flex;
padding-bottom: 0;
}
&.tb-button {
padding: 0;
gap: 0;
.tb-title {
display: none;
}
.tb-file-select-container {
width: auto;
height: auto;
.tb-file-clear-container {
display: none;
}
.tb-flow-drop {
height: auto;
border: none;
border-radius: 0;
.upload-label {
padding: 0;
.drop-label-icon {
display: none;
}
.drop-label-text {
display: none;
}
}
}
}
.tb-file-info-container {
display: none;
}
}
}
.tb-file-select-container {
@ -114,10 +145,12 @@ $previewSize: 100px !default;
:host ::ng-deep {
button.mat-mdc-button.mat-mdc-button-base.browse-file {
padding: 0;
min-width: 0;
height: 24px;
font-size: 16px;
&:not(.tb-ignore-browse-file-button-style) {
padding: 0;
min-width: 0;
height: 24px;
font-size: 16px;
}
label {
display: block;
cursor: pointer;

10
ui-ngx/src/app/shared/components/file-input.component.ts

@ -102,6 +102,16 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
@Input()
workFromFileObj = false;
@Input()
@coerceBoolean()
asButton: boolean;
@Input()
uploadButtonClass = 'browse-file';
@Input()
uploadButtonText: string;
private multipleFileValue = false;
@Input()

39
ui-ngx/src/app/shared/components/js-func-module-row.component.html

@ -0,0 +1,39 @@
<!--
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"
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">
<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>

23
ui-ngx/src/app/shared/components/js-func-module-row.component.scss

@ -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 40%;
}
.tb-module-link-field {
flex: 1 1 60%;
}
}

156
ui-ngx/src/app/shared/components/js-func-module-row.component.ts

@ -0,0 +1,156 @@
///
/// 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,
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';
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;
@Input()
index: number;
@Output()
moduleRemoved = new EventEmitter();
moduleRowFormGroup: UntypedFormGroup;
modelValue: JsFuncModuleRow;
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private cd: ChangeDetectorRef,
private modulesComponent: JsFuncModulesComponent) {}
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 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);
}
}

66
ui-ngx/src/app/shared/components/js-func-modules.component.html

@ -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>

74
ui-ngx/src/app/shared/components/js-func-modules.component.scss

@ -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 40%;
}
&.tb-module-link-header {
flex: 1 1 60%;
}
&.tb-actions-header {
width: 40px;
min-width: 40px;
}
}
.tb-form-table {
overflow: hidden;
}
.tb-form-table-body {
overflow: auto;
tb-js-func-module-row {
overflow: hidden;
}
}
}
}

135
ui-ngx/src/app/shared/components/js-func-modules.component.ts

@ -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();
}
}

14
ui-ngx/src/app/shared/components/js-func.component.html

@ -19,12 +19,24 @@
tb-fullscreen
[fullscreen]="fullscreen">
<div style="min-height: 40px;" class="tb-js-func-toolbar flex flex-row items-center justify-start">
<label class="tb-title no-padding"
<label *ngIf="!hideLabel" class="tb-title no-padding"
[class]="{'tb-error': !disabled && (hasErrors || !functionValid || required && !modelValue), 'tb-required': !disabled && required}">
{{ functionLabel }}
</label>
<ng-content select="[toolbarStartButton]"></ng-content>
<span class="flex-1"></span>
<ng-content select="[toolbarPrefixButton]"></ng-content>
<fieldset *ngIf="!disabled && withModules" style="width: initial">
<div matTooltip="{{'js-func.modules' | translate}}"
matTooltipPosition="above"
style="border-radius: 50%"
#editModulesButton
(click)="editModules($event, editModulesButton)">
<button type='button' mat-icon-button class="tb-mat-32">
<tb-icon color="primary" matButtonIcon>mdi:application-braces-outline</tb-icon>
</button>
</div>
</fieldset>
<button type='button' *ngIf="!disabled" mat-button class="tidy" (click)="beautifyJs()">
{{'js-func.tidy' | translate }}
</button>

138
ui-ngx/src/app/shared/components/js-func.component.ts

@ -21,8 +21,8 @@ import {
forwardRef,
Input,
OnDestroy,
OnInit,
ViewChild,
OnInit, Renderer2,
ViewChild, ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormControl, Validator } from '@angular/forms';
@ -33,13 +33,20 @@ import { ActionNotificationHide, ActionNotificationShow } from '@core/notificati
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { UtilsService } from '@core/services/utils.service';
import { guid, isUndefined } from '@app/core/utils';
import { deepClone, guid, isUndefined, isUndefinedOrNull } from '@app/core/utils';
import { TranslateService } from '@ngx-translate/core';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
import { TbEditorCompleter } from '@shared/models/ace/completion.models';
import { beautifyJs } from '@shared/models/beautify.models';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { coerceBoolean } from '@shared/decorators/coercion';
import { TbFunction } from '@shared/models/js-function.models';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import {
ScadaSymbolPropertyPanelComponent
} from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
@Component({
selector: 'tb-js-func',
@ -103,6 +110,14 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
@coerceBoolean()
hideBrackets = false;
@Input()
@coerceBoolean()
hideLabel = false;
@Input()
@coerceBoolean()
withModules = false;
private noValidateValue: boolean;
get noValidate(): boolean {
return this.noValidateValue;
@ -127,6 +142,8 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
modelValue: string;
modules: {[alias: string]: string };
functionValid = true;
validationError: string;
@ -139,6 +156,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
private functionArgsString = '';
private propagateChange = null;
private _onTouched = null;
public hasErrors = false;
constructor(public elementRef: ElementRef,
@ -146,7 +164,10 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
private translate: TranslateService,
protected store: Store<AppState>,
private raf: RafService,
private cd: ChangeDetectorRef) {
private cd: ChangeDetectorRef,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef) {
}
ngOnInit(): void {
@ -200,6 +221,11 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
this.updateView();
}
});
this.jsEditor.on('blur', () => {
if (this._onTouched) {
this._onTouched();
}
});
if (!this.disableUndefinedCheck) {
// @ts-ignore
this.jsEditor.session.on('changeAnnotation', () => {
@ -231,26 +257,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
// @ts-ignore
this.jsEditor.session.$onChangeMode(newMode);
}
// @ts-ignore
if (!!this.jsEditor.session.$worker) {
const jsWorkerOptions = {
undef: !this.disableUndefinedCheck,
unused: true,
globals: {}
};
if (!this.disableUndefinedCheck && this.functionArgs) {
this.functionArgs.forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
if (!this.disableUndefinedCheck && this.globalVariables) {
this.globalVariables.forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
// @ts-ignore
this.jsEditor.session.$worker.send('changeOptions', [jsWorkerOptions]);
}
this.updateJsWorkerGlobals();
if (this.editorCompleter) {
this.jsEditor.completers = [this.editorCompleter, ...(this.jsEditor.completers || [])];
}
@ -287,6 +294,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
}
registerOnTouched(fn: any): void {
this._onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
@ -437,22 +445,94 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
}
}
writeValue(value: string): void {
this.modelValue = value;
writeValue(value: TbFunction): void {
if (isUndefinedOrNull(value) || typeof value === 'string') {
this.modelValue = value as any;
} else {
this.modelValue = value.body;
this.modules = value.modules;
}
if (this.jsEditor) {
if (this.withModules) {
this.updateJsWorkerGlobals();
}
this.ignoreChange = true;
this.jsEditor.setValue(this.modelValue ? this.modelValue : '', -1);
this.ignoreChange = false;
}
}
updateView() {
updateView(force = false) {
const editorValue = this.jsEditor.getValue();
if (this.modelValue !== editorValue) {
if (this.modelValue !== editorValue || force) {
this.modelValue = editorValue;
this.functionValid = true;
this.propagateChange(this.modelValue);
if (this.withModules && this.modules && Object.keys(this.modules).length) {
const tbFunction: TbFunction = {
body: this.modelValue,
modules: this.modules
};
this.propagateChange(tbFunction);
} else {
this.propagateChange(this.modelValue);
}
this.cd.markForCheck();
}
}
editModules($event: Event, element: Element) {
if ($event) {
$event.stopPropagation();
}
const trigger = element;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
modules: deepClone(this.modules)
};
const modulesPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, JsFuncModulesComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null,
ctx,
{},
{}, {}, true);
modulesPanelPopover.tbComponentRef.instance.popover = modulesPanelPopover;
modulesPanelPopover.tbComponentRef.instance.modulesApplied.subscribe((modules) => {
modulesPanelPopover.hide();
this.modules = modules;
this.updateJsWorkerGlobals();
this.updateView(true);
});
}
}
private updateJsWorkerGlobals() {
// @ts-ignore
if (!!this.jsEditor.session.$worker) {
const jsWorkerOptions = {
undef: !this.disableUndefinedCheck,
unused: true,
globals: {}
};
if (!this.disableUndefinedCheck) {
if (this.functionArgs) {
this.functionArgs.forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
if (this.withModules && this.modules) {
Object.keys(this.modules).forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
if (this.globalVariables) {
this.globalVariables.forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
}
// @ts-ignore
this.jsEditor.session.$worker.send('changeOptions', [jsWorkerOptions]);
}
}
}

8
ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.html

@ -15,10 +15,12 @@
limitations under the License.
-->
<mat-form-field [formGroup]="resourceFormGroup" class="mat-block"
[appearance]="appearance"
<mat-form-field [formGroup]="resourceFormGroup"
[class]="{'tb-inline-field': inlineField, 'flex': inlineField}"
class="mat-block"
[appearance]="inlineField ? 'outline' : appearance"
[hideRequiredMarker]="hideRequiredMarker"
[subscriptSizing]="subscriptSizing">
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing">
<input matInput type="text"
#resourceInput
formControlName="resource"

12
ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts

@ -26,6 +26,7 @@ import {
prependTbResourcePrefix,
removeTbResourcePrefix,
ResourceInfo,
ResourceSubType,
ResourceType
} from '@shared/models/resource.models';
import { TbResourceId } from '@shared/models/id/tb-resource-id';
@ -59,6 +60,10 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
@Input()
subscriptSizing: SubscriptSizing = 'fixed';
@Input()
@coerceBoolean()
inlineField: boolean;
@Input()
placeholder: string;
@ -70,6 +75,9 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
@coerceBoolean()
allowAutocomplete = false;
@Input()
subType = ResourceSubType.EXTENSION;
resourceFormGroup = this.fb.group({
resource: this.fb.control<string|ResourceInfo>(null)
});
@ -101,7 +109,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
let modelValue: string;
if (isObject(value)) {
modelValue = prependTbResourcePrefix((value as ResourceInfo).link);
} else if (isEmptyStr(value)) {
} else if (isEmptyStr(value) || this.subType !== ResourceSubType.EXTENSION) {
modelValue = null;
} else {
modelValue = value as string;
@ -196,7 +204,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
private fetchResources(searchText?: string): Observable<Array<ResourceInfo>> {
this.searchText = searchText;
return this.resourceService.getResources(new PageLink(50, 0, searchText), ResourceType.JS_MODULE, {ignoreLoading: true}).pipe(
return this.resourceService.getResources(new PageLink(50, 0, searchText), ResourceType.JS_MODULE, this.subType, {ignoreLoading: true}).pipe(
catchError(() => of(null)),
map(data => data.data)
);

126
ui-ngx/src/app/shared/models/js-function.models.ts

@ -0,0 +1,126 @@
///
/// 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, Observable, of, ReplaySubject, switchMap } from 'rxjs';
import { removeTbResourcePrefix } from '@shared/models/resource.models';
import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig } from '@core/http/http-utils';
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 true;
}
}
export const compileTbFunction = (http: HttpClient, tbFunction: TbFunction, ...args: string[]): Observable<CompiledTbFunction> => {
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(compiledFunction, compiledModules);
})
);
}
export class CompiledTbFunction {
constructor(private compiledFunction: Function,
private compiledModules: System.Module[]) {
}
execute(...args: any[]): any {
let functionArgs: any[];
if (this.compiledModules?.length) {
functionArgs = args.concat(this.compiledModules);
} else {
functionArgs = args;
}
return this.compiledFunction(...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(
switchMap((response) => {
const objectURL = URL.createObjectURL(response.body);
const asyncModule = from(import(/* @vite-ignore */objectURL));
URL.revokeObjectURL(objectURL);
return asyncModule;
})
).subscribe(
{
next: (value) => {
request.next(value);
request.complete();
},
error: err => {
request.error(err);
},
complete: () => {
delete modulesLoading[url];
}
}
);
}
return request;
}

13
ui-ngx/src/app/shared/models/resource.models.ts

@ -29,7 +29,9 @@ export enum ResourceType {
export enum ResourceSubType {
IMAGE = 'IMAGE',
SCADA_SYMBOL = 'SCADA_SYMBOL'
SCADA_SYMBOL = 'SCADA_SYMBOL',
EXTENSION = 'EXTENSION',
MODULE = 'MODULE'
}
export const ResourceTypeMIMETypes = new Map<ResourceType, string>(
@ -59,6 +61,15 @@ export const ResourceTypeTranslationMap = new Map<ResourceType, string>(
]
);
export const ResourceSubTypeTranslationMap = new Map<ResourceSubType, string>(
[
[ResourceSubType.IMAGE, 'resource.sub-type.image'],
[ResourceSubType.SCADA_SYMBOL, 'resource.sub-type.scada-symbol'],
[ResourceSubType.EXTENSION, 'resource.sub-type.extension'],
[ResourceSubType.MODULE, 'resource.sub-type.module']
]
);
export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' | 'label'>, HasTenantId, ExportableEntity<TbResourceId> {
tenantId?: TenantId;
resourceKey?: string;

3
ui-ngx/src/app/shared/models/widget.models.ts

@ -44,6 +44,7 @@ import { NULL_UUID } from '@shared/models/id/has-uuid';
import { HasTenantId, HasVersion } from '@shared/models/entity.models';
import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models';
import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models';
import { TbFunction } from '@shared/models/js-function.models';
export enum widgetType {
timeseries = 'timeseries',
@ -656,7 +657,7 @@ export interface WidgetMobileActionDescriptor extends WidgetMobileActionDescript
}
export interface CustomActionDescriptor {
customFunction?: string;
customFunction?: TbFunction;
customResources?: Array<WidgetResource>;
customHtml?: string;
customCss?: string;

6
ui-ngx/src/app/shared/shared.module.ts

@ -218,6 +218,8 @@ import { CountryAutocompleteComponent } from '@shared/components/country-autocom
import { CountryData } from '@shared/models/country.models';
import { SvgXmlComponent } from '@shared/components/svg-xml.component';
import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -333,6 +335,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
JsonObjectViewComponent,
JsonContentComponent,
JsFuncComponent,
JsFuncModulesComponent,
JsFuncModuleRowComponent,
CssComponent,
HtmlComponent,
SvgXmlComponent,
@ -538,6 +542,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
JsonObjectViewComponent,
JsonContentComponent,
JsFuncComponent,
JsFuncModulesComponent,
JsFuncModuleRowComponent,
CssComponent,
HtmlComponent,
SvgXmlComponent,

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

@ -3296,7 +3296,14 @@
"no-return-error": "Function must return value!",
"return-type-mismatch": "Function must return value of '{{type}}' type!",
"tidy": "Tidy",
"mini": "Mini"
"mini": "Mini",
"modules": "Modules",
"remove-module": "Remove module",
"no-modules": "No modules configured",
"add-module": "Add module",
"module-alias": "Alias",
"module-resource": "JS module resource",
"not-unique-module-aliases-error": "Modules aliases must be unique!"
},
"key-val": {
"key": "Key",
@ -4078,8 +4085,37 @@
"js-module": "JS module",
"lwm2m-model": "LWM2M model",
"pkcs-12": "PKCS #12"
},
"resource-sub-type": "Sub-type",
"sub-type": {
"image": "image",
"scada-symbol": "Scada symbol",
"extension": "Extension",
"module": "Module"
}
},
"javascript": {
"add": "Add JavaScript resource",
"delete": "Delete JavaScript resource",
"delete-javascript-resource-text": "Be careful, after the confirmation the JavaScript resource will become unrecoverable.",
"delete-javascript-resource-title": "Are you sure you want to delete the JavaScript resource '{{resourceTitle}}'?",
"delete-javascript-resources-action-title": "Delete JavaScript { count, plural, =1 {1 resource} other {# resources} }",
"delete-javascript-resources-text": "Please note that the selected JavaScript resources, even if they are used in JavaScript functions, will be deleted.",
"delete-javascript-resources-title": "Are you sure you want to delete JavaScript { count, plural, =1 {1 resource} other {# resources} }?",
"download": "Download JavaScript resource",
"upload-from-file": "Upload JavaScript from file",
"resource-file": "JavaScript resource file",
"drop-file": "Drop a JavaScript file or click to select a file to upload.",
"drop-resource-file-or": "Drag and drop a JavaScript file or",
"javascript-library": "JavaScript library",
"javascript-type": "JavaScript type",
"javascript-resource-details": "JavaScript resource details",
"search": "Search JavaScript resources",
"selected-javascript-resources": "{ count, plural, =1 {1 JavaScript resource} other {# JavaScript resources} } selected",
"no-javascript-resource-text": "No JavaScript resources found",
"all-types": "All",
"module-script": "Module script"
},
"rpc": {
"error": {
"target-device-is-not-set": "Target device is not set!",

9
ui-ngx/src/form.scss

@ -652,6 +652,15 @@
}
}
}
.mat-mdc-form-field.tb-inline-field {
.mat-mdc-text-field-wrapper {
.mat-mdc-form-field-icon-prefix, .mat-mdc-form-field-icon-suffix,
.mat-datetimepicker-toggle {
line-height: normal;
}
}
}
}
.tb-no-data-available {

Loading…
Cancel
Save