From acdf5ad48c5c8697e942b93a8eff5391fca5cf6c Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 28 Aug 2023 18:54:12 +0300 Subject: [PATCH] Implement ability to move widget types between widget bundles. Ability to deprecate widget type. --- .../controller/WidgetTypeController.java | 43 ++++++- .../controller/WidgetTypeControllerTest.java | 67 ++++++++++- .../server/dao/widget/WidgetTypeService.java | 4 +- .../validator/WidgetTypeDataValidator.java | 5 +- .../dao/widget/WidgetTypeServiceImpl.java | 21 +++- ui-ngx/src/app/core/http/widget.service.ts | 26 +++- .../dashboard-page.component.html | 11 +- .../dashboard-widget-select.component.html | 2 +- .../dashboard-widget-select.component.scss | 4 + .../dashboard-widget-select.component.ts | 26 +++- .../move-widget-type-dialog.component.html | 56 +++++++++ .../move-widget-type-dialog.component.ts | 82 +++++++++++++ .../pages/widget/widget-editor.component.html | 24 +++- .../pages/widget/widget-editor.component.ts | 113 ++++++++++++++---- .../widget/widget-library-routing.module.ts | 72 +++++------ .../pages/widget/widget-library.component.ts | 2 +- .../pages/widget/widget-library.module.ts | 2 + .../widgets-bundle-select.component.ts | 9 ++ ui-ngx/src/app/shared/models/widget.models.ts | 1 + .../assets/locale/locale.constant-en_US.json | 7 ++ 20 files changed, 489 insertions(+), 88 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.ts diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java index 343b107d4f..2b34432e12 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -80,7 +80,7 @@ public class WidgetTypeController extends AutoCommitController { "The newly created Widget Type Id will be present in the response. " + "Specify existing Widget Type id to update the Widget Type. " + "Referencing non-existing Widget Type Id will cause 'Not Found' error." + - "\n\nWidget Type alias is unique in the scope of Widget Bundle. " + + "\n\nWidget Type fqn is unique in the scope of System or Tenant. " + "Special Tenant Id '13814000-1dd2-11b2-8080-808080808080' is automatically used if the create request is sent by user with 'SYS_ADMIN' authority." + "Remove 'id', 'tenantId' rom the request body example (below) to create new Widget Type entity." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @@ -243,8 +243,8 @@ public class WidgetTypeController extends AutoCommitController { notes = "Set Widget Type deprecated flag. Referencing non-existing Widget Type Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(value = "/widgetType/{widgetTypeId}/deprecate/{deprecated}", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) - public void setWidgetTypeDeprecated( + @ResponseBody + public WidgetTypeDetails setWidgetTypeDeprecated( @ApiParam(value = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("widgetTypeId") String strWidgetTypeId, @PathVariable("deprecated") boolean deprecated) throws Exception { @@ -252,14 +252,45 @@ public class WidgetTypeController extends AutoCommitController { var currentUser = getCurrentUser(); WidgetTypeId widgetTypeId = new WidgetTypeId(toUUID(strWidgetTypeId)); WidgetTypeDetails wtd = checkWidgetTypeId(widgetTypeId, Operation.WRITE); - widgetTypeService.setWidgetTypeDeprecated(currentUser.getTenantId(), widgetTypeId, deprecated); - - if (wtd != null && !Authority.SYS_ADMIN.equals(currentUser.getAuthority())) { + WidgetTypeDetails updated = widgetTypeService.setWidgetTypeDeprecated(currentUser.getTenantId(), widgetTypeId, deprecated); + if (!Authority.SYS_ADMIN.equals(currentUser.getAuthority())) { WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(wtd.getTenantId(), wtd.getBundleAlias()); if (widgetsBundle != null) { autoCommit(currentUser, widgetsBundle.getId()); } } + return updated; + } + + @ApiOperation(value = "Move widget type to target widgets bundle (moveWidgetType)", + notes = "Move Widget Type to target Widgets Bundle. Referencing non-existing Widget Type Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetType/{widgetTypeId}/move", params = {"targetBundleAlias"}, method = RequestMethod.POST) + @ResponseBody + public WidgetTypeDetails moveWidgetType( + @ApiParam(value = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("widgetTypeId") String strWidgetTypeId, + @ApiParam(value = "Target Widget Bundle alias", required = true) + @RequestParam String targetBundleAlias) throws Exception { + checkParameter("widgetTypeId", strWidgetTypeId); + checkParameter("targetBundleAlias", targetBundleAlias); + var currentUser = getCurrentUser(); + WidgetTypeId widgetTypeId = new WidgetTypeId(toUUID(strWidgetTypeId)); + WidgetTypeDetails wtd = checkWidgetTypeId(widgetTypeId, Operation.WRITE); + if (!wtd.getBundleAlias().equals(targetBundleAlias)) { + wtd = widgetTypeService.moveWidgetType(currentUser.getTenantId(), widgetTypeId, targetBundleAlias); + if (!Authority.SYS_ADMIN.equals(currentUser.getAuthority())) { + WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(wtd.getTenantId(), wtd.getBundleAlias()); + if (widgetsBundle != null) { + autoCommit(currentUser, widgetsBundle.getId()); + } + WidgetsBundle targetWidgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(currentUser.getTenantId(), targetBundleAlias); + if (targetWidgetsBundle != null) { + autoCommit(currentUser, targetWidgetsBundle.getId()); + } + } + } + return wtd; } } diff --git a/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java index 581f02ce65..c464649b11 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java @@ -191,10 +191,30 @@ public class WidgetTypeControllerTest extends AbstractControllerTest { widgetType.setName("Widget Type"); widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class)); WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class); + + WidgetsBundle widgetsBundle2 = new WidgetsBundle(); + widgetsBundle2.setTitle("My widgets bundle 2"); + WidgetsBundle savedWidgetsBundle2 = doPost("/api/widgetsBundle", widgetsBundle2, WidgetsBundle.class); + savedWidgetType.setBundleAlias(savedWidgetsBundle2.getAlias()); + + doPost("/api/widgetType", savedWidgetType); + + WidgetTypeDetails foundWidgetType = doGet("/api/widgetType/" + savedWidgetType.getId().getId().toString(), WidgetTypeDetails.class); + Assert.assertEquals(savedWidgetsBundle2.getAlias(), foundWidgetType.getBundleAlias()); + + } + + @Test + public void testUpdateWidgetTypeBundleAliasToNonExistent() throws Exception { + WidgetTypeDetails widgetType = new WidgetTypeDetails(); + widgetType.setBundleAlias(savedWidgetsBundle.getAlias()); + widgetType.setName("Widget Type"); + widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class)); + WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class); savedWidgetType.setBundleAlias("some_alias"); doPost("/api/widgetType", savedWidgetType) .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Update of widget type bundle alias is prohibited"))); + .andExpect(statusReason(containsString("Widget type is referencing to non-existent widgets bundle"))); } @@ -212,6 +232,51 @@ public class WidgetTypeControllerTest extends AbstractControllerTest { } + @Test + public void testDeprecateWidgetType() throws Exception { + WidgetTypeDetails widgetType = new WidgetTypeDetails(); + widgetType.setBundleAlias(savedWidgetsBundle.getAlias()); + widgetType.setName("Widget Type"); + widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class)); + WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class); + doPost("/api/widgetType/"+savedWidgetType.getId().getId().toString() + "/deprecate/true") + .andExpect(status().isOk()); + WidgetTypeDetails foundWidgetType = doGet("/api/widgetType/" + savedWidgetType.getId().getId().toString(), WidgetTypeDetails.class); + Assert.assertTrue(foundWidgetType.isDeprecated()); + doPost("/api/widgetType/"+savedWidgetType.getId().getId().toString() + "/deprecate/false") + .andExpect(status().isOk()); + foundWidgetType = doGet("/api/widgetType/" + savedWidgetType.getId().getId().toString(), WidgetTypeDetails.class); + Assert.assertFalse(foundWidgetType.isDeprecated()); + } + + @Test + public void testMoveWidgetType() throws Exception { + WidgetTypeDetails widgetType = new WidgetTypeDetails(); + widgetType.setBundleAlias(savedWidgetsBundle.getAlias()); + widgetType.setName("Widget Type"); + widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class)); + WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class); + WidgetsBundle widgetsBundle2 = new WidgetsBundle(); + widgetsBundle2.setTitle("My widgets bundle 2"); + WidgetsBundle savedWidgetsBundle2 = doPost("/api/widgetsBundle", widgetsBundle2, WidgetsBundle.class); + doPost("/api/widgetType/"+savedWidgetType.getId().getId().toString() + "/move?targetBundleAlias=" + savedWidgetsBundle2.getAlias()) + .andExpect(status().isOk()); + WidgetTypeDetails foundWidgetType = doGet("/api/widgetType/" + savedWidgetType.getId().getId().toString(), WidgetTypeDetails.class); + Assert.assertEquals(savedWidgetsBundle2.getAlias(), foundWidgetType.getBundleAlias()); + } + + @Test + public void testMoveWidgetTypeToNonExistentBundle() throws Exception { + WidgetTypeDetails widgetType = new WidgetTypeDetails(); + widgetType.setBundleAlias(savedWidgetsBundle.getAlias()); + widgetType.setName("Widget Type"); + widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class)); + WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class); + doPost("/api/widgetType/"+savedWidgetType.getId().getId().toString() + "/move?targetBundleAlias=some_alias") + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Widget type is referencing to non-existent widgets bundle"))); + } + @Test public void testGetBundleWidgetTypes() throws Exception { List widgetTypes = new ArrayList<>(); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java index a0b799c9cb..51bffeef4f 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java @@ -33,9 +33,11 @@ public interface WidgetTypeService extends EntityDaoService { WidgetTypeDetails saveWidgetType(WidgetTypeDetails widgetType); + WidgetTypeDetails moveWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId, String targetBundleAlias); + void deleteWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId); - void setWidgetTypeDeprecated(TenantId tenantId, WidgetTypeId widgetTypeId, boolean deprecated); + WidgetTypeDetails setWidgetTypeDeprecated(TenantId tenantId, WidgetTypeId widgetTypeId, boolean deprecated); List findWidgetTypesByTenantIdAndBundleAlias(TenantId tenantId, String bundleAlias); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/WidgetTypeDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/WidgetTypeDataValidator.java index 199493760c..6f634fac3a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/WidgetTypeDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/WidgetTypeDataValidator.java @@ -87,7 +87,10 @@ public class WidgetTypeDataValidator extends DataValidator { throw new DataValidationException("Can't move existing widget type to different tenant!"); } if (!storedWidgetType.getBundleAlias().equals(widgetTypeDetails.getBundleAlias())) { - throw new DataValidationException("Update of widget type bundle alias is prohibited!"); + WidgetsBundle widgetsBundle = widgetsBundleDao.findWidgetsBundleByTenantIdAndAlias(widgetTypeDetails.getTenantId().getId(), widgetTypeDetails.getBundleAlias()); + if (widgetsBundle == null) { + throw new DataValidationException("Widget type is referencing to non-existent widgets bundle!"); + } } if (!storedWidgetType.getFqn().equals(widgetTypeDetails.getFqn())) { throw new DataValidationException("Update of widget type fqn is prohibited!"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java index 7afd29c5f6..6540a5ea85 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java @@ -84,6 +84,21 @@ public class WidgetTypeServiceImpl extends AbstractEntityService implements Widg } } + @Override + public WidgetTypeDetails moveWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId, String targetBundleAlias) { + log.trace("Executing moveWidgetType, widgetTypeId [{}], targetBundleAlias [{}]", widgetTypeId, targetBundleAlias); + Validator.validateId(widgetTypeId, "Incorrect widgetTypeId " + widgetTypeId); + WidgetTypeDetails widgetTypeDetails = widgetTypeDao.findById(tenantId, widgetTypeId.getId()); + if (widgetTypeDetails != null && !widgetTypeDetails.getBundleAlias().equals(targetBundleAlias)) { + widgetTypeDetails.setBundleAlias(targetBundleAlias); + widgetTypeValidator.validate(widgetTypeDetails, WidgetType::getTenantId); + widgetTypeDetails = widgetTypeDao.save(widgetTypeDetails.getTenantId(), widgetTypeDetails); + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(widgetTypeDetails.getTenantId()) + .entityId(widgetTypeDetails.getId()).added(widgetTypeDetails.getId() == null).build()); + } + return widgetTypeDetails; + } + @Override public void deleteWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId) { log.trace("Executing deleteWidgetType [{}]", widgetTypeId); @@ -93,14 +108,15 @@ public class WidgetTypeServiceImpl extends AbstractEntityService implements Widg } @Override - public void setWidgetTypeDeprecated(TenantId tenantId, WidgetTypeId widgetTypeId, boolean deprecated) { + public WidgetTypeDetails setWidgetTypeDeprecated(TenantId tenantId, WidgetTypeId widgetTypeId, boolean deprecated) { log.trace("Executing setWidgetTypeDeprecated, widgetTypeId [{}], deprecated [{}]", widgetTypeId, deprecated); Validator.validateId(widgetTypeId, "Incorrect widgetTypeId " + widgetTypeId); WidgetTypeDetails widgetTypeDetails = widgetTypeDao.findById(tenantId, widgetTypeId.getId()); if (widgetTypeDetails.isDeprecated() != deprecated) { widgetTypeDetails.setDeprecated(deprecated); - widgetTypeDao.save(widgetTypeDetails.getTenantId(), widgetTypeDetails); + widgetTypeDetails = widgetTypeDao.save(widgetTypeDetails.getTenantId(), widgetTypeDetails); } + return widgetTypeDetails; } @Override @@ -117,6 +133,7 @@ public class WidgetTypeServiceImpl extends AbstractEntityService implements Widg Validator.validateId(tenantId, INCORRECT_TENANT_ID + tenantId); Validator.validateString(bundleAlias, INCORRECT_BUNDLE_ALIAS + bundleAlias); return widgetTypeDao.findWidgetTypesDetailsByTenantIdAndBundleAlias(tenantId.getId(), bundleAlias); + } @Override diff --git a/ui-ngx/src/app/core/http/widget.service.ts b/ui-ngx/src/app/core/http/widget.service.ts index a0b5982811..ba17950917 100644 --- a/ui-ngx/src/app/core/http/widget.service.ts +++ b/ui-ngx/src/app/core/http/widget.service.ts @@ -152,9 +152,12 @@ export class WidgetService { return this.getBundleWidgetTypes(bundleAlias, isSystem, config).pipe( map((types) => { types = types.sort((a, b) => { - let result = widgetType[b.descriptor.type].localeCompare(widgetType[a.descriptor.type]); + let result = (a.deprecated ? 1 : 0) - (b.deprecated ? 1 : 0); if (result === 0) { - result = b.createdTime - a.createdTime; + result = widgetType[b.descriptor.type].localeCompare(widgetType[a.descriptor.type]); + if (result === 0) { + result = b.createdTime - a.createdTime; + } } return result; }); @@ -180,6 +183,9 @@ export class WidgetService { }; widget.config.title = widgetTypeInfo.widgetName; + if (type.deprecated) { + widget.config.title += ` (${this.translate.instant('widget.deprecated')})`; + } widgetTypes.push(widget); top += sizeY; @@ -216,6 +222,22 @@ export class WidgetService { })); } + public setWidgetTypeDeprecated(widgetTypeId: string, deprecated: boolean, config?: RequestConfig): Observable { + return this.http.post(`/api/widgetType/${widgetTypeId}/deprecate/${deprecated}`, + defaultHttpOptionsFromConfig(config)).pipe( + tap((savedWidgetType) => { + this.widgetTypeUpdated(savedWidgetType); + })); + } + + public moveWidgetType(widgetTypeId: string, targetBundleAlias: string, config?: RequestConfig): Observable { + return this.http.post(`/api/widgetType/${widgetTypeId}/move?targetBundleAlias=${targetBundleAlias}`, + defaultHttpOptionsFromConfig(config)).pipe( + tap((savedWidgetType) => { + this.widgetTypeUpdated(savedWidgetType); + })); + } + public saveImportedWidgetTypeDetails(widgetTypeDetails: WidgetTypeDetails, config?: RequestConfig): Observable { return this.http.post('/api/widgetType', widgetTypeDetails, diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html index 0699a0bf4e..60b98965ed 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html @@ -408,7 +408,7 @@ (ngModelChange)="searchBundle = $event"> -
+
@@ -419,6 +419,15 @@ matTooltipPosition="above"> filter_list + + {{ 'widget.all' | translate }} + {{ 'widget.actual' | translate }} + {{ 'widget.deprecated' | translate }} +
- {{widget.title}} + {{widget.title}}
widget.deprecated
{{ 'widget.' + widget.type | translate }} {{ widget.description }} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss index a82f9c2f8b..3a290e53e5 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss @@ -54,6 +54,10 @@ font-size: 20px; line-height: normal; margin-bottom: 8px; + .tb-deprecated { + font-size: 14px; + color: rgba(209, 39, 48, 0.87); + } } .mat-mdc-card-subtitle { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts index 31ff1eba6f..05896523b1 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts @@ -25,6 +25,8 @@ import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { isDefinedAndNotNull } from '@core/utils'; +type widgetsListMode = 'all' | 'actual' | 'deprecated'; + @Component({ selector: 'tb-dashboard-widget-select', templateUrl: './dashboard-widget-select.component.html', @@ -34,9 +36,11 @@ export class DashboardWidgetSelectComponent implements OnInit { private search$ = new BehaviorSubject(''); private filterWidgetTypes$ = new BehaviorSubject>(null); + private widgetsListMode$ = new BehaviorSubject('actual'); private widgetsInfo: Observable>; private widgetsBundleValue: WidgetsBundle; widgetTypes = new Set(); + hasDeprecated = false; widgets$: Observable>; loadingWidgetsSubject: BehaviorSubject = new BehaviorSubject(false); @@ -54,8 +58,10 @@ export class DashboardWidgetSelectComponent implements OnInit { this.widgetsBundleValue = widgetBundle; if (widgetBundle === null) { this.widgetTypes.clear(); + this.hasDeprecated = false; } this.filterWidgetTypes$.next(null); + this.widgetsListMode$.next('actual'); this.widgetsInfo = null; } } @@ -81,6 +87,15 @@ export class DashboardWidgetSelectComponent implements OnInit { return this.filterWidgetTypes$.value; } + @Input() + set widgetsListMode(mode: widgetsListMode) { + this.widgetsListMode$.next(mode); + } + + get widgetsListMode(): widgetsListMode { + return this.widgetsListMode$.value; + } + @Output() widgetSelected: EventEmitter = new EventEmitter(); @@ -94,7 +109,7 @@ export class DashboardWidgetSelectComponent implements OnInit { distinctUntilChanged(), switchMap(search => this.fetchWidgetBundle(search)) ); - this.widgets$ = combineLatest([this.search$.asObservable(), this.filterWidgetTypes$.asObservable()]).pipe( + this.widgets$ = combineLatest([this.search$.asObservable(), this.filterWidgetTypes$.asObservable(), this.widgetsListMode$]).pipe( distinctUntilChanged((oldValue, newValue) => JSON.stringify(oldValue) === JSON.stringify(newValue)), switchMap(search => this.fetchWidget(...search)) ); @@ -113,6 +128,7 @@ export class DashboardWidgetSelectComponent implements OnInit { map(widgets => { widgets = widgets.sort((a, b) => b.createdTime - a.createdTime); const widgetTypes = new Set(); + const hasDeprecated = widgets.some(w => w.deprecated); const widgetInfos = widgets.map((widgetTypeInfo) => { widgetTypes.add(widgetTypeInfo.widgetType); const widget: WidgetInfo = { @@ -120,13 +136,15 @@ export class DashboardWidgetSelectComponent implements OnInit { type: widgetTypeInfo.widgetType, title: widgetTypeInfo.name, image: widgetTypeInfo.image, - description: widgetTypeInfo.description + description: widgetTypeInfo.description, + deprecated: widgetTypeInfo.deprecated }; return widget; } ); setTimeout(() => { this.widgetTypes = widgetTypes; + this.hasDeprecated = hasDeprecated; this.cd.markForCheck(); }); return widgetInfos; @@ -185,8 +203,10 @@ export class DashboardWidgetSelectComponent implements OnInit { ); } - private fetchWidget(search: string, filter: widgetType[]): Observable> { + private fetchWidget(search: string, filter: widgetType[], listMode: widgetsListMode): Observable> { return this.getWidgets().pipe( + map(widgets => (listMode && listMode !== 'all') ? + widgets.filter((widget) => listMode === 'actual' ? !widget.deprecated : widget.deprecated) : widgets), map(widgets => filter ? widgets.filter((widget) => filter.includes(widget.type)) : widgets), map(widgets => search ? widgets.filter( widget => ( diff --git a/ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.html new file mode 100644 index 0000000000..c0734b35c5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.html @@ -0,0 +1,56 @@ + +
+ +

widget.move-widget-type

+ + +
+ + +
+
+
+ widget.move-widget-type-text + + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.ts new file mode 100644 index 0000000000..6f92e34bdb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.ts @@ -0,0 +1,82 @@ +/// +/// 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, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; + +export interface MoveWidgetTypeDialogResult { + bundleId: string; + bundleAlias: string; +} + +export interface MoveWidgetTypeDialogData { + currentBundleId: string; +} + +@Component({ + selector: 'tb-move-widget-type-dialog', + templateUrl: './move-widget-type-dialog.component.html', + styleUrls: [] +}) +export class MoveWidgetTypeDialogComponent extends + DialogComponent implements OnInit { + + moveWidgetTypeFormGroup: UntypedFormGroup; + + bundlesScope: string; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: MoveWidgetTypeDialogData, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder) { + super(store, router, dialogRef); + + const authUser = getCurrentAuthUser(store); + if (authUser.authority === Authority.TENANT_ADMIN) { + this.bundlesScope = 'tenant'; + } else { + this.bundlesScope = 'system'; + } + } + + ngOnInit(): void { + this.moveWidgetTypeFormGroup = this.fb.group({ + widgetsBundle: [null, [Validators.required]] + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + move(): void { + const widgetsBundle: WidgetsBundle = this.moveWidgetTypeFormGroup.get('widgetsBundle').value; + const result: MoveWidgetTypeDialogResult = { + bundleId: widgetsBundle.id.id, + bundleAlias: widgetsBundle.alias + }; + this.dialogRef.close(result); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html index 2b650cb1ce..1a4cf8740e 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html @@ -63,9 +63,18 @@ [tb-circular-progress]="saveWidgetAsPending" matTooltip="{{ 'widget.saveAs' | translate }} (Shift + CTRL + S)" matTooltipPosition="below"> - save + save_as action.saveAs + +
@@ -253,6 +268,11 @@ rows="2" maxlength="255"> {{descriptionInput.value?.length || 0}}/255 + + {{ 'widget.deprecated' | translate }} + widget.settings-form-selector (); constructor(protected store: Store, @@ -249,6 +259,16 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe }, ['INPUT', 'SELECT', 'TEXTAREA'], this.translate.instant('widget.saveAs')) ); + this.hotKeys.push( + new Hotkey('shift+ctrl+m', (event: KeyboardEvent) => { + if (!getCurrentIsLoading(this.store) && !this.moveDisabled()) { + event.preventDefault(); + this.moveWidget(); + } + return false; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('widget.move')) + ); this.hotKeys.push( new Hotkey('shift+ctrl+f', (event: KeyboardEvent) => { event.preventDefault(); @@ -542,17 +562,22 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe private commitSaveWidget() { const id = (this.widgetTypeDetails && this.widgetTypeDetails.id) ? this.widgetTypeDetails.id : undefined; const createdTime = (this.widgetTypeDetails && this.widgetTypeDetails.createdTime) ? this.widgetTypeDetails.createdTime : undefined; - this.widgetService.saveWidgetTypeDetails(this.widget, id, this.widgetsBundle.alias, createdTime).subscribe( - (widgetTypeDetails) => { - this.setWidgetTypeDetails(widgetTypeDetails); + this.widgetService.saveWidgetTypeDetails(this.widget, id, this.widgetsBundle.alias, createdTime).subscribe({ + next: (widgetTypeDetails) => { this.saveWidgetPending = false; + if (!this.widgetTypeDetails?.id) { + this.isDirty = false; + this.router.navigate(['..', widgetTypeDetails.id.id], {relativeTo: this.route}); + } else { + this.setWidgetTypeDetails(widgetTypeDetails); + } this.store.dispatch(new ActionNotificationShow( {message: this.translate.instant('widget.widget-saved'), type: 'success', duration: 500})); }, - () => { + error: () => { this.saveWidgetPending = false; } - ); + }); } private commitSaveWidgetAs() { @@ -570,12 +595,18 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe this.widget.defaultConfig = JSON.stringify(config); this.isDirty = false; this.widgetService.saveWidgetTypeDetails(this.widget, undefined, saveWidgetAsData.bundleAlias, undefined).subscribe( - (widgetTypeDetails) => { - this.router.navigateByUrl(`/widgets-bundles/${saveWidgetAsData.bundleId}/widgetTypes/${widgetTypeDetails.id.id}`); - } - ); + { + next: (widgetTypeDetails) => { + this.saveWidgetAsPending = false; + this.router.navigateByUrl(`/widgets-bundles/${saveWidgetAsData.bundleId}/widgetTypes/${widgetTypeDetails.id.id}`); + }, + error: () => { + this.saveWidgetAsPending = false; + } + }); + } else { + this.saveWidgetAsPending = false; } - this.saveWidgetAsPending = false; } ); } @@ -587,6 +618,7 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe this.widget.defaultConfig = JSON.stringify(config); this.origWidget = deepClone(this.widget); this.isDirty = false; + this.updateBreadcrumbs.emit(); } applyWidgetScript(): void { @@ -623,6 +655,34 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe this.applyWidgetScript(); } + moveWidget() { + this.moveWidgetPending = true; + this.dialog.open(MoveWidgetTypeDialogComponent, { + disableClose: true, + data: { + currentBundleId: this.widgetsBundle.id.id + }, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (moveWidgetTypeData) => { + if (moveWidgetTypeData) { + this.widgetService.moveWidgetType(this.widgetTypeDetails.id.id, moveWidgetTypeData.bundleAlias).subscribe({ + next: (widgetTypeDetails) => { + this.moveWidgetPending = false; + this.router.navigateByUrl(`/widgets-bundles/${moveWidgetTypeData.bundleId}/widgetTypes/${widgetTypeDetails.id.id}`); + }, + error: () => { + this.moveWidgetPending = false; + } + }); + } else { + this.moveWidgetPending = false; + } + } + ); + } + undoDisabled(): boolean { return !this.isDirty || !this.iframeWidgetEditModeInited @@ -644,6 +704,15 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe || this.saveWidgetAsPending; } + moveDisabled(): boolean { + return this.isReadOnly + || !this.widgetTypeDetails?.id + || !this.iframeWidgetEditModeInited + || this.saveWidgetPending + || this.saveWidgetAsPending + || this.moveWidgetPending; + } + beautifyCss(): void { beautifyCss(this.widget.templateCss, {indent_size: 4}).subscribe( (res) => { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts index 0319634305..639e4a6e2e 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts @@ -78,35 +78,28 @@ export class WidgetEditorDataResolver implements Resolve { resolve(route: ActivatedRouteSnapshot): Observable { const widgetTypeId = route.params.widgetTypeId; - return this.widgetsService.getWidgetTypeById(widgetTypeId).pipe( - map((result) => ({ + if (!widgetTypeId || Object.keys(widgetType).includes(widgetTypeId)) { + let widgetTypeParam = widgetTypeId as widgetType; + if (!widgetTypeParam) { + widgetTypeParam = widgetType.timeseries; + } + return this.widgetsService.getWidgetTemplate(widgetTypeParam).pipe( + map((widget) => { + widget.widgetName = null; + return { + widgetTypeDetails: null, + widget + }; + }) + ); + } else { + return this.widgetsService.getWidgetTypeById(widgetTypeId).pipe( + map((result) => ({ widgetTypeDetails: result, widget: detailsToWidgetInfo(result) })) - ); - } -} - -@Injectable() -export class WidgetEditorAddDataResolver implements Resolve { - - constructor(private widgetsService: WidgetService) { - } - - resolve(route: ActivatedRouteSnapshot): Observable { - let widgetTypeParam = route.params.widgetType as widgetType; - if (!widgetTypeParam) { - widgetTypeParam = widgetType.timeseries; + ); } - return this.widgetsService.getWidgetTemplate(widgetTypeParam).pipe( - map((widget) => { - widget.widgetName = null; - return { - widgetTypeDetails: null, - widget - }; - }) - ); } } @@ -114,7 +107,9 @@ export const widgetTypesBreadcumbLabelFunction: BreadCrumbLabelFunction = ( route.data.widgetsBundle.title); export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction = - ((route, translate, component) => component ? component.widget.widgetName : ''); + ((route, translate, component) => + component?.widget?.widgetName ? + (component.widget.widgetName + (component.widget.deprecated ? ` (${translate.instant('widget.deprecated')})` : '')) : ''); export const widgetsBundlesRoutes: Routes = [ { @@ -175,22 +170,6 @@ export const widgetsBundlesRoutes: Routes = [ resolve: { widgetEditorData: WidgetEditorDataResolver } - }, - { - path: 'add/:widgetType', - component: WidgetEditorComponent, - canDeactivate: [ConfirmOnExitGuard], - data: { - auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], - title: 'widget.editor', - breadcrumb: { - labelFunction: widgetEditorBreadcumbLabelFunction, - icon: 'insert_chart' - } as BreadCrumbConfig - }, - resolve: { - widgetEditorData: WidgetEditorAddDataResolver - } } ] } @@ -216,7 +195,11 @@ const routes: Routes = [ }, { path: 'widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType', - redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType', + redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetType', + }, + { + path: 'resources/widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType', + redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetType', } ]; @@ -228,8 +211,7 @@ const routes: Routes = [ WidgetsBundlesTableConfigResolver, WidgetsBundleResolver, WidgetsTypesDataResolver, - WidgetEditorDataResolver, - WidgetEditorAddDataResolver + WidgetEditorDataResolver ] }) export class WidgetLibraryRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts index a9ee1b1c9e..e1b29d81cf 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts @@ -160,7 +160,7 @@ export class WidgetLibraryComponent extends PageComponent implements OnInit { }).afterClosed().subscribe( (type) => { if (type) { - this.router.navigate(['add', type], {relativeTo: this.route}); + this.router.navigate([type], {relativeTo: this.route}); } } ); diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts index 6a6e4a47e0..8d08292f76 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts @@ -25,6 +25,7 @@ import { WidgetEditorComponent } from '@home/pages/widget/widget-editor.componen import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component'; import { SaveWidgetTypeAsDialogComponent } from './save-widget-type-as-dialog.component'; import { WidgetsBundleTabsComponent } from '@home/pages/widget/widgets-bundle-tabs.component'; +import { MoveWidgetTypeDialogComponent } from '@home/pages/widget/move-widget-type-dialog.component'; @NgModule({ declarations: [ @@ -33,6 +34,7 @@ import { WidgetsBundleTabsComponent } from '@home/pages/widget/widgets-bundle-ta WidgetEditorComponent, SelectWidgetTypeDialogComponent, SaveWidgetTypeAsDialogComponent, + MoveWidgetTypeDialogComponent, WidgetsBundleTabsComponent ], imports: [ diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts index e1b403e89b..28d15b45a2 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts @@ -61,6 +61,9 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni @Input() disabled: boolean; + @Input() + excludeBundleIds: Array; + widgetsBundles$: Observable>; widgetsBundles: Array; @@ -161,6 +164,12 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni } else { widgetsBundlesObservable = this.widgetService.getAllWidgetsBundles(); } + if (this.excludeBundleIds && this.excludeBundleIds.length) { + widgetsBundlesObservable = widgetsBundlesObservable.pipe( + map((widgetBundles) => + widgetBundles.filter(w => !this.excludeBundleIds.includes(w.id.id))) + ); + } return widgetsBundlesObservable; } diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 73d0895d8d..d45a4f0ada 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -698,6 +698,7 @@ export interface WidgetInfo { title: string; image?: string; description?: string; + deprecated?: boolean; } export interface GroupInfo { 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 ef8352fd63..2cc757f1a6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -19,6 +19,7 @@ "suspend": "Suspend", "save": "Save", "saveAs": "Save as", + "move": "Move", "cancel": "Cancel", "ok": "OK", "delete": "Delete", @@ -4548,8 +4549,11 @@ "unable-to-save-widget-error": "Unable to save widget! Widget has errors!", "save": "Save widget", "saveAs": "Save widget as", + "move": "Move widget", "save-widget-type-as": "Save widget type as", "save-widget-type-as-text": "Please enter new widget title and/or select target widgets bundle", + "move-widget-type": "Move widget type", + "move-widget-type-text": "Please select target widgets bundle", "toggle-fullscreen": "Toggle fullscreen", "run": "Run widget", "title": "Widget title", @@ -4572,6 +4576,9 @@ "settings-form-selector": "Settings form selector", "data-key-settings-form-selector": "Data key settings form selector", "latest-data-key-settings-form-selector": "Latest data key settings form selector", + "all": "All", + "actual": "Actual", + "deprecated": "Deprecated", "has-basic-mode": "Has basic mode", "basic-mode-form-selector": "Basic mode form selector", "basic-mode": "Basic",