Browse Source

Merge pull request #14072 from ArtemDzhereleiko/AD/ai-node-resources

Add 'General' resource type and add entity list for AI rule node
pull/14068/head
Vladyslav Prykhodko 1 year ago
committed by GitHub
parent
commit
78b33863ed
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      ui-ngx/src/app/core/http/entity.service.ts
  2. 7
      ui-ngx/src/app/core/http/resource.service.ts
  3. 6
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  4. 54
      ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html
  5. 24
      ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss
  6. 116
      ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts
  7. 63
      ui-ngx/src/app/modules/home/components/resources/resources-library.component.html
  8. 29
      ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts
  9. 10
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
  10. 24
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts
  11. 2
      ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
  12. 182
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts
  13. 2
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts
  14. 11
      ui-ngx/src/app/shared/components/entity/entity-list.component.html
  15. 29
      ui-ngx/src/app/shared/components/entity/entity-list.component.ts
  16. 14
      ui-ngx/src/app/shared/components/file-input.component.ts
  17. 12
      ui-ngx/src/app/shared/models/resource.models.ts
  18. 13
      ui-ngx/src/assets/locale/locale.constant-en_US.json

6
ui-ngx/src/app/core/http/entity.service.ts

@ -100,6 +100,7 @@ import { OAuth2Service } from '@core/http/oauth2.service';
import { MobileAppService } from '@core/http/mobile-app.service';
import { PlatformType } from '@shared/models/oauth2.models';
import { AiModelService } from '@core/http/ai-model.service';
import { ResourceType } from "@shared/models/resource.models";
@Injectable({
providedIn: 'root'
@ -297,6 +298,9 @@ export class EntityService {
(id) => this.ruleChainService.getRuleChain(id, config),
entityIds);
break;
case EntityType.TB_RESOURCE:
observable = this.resourceService.getResourcesByIds(entityIds, config);
break;
}
return observable;
}
@ -472,7 +476,7 @@ export class EntityService {
break;
case EntityType.TB_RESOURCE:
pageLink.sortOrder.property = 'title';
entitiesObservable = this.resourceService.getTenantResources(pageLink, config);
entitiesObservable = this.resourceService.getResources(pageLink, subType as ResourceType, null, config);
break;
case EntityType.QUEUE_STATS:
pageLink.sortOrder.property = 'createdTime';

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

@ -48,7 +48,7 @@ export class ResourceService {
}
public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
return this.http.get<PageData<ResourceInfo>>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
return this.http.get<PageData<ResourceInfo>>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config))
}
public getResource(resourceId: string, config?: RequestConfig): Observable<Resource> {
@ -94,4 +94,9 @@ export class ResourceService {
return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config));
}
public getResourcesByIds(ids: string[], config?: RequestConfig): Observable<Array<ResourceInfo>> {
return this.http.get<Array<ResourceInfo>>(`/api/resource?resourceIds=${ids.join(',')}`,
defaultHttpOptionsFromConfig(config));
}
}

6
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -205,6 +205,8 @@ import {
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component';
import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component';
import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component";
import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component";
@NgModule({
declarations:
@ -358,6 +360,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo
CalculatedFieldTestArgumentsComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
ResourcesDialogComponent,
ResourcesLibraryComponent,
],
imports: [
CommonModule,
@ -505,6 +509,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo
CalculatedFieldTestArgumentsComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
ResourcesDialogComponent,
ResourcesLibraryComponent,
],
providers: [
WidgetComponentService,

54
ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html

@ -0,0 +1,54 @@
<!--
Copyright © 2016-2025 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.
-->
<form (ngSubmit)="save()" style="width: 600px;">
<mat-toolbar color="primary">
<h2>{{ 'resource.add' | translate }}</h2>
<span class="flex-1"></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<tb-resources-library #resourcesComponent
[standalone]="true"
[entity]="resources"
[defaultResourceType]="ResourceType.GENERAL"
[resourceTypes]="[ResourceType.GENERAL]"
[isEdit]="true">
</tb-resources-library>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
cdkFocusInitial
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || resourcesComponent.entityForm?.invalid || !resourcesComponent.entityForm?.dirty">
{{ (isAdd ? 'action.add' : 'action.save') | translate }}
</button>
</div>
</form>

24
ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host ::ng-deep {
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 !important;
}
}

116
ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts

@ -0,0 +1,116 @@
///
/// Copyright © 2016-2025 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 { AfterViewInit, Component, Inject, SkipSelf, ViewChild } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { EntityType } from '@shared/models/entity-type.models';
import { map } from 'rxjs/operators';
import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component";
import { ErrorStateMatcher } from "@angular/material/core";
import { Resource, ResourceType } from "@shared/models/resource.models";
import { ResourceService } from "@core/http/resource.service";
export interface ResourcesDialogData {
resources?: Resource;
isAdd?: boolean;
}
@Component({
selector: 'tb-resources-dialog',
templateUrl: './resources-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: ResourcesDialogComponent}],
styleUrls: ['./resources-dialog.component.scss']
})
export class ResourcesDialogComponent extends DialogComponent<ResourcesDialogComponent, Resource> implements ErrorStateMatcher, AfterViewInit {
readonly entityType = EntityType;
ResourceType = ResourceType;
isAdd = false;
submitted = false;
resources: Resource;
@ViewChild('resourcesComponent', {static: true}) resourcesComponent: ResourcesLibraryComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<ResourcesDialogComponent, Resource>,
@Inject(MAT_DIALOG_DATA) public data: ResourcesDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
private resourceService: ResourceService) {
super(store, router, dialogRef);
if (this.data.isAdd) {
this.isAdd = true;
}
if (this.data.resources) {
this.resources = this.data.resources;
}
}
ngAfterViewInit(): void {
if (this.isAdd) {
setTimeout(() => {
this.resourcesComponent.entityForm.markAsDirty();
}, 0);
}
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.submitted = true;
if (this.resourcesComponent.entityForm.valid) {
const resource = {...this.resourcesComponent.entityFormValue()};
if (Array.isArray(resource.data)) {
const resources = [];
resource.data.forEach((data, index) => {
resources.push({
resourceType: resource.resourceType,
data,
fileName: resource.fileName[index],
title: resource.title
});
});
this.resourceService.saveResources(resources, {resendRequest: true}).pipe(
map((response) => response[0])
).subscribe(result => this.dialogRef.close(result));
} else {
if (resource.resourceType !== ResourceType.GENERAL) {
delete resource.descriptor;
}
this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result));
}
}
}
}

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

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col">
<div class="tb-details-buttons xs:flex xs:flex-col" *ngIf="!standalone">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
@ -48,14 +48,16 @@
<div class="mat-padding flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<mat-label translate>resource.resource-type</mat-label>
<mat-select formControlName="resourceType" required>
<mat-option *ngFor="let resourceType of resourceTypes" [value]="resourceType">
{{ resourceTypesTranslationMap.get(resourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
@if (resourceTypes.length > 1) {
<mat-form-field class="mat-block">
<mat-label translate>resource.resource-type</mat-label>
<mat-select formControlName="resourceType" required>
<mat-option *ngFor="let resourceType of resourceTypes" [value]="resourceType">
{{ resourceTypesTranslationMap.get(resourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
}
<mat-form-field class="mat-block" *ngIf="entityForm.get('resourceType').value !== resourceType.LWM2M_MODEL || !isAdd">
<mat-label translate>resource.title</mat-label>
<input matInput formControlName="title" required>
@ -66,26 +68,29 @@
{{ 'resource.title-max-length' | translate }}
</mat-error>
</mat-form-field>
<tb-file-input *ngIf="isAdd"
formControlName="data"
required
label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}"
[readAsBinary]="true"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File"
[accept]="getAcceptType()"
[multipleFile]="entityForm.get('resourceType').value === resourceType.LWM2M_MODEL"
dropLabel="{{'resource.drop-resource-file-or' | translate}}"
[existingFileName]="entityForm.get('fileName')?.value"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
<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">
</mat-form-field>
</div>
@if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) {
<tb-file-input formControlName="data"
required
label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}"
[readAsBinary]="true"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File"
[accept]="getAcceptType()"
[multipleFile]="entityForm.get('resourceType').value === resourceType.LWM2M_MODEL"
dropLabel="{{'resource.drop-resource-file-or' | translate}}"
[existingFileName]="entityForm.get('fileName')?.value"
(mediaTypeChanged)="mediaTypeChange($event)"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
} @else {
<div 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>

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

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, Inject, Input, OnDestroy, OnInit, Optional } from '@angular/core';
import { Subject } from 'rxjs';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -40,8 +40,16 @@ import { getCurrentAuthState } from '@core/auth/auth.selectors';
})
export class ResourcesLibraryComponent extends EntityComponent<Resource> implements OnInit, OnDestroy {
@Input()
standalone = false;
@Input()
resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL];
@Input()
defaultResourceType = ResourceType.LWM2M_MODEL;
readonly resourceType = ResourceType;
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize;
@ -49,8 +57,8 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: Resource,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<Resource>,
@Optional() @Inject('entity') protected entityValue: Resource,
@Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<Resource>,
public fb: FormBuilder,
protected cd: ChangeDetectorRef) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
@ -82,10 +90,19 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]],
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] : []]
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []],
descriptor: this.fb.group({
mediaType: ['']
})
});
}
mediaTypeChange(mediaType: string): void {
if (this.entityForm.get('resourceType').value === ResourceType.GENERAL) {
this.entityForm.get('descriptor').get('mediaType').patchValue(mediaType);
}
}
updateForm(entity: Resource): void {
this.entityForm.patchValue(entity);
}
@ -138,7 +155,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
private observeResourceTypeChange(): void {
this.entityForm.get('resourceType').valueChanges.pipe(
startWith(ResourceType.LWM2M_MODEL),
startWith(this.defaultResourceType || ResourceType.LWM2M_MODEL),
takeUntil(this.destroy$)
).subscribe((type: ResourceType) => this.onResourceTypeChange(type));
}

10
ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html

@ -70,6 +70,16 @@
{{ 'rule-node-config.ai.user-prompt-blank' | translate }}
</mat-error>
</mat-form-field>
<tb-entity-list
class="flex-1"
allowCreateNew
placeholderText="{{ 'rule-node-config.ai.ai-resources' | translate }}"
[inlineField]="true"
[entityType]="EntityType.TB_RESOURCE"
[subType]="ResourceType.GENERAL"
(createNew)="createAiResources($event, 'resourceIds')"
formControlName="resourceIds">
</tb-entity-list>
</div>
</mat-expansion-panel>
</div>

24
ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts

@ -24,6 +24,8 @@ import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@
import { deepTrim } from '@core/utils';
import { TranslateService } from '@ngx-translate/core';
import { jsonRequired } from '@shared/components/json-object-edit.component';
import { Resource, ResourceType } from "@shared/models/resource.models";
import { ResourcesDialogComponent, ResourcesDialogData } from "@home/components/resources/resources-dialog.component";
@Component({
selector: 'tb-external-node-ai-config',
@ -38,6 +40,9 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
responseFormat = ResponseFormat;
EntityType = EntityType;
ResourceType = ResourceType;
constructor(private fb: UntypedFormBuilder,
private translate: TranslateService,
private dialog: MatDialog) {
@ -53,6 +58,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
modelId: [configuration?.modelId ?? null, [Validators.required]],
systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]],
userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]],
resourceIds: [configuration?.resourceIds ?? []],
responseFormat: this.fb.group({
type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []],
schema: [configuration?.responseFormat?.schema ?? null, [jsonRequired]],
@ -116,5 +122,23 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
this.aiConfigForm.get(formControl).markAsDirty();
}
});
};
createAiResources(name: string, formControl: string) {
this.dialog.open<ResourcesDialogComponent, ResourcesDialogData, Resource>(ResourcesDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
resources: {title: name, resourceType: ResourceType.GENERAL},
isAdd: true
}
}).afterClosed()
.subscribe((resource) => {
if (resource) {
const resourceIds = [...(this.aiConfigForm.get(formControl).value || []), resource.id.id];
this.aiConfigForm.get(formControl).patchValue(resourceIds);
this.aiConfigForm.get(formControl).markAsDirty();
}
});
}
}

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

@ -26,7 +26,6 @@ import { HomeComponentsModule } from '@modules/home/components/home-components.m
import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component';
import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component';
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component';
import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component';
import { QueueComponent } from '@home/pages/admin/queue/queue.component';
@ -49,7 +48,6 @@ import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resourc
SendTestSmsDialogComponent,
SecuritySettingsComponent,
HomeSettingsComponent,
ResourcesLibraryComponent,
ResourceTabsComponent,
ResourceLibraryTabsComponent,
ResourcesTableHeaderComponent,

182
ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts

@ -22,7 +22,13 @@ import {
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { Router } from '@angular/router';
import { Resource, ResourceInfo, ResourceType, ResourceTypeTranslationMap } from '@shared/models/resource.models';
import {
Resource,
ResourceInfo, ResourceInfoWithReferences,
ResourceType,
ResourceTypeTranslationMap,
toResourceDeleteResult
} from '@shared/models/resource.models';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DatePipe } from '@angular/common';
@ -32,12 +38,22 @@ 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 { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component';
import { PageLink } from '@shared/models/page/page-link';
import { EntityAction } from '@home/models/entity/entity-component.models';
import { map } from 'rxjs/operators';
import { catchError, map } from 'rxjs/operators';
import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component';
import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component';
import { forkJoin, of } from "rxjs";
import {
ResourcesInUseDialogComponent,
ResourcesInUseDialogData
} from "@shared/components/resource/resources-in-use-dialog.component";
import { parseHttpErrorMessage } from "@core/utils";
import { ActionNotificationShow } from "@core/notification/notification.actions";
import { ResourcesDatasource } from "@home/pages/admin/resource/resources-datasource";
import { MatDialog } from "@angular/material/dialog";
import { DialogService } from "@core/services/dialog.service";
@Injectable()
export class ResourcesLibraryTableConfigResolver {
@ -49,6 +65,8 @@ export class ResourcesLibraryTableConfigResolver {
private resourceService: ResourceService,
private translate: TranslateService,
private router: Router,
private dialog: MatDialog,
private dialogService: DialogService,
private datePipe: DatePipe) {
this.config.entityType = EntityType.TB_RESOURCE;
@ -76,19 +94,27 @@ export class ResourcesLibraryTableConfigResolver {
icon: 'file_download',
isEnabled: () => true,
onAction: ($event, entity) => this.downloadResource($event, entity)
}
},
{
name: this.translate.instant('resource.delete'),
icon: 'delete',
isEnabled: (resource) => this.config.deleteEnabled(resource),
onAction: ($event, entity) => this.deleteResource($event, entity)
},
);
this.config.deleteEntityTitle = resource => this.translate.instant('resource.delete-resource-title',
{ resourceTitle: resource.title });
this.config.deleteEntityContent = () => this.translate.instant('resource.delete-resource-text');
this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count});
this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text');
this.config.groupActionDescriptors = [{
name: this.translate.instant('action.delete'),
icon: 'delete',
isEnabled: true,
onAction: ($event, entities) => this.deleteResources($event, entities)
}];
this.config.entitiesDeleteEnabled = false;
this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType);
this.config.loadEntity = id => this.resourceService.getResourceInfoById(id.id);
this.config.saveEntity = resource => this.saveResource(resource);
this.config.deleteEntity = id => this.resourceService.deleteResource(id.id);
this.config.onEntityAction = action => this.onResourceAction(action);
}
@ -147,6 +173,8 @@ export class ResourcesLibraryTableConfigResolver {
case 'downloadResource':
this.downloadResource(action.event, action.entity);
return true;
case 'deleteLibrary':
this.deleteResource(action.event, action.entity);
}
return false;
}
@ -165,4 +193,138 @@ export class ResourcesLibraryTableConfigResolver {
return authority === Authority.SYS_ADMIN;
}
}
private deleteResource($event: Event, resource: ResourceInfo) {
if ($event) {
$event.stopPropagation();
}
this.dialogService.confirm(
this.translate.instant('resource.delete-resource-title', { resourceTitle: resource.title }),
this.translate.instant('resource.delete-resource-text'),
this.translate.instant('action.no'),
this.translate.instant('action.yes'),
true
).subscribe((result) => {
if (result) {
this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe(
map(() => toResourceDeleteResult(resource)),
catchError((err) => of(toResourceDeleteResult(resource, err)))
).subscribe(
(deleteResult) => {
if (deleteResult.success) {
if (this.config.getEntityDetailsPage()) {
this.config.getEntityDetailsPage().goBack();
} else {
this.config.updateData(true);
}
} else if (deleteResult.resourceIsReferencedError) {
const resources: ResourceInfoWithReferences[] = [{...resource, ...{references: deleteResult.references}}];
const data = {
multiple: false,
resources,
configuration: {
title: 'resource.resource-is-in-use',
message: this.translate.instant('resource.resource-is-in-use-text', {title: resources[0].title}),
deleteText: 'resource.delete-resource-in-use-text',
selectedText: 'resource.selected-resources',
columns: ['select', 'title', 'references']
}
};
this.dialog.open<ResourcesInUseDialogComponent, ResourcesInUseDialogData,
ResourceInfo[]>(ResourcesInUseDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data
}).afterClosed().subscribe((resources) => {
if (resources) {
this.resourceService.deleteResource(resource.id.id, true).subscribe(() => {
if (this.config.getEntityDetailsPage()) {
this.config.getEntityDetailsPage().goBack();
} else {
this.config.updateData(true);
}
});
}
});
} else {
const errorMessageWithTimeout = parseHttpErrorMessage(deleteResult.error, this.translate);
setTimeout(() => {
this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'}));
}, errorMessageWithTimeout.timeout);
}
}
);
}
});
}
private deleteResources($event: Event, resources: ResourceInfo[]) {
if ($event) {
$event.stopPropagation();
}
if (resources && resources.length) {
const title = this.translate.instant('resource.delete-resources-title', {count: resources.length});
const content = this.translate.instant('resource.delete-resources-text');
this.dialogService.confirm(title, content,
this.translate.instant('action.no'),
this.translate.instant('action.yes')).subscribe((result) => {
if (result) {
const tasks = resources.map((resource) =>
this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe(
map(() => toResourceDeleteResult(resource)),
catchError((err) => of(toResourceDeleteResult(resource, err)))
)
);
forkJoin(tasks).subscribe(
(deleteResults) => {
const anySuccess = deleteResults.some(res => res.success);
const referenceErrors = deleteResults.filter(res => res.resourceIsReferencedError);
const otherError = deleteResults.find(res => !res.success);
if (anySuccess) {
this.config.updateData();
}
if (referenceErrors?.length) {
const resourcesWithReferences: ResourceInfoWithReferences[] =
referenceErrors.map(ref => ({...ref.resource, ...{references: ref.references}}));
const data = {
multiple: true,
resources: resourcesWithReferences,
configuration: {
title: 'resource.resources-are-in-use',
message: this.translate.instant('resource.resources-are-in-use-text'),
deleteText: 'resource.delete-resource-in-use-text',
selectedText: 'resource.selected-resources',
datasource: new ResourcesDatasource(this.resourceService, resourcesWithReferences, () => true),
columns: ['select', 'title', 'references']
}
};
this.dialog.open<ResourcesInUseDialogComponent, ResourcesInUseDialogData,
ResourceInfo[]>(ResourcesInUseDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data
}).afterClosed().subscribe((forceDeleteResources) => {
if (forceDeleteResources && forceDeleteResources.length) {
const forceDeleteTasks = forceDeleteResources.map((resource) =>
this.resourceService.deleteResource(resource.id.id, true)
);
forkJoin(forceDeleteTasks).subscribe(
() => {
this.config.updateData();
}
);
}
});
} else if (otherError) {
const errorMessageWithTimeout = parseHttpErrorMessage(otherError.error, this.translate);
setTimeout(() => {
this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'}));
}, errorMessageWithTimeout.timeout);
}
}
);
}
});
}
}
}

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.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
constructor(protected store: Store<AppState>) {

11
ui-ngx/src/app/shared/components/entity/entity-list.component.html

@ -40,6 +40,12 @@
[matAutocompleteConnectedTo]="origin"
[matAutocomplete]="entityAutocomplete"
[matChipInputFor]="chipList">
<button mat-button color="primary" matSuffix
type="button"
*ngIf="allowCreateNew && !disabled"
(click)="createNewEntity($event)">
<span style="white-space: nowrap">{{ 'entity.create-new' | translate }}</span>
</button>
</mat-chip-grid>
<mat-autocomplete #entityAutocomplete="matAutocomplete"
class="tb-autocomplete"
@ -56,6 +62,11 @@
<span>
{{ 'entity.no-entities-matching' | translate: {entity: searchText} }}
</span>
@if (allowCreateNew) {
<span>
<a translate (click)="createNewEntity($event, searchText)">entity.create-new-key</a>
</span>
}
</ng-template>
</div>
</mat-option>

29
ui-ngx/src/app/shared/components/entity/entity-list.component.ts

@ -14,7 +14,18 @@
/// limitations under the License.
///
import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import {
Component,
ElementRef,
EventEmitter,
forwardRef,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
@ -93,6 +104,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
}
@Input()
@coerceBoolean()
disabled: boolean;
@Input()
@ -109,6 +121,13 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
@coerceBoolean()
inlineField: boolean;
@Input()
@coerceBoolean()
allowCreateNew: boolean;
@Output()
createNew = new EventEmitter<string>();
@ViewChild('entityInput') entityInput: ElementRef<HTMLInputElement>;
@ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('chipList', {static: true}) chipList: MatChipGrid;
@ -136,6 +155,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
this.entityListFormGroup.get('entities').updateValueAndValidity();
}
createNewEntity($event: Event, searchText?: string) {
$event.stopPropagation();
this.createNew.emit(searchText);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
@ -201,6 +225,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
this.modelValue = null;
}
this.dirty = true;
if (this.entityInput) {
this.entityInput.nativeElement.value = '';
}
}
validate(): ValidationErrors | null {

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

@ -129,10 +129,15 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
@Output()
fileNameChanged = new EventEmitter<string|string[]>();
@Output()
mediaTypeChanged = new EventEmitter<string>();
fileName: string | string[];
fileContent: any;
files: File[];
mediaType: string;
@ViewChild('flow', {static: true})
flow: FlowDirective;
@ -180,6 +185,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
this.fileContent = files[0].fileContent;
this.fileName = files[0].fileName;
this.files = files[0].files;
this.mediaType = files[0].mediaType;
this.updateModel();
} else if (files.length > 1) {
this.fileContent = files.map(content => content.fileContent);
@ -203,6 +209,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
let fileName = null;
let fileContent = null;
let files = null;
let mediaType = null;
if (reader.readyState === reader.DONE) {
if (!this.workFromFileObj) {
fileContent = reader.result;
@ -211,16 +218,18 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
fileContent = this.contentConvertFunction(fileContent);
}
fileName = fileContent ? file.name : null;
mediaType = file?.file?.type || null;
}
} else if (file.name || file.file){
files = file.file;
fileName = file.name;
mediaType = file.file.type || null;
}
}
resolve({fileContent, fileName, files});
resolve({fileContent, fileName, files, mediaType});
};
reader.onerror = () => {
resolve({fileContent: null, fileName: null, files: null});
resolve({fileContent: null, fileName: null, files: null, mediaType: null});
};
if (this.readAsBinary) {
reader.readAsBinaryString(file.file);
@ -283,6 +292,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
this.propagateChange(this.files);
} else {
this.propagateChange(this.fileContent);
this.mediaTypeChanged.emit(this.mediaType);
this.fileNameChanged.emit(this.fileName);
}
}

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

@ -24,7 +24,8 @@ export enum ResourceType {
LWM2M_MODEL = 'LWM2M_MODEL',
PKCS_12 = 'PKCS_12',
JKS = 'JKS',
JS_MODULE = 'JS_MODULE'
JS_MODULE = 'JS_MODULE',
GENERAL = 'GENERAL',
}
export enum ResourceSubType {
@ -57,7 +58,8 @@ export const ResourceTypeTranslationMap = new Map<ResourceType, string>(
[ResourceType.LWM2M_MODEL, 'resource.type.lwm2m-model'],
[ResourceType.PKCS_12, 'resource.type.pkcs-12'],
[ResourceType.JKS, 'resource.type.jks'],
[ResourceType.JS_MODULE, 'resource.type.js-module']
[ResourceType.JS_MODULE, 'resource.type.js-module'],
[ResourceType.GENERAL, 'resource.type.general'],
]
);
@ -76,8 +78,8 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
title?: string;
resourceType: ResourceType;
resourceSubType?: ResourceSubType;
fileName: string;
public: boolean;
fileName?: string;
public?: boolean;
publicResourceKey?: string;
readonly link?: string;
readonly publicLink?: string;
@ -87,7 +89,7 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
export type ResourceInfo = TbResourceInfo<any>;
export interface Resource extends ResourceInfo {
data: string;
data?: string;
name?: string;
}

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

@ -4488,7 +4488,8 @@
"jks": "JKS",
"js-module": "JS module",
"lwm2m-model": "LWM2M model",
"pkcs-12": "PKCS #12"
"pkcs-12": "PKCS #12",
"general": "General"
},
"resource-sub-type": "Sub-type",
"sub-type": {
@ -4496,7 +4497,12 @@
"scada-symbol": "Scada symbol",
"extension": "Extension",
"module": "Module"
}
},
"resource-is-in-use": "Resource is used by other entities",
"resources-are-in-use": "Resources are used by other entities",
"resource-is-in-use-text": "The Resource <b>'{{title}}'</b> was not deleted because it is used by the following entities:",
"resources-are-in-use-text": "Not all Resources have been deleted because they are used by other entities.</br>You can view referenced entities by clicking the <b>References</b> button in the corresponding resource row.</br>If you still want to delete these resources, select them in the table below and click the <b>Delete selected</b> button.",
"delete-resource-in-use-text": "If you still want to delete the resource, click the <b>Delete anyway</b> button."
},
"javascript": {
"add": "Add JavaScript resource",
@ -5467,7 +5473,8 @@
"timeout-required": "Timeout is required",
"timeout-validation": "Must be from 1 second to 10 minutes.",
"force-acknowledgement": "Force acknowledgement",
"force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message."
"force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message.",
"ai-resources": "AI resources"
}
},
"timezone": {

Loading…
Cancel
Save