UI: Added support js module resource in widget/action

This commit is contained in:
Vladyslav_Prykhodko 2023-06-07 15:37:47 +03:00
parent 87786ef72d
commit 1bde8d3dff
19 changed files with 419 additions and 64 deletions

View File

@ -96,7 +96,7 @@
"schema-inspector": "^2.0.2", "schema-inspector": "^2.0.2",
"screenfull": "^6.0.2", "screenfull": "^6.0.2",
"split.js": "^1.6.5", "split.js": "^1.6.5",
"systemjs": "6.11.0", "systemjs": "6.14.1",
"tinycolor2": "^1.6.0", "tinycolor2": "^1.6.0",
"tinymce": "~5.10.7", "tinymce": "~5.10.7",
"tooltipster": "^4.2.8", "tooltipster": "^4.2.8",
@ -137,7 +137,7 @@
"@types/raphael": "^2.3.2", "@types/raphael": "^2.3.2",
"@types/react": "17.0.37", "@types/react": "17.0.37",
"@types/react-dom": "17.0.11", "@types/react-dom": "17.0.11",
"@types/systemjs": "6.1.1", "@types/systemjs": "6.13.1",
"@types/tinycolor2": "^1.4.3", "@types/tinycolor2": "^1.4.3",
"@types/tooltipster": "^0.0.31", "@types/tooltipster": "^0.0.31",
"@typescript-eslint/eslint-plugin": "5.57.0", "@typescript-eslint/eslint-plugin": "5.57.0",

View File

@ -20,8 +20,9 @@ import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { forkJoin, Observable, of } from 'rxjs'; import { forkJoin, Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { Resource, ResourceInfo } from '@shared/models/resource.models'; import { Resource, ResourceInfo, ResourceType } from '@shared/models/resource.models';
import { catchError, map, mergeMap } from 'rxjs/operators'; import { catchError, map, mergeMap } from 'rxjs/operators';
import { isNotEmptyStr } from '@core/utils';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -33,15 +34,22 @@ export class ResourceService {
} }
public getResources(pageLink: PageLink, config?: RequestConfig): Observable<PageData<ResourceInfo>> { public getResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
return this.http.get<PageData<ResourceInfo>>(`/api/resource${pageLink.toQuery()}`, let url = `/api/resource${pageLink.toQuery()}`;
defaultHttpOptionsFromConfig(config)); if (isNotEmptyStr(resourceType)) {
url += `&resourceType=${resourceType}`;
}
return this.http.get<PageData<ResourceInfo>>(url, defaultHttpOptionsFromConfig(config));
} }
public getResource(resourceId: string, config?: RequestConfig): Observable<Resource> { public getResource(resourceId: string, config?: RequestConfig): Observable<Resource> {
return this.http.get<Resource>(`/api/resource/${resourceId}`, defaultHttpOptionsFromConfig(config)); return this.http.get<Resource>(`/api/resource/${resourceId}`, defaultHttpOptionsFromConfig(config));
} }
public getResourceInfo(resourceId: string, config?: RequestConfig): Observable<ResourceInfo> {
return this.http.get<Resource>(`/api/resource/info/${resourceId}`, defaultHttpOptionsFromConfig(config));
}
public downloadResource(resourceId: string): Observable<any> { public downloadResource(resourceId: string): Observable<any> {
return this.http.get(`/api/resource/${resourceId}/download`, { return this.http.get(`/api/resource/${resourceId}/download`, {
responseType: 'arraybuffer', responseType: 'arraybuffer',

View File

@ -27,6 +27,9 @@ import { DOCUMENT } from '@angular/common';
import { forkJoin, Observable, ReplaySubject, throwError } from 'rxjs'; import { forkJoin, Observable, ReplaySubject, throwError } from 'rxjs';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { IModulesMap } from '@modules/common/modules-map.models'; import { IModulesMap } from '@modules/common/modules-map.models';
import { TbResourceId } from '@shared/models/id/tb-resource-id';
import { isObject } from '@core/utils';
import { AuthService } from '@core/auth/auth.service';
declare const System; declare const System;
@ -69,16 +72,18 @@ export class ResourcesService {
return this.loadResourceByType(fileType, url); return this.loadResourceByType(fileType, url);
} }
public loadFactories(url: string, modulesMap: IModulesMap): Observable<ModulesWithFactories> { public loadFactories(resourceId: string | TbResourceId, modulesMap: IModulesMap): Observable<ModulesWithFactories> {
const url = this.getDownloadUrl(resourceId);
if (this.loadedModulesAndFactories[url]) { if (this.loadedModulesAndFactories[url]) {
return this.loadedModulesAndFactories[url].asObservable(); return this.loadedModulesAndFactories[url].asObservable();
} }
modulesMap.init(); modulesMap.init();
const meta = this.getMetaInfo(resourceId);
const subject = new ReplaySubject<ModulesWithFactories>(); const subject = new ReplaySubject<ModulesWithFactories>();
this.loadedModulesAndFactories[url] = subject; this.loadedModulesAndFactories[url] = subject;
import('@angular/compiler').then( import('@angular/compiler').then(
() => { () => {
System.import(url).then( System.import(url, undefined, meta).then(
(module) => { (module) => {
const modules = this.extractNgModules(module); const modules = this.extractNgModules(module);
if (modules.length) { if (modules.length) {
@ -123,16 +128,18 @@ export class ResourcesService {
return subject.asObservable(); return subject.asObservable();
} }
public loadModules(url: string, modulesMap: IModulesMap): Observable<Type<any>[]> { public loadModules(resourceId: string | TbResourceId, modulesMap: IModulesMap): Observable<Type<any>[]> {
const url = this.getDownloadUrl(resourceId);
if (this.loadedModules[url]) { if (this.loadedModules[url]) {
return this.loadedModules[url].asObservable(); return this.loadedModules[url].asObservable();
} }
modulesMap.init(); modulesMap.init();
const meta = this.getMetaInfo(resourceId);
const subject = new ReplaySubject<Type<any>[]>(); const subject = new ReplaySubject<Type<any>[]>();
this.loadedModules[url] = subject; this.loadedModules[url] = subject;
import('@angular/compiler').then( import('@angular/compiler').then(
() => { () => {
System.import(url).then( System.import(url, undefined, meta).then(
(module) => { (module) => {
try { try {
let modules; let modules;
@ -246,4 +253,21 @@ export class ResourcesService {
this.anchor.appendChild(el); this.anchor.appendChild(el);
return subject.asObservable(); return subject.asObservable();
} }
private getDownloadUrl(resourceId: string | TbResourceId): string {
if (isObject(resourceId)) {
return `/api/resource/js/${(resourceId as TbResourceId).id}/download`;
}
return resourceId as string;
}
private getMetaInfo(resourceId: string | TbResourceId): object {
if (isObject(resourceId)) {
return {
additionalHeaders: {
'X-Authorization': `Bearer ${AuthService.getJwtToken()}`
}
};
}
}
} }

View File

@ -614,6 +614,13 @@ class ModulesMap implements IModulesMap {
for (const moduleId of Object.keys(this.modulesMap)) { for (const moduleId of Object.keys(this.modulesMap)) {
System.set('app:' + moduleId, this.modulesMap[moduleId]); System.set('app:' + moduleId, this.modulesMap[moduleId]);
} }
System.constructor.prototype.shouldFetch = (url: string) => url.endsWith('/download');
System.constructor.prototype.fetch = (url, options: RequestInit & {meta?: any}) => {
if (options?.meta?.additionalHeaders) {
options.headers = { ...options.headers, ...options.meta.additionalHeaders };
}
return fetch(url, options);
};
this.initialized = true; this.initialized = true;
} }
} }

View File

@ -22,11 +22,14 @@
<div fxFlex fxLayout="row" <div fxFlex fxLayout="row"
fxLayoutAlign="start center" fxLayoutAlign="start center"
*ngFor="let resource of action.customResources; let i = index" style="padding-top: 10px;"> *ngFor="let resource of action.customResources; let i = index" style="padding-top: 10px;">
<mat-form-field fxFlex class="mat-block" subscriptSizing="dynamic"> <tb-resource-autocomplete fxFlex class="mat-block"
<input required matInput [(ngModel)]="resource.url" subscriptSizing="dynamic"
(ngModelChange)="notifyActionUpdated()" hideRequiredMarker required
placeholder="{{ 'widget.resource-url' | translate }}"/> [allowAutocomplete]="resource.isModule"
</mat-form-field> [(ngModel)]="resource.url"
(ngModelChange)="notifyActionUpdated()"
placeholder="{{ 'widget.resource-url' | translate }}">
</tb-resource-autocomplete>
<mat-checkbox [(ngModel)]="resource.isModule" <mat-checkbox [(ngModel)]="resource.isModule"
(ngModelChange)="notifyActionUpdated()"> (ngModelChange)="notifyActionUpdated()">
{{ 'widget.resource-is-module' | translate }} {{ 'widget.resource-is-module' | translate }}
@ -40,16 +43,15 @@
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
</button> </button>
</div> </div>
<div style="margin-top: 18px;"> <button mat-button mat-raised-button color="primary"
<button mat-button mat-raised-button color="primary" style="margin-top: 18px;"
[disabled]="isLoading$ | async" [disabled]="isLoading$ | async"
type="button" type="button"
(click)="addResource()" (click)="addResource()"
matTooltip="{{'widget.add-resource' | translate}}" matTooltip="{{'widget.add-resource' | translate}}"
matTooltipPosition="above"> matTooltipPosition="above">
<span translate>action.add</span> <span translate>action.add</span>
</button> </button>
</div>
</div> </div>
</div> </div>
</mat-tab> </mat-tab>

View File

@ -28,6 +28,7 @@ import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component';
import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component'; import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component';
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component';
import { QueueComponent } from '@home/pages/admin/queue/queue.component'; import { QueueComponent } from '@home/pages/admin/queue/queue.component';
import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-admin-settings.component'; import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-admin-settings.component';
import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component'; import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component';
@ -44,6 +45,7 @@ import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-aut
OAuth2SettingsComponent, OAuth2SettingsComponent,
HomeSettingsComponent, HomeSettingsComponent,
ResourcesLibraryComponent, ResourcesLibraryComponent,
ResourcesTableHeaderComponent,
QueueComponent, QueueComponent,
RepositoryAdminSettingsComponent, RepositoryAdminSettingsComponent,
AutoCommitAdminSettingsComponent, AutoCommitAdminSettingsComponent,

View File

@ -36,6 +36,7 @@ import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { EntityAction } from '@home/models/entity/entity-component.models'; import { EntityAction } from '@home/models/entity/entity-component.models';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component';
@Injectable() @Injectable()
export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableConfig<Resource, PageLink, ResourceInfo>> { export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableConfig<Resource, PageLink, ResourceInfo>> {
@ -53,6 +54,7 @@ export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableC
this.config.entityComponent = ResourcesLibraryComponent; this.config.entityComponent = ResourcesLibraryComponent;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.TB_RESOURCE); this.config.entityTranslations = entityTypeTranslations.get(EntityType.TB_RESOURCE);
this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE); this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE);
this.config.headerComponent = ResourcesTableHeaderComponent;
this.config.entityTitle = (resource) => resource ? this.config.entityTitle = (resource) => resource ?
resource.title : ''; resource.title : '';
@ -81,7 +83,7 @@ export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableC
this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count}); this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count});
this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text'); this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text');
this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink); this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType);
this.config.loadEntity = id => this.resourceService.getResource(id.id); this.config.loadEntity = id => this.resourceService.getResource(id.id);
this.config.saveEntity = resource => this.saveResource(resource); this.config.saveEntity = resource => this.saveResource(resource);
this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); this.config.deleteEntity = id => this.resourceService.deleteResource(id.id);
@ -110,6 +112,9 @@ export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableC
resolve(): EntityTableConfig<Resource, PageLink, ResourceInfo> { resolve(): EntityTableConfig<Resource, PageLink, ResourceInfo> {
this.config.tableTitle = this.translate.instant('resource.resources-library'); this.config.tableTitle = this.translate.instant('resource.resources-library');
this.config.componentsData = {
resourceType: ''
};
const authUser = getCurrentAuthUser(this.store); const authUser = getCurrentAuthUser(this.store);
this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority); this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority);
this.config.entitySelectionEnabled = (resource) => this.isResourceEditable(resource, authUser.authority); this.config.entitySelectionEnabled = (resource) => this.isResourceEditable(resource, authUser.authority);

View File

@ -57,7 +57,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.entityForm.get('resourceType').valueChanges.pipe( this.entityForm.get('resourceType').valueChanges.pipe(
startWith(ResourceType.LWM2M_MODEL), startWith(ResourceType.JS_MODULE),
filter(() => this.isAdd), filter(() => this.isAdd),
takeUntil(this.destroy$) takeUntil(this.destroy$)
).subscribe((type) => { ).subscribe((type) => {
@ -91,7 +91,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
buildForm(entity: Resource): FormGroup { buildForm(entity: Resource): FormGroup {
return this.fb.group({ return this.fb.group({
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]],
resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required], resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.JS_MODULE, Validators.required],
fileName: [entity ? entity.fileName : null, Validators.required], fileName: [entity ? entity.fileName : null, Validators.required],
data: [entity ? entity.data : null, Validators.required] data: [entity ? entity.data : null, Validators.required]
}); });

View File

@ -0,0 +1,30 @@
<!--
Copyright © 2016-2023 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>resource.resource-type</mat-label>
<mat-select [ngModel]="entitiesTableConfig.componentsData.resourceType"
(ngModelChange)="resourceTypeChanged($event)"
placeholder="{{ 'resource.resource-type' | translate }}">
<mat-option value="">
{{ "resource.all-types" | translate }}
</mat-option>
<mat-option *ngFor="let resourceType of resourceTypes" [value]="resourceType">
{{ resourceTypesTranslationMap.get(resourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -0,0 +1,42 @@
///
/// Copyright © 2016-2023 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, ResourceType, ResourceTypeTranslationMap } from '@shared/models/resource.models';
import { PageLink } from '@shared/models/page/page-link';
@Component({
selector: 'tb-resources-table-header',
templateUrl: './resources-table-header.component.html',
styleUrls: []
})
export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent<Resource, PageLink, ResourceInfo> {
readonly resourceTypes: ResourceType[] = Object.values(ResourceType);
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
constructor(protected store: Store<AppState>) {
super(store);
}
resourceTypeChanged(resourceType: ResourceType) {
this.entitiesTableConfig.componentsData.resourceType = resourceType;
this.entitiesTableConfig.getTable().resetSortAndFilter(true);
}
}

View File

@ -122,12 +122,14 @@
<div fxFlex fxLayout="row" style="max-height: 40px;" <div fxFlex fxLayout="row" style="max-height: 40px;"
fxLayoutAlign="start center" fxLayoutAlign="start center"
*ngFor="let resource of widget.resources; let i = index" > *ngFor="let resource of widget.resources; let i = index" >
<mat-form-field fxFlex class="mat-block resource-field" hideRequiredMarker="false" <tb-resource-autocomplete fxFlex class="mat-block resource-field"
style="margin: 10px 0 0 0; max-height: 40px;"> hideRequiredMarker required
<input required matInput [(ngModel)]="resource.url" subscriptSizing="dynamic"
(ngModelChange)="isDirty = true" [allowAutocomplete]="resource.isModule"
placeholder="{{ 'widget.resource-url' | translate }}"/> [(ngModel)]="resource.url"
</mat-form-field> (ngModelChange)="isDirty = true"
placeholder="{{ 'widget.resource-url' | translate }}">
</tb-resource-autocomplete>
<mat-checkbox [(ngModel)]="resource.isModule" <mat-checkbox [(ngModel)]="resource.isModule"
(ngModelChange)="isDirty = true"> (ngModelChange)="isDirty = true">
{{ 'widget.resource-is-module' | translate }} {{ 'widget.resource-is-module' | translate }}
@ -140,15 +142,14 @@
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
</button> </button>
</div> </div>
<div style="margin-top: 6px;"> <button mat-raised-button color="primary"
<button mat-raised-button color="primary" style="margin-top: 8px;"
[disabled]="isLoading$ | async" [disabled]="isLoading$ | async"
(click)="addResource()" (click)="addResource()"
matTooltip="{{'widget.add-resource' | translate}}" matTooltip="{{'widget.add-resource' | translate}}"
matTooltipPosition="above"> matTooltipPosition="above">
<span translate>action.add</span> <span translate>action.add</span>
</button> </button>
</div>
</div> </div>
</div> </div>
</mat-tab> </mat-tab>

View File

@ -31,18 +31,21 @@ tb-widget-editor {
overflow-y: auto; overflow-y: auto;
} }
mat-form-field.resource-field { .resource-field {
max-height: 40px; mat-form-field {
margin: 10px 0 0; .mat-mdc-text-field-wrapper {
.mat-mdc-text-field-wrapper { padding-bottom: 0;
padding-bottom: 0; height: 40px;
.mat-mdc-form-field-flex {
max-height: 40px; .mat-mdc-form-field-flex {
.mat-mdc-form-field-infix { max-height: 40px;
border: 0;
padding-top: 7px; .mat-mdc-form-field-infix {
padding-bottom: 7px; border: 0;
min-height: 32px; padding-top: 7px;
padding-bottom: 7px;
min-height: 32px;
}
} }
} }
} }

View File

@ -22,4 +22,5 @@ export * from './js-func.component';
export * from './script-lang.component'; export * from './script-lang.component';
export * from './slack-conversation-autocomplete.component'; export * from './slack-conversation-autocomplete.component';
export * from './notification/template-autocomplete.component'; export * from './notification/template-autocomplete.component';
export * from './resource/resource-autocomplete.component';
export * from './toggle-header.component'; export * from './toggle-header.component';

View File

@ -0,0 +1,46 @@
<!--
Copyright © 2016-2023 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 [formGroup]="resourceFormGroup" class="mat-block"
[appearance]="appearance"
[hideRequiredMarker]="hideRequiredMarker"
[subscriptSizing]="subscriptSizing">
<input matInput type="text"
#resourceInput
formControlName="resource"
(focusin)="onFocus()"
[placeholder]="placeholder"
[required]="required"
[matAutocomplete]="entityAutocomplete"
[matAutocompleteDisabled]="!allowAutocomplete">
<button *ngIf="resourceFormGroup.get('resource').value && !disabled"
type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-autocomplete class="tb-autocomplete"
#entityAutocomplete="matAutocomplete"
[displayWith]="displayResourceFn">
<mat-option *ngFor="let resource of filteredResources$ | async" [value]="resource">
<span [innerHTML]="resource.title | highlight:searchText"></span>
</mat-option>
<mat-option *ngIf="!(filteredResources$ | async)?.length" [value]="searchText">
{{ searchText }}
</mat-option>
</mat-autocomplete>
</mat-form-field>

View File

@ -0,0 +1,179 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { coerceBoolean } from '@shared/decorators/coercion';
import { Observable, of } from 'rxjs';
import { catchError, debounceTime, map, share, switchMap, tap } from 'rxjs/operators';
import { isDefinedAndNotNull, isEmptyStr, isEqual, isObject } from '@core/utils';
import { ResourceInfo, ResourceType } from '@shared/models/resource.models';
import { TbResourceId } from '@shared/models/id/tb-resource-id';
import { ResourceService } from '@core/http/resource.service';
import { PageLink } from '@shared/models/page/page-link';
import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
@Component({
selector: 'tb-resource-autocomplete',
templateUrl: './resource-autocomplete.component.html',
styleUrls: [],
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ResourceAutocompleteComponent),
multi: true
}]
})
export class ResourceAutocompleteComponent implements ControlValueAccessor, OnInit {
@Input()
@coerceBoolean()
disabled: boolean;
@Input()
@coerceBoolean()
required: boolean;
@Input()
appearance: MatFormFieldAppearance = 'fill';
@Input()
subscriptSizing: SubscriptSizing = 'fixed';
@Input()
placeholder: string;
@Input()
@coerceBoolean()
hideRequiredMarker = false;
@Input()
@coerceBoolean()
allowAutocomplete = false;
resourceFormGroup = this.fb.group({
resource: [null]
});
filteredResources$: Observable<Array<ResourceInfo>>;
searchText = '';
@ViewChild('resourceInput', {static: true}) resourceInput: ElementRef;
private modelValue: string | TbResourceId;
private dirty = false;
private propagateChange = (v: any) => { };
constructor(private fb: FormBuilder,
private resourceService: ResourceService) {
}
ngOnInit(): void {
if(this.required) {
this.resourceFormGroup.get('resource').setValidators(Validators.required);
this.resourceFormGroup.get('resource').updateValueAndValidity({emitEvent: false});
}
this.filteredResources$ = this.resourceFormGroup.get('resource').valueChanges
.pipe(
debounceTime(150),
tap(value => {
let modelValue;
if (isObject(value)) {
modelValue = value.id;
} else if (isEmptyStr(value)) {
modelValue = null;
} else {
modelValue = value;
}
this.updateView(modelValue);
if (value === null) {
this.clear();
}
}),
map(value => value ? (typeof value === 'string' ? value : value.title) : ''),
switchMap(name => this.fetchResources(name) ),
share()
);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled;
if (this.disabled) {
this.resourceFormGroup.disable({emitEvent: false});
} else {
this.resourceFormGroup.enable({emitEvent: false});
}
}
writeValue(value: string | TbResourceId) {
if (isDefinedAndNotNull(value)) {
this.searchText = '';
if (isObject(value) && typeof value !== 'string' && (value as TbResourceId).id) {
this.resourceService.getResourceInfo(value.id, {ignoreLoading: true, ignoreErrors: true}).subscribe(resource => {
this.modelValue = resource.id;
this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false});
});
} else {
this.modelValue = value;
this.resourceFormGroup.get('resource').patchValue(value, {emitEvent: false});
}
this.dirty = true;
}
}
displayResourceFn(resource?: ResourceInfo | string): string {
return isObject(resource) ? (resource as ResourceInfo).title : resource as string;
}
clear() {
this.resourceFormGroup.get('resource').patchValue('', {emitEvent: true});
setTimeout(() => {
this.resourceInput.nativeElement.blur();
this.resourceInput.nativeElement.focus();
}, 0);
}
onFocus() {
if (this.dirty) {
this.resourceFormGroup.get('resource').updateValueAndValidity({onlySelf: true, emitEvent: true});
this.dirty = false;
}
}
private updateView(value: string | TbResourceId ) {
if (!isEqual(this.modelValue, value)) {
this.modelValue = value;
this.propagateChange(this.modelValue);
}
}
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(
catchError(() => of(null)),
map(data => data.data)
);
}
}

View File

@ -52,7 +52,7 @@ export const ResourceTypeTranslationMap = new Map<ResourceType, string>(
] ]
); );
export interface ResourceInfo extends BaseData<TbResourceId> { export interface ResourceInfo extends Omit<BaseData<TbResourceId>, 'name' | 'label'> {
tenantId?: TenantId; tenantId?: TenantId;
resourceKey?: string; resourceKey?: string;
title?: string; title?: string;
@ -62,6 +62,7 @@ export interface ResourceInfo extends BaseData<TbResourceId> {
export interface Resource extends ResourceInfo { export interface Resource extends ResourceInfo {
data: string; data: string;
fileName: string; fileName: string;
name?: string;
} }
export interface Resources extends ResourceInfo { export interface Resources extends ResourceInfo {

View File

@ -189,6 +189,7 @@ import {
GtMdLgShowHideDirective GtMdLgShowHideDirective
} from '@shared/layout/layout.directives'; } from '@shared/layout/layout.directives';
import { ColorPickerComponent } from '@shared/components/color-picker/color-picker.component'; import { ColorPickerComponent } from '@shared/components/color-picker/color-picker.component';
import { ResourceAutocompleteComponent } from '@shared/components/resource/resource-autocomplete.component';
import { ShortNumberPipe } from '@shared/pipe/short-number.pipe'; import { ShortNumberPipe } from '@shared/pipe/short-number.pipe';
import { ToggleHeaderComponent } from '@shared/components/toggle-header.component'; import { ToggleHeaderComponent } from '@shared/components/toggle-header.component';
@ -360,6 +361,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
GtMdLgLayoutGapDirective, GtMdLgLayoutGapDirective,
GtMdLgShowHideDirective, GtMdLgShowHideDirective,
ColorPickerComponent, ColorPickerComponent,
ResourceAutocompleteComponent,
ToggleHeaderComponent ToggleHeaderComponent
], ],
imports: [ imports: [
@ -586,6 +588,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
GtMdLgLayoutGapDirective, GtMdLgLayoutGapDirective,
GtMdLgShowHideDirective, GtMdLgShowHideDirective,
ColorPickerComponent, ColorPickerComponent,
ResourceAutocompleteComponent,
ToggleHeaderComponent ToggleHeaderComponent
] ]
}) })

View File

@ -3252,6 +3252,7 @@
}, },
"resource": { "resource": {
"add": "Add Resource", "add": "Add Resource",
"all-types": "All",
"copyId": "Copy resource Id", "copyId": "Copy resource Id",
"delete": "Delete resource", "delete": "Delete resource",
"delete-resource-text": "Be careful, after the confirmation the resource will become unrecoverable.", "delete-resource-text": "Be careful, after the confirmation the resource will become unrecoverable.",

View File

@ -3286,10 +3286,10 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/systemjs@6.1.1": "@types/systemjs@6.13.1":
version "6.1.1" version "6.13.1"
resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.1.1.tgz#eae17f2a080e867d01a2dd614f524ab227cf5a41" resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.13.1.tgz#fccf8049fdf328bca4cfbad3a9cc7bf088b45048"
integrity sha512-d1M6eDKBGWx7RbYy295VEFoOF9YDJkPI959QYnmzcmeaV+SP4D0xV7dEh3sN5XF3GvO3PhGzm+17Z598nvHQuQ== integrity sha512-Jxo2/uif1WpkabfyvWpFmPWFPDdwKUmyL7xWzjtxNALEu2pgce+eISjbf0Vr+SsK/D9savO5kTRcf+COLK5eiQ==
"@types/tinycolor2@^1.4.3": "@types/tinycolor2@^1.4.3":
version "1.4.3" version "1.4.3"
@ -10101,10 +10101,10 @@ symbol-observable@4.0.0:
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205"
integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ== integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==
systemjs@6.11.0: systemjs@6.14.1:
version "6.11.0" version "6.14.1"
resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.11.0.tgz#8df8e74fc05822e6c40170aa409b9ca64833315f" resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.14.1.tgz#95a580b91b50d0d69ff178ed4816f0ddbcea23c1"
integrity sha512-7YPIY44j+BoY+E6cGBSw0oCU8SNTTIHKZgftcBdwWkDzs/M86Fdlr21FrzAyph7Zo8r3CFGscyFe4rrBtixrBg== integrity sha512-8ftwWd+XnQtZ/aGbatrN4QFNGrKJzmbtixW+ODpci7pyoTajg4sonPP8aFLESAcuVxaC1FyDESt+SpfFCH9rZQ==
table-layout@^1.0.2: table-layout@^1.0.2:
version "1.0.2" version "1.0.2"