From c3d40a61e879200291ea30a5b13b943b294c484b Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 2 Sep 2025 14:30:09 +0300 Subject: [PATCH 1/4] UI: Add new resource type text --- ui-ngx/src/app/core/http/entity.service.ts | 8 +- ui-ngx/src/app/core/http/resource.service.ts | 8 +- .../home/components/home-components.module.ts | 6 + .../resources/resources-dialog.component.html | 54 +++++++++ .../resources/resources-dialog.component.scss | 24 ++++ .../resources/resources-dialog.component.ts | 113 ++++++++++++++++++ .../resources-library.component.html | 2 +- .../resources}/resources-library.component.ts | 18 ++- .../external/ai-config.component.html | 10 ++ .../rule-node/external/ai-config.component.ts | 24 ++++ .../modules/home/pages/admin/admin.module.ts | 2 - .../resources-library-table-config.resolve.ts | 2 +- .../resources-table-header.component.ts | 2 +- .../entity/entity-list.component.html | 11 ++ .../entity/entity-list.component.ts | 29 ++++- .../src/app/shared/models/resource.models.ts | 12 +- .../assets/locale/locale.constant-en_US.json | 6 +- 17 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts rename ui-ngx/src/app/modules/home/{pages/admin/resource => components/resources}/resources-library.component.html (98%) rename ui-ngx/src/app/modules/home/{pages/admin/resource => components/resources}/resources-library.component.ts (89%) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 572d6cc473..53a55bfe7f 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/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,11 @@ export class EntityService { (id) => this.ruleChainService.getRuleChain(id, config), entityIds); break; + case EntityType.TB_RESOURCE: + observable = this.getEntitiesByIdsObservable( + (id) => this.resourceService.getResource(id, config), + entityIds); + break; } return observable; } @@ -472,7 +478,7 @@ export class EntityService { break; case EntityType.TB_RESOURCE: pageLink.sortOrder.property = 'title'; - entitiesObservable = this.resourceService.getTenantResources(pageLink, config); + entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, config); break; case EntityType.QUEUE_STATS: pageLink.sortOrder.property = 'createdTime'; diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 615b721b97..52c92d96e4 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -47,8 +47,12 @@ export class ResourceService { return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } - public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + public getTenantResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable> { + let url = `/api/resource${pageLink.toQuery()}`; + if (isNotEmptyStr(resourceType)) { + url += `&resourceType=${resourceType}`; + } + return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } public getResource(resourceId: string, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 31a3066edf..060f804cdf 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/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, diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html new file mode 100644 index 0000000000..c6813ff0f2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html @@ -0,0 +1,54 @@ + +
+ +

{{ 'resource.add' | translate }}

+ + +
+ + +
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss new file mode 100644 index 0000000000..b32c3933c5 --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts new file mode 100644 index 0000000000..a06c72827a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -0,0 +1,113 @@ +/// +/// 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 implements ErrorStateMatcher, AfterViewInit { + + readonly entityType = EntityType; + + ResourceType = ResourceType; + + isAdd = false; + + submitted = false; + + resources: Resource; + + @ViewChild('resourcesComponent', {static: true}) resourcesComponent: ResourcesLibraryComponent; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @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 { + this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html similarity index 98% rename from ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html rename to ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index ebb946ccbc..c602906be1 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts index bfba25afa1..a4d973e998 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts +++ b/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, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + resources: {title: name, resourceType: ResourceType.TEXT}, + 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(); + } + }); } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index 10721ade5a..60790edd74 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/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, diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index f39ed9ff7b..dc85ca4914 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -32,7 +32,7 @@ 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'; diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts index 80c760c6ca..d4b5d18493 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts +++ b/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 { - readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS]; + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(protected store: Store) { diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index 6bf7cdb78d..e0e7dfe3f7 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -40,6 +40,12 @@ [matAutocompleteConnectedTo]="origin" [matAutocomplete]="entityAutocomplete" [matChipInputFor]="chipList"> + {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} + @if (allowCreateNew) { + + entity.create-new-key + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 552c4f1f71..9d1de180e9 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/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(); + @ViewChild('entityInput') entityInput: ElementRef; @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 { diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 0419e40a7c..3495bf9eac 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/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', + TEXT = 'TEXT', } export enum ResourceSubType { @@ -57,7 +58,8 @@ export const ResourceTypeTranslationMap = new Map( [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.TEXT, 'resource.type.text'], ] ); @@ -76,8 +78,8 @@ export interface TbResourceInfo extends Omit, '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 extends Omit, 'name' | export type ResourceInfo = TbResourceInfo; export interface Resource extends ResourceInfo { - data: string; + data?: string; name?: string; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 80328181f6..d4bfd4ea06 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/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", + "text": "Text" }, "resource-sub-type": "Sub-type", "sub-type": { @@ -5467,7 +5468,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": { From cd6063fd097e8c2db83b87e392011ad34866065c Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 10 Sep 2025 09:41:47 +0300 Subject: [PATCH 2/4] UI: General resources --- ui-ngx/src/app/core/http/entity.service.ts | 6 +- ui-ngx/src/app/core/http/resource.service.ts | 14 +++-- .../resources/resources-dialog.component.html | 4 +- .../resources/resources-dialog.component.ts | 3 + .../resources-library.component.html | 61 ++++++++++--------- .../resources/resources-library.component.ts | 13 +++- .../external/ai-config.component.html | 2 +- .../rule-node/external/ai-config.component.ts | 2 +- .../resources-table-header.component.ts | 2 +- .../shared/components/file-input.component.ts | 14 ++++- .../src/app/shared/models/resource.models.ts | 4 +- .../assets/locale/locale.constant-en_US.json | 2 +- 12 files changed, 77 insertions(+), 50 deletions(-) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 53a55bfe7f..c652ba39ba 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -299,9 +299,7 @@ export class EntityService { entityIds); break; case EntityType.TB_RESOURCE: - observable = this.getEntitiesByIdsObservable( - (id) => this.resourceService.getResource(id, config), - entityIds); + observable = this.resourceService.getResourcesByIds(entityIds, config); break; } return observable; @@ -478,7 +476,7 @@ export class EntityService { break; case EntityType.TB_RESOURCE: pageLink.sortOrder.property = 'title'; - entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, config); + entitiesObservable = this.resourceService.getResources(pageLink, subType as ResourceType, null, config); break; case EntityType.QUEUE_STATS: pageLink.sortOrder.property = 'createdTime'; diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 52c92d96e4..90335758dd 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -24,6 +24,7 @@ import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope import { catchError, mergeMap } from 'rxjs/operators'; import { isNotEmptyStr } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; +import { NotificationTarget } from "@shared/models/notification.models"; @Injectable({ providedIn: 'root' @@ -47,12 +48,8 @@ export class ResourceService { return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } - public getTenantResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable> { - let url = `/api/resource${pageLink.toQuery()}`; - if (isNotEmptyStr(resourceType)) { - url += `&resourceType=${resourceType}`; - } - return this.http.get>(url, defaultHttpOptionsFromConfig(config)); + public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)) } public getResource(resourceId: string, config?: RequestConfig): Observable { @@ -98,4 +95,9 @@ export class ResourceService { return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config)); } + public getResourcesByIds(ids: string[], config?: RequestConfig): Observable> { + return this.http.get>(`/api/resource?resourceIds=${ids.join(',')}`, + defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html index c6813ff0f2..0062cfa9ac 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html @@ -32,8 +32,8 @@ diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts index a06c72827a..6216f087b1 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -106,6 +106,9 @@ export class ResourcesDialogComponent extends DialogComponent 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)); } } diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index c602906be1..4737b75ef2 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -48,14 +48,16 @@
- - resource.resource-type - - - {{ resourceTypesTranslationMap.get(resourceType) | translate }} - - - + @if (resourceTypes.length > 1) { + + resource.resource-type + + + {{ resourceTypesTranslationMap.get(resourceType) | translate }} + + + + } resource.title @@ -66,26 +68,29 @@ {{ 'resource.title-max-length' | translate }} - - -
- - resource.file-name - - -
+ @if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) { + + + } @else { +
+ + resource.file-name + + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts index 0b50e45a7a..e3ad0e15f3 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts @@ -44,7 +44,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme standalone = false; @Input() - resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; + resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL]; @Input() defaultResourceType = ResourceType.LWM2M_MODEL; @@ -90,10 +90,19 @@ export class ResourcesLibraryComponent extends EntityComponent 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); } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index d259d57ef3..5381fc770b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -76,7 +76,7 @@ placeholderText="{{ 'rule-node-config.ai.ai-resources' | translate }}" [inlineField]="true" [entityType]="EntityType.TB_RESOURCE" - [subType]="ResourceType.TEXT" + [subType]="ResourceType.GENERAL" (createNew)="createAiResources($event, 'resourceIds')" formControlName="resourceIds"> diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts index a4d973e998..07bcb86fdf 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -129,7 +129,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { - resources: {title: name, resourceType: ResourceType.TEXT}, + resources: {title: name, resourceType: ResourceType.GENERAL}, isAdd: true } }).afterClosed() diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts index d4b5d18493..136c143e3c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts +++ b/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 { - readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(protected store: Store) { diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index bbe68bb9c6..6960db73dd 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/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(); + @Output() + mediaTypeChanged = new EventEmitter(); + 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); } } diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 3495bf9eac..12590e7608 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -25,7 +25,7 @@ export enum ResourceType { PKCS_12 = 'PKCS_12', JKS = 'JKS', JS_MODULE = 'JS_MODULE', - TEXT = 'TEXT', + GENERAL = 'GENERAL', } export enum ResourceSubType { @@ -59,7 +59,7 @@ export const ResourceTypeTranslationMap = new Map( [ResourceType.PKCS_12, 'resource.type.pkcs-12'], [ResourceType.JKS, 'resource.type.jks'], [ResourceType.JS_MODULE, 'resource.type.js-module'], - [ResourceType.TEXT, 'resource.type.text'], + [ResourceType.GENERAL, 'resource.type.general'], ] ); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index d4bfd4ea06..d79bb81ae5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4489,7 +4489,7 @@ "js-module": "JS module", "lwm2m-model": "LWM2M model", "pkcs-12": "PKCS #12", - "text": "Text" + "general": "General" }, "resource-sub-type": "Sub-type", "sub-type": { From caf90636382db5418dd59703c52ceed0a0aaf0e5 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 12 Sep 2025 16:23:53 +0300 Subject: [PATCH 3/4] UI: Add resources in use dialog with force to delete --- .../resources-library-table-config.resolve.ts | 180 +++++++++++++++++- .../assets/locale/locale.constant-en_US.json | 7 +- 2 files changed, 177 insertions(+), 10 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index dc85ca4914..d92355fbac 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/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'; @@ -35,9 +41,19 @@ import { Authority } from '@shared/models/authority.enum'; 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, { + 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, { + 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); + } + } + ); + } + }); + } + } } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index d79bb81ae5..4639552658 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4497,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 '{{title}}' 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.
You can view referenced entities by clicking the References button in the corresponding resource row.
If you still want to delete these resources, select them in the table below and click the Delete selected button.", + "delete-resource-in-use-text": "If you still want to delete the resource, click the Delete anyway button." }, "javascript": { "add": "Add JavaScript resource", From af600ff450925466baa86461f33e98065d82af9b Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 12 Sep 2025 16:46:38 +0300 Subject: [PATCH 4/4] UI: oprimize import --- ui-ngx/src/app/core/http/resource.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 90335758dd..168c63b3b1 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -24,7 +24,6 @@ import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope import { catchError, mergeMap } from 'rxjs/operators'; import { isNotEmptyStr } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; -import { NotificationTarget } from "@shared/models/notification.models"; @Injectable({ providedIn: 'root'