109 changed files with 4929 additions and 235 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,77 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.asset; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.data.util.Pair; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.StringUtils; |
|||
import org.thingsboard.server.common.data.asset.Asset; |
|||
import org.thingsboard.server.common.data.id.AssetId; |
|||
import org.thingsboard.server.common.data.id.AssetProfileId; |
|||
import org.thingsboard.server.common.data.id.CustomerId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; |
|||
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; |
|||
|
|||
import java.util.UUID; |
|||
|
|||
@Slf4j |
|||
public abstract class BaseAssetProcessor extends BaseEdgeProcessor { |
|||
|
|||
protected Pair<Boolean, Boolean> saveOrUpdateAsset(TenantId tenantId, AssetId assetId, AssetUpdateMsg assetUpdateMsg, CustomerId customerId) { |
|||
boolean created = false; |
|||
boolean assetNameUpdated = false; |
|||
assetCreationLock.lock(); |
|||
try { |
|||
Asset asset = assetService.findAssetById(tenantId, assetId); |
|||
String assetName = assetUpdateMsg.getName(); |
|||
if (asset == null) { |
|||
created = true; |
|||
asset = new Asset(); |
|||
asset.setTenantId(tenantId); |
|||
asset.setCreatedTime(Uuids.unixTimestamp(assetId.getId())); |
|||
} |
|||
Asset assetByName = assetService.findAssetByTenantIdAndName(tenantId, assetName); |
|||
if (assetByName != null && !assetByName.getId().equals(assetId)) { |
|||
assetName = assetName + "_" + StringUtils.randomAlphanumeric(15); |
|||
log.warn("Asset with name {} already exists. Renaming asset name to {}", |
|||
assetUpdateMsg.getName(), assetName); |
|||
assetNameUpdated = true; |
|||
} |
|||
asset.setName(assetName); |
|||
asset.setType(assetUpdateMsg.getType()); |
|||
asset.setLabel(assetUpdateMsg.hasLabel() ? assetUpdateMsg.getLabel() : null); |
|||
asset.setAdditionalInfo(assetUpdateMsg.hasAdditionalInfo() |
|||
? JacksonUtil.toJsonNode(assetUpdateMsg.getAdditionalInfo()) : null); |
|||
|
|||
UUID assetProfileUUID = safeGetUUID(assetUpdateMsg.getAssetProfileIdMSB(), assetUpdateMsg.getAssetProfileIdLSB()); |
|||
asset.setAssetProfileId(assetProfileUUID != null ? new AssetProfileId(assetProfileUUID) : null); |
|||
|
|||
asset.setCustomerId(customerId); |
|||
|
|||
assetValidator.validate(asset, Asset::getTenantId); |
|||
if (created) { |
|||
asset.setId(assetId); |
|||
} |
|||
assetService.saveAsset(asset, false); |
|||
} finally { |
|||
assetCreationLock.unlock(); |
|||
} |
|||
return Pair.of(created, assetNameUpdated); |
|||
} |
|||
} |
|||
@ -0,0 +1,69 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.asset; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.server.common.data.asset.AssetProfile; |
|||
import org.thingsboard.server.common.data.id.AssetProfileId; |
|||
import org.thingsboard.server.common.data.id.DashboardId; |
|||
import org.thingsboard.server.common.data.id.RuleChainId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; |
|||
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.UUID; |
|||
|
|||
@Slf4j |
|||
public class BaseAssetProfileProcessor extends BaseEdgeProcessor { |
|||
|
|||
protected boolean saveOrUpdateAssetProfile(TenantId tenantId, AssetProfileId assetProfileId, AssetProfileUpdateMsg assetProfileUpdateMsg) { |
|||
boolean created = false; |
|||
assetCreationLock.lock(); |
|||
try { |
|||
AssetProfile assetProfile = assetProfileService.findAssetProfileById(tenantId, assetProfileId); |
|||
String assetProfileName = assetProfileUpdateMsg.getName(); |
|||
if (assetProfile == null) { |
|||
created = true; |
|||
assetProfile = new AssetProfile(); |
|||
assetProfile.setTenantId(tenantId); |
|||
assetProfile.setCreatedTime(Uuids.unixTimestamp(assetProfileId.getId())); |
|||
} |
|||
assetProfile.setName(assetProfileName); |
|||
assetProfile.setDefault(assetProfileUpdateMsg.getDefault()); |
|||
assetProfile.setDefaultQueueName(assetProfileUpdateMsg.hasDefaultQueueName() ? assetProfileUpdateMsg.getDefaultQueueName() : null); |
|||
assetProfile.setDescription(assetProfileUpdateMsg.hasDescription() ? assetProfileUpdateMsg.getDescription() : null); |
|||
assetProfile.setImage(assetProfileUpdateMsg.hasImage() |
|||
? new String(assetProfileUpdateMsg.getImage().toByteArray(), StandardCharsets.UTF_8) : null); |
|||
|
|||
UUID defaultRuleChainUUID = safeGetUUID(assetProfileUpdateMsg.getDefaultRuleChainIdMSB(), assetProfileUpdateMsg.getDefaultRuleChainIdLSB()); |
|||
assetProfile.setDefaultRuleChainId(defaultRuleChainUUID != null ? new RuleChainId(defaultRuleChainUUID) : null); |
|||
|
|||
UUID defaultDashboardUUID = safeGetUUID(assetProfileUpdateMsg.getDefaultDashboardIdMSB(), assetProfileUpdateMsg.getDefaultDashboardIdLSB()); |
|||
assetProfile.setDefaultDashboardId(defaultDashboardUUID != null ? new DashboardId(defaultDashboardUUID) : null); |
|||
|
|||
assetProfileValidator.validate(assetProfile, AssetProfile::getTenantId); |
|||
if (created) { |
|||
assetProfile.setId(assetProfileId); |
|||
} |
|||
assetProfileService.saveAssetProfile(assetProfile, false); |
|||
} finally { |
|||
assetCreationLock.unlock(); |
|||
} |
|||
return created; |
|||
} |
|||
} |
|||
@ -0,0 +1,77 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.dashboard; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.Dashboard; |
|||
import org.thingsboard.server.common.data.ShortCustomerInfo; |
|||
import org.thingsboard.server.common.data.id.CustomerId; |
|||
import org.thingsboard.server.common.data.id.DashboardId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; |
|||
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; |
|||
|
|||
import java.util.Set; |
|||
|
|||
@Slf4j |
|||
public abstract class BaseDashboardProcessor extends BaseEdgeProcessor { |
|||
|
|||
protected boolean saveOrUpdateDashboard(TenantId tenantId, DashboardId dashboardId, DashboardUpdateMsg dashboardUpdateMsg, CustomerId customerId) { |
|||
boolean created = false; |
|||
Dashboard dashboard = dashboardService.findDashboardById(tenantId, dashboardId); |
|||
if (dashboard == null) { |
|||
created = true; |
|||
dashboard = new Dashboard(); |
|||
dashboard.setTenantId(tenantId); |
|||
dashboard.setCreatedTime(Uuids.unixTimestamp(dashboardId.getId())); |
|||
} |
|||
dashboard.setTitle(dashboardUpdateMsg.getTitle()); |
|||
dashboard.setConfiguration(JacksonUtil.toJsonNode(dashboardUpdateMsg.getConfiguration())); |
|||
Set<ShortCustomerInfo> assignedCustomers = null; |
|||
if (dashboardUpdateMsg.hasAssignedCustomers()) { |
|||
assignedCustomers = JacksonUtil.fromString(dashboardUpdateMsg.getAssignedCustomers(), new TypeReference<>() { |
|||
}); |
|||
dashboard.setAssignedCustomers(assignedCustomers); |
|||
} |
|||
|
|||
dashboardValidator.validate(dashboard, Dashboard::getTenantId); |
|||
if (created) { |
|||
dashboard.setId(dashboardId); |
|||
} |
|||
Dashboard savedDashboard = dashboardService.saveDashboard(dashboard, false); |
|||
if (assignedCustomers != null && !assignedCustomers.isEmpty()) { |
|||
for (ShortCustomerInfo assignedCustomer : assignedCustomers) { |
|||
if (assignedCustomer.getCustomerId().equals(customerId)) { |
|||
dashboardService.assignDashboardToCustomer(tenantId, dashboardId, assignedCustomer.getCustomerId()); |
|||
} |
|||
} |
|||
} else { |
|||
unassignCustomersFromDashboard(tenantId, savedDashboard); |
|||
} |
|||
return created; |
|||
} |
|||
|
|||
private void unassignCustomersFromDashboard(TenantId tenantId, Dashboard dashboard) { |
|||
if (dashboard.getAssignedCustomers() != null && !dashboard.getAssignedCustomers().isEmpty()) { |
|||
for (ShortCustomerInfo assignedCustomer : dashboard.getAssignedCustomers()) { |
|||
dashboardService.unassignDashboardFromCustomer(tenantId, dashboard.getId(), assignedCustomer.getCustomerId()); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.device; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.thingsboard.server.common.data.DeviceProfile; |
|||
import org.thingsboard.server.common.data.DeviceProfileProvisionType; |
|||
import org.thingsboard.server.common.data.DeviceProfileType; |
|||
import org.thingsboard.server.common.data.DeviceTransportType; |
|||
import org.thingsboard.server.common.data.StringUtils; |
|||
import org.thingsboard.server.common.data.device.profile.DeviceProfileData; |
|||
import org.thingsboard.server.common.data.id.DashboardId; |
|||
import org.thingsboard.server.common.data.id.DeviceProfileId; |
|||
import org.thingsboard.server.common.data.id.OtaPackageId; |
|||
import org.thingsboard.server.common.data.id.RuleChainId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; |
|||
import org.thingsboard.server.queue.util.DataDecodingEncodingService; |
|||
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.Optional; |
|||
import java.util.UUID; |
|||
|
|||
@Slf4j |
|||
public class BaseDeviceProfileProcessor extends BaseEdgeProcessor { |
|||
|
|||
@Autowired |
|||
private DataDecodingEncodingService dataDecodingEncodingService; |
|||
|
|||
protected boolean saveOrUpdateDeviceProfile(TenantId tenantId, DeviceProfileId deviceProfileId, DeviceProfileUpdateMsg deviceProfileUpdateMsg) { |
|||
boolean created = false; |
|||
deviceCreationLock.lock(); |
|||
try { |
|||
DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(tenantId, deviceProfileId); |
|||
if (deviceProfile == null) { |
|||
created = true; |
|||
deviceProfile = new DeviceProfile(); |
|||
deviceProfile.setTenantId(tenantId); |
|||
deviceProfile.setCreatedTime(Uuids.unixTimestamp(deviceProfileId.getId())); |
|||
} |
|||
deviceProfile.setName(deviceProfileUpdateMsg.getName()); |
|||
deviceProfile.setDescription(deviceProfileUpdateMsg.hasDescription() ? deviceProfileUpdateMsg.getDescription() : null); |
|||
deviceProfile.setDefault(deviceProfileUpdateMsg.getDefault()); |
|||
deviceProfile.setType(DeviceProfileType.valueOf(deviceProfileUpdateMsg.getType())); |
|||
deviceProfile.setTransportType(deviceProfileUpdateMsg.hasTransportType() |
|||
? DeviceTransportType.valueOf(deviceProfileUpdateMsg.getTransportType()) : DeviceTransportType.DEFAULT); |
|||
deviceProfile.setImage(deviceProfileUpdateMsg.hasImage() |
|||
? new String(deviceProfileUpdateMsg.getImage().toByteArray(), StandardCharsets.UTF_8) : null); |
|||
deviceProfile.setProvisionType(deviceProfileUpdateMsg.hasProvisionType() |
|||
? DeviceProfileProvisionType.valueOf(deviceProfileUpdateMsg.getProvisionType()) : DeviceProfileProvisionType.DISABLED); |
|||
deviceProfile.setProvisionDeviceKey(deviceProfileUpdateMsg.hasProvisionDeviceKey() |
|||
? deviceProfileUpdateMsg.getProvisionDeviceKey() : null); |
|||
deviceProfile.setDefaultQueueName(deviceProfileUpdateMsg.getDefaultQueueName()); |
|||
|
|||
Optional<DeviceProfileData> profileDataOpt = |
|||
dataDecodingEncodingService.decode(deviceProfileUpdateMsg.getProfileDataBytes().toByteArray()); |
|||
deviceProfile.setProfileData(profileDataOpt.orElse(null)); |
|||
|
|||
UUID defaultRuleChainUUID = safeGetUUID(deviceProfileUpdateMsg.getDefaultRuleChainIdMSB(), deviceProfileUpdateMsg.getDefaultRuleChainIdLSB()); |
|||
deviceProfile.setDefaultRuleChainId(defaultRuleChainUUID != null ? new RuleChainId(defaultRuleChainUUID) : null); |
|||
|
|||
UUID defaultDashboardUUID = safeGetUUID(deviceProfileUpdateMsg.getDefaultDashboardIdMSB(), deviceProfileUpdateMsg.getDefaultDashboardIdLSB()); |
|||
deviceProfile.setDefaultDashboardId(defaultDashboardUUID != null ? new DashboardId(defaultDashboardUUID) : null); |
|||
|
|||
String defaultQueueName = StringUtils.isNotBlank(deviceProfileUpdateMsg.getDefaultQueueName()) |
|||
? deviceProfileUpdateMsg.getDefaultQueueName() : null; |
|||
deviceProfile.setDefaultQueueName(defaultQueueName); |
|||
|
|||
UUID firmwareUUID = safeGetUUID(deviceProfileUpdateMsg.getFirmwareIdMSB(), deviceProfileUpdateMsg.getFirmwareIdLSB()); |
|||
deviceProfile.setFirmwareId(firmwareUUID != null ? new OtaPackageId(firmwareUUID) : null); |
|||
|
|||
UUID softwareUUID = safeGetUUID(deviceProfileUpdateMsg.getSoftwareIdMSB(), deviceProfileUpdateMsg.getSoftwareIdLSB()); |
|||
deviceProfile.setSoftwareId(softwareUUID != null ? new OtaPackageId(softwareUUID) : null); |
|||
|
|||
|
|||
deviceProfileValidator.validate(deviceProfile, DeviceProfile::getTenantId); |
|||
if (created) { |
|||
deviceProfile.setId(deviceProfileId); |
|||
} |
|||
deviceProfileService.saveDeviceProfile(deviceProfile, false); |
|||
} finally { |
|||
deviceCreationLock.unlock(); |
|||
} |
|||
return created; |
|||
} |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.entityview; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.data.util.Pair; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.EntityView; |
|||
import org.thingsboard.server.common.data.StringUtils; |
|||
import org.thingsboard.server.common.data.id.AssetId; |
|||
import org.thingsboard.server.common.data.id.CustomerId; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.EntityViewId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.gen.edge.v1.EdgeEntityType; |
|||
import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; |
|||
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; |
|||
|
|||
import java.util.UUID; |
|||
|
|||
@Slf4j |
|||
public abstract class BaseEntityViewProcessor extends BaseEdgeProcessor { |
|||
|
|||
protected Pair<Boolean, Boolean> saveOrUpdateEntityView(TenantId tenantId, EntityViewId entityViewId, EntityViewUpdateMsg entityViewUpdateMsg, CustomerId customerId) { |
|||
boolean created = false; |
|||
boolean entityViewNameUpdated = false; |
|||
EntityView entityView = entityViewService.findEntityViewById(tenantId, entityViewId); |
|||
String entityViewName = entityViewUpdateMsg.getName(); |
|||
if (entityView == null) { |
|||
created = true; |
|||
entityView = new EntityView(); |
|||
entityView.setTenantId(tenantId); |
|||
entityView.setCreatedTime(Uuids.unixTimestamp(entityViewId.getId())); |
|||
} |
|||
EntityView entityViewByName = entityViewService.findEntityViewByTenantIdAndName(tenantId, entityViewName); |
|||
if (entityViewByName != null && !entityViewByName.getId().equals(entityViewId)) { |
|||
entityViewName = entityViewName + "_" + StringUtils.randomAlphanumeric(15); |
|||
log.warn("Entity view with name {} already exists. Renaming entity view name to {}", |
|||
entityViewUpdateMsg.getName(), entityViewName); |
|||
entityViewNameUpdated = true; |
|||
} |
|||
entityView.setName(entityViewName); |
|||
entityView.setType(entityViewUpdateMsg.getType()); |
|||
entityView.setCustomerId(customerId); |
|||
entityView.setAdditionalInfo(entityViewUpdateMsg.hasAdditionalInfo() ? |
|||
JacksonUtil.toJsonNode(entityViewUpdateMsg.getAdditionalInfo()) : null); |
|||
|
|||
UUID entityIdUUID = safeGetUUID(entityViewUpdateMsg.getEntityIdMSB(), entityViewUpdateMsg.getEntityIdLSB()); |
|||
if (EdgeEntityType.DEVICE.equals(entityViewUpdateMsg.getEntityType())) { |
|||
entityView.setEntityId(entityIdUUID != null ? new DeviceId(entityIdUUID) : null); |
|||
} else if (EdgeEntityType.ASSET.equals(entityViewUpdateMsg.getEntityType())) { |
|||
entityView.setEntityId(entityIdUUID != null ? new AssetId(entityIdUUID) : null); |
|||
} |
|||
|
|||
entityViewValidator.validate(entityView, EntityView::getTenantId); |
|||
if (created) { |
|||
entityView.setId(entityViewId); |
|||
} |
|||
entityViewService.saveEntityView(entityView, false); |
|||
return Pair.of(created, entityViewNameUpdated); |
|||
} |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.common.data.notification.settings; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonCreator; |
|||
import com.fasterxml.jackson.annotation.JsonIgnore; |
|||
import com.fasterxml.jackson.annotation.JsonProperty; |
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; |
|||
import org.thingsboard.server.common.data.notification.NotificationType; |
|||
import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; |
|||
|
|||
import javax.validation.Valid; |
|||
import javax.validation.constraints.AssertTrue; |
|||
import javax.validation.constraints.NotNull; |
|||
import java.util.Collections; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Data |
|||
public class UserNotificationSettings { |
|||
|
|||
@NotNull |
|||
@Valid |
|||
private final Map<NotificationType, NotificationPref> prefs; |
|||
|
|||
public static final UserNotificationSettings DEFAULT = new UserNotificationSettings(Collections.emptyMap()); |
|||
|
|||
public static final Set<NotificationDeliveryMethod> deliveryMethods = NotificationTargetType.PLATFORM_USERS.getSupportedDeliveryMethods(); |
|||
|
|||
@JsonCreator |
|||
public UserNotificationSettings(@JsonProperty("prefs") Map<NotificationType, NotificationPref> prefs) { |
|||
this.prefs = prefs; |
|||
} |
|||
|
|||
public boolean isEnabled(NotificationType notificationType, NotificationDeliveryMethod deliveryMethod) { |
|||
NotificationPref pref = prefs.get(notificationType); |
|||
if (pref == null) { |
|||
return true; |
|||
} |
|||
if (!pref.isEnabled()) { |
|||
return false; |
|||
} |
|||
return pref.getEnabledDeliveryMethods().getOrDefault(deliveryMethod, true); |
|||
} |
|||
|
|||
@Data |
|||
public static class NotificationPref { |
|||
private boolean enabled; |
|||
@NotNull |
|||
private Map<NotificationDeliveryMethod, Boolean> enabledDeliveryMethods; |
|||
|
|||
public static NotificationPref createDefault() { |
|||
NotificationPref pref = new NotificationPref(); |
|||
pref.setEnabled(true); |
|||
pref.setEnabledDeliveryMethods(deliveryMethods.stream().collect(Collectors.toMap(v -> v, v -> true))); |
|||
return pref; |
|||
} |
|||
|
|||
@JsonIgnore |
|||
@AssertTrue(message = "Only email, Web and SMS delivery methods are allowed") |
|||
public boolean isValid() { |
|||
return enabledDeliveryMethods.entrySet().stream() |
|||
.allMatch(entry -> deliveryMethods.contains(entry.getKey()) && entry.getValue() != null); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,94 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<div [formGroup]="keyRowFormGroup" class="tb-form-table-row tb-aggregated-data-key-row"> |
|||
<mat-form-field class="tb-inline-field tb-position-field" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="position"> |
|||
<mat-option *ngFor="let position of aggregatedValueCardKeyPositions" [value]="position"> |
|||
{{ aggregatedValueCardKeyPositionTranslationMap.get(position) | translate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
<mat-form-field class="tb-inline-field tb-aggregation-field" subscriptSizing="dynamic"> |
|||
<mat-chip-grid #chipList> |
|||
<mat-chip-row class="tb-datakey-chip"> |
|||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="4px" class="tb-attribute-chip"> |
|||
<div class="tb-chip-labels"> |
|||
<div class="tb-chip-label tb-chip-text"> |
|||
<ng-container *ngTemplateOutlet="keyName"></ng-container> |
|||
</div> |
|||
</div> |
|||
<button type="button" |
|||
(click)="editKey()" mat-icon-button class="tb-mat-24"> |
|||
<mat-icon class="tb-mat-18">edit</mat-icon> |
|||
</button> |
|||
</div> |
|||
</mat-chip-row> |
|||
</mat-chip-grid> |
|||
<input matInput |
|||
type="text" |
|||
fxHide |
|||
readonly |
|||
[matChipInputFor]="chipList" |
|||
/> |
|||
</mat-form-field> |
|||
<div class="tb-units-field"> |
|||
<tb-unit-input formControlName="units"> |
|||
</tb-unit-input> |
|||
</div> |
|||
<div class="tb-decimals-field"> |
|||
<mat-form-field appearance="outline" class="tb-inline-field number" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-font-field"> |
|||
<tb-font-settings formControlName="font" |
|||
[previewText]="valuePreviewFn"> |
|||
</tb-font-settings> |
|||
</div> |
|||
<div class="tb-color-field"> |
|||
<tb-color-settings formControlName="color"> |
|||
</tb-color-settings> |
|||
</div> |
|||
<div class="tb-form-table-row-cell-buttons"> |
|||
<button type="button" |
|||
mat-icon-button |
|||
(click)="keyRemoved.emit()" |
|||
matTooltip="{{ 'widgets.aggregated-value-card.remove-value' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
<ng-template #keyName> |
|||
<ng-container *ngIf="dataKeyHasPostprocessing(); else keyName"> |
|||
<strong><span>f(</span></strong><ng-container *ngTemplateOutlet="keyNameTemplate"></ng-container><strong><span>)</span></strong> |
|||
</ng-container> |
|||
<ng-template #keyName> |
|||
<ng-container *ngTemplateOutlet="keyNameTemplate"></ng-container> |
|||
</ng-template> |
|||
</ng-template> |
|||
<ng-template #keyNameTemplate> |
|||
<ng-container> |
|||
<strong><span class="tb-agg-func">{{ (modelValue?.aggregationType || aggregationTypes.NONE) }}</span></strong> |
|||
<span *ngIf="!modelValue?.aggregationType || modelValue?.aggregationType === aggregationTypes.NONE; else aggValue">({{ 'datakey.latest-value' | translate }})</span> |
|||
</ng-container> |
|||
<ng-template #aggValue> |
|||
<span *ngIf="modelValue?.comparisonEnabled && modelValue?.comparisonResultType !== comparisonResultTypes.PREVIOUS_VALUE">({{ 'datakey.delta' | translate }}:{{ (modelValue?.comparisonResultType === comparisonResultTypes.DELTA_PERCENT ? 'datakey.percent' : 'datakey.absolute') | translate }})</span> |
|||
<span *ngIf="modelValue?.comparisonEnabled && modelValue?.comparisonResultType === comparisonResultTypes.PREVIOUS_VALUE">({{ 'datakey.delta-calculation-result-previous-value' | translate }})</span> |
|||
</ng-template> |
|||
</ng-template> |
|||
@ -0,0 +1,88 @@ |
|||
/** |
|||
* 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'; |
|||
|
|||
.tb-aggregated-data-key-row { |
|||
.mat-mdc-form-field.tb-inline-field.tb-aggregation-field { |
|||
.mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) { |
|||
padding-left: 8px; |
|||
padding-right: 0; |
|||
.mat-mdc-form-field-infix { |
|||
padding-top: 0; |
|||
padding-bottom: 6px; |
|||
.mdc-evolution-chip-set .mdc-evolution-chip { |
|||
margin: 0; |
|||
} |
|||
input.mat-mdc-chip-input { |
|||
height: 32px; |
|||
margin-left: 0; |
|||
} |
|||
} |
|||
} |
|||
.mat-mdc-chip.mat-mdc-standard-chip.tb-datakey-chip { |
|||
.tb-attribute-chip { |
|||
.tb-chip-labels { |
|||
background: transparent; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.tb-position-field { |
|||
width: 132px; |
|||
min-width: 132px; |
|||
} |
|||
|
|||
.tb-aggregation-field { |
|||
flex: 1; |
|||
min-width: 150px; |
|||
} |
|||
|
|||
.tb-units-field, .tb-decimals-field, .tb-font-field, .tb-color-field { |
|||
display: flex; |
|||
flex-direction: row; |
|||
place-content: center; |
|||
align-items: center; |
|||
} |
|||
|
|||
.tb-units-field { |
|||
width: 80px; |
|||
min-width: 80px; |
|||
} |
|||
|
|||
.tb-decimals-field { |
|||
width: 60px; |
|||
min-width: 60px; |
|||
} |
|||
|
|||
.tb-font-field { |
|||
width: 40px; |
|||
min-width: 40px; |
|||
} |
|||
|
|||
.tb-color-field { |
|||
width: 40px; |
|||
min-width: 40px; |
|||
} |
|||
|
|||
.tb-units-field, .tb-decimals-field { |
|||
display: none; |
|||
@media #{$mat-gt-sm} { |
|||
display: block; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,233 @@ |
|||
///
|
|||
/// 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, |
|||
EventEmitter, |
|||
forwardRef, |
|||
Input, |
|||
OnChanges, |
|||
OnInit, |
|||
Output, |
|||
SimpleChanges, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { |
|||
ComparisonResultType, |
|||
DataKey, |
|||
DataKeyConfigMode, |
|||
DatasourceType, Widget, |
|||
widgetType |
|||
} from '@shared/models/widget.models'; |
|||
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { AggregationType } from '@shared/models/time/time.models'; |
|||
import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; |
|||
import { TranslateService } from '@ngx-translate/core'; |
|||
import { TruncatePipe } from '@shared/pipe/truncate.pipe'; |
|||
import { |
|||
DataKeyConfigDialogComponent, |
|||
DataKeyConfigDialogData |
|||
} from '@home/components/widget/config/data-key-config-dialog.component'; |
|||
import { deepClone, formatValue } from '@core/utils'; |
|||
import { |
|||
AggregatedValueCardKeyPosition, |
|||
aggregatedValueCardKeyPositionTranslations, |
|||
AggregatedValueCardKeySettings |
|||
} from '@home/components/widget/lib/cards/aggregated-value-card.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-aggregated-data-key-row', |
|||
templateUrl: './aggregated-data-key-row.component.html', |
|||
styleUrls: ['./aggregated-data-key-row.component.scss', '../../data-keys.component.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => AggregatedDataKeyRowComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class AggregatedDataKeyRowComponent implements ControlValueAccessor, OnInit, OnChanges { |
|||
|
|||
aggregatedValueCardKeyPositions: AggregatedValueCardKeyPosition[] = |
|||
Object.keys(AggregatedValueCardKeyPosition).map(value => AggregatedValueCardKeyPosition[value]); |
|||
|
|||
aggregatedValueCardKeyPositionTranslationMap = aggregatedValueCardKeyPositionTranslations; |
|||
|
|||
dataKeyTypes = DataKeyType; |
|||
|
|||
aggregationTypes = AggregationType; |
|||
|
|||
comparisonResultTypes = ComparisonResultType; |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
@Input() |
|||
datasourceType: DatasourceType; |
|||
|
|||
@Input() |
|||
keyName: string; |
|||
|
|||
@Output() |
|||
keyRemoved = new EventEmitter(); |
|||
|
|||
keyRowFormGroup: UntypedFormGroup; |
|||
|
|||
modelValue: DataKey; |
|||
|
|||
valuePreviewFn = this._valuePreviewFn.bind(this); |
|||
|
|||
get callbacks(): DataKeysCallbacks { |
|||
return this.widgetConfigComponent.widgetConfigCallbacks; |
|||
} |
|||
|
|||
get widget(): Widget { |
|||
return this.widgetConfigComponent.widget; |
|||
} |
|||
|
|||
get isEntityDatasource(): boolean { |
|||
return [DatasourceType.device, DatasourceType.entity].includes(this.datasourceType); |
|||
} |
|||
|
|||
private propagateChange = (_val: any) => {}; |
|||
|
|||
constructor(private fb: UntypedFormBuilder, |
|||
private dialog: MatDialog, |
|||
private cd: ChangeDetectorRef, |
|||
public translate: TranslateService, |
|||
public truncate: TruncatePipe, |
|||
private widgetConfigComponent: WidgetConfigComponent) { |
|||
} |
|||
|
|||
ngOnInit() { |
|||
this.keyRowFormGroup = this.fb.group({ |
|||
position: [null, []], |
|||
units: [null, []], |
|||
decimals: [null, []], |
|||
font: [null, []], |
|||
color: [null, []] |
|||
}); |
|||
this.keyRowFormGroup.valueChanges.subscribe( |
|||
() => this.updateModel() |
|||
); |
|||
} |
|||
|
|||
ngOnChanges(changes: SimpleChanges): void { |
|||
for (const propName of Object.keys(changes)) { |
|||
const change = changes[propName]; |
|||
if (!change.firstChange && change.currentValue !== change.previousValue) { |
|||
if (['keyName'].includes(propName)) { |
|||
if (change.currentValue) { |
|||
this.modelValue.name = change.currentValue; |
|||
setTimeout(() => { |
|||
this.updateModel(); |
|||
}, 0); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (isDisabled) { |
|||
this.keyRowFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.keyRowFormGroup.enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: DataKey): void { |
|||
this.modelValue = value || {} as DataKey; |
|||
const settings: AggregatedValueCardKeySettings = (this.modelValue.settings || {}); |
|||
this.keyRowFormGroup.patchValue( |
|||
{ |
|||
position: settings.position || AggregatedValueCardKeyPosition.center, |
|||
units: value?.units, |
|||
decimals: value?.decimals, |
|||
font: settings.font, |
|||
color: settings.color |
|||
}, {emitEvent: false} |
|||
); |
|||
this.cd.markForCheck(); |
|||
} |
|||
|
|||
dataKeyHasPostprocessing(): boolean { |
|||
return !!this.modelValue?.postFuncBody; |
|||
} |
|||
|
|||
editKey() { |
|||
this.dialog.open<DataKeyConfigDialogComponent, DataKeyConfigDialogData, DataKey>(DataKeyConfigDialogComponent, |
|||
{ |
|||
disableClose: true, |
|||
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], |
|||
data: { |
|||
dataKey: deepClone(this.modelValue), |
|||
dataKeyConfigMode: DataKeyConfigMode.general, |
|||
dataKeySettingsSchema: null, |
|||
dataKeySettingsDirective: null, |
|||
dashboard: null, |
|||
aliasController: null, |
|||
widget: this.widget, |
|||
widgetType: widgetType.latest, |
|||
deviceId: null, |
|||
entityAliasId: null, |
|||
showPostProcessing: true, |
|||
callbacks: this.callbacks, |
|||
hideDataKeyName: true, |
|||
hideDataKeyLabel: true, |
|||
hideDataKeyColor: true |
|||
} |
|||
}).afterClosed().subscribe((updatedDataKey) => { |
|||
if (updatedDataKey) { |
|||
this.modelValue = updatedDataKey; |
|||
this.keyRowFormGroup.get('units').patchValue(this.modelValue.units, {emitEvent: false}); |
|||
this.keyRowFormGroup.get('decimals').patchValue(this.modelValue.decimals, {emitEvent: false}); |
|||
this.updateModel(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private updateModel() { |
|||
const value = this.keyRowFormGroup.value; |
|||
this.modelValue.settings = this.modelValue.settings || {}; |
|||
this.modelValue.settings.position = value.position; |
|||
this.modelValue.settings.font = value.font; |
|||
this.modelValue.settings.color = value.color; |
|||
this.modelValue.units = value.units; |
|||
this.modelValue.decimals = value.decimals; |
|||
this.propagateChange(this.modelValue); |
|||
} |
|||
|
|||
private _valuePreviewFn(): string { |
|||
const units: string = this.keyRowFormGroup.get('units').value; |
|||
const decimals: number = this.keyRowFormGroup.get('decimals').value; |
|||
return formatValue(22, decimals, units, true); |
|||
} |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<div class="tb-form-panel tb-aggregated-data-keys-panel"> |
|||
<div class="tb-form-panel-title">{{ 'widgets.aggregated-value-card.values' | translate }}</div> |
|||
<div class="tb-form-table"> |
|||
<div class="tb-form-table-header"> |
|||
<div class="tb-form-table-header-cell tb-position-header" translate>widgets.aggregated-value-card.position</div> |
|||
<div class="tb-form-table-header-cell tb-aggregation-header" translate>widgets.aggregated-value-card.aggregation</div> |
|||
<div class="tb-form-table-header-cell tb-units-header" translate>widget-config.units-short</div> |
|||
<div class="tb-form-table-header-cell tb-decimals-header" translate>widget-config.decimals-short</div> |
|||
<div class="tb-form-table-header-cell tb-font-header" translate>widgets.aggregated-value-card.font</div> |
|||
<div class="tb-form-table-header-cell tb-color-header" translate>widgets.aggregated-value-card.color</div> |
|||
<div class="tb-form-table-header-cell tb-actions-header"></div> |
|||
</div> |
|||
<div *ngIf="keysFormArray().controls.length; else noKeys" class="tb-form-table-body"> |
|||
<div *ngFor="let keyControl of keysFormArray().controls; trackBy: trackByKey; let $index = index;"> |
|||
<tb-aggregated-data-key-row fxFlex |
|||
[formControl]="keyControl" |
|||
[datasourceType]="datasourceType" |
|||
[keyName]="keyName" |
|||
(keyRemoved)="removeKey($index)"> |
|||
</tb-aggregated-data-key-row> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div> |
|||
<button type="button" mat-stroked-button color="primary" (click)="addKey()"> |
|||
{{ 'widgets.aggregated-value-card.add-value' | translate }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
<ng-template #noKeys> |
|||
<span fxLayoutAlign="center center" |
|||
class="tb-prompt">{{ 'widgets.aggregated-value-card.no-values' | translate }}</span> |
|||
</ng-template> |
|||
@ -0,0 +1,69 @@ |
|||
/** |
|||
* 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'; |
|||
|
|||
.tb-aggregated-data-keys-panel { |
|||
.tb-form-table-header-cell { |
|||
&.tb-position-header { |
|||
width: 132px; |
|||
min-width: 132px; |
|||
} |
|||
|
|||
&.tb-aggregation-header { |
|||
flex: 1; |
|||
min-width: 150px; |
|||
} |
|||
|
|||
&.tb-units-header { |
|||
width: 80px; |
|||
min-width: 80px; |
|||
} |
|||
|
|||
&.tb-decimals-header { |
|||
width: 60px; |
|||
min-width: 60px; |
|||
} |
|||
|
|||
&.tb-font-header { |
|||
width: 40px; |
|||
min-width: 40px; |
|||
} |
|||
|
|||
&.tb-color-header { |
|||
width: 40px; |
|||
min-width: 40px; |
|||
} |
|||
|
|||
&.tb-actions-header { |
|||
width: 40px; |
|||
min-width: 40px; |
|||
} |
|||
|
|||
&.tb-units-header, &.tb-decimals-header { |
|||
display: none; |
|||
@media #{$mat-gt-sm} { |
|||
display: block; |
|||
} |
|||
} |
|||
} |
|||
.tb-form-table-body { |
|||
tb-aggregated-data-key-row { |
|||
overflow: hidden; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,173 @@ |
|||
///
|
|||
/// 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, |
|||
forwardRef, |
|||
Input, |
|||
OnChanges, |
|||
OnInit, |
|||
SimpleChanges, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { |
|||
AbstractControl, |
|||
ControlValueAccessor, |
|||
NG_VALUE_ACCESSOR, |
|||
UntypedFormArray, |
|||
UntypedFormBuilder, |
|||
UntypedFormGroup |
|||
} from '@angular/forms'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; |
|||
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; |
|||
import { aggregatedValueCardDefaultKeySettings } from '@home/components/widget/lib/cards/aggregated-value-card.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-aggregated-data-keys-panel', |
|||
templateUrl: './aggregated-data-keys-panel.component.html', |
|||
styleUrls: ['./aggregated-data-keys-panel.component.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => AggregatedDataKeysPanelComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class AggregatedDataKeysPanelComponent implements ControlValueAccessor, OnInit, OnChanges { |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
@Input() |
|||
datasourceType: DatasourceType; |
|||
|
|||
@Input() |
|||
keyName: string; |
|||
|
|||
dataKeyType: DataKeyType; |
|||
|
|||
keysListFormGroup: UntypedFormGroup; |
|||
|
|||
get widgetType(): widgetType { |
|||
return this.widgetConfigComponent.widgetType; |
|||
} |
|||
|
|||
get callbacks(): DataKeysCallbacks { |
|||
return this.widgetConfigComponent.widgetConfigCallbacks; |
|||
} |
|||
|
|||
get noKeys(): boolean { |
|||
const keys: DataKey[] = this.keysListFormGroup.get('keys').value; |
|||
return keys.length === 0; |
|||
} |
|||
|
|||
private propagateChange = (_val: any) => {}; |
|||
|
|||
constructor(private fb: UntypedFormBuilder, |
|||
private dialog: MatDialog, |
|||
private cd: ChangeDetectorRef, |
|||
private utils: UtilsService, |
|||
private widgetConfigComponent: WidgetConfigComponent) { |
|||
} |
|||
|
|||
ngOnInit() { |
|||
this.keysListFormGroup = this.fb.group({ |
|||
keys: [this.fb.array([]), []] |
|||
}); |
|||
this.keysListFormGroup.valueChanges.subscribe( |
|||
(val) => this.propagateChange(this.keysListFormGroup.get('keys').value) |
|||
); |
|||
this.updateParams(); |
|||
} |
|||
|
|||
ngOnChanges(changes: SimpleChanges): void { |
|||
for (const propName of Object.keys(changes)) { |
|||
const change = changes[propName]; |
|||
if (!change.firstChange && change.currentValue !== change.previousValue) { |
|||
if (['datasourceType'].includes(propName)) { |
|||
this.updateParams(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private updateParams() { |
|||
if (this.datasourceType === DatasourceType.function) { |
|||
this.dataKeyType = DataKeyType.function; |
|||
} else { |
|||
this.dataKeyType = DataKeyType.timeseries; |
|||
} |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (isDisabled) { |
|||
this.keysListFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.keysListFormGroup.enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: DataKey[] | undefined): void { |
|||
this.keysListFormGroup.setControl('keys', this.prepareKeysFormArray(value), {emitEvent: false}); |
|||
} |
|||
|
|||
keysFormArray(): UntypedFormArray { |
|||
return this.keysListFormGroup.get('keys') as UntypedFormArray; |
|||
} |
|||
|
|||
trackByKey(index: number, keyControl: AbstractControl): any { |
|||
return keyControl; |
|||
} |
|||
|
|||
removeKey(index: number) { |
|||
(this.keysListFormGroup.get('keys') as UntypedFormArray).removeAt(index); |
|||
} |
|||
|
|||
addKey() { |
|||
const dataKey = this.callbacks.generateDataKey(this.keyName, this.dataKeyType, null); |
|||
dataKey.decimals = 0; |
|||
dataKey.settings = {...aggregatedValueCardDefaultKeySettings}; |
|||
const keysArray = this.keysListFormGroup.get('keys') as UntypedFormArray; |
|||
const keyControl = this.fb.control(dataKey, []); |
|||
keysArray.push(keyControl); |
|||
} |
|||
|
|||
private prepareKeysFormArray(keys: DataKey[] | undefined): UntypedFormArray { |
|||
const keysControls: Array<AbstractControl> = []; |
|||
if (keys) { |
|||
keys.forEach((key) => { |
|||
keysControls.push(this.fb.control(key, [])); |
|||
}); |
|||
} |
|||
return this.fb.array(keysControls); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,136 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="aggregatedValueCardWidgetConfigForm"> |
|||
<tb-timewindow-config-panel formControlName="timewindowConfig"> |
|||
</tb-timewindow-config-panel> |
|||
<tb-datasources |
|||
[configMode]="basicMode" |
|||
hideDataKeyLabel |
|||
hideDataKeyColor |
|||
hideDataKeyUnits |
|||
hideDataKeyDecimals |
|||
hideLatestDataKeys |
|||
formControlName="datasources"> |
|||
</tb-datasources> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widget-config.appearance</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle"> |
|||
{{ 'widget-config.title' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-font-settings formControlName="titleFont" |
|||
clearButton |
|||
[previewText]="aggregatedValueCardWidgetConfigForm.get('title').value" |
|||
[initialPreviewStyle]="widgetConfig.config.titleStyle"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="titleColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon"> |
|||
{{ 'widgets.value-card.icon' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic"> |
|||
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select> |
|||
<tb-material-icon-select asBoxInput |
|||
iconClearButton |
|||
[color]="aggregatedValueCardWidgetConfigForm.get('iconColor').value" |
|||
formControlName="icon"> |
|||
</tb-material-icon-select> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="iconColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showSubtitle"> |
|||
{{ 'widgets.aggregated-value-card.subtitle' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="subtitle" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-font-settings formControlName="subtitleFont" |
|||
clearButton |
|||
[previewText]="aggregatedValueCardWidgetConfigForm.get('subtitle').value"> |
|||
</tb-font-settings> |
|||
<tb-color-settings formControlName="subtitleColor"> |
|||
</tb-color-settings> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row column-xs"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showDate"> |
|||
{{ 'widgets.value-card.date' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<tb-date-format-select fxFlex formControlName="dateFormat"></tb-date-format-select> |
|||
<tb-font-settings formControlName="dateFont" |
|||
[previewText]="datePreviewFn"> |
|||
</tb-font-settings> |
|||
<tb-color-settings formControlName="dateColor"> |
|||
</tb-color-settings> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="showChart"> |
|||
{{ 'widgets.aggregated-value-card.chart' | translate }} |
|||
</mat-slide-toggle> |
|||
<tb-color-settings formControlName="chartColor"> |
|||
</tb-color-settings> |
|||
</div> |
|||
</div> |
|||
<tb-aggregated-data-keys-panel |
|||
[datasourceType]="datasource?.type" |
|||
[keyName]="keyName" |
|||
formControlName="values"> |
|||
</tb-aggregated-data-keys-panel> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.background.background' | translate }}</div> |
|||
<tb-background-settings formControlName="background"> |
|||
</tb-background-settings> |
|||
</div> |
|||
<div class="tb-form-row space-between column-lt-md"> |
|||
<div translate>widget-config.show-card-buttons</div> |
|||
<mat-chip-listbox multiple formControlName="cardButtons"> |
|||
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option> |
|||
</mat-chip-listbox> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widget-config.card-border-radius' | translate }}</div> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<tb-widget-actions-panel |
|||
formControlName="actions"> |
|||
</tb-widget-actions-panel> |
|||
</ng-container> |
|||
@ -0,0 +1,293 @@ |
|||
///
|
|||
/// 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, Injector } from '@angular/core'; |
|||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; |
|||
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; |
|||
import { DataKey, Datasource, WidgetConfig, } from '@shared/models/widget.models'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { |
|||
getTimewindowConfig, |
|||
setTimewindowConfig |
|||
} from '@home/components/widget/config/timewindow-config-panel.component'; |
|||
import { isUndefined } from '@core/utils'; |
|||
import { |
|||
cssSizeToStrSize, |
|||
DateFormatProcessor, |
|||
DateFormatSettings, getDataKey, |
|||
resolveCssSize |
|||
} from '@shared/models/widget-settings.models'; |
|||
import { |
|||
aggregatedValueCardDefaultSettings, |
|||
AggregatedValueCardWidgetSettings, |
|||
createDefaultAggregatedValueLatestDataKeys |
|||
} from '@home/components/widget/lib/cards/aggregated-value-card.models'; |
|||
import { |
|||
AggregationType, |
|||
HistoryWindowType, |
|||
HOUR, |
|||
QuickTimeInterval, |
|||
TimewindowType |
|||
} from '@shared/models/time/time.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-aggregated-value-card-basic-config', |
|||
templateUrl: './aggregated-value-card-basic-config.component.html', |
|||
styleUrls: ['../basic-config.scss'] |
|||
}) |
|||
export class AggregatedValueCardBasicConfigComponent extends BasicWidgetConfigComponent { |
|||
|
|||
public get datasource(): Datasource { |
|||
const datasources: Datasource[] = this.aggregatedValueCardWidgetConfigForm.get('datasources').value; |
|||
if (datasources && datasources.length) { |
|||
return datasources[0]; |
|||
} else { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
public get keyName(): string { |
|||
const dataKey = getDataKey(this.aggregatedValueCardWidgetConfigForm.get('datasources').value); |
|||
if (dataKey) { |
|||
return dataKey.name; |
|||
} else { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
aggregatedValueCardWidgetConfigForm: UntypedFormGroup; |
|||
|
|||
datePreviewFn = this._datePreviewFn.bind(this); |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected widgetConfigComponent: WidgetConfigComponent, |
|||
private cd: ChangeDetectorRef, |
|||
private $injector: Injector, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store, widgetConfigComponent); |
|||
} |
|||
|
|||
protected configForm(): UntypedFormGroup { |
|||
return this.aggregatedValueCardWidgetConfigForm; |
|||
} |
|||
|
|||
protected setupDefaults(configData: WidgetConfigComponentData) { |
|||
this.setupDefaultDatasource(configData, [ |
|||
{ name: 'watermeter', label: 'Watermeter', type: DataKeyType.timeseries } |
|||
], |
|||
createDefaultAggregatedValueLatestDataKeys('watermeter', 'm³') |
|||
); |
|||
configData.config.useDashboardTimewindow = false; |
|||
configData.config.displayTimewindow = true; |
|||
configData.config.timewindow = { |
|||
selectedTab: TimewindowType.HISTORY, |
|||
history: { |
|||
historyType: HistoryWindowType.INTERVAL, |
|||
quickInterval: QuickTimeInterval.CURRENT_MONTH_SO_FAR, |
|||
}, |
|||
aggregation: { |
|||
type: AggregationType.AVG, |
|||
interval: 12 * HOUR, |
|||
limit: 5000 |
|||
} |
|||
}; |
|||
} |
|||
|
|||
protected onConfigSet(configData: WidgetConfigComponentData) { |
|||
const settings: AggregatedValueCardWidgetSettings = {...aggregatedValueCardDefaultSettings, ...(configData.config.settings || {})}; |
|||
const dataKey = getDataKey(configData.config.datasources); |
|||
const keyName = dataKey?.name; |
|||
const iconSize = resolveCssSize(configData.config.iconSize); |
|||
this.aggregatedValueCardWidgetConfigForm = this.fb.group({ |
|||
timewindowConfig: [getTimewindowConfig(configData.config), []], |
|||
datasources: [configData.config.datasources, []], |
|||
|
|||
showTitle: [configData.config.showTitle, []], |
|||
title: [configData.config.title, []], |
|||
titleFont: [configData.config.titleFont, []], |
|||
titleColor: [configData.config.titleColor, []], |
|||
|
|||
showIcon: [configData.config.showTitleIcon, []], |
|||
iconSize: [iconSize[0], [Validators.min(0)]], |
|||
iconSizeUnit: [iconSize[1], []], |
|||
icon: [configData.config.titleIcon, []], |
|||
iconColor: [configData.config.iconColor, []], |
|||
|
|||
showSubtitle: [settings.showSubtitle, []], |
|||
subtitle: [settings.subtitle, []], |
|||
subtitleFont: [settings.subtitleFont, []], |
|||
subtitleColor: [settings.subtitleColor, []], |
|||
|
|||
showDate: [settings.showDate, []], |
|||
dateFormat: [settings.dateFormat, []], |
|||
dateFont: [settings.dateFont, []], |
|||
dateColor: [settings.dateColor, []], |
|||
|
|||
showChart: [settings.showChart, []], |
|||
chartColor: [settings.chartColor, []], |
|||
|
|||
values: [this.getValues(configData.config.datasources, keyName), []], |
|||
|
|||
background: [settings.background, []], |
|||
|
|||
cardButtons: [this.getCardButtons(configData.config), []], |
|||
borderRadius: [configData.config.borderRadius, []], |
|||
|
|||
actions: [configData.config.actions || {}, []] |
|||
}); |
|||
} |
|||
|
|||
protected prepareOutputConfig(config: any): WidgetConfigComponentData { |
|||
setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); |
|||
this.widgetConfig.config.datasources = config.datasources; |
|||
|
|||
this.widgetConfig.config.showTitle = config.showTitle; |
|||
this.widgetConfig.config.title = config.title; |
|||
this.widgetConfig.config.titleFont = config.titleFont; |
|||
this.widgetConfig.config.titleColor = config.titleColor; |
|||
|
|||
this.widgetConfig.config.showTitleIcon = config.showIcon; |
|||
this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit); |
|||
this.widgetConfig.config.titleIcon = config.icon; |
|||
this.widgetConfig.config.iconColor = config.iconColor; |
|||
|
|||
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; |
|||
|
|||
this.widgetConfig.config.settings.showSubtitle = config.showSubtitle; |
|||
this.widgetConfig.config.settings.subtitle = config.subtitle; |
|||
this.widgetConfig.config.settings.subtitleFont = config.subtitleFont; |
|||
this.widgetConfig.config.settings.subtitleColor = config.subtitleColor; |
|||
|
|||
this.widgetConfig.config.settings.showDate = config.showDate; |
|||
this.widgetConfig.config.settings.dateFormat = config.dateFormat; |
|||
this.widgetConfig.config.settings.dateFont = config.dateFont; |
|||
this.widgetConfig.config.settings.dateColor = config.dateColor; |
|||
|
|||
this.widgetConfig.config.settings.showChart = config.showChart; |
|||
this.widgetConfig.config.settings.chartColor = config.chartColor; |
|||
|
|||
this.setValues(config.values, this.widgetConfig.config.datasources); |
|||
|
|||
this.widgetConfig.config.settings.background = config.background; |
|||
|
|||
this.setCardButtons(config.cardButtons, this.widgetConfig.config); |
|||
this.widgetConfig.config.borderRadius = config.borderRadius; |
|||
|
|||
this.widgetConfig.config.actions = config.actions; |
|||
return this.widgetConfig; |
|||
} |
|||
|
|||
protected validatorTriggers(): string[] { |
|||
return ['showTitle', 'showIcon', 'showSubtitle', 'showDate', 'showChart']; |
|||
} |
|||
|
|||
protected updateValidators(emitEvent: boolean, trigger?: string) { |
|||
const showTitle: boolean = this.aggregatedValueCardWidgetConfigForm.get('showTitle').value; |
|||
const showIcon: boolean = this.aggregatedValueCardWidgetConfigForm.get('showIcon').value; |
|||
const showSubtitle: boolean = this.aggregatedValueCardWidgetConfigForm.get('showSubtitle').value; |
|||
const showDate: boolean = this.aggregatedValueCardWidgetConfigForm.get('showDate').value; |
|||
const showChart: boolean = this.aggregatedValueCardWidgetConfigForm.get('showChart').value; |
|||
|
|||
if (showTitle) { |
|||
this.aggregatedValueCardWidgetConfigForm.get('title').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('titleFont').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('titleColor').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('showIcon').enable({emitEvent: false}); |
|||
if (showIcon) { |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconSize').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('icon').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconColor').enable(); |
|||
} else { |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconSize').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('icon').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconColor').disable(); |
|||
} |
|||
} else { |
|||
this.aggregatedValueCardWidgetConfigForm.get('title').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('titleFont').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('titleColor').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('showIcon').disable({emitEvent: false}); |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconSize').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('icon').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('iconColor').disable(); |
|||
} |
|||
|
|||
if (showSubtitle) { |
|||
this.aggregatedValueCardWidgetConfigForm.get('subtitle').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('subtitleFont').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('subtitleColor').enable(); |
|||
} else { |
|||
this.aggregatedValueCardWidgetConfigForm.get('subtitle').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('subtitleFont').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('subtitleColor').disable(); |
|||
} |
|||
|
|||
if (showDate) { |
|||
this.aggregatedValueCardWidgetConfigForm.get('dateFormat').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('dateFont').enable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('dateColor').enable(); |
|||
} else { |
|||
this.aggregatedValueCardWidgetConfigForm.get('dateFormat').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('dateFont').disable(); |
|||
this.aggregatedValueCardWidgetConfigForm.get('dateColor').disable(); |
|||
} |
|||
|
|||
if (showChart) { |
|||
this.aggregatedValueCardWidgetConfigForm.get('chartColor').enable(); |
|||
} else { |
|||
this.aggregatedValueCardWidgetConfigForm.get('chartColor').disable(); |
|||
} |
|||
} |
|||
|
|||
private getValues(datasources: Datasource[], keyName: string): DataKey[] { |
|||
if (datasources && datasources.length) { |
|||
return (datasources[0].latestDataKeys || []).filter(k => k.name === keyName); |
|||
} |
|||
return []; |
|||
} |
|||
|
|||
private setValues(values: DataKey[], datasources?: Datasource[]) { |
|||
if (datasources && datasources.length) { |
|||
datasources[0].latestDataKeys = values; |
|||
} |
|||
} |
|||
|
|||
private getCardButtons(config: WidgetConfig): string[] { |
|||
const buttons: string[] = []; |
|||
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { |
|||
buttons.push('fullscreen'); |
|||
} |
|||
return buttons; |
|||
} |
|||
|
|||
private setCardButtons(buttons: string[], config: WidgetConfig) { |
|||
config.enableFullscreen = buttons.includes('fullscreen'); |
|||
} |
|||
|
|||
private _datePreviewFn(): string { |
|||
const dateFormat: DateFormatSettings = this.aggregatedValueCardWidgetConfigForm.get('dateFormat').value; |
|||
const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat); |
|||
processor.update(Date.now()); |
|||
return processor.formatted; |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<div class="tb-aggregated-value-card-panel" [style]="backgroundStyle"> |
|||
<div class="tb-aggregated-value-card-overlay" [style]="overlayStyle"></div> |
|||
<div class="tb-aggregated-value-card-title-panel"> |
|||
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> |
|||
<ng-container *ngTemplateOutlet="subtitleTpl"></ng-container> |
|||
</div> |
|||
<ng-container *ngTemplateOutlet="valuesTpl"></ng-container> |
|||
<ng-container *ngTemplateOutlet="chartTpl"></ng-container> |
|||
<ng-container *ngTemplateOutlet="dateTpl"></ng-container> |
|||
</div> |
|||
|
|||
<ng-template #subtitleTpl> |
|||
<div *ngIf="showSubtitle" class="tb-aggregated-value-card-subtitle" |
|||
[style]="subtitleStyle" [style.color]="subtitleColor.color">{{ subtitle$ | async }}</div> |
|||
</ng-template> |
|||
<ng-template #valuesTpl> |
|||
<div *ngIf="showValues" class="tb-aggregated-value-card-values"> |
|||
<div class="tb-aggregated-value-card-values-container"> |
|||
<div class="tb-aggregated-value-card-values-section left"> |
|||
<ng-container *ngIf="values[aggregatedValueCardKeyPosition.leftTop]"> |
|||
<ng-container *ngTemplateOutlet="valueTpl; context:{value: values[aggregatedValueCardKeyPosition.leftTop]}"></ng-container> |
|||
</ng-container> |
|||
<ng-container *ngIf="values[aggregatedValueCardKeyPosition.leftBottom]"> |
|||
<ng-container *ngTemplateOutlet="valueTpl; context:{value: values[aggregatedValueCardKeyPosition.leftBottom]}"></ng-container> |
|||
</ng-container> |
|||
</div> |
|||
<div class="tb-aggregated-value-card-values-section center"> |
|||
<ng-container *ngIf="values[aggregatedValueCardKeyPosition.center]"> |
|||
<ng-container *ngTemplateOutlet="valueTpl; context:{value: values[aggregatedValueCardKeyPosition.center]}"></ng-container> |
|||
</ng-container> |
|||
</div> |
|||
<div class="tb-aggregated-value-card-values-section right"> |
|||
<ng-container *ngIf="values[aggregatedValueCardKeyPosition.rightTop]"> |
|||
<ng-container *ngTemplateOutlet="valueTpl; context:{value: values[aggregatedValueCardKeyPosition.rightTop]}"></ng-container> |
|||
</ng-container> |
|||
<ng-container *ngIf="values[aggregatedValueCardKeyPosition.rightBottom]"> |
|||
<ng-container *ngTemplateOutlet="valueTpl; context:{value: values[aggregatedValueCardKeyPosition.rightBottom]}"></ng-container> |
|||
</ng-container> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
<ng-template #chartTpl> |
|||
<div *ngIf="showChart" class="tb-aggregated-value-card-chart"> |
|||
<div class="tb-aggregated-value-card-chart-ticks"> |
|||
<div>{{tickMax$ | async}}</div> |
|||
<div>{{tickMin$ | async}}</div> |
|||
</div> |
|||
<div class="tb-aggregated-value-card-chart-container"> |
|||
<div class="tb-aggregated-value-card-chart-element" #chartElement></div> |
|||
<div class="tb-aggregated-value-card-chart-boundary top left"></div> |
|||
<div class="tb-aggregated-value-card-chart-boundary top right"></div> |
|||
<div class="tb-aggregated-value-card-chart-boundary bottom left"></div> |
|||
<div class="tb-aggregated-value-card-chart-boundary bottom right"></div> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
<ng-template #dateTpl> |
|||
<div *ngIf="showDate" [style]="dateStyle" [style.color]="dateColor.color" [innerHTML]="dateFormat.formatted"></div> |
|||
</ng-template> |
|||
<ng-template #valueTpl let-value="value"> |
|||
<div class="tb-aggregated-value-card-value" [style]="value.style" [style.color]="value.color.color"> |
|||
<div *ngIf="value.showArrow" class="value-arrow-container"> |
|||
<span *ngIf="!value.upArrow && !value.downArrow" class="value-arrow"></span> |
|||
<tb-icon *ngIf="value.upArrow" class="value-arrow">arrow_upward</tb-icon> |
|||
<tb-icon *ngIf="value.downArrow" class="value-arrow">arrow_downward</tb-icon> |
|||
</div> |
|||
<div class="value-text"> |
|||
<span>{{ value.value }}</span> |
|||
<span class="units" [class]="{'small': value.center}">{{ value.units }}</span> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
@ -0,0 +1,156 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
:host { |
|||
.tb-aggregated-value-card-panel { |
|||
width: 100%; |
|||
height: 100%; |
|||
position: relative; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: stretch; |
|||
gap: 8px; |
|||
padding: 20px 24px 24px; |
|||
> div:not(.tb-value-card-overlay) { |
|||
z-index: 1; |
|||
} |
|||
.tb-aggregated-value-card-overlay { |
|||
position: absolute; |
|||
top: 12px; |
|||
left: 12px; |
|||
bottom: 12px; |
|||
right: 12px; |
|||
} |
|||
> div.tb-aggregated-value-card-title-panel { |
|||
display: flex; |
|||
flex-direction: column; |
|||
.tb-aggregated-value-card-subtitle { |
|||
margin-left: 28px; |
|||
} |
|||
} |
|||
.tb-aggregated-value-card-values, .tb-aggregated-value-card-chart { |
|||
flex: 1; |
|||
min-height: 0; |
|||
overflow: hidden; |
|||
} |
|||
.tb-aggregated-value-card-values-container { |
|||
width: 100%; |
|||
height: 100%; |
|||
padding: 8px 0; |
|||
display: grid; |
|||
grid-template-columns: minmax(0, 1fr) fit-content(100%) minmax(0, 1fr); |
|||
.tb-aggregated-value-card-values-section { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: center; |
|||
overflow: hidden; |
|||
&.left { |
|||
align-items: flex-start; |
|||
} |
|||
&.center { |
|||
align-items: center; |
|||
} |
|||
&.right { |
|||
align-items: flex-end; |
|||
} |
|||
} |
|||
} |
|||
.tb-aggregated-value-card-chart { |
|||
display: flex; |
|||
gap: 8px; |
|||
flex-direction: row; |
|||
.tb-aggregated-value-card-chart-ticks { |
|||
height: 100%; |
|||
display: flex; |
|||
flex-direction: column; |
|||
place-content: flex-end space-between; |
|||
align-items: flex-end; |
|||
font-size: 11px; |
|||
line-height: 16px; |
|||
font-weight: 400; |
|||
color: rgba(0, 0, 0, 0.38); |
|||
} |
|||
.tb-aggregated-value-card-chart-container { |
|||
position: relative; |
|||
flex: 1; |
|||
margin-top: 8px; |
|||
margin-bottom: 8px; |
|||
.tb-aggregated-value-card-chart-element { |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
.tb-aggregated-value-card-chart-boundary { |
|||
position: absolute; |
|||
width: 6px; |
|||
height: 6px; |
|||
&.top { |
|||
top: 0; |
|||
border-top: 2px solid rgba(0,0,0,0.38); |
|||
} |
|||
&.left { |
|||
left: 0; |
|||
border-left: 2px solid rgba(0,0,0,0.38); |
|||
} |
|||
&.right { |
|||
right: 0; |
|||
border-right: 2px solid rgba(0,0,0,0.38); |
|||
} |
|||
&.bottom { |
|||
bottom: 0; |
|||
border-bottom: 2px solid rgba(0,0,0,0.38); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
.tb-aggregated-value-card-value { |
|||
white-space: nowrap; |
|||
min-height: 0; |
|||
display: flex; |
|||
flex-direction: row; |
|||
place-content: center; |
|||
align-items: center; |
|||
.value-arrow-container { |
|||
display: flex; |
|||
} |
|||
.value-text { |
|||
line-height: 1; |
|||
} |
|||
.value-arrow { |
|||
font-size: 1.1em; |
|||
height: 1.1em; |
|||
line-height: 1.1em; |
|||
min-width: 1em; |
|||
width: 1em; |
|||
} |
|||
.units { |
|||
font-size: 85%; |
|||
padding-left: 0.2em; |
|||
&.small { |
|||
font-size: 50%; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
:host ::ng-deep { |
|||
.tb-aggregated-value-card-panel { |
|||
> div.tb-aggregated-value-card-title-panel { |
|||
.tb-widget-title { |
|||
padding: 0; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,261 @@ |
|||
///
|
|||
/// 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, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
ElementRef, |
|||
Input, |
|||
OnInit, |
|||
TemplateRef, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { |
|||
aggregatedValueCardDefaultSettings, |
|||
AggregatedValueCardKeyPosition, |
|||
AggregatedValueCardValue, |
|||
AggregatedValueCardWidgetSettings, |
|||
computeAggregatedCardValue, |
|||
getTsValueByLatestDataKey |
|||
} from '@home/components/widget/lib/cards/aggregated-value-card.models'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { Observable } from 'rxjs'; |
|||
import { |
|||
backgroundStyle, |
|||
ColorProcessor, |
|||
ComponentStyle, |
|||
DateFormatProcessor, getDataKey, |
|||
getLatestSingleTsValue, |
|||
overlayStyle, |
|||
textStyle |
|||
} from '@shared/models/widget-settings.models'; |
|||
import { DatePipe } from '@angular/common'; |
|||
import { TbFlot } from '@home/components/widget/lib/flot-widget'; |
|||
import { TbFlotKeySettings, TbFlotSettings } from '@home/components/widget/lib/flot-widget.models'; |
|||
import { DataKey } from '@shared/models/widget.models'; |
|||
import { formatNumberValue, formatValue, isDefined, isNumeric } from '@core/utils'; |
|||
import { map } from 'rxjs/operators'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-aggregated-value-card-widget', |
|||
templateUrl: './aggregated-value-card-widget.component.html', |
|||
styleUrls: ['./aggregated-value-card-widget.component.scss'] |
|||
}) |
|||
export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit { |
|||
|
|||
@ViewChild('chartElement', {static: false}) chartElement: ElementRef; |
|||
|
|||
aggregatedValueCardKeyPosition = AggregatedValueCardKeyPosition; |
|||
|
|||
settings: AggregatedValueCardWidgetSettings; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
@Input() |
|||
widgetTitlePanel: TemplateRef<any>; |
|||
|
|||
showSubtitle = true; |
|||
subtitle$: Observable<string>; |
|||
subtitleStyle: ComponentStyle = {}; |
|||
subtitleColor: ColorProcessor; |
|||
|
|||
showValues = false; |
|||
|
|||
values: {[key: string]: AggregatedValueCardValue} = {}; |
|||
|
|||
showChart = true; |
|||
chartColor: ColorProcessor; |
|||
|
|||
showDate = true; |
|||
dateFormat: DateFormatProcessor; |
|||
dateStyle: ComponentStyle = {}; |
|||
dateColor: ColorProcessor; |
|||
|
|||
backgroundStyle: ComponentStyle = {}; |
|||
overlayStyle: ComponentStyle = {}; |
|||
|
|||
private flot: TbFlot; |
|||
private flotDataKey: DataKey; |
|||
|
|||
private lastUpdateTs: number; |
|||
|
|||
tickMin$: Observable<string>; |
|||
tickMax$: Observable<string>; |
|||
|
|||
constructor(private date: DatePipe, |
|||
private cd: ChangeDetectorRef) { |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.ctx.$scope.aggregatedValueCardWidget = this; |
|||
this.settings = {...aggregatedValueCardDefaultSettings, ...this.ctx.settings}; |
|||
this.showSubtitle = this.settings.showSubtitle; |
|||
const subtitle = this.settings.subtitle; |
|||
this.subtitle$ = this.ctx.registerLabelPattern(subtitle, this.subtitle$); |
|||
this.subtitleStyle = textStyle(this.settings.subtitleFont, '0.25px'); |
|||
this.subtitleColor = ColorProcessor.fromSettings(this.settings.subtitleColor); |
|||
|
|||
const dataKey = getDataKey(this.ctx.defaultSubscription.datasources); |
|||
if (dataKey?.name && this.ctx.defaultSubscription.firstDatasource?.latestDataKeys?.length) { |
|||
const dataKeys = this.ctx.defaultSubscription.firstDatasource?.latestDataKeys; |
|||
for (const position of Object.keys(AggregatedValueCardKeyPosition)) { |
|||
const value = computeAggregatedCardValue(dataKeys, dataKey?.name, AggregatedValueCardKeyPosition[position]); |
|||
if (value) { |
|||
this.values[position] = value; |
|||
} |
|||
} |
|||
this.showValues = !!Object.keys(this.values).length; |
|||
} |
|||
|
|||
this.showChart = this.settings.showChart; |
|||
this.chartColor = ColorProcessor.fromSettings(this.settings.chartColor); |
|||
if (this.showChart) { |
|||
if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) { |
|||
this.flotDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0]; |
|||
this.flotDataKey.settings = { |
|||
fillLines: false, |
|||
showLines: true, |
|||
lineWidth: 2 |
|||
} as TbFlotKeySettings; |
|||
this.flotDataKey.color = this.chartColor.color; |
|||
} |
|||
} |
|||
|
|||
this.showDate = this.settings.showDate; |
|||
this.dateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.dateFormat); |
|||
this.dateStyle = textStyle(this.settings.dateFont, '0.25px'); |
|||
this.dateColor = ColorProcessor.fromSettings(this.settings.dateColor); |
|||
|
|||
this.backgroundStyle = backgroundStyle(this.settings.background); |
|||
this.overlayStyle = overlayStyle(this.settings.background.overlay); |
|||
} |
|||
|
|||
ngAfterViewInit(): void { |
|||
if (this.showChart) { |
|||
const settings = { |
|||
shadowSize: 0, |
|||
enableSelection: false, |
|||
smoothLines: false, |
|||
grid: { |
|||
tickColor: 'rgba(0,0,0,0.12)', |
|||
horizontalLines: true, |
|||
verticalLines: false, |
|||
outlineWidth: 0, |
|||
minBorderMargin: 0, |
|||
margin: 0 |
|||
}, |
|||
yaxis: { |
|||
showLabels: false, |
|||
tickGenerator: 'return [(axis.max + axis.min) / 2];' |
|||
}, |
|||
xaxis: { |
|||
showLabels: false |
|||
} |
|||
} as TbFlotSettings; |
|||
this.flot = new TbFlot(this.ctx, 'line', $(this.chartElement.nativeElement), settings); |
|||
this.tickMin$ = this.flot.yMin$.pipe( |
|||
map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals), |
|||
(this.flotDataKey?.units || this.ctx.units)) |
|||
)); |
|||
this.tickMax$ = this.flot.yMax$.pipe( |
|||
map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals), |
|||
(this.flotDataKey?.units || this.ctx.units)) |
|||
)); |
|||
} |
|||
} |
|||
|
|||
public onInit() { |
|||
const borderRadius = this.ctx.$widgetElement.css('borderRadius'); |
|||
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; |
|||
this.cd.detectChanges(); |
|||
} |
|||
|
|||
public onDataUpdated() { |
|||
const tsValue = getLatestSingleTsValue(this.ctx.data); |
|||
let ts; |
|||
let value; |
|||
if (tsValue) { |
|||
ts = tsValue[0]; |
|||
value = tsValue[1]; |
|||
} |
|||
this.subtitleColor.update(value); |
|||
this.dateColor.update(value); |
|||
|
|||
if (this.showChart) { |
|||
this.chartColor.update(value); |
|||
this.flot.updateSeriesColor(this.chartColor.color); |
|||
this.flot.update(); |
|||
} |
|||
|
|||
this.updateLastUpdateTs(ts); |
|||
this.cd.detectChanges(); |
|||
} |
|||
|
|||
public onLatestDataUpdated() { |
|||
if (this.showValues) { |
|||
for (const aggValue of Object.values(this.values)) { |
|||
const tsValue = getTsValueByLatestDataKey(this.ctx.latestData, aggValue.key); |
|||
let ts; |
|||
let value; |
|||
if (tsValue) { |
|||
ts = tsValue[0]; |
|||
value = tsValue[1]; |
|||
aggValue.value = formatValue(value, (aggValue.key.decimals || this.ctx.decimals), null, false); |
|||
} else { |
|||
aggValue.value = 'N/A'; |
|||
} |
|||
const numeric = formatNumberValue(value, (aggValue.key.decimals || this.ctx.decimals)); |
|||
aggValue.color.update(numeric); |
|||
if (aggValue.showArrow && isDefined(numeric)) { |
|||
aggValue.upArrow = numeric > 0; |
|||
aggValue.downArrow = numeric < 0; |
|||
} else { |
|||
aggValue.upArrow = aggValue.downArrow = false; |
|||
} |
|||
this.updateLastUpdateTs(ts); |
|||
} |
|||
this.cd.detectChanges(); |
|||
} |
|||
} |
|||
|
|||
public onResize() { |
|||
if (this.showChart) { |
|||
this.flot.resize(); |
|||
} |
|||
} |
|||
|
|||
public onEditModeChanged() { |
|||
if (this.showChart) { |
|||
this.flot.checkMouseEvents(); |
|||
} |
|||
} |
|||
|
|||
public onDestroy() { |
|||
if (this.showChart) { |
|||
this.flot.destroy(); |
|||
} |
|||
} |
|||
|
|||
private updateLastUpdateTs(ts: number) { |
|||
if (ts && (!this.lastUpdateTs || ts > this.lastUpdateTs)) { |
|||
this.lastUpdateTs = ts; |
|||
this.dateFormat.update(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 { |
|||
BackgroundSettings, |
|||
BackgroundType, |
|||
ColorProcessor, |
|||
ColorSettings, |
|||
ColorType, |
|||
ComponentStyle, |
|||
constantColor, |
|||
DateFormatSettings, |
|||
Font, |
|||
iconStyle, |
|||
lastUpdateAgoDateFormat, |
|||
textStyle |
|||
} from '@shared/models/widget-settings.models'; |
|||
import { ComparisonResultType, DataKey, DatasourceData } from '@shared/models/widget.models'; |
|||
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { AggregationType } from '@shared/models/time/time.models'; |
|||
|
|||
export interface AggregatedValueCardWidgetSettings { |
|||
showSubtitle: boolean; |
|||
subtitle: string; |
|||
subtitleFont: Font; |
|||
subtitleColor: ColorSettings; |
|||
showDate: boolean; |
|||
dateFormat: DateFormatSettings; |
|||
dateFont: Font; |
|||
dateColor: ColorSettings; |
|||
showChart: boolean; |
|||
chartColor: ColorSettings; |
|||
background: BackgroundSettings; |
|||
} |
|||
|
|||
export enum AggregatedValueCardKeyPosition { |
|||
center = 'center', |
|||
rightTop = 'rightTop', |
|||
rightBottom = 'rightBottom', |
|||
leftTop = 'leftTop', |
|||
leftBottom = 'leftBottom' |
|||
} |
|||
|
|||
export const aggregatedValueCardKeyPositionTranslations = new Map<AggregatedValueCardKeyPosition, string>( |
|||
[ |
|||
[AggregatedValueCardKeyPosition.center, 'widgets.aggregated-value-card.position-center'], |
|||
[AggregatedValueCardKeyPosition.rightTop, 'widgets.aggregated-value-card.position-right-top'], |
|||
[AggregatedValueCardKeyPosition.rightBottom, 'widgets.aggregated-value-card.position-right-bottom'], |
|||
[AggregatedValueCardKeyPosition.leftTop, 'widgets.aggregated-value-card.position-left-top'], |
|||
[AggregatedValueCardKeyPosition.leftBottom, 'widgets.aggregated-value-card.position-left-bottom'] |
|||
] |
|||
); |
|||
|
|||
export interface AggregatedValueCardKeySettings { |
|||
position: AggregatedValueCardKeyPosition; |
|||
font: Font; |
|||
color: ColorSettings; |
|||
showArrow: boolean; |
|||
} |
|||
|
|||
export interface AggregatedValueCardValue { |
|||
key: DataKey; |
|||
value: string; |
|||
units: string; |
|||
style: ComponentStyle; |
|||
color: ColorProcessor; |
|||
center: boolean; |
|||
showArrow: boolean; |
|||
upArrow: boolean; |
|||
downArrow: boolean; |
|||
} |
|||
|
|||
export const computeAggregatedCardValue = (dataKeys: DataKey[], keyName: string, position: AggregatedValueCardKeyPosition): AggregatedValueCardValue => { |
|||
const key = dataKeys.find(dataKey => ( dataKey.name === keyName && (dataKey.settings?.position === position || |
|||
(!dataKey.settings?.position && position === AggregatedValueCardKeyPosition.center)) )); |
|||
if (key) { |
|||
const settings: AggregatedValueCardKeySettings = key.settings; |
|||
return { |
|||
key, |
|||
value: '', |
|||
units: key.units, |
|||
style: textStyle(settings.font, '0.25px'), |
|||
color: ColorProcessor.fromSettings(settings.color), |
|||
center: position === AggregatedValueCardKeyPosition.center, |
|||
showArrow: settings.showArrow, |
|||
upArrow: false, |
|||
downArrow: false |
|||
}; |
|||
} |
|||
}; |
|||
|
|||
export const getTsValueByLatestDataKey = (latestData: Array<DatasourceData>, dataKey: DataKey): [number, any] => { |
|||
if (latestData?.length) { |
|||
const dsData = latestData.find(data => data.dataKey === dataKey); |
|||
if (dsData?.data?.length) { |
|||
return dsData.data[0]; |
|||
} |
|||
} |
|||
return null; |
|||
}; |
|||
|
|||
export const aggregatedValueCardDefaultSettings: AggregatedValueCardWidgetSettings = { |
|||
showSubtitle: true, |
|||
subtitle: '${entityName}', |
|||
subtitleFont: { |
|||
family: 'Roboto', |
|||
size: 12, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '400', |
|||
lineHeight: '16px' |
|||
}, |
|||
subtitleColor: constantColor('rgba(0, 0, 0, 0.38)'), |
|||
showDate: true, |
|||
dateFormat: lastUpdateAgoDateFormat(), |
|||
dateFont: { |
|||
family: 'Roboto', |
|||
size: 12, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '400', |
|||
lineHeight: '16px' |
|||
}, |
|||
dateColor: constantColor('rgba(0, 0, 0, 0.38)'), |
|||
showChart: true, |
|||
chartColor: constantColor('rgba(0, 0, 0, 0.87)'), |
|||
background: { |
|||
type: BackgroundType.color, |
|||
color: '#fff', |
|||
overlay: { |
|||
enabled: false, |
|||
color: 'rgba(255,255,255,0.72)', |
|||
blur: 3 |
|||
} |
|||
} |
|||
}; |
|||
|
|||
export const aggregatedValueCardDefaultKeySettings: AggregatedValueCardKeySettings = { |
|||
position: AggregatedValueCardKeyPosition.center, |
|||
font: { |
|||
family: 'Roboto', |
|||
size: 14, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '500', |
|||
lineHeight: '1' |
|||
}, |
|||
color: constantColor('rgba(0, 0, 0, 0.87)'), |
|||
showArrow: false |
|||
}; |
|||
|
|||
export const createDefaultAggregatedValueLatestDataKeys = (keyName: string, units): DataKey[] => [ |
|||
{ |
|||
name: keyName, label: keyName, type: DataKeyType.timeseries, units, decimals: 0, |
|||
aggregationType: AggregationType.NONE, |
|||
settings: { |
|||
position: AggregatedValueCardKeyPosition.center, |
|||
font: { |
|||
family: 'Roboto', |
|||
size: 52, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '500', |
|||
lineHeight: '1' |
|||
}, |
|||
color: constantColor('rgba(0, 0, 0, 0.87)'), |
|||
showArrow: false |
|||
} as AggregatedValueCardKeySettings |
|||
}, |
|||
{ |
|||
name: keyName, label: 'Delta percent ' + keyName, type: DataKeyType.timeseries, units: '%', decimals: 0, |
|||
aggregationType: AggregationType.AVG, |
|||
comparisonEnabled: true, |
|||
timeForComparison: 'previousInterval', |
|||
comparisonResultType: ComparisonResultType.DELTA_PERCENT, |
|||
settings: { |
|||
position: AggregatedValueCardKeyPosition.rightTop, |
|||
font: { |
|||
family: 'Roboto', |
|||
size: 14, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '500', |
|||
lineHeight: '1' |
|||
}, |
|||
color: { |
|||
color: 'rgba(0, 0, 0, 0.87)', |
|||
type: ColorType.range, |
|||
rangeList: [ |
|||
{to: 0, color: '#198038'}, |
|||
{from: 0, to: 0, color: 'rgba(0, 0, 0, 0.87)'}, |
|||
{from: 0, color: '#D12730'} |
|||
], |
|||
colorFunction: '' |
|||
}, |
|||
showArrow: true |
|||
} as AggregatedValueCardKeySettings |
|||
}, |
|||
{ |
|||
name: keyName, label: 'Delta absolute ' + keyName, type: DataKeyType.timeseries, units, decimals: 1, |
|||
aggregationType: AggregationType.AVG, |
|||
comparisonEnabled: true, |
|||
timeForComparison: 'previousInterval', |
|||
comparisonResultType: ComparisonResultType.DELTA_ABSOLUTE, |
|||
settings: { |
|||
position: AggregatedValueCardKeyPosition.rightBottom, |
|||
font: { |
|||
family: 'Roboto', |
|||
size: 11, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '400', |
|||
lineHeight: '1' |
|||
}, |
|||
color: constantColor('rgba(0, 0, 0, 0.38)'), |
|||
showArrow: false |
|||
} as AggregatedValueCardKeySettings |
|||
} |
|||
]; |
|||
@ -0,0 +1,48 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="aggregatedValueCardKeySettingsForm"> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widgets.aggregated-value-card.value-appearance</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.aggregated-value-card.position' | translate }}</div> |
|||
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="position"> |
|||
<mat-option *ngFor="let position of aggregatedValueCardKeyPositions" [value]="position"> |
|||
{{ aggregatedValueCardKeyPositionTranslationMap.get(position) | translate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.aggregated-value-card.font' | translate }}</div> |
|||
<tb-font-settings formControlName="font" |
|||
previewText="22 °C"> |
|||
</tb-font-settings> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.aggregated-value-card.color' | translate }}</div> |
|||
<tb-color-settings formControlName="color"> |
|||
</tb-color-settings> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="showArrow"> |
|||
{{ 'widgets.aggregated-value-card.display-up-down-arrow' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
</div> |
|||
</ng-container> |
|||
@ -0,0 +1,64 @@ |
|||
///
|
|||
/// 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 { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; |
|||
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { |
|||
aggregatedValueCardDefaultKeySettings, |
|||
AggregatedValueCardKeyPosition, |
|||
aggregatedValueCardKeyPositionTranslations |
|||
} from '@home/components/widget/lib/cards/aggregated-value-card.models'; |
|||
import { constantColor } from '@shared/models/widget-settings.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-aggregated-value-card-key-settings', |
|||
templateUrl: './aggregated-value-card-key-settings.component.html', |
|||
styleUrls: ['./../widget-settings.scss'] |
|||
}) |
|||
export class AggregatedValueCardKeySettingsComponent extends WidgetSettingsComponent { |
|||
|
|||
aggregatedValueCardKeyPositions: AggregatedValueCardKeyPosition[] = |
|||
Object.keys(AggregatedValueCardKeyPosition).map(value => AggregatedValueCardKeyPosition[value]); |
|||
|
|||
aggregatedValueCardKeyPositionTranslationMap = aggregatedValueCardKeyPositionTranslations; |
|||
|
|||
aggregatedValueCardKeySettingsForm: UntypedFormGroup; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store); |
|||
} |
|||
|
|||
protected settingsForm(): UntypedFormGroup { |
|||
return this.aggregatedValueCardKeySettingsForm; |
|||
} |
|||
|
|||
protected defaultSettings(): WidgetSettings { |
|||
return {...aggregatedValueCardDefaultKeySettings}; |
|||
} |
|||
|
|||
protected onSettingsSet(settings: WidgetSettings) { |
|||
this.aggregatedValueCardKeySettingsForm = this.fb.group({ |
|||
position: [settings.position, []], |
|||
font: [settings.font, []], |
|||
color: [settings.color, []], |
|||
showArrow: [settings.showArrow, []] |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<form [formGroup]="notificationSettingsFormGroup" fxLayout="column"> |
|||
<div fxLayout="row" style="height: 48px;"> |
|||
<div fxFlex="50" fxLayoutAlign="start center"> |
|||
<mat-checkbox color="primary" |
|||
[checked]="notificationSettingsFormGroup.get('enabled').value" |
|||
(click)="toggleEnabled()"> |
|||
<span class="notification-type" |
|||
[ngClass]="{'notification-type-disabled': !notificationSettingsFormGroup.get('enabled').value}"> |
|||
{{notificationTemplateTypeTranslateMap.get(notificationSettingsFormGroup.get('name').value)?.name | translate}} |
|||
</span> |
|||
</mat-checkbox> |
|||
</div> |
|||
<div fxFlex fxLayout="row" *ngFor="let deliveryMethods of deliveryMethods"> |
|||
<div fxFlex fxLayoutAlign="start center"> |
|||
<mat-checkbox color="primary" |
|||
[disabled]="!allowDeliveryMethods?.includes(deliveryMethods) || !notificationSettingsFormGroup.get('enabled').value" |
|||
[checked]="getChecked(deliveryMethods)" |
|||
(click)="toggleDeliviryMethod(deliveryMethods)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
@ -0,0 +1,24 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
:host { |
|||
.notification-type { |
|||
font-size: 14px; |
|||
|
|||
&-disabled { |
|||
color: rgba(0, 0, 0, 0.38) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,126 @@ |
|||
///
|
|||
/// 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, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; |
|||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { isDefinedAndNotNull } from '@core/utils'; |
|||
import { Subscription } from 'rxjs'; |
|||
import { |
|||
NotificationDeliveryMethod, |
|||
NotificationTemplateTypeTranslateMap, |
|||
NotificationUserSetting |
|||
} from '@shared/models/notification.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-notification-setting-form', |
|||
templateUrl: './notification-setting-form.component.html', |
|||
styleUrls: ['./notification-setting-form.component.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => NotificationSettingFormComponent), |
|||
multi: true |
|||
} |
|||
] |
|||
}) |
|||
export class NotificationSettingFormComponent implements ControlValueAccessor, OnInit, OnDestroy { |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
@Input() |
|||
deliveryMethods: NotificationDeliveryMethod[] = []; |
|||
|
|||
@Input() |
|||
allowDeliveryMethods: NotificationDeliveryMethod[] = []; |
|||
|
|||
notificationSettingsFormGroup: UntypedFormGroup; |
|||
|
|||
notificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap; |
|||
|
|||
private propagateChange = null; |
|||
|
|||
private valueChange$: Subscription = null; |
|||
|
|||
constructor(private utils: UtilsService, |
|||
private fb: UntypedFormBuilder) { |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
} |
|||
|
|||
ngOnInit() { |
|||
const deliveryMethod = {}; |
|||
this.deliveryMethods.forEach(value => { |
|||
deliveryMethod[value] = true; |
|||
}); |
|||
this.notificationSettingsFormGroup = this.fb.group( |
|||
{ |
|||
name: [''], |
|||
enabled: [true], |
|||
enabledDeliveryMethods: this.fb.group({ |
|||
...deliveryMethod |
|||
}) |
|||
}); |
|||
this.valueChange$ = this.notificationSettingsFormGroup.valueChanges.subscribe(() => { |
|||
this.updateModel(); |
|||
}); |
|||
} |
|||
|
|||
ngOnDestroy() { |
|||
if (this.valueChange$) { |
|||
this.valueChange$.unsubscribe(); |
|||
this.valueChange$ = null; |
|||
} |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (this.disabled) { |
|||
this.notificationSettingsFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.notificationSettingsFormGroup.enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
toggleEnabled() { |
|||
this.notificationSettingsFormGroup.get('enabled').patchValue(!this.notificationSettingsFormGroup.get('enabled').value); |
|||
} |
|||
|
|||
getChecked(deliveryMethod: NotificationDeliveryMethod): boolean { |
|||
return this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod).value; |
|||
} |
|||
|
|||
toggleDeliviryMethod(deliveryMethod: NotificationDeliveryMethod) { |
|||
this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod) |
|||
.patchValue(!this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod).value); |
|||
} |
|||
|
|||
writeValue(value: NotificationUserSetting): void { |
|||
if (isDefinedAndNotNull(value)) { |
|||
this.notificationSettingsFormGroup.patchValue(value, {emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
private updateModel() { |
|||
this.propagateChange(this.notificationSettingsFormGroup.value); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue