From 875c8d526b368b65fc2fd4c855f74ce3454e25a3 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 1 Sep 2023 18:55:29 +0300 Subject: [PATCH] Widget Bundles to Wudget Types Many to Many support for UI --- .../controller/WidgetTypeController.java | 32 ++- .../controller/WidgetsBundleController.java | 17 ++ .../bundle/DefaultWidgetsBundleService.java | 6 + .../bundle/TbWidgetsBundleService.java | 2 + .../type/DefaultWidgetTypeService.java | 16 +- .../widgets/type/TbWidgetTypeService.java | 4 + .../DefaultSystemDataLoaderService.java | 8 + .../service/install/InstallScripts.java | 55 ++++- .../server/common/data/sync/JsonTbEntity.java | 2 + .../common/data/widget/WidgetTypeInfo.java | 11 + .../dao/model/sql/WidgetTypeIdFqnEntity.java | 26 +- .../dao/sql/widget/JpaWidgetTypeDao.java | 7 +- .../dao/sql/widget/WidgetTypeRepository.java | 5 +- ui-ngx/src/app/core/http/widget.service.ts | 127 +++++----- ui-ngx/src/app/core/services/menu.service.ts | 42 +++- .../attribute/attribute-table.component.ts | 4 +- .../dashboard-widget-select.component.ts | 5 +- .../home/components/home-components.module.ts | 5 + ...xport-widgets-bundle-dialog.component.html | 46 ++++ .../export-widgets-bundle-dialog.component.ts | 67 +++++ .../import-export/import-export.models.ts | 3 +- .../import-export/import-export.service.ts | 183 ++++++++++---- .../components/router-tabs.component.scss | 1 - .../widget/widget-component.service.ts | 3 +- .../home/models/widget-component.models.ts | 11 +- .../home/pages/admin/admin-routing.module.ts | 6 +- .../move-widget-type-dialog.component.html | 56 ----- .../move-widget-type-dialog.component.ts | 82 ------- .../save-widget-type-as-dialog.component.html | 5 - .../save-widget-type-as-dialog.component.ts | 22 +- .../pages/widget/widget-editor.component.html | 17 +- .../pages/widget/widget-editor.component.ts | 68 +---- .../widget/widget-library-routing.module.ts | 181 +++++++++----- .../widget/widget-library.component.html | 42 ---- .../pages/widget/widget-library.component.ts | 205 ---------------- .../pages/widget/widget-library.module.ts | 13 +- .../widget-type-autocomplete.component.html | 59 +++++ .../widget-type-autocomplete.component.scss | 49 ++++ .../widget-type-autocomplete.component.ts | 227 +++++++++++++++++ .../widget/widget-type-tabs.component.html | 23 ++ .../widget/widget-type-tabs.component.ts | 43 ++++ .../pages/widget/widget-type.component.html | 66 +++++ .../pages/widget/widget-type.component.ts | 68 +++++ .../widget-types-table-config.resolver.ts | 232 ++++++++++++++++++ .../widgets-bundle-widgets.component.html | 134 ++++++++++ .../widgets-bundle-widgets.component.scss | 184 ++++++++++++++ .../widgets-bundle-widgets.component.ts | 178 ++++++++++++++ .../widgets-bundles-table-config.resolver.ts | 4 + ui-ngx/src/app/shared/models/constants.ts | 3 +- .../app/shared/models/entity-type.models.ts | 19 ++ ui-ngx/src/app/shared/models/widget.models.ts | 5 +- .../assets/locale/locale.constant-en_US.json | 34 ++- 52 files changed, 1986 insertions(+), 727 deletions(-) rename ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss => dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeIdFqnEntity.java (70%) create mode 100644 ui-ngx/src/app/modules/home/components/import-export/export-widgets-bundle-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/import-export/export-widgets-bundle-dialog.component.ts delete mode 100644 ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.html delete mode 100644 ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.ts delete mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html delete mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.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 469a6c2cb2..4d389240d7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -74,6 +74,7 @@ public class WidgetTypeController extends AutoCommitController { "Those properties are useful to edit the Widget Type but they are not required for Dashboard rendering. "; private static final String WIDGET_TYPE_INFO_DESCRIPTION = "Widget Type Info is a lightweight object that represents Widget Type but does not contain the heavyweight widget descriptor JSON"; private static final String TENANT_ONLY_PARAM_DESCRIPTION = "Optional boolean parameter indicating whether only tenant widget types should be returned"; + private static final String UPDATE_EXISTING_BY_FQN_PARAM_DESCRIPTION = "Optional boolean parameter indicating whether to update existing widget type by FQN if present instead of creating new one"; @ApiOperation(value = "Get Widget Type Details (getWidgetTypeById)", notes = "Get the Widget Type Details based on the provided Widget Type Id. " + WIDGET_TYPE_DETAILS_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @@ -88,6 +89,19 @@ public class WidgetTypeController extends AutoCommitController { return checkWidgetTypeId(widgetTypeId, Operation.READ); } + @ApiOperation(value = "Get Widget Type Info (getWidgetTypeInfoById)", + notes = "Get the Widget Type Info based on the provided Widget Type Id. " + WIDGET_TYPE_DETAILS_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetTypeInfo/{widgetTypeId}", method = RequestMethod.GET) + @ResponseBody + public WidgetTypeInfo getWidgetTypeInfoById( + @ApiParam(value = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("widgetTypeId") String strWidgetTypeId) throws ThingsboardException { + checkParameter("widgetTypeId", strWidgetTypeId); + WidgetTypeId widgetTypeId = new WidgetTypeId(toUUID(strWidgetTypeId)); + return new WidgetTypeInfo(checkWidgetTypeId(widgetTypeId, Operation.READ)); + } + @ApiOperation(value = "Create Or Update Widget Type (saveWidgetType)", notes = "Create or update the Widget Type. " + WIDGET_TYPE_DESCRIPTION + " " + "When creating the Widget Type, platform generates Widget Type Id as " + UUID_WIKI_LINK + @@ -103,7 +117,9 @@ public class WidgetTypeController extends AutoCommitController { @ResponseBody public WidgetTypeDetails saveWidgetType( @ApiParam(value = "A JSON value representing the Widget Type Details.", required = true) - @RequestBody WidgetTypeDetails widgetTypeDetails) throws Exception { + @RequestBody WidgetTypeDetails widgetTypeDetails, + @ApiParam(value = UPDATE_EXISTING_BY_FQN_PARAM_DESCRIPTION) + @RequestParam(required = false) Boolean updateExistingByFqn) throws Exception { var currentUser = getCurrentUser(); if (Authority.SYS_ADMIN.equals(currentUser.getAuthority())) { widgetTypeDetails.setTenantId(TenantId.SYS_TENANT_ID); @@ -112,7 +128,7 @@ public class WidgetTypeController extends AutoCommitController { } checkEntity(widgetTypeDetails.getId(), widgetTypeDetails, Resource.WIDGET_TYPE); - return tbWidgetTypeService.save(widgetTypeDetails, currentUser); + return tbWidgetTypeService.save(widgetTypeDetails, updateExistingByFqn != null && updateExistingByFqn, currentUser); } @ApiOperation(value = "Delete widget type (deleteWidgetType)", @@ -224,6 +240,18 @@ public class WidgetTypeController extends AutoCommitController { return checkNotNull(widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(getTenantId(), widgetsBundleId)); } + @ApiOperation(value = "Get all Widget type fqns for specified Bundle (getBundleWidgetTypeFqns)", + notes = "Returns an array of Widget Type fqns that belong to specified Widget Bundle." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetTypeFqns", params = {"widgetsBundleId"}, method = RequestMethod.GET) + @ResponseBody + public List getBundleWidgetTypeFqns( + @ApiParam(value = "Widget Bundle Id", required = true) + @RequestParam("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { + WidgetsBundleId widgetsBundleId = new WidgetsBundleId(toUUID(strWidgetsBundleId)); + return checkNotNull(widgetTypeService.findWidgetFqnsByWidgetsBundleId(getTenantId(), widgetsBundleId)); + } + @ApiOperation(value = "Get Widget Type Info objects (getBundleWidgetTypesInfosByBundleAlias) (Deprecated)", notes = "Get the Widget Type Info objects based on the provided parameters. " + WIDGET_TYPE_INFO_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java index dff740ed09..382546dc96 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java @@ -136,6 +136,23 @@ public class WidgetsBundleController extends BaseController { tbWidgetsBundleService.updateWidgetsBundleWidgetTypes(widgetsBundleId, new ArrayList<>(widgetTypeIds), currentUser); } + @ApiOperation(value = "Update widgets bundle widgets list from widget type FQNs list (updateWidgetsBundleWidgetFqns)", + notes = "Updates widgets bundle widgets list from widget type FQNs list." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypeFqns", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void updateWidgetsBundleWidgetFqns( + @ApiParam(value = WIDGET_BUNDLE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("widgetsBundleId") String strWidgetsBundleId, + @ApiParam(value = "Ordered list of widget type FQNs to be included by widgets bundle") + @RequestBody List widgetTypeFqns) throws Exception { + checkParameter("widgetsBundleId", strWidgetsBundleId); + WidgetsBundleId widgetsBundleId = new WidgetsBundleId(toUUID(strWidgetsBundleId)); + checkNotNull(widgetTypeFqns); + var currentUser = getCurrentUser(); + tbWidgetsBundleService.updateWidgetsBundleWidgetFqns(widgetsBundleId, widgetTypeFqns, currentUser); + } + @ApiOperation(value = "Delete widgets bundle (deleteWidgetsBundle)", notes = "Deletes the widget bundle. Referencing non-existing Widget Bundle Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/DefaultWidgetsBundleService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/DefaultWidgetsBundleService.java index 2f9333a61e..ffa11e8c46 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/DefaultWidgetsBundleService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/DefaultWidgetsBundleService.java @@ -73,4 +73,10 @@ public class DefaultWidgetsBundleService extends AbstractTbEntityService impleme widgetTypeService.updateWidgetsBundleWidgetTypes(user.getTenantId(), widgetsBundleId, widgetTypeIds); autoCommit(user, widgetsBundleId); } + + @Override + public void updateWidgetsBundleWidgetFqns(WidgetsBundleId widgetsBundleId, List widgetFqns, User user) throws Exception { + widgetTypeService.updateWidgetsBundleWidgetFqns(user.getTenantId(), widgetsBundleId, widgetFqns); + autoCommit(user, widgetsBundleId); + } } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/TbWidgetsBundleService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/TbWidgetsBundleService.java index 9e266d206e..7a954cdda8 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/TbWidgetsBundleService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/TbWidgetsBundleService.java @@ -27,4 +27,6 @@ public interface TbWidgetsBundleService extends SimpleTbEntityService widgetTypeIds, User user) throws Exception; + void updateWidgetsBundleWidgetFqns(WidgetsBundleId widgetsBundleId, List widgetFqns, User user) throws Exception; + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java index 7f9ab2bb8c..650f92ac20 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -34,9 +35,20 @@ public class DefaultWidgetTypeService extends AbstractTbEntityService implements private final WidgetTypeService widgetTypeService; @Override - public WidgetTypeDetails save(WidgetTypeDetails widgetTypeDetails, User user) throws Exception { - ActionType actionType = widgetTypeDetails.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + public WidgetTypeDetails save(WidgetTypeDetails entity, User user) throws Exception { + return this.save(entity, false, user); + } + + @Override + public WidgetTypeDetails save(WidgetTypeDetails widgetTypeDetails, boolean updateExistingByFqn, User user) throws Exception { TenantId tenantId = widgetTypeDetails.getTenantId(); + if (widgetTypeDetails.getId() == null && updateExistingByFqn) { + WidgetType widgetType = widgetTypeService.findWidgetTypeByTenantIdAndFqn(tenantId, widgetTypeDetails.getFqn()); + if (widgetType != null) { + widgetTypeDetails.setId(widgetType.getId()); + } + } + ActionType actionType = widgetTypeDetails.getId() == null ? ActionType.ADDED : ActionType.UPDATED; try { WidgetTypeDetails savedWidgetTypeDetails = checkNotNull(widgetTypeService.saveWidgetType(widgetTypeDetails)); autoCommit(user, savedWidgetTypeDetails.getId()); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/TbWidgetTypeService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/TbWidgetTypeService.java index fa68c8fa8b..8951b49552 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/TbWidgetTypeService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/TbWidgetTypeService.java @@ -15,8 +15,12 @@ */ package org.thingsboard.server.service.entitiy.widgets.type; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.service.entitiy.SimpleTbEntityService; public interface TbWidgetTypeService extends SimpleTbEntityService { + + WidgetTypeDetails save(WidgetTypeDetails widgetTypeDetails, boolean updateExistingByFqn, User user) throws Exception; + } diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 28a12016ac..8530478c2a 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -99,6 +99,7 @@ import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; @@ -136,6 +137,9 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { @Autowired private AdminSettingsService adminSettingsService; + @Autowired + private WidgetTypeService widgetTypeService; + @Autowired private WidgetsBundleService widgetsBundleService; @@ -485,6 +489,10 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { public void deleteSystemWidgetBundle(String bundleAlias) throws Exception { WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, bundleAlias); if (widgetsBundle != null) { + var widgetTypes = widgetTypeService.findWidgetTypesInfosByWidgetsBundleId(TenantId.SYS_TENANT_ID, widgetsBundle.getId()); + for (var widgetType : widgetTypes) { + widgetTypeService.deleteWidgetType(TenantId.SYS_TENANT_ID, widgetType.getId()); + } widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, widgetsBundle.getId()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index bab69e0cb9..2285fdfe52 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.oauth2.OAuth2ClientRegistrationTemplate; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; @@ -46,7 +47,9 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Base64; +import java.util.List; import java.util.Optional; import java.util.function.Function; @@ -70,6 +73,7 @@ public class InstallScripts { public static final String DEVICE_PROFILE_DIR = "device_profile"; public static final String DEMO_DIR = "demo"; public static final String RULE_CHAINS_DIR = "rule_chains"; + public static final String WIDGET_TYPES_DIR = "widget_types"; public static final String WIDGET_BUNDLES_DIR = "widget_bundles"; public static final String OAUTH2_CONFIG_TEMPLATES_DIR = "oauth2_config_templates"; public static final String DASHBOARDS_DIR = "dashboards"; @@ -180,6 +184,23 @@ public class InstallScripts { } public void loadSystemWidgets() throws Exception { + Path widgetTypesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); + if (Files.exists(widgetTypesDir)) { + try (DirectoryStream dirStream = Files.newDirectoryStream(widgetTypesDir, path -> path.toString().endsWith(JSON_EXT))) { + dirStream.forEach( + path -> { + try { + JsonNode widgetTypeJson = JacksonUtil.toJsonNode(path.toFile()); + WidgetTypeDetails widgetTypeDetails = JacksonUtil.treeToValue(widgetTypeJson, WidgetTypeDetails.class); + widgetTypeService.saveWidgetType(widgetTypeDetails); + } catch (Exception e) { + log.error("Unable to load widget type from json: [{}]", path.toString()); + throw new RuntimeException("Unable to load widget type from json", e); + } + } + ); + } + } Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); try (DirectoryStream dirStream = Files.newDirectoryStream(widgetBundlesDir, path -> path.toString().endsWith(JSON_EXT))) { dirStream.forEach( @@ -189,19 +210,29 @@ public class InstallScripts { JsonNode widgetsBundleJson = widgetsBundleDescriptorJson.get("widgetsBundle"); WidgetsBundle widgetsBundle = JacksonUtil.treeToValue(widgetsBundleJson, WidgetsBundle.class); WidgetsBundle savedWidgetsBundle = widgetsBundleService.saveWidgetsBundle(widgetsBundle); - JsonNode widgetTypesArrayJson = widgetsBundleDescriptorJson.get("widgetTypes"); - widgetTypesArrayJson.forEach( - widgetTypeJson -> { - try { - WidgetTypeDetails widgetTypeDetails = JacksonUtil.treeToValue(widgetTypeJson, WidgetTypeDetails.class); - // widgetTypeDetails.setBundleAlias(savedWidgetsBundle.getAlias()); // TODO: - widgetTypeService.saveWidgetType(widgetTypeDetails); - } catch (Exception e) { - log.error("Unable to load widget type from json: [{}]", path.toString()); - throw new RuntimeException("Unable to load widget type from json", e); + List widgetTypeFqns = new ArrayList<>(); + if (widgetsBundleDescriptorJson.has("widgetTypes")) { + JsonNode widgetTypesArrayJson = widgetsBundleDescriptorJson.get("widgetTypes"); + widgetTypesArrayJson.forEach( + widgetTypeJson -> { + try { + WidgetTypeDetails widgetTypeDetails = JacksonUtil.treeToValue(widgetTypeJson, WidgetTypeDetails.class); + var savedWidgetType = widgetTypeService.saveWidgetType(widgetTypeDetails); + widgetTypeFqns.add(savedWidgetType.getFqn()); + } catch (Exception e) { + log.error("Unable to load widget type from json: [{}]", path.toString()); + throw new RuntimeException("Unable to load widget type from json", e); + } } - } - ); + ); + } + if (widgetsBundleDescriptorJson.has("widgetTypeFqns")) { + JsonNode widgetFqnsArrayJson = widgetsBundleDescriptorJson.get("widgetTypeFqns"); + widgetFqnsArrayJson.forEach(fqnJson -> { + widgetTypeFqns.add(fqnJson.asText()); + }); + } + widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, savedWidgetsBundle.getId(), widgetTypeFqns); } catch (Exception e) { log.error("Unable to load widgets bundle from json: [{}]", path.toString()); throw new RuntimeException("Unable to load widgets bundle from json", e); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java index 23a98b395b..92c6a332df 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.notification.rule.NotificationRule; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; import java.lang.annotation.ElementType; @@ -52,6 +53,7 @@ import java.lang.annotation.Target; @Type(name = "CUSTOMER", value = Customer.class), @Type(name = "ENTITY_VIEW", value = EntityView.class), @Type(name = "WIDGETS_BUNDLE", value = WidgetsBundle.class), + @Type(name = "WIDGET_TYPE", value = WidgetTypeDetails.class), @Type(name = "NOTIFICATION_TEMPLATE", value = NotificationTemplate.class), @Type(name = "NOTIFICATION_TARGET", value = NotificationTarget.class), @Type(name = "NOTIFICATION_RULE", value = NotificationRule.class) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java index 98877aa890..0e8bce0a19 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java @@ -50,4 +50,15 @@ public class WidgetTypeInfo extends BaseWidgetType { this.description = widgetTypeInfo.getDescription(); this.widgetType = widgetTypeInfo.getWidgetType(); } + + public WidgetTypeInfo(WidgetTypeDetails widgetTypeDetails) { + super(widgetTypeDetails); + this.image = widgetTypeDetails.getImage(); + this.description = widgetTypeDetails.getDescription(); + if (widgetTypeDetails.getDescriptor() != null && widgetTypeDetails.getDescriptor().has("type")) { + this.widgetType = widgetTypeDetails.getDescriptor().get("type").asText(); + } else { + this.widgetType = ""; + } + } } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeIdFqnEntity.java similarity index 70% rename from ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss rename to dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeIdFqnEntity.java index 8fcd595570..c834a6671b 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeIdFqnEntity.java @@ -13,20 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host { - button.tb-add-new-widget { - height: auto; - padding-right: 12px; - font-size: 24px; - border-style: dashed; - border-width: 2px; - } -} +package org.thingsboard.server.dao.model.sql; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.UUID; -:host ::ng-deep { - .tb-widget-library { - .tb-widget-container { - cursor: pointer; - } - } +@Data +@AllArgsConstructor +public class WidgetTypeIdFqnEntity { + private UUID id; + private String fqn; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java index 53e921f977..ec0b92cc5f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java @@ -35,6 +35,7 @@ import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import org.thingsboard.server.dao.widget.WidgetTypeDao; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -139,8 +140,10 @@ public class JpaWidgetTypeDao extends JpaAbstractDao findWidgetTypeIdsByTenantIdAndFqns(UUID tenantId, List widgetFqns) { - return widgetTypeRepository.findWidgetTypeIdsByTenantIdAndFqns(tenantId, widgetFqns).stream() - .map(WidgetTypeId::new).collect(Collectors.toList()); + var idFqnPairs = widgetTypeRepository.findWidgetTypeIdsByTenantIdAndFqns(tenantId, widgetFqns); + idFqnPairs.sort(Comparator.comparingInt(o -> widgetFqns.indexOf(o.getFqn()))); + return idFqnPairs.stream() + .map(id -> new WidgetTypeId(id.getId())).collect(Collectors.toList()); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java index 6d991737ef..b1b96b771e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java @@ -23,6 +23,7 @@ import org.springframework.data.repository.query.Param; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.WidgetTypeDetailsEntity; import org.thingsboard.server.dao.model.sql.WidgetTypeEntity; +import org.thingsboard.server.dao.model.sql.WidgetTypeIdFqnEntity; import org.thingsboard.server.dao.model.sql.WidgetTypeInfoEntity; import org.thingsboard.server.dao.model.sql.WidgetsBundleEntity; @@ -81,10 +82,10 @@ public interface WidgetTypeRepository extends JpaRepository findWidgetFqnsByWidgetsBundleId(@Param("widgetsBundleId") UUID widgetsBundleId); - @Query("SELECT wtd.id FROM WidgetTypeDetailsEntity wtd " + + @Query("SELECT new org.thingsboard.server.dao.model.sql.WidgetTypeIdFqnEntity(wtd.id, wtd.fqn) FROM WidgetTypeDetailsEntity wtd " + "WHERE wtd.tenantId = :tenantId " + "AND wtd.fqn IN (:widgetFqns)") - List findWidgetTypeIdsByTenantIdAndFqns(@Param("tenantId") UUID tenantId, @Param("widgetFqns") List widgetFqns); + List findWidgetTypeIdsByTenantIdAndFqns(@Param("tenantId") UUID tenantId, @Param("widgetFqns") List widgetFqns); @Query("SELECT wt FROM WidgetTypeEntity wt " + "WHERE wt.tenantId = :tenantId AND wt.fqn = :fqn") diff --git a/ui-ngx/src/app/core/http/widget.service.ts b/ui-ngx/src/app/core/http/widget.service.ts index ba17950917..c0fde187fc 100644 --- a/ui-ngx/src/app/core/http/widget.service.ts +++ b/ui-ngx/src/app/core/http/widget.service.ts @@ -22,6 +22,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { + BaseWidgetType, fullWidgetTypeFqn, Widget, WidgetType, @@ -30,9 +31,7 @@ import { WidgetTypeInfo, widgetTypesData } from '@shared/models/widget.models'; -import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; -import { ResourcesService } from '../services/resources.service'; import { toWidgetInfo, toWidgetTypeDetails, WidgetInfo } from '@app/modules/home/models/widget-component.models'; import { filter, map, mergeMap, tap } from 'rxjs/operators'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; @@ -56,8 +55,6 @@ export class WidgetService { constructor( private http: HttpClient, - private utils: UtilsService, - private resources: ResourcesService, private translate: TranslateService, private router: Router ) { @@ -110,46 +107,70 @@ export class WidgetService { ); } + public updateWidgetsBundleWidgetTypes(widgetsBundleId: string, widgetTypeIds: Array, + config?: RequestConfig): Observable { + return this.http.post(`/api/widgetsBundle/${widgetsBundleId}/widgetTypes`, widgetTypeIds, + defaultHttpOptionsFromConfig(config)).pipe( + tap(() => { + this.widgetTypeInfosCache.delete(widgetsBundleId); + }) + ); + } + + public updateWidgetsBundleWidgetFqns(widgetsBundleId: string, widgetTypeFqns: Array, + config?: RequestConfig): Observable { + return this.http.post(`/api/widgetsBundle/${widgetsBundleId}/widgetTypeFqns`, widgetTypeFqns, + defaultHttpOptionsFromConfig(config)).pipe( + tap(() => { + this.widgetTypeInfosCache.delete(widgetsBundleId); + }) + ); + } + public deleteWidgetsBundle(widgetsBundleId: string, config?: RequestConfig) { return this.getWidgetsBundle(widgetsBundleId, config).pipe( mergeMap((widgetsBundle) => this.http.delete(`/api/widgetsBundle/${widgetsBundleId}`, defaultHttpOptionsFromConfig(config)).pipe( tap(() => { this.invalidateWidgetsBundleCache(); - this.widgetsBundleDeleted(widgetsBundle); }) ) )); } - public getBundleWidgetTypes(bundleAlias: string, isSystem: boolean, + public getBundleWidgetTypes(widgetsBundleId: string, config?: RequestConfig): Observable> { - return this.http.get>(`/api/widgetTypes?isSystem=${isSystem}&bundleAlias=${bundleAlias}`, + return this.http.get>(`/api/widgetTypes?widgetsBundleId=${widgetsBundleId}`, defaultHttpOptionsFromConfig(config)); } - public getBundleWidgetTypesDetails(bundleAlias: string, isSystem: boolean, + public getBundleWidgetTypesDetails(widgetsBundleId: string, config?: RequestConfig): Observable> { - return this.http.get>(`/api/widgetTypesDetails?isSystem=${isSystem}&bundleAlias=${bundleAlias}`, + return this.http.get>(`/api/widgetTypesDetails?widgetsBundleId=${widgetsBundleId}`, defaultHttpOptionsFromConfig(config)); } - public getBundleWidgetTypeInfos(bundleAlias: string, isSystem: boolean, + public getBundleWidgetTypeFqns(widgetsBundleId: string, + config?: RequestConfig): Observable> { + return this.http.get>(`/api/widgetTypeFqns?widgetsBundleId=${widgetsBundleId}`, + defaultHttpOptionsFromConfig(config)); + } + + public getBundleWidgetTypeInfos(widgetsBundleId: string, config?: RequestConfig): Observable> { - const key = bundleAlias + (isSystem ? '_sys' : ''); - if (this.widgetTypeInfosCache.has(key)) { - return of(this.widgetTypeInfosCache.get(key)); + if (this.widgetTypeInfosCache.has(widgetsBundleId)) { + return of(this.widgetTypeInfosCache.get(widgetsBundleId)); } else { - return this.http.get>(`/api/widgetTypesInfos?isSystem=${isSystem}&bundleAlias=${bundleAlias}`, + return this.http.get>(`/api/widgetTypesInfos?widgetsBundleId=${widgetsBundleId}`, defaultHttpOptionsFromConfig(config)).pipe( - tap((res) => this.widgetTypeInfosCache.set(key, res) ) + tap((res) => this.widgetTypeInfosCache.set(widgetsBundleId, res) ) ); } } - public loadBundleLibraryWidgets(bundleAlias: string, isSystem: boolean, + public loadBundleLibraryWidgets(widgetsBundleId: string, config?: RequestConfig): Observable> { - return this.getBundleWidgetTypes(bundleAlias, isSystem, config).pipe( + return this.getBundleWidgetTypes(widgetsBundleId, config).pipe( map((types) => { types = types.sort((a, b) => { let result = (a.deprecated ? 1 : 0) - (b.deprecated ? 1 : 0); @@ -211,10 +232,9 @@ export class WidgetService { public saveWidgetTypeDetails(widgetInfo: WidgetInfo, id: WidgetTypeId, - bundleAlias: string, createdTime: number, config?: RequestConfig): Observable { - const widgetTypeDetails = toWidgetTypeDetails(widgetInfo, id, undefined, bundleAlias, createdTime); + const widgetTypeDetails = toWidgetTypeDetails(widgetInfo, id, undefined, createdTime); return this.http.post('/api/widgetType', widgetTypeDetails, defaultHttpOptionsFromConfig(config)).pipe( tap((savedWidgetType) => { @@ -222,46 +242,47 @@ export class WidgetService { })); } - public setWidgetTypeDeprecated(widgetTypeId: string, deprecated: boolean, config?: RequestConfig): Observable { - return this.http.post(`/api/widgetType/${widgetTypeId}/deprecate/${deprecated}`, + public saveImportedWidgetTypeDetails(widgetTypeDetails: WidgetTypeDetails, + config?: RequestConfig): Observable { + return this.http.post('/api/widgetType?updateExistingByFqn=true', widgetTypeDetails, 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 getWidgetTypeById(widgetTypeId: string, + config?: RequestConfig): Observable { + return this.http.get(`/api/widgetType/${widgetTypeId}`, + defaultHttpOptionsFromConfig(config)); } - public saveImportedWidgetTypeDetails(widgetTypeDetails: WidgetTypeDetails, - config?: RequestConfig): Observable { - return this.http.post('/api/widgetType', widgetTypeDetails, - defaultHttpOptionsFromConfig(config)).pipe( - tap((savedWidgetType) => { - this.widgetTypeUpdated(savedWidgetType); - })); + public getWidgetTypeInfoById(widgetTypeId: string, + config?: RequestConfig): Observable { + return this.http.get(`/api/widgetTypeInfo/${widgetTypeId}`, + defaultHttpOptionsFromConfig(config)); + } + + public saveWidgetType(widgetTypeDetails: WidgetTypeDetails, + config?: RequestConfig): Observable { + return this.http.post(`/api/widgetType`, + defaultHttpOptionsFromConfig(config)); } - public deleteWidgetType(fullFqn: string, + public deleteWidgetType(widgetTypeId: string, config?: RequestConfig) { - return this.getWidgetType(fullFqn, config).pipe( - mergeMap((widgetTypeInstance) => this.http.delete(`/api/widgetType/${widgetTypeInstance.id.id}`, - defaultHttpOptionsFromConfig(config)).pipe( - tap(() => { - this.widgetTypeUpdated(widgetTypeInstance); - }) - ) - )); + return this.getWidgetTypeById(widgetTypeId, config).pipe( + mergeMap((widgetTypeDetails) => + this.http.delete(`/api/widgetType/${widgetTypeId}`, defaultHttpOptionsFromConfig(config)).pipe( + tap(() => { + this.widgetTypeUpdated(widgetTypeDetails); + }) + ) + )); } - public getWidgetTypeById(widgetTypeId: string, - config?: RequestConfig): Observable { - return this.http.get(`/api/widgetType/${widgetTypeId}`, + public getWidgetTypes(pageLink: PageLink, tenantOnly = false, config?: RequestConfig): Observable> { + return this.http.get>(`/api/widgetTypes${pageLink.toQuery()}&tenantOnly=${tenantOnly}`, defaultHttpOptionsFromConfig(config)); } @@ -286,26 +307,14 @@ export class WidgetService { this.widgetsInfoInMemoryCache.set(widgetInfo.fullFqn, widgetInfo); } - private widgetTypeUpdated(updatedWidgetType: WidgetType): void { + private widgetTypeUpdated(updatedWidgetType: BaseWidgetType): void { this.deleteWidgetInfoFromCache(fullWidgetTypeFqn(updatedWidgetType)); } - private widgetsBundleDeleted(widgetsBundle: WidgetsBundle): void { - this.deleteWidgetsBundleFromCache(widgetsBundle.alias); - } - public deleteWidgetInfoFromCache(fullFqn: string) { this.widgetsInfoInMemoryCache.delete(fullFqn); } - private deleteWidgetsBundleFromCache(bundleAlias: string) { - this.widgetsInfoInMemoryCache.forEach((widgetInfo, fullFqn) => { - if (widgetInfo.bundleAlias === bundleAlias) { - this.widgetsInfoInMemoryCache.delete(fullFqn); - } - }); - } - private loadWidgetsBundleCache(config?: RequestConfig): Observable { if (!this.allWidgetsBundles) { if (!this.loadWidgetsBundleCacheSubject) { diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index 0de3346af9..d48b0619ca 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -124,8 +124,24 @@ export class MenuService { id: 'widget_library', name: 'widget.widget-library', type: 'link', - path: '/resources/widgets-bundles', - icon: 'now_widgets' + path: '/resources/widgets-library', + icon: 'now_widgets', + pages: [ + { + id: 'widget_types', + name: 'widget.widgets', + type: 'link', + path: '/resources/widgets-library/widget-types', + icon: 'now_widgets' + }, + { + id: 'widgets_bundles', + name: 'widgets-bundle.widgets-bundles', + type: 'link', + path: '/resources/widgets-library/widgets-bundles', + icon: 'now_widgets' + } + ] }, { id: 'resources_library', @@ -283,7 +299,7 @@ export class MenuService { { name: 'widget.widget-library', icon: 'now_widgets', - path: '/widgets-bundles' + path: '/resources/widgets-library', } ] }, @@ -492,8 +508,24 @@ export class MenuService { id: 'widget_library', name: 'widget.widget-library', type: 'link', - path: '/resources/widgets-bundles', - icon: 'now_widgets' + path: '/resources/widgets-library', + icon: 'now_widgets', + pages: [ + { + id: 'widget_types', + name: 'widget.widgets', + type: 'link', + path: '/resources/widgets-library/widget-types', + icon: 'now_widgets' + }, + { + id: 'widgets_bundles', + name: 'widgets-bundle.widgets-bundles', + type: 'link', + path: '/resources/widgets-library/widgets-bundles', + icon: 'now_widgets' + } + ] }, { id: 'resources_library', diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts index d6d0b2731b..0837474a87 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts @@ -567,9 +567,7 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI this.widgetsCarouselIndex = 0; if (widgetsBundle) { this.widgetsLoaded = false; - const bundleAlias = widgetsBundle.alias; - const isSystem = widgetsBundle.tenantId.id === NULL_UUID; - this.widgetService.getBundleWidgetTypes(bundleAlias, isSystem).subscribe( + this.widgetService.getBundleWidgetTypes(widgetsBundle.id.id).subscribe( (widgetTypes) => { widgetTypes = widgetTypes.sort((a, b) => { let result = widgetType[b.descriptor.type].localeCompare(widgetType[a.descriptor.type]); 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 05896523b1..09e5480bf5 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 @@ -121,12 +121,9 @@ export class DashboardWidgetSelectComponent implements OnInit { private getWidgets(): Observable> { if (!this.widgetsInfo) { if (this.widgetsBundle !== null) { - const bundleAlias = this.widgetsBundle.alias; - const isSystem = this.widgetsBundle.tenantId.id === NULL_UUID; this.loadingWidgetsSubject.next(true); - this.widgetsInfo = this.widgetsService.getBundleWidgetTypeInfos(bundleAlias, isSystem).pipe( + this.widgetsInfo = this.widgetsService.getBundleWidgetTypeInfos(this.widgetsBundle.id.id).pipe( 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) => { 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 a18f785b35..735ef9742b 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 @@ -178,6 +178,9 @@ import { import { WidgetConfigComponentsModule } from '@home/components/widget/config/widget-config-components.module'; import { BasicWidgetConfigModule } from '@home/components/widget/config/basic/basic-widget-config.module'; import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delete-timeseries-panel.component'; +import { + ExportWidgetsBundleDialogComponent +} from '@home/components/import-export/export-widgets-bundle-dialog.component'; @NgModule({ declarations: @@ -228,6 +231,7 @@ import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delet CustomDialogContainerComponent, ImportDialogComponent, ImportDialogCsvComponent, + ExportWidgetsBundleDialogComponent, SelectTargetLayoutDialogComponent, SelectTargetStateDialogComponent, AddWidgetToDashboardDialogComponent, @@ -372,6 +376,7 @@ import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delet CustomDialogContainerComponent, ImportDialogComponent, ImportDialogCsvComponent, + ExportWidgetsBundleDialogComponent, TableColumnsAssignmentComponent, SelectTargetLayoutDialogComponent, SelectTargetStateDialogComponent, diff --git a/ui-ngx/src/app/modules/home/components/import-export/export-widgets-bundle-dialog.component.html b/ui-ngx/src/app/modules/home/components/import-export/export-widgets-bundle-dialog.component.html new file mode 100644 index 0000000000..a23a55fecf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/import-export/export-widgets-bundle-dialog.component.html @@ -0,0 +1,46 @@ + + +

widgets-bundle.export

+ + +
+ + +
+
+ {{ 'widgets-bundle.export-widgets-bundle-widgets-prompt' | translate }} +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/import-export/export-widgets-bundle-dialog.component.ts b/ui-ngx/src/app/modules/home/components/import-export/export-widgets-bundle-dialog.component.ts new file mode 100644 index 0000000000..570beaf7e8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/import-export/export-widgets-bundle-dialog.component.ts @@ -0,0 +1,67 @@ +/// +/// 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 { FormControl } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; + +export interface ExportWidgetsBundleDialogData { + widgetsBundle: WidgetsBundle; +} + +export interface ExportWidgetsBundleDialogResult { + exportWidgets: boolean; +} + +@Component({ + selector: 'tb-export-widgets-bundle-dialog', + templateUrl: './export-widgets-bundle-dialog.component.html', + providers: [], + styleUrls: [] +}) +export class ExportWidgetsBundleDialogComponent extends DialogComponent + implements OnInit { + + widgetsBundle: WidgetsBundle; + + exportWidgetsFormControl = new FormControl(false); + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ExportWidgetsBundleDialogData, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + this.widgetsBundle = data.widgetsBundle; + } + + ngOnInit(): void { + } + + cancel(): void { + this.dialogRef.close(null); + } + + export(): void { + this.dialogRef.close({ + exportWidgets: this.exportWidgetsFormControl.value + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts index fddfa80d91..4b41f00994 100644 --- a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts @@ -25,7 +25,8 @@ export interface ImportWidgetResult { export interface WidgetsBundleItem { widgetsBundle: WidgetsBundle; - widgetTypes: WidgetTypeDetails[]; + widgetTypes?: WidgetTypeDetails[]; + widgetTypeFqns?: string[]; } export interface CsvToJsonConfig { diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts index 9e2facd938..ffec98f40f 100644 --- a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts @@ -34,8 +34,8 @@ import { } from '@shared/models/alias.models'; import { MatDialog } from '@angular/material/dialog'; import { ImportDialogComponent, ImportDialogData } from '@home/components/import-export/import-dialog.component'; -import { forkJoin, Observable, of } from 'rxjs'; -import { catchError, map, mergeMap, tap } from 'rxjs/operators'; +import { forkJoin, Observable, of, Subject } from 'rxjs'; +import { catchError, map, mergeMap, switchMap, tap } from 'rxjs/operators'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { EntityService } from '@core/http/entity.service'; import { Widget, WidgetSize, WidgetType, WidgetTypeDetails } from '@shared/models/widget.models'; @@ -58,7 +58,6 @@ import { import { EntityType } from '@shared/models/entity-type.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetService } from '@core/http/widget.service'; -import { NULL_UUID } from '@shared/models/id/has-uuid'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { ImportEntitiesResultInfo, ImportEntityData } from '@shared/models/entity.models'; import { RequestConfig } from '@core/http/http-utils'; @@ -75,6 +74,11 @@ import { EdgeService } from '@core/http/edge.service'; import { RuleNode } from '@shared/models/rule-node.models'; import { AssetProfileService } from '@core/http/asset-profile.service'; import { AssetProfile } from '@shared/models/asset.models'; +import { + ExportWidgetsBundleDialogComponent, + ExportWidgetsBundleDialogData, + ExportWidgetsBundleDialogResult +} from '@home/components/import-export/export-widgets-bundle-dialog.component'; // @dynamic @Injectable() @@ -261,9 +265,6 @@ export class ImportExportService { public exportWidgetType(widgetTypeId: string) { this.widgetService.getWidgetTypeById(widgetTypeId).subscribe( (widgetTypeDetails) => { - if (isDefined(widgetTypeDetails.bundleAlias)) { - delete widgetTypeDetails.bundleAlias; - } let name = widgetTypeDetails.name; name = name.toLowerCase().replace(/\W/g, '_'); this.exportToPc(this.prepareExport(widgetTypeDetails), name); @@ -274,7 +275,28 @@ export class ImportExportService { ); } - public importWidgetType(bundleAlias: string): Observable { + public exportWidgetTypes(widgetTypeIds: string[]): Observable { + const widgetTypesObservables: Array> = []; + for (const id of widgetTypeIds) { + widgetTypesObservables.push(this.widgetService.getWidgetTypeById(id)); + } + return forkJoin(widgetTypesObservables).pipe( + map((widgetTypes) => + Object.fromEntries(widgetTypes.map(wt=> { + let name = wt.name; + name = name.toLowerCase().replace(/\W/g, '_') + `.${JSON_TYPE.extension}`; + const data = JSON.stringify(this.prepareExport(wt), null, 2); + return [name, data]; + }))), + mergeMap(widgetTypeFiles => this.exportJSZip(widgetTypeFiles, 'widget_types')), + catchError(e => { + this.handleExportError(e, 'widget-type.export-failed-error'); + throw e; + }) + ); + } + + public importWidgetType(): Observable { return this.openImportDialog('widget-type.import', 'widget-type.widget-type-file').pipe( mergeMap((widgetTypeDetails: WidgetTypeDetails) => { if (!this.validateImportedWidgetTypeDetails(widgetTypeDetails)) { @@ -283,7 +305,6 @@ export class ImportExportService { type: 'error'})); throw new Error('Invalid widget type file'); } else { - widgetTypeDetails.bundleAlias = bundleAlias; return this.widgetService.saveImportedWidgetTypeDetails(widgetTypeDetails); } }), @@ -296,27 +317,22 @@ export class ImportExportService { public exportWidgetsBundle(widgetsBundleId: string) { this.widgetService.getWidgetsBundle(widgetsBundleId).subscribe( (widgetsBundle) => { - const bundleAlias = widgetsBundle.alias; - const isSystem = widgetsBundle.tenantId.id === NULL_UUID; - this.widgetService.getBundleWidgetTypesDetails(bundleAlias, isSystem).subscribe( - (widgetTypesDetails) => { - widgetTypesDetails = widgetTypesDetails.sort((a, b) => a.createdTime - b.createdTime); - const widgetsBundleItem: WidgetsBundleItem = { - widgetsBundle: this.prepareExport(widgetsBundle), - widgetTypes: [] - }; - for (const widgetTypeDetails of widgetTypesDetails) { - if (isDefined(widgetTypeDetails.bundleAlias)) { - delete widgetTypeDetails.bundleAlias; + this.dialog.open(ExportWidgetsBundleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + widgetsBundle + } + }).afterClosed().subscribe( + (result) => { + if (result) { + if (result.exportWidgets) { + this.exportWidgetsBundleWithWidgetTypes(widgetsBundle); + } else { + this.exportWidgetsBundleWithWidgetTypeFqns(widgetsBundle); } - widgetsBundleItem.widgetTypes.push(this.prepareExport(widgetTypeDetails)); } - let name = widgetsBundle.title; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(widgetsBundleItem, name); - }, - (e) => { - this.handleExportError(e, 'widgets-bundle.export-failed-error'); } ); }, @@ -326,6 +342,43 @@ export class ImportExportService { ); } + private exportWidgetsBundleWithWidgetTypes(widgetsBundle: WidgetsBundle) { + this.widgetService.getBundleWidgetTypesDetails(widgetsBundle.id.id).subscribe( + (widgetTypesDetails) => { + const widgetsBundleItem: WidgetsBundleItem = { + widgetsBundle: this.prepareExport(widgetsBundle), + widgetTypes: [] + }; + for (const widgetTypeDetails of widgetTypesDetails) { + widgetsBundleItem.widgetTypes.push(this.prepareExport(widgetTypeDetails)); + } + let name = widgetsBundle.title; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(widgetsBundleItem, name); + }, + (e) => { + this.handleExportError(e, 'widgets-bundle.export-failed-error'); + } + ); + } + + private exportWidgetsBundleWithWidgetTypeFqns(widgetsBundle: WidgetsBundle) { + this.widgetService.getBundleWidgetTypeFqns(widgetsBundle.id.id).subscribe( + (widgetTypeFqns) => { + const widgetsBundleItem: WidgetsBundleItem = { + widgetsBundle: this.prepareExport(widgetsBundle), + widgetTypeFqns + }; + let name = widgetsBundle.title; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(widgetsBundleItem, name); + }, + (e) => { + this.handleExportError(e, 'widgets-bundle.export-failed-error'); + } + ); + } + public importWidgetsBundle(): Observable { return this.openImportDialog('widgets-bundle.import', 'widgets-bundle.widgets-bundle-file').pipe( mergeMap((widgetsBundleItem: WidgetsBundleItem) => { @@ -338,16 +391,32 @@ export class ImportExportService { const widgetsBundle = widgetsBundleItem.widgetsBundle; return this.widgetService.saveWidgetsBundle(widgetsBundle).pipe( mergeMap((savedWidgetsBundle) => { - const bundleAlias = savedWidgetsBundle.alias; - const widgetTypesDetails = widgetsBundleItem.widgetTypes; - if (widgetTypesDetails.length) { - const saveWidgetTypesObservables: Array> = []; - for (const widgetTypeDetails of widgetTypesDetails) { - widgetTypeDetails.bundleAlias = bundleAlias; - saveWidgetTypesObservables.push(this.widgetService.saveImportedWidgetTypeDetails(widgetTypeDetails)); + if (widgetsBundleItem.widgetTypes?.length || widgetsBundleItem.widgetTypeFqns?.length) { + let widgetTypesObservable: Observable>; + if (widgetsBundleItem.widgetTypes?.length) { + const widgetTypesDetails = widgetsBundleItem.widgetTypes; + const saveWidgetTypesObservables: Array> = []; + for (const widgetTypeDetails of widgetTypesDetails) { + saveWidgetTypesObservables.push(this.widgetService.saveImportedWidgetTypeDetails(widgetTypeDetails)); + } + widgetTypesObservable = forkJoin(saveWidgetTypesObservables); + } else { + widgetTypesObservable = of([]); } - return forkJoin(saveWidgetTypesObservables).pipe( - map(() => savedWidgetsBundle) + return widgetTypesObservable.pipe( + switchMap((widgetTypes) => { + let widgetTypeFqns = widgetTypes.map(w => w.fqn); + if (widgetsBundleItem.widgetTypeFqns?.length) { + widgetTypeFqns = widgetTypeFqns.concat(widgetsBundleItem.widgetTypeFqns); + } + if (widgetTypeFqns.length) { + return this.widgetService.updateWidgetsBundleWidgetFqns(savedWidgetsBundle.id.id, widgetTypeFqns).pipe( + map((res) => savedWidgetsBundle) + ); + } else { + return of(savedWidgetsBundle); + } + }) ); } else { return of(savedWidgetsBundle); @@ -636,19 +705,28 @@ export class ImportExportService { this.downloadFile(content, filename, TEXT_TYPE); } - public exportJSZip(data: object, filename: string) { + public exportJSZip(data: object, filename: string): Observable { + const exportJsSubjectSubject = new Subject(); import('jszip').then((JSZip) => { - const jsZip = new JSZip.default(); - for (const keyName in data) { - if (data.hasOwnProperty(keyName)) { - const valueData = data[keyName]; - jsZip.file(keyName, valueData); + try { + const jsZip = new JSZip.default(); + for (const keyName in data) { + if (data.hasOwnProperty(keyName)) { + const valueData = data[keyName]; + jsZip.file(keyName, valueData); + } } + jsZip.generateAsync({type: 'blob'}).then(content => { + this.downloadFile(content, filename, ZIP_TYPE); + exportJsSubjectSubject.next(null); + }).catch(e => { + exportJsSubjectSubject.error(e); + }); + } catch (e) { + exportJsSubjectSubject.error(e); } - jsZip.generateAsync({type: 'blob'}).then(content => { - this.downloadFile(content, filename, ZIP_TYPE); - }); }); + return exportJsSubjectSubject.asObservable(); } private prepareRuleChain(ruleChain: RuleChain): RuleChain { @@ -765,16 +843,23 @@ export class ImportExportService { if (isUndefined(widgetsBundleItem.widgetsBundle)) { return false; } - if (isUndefined(widgetsBundleItem.widgetTypes)) { + if (isUndefined(widgetsBundleItem.widgetTypes) && isUndefined(widgetsBundleItem.widgetTypeFqns)) { return false; } const widgetsBundle = widgetsBundleItem.widgetsBundle; if (isUndefined(widgetsBundle.title)) { return false; } - const widgetTypesDetails = widgetsBundleItem.widgetTypes; - for (const widgetTypeDetails of widgetTypesDetails) { - if (!this.validateImportedWidgetTypeDetails(widgetTypeDetails)) { + if (isDefined(widgetsBundleItem.widgetTypes)) { + const widgetTypesDetails = widgetsBundleItem.widgetTypes; + for (const widgetTypeDetails of widgetTypesDetails) { + if (!this.validateImportedWidgetTypeDetails(widgetTypeDetails)) { + return false; + } + } + } + if (isDefined(widgetsBundleItem.widgetTypeFqns)) { + if (!Array.isArray(widgetsBundleItem.widgetTypeFqns)) { return false; } } diff --git a/ui-ngx/src/app/modules/home/components/router-tabs.component.scss b/ui-ngx/src/app/modules/home/components/router-tabs.component.scss index bd252f6832..ad8ad2a830 100644 --- a/ui-ngx/src/app/modules/home/components/router-tabs.component.scss +++ b/ui-ngx/src/app/modules/home/components/router-tabs.component.scss @@ -31,7 +31,6 @@ margin-left: 0; } span { - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index 1832571e5f..669f1d7707 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts @@ -85,7 +85,6 @@ export class WidgetComponentService { this.editingWidgetType = toWidgetType( { widgetName: this.utils.editWidgetInfo.widgetName, - bundleAlias: 'customWidgetBundle', fullFqn: 'system.customWidget', deprecated: false, type: this.utils.editWidgetInfo.type, @@ -104,7 +103,7 @@ export class WidgetComponentService { hasBasicMode: this.utils.editWidgetInfo.hasBasicMode, basicModeDirective: this.utils.editWidgetInfo.basicModeDirective, defaultConfig: this.utils.editWidgetInfo.defaultConfig - }, new WidgetTypeId('1'), new TenantId( NULL_UUID ), 'customWidgetBundle', undefined + }, new WidgetTypeId('1'), new TenantId( NULL_UUID ), undefined ); } const initSubject = new ReplaySubject(); diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index ffd89ae1ee..214a78d022 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -509,7 +509,6 @@ export interface IDynamicWidgetComponent { export interface WidgetInfo extends WidgetTypeDescriptor, WidgetControllerDescriptor { widgetName: string; - bundleAlias: string; fullFqn: string; deprecated: boolean; typeSettingsSchema?: string | any; @@ -542,7 +541,6 @@ export interface WidgetConfigComponentData { export const MissingWidgetType: WidgetInfo = { type: widgetType.latest, widgetName: 'Widget type not found', - bundleAlias: 'undefined', fullFqn: 'undefined', deprecated: false, sizeX: 8, @@ -568,7 +566,6 @@ export const MissingWidgetType: WidgetInfo = { export const ErrorWidgetType: WidgetInfo = { type: widgetType.latest, widgetName: 'Error loading widget', - bundleAlias: 'error', fullFqn: 'error', deprecated: false, sizeX: 8, @@ -611,7 +608,6 @@ export interface WidgetTypeInstance { export const toWidgetInfo = (widgetTypeEntity: WidgetType): WidgetInfo => ({ widgetName: widgetTypeEntity.name, - bundleAlias: widgetTypeEntity.bundleAlias, fullFqn: fullWidgetTypeFqn(widgetTypeEntity), deprecated: widgetTypeEntity.deprecated, type: widgetTypeEntity.descriptor.type, @@ -640,7 +636,7 @@ export const detailsToWidgetInfo = (widgetTypeDetailsEntity: WidgetTypeDetails): }; export const toWidgetType = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: TenantId, - bundleAlias: string, createdTime: number): WidgetType => { + createdTime: number): WidgetType => { const descriptor: WidgetTypeDescriptor = { type: widgetInfo.type, sizeX: widgetInfo.sizeX, @@ -663,7 +659,6 @@ export const toWidgetType = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: id, tenantId, createdTime, - bundleAlias, fqn: widgetTypeFqn(widgetInfo.fullFqn), name: widgetInfo.widgetName, deprecated: widgetInfo.deprecated, @@ -672,8 +667,8 @@ export const toWidgetType = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: }; export const toWidgetTypeDetails = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: TenantId, - bundleAlias: string, createdTime: number): WidgetTypeDetails => { - const widgetTypeEntity = toWidgetType(widgetInfo, id, tenantId, bundleAlias, createdTime); + createdTime: number): WidgetTypeDetails => { + const widgetTypeEntity = toWidgetType(widgetInfo, id, tenantId, createdTime); return { ...widgetTypeEntity, description: widgetInfo.description, diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index 5748cc63a6..5d2727533c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -36,7 +36,7 @@ import { QueuesTableConfigResolver } from '@home/pages/admin/queue/queues-table- import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-admin-settings.component'; import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component'; import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component'; -import { widgetsBundlesRoutes } from '@home/pages/widget/widget-library-routing.module'; +import { widgetsLibraryRoutes } from '@home/pages/widget/widget-library-routing.module'; import { RouterTabsComponent } from '@home/components/router-tabs.component'; import { auditLogsRoutes } from '@home/pages/audit-log/audit-log-routing.module'; @@ -67,10 +67,10 @@ const routes: Routes = [ children: [], data: { auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], - redirectTo: '/resources/widgets-bundles' + redirectTo: '/resources/widgets-library' } }, - ...widgetsBundlesRoutes, + ...widgetsLibraryRoutes, { path: 'resources-library', data: { 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 deleted file mode 100644 index c0734b35c5..0000000000 --- a/ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.html +++ /dev/null @@ -1,56 +0,0 @@ - -
- -

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 deleted file mode 100644 index 6f92e34bdb..0000000000 --- a/ui-ngx/src/app/modules/home/pages/widget/move-widget-type-dialog.component.ts +++ /dev/null @@ -1,82 +0,0 @@ -/// -/// 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/save-widget-type-as-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html index b2cfa8f49e..2ce7acd7e1 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html @@ -38,11 +38,6 @@ {{ 'widget.title-required' | translate }} - -
diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts index 4382ee4682..811c9ad350 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts @@ -21,14 +21,9 @@ 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 SaveWidgetTypeAsDialogResult { widgetName: string; - bundleId: string; - bundleAlias: string; } @Component({ @@ -41,26 +36,16 @@ export class SaveWidgetTypeAsDialogComponent extends saveWidgetTypeAsFormGroup: UntypedFormGroup; - bundlesScope: string; - constructor(protected store: Store, protected router: Router, 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.saveWidgetTypeAsFormGroup = this.fb.group({ - title: [null, [Validators.required]], - widgetsBundle: [null, [Validators.required]] + title: [null, [Validators.required]] }); } @@ -70,11 +55,8 @@ export class SaveWidgetTypeAsDialogComponent extends saveAs(): void { const widgetName: string = this.saveWidgetTypeAsFormGroup.get('title').value; - const widgetsBundle: WidgetsBundle = this.saveWidgetTypeAsFormGroup.get('widgetsBundle').value; const result: SaveWidgetTypeAsDialogResult = { - widgetName, - bundleId: widgetsBundle.id.id, - bundleAlias: widgetsBundle.alias + widgetName }; 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 1a4cf8740e..f19edc35c7 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 @@ -22,7 +22,7 @@ + placeholder="{{ 'widget.widget-title' | translate }}"/> save_as action.saveAs - -
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts index 5a7a0f262d..d3ba1d1daa 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts @@ -25,7 +25,6 @@ import { ViewChild, ViewEncapsulation } from '@angular/core'; -import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { WidgetService } from '@core/http/widget.service'; @@ -59,11 +58,6 @@ import { widgetEditorCompleter } from '@home/pages/widget/widget-editor.models'; import { Observable } from 'rxjs/internal/Observable'; import { map, tap } from 'rxjs/operators'; import { beautifyCss, beautifyHtml, beautifyJs } from '@shared/models/beautify.models'; -import { - MoveWidgetTypeDialogComponent, - MoveWidgetTypeDialogData, - MoveWidgetTypeDialogResult -} from '@home/pages/widget/move-widget-type-dialog.component'; import Timeout = NodeJS.Timeout; // @dynamic @@ -124,7 +118,6 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe isReadOnly: boolean; - widgetsBundle: WidgetsBundle; widgetTypeDetails: WidgetTypeDetails; widget: WidgetInfo; origWidget: WidgetInfo; @@ -155,7 +148,6 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe iframeWidgetEditModeInited = false; saveWidgetPending = false; saveWidgetAsPending = false; - moveWidgetPending = false; gotError = false; errorMarkers: number[] = []; @@ -191,14 +183,13 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe } private init(data: any) { - this.widgetsBundle = data.widgetsBundle; + this.widgetTypeDetails = data.widgetEditorData.widgetTypeDetails; + this.widget = data.widgetEditorData.widget; if (this.authUser.authority === Authority.TENANT_ADMIN) { - this.isReadOnly = !this.widgetsBundle || this.widgetsBundle.tenantId.id === NULL_UUID; + this.isReadOnly = this.widgetTypeDetails && this.widgetTypeDetails.tenantId.id === NULL_UUID; } else { this.isReadOnly = this.authUser.authority !== Authority.SYS_ADMIN; } - this.widgetTypeDetails = data.widgetEditorData.widgetTypeDetails; - this.widget = data.widgetEditorData.widget; if (this.widgetTypeDetails) { const config = JSON.parse(this.widget.defaultConfig); this.widget.defaultConfig = JSON.stringify(config); @@ -259,16 +250,6 @@ 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(); @@ -562,7 +543,7 @@ 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({ + this.widgetService.saveWidgetTypeDetails(this.widget, id, createdTime).subscribe({ next: (widgetTypeDetails) => { this.saveWidgetPending = false; if (!this.widgetTypeDetails?.id) { @@ -594,11 +575,11 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe config.title = this.widget.widgetName; this.widget.defaultConfig = JSON.stringify(config); this.isDirty = false; - this.widgetService.saveWidgetTypeDetails(this.widget, undefined, saveWidgetAsData.bundleAlias, undefined).subscribe( + this.widgetService.saveWidgetTypeDetails(this.widget, undefined, undefined).subscribe( { next: (widgetTypeDetails) => { this.saveWidgetAsPending = false; - this.router.navigateByUrl(`/widgets-bundles/${saveWidgetAsData.bundleId}/widgetTypes/${widgetTypeDetails.id.id}`); + this.router.navigate(['..', widgetTypeDetails.id.id], {relativeTo: this.route}); }, error: () => { this.saveWidgetAsPending = false; @@ -655,34 +636,6 @@ 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 @@ -704,15 +657,6 @@ 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 639e4a6e2e..945cee8e0b 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 @@ -20,7 +20,6 @@ import { ActivatedRouteSnapshot, Resolve, RouterModule, Routes } from '@angular/ import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; import { Authority } from '@shared/models/authority.enum'; import { WidgetsBundlesTableConfigResolver } from '@modules/home/pages/widget/widgets-bundles-table-config.resolver'; -import { WidgetLibraryComponent } from '@home/pages/widget/widget-library.component'; import { BreadCrumbConfig, BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; import { Observable } from 'rxjs'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; @@ -28,10 +27,11 @@ import { WidgetService } from '@core/http/widget.service'; import { WidgetEditorComponent } from '@home/pages/widget/widget-editor.component'; import { map } from 'rxjs/operators'; import { detailsToWidgetInfo, WidgetInfo } from '@home/models/widget-component.models'; -import { widgetType, WidgetTypeDetails } from '@app/shared/models/widget.models'; +import { widgetType, WidgetTypeDetails, WidgetTypeInfo } from '@app/shared/models/widget.models'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; -import { WidgetsData } from '@home/models/dashboard-component.models'; -import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { RouterTabsComponent } from '@home/components/router-tabs.component'; +import { WidgetTypesTableConfigResolver } from '@home/pages/widget/widget-types-table-config.resolver'; +import { WidgetsBundleWidgetsComponent } from '@home/pages/widget/widgets-bundle-widgets.component'; export interface WidgetEditorData { widgetTypeDetails: WidgetTypeDetails; @@ -45,14 +45,24 @@ export class WidgetsBundleResolver implements Resolve { } resolve(route: ActivatedRouteSnapshot): Observable { - let widgetsBundleId = route.params.widgetsBundleId; - if (!widgetsBundleId) { - widgetsBundleId = route.parent.params.widgetsBundleId; - } + const widgetsBundleId = route.params.widgetsBundleId; return this.widgetsService.getWidgetsBundle(widgetsBundleId); } } +@Injectable() +export class WidgetsBundleWidgetsResolver implements Resolve> { + + constructor(private widgetsService: WidgetService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + const widgetsBundleId = route.params.widgetsBundleId; + return this.widgetsService.getBundleWidgetTypeInfos(widgetsBundleId); + } +} + +/* @Injectable() export class WidgetsTypesDataResolver implements Resolve { @@ -60,15 +70,12 @@ export class WidgetsTypesDataResolver implements Resolve { } resolve(route: ActivatedRouteSnapshot): Observable { - const widgetsBundle: WidgetsBundle = route.parent.data.widgetsBundle; - const bundleAlias = widgetsBundle.alias; - const isSystem = widgetsBundle.tenantId.id === NULL_UUID; - return this.widgetsService.loadBundleLibraryWidgets(bundleAlias, - isSystem).pipe( + const widgetsBundleId = route.params.widgetsBundleId; + return this.widgetsService.loadBundleLibraryWidgets(widgetsBundleId).pipe( map((widgets) => ({ widgets }) )); } -} +}*/ @Injectable() export class WidgetEditorDataResolver implements Resolve { @@ -103,7 +110,7 @@ export class WidgetEditorDataResolver implements Resolve { } } -export const widgetTypesBreadcumbLabelFunction: BreadCrumbLabelFunction = ((route, translate) => +export const widgetsBundleWidgetsBreadcumbLabelFunction: BreadCrumbLabelFunction = ((route, translate) => route.data.widgetsBundle.title); export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction = @@ -111,7 +118,49 @@ export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction, + hideTabs: true + }, + resolve: { + widgetEditorData: WidgetEditorDataResolver + } + } + ] + }, +]; + +const widgetsBundlesRoutes: Routes = [ { path: 'widgets-bundles', data: { @@ -133,73 +182,91 @@ export const widgetsBundlesRoutes: Routes = [ } }, { - path: ':widgetsBundleId/widgetTypes', + path: ':widgetsBundleId', + component: WidgetsBundleWidgetsComponent, + canDeactivate: [ConfirmOnExitGuard], data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'widgets-bundle.widgets-bundle-widgets', breadcrumb: { - labelFunction: widgetTypesBreadcumbLabelFunction, + labelFunction: widgetsBundleWidgetsBreadcumbLabelFunction, icon: 'now_widgets' - } as BreadCrumbConfig + } as BreadCrumbConfig, + hideTabs: true }, resolve: { - widgetsBundle: WidgetsBundleResolver - }, - children: [ - { - path: '', - component: WidgetLibraryComponent, - data: { - auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], - title: 'widget.widget-library' - }, - resolve: { - widgetsData: WidgetsTypesDataResolver - } - }, - { - path: ':widgetTypeId', - 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: WidgetEditorDataResolver - } - } - ] + widgetsBundle: WidgetsBundleResolver, + widgets: WidgetsBundleWidgetsResolver + } } ] }, ]; +export const widgetsLibraryRoutes: Routes = [ + { + path: 'widgets-library', + component: RouterTabsComponent, + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + breadcrumb: { + label: 'widget.widget-library', + icon: 'now_widgets' + } + }, + children: [ + { + path: '', + children: [], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + redirectTo: '/resources/widgets-library/widget-types' + } + }, + ...widgetTypesRoutes, + ...widgetsBundlesRoutes + ] + } +]; + const routes: Routes = [ { path: 'widgets-bundles', pathMatch: 'full', - redirectTo: '/resources/widgets-bundles' + redirectTo: '/resources/widgets-library/widgets-bundles' + }, + { + path: 'resources/widgets-bundles', + pathMatch: 'full', + redirectTo: '/resources/widgets-library/widgets-bundles' }, { path: 'widgets-bundles/:widgetsBundleId/widgetTypes', pathMatch: 'full', - redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes', + redirectTo: '/resources/widgets-library/widgets-bundles/:widgetsBundleId' + }, + { + path: 'resources/widgets-bundles/:widgetsBundleId/widgetTypes', + pathMatch: 'full', + redirectTo: '/resources/widgets-library/widgets-bundles/:widgetsBundleId' }, { path: 'widgets-bundles/:widgetsBundleId/widgetTypes/:widgetTypeId', pathMatch: 'full', - redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetTypeId', + redirectTo: '/resources/widgets-library/widget-types/:widgetTypeId' + }, + { + path: 'resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetTypeId', + pathMatch: 'full', + redirectTo: '/resources/widgets-library/widget-types/:widgetTypeId' }, { path: 'widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType', - redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetType', + redirectTo: '/resources/widgets-library/widget-types/:widgetType', }, { path: 'resources/widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType', - redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetType', + redirectTo: '/resources/widgets-library/widget-types/:widgetType', } ]; @@ -208,9 +275,11 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule], providers: [ + WidgetTypesTableConfigResolver, WidgetsBundlesTableConfigResolver, WidgetsBundleResolver, - WidgetsTypesDataResolver, + WidgetsBundleWidgetsResolver, + // WidgetsTypesDataResolver, WidgetEditorDataResolver ] }) diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html deleted file mode 100644 index d6d7fb4869..0000000000 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html +++ /dev/null @@ -1,42 +0,0 @@ - -
- - widgets-bundle.empty -
- - - 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 deleted file mode 100644 index e1b29d81cf..0000000000 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts +++ /dev/null @@ -1,205 +0,0 @@ -/// -/// 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, OnInit, ViewChild } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { PageComponent } from '@shared/components/page.component'; -import { AuthUser } from '@shared/models/user.model'; -import { getCurrentAuthUser } from '@core/auth/auth.selectors'; -import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Authority } from '@shared/models/authority.enum'; -import { NULL_UUID } from '@shared/models/id/has-uuid'; -import { of } from 'rxjs'; -import { Widget, widgetType } from '@app/shared/models/widget.models'; -import { WidgetService } from '@core/http/widget.service'; -import { map, mergeMap } from 'rxjs/operators'; -import { DialogService } from '@core/services/dialog.service'; -import { FooterFabButtons } from '@app/shared/components/footer-fab-buttons.component'; -import { DashboardCallbacks, IDashboardComponent, WidgetsData } from '@home/models/dashboard-component.models'; -import { IAliasController, IStateController, StateParams } from '@app/core/api/widget-api.models'; -import { AliasController } from '@core/api/alias-controller'; -import { MatDialog } from '@angular/material/dialog'; -import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component'; -import { TranslateService } from '@ngx-translate/core'; -import { ImportExportService } from '@home/components/import-export/import-export.service'; -import { UtilsService } from '@core/services/utils.service'; -import { EntityService } from '@core/http/entity.service'; - -@Component({ - selector: 'tb-widget-library', - templateUrl: './widget-library.component.html', - styleUrls: ['./widget-library.component.scss'] -}) -export class WidgetLibraryComponent extends PageComponent implements OnInit { - - authUser: AuthUser; - - isReadOnly: boolean; - - widgetsBundle: WidgetsBundle; - - widgetsData: WidgetsData; - - footerFabButtons: FooterFabButtons = { - fabTogglerName: 'widget.add-widget-type', - fabTogglerIcon: 'add', - buttons: [ - { - name: 'widget-type.create-new-widget-type', - icon: 'insert_drive_file', - onAction: ($event) => { - this.addWidgetType($event); - } - }, - { - name: 'widget-type.import', - icon: 'file_upload', - onAction: ($event) => { - this.importWidgetType($event); - } - } - ] - }; - - dashboardCallbacks: DashboardCallbacks = { - onEditWidget: this.openWidgetType.bind(this), - onWidgetClicked: this.openWidgetType.bind(this), - onExportWidget: this.exportWidgetType.bind(this), - onRemoveWidget: this.removeWidgetType.bind(this) - }; - - aliasController: IAliasController = new AliasController(this.utils, - this.entityService, - this.translate, - () => ({ - getStateParams: (): StateParams => ({}) - } as IStateController), - {}, - {}); - - @ViewChild('dashboard', {static: true}) dashboard: IDashboardComponent; - - constructor(protected store: Store, - private route: ActivatedRoute, - private router: Router, - private widgetService: WidgetService, - private dialogService: DialogService, - private importExport: ImportExportService, - private dialog: MatDialog, - private translate: TranslateService, - private utils: UtilsService, - private entityService: EntityService) { - super(store); - - this.authUser = getCurrentAuthUser(this.store); - this.widgetsBundle = this.route.snapshot.data.widgetsBundle; - this.widgetsData = this.route.snapshot.data.widgetsData; - if (this.authUser.authority === Authority.TENANT_ADMIN) { - this.isReadOnly = !this.widgetsBundle || this.widgetsBundle.tenantId.id === NULL_UUID; - } else { - this.isReadOnly = this.authUser.authority !== Authority.SYS_ADMIN; - } - } - - ngOnInit(): void { - } - - addWidgetType($event: Event): void { - this.openWidgetType($event); - } - - importWidgetType($event: Event): void { - if ($event) { - $event.stopPropagation(); - } - this.importExport.importWidgetType(this.widgetsBundle.alias).subscribe( - (widgetTypeInstance) => { - if (widgetTypeInstance) { - this.reload(); - } - } - ); - } - - private reload() { - const bundleAlias = this.widgetsBundle.alias; - const isSystem = this.widgetsBundle.tenantId.id === NULL_UUID; - this.widgetService.loadBundleLibraryWidgets(bundleAlias, isSystem).subscribe( - (widgets) => { - this.widgetsData = {widgets}; - } - ); - } - - openWidgetType($event: Event, widget?: Widget): void { - if ($event) { - $event.stopPropagation(); - } - if (widget) { - this.router.navigate([widget.typeId.id], {relativeTo: this.route}); - } else { - this.dialog.open(SelectWidgetTypeDialogComponent, { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] - }).afterClosed().subscribe( - (type) => { - if (type) { - this.router.navigate([type], {relativeTo: this.route}); - } - } - ); - } - } - - exportWidgetType($event: Event, widget: Widget): void { - if ($event) { - $event.stopPropagation(); - } - this.importExport.exportWidgetType(widget.typeId.id); - } - - removeWidgetType($event: Event, widget: Widget): void { - if ($event) { - $event.stopPropagation(); - } - this.dialogService.confirm( - this.translate.instant('widget.remove-widget-type-title', {widgetName: widget.config.title}), - this.translate.instant('widget.remove-widget-type-text'), - this.translate.instant('action.no'), - this.translate.instant('action.yes'), - ).pipe( - mergeMap((result) => { - if (result) { - return this.widgetService.deleteWidgetType(widget.typeFullFqn); - } else { - return of(false); - } - }), - map((result) => { - if (result !== false) { - this.reload(); - return true; - } else { - return false; - } - } - )).subscribe(); - } - -} 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 8d08292f76..b9723c15d8 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 @@ -20,21 +20,26 @@ import { SharedModule } from '@shared/shared.module'; import { WidgetsBundleComponent } from '@modules/home/pages/widget/widgets-bundle.component'; import { WidgetLibraryRoutingModule } from '@modules/home/pages/widget/widget-library-routing.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; -import { WidgetLibraryComponent } from './widget-library.component'; import { WidgetEditorComponent } from '@home/pages/widget/widget-editor.component'; 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'; +import { WidgetTypeComponent } from '@home/pages/widget/widget-type.component'; +import { WidgetTypeTabsComponent } from '@home/pages/widget/widget-type-tabs.component'; +import { WidgetsBundleWidgetsComponent } from '@home/pages/widget/widgets-bundle-widgets.component'; +import { WidgetTypeAutocompleteComponent } from '@home/pages/widget/widget-type-autocomplete.component'; @NgModule({ declarations: [ + WidgetTypeComponent, WidgetsBundleComponent, - WidgetLibraryComponent, + // WidgetLibraryComponent, + WidgetTypeAutocompleteComponent, + WidgetsBundleWidgetsComponent, WidgetEditorComponent, SelectWidgetTypeDialogComponent, SaveWidgetTypeAsDialogComponent, - MoveWidgetTypeDialogComponent, + WidgetTypeTabsComponent, WidgetsBundleTabsComponent ], imports: [ diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html new file mode 100644 index 0000000000..4c8087f39b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html @@ -0,0 +1,59 @@ + + + {{ label }} + + + + +
+ {{ widgetType.name }} +
+
+
{{ 'widget.deprecated' | translate }}
+
+
+
+ + + {{ translate.get('widget.no-widgets-matching', {entity: searchText}) | async }} + + +
+ + + + + + +
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.scss b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.scss new file mode 100644 index 0000000000..79a5bc5c80 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.scss @@ -0,0 +1,49 @@ +/** + * 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. + */ +.tb-widget-type-autocomplete { + .mat-mdc-option { + .mdc-list-item__primary-text { + width: 100%; + .tb-widget-type-option-container { + display: flex; + gap: 8px; + align-items: center; + } + .tb-widget-type-option-image-preview { + width: 36px; + max-height: 100%; + object-fit: contain; + border-radius: 6px; + } + .tb-widget-type-option-details { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + .tb-widget-type-option-text { + font-size: 14px; + font-weight: 400; + letter-spacing: 0.2px; + } + .tb-widget-type-option-deprecated { + font-size: 13px; + font-weight: 400; + color: rgba(0, 0, 0, 0.54); + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts new file mode 100644 index 0000000000..1bb5fa0d17 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts @@ -0,0 +1,227 @@ +/// +/// 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 { + AfterViewInit, + Component, + ElementRef, + forwardRef, + Input, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction } from '@shared/models/page/sort-order'; +import { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; +import { emptyPageData } from '@shared/models/page/page-data'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { WidgetTypeInfo } from '@shared/models/widget.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { WidgetService } from '@core/http/widget.service'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-widget-type-autocomplete', + templateUrl: './widget-type-autocomplete.component.html', + styleUrls: ['./widget-type-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetTypeAutocompleteComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None +}) +export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + private dirty = false; + + selectWidgetTypeFormGroup: UntypedFormGroup; + + modelValue: WidgetTypeInfo | null; + + @Input() + label = this.translate.instant('widget.widget'); + + @Input() + placeholder: string; + + @Input() + floatLabel: FloatLabelType = 'auto'; + + @Input() + appearance: MatFormFieldAppearance = 'fill'; + + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + + @Input() + @coerceBoolean() + required: boolean; + + @Input() + disabled: boolean; + + @Input() + excludeWidgetTypeIds: Array; + + @ViewChild('widgetTypeInput', {static: true}) widgetTypeInput: ElementRef; + + filteredWidgetTypes: Observable>; + + searchText = ''; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private widgetService: WidgetService, + private sanitizer: DomSanitizer, + private fb: UntypedFormBuilder) { + this.selectWidgetTypeFormGroup = this.fb.group({ + widgetType: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredWidgetTypes = this.selectWidgetTypeFormGroup.get('widgetType').valueChanges + .pipe( + debounceTime(150), + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = value; + } + this.updateView(modelValue); + }), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + distinctUntilChanged(), + switchMap(name => this.fetchWidgetTypes(name) ), + share() + ); + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectWidgetTypeFormGroup.disable({emitEvent: false}); + } else { + this.selectWidgetTypeFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: WidgetTypeInfo | string | null): void { + this.searchText = ''; + if (value != null) { + if (typeof value === 'string') { + this.widgetService.getWidgetTypeInfoById(value).subscribe( + (widgetType) => { + this.modelValue = widgetType; + this.selectWidgetTypeFormGroup.get('widgetType').patchValue(widgetType, {emitEvent: false}); + } + ); + } else { + this.modelValue = value; + this.selectWidgetTypeFormGroup.get('widgetType').patchValue(value, {emitEvent: false}); + } + } else { + this.modelValue = null; + this.selectWidgetTypeFormGroup.get('widgetType').patchValue('', {emitEvent: false}); + } + this.dirty = true; + } + + updateView(value: WidgetTypeInfo | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayWidgetTypeFn(widgetType?: WidgetTypeInfo): string | undefined { + return widgetType ? widgetType.name : undefined; + } + + getPreviewImage(imageUrl: string | null): SafeUrl | string { + if (isDefinedAndNotNull(imageUrl)) { + return this.sanitizer.bypassSecurityTrustUrl(imageUrl); + } + return '/assets/widget-preview-empty.svg'; + } + + fetchWidgetTypes(searchText?: string): Observable> { + this.searchText = searchText; + const pageLink = new PageLink(10, 0, searchText, { + property: 'name', + direction: Direction.ASC + }); + return this.getWidgetTypes(pageLink); + } + + getWidgetTypes(pageLink: PageLink, + result: Array = []): Observable> { + return this.widgetService.getWidgetTypes(pageLink, true).pipe( + catchError(() => of(emptyPageData())), + switchMap((data) => { + if (this.excludeWidgetTypeIds?.length) { + const filtered = data.data.filter(w => !this.excludeWidgetTypeIds.includes(w.id.id)); + result = result.concat(filtered); + } else { + result = data.data; + } + if (result.length >= pageLink.pageSize || !this.excludeWidgetTypeIds?.length || !data.hasNext) { + return of(result); + } else { + return this.getWidgetTypes(pageLink.nextPageLink(), result); + } + }) + ); + } + + onFocus() { + if (this.dirty) { + this.selectWidgetTypeFormGroup.get('widgetType').updateValueAndValidity({onlySelf: true}); + this.dirty = false; + } + } + + clear() { + this.selectWidgetTypeFormGroup.get('widgetType').patchValue(''); + setTimeout(() => { + this.widgetTypeInput.nativeElement.blur(); + this.widgetTypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.html new file mode 100644 index 0000000000..b6a6ecc32b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.html @@ -0,0 +1,23 @@ + + + + diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.ts new file mode 100644 index 0000000000..6543777295 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { WidgetTypeDetails } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-widget-type-tabs', + templateUrl: './widget-type-tabs.component.html', + styleUrls: [] +}) +export class WidgetTypeTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + isTenantWidgetType() { + return this.entity && this.entity.tenantId.id !== NULL_UUID; + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html new file mode 100644 index 0000000000..cacd187b5b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html @@ -0,0 +1,66 @@ + +
+ + + +
+
+
+
+ + widget.title + + + {{ 'widget.title-required' | translate }} + + + {{ 'widget.title-max-length' | translate }} + + + + + + widget.description + + {{descriptionInput.value?.length || 0}}/255 + + + {{ 'widget.deprecated' | translate }} + +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts new file mode 100644 index 0000000000..bdbfebbd8f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts @@ -0,0 +1,68 @@ +/// +/// 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { WidgetTypeDetails } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-widget-type', + templateUrl: './widget-type.component.html', + styleUrls: [] +}) +export class WidgetTypeComponent extends EntityComponent { + + constructor(protected store: Store, + @Inject('entity') protected entityValue: WidgetTypeDetails, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: UntypedFormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildForm(entity: WidgetTypeDetails): UntypedFormGroup { + return this.fb.group( + { + name: [entity ? entity.name : '', [Validators.required, Validators.maxLength(255)]], + image: [entity ? entity.image : ''], + description: [entity ? entity.description : '', Validators.maxLength(255)], + deprecated: [entity ? entity.deprecated : false] + } + ); + } + + updateForm(entity: WidgetTypeDetails) { + this.entityForm.patchValue({ + name: entity.name, + image: entity.image, + description: entity.description, + deprecated: entity.deprecated + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts new file mode 100644 index 0000000000..c4c1e84417 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts @@ -0,0 +1,232 @@ +/// +/// 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 { Injectable } from '@angular/core'; + +import { Resolve, Router } from '@angular/router'; +import { + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { WidgetService } from '@app/core/http/widget.service'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthState, getCurrentAuthUser } from '@app/core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { DialogService } from '@core/services/dialog.service'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { Direction } from '@shared/models/page/sort-order'; +import { + BaseWidgetType, + WidgetTypeDetails, + WidgetTypeInfo, + widgetType as WidgetDataType, + widgetTypesData +} from '@shared/models/widget.models'; +import { WidgetTypeComponent } from '@home/pages/widget/widget-type.component'; +import { WidgetTypeTabsComponent } from '@home/pages/widget/widget-type-tabs.component'; +import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; + +@Injectable() +export class WidgetTypesTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = + new EntityTableConfig(); + + constructor(private store: Store, + private dialog: MatDialog, + private widgetsService: WidgetService, + private translate: TranslateService, + private importExport: ImportExportService, + private datePipe: DatePipe, + private router: Router) { + + this.config.entityType = EntityType.WIDGETS_BUNDLE; + this.config.entityComponent = WidgetTypeComponent; + this.config.entityTabsComponent = WidgetTypeTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.WIDGET_TYPE); + this.config.entityResources = entityTypeResources.get(EntityType.WIDGET_TYPE); + this.config.defaultSortOrder = {property: 'name', direction: Direction.ASC}; + + this.config.rowPointer = true; + + this.config.entityTitle = (widgetType) => widgetType ? + widgetType.name : ''; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'widget.title', '100%'), + new EntityTableColumn('widgetType', 'widget.type', '150px', entity => + entity?.widgetType ? this.translate.instant(widgetTypesData.get(entity.widgetType).name) : '', undefined, false), + new EntityTableColumn('tenantId', 'widget.system', '60px', + entity => checkBoxCell(entity.tenantId.id === NULL_UUID)), + new EntityTableColumn('deprecated', 'widget.deprecated', '60px', + entity => checkBoxCell(entity.deprecated)) + ); + + this.config.addActionDescriptors.push( + { + name: this.translate.instant('widget-type.create-new-widget-type'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.addWidgetType($event) + }, + { + name: this.translate.instant('widget-type.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importWidgetType($event) + } + ); + + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('widget-type.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportWidgetType($event, entity) + }, + { + name: this.translate.instant('widget.widget-type-details'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.config.toggleEntityDetails($event, entity) + } + ); + + this.config.groupActionDescriptors.push( + { + name: this.translate.instant('widget-type.export-widget-types'), + icon: 'file_download', + isEnabled: true, + onAction: ($event, entities) => this.exportWidgetTypes($event, entities) + } + ); + + this.config.deleteEntityTitle = widgetType => this.translate.instant('widget.delete-widget-type-title', + { widgetTypeName: widgetType.name }); + this.config.deleteEntityContent = () => this.translate.instant('widget.delete-widget-type-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('widget.delete-widget-types-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('widget.delete-widget-types-text'); + + + this.config.loadEntity = id => this.widgetsService.getWidgetTypeById(id.id); + this.config.saveEntity = widgetType => this.widgetsService.saveWidgetType(widgetType as WidgetTypeDetails); + this.config.deleteEntity = id => this.widgetsService.deleteWidgetType(id.id); + this.config.onEntityAction = action => this.onWidgetTypeAction(action); + + this.config.handleRowClick = ($event, widgetType) => { + if (this.config.isDetailsOpen()) { + this.config.toggleEntityDetails($event, widgetType); + } else { + this.openWidgetEditor($event, widgetType); + } + return true; + }; + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('widget.widget-types'); + const authUser = getCurrentAuthUser(this.store); + this.config.deleteEnabled = (widgetType) => this.isWidgetTypeEditable(widgetType, authUser.authority); + this.config.entitySelectionEnabled = (widgetType) => this.isWidgetTypeEditable(widgetType, authUser.authority); + this.config.detailsReadonly = (widgetType) => !this.isWidgetTypeEditable(widgetType, authUser.authority); + const authState = getCurrentAuthState(this.store); + this.config.entitiesFetchFunction = pageLink => this.widgetsService.getWidgetTypes(pageLink); + return this.config; + } + + isWidgetTypeEditable(widgetType: BaseWidgetType, authority: Authority): boolean { + if (authority === Authority.TENANT_ADMIN) { + return widgetType && widgetType.tenantId && widgetType.tenantId.id !== NULL_UUID; + } else { + return authority === Authority.SYS_ADMIN; + } + } + + addWidgetType($event: Event): void { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(SelectWidgetTypeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (type) => { + if (type) { + this.router.navigateByUrl(`resources/widgets-library/widget-types/${type}`); + } + } + ); + } + + importWidgetType($event: Event) { + this.importExport.importWidgetType().subscribe( + (widgetType) => { + if (widgetType) { + this.config.updateData(); + } + } + ); + } + + openWidgetEditor($event: Event, widgetType: BaseWidgetType) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`resources/widgets-library/widget-types/${widgetType.id.id}`); + } + + exportWidgetType($event: Event, widgetType: BaseWidgetType) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportWidgetType(widgetType.id.id); + } + + exportWidgetTypes($event: Event, widgetTypes: BaseWidgetType[]) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportWidgetTypes(widgetTypes.map(w => w.id.id)).subscribe( + () => { + this.config.getTable().clearSelection(); + } + ); + } + + onWidgetTypeAction(action: EntityAction): boolean { + switch (action.action) { + case 'edit': + this.openWidgetEditor(action.event, action.entity); + return true; + case 'export': + this.exportWidgetType(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html new file mode 100644 index 0000000000..2d95fd1fdb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html @@ -0,0 +1,134 @@ + + + + +
+ + {{ widgetsBundle.title }}: {{ 'widget.widgets' | translate }} + + +
+
+
+ + +
+ +
+
+
+ {{ widget.name }} +
+
{{ widget.name }}
+
{{ 'widget.deprecated' | translate }}
+
+
+ + + + +
+
+
+ add +
+
+
+ add +
widget.add
+
+
+
+
+
widget.no-widgets
+
+
+ + +
+ +
+
+
+ + + + + +
+ diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.scss b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.scss new file mode 100644 index 0000000000..3c971105ff --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.scss @@ -0,0 +1,184 @@ +/** + * 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 "../../../../../scss/constants"; + +:host { + width: 100%; + height: 100%; + padding: 8px; +} + +:host ::ng-deep { + .tb-bundle-widgets-card { + .mat-mdc-card-header-text { + flex: 1; + } + } +} + +.tb-bundle-widgets-card { + padding: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + + @media #{$mat-md} { + width: 80%; + } + + @media #{$mat-gt-md} { + width: 60%; + } + + .title-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + .mat-headline-5 { + margin: 0; + } + } + + .mat-mdc-card-content { + flex: 1; + padding: 16px; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 8px; + } + + .tb-add-widget-button { + min-height: 56px; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: #FFFFFF; + padding: 8px; + border: 2px dashed rgba(0, 0, 0, 0.08); + border-radius: 10px; + } + + .tb-add-widget-button-panel { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + .tb-add-widget-button-with-text { + cursor: pointer; + display: flex; + padding: 24px 40px 24px 24px; + justify-content: center; + align-items: center; + gap: 8px; + border: 2px dashed rgba(0, 0, 0, 0.12); + border-radius: 10px; + .tb-add-widget-button-text { + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: $tb-primary-color; + } + } + } + + .tb-add-bundle-widget-form { + display: flex; + padding: 16px; + flex-direction: column; + gap: 12px; + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px 0 rgba(0, 0, 0, 0.04); + } + + .tb-add-bundle-widget-actions { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 16px; + } + + .mat-mdc-card-actions { + justify-content: end; + gap: 8px; + } +} + +.tb-bundle-widgets-container { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: auto; + padding-top: 8px; + padding-bottom: 8px; +} + +.tb-bundle-widget-row { + display: flex; + flex-direction: row; + align-items: center; + padding: 6px 12px 6px 6px; + background: #fff; + gap: 12px; + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px 0 rgba(0, 0, 0, 0.04); + .tb-bundle-widget-row-details { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + .tb-bundle-widget-image-preview { + width: 64px; + max-height: 100%; + object-fit: contain; + border-radius: 6px; + } + .tb-bundle-widget-details { + padding: 18px 0; + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + .tb-bundle-widget-title { + font-size: 14px; + font-weight: 400; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + } + .tb-bundle-widget-deprecated { + font-size: 13px; + font-weight: 400; + color: rgba(0, 0, 0, 0.54); + } + } + } + .mat-icon { + color: rgba(0, 0, 0, 0.54); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts new file mode 100644 index 0000000000..0d21c6e556 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts @@ -0,0 +1,178 @@ +/// +/// 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 { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { AuthUser } from '@shared/models/user.model'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActivatedRoute, Router } from '@angular/router'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { WidgetTypeInfo } from '@shared/models/widget.models'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { WidgetService } from '@core/http/widget.service'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { isDefinedAndNotNull } from '@core/utils'; +import { FormControl, Validators } from '@angular/forms'; + +@Component({ + selector: 'tb-widgets-bundle-widget', + templateUrl: './widgets-bundle-widgets.component.html', + styleUrls: ['./widgets-bundle-widgets.component.scss'] +}) +export class WidgetsBundleWidgetsComponent extends PageComponent implements OnInit { + + authUser: AuthUser; + + isReadOnly: boolean; + editMode = false; + addMode = false; + isDirty = false; + + widgetsBundle: WidgetsBundle; + widgets: Array; + excludeWidgetTypeIds: Array; + + addWidgetFormControl = new FormControl(null, [Validators.required]); + + constructor(protected store: Store, + private router: Router, + private route: ActivatedRoute, + private widgetsService: WidgetService, + private importExport: ImportExportService, + private sanitizer: DomSanitizer, + private cd: ChangeDetectorRef) { + super(store); + this.authUser = getCurrentAuthUser(this.store); + this.widgetsBundle = this.route.snapshot.data.widgetsBundle; + this.widgets = [...this.route.snapshot.data.widgets]; + if (this.authUser.authority === Authority.TENANT_ADMIN) { + this.isReadOnly = !this.widgetsBundle || this.widgetsBundle.tenantId.id === NULL_UUID; + } else { + this.isReadOnly = this.authUser.authority !== Authority.SYS_ADMIN; + } + if (!this.isReadOnly && !this.widgets.length) { + this.editMode = true; + } + this.addWidgetFormControl.valueChanges.subscribe((newWidget) => { + if (newWidget) { + this.addWidget(newWidget); + } + }); + } + + ngOnInit(): void { + } + + getPreviewImage(imageUrl: string | null): SafeUrl | string { + if (isDefinedAndNotNull(imageUrl)) { + return this.sanitizer.bypassSecurityTrustUrl(imageUrl); + } + return '/assets/widget-preview-empty.svg'; + } + + trackByWidget(index: number, widget: WidgetTypeInfo): any { + return widget; + } + + widgetDrop(event: CdkDragDrop) { + const widget = this.widgets[event.previousIndex]; + this.widgets.splice(event.previousIndex, 1); + this.widgets.splice(event.currentIndex, 0, widget); + this.isDirty = true; + } + + addWidgetMode() { + this.addWidgetFormControl.patchValue(null, {emitEvent: false}); + this.excludeWidgetTypeIds = this.widgets.map(w => w.id.id); + this.addMode = true; + } + + cancelAddWidgetMode() { + this.addMode = false; + } + + private addWidget(newWidget: WidgetTypeInfo) { + this.widgets.push(newWidget); + this.isDirty = true; + this.addMode = false; + } + + openWidgetEditor($event: Event, widgetType: WidgetTypeInfo) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`resources/widgets-library/widget-types/${widgetType.id.id}`); + } + + exportWidgetType($event: Event, widgetType: WidgetTypeInfo) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportWidgetType(widgetType.id.id); + } + + removeWidgetType($event: Event, widgetType: WidgetTypeInfo) { + if ($event) { + $event.stopPropagation(); + } + const index = this.widgets.indexOf(widgetType); + this.widgets.splice(index, 1); + this.isDirty = true; + } + + goBack() { + this.router.navigate(['..'], { relativeTo: this.route }); + } + + exportWidgetsBundle() { + this.importExport.exportWidgetsBundle(this.widgetsBundle.id.id); + } + + edit() { + this.editMode = true; + } + + cancel() { + if (this.isDirty) { + this.widgetsService.getBundleWidgetTypeInfos(this.widgetsBundle.id.id).subscribe( + (widgets) => { + this.widgets = [...widgets]; + this.isDirty = false; + this.addMode = false; + this.editMode = !this.widgets.length; + this.cd.markForCheck(); + } + ); + } else { + this.addMode = false; + this.editMode = !this.widgets.length; + } + } + + save() { + const widgetTypeIds = this.widgets.map(w => w.id.id); + this.widgetsService.updateWidgetsBundleWidgetTypes(this.widgetsBundle.id.id, widgetTypeIds).subscribe(() => { + this.isDirty = false; + this.editMode = false; + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts index 6fd2804ae3..932c19f668 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts @@ -125,6 +125,10 @@ export class WidgetsBundlesTableConfigResolver implements Resolve { + this.openWidgetsBundle(null, widgetsBundle); + }; } resolve(): EntityTableConfig { diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 8648973c72..12469d6b90 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -149,7 +149,8 @@ export const HelpLinks = { lwm2mResourceLibrary: helpBaseUrl + '/docs/reference/lwm2m-api', dashboards: helpBaseUrl + '/docs/user-guide/ui/dashboards', otaUpdates: helpBaseUrl + '/docs/user-guide/ota-updates', - widgetsBundles: helpBaseUrl + '/docs/user-guide/ui/widget-library#bundles', + widgetTypes: helpBaseUrl + '/docs/user-guide/ui/widget-library/#widget-types', + widgetsBundles: helpBaseUrl + '/docs/user-guide/ui/widget-library/#widgets-library-bundles', widgetsConfig: helpBaseUrl + '/docs/user-guide/ui/dashboards#widget-configuration', widgetsConfigTimeseries: helpBaseUrl + '/docs/user-guide/ui/dashboards#timeseries', widgetsConfigLatest: helpBaseUrl + '/docs/user-guide/ui/dashboards#latest', diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 1a5f187459..d7858965d7 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -268,6 +268,19 @@ export const entityTypeTranslations = new Map { tenantId: TenantId; - bundleAlias: string; fqn: string; name: string; deprecated: boolean; @@ -235,7 +234,7 @@ export interface WidgetTypeInfo extends BaseWidgetType { widgetType: widgetType; } -export interface WidgetTypeDetails extends WidgetType { +export interface WidgetTypeDetails extends WidgetType, ExportableEntity { image: string; description: 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 e982f2440f..61778fded2 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -733,7 +733,6 @@ "no-attributes-text": "No attributes found", "no-telemetry-text": "No telemetry found", "copy-key": "Copy key", - "copy-value": "Copy value", "add-telemetry": "Add telemetry", "copy-value": "Copy value", "delete-timeseries": { @@ -2148,6 +2147,9 @@ "type-widgets-bundle": "Widgets bundle", "type-widgets-bundles": "Widgets bundles", "list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }", + "type-widget-type": "Widget type", + "type-widget-types": "Widget types", + "list-of-widget-types": "{ count, plural, =1 {One widget type} other {List of # widget types} }", "search": "Search entities", "selected-entities": "{ count, plural, =1 {1 entity} other {# entities} } selected", "entity-name": "Entity name", @@ -4528,12 +4530,19 @@ "widget-bundle": "Widgets Bundle", "all-bundles": "All bundles", "select-widgets-bundle": "Select widgets bundle", + "widgets": "Widgets", + "widget": "Widget", + "select-widget": "Select widget", + "no-widgets-matching": "No widgets matching '{{entity}}' were found.", + "no-widgets": "No widgets yet", "management": "Widget management", "editor": "Widget Editor", "widget-type-not-found": "Problem loading widget configuration.
Probably associated\n widget type was removed.", "widget-type-load-error": "Widget wasn't loaded due to the following errors:", "remove": "Remove widget", + "delete": "Delete widget", "edit": "Edit widget", + "edit-widget-type": "Edit widget type", "remove-widget-title": "Are you sure you want to remove the widget '{{widgetTitle}}'?", "remove-widget-text": "After the confirmation the widget and all related data will become unrecoverable.", "timeseries": "Time series", @@ -4551,13 +4560,14 @@ "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", + "save-widget-type-as-text": "Please enter new widget title", "toggle-fullscreen": "Toggle fullscreen", "run": "Run widget", - "title": "Widget title", + "widget-title": "Widget title", + "title": "Title", "title-required": "Widget title is required.", + "title-max-length": "Title should be less than 256", + "system": "System", "type": "Widget type", "resources": "Resources", "resource-url": "JavaScript/CSS URL", @@ -4588,10 +4598,21 @@ "remove-widget-type-title": "Are you sure you want to remove the widget type '{{widgetName}}'?", "remove-widget-type-text": "After the confirmation the widget type and all related data will become unrecoverable.", "remove-widget-type": "Remove widget type", + "widget-types": "Widget Types", + "delete-widget-type-title": "Are you sure you want to delete the widget type '{{widgetTypeName}}'?", + "delete-widget-type-text": "After the confirmation the widget and all related data will become unrecoverable.", + "delete-widget-types-title": "Are you sure you want to delete { count, plural, =1 {1 widget type} other {# widget types} }?", + "delete-widget-types-text": "Be careful, after the confirmation all selected widget types will be removed and all related data will become unrecoverable.", + "delete-widget-type": "Delete widget type", "add-widget-type": "Add new widget type", "widget-type-load-failed-error": "Failed to load widget type!", "widget-template-load-failed-error": "Failed to load widget template!", + "details": "Details", + "widget-type-details": "Widget type details", "add": "Add Widget", + "no-widget-types-text": "No widget types found", + "search-widget-types": "Search widget types", + "selected-widget-types": "{ count, plural, =1 {1 widget type} other {# widget types} } selected", "undo": "Undo widget changes", "export": "Export widget", "no-data": "No data to display on widget", @@ -4662,6 +4683,7 @@ "widgets-bundle": { "current": "Current bundle", "widgets-bundles": "Widgets Bundles", + "widgets-bundle-widgets": "Widgets Bundle Widgets", "add": "Add Widgets Bundle", "delete": "Delete widgets bundle", "title": "Title", @@ -4684,6 +4706,7 @@ "system": "System", "import": "Import widgets bundle", "export": "Export widgets bundle", + "export-widgets-bundle-widgets-prompt": "Include bundle widget types in exported data (otherwise only referenced widget type FQNs will be exported)", "export-failed-error": "Unable to export widgets bundle: {{error}}", "create-new-widgets-bundle": "Create new widgets bundle", "widgets-bundle-file": "Widgets bundle file", @@ -4790,6 +4813,7 @@ "widget-type": { "import": "Import widget type", "export": "Export widget type", + "export-widget-types": "Export widget types", "export-failed-error": "Unable to export widget type: {{error}}", "create-new-widget-type": "Create new widget type", "widget-type-file": "Widget type file",