Browse Source

Merge pull request #8522 from thingsboard/feature/push-notifications

Notifications to mobile app
pull/10219/head
Andrew Shvayka 3 years ago
committed by GitHub
parent
commit
a8e1ac9257
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      application/pom.xml
  2. 13
      application/src/main/data/upgrade/3.6.2/schema_update.sql
  3. 2
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  4. 2
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  5. 2
      application/src/main/java/org/thingsboard/server/controller/NotificationTemplateController.java
  6. 27
      application/src/main/java/org/thingsboard/server/controller/UserController.java
  7. 6
      application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java
  8. 10
      application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java
  9. 103
      application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java
  10. 2
      application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java
  11. 133
      application/src/main/java/org/thingsboard/server/service/notification/provider/DefaultFirebaseService.java
  12. 4
      application/src/main/java/org/thingsboard/server/service/notification/provider/DefaultSlackService.java
  13. 4
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  14. 32
      application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java
  15. 83
      application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java
  16. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationSettingsService.java
  17. 10
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  18. 23
      common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileSessionInfo.java
  19. 27
      common/data/src/main/java/org/thingsboard/server/common/data/mobile/UserMobileInfo.java
  20. 3
      common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationDeliveryMethod.java
  21. 35
      common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java
  22. 3
      common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java
  23. 1
      common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java
  24. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java
  25. 3
      common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java
  26. 64
      common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java
  27. 9
      common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java
  28. 8
      dao/src/main/java/org/thingsboard/server/dao/model/sql/UserSettingsEntity.java
  29. 10
      dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java
  30. 1
      dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java
  31. 8
      dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserSettingsDao.java
  32. 7
      dao/src/main/java/org/thingsboard/server/dao/sql/user/UserSettingsRepository.java
  33. 1
      dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
  34. 43
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  35. 5
      dao/src/main/java/org/thingsboard/server/dao/user/UserSettingsDao.java
  36. 2
      dao/src/main/resources/sql/schema-entities.sql
  37. 3
      dao/src/test/resources/application-test.properties
  38. 6
      pom.xml
  39. 2
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  40. 26
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/notification/FirebaseService.java
  41. 2
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/notification/SlackService.java
  42. 89
      ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html
  43. 7
      ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss
  44. 22
      ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts
  45. 5
      ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts
  46. 10
      ui-ngx/src/app/modules/home/pages/notification/notification.module.ts
  47. 3
      ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts
  48. 2
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.html
  49. 4
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts
  50. 413
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html
  51. 52
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss
  52. 19
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts
  53. 4
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-table-config.resolver.ts
  54. 2
      ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html
  55. 4
      ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts
  56. 83
      ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html
  57. 168
      ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.ts
  58. 261
      ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html
  59. 72
      ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.scss
  60. 235
      ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts
  61. 145
      ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.ts
  62. 312
      ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html
  63. 44
      ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.scss
  64. 23
      ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts
  65. 2
      ui-ngx/src/app/shared/components/notification/template-autocomplete.component.html
  66. 4
      ui-ngx/src/app/shared/components/notification/template-autocomplete.component.ts
  67. 7
      ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.ts
  68. 69
      ui-ngx/src/app/shared/models/notification.models.ts
  69. 13
      ui-ngx/src/assets/locale/locale.constant-en_US.json

4
application/pom.xml

@ -358,6 +358,10 @@
<groupId>com.google.oauth-client</groupId>
<artifactId>google-oauth-client</artifactId>
</dependency>
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
</dependency>
</dependencies>
<build>

13
application/src/main/data/upgrade/3.6.2/schema_update.sql

@ -28,3 +28,16 @@ ALTER TABLE rule_node ADD COLUMN IF NOT EXISTS queue_name varchar(255);
ALTER TABLE component_descriptor ADD COLUMN IF NOT EXISTS has_queue_name boolean DEFAULT false;
-- RULE NODE QUEUE UPDATE END
DO
$$
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = 'user_settings' AND column_name = 'settings' AND data_type = 'jsonb') THEN
ALTER TABLE user_settings RENAME COLUMN settings to old_settings;
ALTER TABLE user_settings ADD COLUMN settings jsonb;
UPDATE user_settings SET settings = old_settings::jsonb WHERE old_settings IS NOT NULL;
ALTER TABLE user_settings DROP COLUMN old_settings;
END IF;
END;
$$;

2
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -33,7 +33,7 @@ import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineDeviceStateManager;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.rule.engine.api.slack.SlackService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
import org.thingsboard.script.api.js.JsInvokeService;
import org.thingsboard.script.api.tbel.TbelInvokeService;

2
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -35,7 +35,7 @@ import org.thingsboard.rule.engine.api.ScriptEngine;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.slack.SlackService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
import org.thingsboard.rule.engine.util.TenantIdLoader;
import org.thingsboard.server.actors.ActorSystemContext;

2
application/src/main/java/org/thingsboard/server/controller/NotificationTemplateController.java

@ -29,7 +29,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.rule.engine.api.slack.SlackService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.NotificationTemplateId;

27
application/src/main/java/org/thingsboard/server/controller/UserController.java

@ -25,11 +25,14 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@ -49,6 +52,7 @@ 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.common.data.id.UserId;
import org.thingsboard.server.common.data.mobile.MobileSessionInfo;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.query.EntityDataPageLink;
@ -117,6 +121,7 @@ public class UserController extends BaseController {
public static final String PATHS = "paths";
public static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!";
public static final String ACTIVATE_URL_PATTERN = "%s/api/noauth/activate?activateToken=%s";
public static final String MOBILE_TOKEN_HEADER = "X-Mobile-Token";
@Value("${security.user_token_access_enabled}")
private boolean userTokenAccessEnabled;
@ -584,6 +589,28 @@ public class UserController extends BaseController {
return userSettingsService.reportUserDashboardAction(currentUser.getTenantId(), currentUser.getId(), dashboardId, action);
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping("/user/mobile/session")
public MobileSessionInfo getMobileSession(@RequestHeader(MOBILE_TOKEN_HEADER) String mobileToken,
@AuthenticationPrincipal SecurityUser user) {
return userService.findMobileSession(user.getTenantId(), user.getId(), mobileToken);
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PostMapping("/user/mobile/session")
public void saveMobileSession(@RequestBody MobileSessionInfo sessionInfo,
@RequestHeader(MOBILE_TOKEN_HEADER) String mobileToken,
@AuthenticationPrincipal SecurityUser user) {
userService.saveMobileSession(user.getTenantId(), user.getId(), mobileToken, sessionInfo);
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@DeleteMapping("/user/mobile/session")
public void removeMobileSession(@RequestHeader(MOBILE_TOKEN_HEADER) String mobileToken,
@AuthenticationPrincipal SecurityUser user) {
userService.removeMobileSession(user.getTenantId(), mobileToken);
}
private void checkNotReserved(String strType, UserSettingsType type) throws ThingsboardException {
if (type.isReserved()) {
throw new ThingsboardException("Settings with type: " + strType + " are reserved for internal use!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);

6
application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java

@ -154,6 +154,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple
}
}
NotificationSettings settings = notificationSettingsService.findNotificationSettings(tenantId);
NotificationSettings systemSettings = tenantId.isSysTenantId() ? settings : notificationSettingsService.findNotificationSettings(TenantId.SYS_TENANT_ID);
log.debug("Processing notification request (tenantId: {}, targets: {})", tenantId, request.getTargets());
request.setStatus(NotificationRequestStatus.PROCESSING);
@ -165,6 +166,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple
.deliveryMethods(deliveryMethods)
.template(notificationTemplate)
.settings(settings)
.systemSettings(systemSettings)
.build();
processNotificationRequestAsync(ctx, targets, callback);
@ -243,11 +245,11 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple
if (targetConfig.getUsersFilter().getType().isForRules() && ctx.getRequest().getInfo() instanceof RuleOriginatedNotificationInfo) {
recipients = new PageDataIterable<>(pageLink -> {
return notificationTargetService.findRecipientsForRuleNotificationTargetConfig(ctx.getTenantId(), targetConfig, (RuleOriginatedNotificationInfo) ctx.getRequest().getInfo(), pageLink);
}, 500);
}, 256);
} else {
recipients = new PageDataIterable<>(pageLink -> {
return notificationTargetService.findRecipientsForNotificationTargetConfig(ctx.getTenantId(), targetConfig, pageLink);
}, 500);
}, 256);
}
break;
}

10
application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java

@ -43,6 +43,7 @@ public class NotificationProcessingContext {
@Getter
private final TenantId tenantId;
private final NotificationSettings settings;
private final NotificationSettings systemSettings;
@Getter
private final NotificationRequest request;
@Getter
@ -58,11 +59,12 @@ public class NotificationProcessingContext {
@Builder
public NotificationProcessingContext(TenantId tenantId, NotificationRequest request, Set<NotificationDeliveryMethod> deliveryMethods,
NotificationTemplate template, NotificationSettings settings) {
NotificationTemplate template, NotificationSettings settings, NotificationSettings systemSettings) {
this.tenantId = tenantId;
this.request = request;
this.deliveryMethods = deliveryMethods;
this.settings = settings;
this.systemSettings = systemSettings;
this.notificationTemplate = template;
this.notificationType = template.getNotificationType();
this.templates = new EnumMap<>(NotificationDeliveryMethod.class);
@ -81,6 +83,12 @@ public class NotificationProcessingContext {
}
public <C extends NotificationDeliveryMethodConfig> C getDeliveryMethodConfig(NotificationDeliveryMethod deliveryMethod) {
NotificationSettings settings;
if (deliveryMethod == NotificationDeliveryMethod.MOBILE_APP) {
settings = this.systemSettings;
} else {
settings = this.settings;
}
return (C) settings.getDeliveryMethodsConfigs().get(deliveryMethod);
}

103
application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java

@ -0,0 +1,103 @@
/**
* Copyright © 2016-2024 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.notification.channels;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.MessagingErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.notification.FirebaseService;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.info.NotificationInfo;
import org.thingsboard.server.common.data.notification.settings.MobileAppNotificationDeliveryMethodConfig;
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
import org.thingsboard.server.common.data.notification.template.MobileAppDeliveryMethodNotificationTemplate;
import org.thingsboard.server.dao.notification.NotificationSettingsService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.service.notification.NotificationProcessingContext;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@Component
@RequiredArgsConstructor
@Slf4j
public class MobileAppNotificationChannel implements NotificationChannel<User, MobileAppDeliveryMethodNotificationTemplate> {
private final FirebaseService firebaseService;
private final UserService userService;
private final NotificationSettingsService notificationSettingsService;
@Override
public void sendNotification(User recipient, MobileAppDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws Exception {
var mobileSessions = userService.findMobileSessions(recipient.getTenantId(), recipient.getId());
if (mobileSessions.isEmpty()) {
throw new IllegalArgumentException("User doesn't use the mobile app");
}
MobileAppNotificationDeliveryMethodConfig config = ctx.getDeliveryMethodConfig(NotificationDeliveryMethod.MOBILE_APP);
String credentials = config.getFirebaseServiceAccountCredentials();
Set<String> validTokens = new HashSet<>(mobileSessions.keySet());
String subject = processedTemplate.getSubject();
String body = processedTemplate.getBody();
Map<String, String> data = Optional.ofNullable(processedTemplate.getAdditionalConfig())
.map(JacksonUtil::toFlatMap).orElseGet(HashMap::new);
Optional.ofNullable(ctx.getRequest().getInfo())
.map(NotificationInfo::getStateEntityId)
.ifPresent(stateEntityId -> {
data.put("stateEntityId", stateEntityId.getId().toString());
data.put("stateEntityType", stateEntityId.getEntityType().name());
});
for (String token : mobileSessions.keySet()) {
try {
firebaseService.sendMessage(ctx.getTenantId(), credentials, token, subject, body, data);
} catch (FirebaseMessagingException e) {
MessagingErrorCode errorCode = e.getMessagingErrorCode();
if (errorCode == MessagingErrorCode.UNREGISTERED || errorCode == MessagingErrorCode.INVALID_ARGUMENT) {
validTokens.remove(token);
userService.removeMobileSession(recipient.getTenantId(), token);
continue;
}
throw new RuntimeException("Failed to send message via FCM: " + e.getMessage(), e);
}
}
if (validTokens.isEmpty()) {
throw new IllegalArgumentException("User doesn't use the mobile app");
}
}
@Override
public void check(TenantId tenantId) throws Exception {
NotificationSettings systemSettings = notificationSettingsService.findNotificationSettings(TenantId.SYS_TENANT_ID);
if (!systemSettings.getDeliveryMethodsConfigs().containsKey(NotificationDeliveryMethod.MOBILE_APP)) {
throw new RuntimeException("Push-notifications to mobile are not configured");
}
}
@Override
public NotificationDeliveryMethod getDeliveryMethod() {
return NotificationDeliveryMethod.MOBILE_APP;
}
}

2
application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java

@ -17,7 +17,7 @@ package org.thingsboard.server.service.notification.channels;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.thingsboard.rule.engine.api.slack.SlackService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;

133
application/src/main/java/org/thingsboard/server/service/notification/provider/DefaultFirebaseService.java

@ -0,0 +1,133 @@
/**
* Copyright © 2016-2024 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.notification.provider;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.RemovalCause;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.AndroidConfig;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.springframework.stereotype.Service;
import org.thingsboard.rule.engine.api.notification.FirebaseService;
import org.thingsboard.server.common.data.id.TenantId;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class DefaultFirebaseService implements FirebaseService {
private final Cache<String, FirebaseContext> contexts = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.DAYS)
.<String, FirebaseContext>removalListener((key, context, cause) -> {
if (cause == RemovalCause.EXPIRED && context != null) {
context.destroy();
}
})
.build();
@Override
public void sendMessage(TenantId tenantId, String credentials, String fcmToken, String title, String body, Map<String, String> data) throws FirebaseMessagingException {
FirebaseContext firebaseContext = contexts.asMap().compute(tenantId.toString(), (key, context) -> {
if (context == null) {
return new FirebaseContext(key, credentials);
} else {
context.check(credentials);
return context;
}
});
Message message = Message.builder()
.setToken(fcmToken)
.setNotification(Notification.builder()
.setTitle(title)
.setBody(body)
.build())
.setAndroidConfig(AndroidConfig.builder()
.setPriority(AndroidConfig.Priority.HIGH)
.build())
.putAllData(data)
.build();
firebaseContext.getMessaging().send(message);
log.trace("[{}] Sent message for FCM token {}", tenantId, fcmToken);
}
public static class FirebaseContext {
private final String key;
private String credentials;
private FirebaseApp app;
@Getter
private FirebaseMessaging messaging;
public FirebaseContext(String key, String credentials) {
this.key = key;
this.credentials = credentials;
init();
}
private void init() {
FirebaseOptions options;
try {
options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(IOUtils.toInputStream(credentials, StandardCharsets.UTF_8)))
.build();
} catch (IOException e) {
throw new RuntimeException("Failed to process service account credentials: " + e.getMessage(), e);
}
try {
app = FirebaseApp.initializeApp(options, key);
} catch (IllegalStateException alreadyExists) { // should never normally happen
app = FirebaseApp.getInstance(key);
}
try {
messaging = FirebaseMessaging.getInstance(app);
} catch (IllegalStateException alreadyExists) { // should never normally happen
messaging = FirebaseMessaging.getInstance(app);
}
log.debug("[{}] Initialized new FirebaseContext", key);
}
public void check(String credentials) {
if (!this.credentials.equals(credentials)) {
destroy();
this.credentials = credentials;
init();
} else if (app == null || messaging == null) {
throw new IllegalStateException("Firebase app couldn't be initialized");
}
}
public void destroy() {
app.delete();
app = null;
messaging = null;
log.debug("[{}] Destroyed FirebaseContext", key);
}
}
}

4
application/src/main/java/org/thingsboard/server/service/slack/DefaultSlackService.java → application/src/main/java/org/thingsboard/server/service/notification/provider/DefaultSlackService.java

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.slack;
package org.thingsboard.server.service.notification.provider;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
@ -29,7 +29,7 @@ import com.slack.api.methods.response.users.UsersListResponse;
import com.slack.api.model.ConversationType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.thingsboard.rule.engine.api.slack.SlackService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;

4
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -189,6 +189,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected String token;
protected String refreshToken;
protected String mobileToken;
protected String username;
protected TenantId tenantId;
@ -573,6 +574,9 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
if (this.token != null) {
request.header(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM, "Bearer " + this.token);
}
if (this.mobileToken != null) {
request.header(UserController.MOBILE_TOKEN_HEADER, this.mobileToken);
}
}
protected DeviceProfile createDeviceProfile(String name) {

32
application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java

@ -22,7 +22,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.util.Pair;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.slack.SlackService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.id.NotificationRequestId;
import org.thingsboard.server.common.data.id.NotificationTargetId;
@ -41,6 +41,7 @@ import org.thingsboard.server.common.data.notification.rule.DefaultNotificationR
import org.thingsboard.server.common.data.notification.rule.NotificationRule;
import org.thingsboard.server.common.data.notification.rule.NotificationRuleInfo;
import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.settings.NotificationDeliveryMethodConfig;
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig;
@ -48,6 +49,8 @@ import org.thingsboard.server.common.data.notification.targets.platform.UserList
import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter;
import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.HasSubject;
import org.thingsboard.server.common.data.notification.template.MobileAppDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig;
import org.thingsboard.server.common.data.notification.template.SmsDeliveryMethodNotificationTemplate;
@ -59,6 +62,7 @@ import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.notification.NotificationRequestService;
import org.thingsboard.server.dao.notification.NotificationRuleService;
import org.thingsboard.server.dao.notification.NotificationSettingsService;
import org.thingsboard.server.dao.notification.NotificationTargetService;
import org.thingsboard.server.dao.notification.NotificationTemplateService;
import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository;
@ -92,6 +96,8 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest
@Autowired
protected NotificationRequestService notificationRequestService;
@Autowired
protected NotificationSettingsService notificationSettingsService;
@Autowired
protected SqlPartitioningRepository partitioningRepository;
public static final String DEFAULT_NOTIFICATION_SUBJECT = "Just a test";
@ -104,6 +110,7 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest
notificationTemplateService.deleteNotificationTemplatesByTenantId(TenantId.SYS_TENANT_ID);
notificationTargetService.deleteNotificationTargetsByTenantId(TenantId.SYS_TENANT_ID);
partitioningRepository.dropPartitionsBefore("notification", Long.MAX_VALUE, 1);
notificationSettingsService.deleteNotificationSettings(TenantId.SYS_TENANT_ID);
}
protected NotificationTarget createNotificationTarget(UserId... usersIds) {
@ -168,26 +175,28 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest
DeliveryMethodNotificationTemplate deliveryMethodNotificationTemplate;
switch (deliveryMethod) {
case WEB: {
WebDeliveryMethodNotificationTemplate template = new WebDeliveryMethodNotificationTemplate();
template.setSubject(subject);
deliveryMethodNotificationTemplate = template;
deliveryMethodNotificationTemplate = new WebDeliveryMethodNotificationTemplate();
break;
}
case EMAIL: {
EmailDeliveryMethodNotificationTemplate template = new EmailDeliveryMethodNotificationTemplate();
template.setSubject(subject);
deliveryMethodNotificationTemplate = template;
deliveryMethodNotificationTemplate = new EmailDeliveryMethodNotificationTemplate();
break;
}
case SMS: {
deliveryMethodNotificationTemplate = new SmsDeliveryMethodNotificationTemplate();
break;
}
case MOBILE_APP:
deliveryMethodNotificationTemplate = new MobileAppDeliveryMethodNotificationTemplate();
break;
default:
throw new IllegalArgumentException("Unsupported delivery method " + deliveryMethod);
}
deliveryMethodNotificationTemplate.setEnabled(true);
deliveryMethodNotificationTemplate.setBody(text);
if (deliveryMethodNotificationTemplate instanceof HasSubject) {
((HasSubject) deliveryMethodNotificationTemplate).setSubject(subject);
}
config.getDeliveryMethodsTemplates().put(deliveryMethod, deliveryMethodNotificationTemplate);
}
notificationTemplate.setConfiguration(config);
@ -202,6 +211,15 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest
doPost("/api/notification/settings", notificationSettings).andExpect(status().isOk());
}
protected void saveNotificationSettings(NotificationDeliveryMethodConfig... configs) throws Exception {
NotificationSettings settings = new NotificationSettings();
settings.setDeliveryMethodsConfigs(Arrays.stream(configs)
.collect(Collectors.toMap(
NotificationDeliveryMethodConfig::getMethod, config -> config
)));
saveNotificationSettings(settings);
}
protected Pair<User, NotificationApiWsClient> createUserAndConnectWsClient(Authority authority) throws Exception {
User user = new User();
user.setTenantId(tenantId);

83
application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java

@ -24,8 +24,12 @@ import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.web.client.RestTemplate;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.notification.FirebaseService;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
@ -34,6 +38,7 @@ import org.thingsboard.server.common.data.id.NotificationRequestId;
import org.thingsboard.server.common.data.id.NotificationRuleId;
import org.thingsboard.server.common.data.id.NotificationTargetId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.mobile.MobileSessionInfo;
import org.thingsboard.server.common.data.notification.Notification;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.NotificationRequest;
@ -44,11 +49,13 @@ import org.thingsboard.server.common.data.notification.NotificationRequestStats;
import org.thingsboard.server.common.data.notification.NotificationRequestStatus;
import org.thingsboard.server.common.data.notification.NotificationType;
import org.thingsboard.server.common.data.notification.info.EntityActionNotificationInfo;
import org.thingsboard.server.common.data.notification.settings.MobileAppNotificationDeliveryMethodConfig;
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig;
import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings;
import org.thingsboard.server.common.data.notification.targets.MicrosoftTeamsNotificationTargetConfig;
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig;
import org.thingsboard.server.common.data.notification.targets.platform.SystemAdministratorsFilter;
@ -60,6 +67,7 @@ import org.thingsboard.server.common.data.notification.template.DeliveryMethodNo
import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.MicrosoftTeamsDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.MicrosoftTeamsDeliveryMethodNotificationTemplate.Button.LinkType;
import org.thingsboard.server.common.data.notification.template.MobileAppDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig;
import org.thingsboard.server.common.data.notification.template.SlackDeliveryMethodNotificationTemplate;
@ -70,7 +78,6 @@ import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.notification.DefaultNotifications;
import org.thingsboard.server.dao.notification.NotificationDao;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.service.executors.DbCallbackExecutorService;
import org.thingsboard.server.service.notification.channels.MicrosoftTeamsNotificationChannel;
import org.thingsboard.server.service.ws.notification.cmd.UnreadNotificationsUpdate;
@ -86,11 +93,16 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.type;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
@Slf4j
@ -101,9 +113,9 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
@Autowired
private NotificationDao notificationDao;
@Autowired
private DbCallbackExecutorService executor;
@Autowired
private MicrosoftTeamsNotificationChannel microsoftTeamsNotificationChannel;
@MockBean
private FirebaseService firebaseService;
@Before
public void beforeEach() throws Exception {
@ -708,6 +720,71 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
assertThat(message.getPotentialAction().get(0).getTargets().get(0).getUri()).isEqualTo("https://" + expectedParams);
}
@Test
public void testMobileAppNotifications() throws Exception {
loginSysAdmin();
MobileAppNotificationDeliveryMethodConfig config = new MobileAppNotificationDeliveryMethodConfig();
config.setFirebaseServiceAccountCredentials("testCredentials");
saveNotificationSettings(config);
loginCustomerUser();
mobileToken = "customerFcmToken";
doPost("/api/user/mobile/session", new MobileSessionInfo()).andExpect(status().isOk());
loginTenantAdmin();
mobileToken = "tenantFcmToken1";
doPost("/api/user/mobile/session", new MobileSessionInfo()).andExpect(status().isOk());
mobileToken = "tenantFcmToken2";
doPost("/api/user/mobile/session", new MobileSessionInfo()).andExpect(status().isOk());
loginDifferentCustomer(); // with no mobile info
loginTenantAdmin();
NotificationTarget target = createNotificationTarget(new AllUsersFilter());
NotificationTemplate template = createNotificationTemplate(NotificationType.GENERAL, "Title", "Message", NotificationDeliveryMethod.MOBILE_APP);
((MobileAppDeliveryMethodNotificationTemplate) template.getConfiguration().getDeliveryMethodsTemplates().get(NotificationDeliveryMethod.MOBILE_APP))
.setAdditionalConfig(JacksonUtil.newObjectNode().set("test", JacksonUtil.newObjectNode().put("test", "test")));
saveNotificationTemplate(template);
NotificationRequest request = submitNotificationRequest(List.of(target.getId()), template.getId(), 0);
NotificationRequestStats stats = awaitNotificationRequest(request.getId());
assertThat(stats.getSent().get(NotificationDeliveryMethod.MOBILE_APP)).hasValue(2);
assertThat(stats.getErrors().get(NotificationDeliveryMethod.MOBILE_APP).get(differentCustomerUser.getEmail()))
.contains("doesn't use the mobile app");
verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"),
eq("tenantFcmToken1"), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test"))));
verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"),
eq("tenantFcmToken2"), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test"))));
verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"),
eq("customerFcmToken"), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test"))));
verifyNoMoreInteractions(firebaseService);
clearInvocations(firebaseService);
doDelete("/api/user/mobile/session").andExpect(status().isOk());
request = submitNotificationRequest(List.of(target.getId()), template.getId(), 0);
awaitNotificationRequest(request.getId());
verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"),
eq("tenantFcmToken1"), eq("Title"), eq("Message"), anyMap());
verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"),
eq("customerFcmToken"), eq("Title"), eq("Message"), anyMap());
verifyNoMoreInteractions(firebaseService);
}
@Test
public void testMobileSettings_tenantLevel() throws Exception {
MobileAppNotificationDeliveryMethodConfig config = new MobileAppNotificationDeliveryMethodConfig();
config.setFirebaseServiceAccountCredentials("testCredentials");
NotificationSettings settings = new NotificationSettings();
settings.setDeliveryMethodsConfigs(Map.of(
NotificationDeliveryMethod.MOBILE_APP, config
));
ResultActions result = doPost("/api/notification/settings", settings)
.andExpect(status().isBadRequest());
assertThat(getErrorMessage(result)).contains("can only be configured by system administrator");
}
private NotificationRequestStats submitNotificationRequestAndWait(NotificationRequest notificationRequest) throws Exception {
SettableFuture<NotificationRequestStats> future = SettableFuture.create();
notificationCenter.processNotificationRequest(notificationRequest.getTenantId(), notificationRequest, new FutureCallback<>() {

2
common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationSettingsService.java

@ -26,6 +26,8 @@ public interface NotificationSettingsService {
NotificationSettings findNotificationSettings(TenantId tenantId);
void deleteNotificationSettings(TenantId tenantId);
UserNotificationSettings saveUserNotificationSettings(TenantId tenantId, UserId userId, UserNotificationSettings settings);
UserNotificationSettings getUserNotificationSettings(TenantId tenantId, UserId userId, boolean format);

10
common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java

@ -17,6 +17,7 @@ package org.thingsboard.server.dao.user;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.mobile.MobileSessionInfo;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.dao.entity.EntityDaoService;
import java.util.List;
import java.util.Map;
public interface UserService extends EntityDaoService {
@ -89,4 +91,12 @@ public interface UserService extends EntityDaoService {
void setLastLoginTs(TenantId tenantId, UserId userId);
void saveMobileSession(TenantId tenantId, UserId userId, String mobileToken, MobileSessionInfo sessionInfo);
Map<String, MobileSessionInfo> findMobileSessions(TenantId tenantId, UserId userId);
MobileSessionInfo findMobileSession(TenantId tenantId, UserId userId, String mobileToken);
void removeMobileSession(TenantId tenantId, String mobileToken);
}

23
common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileSessionInfo.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2024 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.mobile;
import lombok.Data;
@Data
public class MobileSessionInfo {
private long fcmTokenTimestamp;
}

27
common/data/src/main/java/org/thingsboard/server/common/data/mobile/UserMobileInfo.java

@ -0,0 +1,27 @@
/**
* Copyright © 2016-2024 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.mobile;
import lombok.Data;
import java.util.Map;
@Data
public class UserMobileInfo {
private Map<String, MobileSessionInfo> sessions;
}

3
common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationDeliveryMethod.java

@ -25,7 +25,8 @@ public enum NotificationDeliveryMethod {
EMAIL("email"),
SMS("SMS"),
SLACK("Slack"),
MICROSOFT_TEAMS("Microsoft Teams");
MICROSOFT_TEAMS("Microsoft Teams"),
MOBILE_APP("mobile app");
@Getter
private final String name;

35
common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java

@ -0,0 +1,35 @@
/**
* Copyright © 2016-2024 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 lombok.Data;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import javax.validation.constraints.NotEmpty;
@Data
public class MobileAppNotificationDeliveryMethodConfig implements NotificationDeliveryMethodConfig {
private String firebaseServiceAccountCredentialsFileName;
@NotEmpty
private String firebaseServiceAccountCredentials;
@Override
public NotificationDeliveryMethod getMethod() {
return NotificationDeliveryMethod.MOBILE_APP;
}
}

3
common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java

@ -27,7 +27,8 @@ import java.io.Serializable;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "method")
@JsonSubTypes({
@Type(name = "SLACK", value = SlackNotificationDeliveryMethodConfig.class)
@Type(name = "SLACK", value = SlackNotificationDeliveryMethodConfig.class),
@Type(name = "MOBILE_APP", value = MobileAppNotificationDeliveryMethodConfig.class)
})
public interface NotificationDeliveryMethodConfig extends Serializable {

1
common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java

@ -28,7 +28,6 @@ public class NotificationSettings implements Serializable {
@NotNull
@Valid
// location on the screen, shown notifications count, timings of displaying
private Map<NotificationDeliveryMethod, NotificationDeliveryMethodConfig> deliveryMethodsConfigs;
}

2
common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java

@ -25,7 +25,7 @@ import java.util.Set;
@RequiredArgsConstructor
public enum NotificationTargetType {
PLATFORM_USERS(Set.of(NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.EMAIL, NotificationDeliveryMethod.SMS)),
PLATFORM_USERS(Set.of(NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.EMAIL, NotificationDeliveryMethod.SMS, NotificationDeliveryMethod.MOBILE_APP)),
SLACK(Set.of(NotificationDeliveryMethod.SLACK)),
MICROSOFT_TEAMS(Set.of(NotificationDeliveryMethod.MICROSOFT_TEAMS));

3
common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java

@ -34,7 +34,8 @@ import java.util.List;
@Type(name = "EMAIL", value = EmailDeliveryMethodNotificationTemplate.class),
@Type(name = "SMS", value = SmsDeliveryMethodNotificationTemplate.class),
@Type(name = "SLACK", value = SlackDeliveryMethodNotificationTemplate.class),
@Type(name = "MICROSOFT_TEAMS", value = MicrosoftTeamsDeliveryMethodNotificationTemplate.class)
@Type(name = "MICROSOFT_TEAMS", value = MicrosoftTeamsDeliveryMethodNotificationTemplate.class),
@Type(name = "MOBILE_APP", value = MobileAppDeliveryMethodNotificationTemplate.class)
})
@Data
@NoArgsConstructor

64
common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java

@ -0,0 +1,64 @@
/**
* Copyright © 2016-2024 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.template;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class MobileAppDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject {
@NotEmpty
private String subject;
private JsonNode additionalConfig;
private final List<TemplatableValue> templatableValues = List.of(
TemplatableValue.of(this::getBody, this::setBody),
TemplatableValue.of(this::getSubject, this::setSubject)
);
public MobileAppDeliveryMethodNotificationTemplate(MobileAppDeliveryMethodNotificationTemplate other) {
super(other);
this.subject = other.subject;
this.additionalConfig = other.additionalConfig;
}
@Override
public NotificationDeliveryMethod getMethod() {
return NotificationDeliveryMethod.MOBILE_APP;
}
@Override
public MobileAppDeliveryMethodNotificationTemplate copy() {
return new MobileAppDeliveryMethodNotificationTemplate(this);
}
@Override
public List<TemplatableValue> getTemplatableValues() {
return templatableValues;
}
}

9
common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java

@ -19,7 +19,14 @@ import lombok.Getter;
public enum UserSettingsType {
GENERAL, VISITED_DASHBOARDS(true), QUICK_LINKS, DOC_LINKS, DASHBOARDS, GETTING_STARTED, NOTIFICATIONS;
GENERAL,
VISITED_DASHBOARDS(true),
QUICK_LINKS,
DOC_LINKS,
DASHBOARDS,
GETTING_STARTED,
NOTIFICATIONS,
MOBILE(true);
@Getter
private final boolean reserved;

8
dao/src/main/java/org/thingsboard/server/dao/model/sql/UserSettingsEntity.java

@ -26,7 +26,7 @@ import org.thingsboard.server.common.data.settings.UserSettingsCompositeKey;
import org.thingsboard.server.common.data.settings.UserSettingsType;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.model.ToData;
import org.thingsboard.server.dao.util.mapping.JsonStringType;
import org.thingsboard.server.dao.util.mapping.JsonBinaryType;
import javax.persistence.Column;
import javax.persistence.Entity;
@ -37,7 +37,7 @@ import java.util.UUID;
@Data
@NoArgsConstructor
@TypeDef(name = "json", typeClass = JsonStringType.class)
@TypeDef(name = "jsonb", typeClass = JsonBinaryType.class)
@Entity
@Table(name = ModelConstants.USER_SETTINGS_TABLE_NAME)
@IdClass(UserSettingsCompositeKey.class)
@ -49,8 +49,8 @@ public class UserSettingsEntity implements ToData<UserSettings> {
@Id
@Column(name = ModelConstants.USER_SETTINGS_TYPE_PROPERTY)
private String type;
@Type(type = "json")
@Column(name = ModelConstants.USER_SETTINGS_SETTINGS)
@Type(type = "jsonb")
@Column(name = ModelConstants.USER_SETTINGS_SETTINGS, columnDefinition = "jsonb")
private JsonNode settings;
public UserSettingsEntity(UserSettings userSettings) {

10
dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java

@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.NotificationType;
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings;
@ -72,6 +73,9 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS
@CacheEvict(cacheNames = CacheConstants.NOTIFICATION_SETTINGS_CACHE, key = "#tenantId")
@Override
public void saveNotificationSettings(TenantId tenantId, NotificationSettings settings) {
if (!tenantId.isSysTenantId() && settings.getDeliveryMethodsConfigs().containsKey(NotificationDeliveryMethod.MOBILE_APP)) {
throw new IllegalArgumentException("Mobile settings can only be configured by system administrator");
}
AdminSettings adminSettings = Optional.ofNullable(adminSettingsService.findAdminSettingsByTenantIdAndKey(tenantId, SETTINGS_KEY))
.orElseGet(() -> {
AdminSettings newAdminSettings = new AdminSettings();
@ -95,6 +99,12 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS
});
}
@CacheEvict(cacheNames = CacheConstants.NOTIFICATION_SETTINGS_CACHE, key = "#tenantId")
@Override
public void deleteNotificationSettings(TenantId tenantId) {
adminSettingsService.deleteAdminSettingsByTenantIdAndKey(tenantId, SETTINGS_KEY);
}
@Override
public UserNotificationSettings saveUserNotificationSettings(TenantId tenantId, UserId userId, UserNotificationSettings settings) {
UserSettings userSettings = new UserSettings();

1
dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java

@ -33,7 +33,6 @@ import org.thingsboard.server.dao.user.UserDao;
import org.thingsboard.server.dao.util.SqlDao;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;

8
dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserSettingsDao.java

@ -21,12 +21,15 @@ import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.settings.UserSettings;
import org.thingsboard.server.common.data.settings.UserSettingsCompositeKey;
import org.thingsboard.server.common.data.settings.UserSettingsType;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.model.sql.UserSettingsEntity;
import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService;
import org.thingsboard.server.dao.user.UserSettingsDao;
import org.thingsboard.server.dao.util.SqlDao;
import java.util.List;
@Slf4j
@Component
@SqlDao
@ -50,4 +53,9 @@ public class JpaUserSettingsDao extends JpaAbstractDaoListeningExecutorService i
userSettingsRepository.deleteById(id);
}
@Override
public List<UserSettings> findByTypeAndPath(TenantId tenantId, UserSettingsType type, String... path) {
return DaoUtil.convertDataList(userSettingsRepository.findByTypeAndPathExisting(type.name(), path));
}
}

7
dao/src/main/java/org/thingsboard/server/dao/sql/user/UserSettingsRepository.java

@ -16,9 +16,16 @@
package org.thingsboard.server.dao.sql.user;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.settings.UserSettingsCompositeKey;
import org.thingsboard.server.dao.model.sql.UserSettingsEntity;
import java.util.List;
public interface UserSettingsRepository extends JpaRepository<UserSettingsEntity, UserSettingsCompositeKey> {
@Query(value = "SELECT * FROM user_settings WHERE type = :type AND (settings #> :path) IS NOT NULL", nativeQuery = true)
List<UserSettingsEntity> findByTypeAndPathExisting(@Param("type") String type, @Param("path") String[] path);
}

1
dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java

@ -241,6 +241,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService<TenantId, Ten
notificationRuleService.deleteNotificationRulesByTenantId(tenantId);
notificationTemplateService.deleteNotificationTemplatesByTenantId(tenantId);
notificationTargetService.deleteNotificationTargetsByTenantId(tenantId);
notificationSettingsService.deleteNotificationSettings(tenantId);
adminSettingsService.deleteAdminSettingsByTenantId(tenantId);
tenantDao.removeById(tenantId, tenantId.getId());
publishEvictEvent(new TenantEvictEvent(tenantId, true));

43
dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java

@ -38,11 +38,15 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.id.UserCredentialsId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.mobile.MobileSessionInfo;
import org.thingsboard.server.common.data.mobile.UserMobileInfo;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent;
import org.thingsboard.server.common.data.settings.UserSettings;
import org.thingsboard.server.common.data.settings.UserSettingsType;
import org.thingsboard.server.dao.entity.AbstractEntityService;
import org.thingsboard.server.dao.entity.EntityCountService;
import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent;
@ -52,6 +56,7 @@ import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -85,6 +90,8 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic
private final UserDao userDao;
private final UserCredentialsDao userCredentialsDao;
private final UserAuthSettingsDao userAuthSettingsDao;
private final UserSettingsService userSettingsService;
private final UserSettingsDao userSettingsDao;
private final DataValidator<User> userValidator;
private final DataValidator<UserCredentials> userCredentialsValidator;
private final ApplicationEventPublisher eventPublisher;
@ -391,6 +398,42 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic
saveUser(tenantId, user);
}
@Override
public void saveMobileSession(TenantId tenantId, UserId userId, String mobileToken, MobileSessionInfo sessionInfo) {
removeMobileSession(tenantId, mobileToken); // unassigning fcm token from other users, in case we didn't clean up it on log out or mobile app uninstall
UserMobileInfo mobileInfo = findMobileInfo(tenantId, userId).orElseGet(() -> {
UserMobileInfo newMobileInfo = new UserMobileInfo();
newMobileInfo.setSessions(new HashMap<>());
return newMobileInfo;
});
mobileInfo.getSessions().put(mobileToken, sessionInfo);
userSettingsService.updateUserSettings(tenantId, userId, UserSettingsType.MOBILE, JacksonUtil.valueToTree(mobileInfo));
}
@Override
public Map<String, MobileSessionInfo> findMobileSessions(TenantId tenantId, UserId userId) {
return findMobileInfo(tenantId, userId).map(UserMobileInfo::getSessions).orElse(Collections.emptyMap());
}
@Override
public MobileSessionInfo findMobileSession(TenantId tenantId, UserId userId, String mobileToken) {
return findMobileInfo(tenantId, userId).map(mobileInfo -> mobileInfo.getSessions().get(mobileToken)).orElse(null);
}
@Override
public void removeMobileSession(TenantId tenantId, String mobileToken) {
for (UserSettings userSettings : userSettingsDao.findByTypeAndPath(tenantId, UserSettingsType.MOBILE, "sessions", mobileToken)) {
((ObjectNode) userSettings.getSettings().get("sessions")).remove(mobileToken);
userSettingsService.saveUserSettings(tenantId, userSettings);
}
}
private Optional<UserMobileInfo> findMobileInfo(TenantId tenantId, UserId userId) {
return Optional.ofNullable(userSettingsService.findUserSettings(tenantId, userId, UserSettingsType.MOBILE))
.map(UserSettings::getSettings).map(settings -> JacksonUtil.treeToValue(settings, UserMobileInfo.class));
}
@Override
public int increaseFailedLoginAttempts(TenantId tenantId, UserId userId) {
log.trace("Executing onUserLoginIncorrectCredentials [{}]", userId);

5
dao/src/main/java/org/thingsboard/server/dao/user/UserSettingsDao.java

@ -18,6 +18,9 @@ package org.thingsboard.server.dao.user;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.settings.UserSettings;
import org.thingsboard.server.common.data.settings.UserSettingsCompositeKey;
import org.thingsboard.server.common.data.settings.UserSettingsType;
import java.util.List;
public interface UserSettingsDao {
@ -27,4 +30,6 @@ public interface UserSettingsDao {
void removeById(TenantId tenantId, UserSettingsCompositeKey key);
List<UserSettings> findByTypeAndPath(TenantId tenantId, UserSettingsType type, String... path);
}

2
dao/src/main/resources/sql/schema-entities.sql

@ -873,7 +873,7 @@ CREATE TABLE IF NOT EXISTS notification (
CREATE TABLE IF NOT EXISTS user_settings (
user_id uuid NOT NULL,
type VARCHAR(50) NOT NULL,
settings varchar(10000),
settings jsonb,
CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES tb_user(id) ON DELETE CASCADE,
CONSTRAINT user_settings_pkey PRIMARY KEY (user_id, type)
);

3
dao/src/test/resources/application-test.properties

@ -68,6 +68,9 @@ cache.specs.edges.maxSize=100000
cache.specs.notificationRules.timeToLiveInMinutes=1440
cache.specs.notificationRules.maxSize=10000
cache.specs.notificationSettings.timeToLiveInMinutes=1440
cache.specs.notificationSettings.maxSize=10000
cache.specs.dashboardTitles.timeToLiveInMinutes=1440
cache.specs.dashboardTitles.maxSize=10000

6
pom.xml

@ -156,6 +156,7 @@
<google-oauth-client.version>1.34.1</google-oauth-client.version>
<apache-xmlgraphics.version>1.17</apache-xmlgraphics.version>
<drewnoakes-metadata-extractor.version>2.19.0</drewnoakes-metadata-extractor.version>
<firebase-admin.version>8.0.1</firebase-admin.version>
</properties>
<modules>
@ -2018,6 +2019,11 @@
<artifactId>slack-api-client</artifactId>
<version>${slack-api.version}</version>
</dependency>
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
<version>${firebase-admin.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jgit</groupId>
<artifactId>org.eclipse.jgit</artifactId>

2
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -18,7 +18,7 @@ package org.thingsboard.rule.engine.api;
import io.netty.channel.EventLoopGroup;
import org.thingsboard.common.util.ExecutorProvider;
import org.thingsboard.common.util.ListeningExecutor;
import org.thingsboard.rule.engine.api.slack.SlackService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.Customer;

26
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/notification/FirebaseService.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2024 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.rule.engine.api.notification;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.Map;
public interface FirebaseService {
void sendMessage(TenantId tenantId, String credentials, String fcmToken, String title, String body, Map<String, String> data) throws Exception;
}

2
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/slack/SlackService.java → rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/notification/SlackService.java

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.api.slack;
package org.thingsboard.rule.engine.api.notification;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation;

89
ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html

@ -26,27 +26,26 @@
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<mat-card-content style="padding-top: 16px;">
<mat-card-content>
<form [formGroup]="smsProvider" (ngSubmit)="save()">
<fieldset [disabled]="isLoading$ | async">
<tb-sms-provider-configuration
required
formControlName="configuration">
</tb-sms-provider-configuration>
<div fxLayout="row" fxLayoutAlign="end center" fxLayout.xs="column" fxLayoutAlign.xs="end" fxLayoutGap="16px">
<button mat-raised-button type="button"
[disabled]="(isLoading$ | async) || smsProvider.invalid" (click)="sendTestSms()">
{{'admin.send-test-sms' | translate}}
</button>
<button mat-raised-button color="primary" [disabled]="(isLoading$ | async) || smsProvider.invalid || !smsProvider.dirty"
type="submit">{{'action.save' | translate}}
</button>
</div>
</fieldset>
<tb-sms-provider-configuration
required
formControlName="configuration">
</tb-sms-provider-configuration>
<div fxLayout="row" fxLayoutAlign="end center" fxLayout.xs="column" fxLayoutAlign.xs="end" fxLayoutGap="16px">
<button mat-raised-button type="button"
[disabled]="(isLoading$ | async) || smsProvider.invalid" (click)="sendTestSms()">
{{'admin.send-test-sms' | translate}}
</button>
<button mat-raised-button color="primary" [disabled]="(isLoading$ | async) || smsProvider.invalid || !smsProvider.dirty"
type="submit">{{'action.save' | translate}}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="settings-card">
<form [formGroup]="notificationSettingsForm" (ngSubmit)="saveNotification()">
<mat-card appearance="outlined" class="settings-card" formGroupName="deliveryMethodsConfigs">
<mat-card-header>
<mat-card-title>
<span class="mat-headline-5" translate>admin.slack-settings</span>
@ -54,24 +53,38 @@
<span fxFlex></span>
<div tb-help="slackSettings"></div>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<mat-card-content style="padding-top: 16px;">
<form [formGroup]="slackSettingsForm" (ngSubmit)="saveNotification()">
<fieldset [disabled]="isLoading$ | async" formGroupName="deliveryMethodsConfigs">
<section formGroupName="SLACK">
<mat-form-field class="mat-block">
<mat-label translate>admin.slack-api-token</mat-label>
<input matInput formControlName="botToken" />
</mat-form-field>
</section>
<div fxLayout="row" fxLayoutAlign="end center" class="layout-wrap">
<button mat-button mat-raised-button color="primary" [disabled]="(isLoading$ | async) || slackSettingsForm.invalid || !slackSettingsForm.dirty"
type="submit">{{'action.save' | translate}}
</button>
</div>
</fieldset>
</form>
</mat-card-content>
</mat-card>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<mat-card-content formGroupName="SLACK">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>admin.slack-api-token</mat-label>
<input matInput formControlName="botToken"/>
</mat-form-field>
</mat-card-content>
<mat-card-header *ngIf="isSysAdmin()">
<mat-card-title>
<span class="mat-headline-5" translate>admin.mobile-settings</span>
</mat-card-title>
<span fxFlex></span>
<!-- <div tb-help="mobileSettings"></div>-->
</mat-card-header>
<mat-card-content formGroupName="MOBILE_APP" *ngIf="isSysAdmin()">
<tb-file-input formControlName="firebaseServiceAccountCredentials"
dropLabel="{{ 'admin.select-firebase-service-account-file' | translate }}"
label="{{ 'admin.firebase-service-account-file' | translate }}"
accept=".json,application/json"
allowedExtensions="json"
[existingFileName]="notificationSettingsForm.get('deliveryMethodsConfigs.MOBILE_APP.firebaseServiceAccountCredentialsFileName')?.value"
(fileNameChanged)="notificationSettingsForm?.get('deliveryMethodsConfigs.MOBILE_APP.firebaseServiceAccountCredentialsFileName').patchValue($event)">
</tb-file-input>
</mat-card-content>
<mat-card-actions fxLayoutAlign="end center" fxLayout.xs="column" fxLayoutAlign.xs="end">
<button mat-button mat-raised-button color="primary"
[disabled]="(isLoading$ | async) || notificationSettingsForm.invalid || !notificationSettingsForm.dirty"
type="submit">{{'action.save' | translate}}
</button>
</mat-card-actions>
</mat-card>
</form>

7
ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss

@ -14,5 +14,12 @@
* limitations under the License.
*/
:host {
.mat-mdc-card-header {
align-items: center;
min-height: 64px;
}
.mdc-card__actions {
padding: 16px;
}
}

22
ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts

@ -26,7 +26,7 @@ import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { MatDialog } from '@angular/material/dialog';
import { SendTestSmsDialogComponent, SendTestSmsDialogData } from '@home/pages/admin/send-test-sms-dialog.component';
import { NotificationSettings } from '@shared/models/notification.models';
import { deepTrim, isEmptyStr } from '@core/utils';
import { deepTrim, isNotEmptyStr } from '@core/utils';
import { NotificationService } from '@core/http/notification.service';
import { Authority } from '@shared/models/authority.enum';
import { AuthUser } from '@shared/models/user.model';
@ -42,7 +42,7 @@ export class SmsProviderComponent extends PageComponent implements HasConfirmFor
smsProvider: FormGroup;
private adminSettings: AdminSettings<SmsProviderConfiguration>;
slackSettingsForm: FormGroup;
notificationSettingsForm: FormGroup;
private notificationSettings: NotificationSettings;
private readonly authUser: AuthUser;
@ -60,7 +60,7 @@ export class SmsProviderComponent extends PageComponent implements HasConfirmFor
this.notificationService.getNotificationSettings().subscribe(
(settings) => {
this.notificationSettings = settings;
this.slackSettingsForm.reset(this.notificationSettings);
this.notificationSettingsForm.reset(this.notificationSettings);
}
);
if (this.isSysAdmin()) {
@ -108,29 +108,33 @@ export class SmsProviderComponent extends PageComponent implements HasConfirmFor
}
confirmForm(): FormGroup {
return this.smsProvider.dirty ? this.smsProvider : this.slackSettingsForm;
return this.smsProvider.dirty ? this.smsProvider : this.notificationSettingsForm;
}
private buildGeneralServerSettingsForm() {
this.slackSettingsForm = this.fb.group({
this.notificationSettingsForm = this.fb.group({
deliveryMethodsConfigs: this.fb.group({
SLACK: this.fb.group({
botToken: ['']
}),
MOBILE_APP: this.fb.group({
firebaseServiceAccountCredentialsFileName: [''],
firebaseServiceAccountCredentials: ['']
})
})
});
this.registerDisableOnLoadFormControl(this.slackSettingsForm.get('deliveryMethodsConfigs'));
this.registerDisableOnLoadFormControl(this.notificationSettingsForm.get('deliveryMethodsConfigs'));
}
saveNotification(): void {
this.notificationSettings = deepTrim({
...this.notificationSettings,
...this.slackSettingsForm.value
...this.notificationSettingsForm.value
});
// eslint-disable-next-line guard-for-in
for (const method in this.notificationSettings.deliveryMethodsConfigs) {
const keys = Object.keys(this.notificationSettings.deliveryMethodsConfigs[method]);
if (keys.some(item => isEmptyStr(this.notificationSettings.deliveryMethodsConfigs[method][item]))) {
if (keys.some(item => !isNotEmptyStr(this.notificationSettings.deliveryMethodsConfigs[method][item]))) {
delete this.notificationSettings.deliveryMethodsConfigs[method];
} else {
this.notificationSettings.deliveryMethodsConfigs[method].method = method;
@ -138,7 +142,7 @@ export class SmsProviderComponent extends PageComponent implements HasConfirmFor
}
this.notificationService.saveNotificationSettings(this.notificationSettings).subscribe(setting => {
this.notificationSettings = setting;
this.slackSettingsForm.reset(this.notificationSettings);
this.notificationSettingsForm.reset(this.notificationSettings);
});
}

5
ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts

@ -137,9 +137,10 @@ export class InboxTableConfigResolver implements Resolve<EntityTableConfig<Notif
this.config.getTable().dataSource.pageData$.pipe(take(1)).subscribe(
(value) => {
if (value.data.length === 1 && this.config.getTable().pageLink.page) {
this.config.getTable().pageLink.page--;
this.config.getTable().paginator.previousPage();
} else {
this.config.updateData();
}
this.config.updateData();
}
);
} else {

10
ui-ngx/src/app/modules/home/pages/notification/notification.module.ts

@ -39,6 +39,12 @@ import { NotificationSettingsComponent } from '@home/pages/notification/settings
import {
NotificationSettingFormComponent
} from '@home/pages/notification/settings/notification-setting-form.component';
import {
NotificationTemplateConfigurationComponent
} from '@home/pages/notification/template/configuration/notification-template-configuration.component';
import {
NotificationActionButtonConfigurationComponent
} from '@home/pages/notification/template/configuration/notification-action-button-configuration.component';
@NgModule({
declarations: [
@ -55,7 +61,9 @@ import {
RuleNotificationDialogComponent,
RuleTableHeaderComponent,
NotificationSettingsComponent,
NotificationSettingFormComponent
NotificationSettingFormComponent,
NotificationTemplateConfigurationComponent,
NotificationActionButtonConfigurationComponent
],
imports: [
CommonModule,

3
ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts

@ -383,6 +383,9 @@ export class RuleNotificationDialogComponent extends
changeStep($event: StepperSelectionEvent) {
this.selectedIndex = $event.selectedIndex;
if ($event.previouslySelectedIndex > $event.selectedIndex) {
$event.previouslySelectedStep.interacted = false;
}
}
backStep() {

2
ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.html

@ -26,7 +26,7 @@
</mat-toolbar>
<div mat-dialog-content>
<div *ngFor="let errorStat of errorStats | keyvalue; last as isLast">
<h6>{{ notificationDeliveryMethodTranslateMap.get(errorStat.key) | translate }}</h6>
<h6>{{ NotificationDeliveryMethodInfoMap.get(errorStat.key).name | translate }}</h6>
<table class="tb-table-list">
<tr *ngFor="let error of errorStat.value | keyvalue">
<td>

4
ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts

@ -22,7 +22,7 @@ import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
NotificationDeliveryMethod,
NotificationDeliveryMethodTranslateMap,
NotificationDeliveryMethodInfoMap,
NotificationRequest
} from '@shared/models/notification.models';
@ -39,7 +39,7 @@ export class SentErrorDialogComponent extends DialogComponent<SentErrorDialogCom
errorStats: { [key in NotificationDeliveryMethod]: {[errorKey in string]: string}};
notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap;
NotificationDeliveryMethodInfoMap = NotificationDeliveryMethodInfoMap;
constructor(protected store: Store<AppState>,
protected router: Router,

413
ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html

@ -29,7 +29,7 @@
</mat-progress-bar>
<div mat-dialog-content>
<mat-horizontal-stepper linear #createNotification
labelPosition="bottom"
labelPosition="end"
[orientation]="(stepperOrientation | async)"
(selectionChange)="changeStep($event)">
<ng-template matStepperIcon="edit">
@ -38,11 +38,10 @@
<mat-step [stepControl]="notificationRequestForm">
<ng-template matStepLabel>{{ 'notification.compose' | translate }}</ng-template>
<form [formGroup]="notificationRequestForm">
<div fxLayout="row" fxLayoutAlign="center">
<tb-toggle-select class="tb-notification-use-template-toggle-group" appearance="fill"
formControlName="useTemplate">
<tb-toggle-option [value]="false">{{ 'notification.start-from-scratch' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="true">{{ 'notification.use-template' | translate }}</tb-toggle-option>
<div fxLayoutAlign="center">
<tb-toggle-select formControlName="useTemplate" appearance="fill">
<tb-toggle-option [value]="false">{{ 'notification.start-from-scratch' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="true">{{ 'notification.use-template' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div *ngIf="notificationRequestForm.get('useTemplate').value; else scratchTemplate">
@ -61,7 +60,7 @@
<div>
<label [ngClass]="{'tb-error': notificationRequestForm.get('template.configuration.deliveryMethodsTemplates').hasError('atLeastOne')}"
class="tb-title tb-required">{{ "notification.delivery-methods" | translate }}</label>
<div class="tb-hint" translate>notification.at-least-one-should-be-selected</div>
<div class="tb-form-hint" translate>notification.at-least-one-should-be-selected</div>
</div>
<button
matTooltip="{{ 'notification.refresh-allow-delivery-method' | translate }}"
@ -75,26 +74,26 @@
<section formGroupName="deliveryMethodsTemplates" class="delivery-methods-container">
<ng-container *ngFor="let deliveryMethods of notificationDeliveryMethods">
<a *ngIf="isInteractDeliveryMethod(deliveryMethods); else deliveryMethod"
class="delivery-method-container interact"
class="tb-form-panel stroked delivery-method-container tb-pointer interact"
[routerLink]="configurationPage(deliveryMethods)"
[formGroupName]="deliveryMethods"
[matTooltip]="getDeliveryMethodsTooltip(deliveryMethods)"
matTooltipPosition="above">
<mat-slide-toggle class="delivery-method" formControlName="enabled">
{{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }}
{{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }}
</mat-slide-toggle>
<mat-icon *ngIf="isInteractDeliveryMethod(deliveryMethods)">
chevron_right
</mat-icon>
</a>
<ng-template #deliveryMethod>
<section class="delivery-method-container"
<section class="delivery-method-container tb-form-panel stroked"
[formGroupName]="deliveryMethods"
[matTooltip]="getDeliveryMethodsTooltip(deliveryMethods)"
[matTooltipDisabled]="getDeliveryMethodsTemplatesControl(deliveryMethods).enabled"
matTooltipPosition="above">
<mat-slide-toggle class="delivery-method" formControlName="enabled">
{{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }}
{{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }}
</mat-slide-toggle>
</section>
</ng-template>
@ -119,7 +118,7 @@
</button>
</tb-entity-list>
</ng-template>
<section formGroupName="additionalConfig" class="additional-config-group">
<section formGroupName="additionalConfig" class="tb-form-panel stroked no-padding-bottom no-gap">
<mat-slide-toggle formControlName="enabled" class="toggle">
{{ 'notification.scheduler-later' | translate }}
</mat-slide-toggle>
@ -138,304 +137,16 @@
</section>
</form>
</mat-step>
<mat-step *ngIf="!notificationRequestForm.get('useTemplate').value &&
notificationRequestForm.get('template.configuration.deliveryMethodsTemplates.WEB.enabled').value"
[stepControl]="webTemplateForm">
<ng-template matStepLabel>{{ 'notification.delivery-method.web' | translate }}</ng-template>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-fields-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(notificationType.GENERAL).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '820px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="webTemplateForm">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="webTemplateForm.get('subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="webTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<section formGroupName="additionalConfig" class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between" formGroupName="icon">
<mat-slide-toggle formControlName="enabled" class="mat-slide">
{{ 'icon.icon' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-material-icon-select asBoxInput
[color]="webTemplateForm.get('additionalConfig.icon.color').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="color">
</tb-color-input>
</div>
</div>
<div class="tb-form-panel tb-slide-toggle stroked" formGroupName="actionButtonConfig">
<mat-expansion-panel class="tb-settings" [expanded]="webTemplateForm.get('additionalConfig.actionButtonConfig.enabled').value">
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title fxFlex="60">
<mat-slide-toggle class="mat-slide" formControlName="enabled" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'notification.action-button' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>notification.button-text</mat-label>
<input matInput formControlName="text" required>
<mat-error *ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.text').hasError('required')">
{{ 'notification.button-text-required' | translate }}
</mat-error>
<mat-error *ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.text').hasError('maxlength')">
{{ 'notification.button-text-max-length' | translate :
{length: webTemplateForm.get('additionalConfig.actionButtonConfig.text').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field fxFlex="30" fxFlex.xs="100">
<mat-label translate>notification.action-type</mat-label>
<mat-select formControlName="linkType">
<mat-option *ngFor="let actionButtonLinkType of actionButtonLinkTypes" [value]="actionButtonLinkType">
{{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex
*ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.linkType').value === actionButtonLinkType.LINK; else dashboardSelector">
<mat-label translate>notification.link</mat-label>
<input matInput formControlName="link" required>
<mat-error *ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.link').hasError('required')">
{{ 'notification.link-required' | translate }}
</mat-error>
</mat-form-field>
<ng-template #dashboardSelector>
<tb-dashboard-autocomplete
fxFlex="35" fxFlex.xs="100"
required
formControlName="dashboardId">
</tb-dashboard-autocomplete>
<tb-dashboard-state-autocomplete fxFlex="35" fxFlex.xs="100"
[dashboardId]="webTemplateForm.get('additionalConfig.actionButtonConfig.dashboardId').value"
formControlName="dashboardState">
</tb-dashboard-state-autocomplete>
</ng-template>
</div>
<mat-slide-toggle formControlName="setEntityIdInState" class="toggle"
*ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.linkType').value === actionButtonLinkType.DASHBOARD">
{{ 'notification.set-entity-from-notification' | translate }}
</mat-slide-toggle>
</ng-template>
</mat-expansion-panel>
</div>
</section>
</form>
</mat-step>
<mat-step *ngIf="!notificationRequestForm.get('useTemplate').value &&
notificationRequestForm.get('template.configuration.deliveryMethodsTemplates.EMAIL.enabled').value"
[stepControl]="emailTemplateForm" #emailStep="matStep">
<ng-template matStepLabel>{{ 'notification.delivery-method.email' | translate }}</ng-template>
<ng-template matStepContent>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-fields-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(notificationType.GENERAL).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '820px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="emailTemplateForm">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="emailTemplateForm.get('subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
</mat-form-field>
<mat-label class="tb-title tb-required"
[ngClass]="{'tb-error': (emailStep.interacted || emailTemplateForm.get('body').dirty) && emailTemplateForm.get('body').hasError('required')}"
translate>notification.message</mat-label>
<editor [init]="tinyMceOptions" formControlName="body"></editor>
<mat-error class="tb-mat-error"
*ngIf="(emailStep.interacted || emailTemplateForm.get('body').dirty) && emailTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</form>
</ng-template>
</mat-step>
<mat-step *ngIf="!notificationRequestForm.get('useTemplate').value &&
notificationRequestForm.get('template.configuration.deliveryMethodsTemplates.SMS.enabled').value"
[stepControl]="smsTemplateForm">
<ng-template matStepLabel>{{ 'notification.delivery-method.sms' | translate }}</ng-template>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-field-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(notificationType.GENERAL).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '820px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="smsTemplateForm">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="smsTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
<mat-error *ngIf="smsTemplateForm.get('body').hasError('maxlength')">
{{ 'notification.message-max-length' | translate :
{length: smsTemplateForm.get('body').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</form>
</mat-step>
<mat-step *ngIf="!notificationRequestForm.get('useTemplate').value &&
notificationRequestForm.get('template.configuration.deliveryMethodsTemplates.SLACK.enabled').value"
[stepControl]="slackTemplateForm">
<ng-template matStepLabel>{{ 'notification.delivery-method.slack' | translate }}</ng-template>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-field-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(notificationType.GENERAL).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '820px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="slackTemplateForm" fxLayoutGap="8px">
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="slackTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
</form>
</mat-step>
<mat-step *ngIf="!notificationRequestForm.get('useTemplate').value &&
notificationRequestForm.get('template.configuration.deliveryMethodsTemplates.MICROSOFT_TEAMS.enabled').value"
[stepControl]="microsoftTeamsTemplateForm">
<ng-template matStepLabel>{{ 'notification.delivery-method.microsoft-teams' | translate }}</ng-template>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-fields-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(templateNotificationForm.get('notificationType').value).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '800px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="microsoftTeamsTemplateForm">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="microsoftTeamsTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between">
<div translate>notification.theme-color</div>
<tb-color-input asBoxInput formControlName="themeColor"></tb-color-input>
</div>
<div class="tb-form-panel tb-slide-toggle stroked" formGroupName="button">
<mat-expansion-panel class="tb-settings" [expanded]="microsoftTeamsTemplateForm.get('button.enabled').value">
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title fxFlex="60">
<mat-slide-toggle class="mat-slide" formControlName="enabled" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'notification.action-button' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>notification.button-text</mat-label>
<input matInput formControlName="text" required>
<mat-error *ngIf="microsoftTeamsTemplateForm.get('button.text').hasError('required')">
{{ 'notification.button-text-required' | translate }}
</mat-error>
<mat-error *ngIf="microsoftTeamsTemplateForm.get('button.text').hasError('maxlength')">
{{ 'notification.button-text-max-length' | translate :
{length: microsoftTeamsTemplateForm.get('button.text').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field fxFlex="30" fxFlex.xs="100">
<mat-label translate>notification.action-type</mat-label>
<mat-select formControlName="linkType">
<mat-option *ngFor="let actionButtonLinkType of actionButtonLinkTypes" [value]="actionButtonLinkType">
{{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex
*ngIf="microsoftTeamsTemplateForm.get('button.linkType').value === actionButtonLinkType.LINK; else dashboardSelector">
<mat-label translate>notification.link</mat-label>
<input matInput formControlName="link" required>
<mat-error *ngIf="microsoftTeamsTemplateForm.get('button.link').hasError('required')">
{{ 'notification.link-required' | translate }}
</mat-error>
</mat-form-field>
<ng-template #dashboardSelector>
<tb-dashboard-autocomplete
fxFlex="35" fxFlex.xs="100"
required
formControlName="dashboardId">
</tb-dashboard-autocomplete>
<tb-dashboard-state-autocomplete fxFlex="35" fxFlex.xs="100"
[dashboardId]="microsoftTeamsTemplateForm.get('button.dashboardId').value"
formControlName="dashboardState">
</tb-dashboard-state-autocomplete>
</ng-template>
</div>
<mat-slide-toggle formControlName="setEntityIdInState" class="toggle"
*ngIf="microsoftTeamsTemplateForm.get('button.linkType').value === actionButtonLinkType.DASHBOARD">
{{ 'notification.set-entity-from-notification' | translate }}
</mat-slide-toggle>
</ng-template>
</mat-expansion-panel>
</div>
</div>
<mat-step *ngIf="!notificationRequestForm.get('useTemplate').value"
[stepControl]="notificationTemplateConfigurationForm" #composeStep=matStep>
<ng-template matStepLabel>{{ 'notification.compose' | translate }}</ng-template>
<form [formGroup]="notificationTemplateConfigurationForm">
<tb-template-configuration
[notificationType]="templateNotificationForm.get('notificationType').value"
[predefinedDeliveryMethodsTemplate]="deliveryConfiguration"
[interacted]="composeStep.interacted"
formControlName="deliveryMethodsTemplates">
</tb-template-configuration>
</form>
</mat-step>
<mat-step>
@ -443,71 +154,81 @@
<mat-progress-spinner color="warn" mode="indeterminate"
strokeWidth="5" *ngIf="(isLoading$ | async) && !preview">
</mat-progress-spinner>
<div *ngIf="preview" style="padding-bottom: 16px">
<section class="preview-group notification" *ngIf="preview.processedTemplates.WEB?.enabled">
<div *ngIf="preview" class="tb-form-panel no-padding no-border">
<section class="preview-group tb-form-panel stroked no-gap">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<mat-icon class="tb-mat-18" svgIcon="mdi:bell-badge"></mat-icon>
<div class="group-title" translate>notification.delivery-method.web-preview</div>
<tb-icon class="tb-mat-18">supervisor_account</tb-icon>
<div class="tb-form-panel-title">{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}</div>
</div>
<div class="details-recipients" *ngIf="notificationRequestForm.get('targets').value?.length > 1">
<div *ngFor="let detail of preview.recipientsCountByTarget | keyvalue" class="details-recipient">
<span class="number">{{ detail.value }}</span>{{ detail.key }}
</div>
</div>
<mat-divider class="divider"></mat-divider>
<mat-chip-listbox>
<mat-chip *ngFor="let recipientTitle of preview.recipientsPreview">
<span>{{ recipientTitle }}</span>
</mat-chip>
</mat-chip-listbox>
</section>
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.WEB?.enabled">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<tb-icon class="tb-mat-18">mdi:bell-badge</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.web-preview</div>
</div>
<div class="web-preview">
<tb-notification preview [notification]="preview.processedTemplates.WEB"></tb-notification>
</div>
</section>
<section class="preview-group notification" *ngIf="preview.processedTemplates.EMAIL?.enabled">
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.MOBILE_APP?.enabled">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<mat-icon class="tb-mat-18" svgIcon="mdi:email"></mat-icon>
<div class="group-title" translate>notification.delivery-method.email-preview</div>
<tb-icon class="tb-mat-18">mdi:cellphone-text</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.mobile-app-preview</div>
</div>
<div class="notification-content">
<div class="subject">{{ preview.processedTemplates.EMAIL.subject }}</div>
<mat-divider></mat-divider>
<div class="html-content" [innerHTML]="(preview.processedTemplates.EMAIL.body | safe: 'html')"></div>
<div class="subject">{{ preview.processedTemplates.MOBILE_APP.subject }}</div>
<div>{{ preview.processedTemplates.MOBILE_APP.body }}</div>
</div>
</section>
<section class="preview-group notification" *ngIf="preview.processedTemplates.SMS?.enabled">
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.SMS?.enabled">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<mat-icon class="tb-mat-18" svgIcon="mdi:message-processing"></mat-icon>
<div class="group-title" translate>notification.delivery-method.sms-preview</div>
<tb-icon class="tb-mat-18">mdi:message-processing</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.sms-preview</div>
</div>
<div class="notification-content">
{{ preview.processedTemplates.SMS.body }}
</div>
</section>
<section class="preview-group notification" *ngIf="preview.processedTemplates.SLACK?.enabled">
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.EMAIL?.enabled">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<mat-icon class="tb-mat-18" svgIcon="mdi:slack"></mat-icon>
<div class="group-title" translate>notification.delivery-method.slack-preview</div>
<tb-icon class="tb-mat-18">mdi:email</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.email-preview</div>
</div>
<div class="notification-content">
{{ preview.processedTemplates.SLACK.body }}
<div class="subject">{{ preview.processedTemplates.EMAIL.subject }}</div>
<mat-divider></mat-divider>
<div class="html-content" [innerHTML]="(preview.processedTemplates.EMAIL.body | safe: 'html')"></div>
</div>
</section>
<section class="preview-group notification" *ngIf="preview.processedTemplates.MICROSOFT_TEAMS?.enabled">
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.SLACK?.enabled">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<mat-icon class="tb-mat-18" svgIcon="mdi:microsoft-teams"></mat-icon>
<div class="group-title" translate>notification.delivery-method.microsoft-teams-preview</div>
<tb-icon class="tb-mat-18">mdi:slack</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.slack-preview</div>
</div>
<div class="notification-content mini">
<div class="subject">{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}</div>
{{ preview.processedTemplates.MICROSOFT_TEAMS.body }}
<div class="notification-content">
{{ preview.processedTemplates.SLACK.body }}
</div>
</section>
<section class="preview-group">
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.MICROSOFT_TEAMS?.enabled">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<mat-icon class="tb-mat-18">supervisor_account</mat-icon>
<div class="group-title">{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}</div>
<tb-icon class="tb-mat-18">mdi:microsoft-teams</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.microsoft-teams-preview</div>
</div>
<div class="details-recipients" *ngIf="notificationRequestForm.get('targets').value?.length > 1">
<div *ngFor="let detail of preview.recipientsCountByTarget | keyvalue" class="details-recipient">
<span class="number">{{ detail.value }}</span>{{ detail.key }}
</div>
<div class="notification-content mini">
<div class="subject">{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}</div>
{{ preview.processedTemplates.MICROSOFT_TEAMS.body }}
</div>
<mat-divider class="divider"></mat-divider>
<mat-chip-listbox>
<mat-chip *ngFor="let recipientTitle of preview.recipientsPreview">
<span>{{ recipientTitle }}</span>
</mat-chip>
</mat-chip-listbox>
</section>
</div>
</mat-step>

52
ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss

@ -16,7 +16,7 @@
@import "../../../../../../scss/constants";
:host {
width: 820px;
width: 780px;
height: 100%;
max-width: 100%;
max-height: 100vh;
@ -64,7 +64,7 @@
font-size: 13px;
}
.tb-hint {
.tb-form-hint {
padding: 0 0 8px;
}
@ -88,20 +88,14 @@
.delivery-methods-container {
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, auto));
gap: 8px;
.delivery-method-container {
display: inline-flex;
flex: 1 1 calc(50% - 8px);
max-width: calc(50% - 8px);
padding: 16px 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
flex-direction: row;
&.interact {
cursor: pointer;
color: inherit;
}
@ -112,32 +106,17 @@
}
}
.additional-config-group {
padding: 16px 16px 0;
margin-bottom: 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
width: 100%;
height: 100%;
.toggle {
margin-bottom: 16px;
}
.toggle {
margin-bottom: 16px;
}
.preview-group {
padding: 16px;
margin-bottom: 10px;
border: 1px groove rgba(0, 0, 0, .12);
border-radius: 4px;
&.notification {
background-color: #F3F6FA;
}
.group-title {
font-weight: 500;
font-size: 14px;
tb-icon, .tb-form-panel-title {
color: rgba(0, 0, 0, .38);
}
}
&> div:not(:last-child) {
@ -212,7 +191,7 @@
}
}
.tb-notification-use-template-toggle-group {
tb-toggle-select {
margin-bottom: 24px;
width: 320px;
}
@ -227,6 +206,7 @@
.mat-horizontal-stepper-wrapper {
flex: 1 1 100%;
width: 100%;
}
.mat-horizontal-content-container {
@ -240,14 +220,6 @@
}
}
}
.tb-form-panel .mat-expansion-panel.tb-settings {
padding: 11px 16px;
& > .mat-expansion-panel-content > .mat-expansion-panel-body {
gap: 0;
}
}
}
.preview-group {

19
ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts

@ -24,7 +24,6 @@ import {
import { Component, Inject, OnDestroy, ViewChild } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { NotificationService } from '@core/http/notification.service';
@ -47,6 +46,7 @@ import { Authority } from '@shared/models/authority.enum';
import { AuthUser } from '@shared/models/user.model';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { TranslateService } from '@ngx-translate/core';
import { Router } from '@angular/router';
export interface RequestNotificationDialogData {
request?: NotificationRequest;
@ -150,11 +150,9 @@ export class SentNotificationDialogComponent extends
let useTemplate = true;
if (isDefinedAndNotNull(this.data.request.template)) {
useTemplate = false;
// eslint-disable-next-line guard-for-in
for (const method in this.data.request.template.configuration.deliveryMethodsTemplates) {
this.deliveryMethodFormsMap.get(NotificationDeliveryMethod[method])
.patchValue(this.data.request.template.configuration.deliveryMethodsTemplates[method]);
}
this.notificationTemplateConfigurationForm.patchValue({
deliveryMethodsTemplates: this.data.request.template.configuration.deliveryMethodsTemplates
}, {emitEvent: false});
}
this.notificationRequestForm.get('useTemplate').setValue(useTemplate, {onlySelf : true});
}
@ -178,6 +176,9 @@ export class SentNotificationDialogComponent extends
changeStep($event: StepperSelectionEvent) {
this.selectedIndex = $event.selectedIndex;
if ($event.previouslySelectedIndex > $event.selectedIndex) {
$event.previouslySelectedStep.interacted = false;
}
if (this.selectedIndex === this.maxStepperIndex) {
this.getPreview();
}
@ -304,13 +305,16 @@ export class SentNotificationDialogComponent extends
}
allowConfigureDeliveryMethod(deliveryMethod: NotificationDeliveryMethod): boolean {
const tenantAllowConfigureDeliveryMethod = new Set([
NotificationDeliveryMethod.SLACK
]);
if (deliveryMethod === NotificationDeliveryMethod.WEB) {
return false;
}
if(this.isSysAdmin()) {
return true;
} else if (this.isTenantAdmin()) {
return deliveryMethod === NotificationDeliveryMethod.SLACK;
return tenantAllowConfigureDeliveryMethod.has(deliveryMethod);
}
return false;
}
@ -325,6 +329,7 @@ export class SentNotificationDialogComponent extends
return '/settings/outgoing-mail';
case NotificationDeliveryMethod.SMS:
case NotificationDeliveryMethod.SLACK:
case NotificationDeliveryMethod.MOBILE_APP:
return '/settings/notifications';
}
}

4
ui-ngx/src/app/modules/home/pages/notification/sent/sent-table-config.resolver.ts

@ -21,7 +21,7 @@ import {
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import {
NotificationDeliveryMethodTranslateMap,
NotificationDeliveryMethodInfoMap,
NotificationRequest,
NotificationRequestInfo,
NotificationRequestStats,
@ -94,7 +94,7 @@ export class SentTableConfigResolver implements Resolve<EntityTableConfig<Notifi
request => this.requestStatusStyle(request.status)),
new EntityTableColumn<NotificationRequest>('deliveryMethods', 'notification.delivery-method.delivery-method', '15%',
(request) => request.deliveryMethods
.map((deliveryMethod) => this.translate.instant(NotificationDeliveryMethodTranslateMap.get(deliveryMethod))).join(', '),
.map((deliveryMethod) => this.translate.instant(NotificationDeliveryMethodInfoMap.get(deliveryMethod).name)).join(', '),
() => ({}), false),
new EntityTableColumn<NotificationRequest>('templateName', 'notification.template', '70%')
);

2
ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html

@ -54,7 +54,7 @@
(change)="changeInstanceTypeCheckBox($event.checked, deliveryMethods)"
[indeterminate]="getIndeterminate(deliveryMethods)"
(click)="$event.stopPropagation()">
<mat-label>{{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }}</mat-label>
<mat-label>{{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }}</mat-label>
</mat-checkbox>
</div>
</div>

4
ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts

@ -25,7 +25,7 @@ import { ActivatedRoute } from '@angular/router';
import { deepClone, isDefinedAndNotNull } from '@core/utils';
import {
NotificationDeliveryMethod,
NotificationDeliveryMethodTranslateMap,
NotificationDeliveryMethodInfoMap,
NotificationUserSettings
} from '@shared/models/notification.models';
import { NotificationService } from '@core/http/notification.service';
@ -41,7 +41,7 @@ export class NotificationSettingsComponent extends PageComponent implements OnIn
notificationSettings: UntypedFormGroup;
notificationDeliveryMethods: NotificationDeliveryMethod[];
notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap;
notificationDeliveryMethodInfoMap = NotificationDeliveryMethodInfoMap;
private deliveryMethods = new Set([
NotificationDeliveryMethod.SLACK,

83
ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html

@ -0,0 +1,83 @@
<!--
Copyright © 2016-2024 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-slide-toggle stroked" [formGroup]="actionButtonConfigForm">
<mat-expansion-panel class="tb-settings"
[expanded]="actionButtonConfigForm.get('enabled').value">
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title fxFlex="60">
<mat-slide-toggle class="mat-slide" formControlName="enabled" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ actionTitle }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field class="mat-block" fxFlex *ngIf="!hideButtonText">
<mat-label translate>notification.button-text</mat-label>
<input matInput formControlName="text" required>
<mat-error
*ngIf="actionButtonConfigForm.get('text').hasError('required')">
{{ 'notification.button-text-required' | translate }}
</mat-error>
<mat-error
*ngIf="actionButtonConfigForm.get('text').hasError('maxlength')">
{{ 'notification.button-text-max-length' | translate :
{length: actionButtonConfigForm.get('text').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field fxFlex="30" fxFlex.xs="100">
<mat-label translate>notification.action-type</mat-label>
<mat-select formControlName="linkType">
<mat-option *ngFor="let actionButtonLinkType of actionButtonLinkTypes"
[value]="actionButtonLinkType">
{{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex
*ngIf="actionButtonConfigForm.get('linkType').value === actionButtonLinkType.LINK; else dashboardSelector">
<mat-label translate>notification.link</mat-label>
<input matInput formControlName="link" required>
<mat-error
*ngIf="actionButtonConfigForm.get('link').hasError('required')">
{{ 'notification.link-required' | translate }}
</mat-error>
</mat-form-field>
<ng-template #dashboardSelector>
<tb-dashboard-autocomplete
fxFlex="35" fxFlex.xs="100"
required
formControlName="dashboardId">
</tb-dashboard-autocomplete>
<tb-dashboard-state-autocomplete fxFlex="35" fxFlex.xs="100"
[dashboardId]="actionButtonConfigForm.get('dashboardId').value"
formControlName="dashboardState">
</tb-dashboard-state-autocomplete>
</ng-template>
</div>
<mat-slide-toggle formControlName="setEntityIdInState" class="toggle"
*ngIf="actionButtonConfigForm.get('linkType').value === actionButtonLinkType.DASHBOARD">
{{ 'notification.set-entity-from-notification' | translate }}
</mat-slide-toggle>
</ng-template>
</mat-expansion-panel>
</div>

168
ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.ts

@ -0,0 +1,168 @@
///
/// Copyright © 2016-2024 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,
FormBuilder,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { ActionButtonLinkType, ActionButtonLinkTypeTranslateMap } from '@shared/models/notification.models';
import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { isDefinedAndNotNull } from '@core/utils';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-notification-action-button-configuration',
templateUrl: './notification-action-button-configuration.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NotificationActionButtonConfigurationComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => NotificationActionButtonConfigurationComponent),
multi: true,
}
]
})
export class NotificationActionButtonConfigurationComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy {
@Input()
actionTitle: string;
private hideButtonTextValue = false;
get hideButtonText(): boolean {
return this.hideButtonTextValue;
}
@Input()
@coerceBoolean()
set hideButtonText(hideButtonText: boolean) {
this.hideButtonTextValue = hideButtonText;
if (this.hideButtonTextValue) {
this.actionButtonConfigForm.removeControl('text');
}
}
actionButtonConfigForm: FormGroup;
readonly actionButtonLinkType = ActionButtonLinkType;
readonly actionButtonLinkTypes = Object.keys(ActionButtonLinkType) as ActionButtonLinkType[];
readonly actionButtonLinkTypeTranslateMap = ActionButtonLinkTypeTranslateMap;
private propagateChange = (v: any) => { };
private readonly destroy$ = new Subject<void>();
constructor(private fb: FormBuilder) {
this.actionButtonConfigForm = this.fb.group({
enabled: [false],
text: [{value: '', disabled: true}, [Validators.required, Validators.maxLength(50)]],
linkType: [ActionButtonLinkType.LINK],
link: [{value: '', disabled: true}, Validators.required],
dashboardId: [{value: null, disabled: true}, Validators.required],
dashboardState: [{value: null, disabled: true}],
setEntityIdInState: [{value: true, disabled: true}]
});
this.actionButtonConfigForm.get('enabled').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
if (value) {
if (!this.hideButtonText) {
this.actionButtonConfigForm.get('text').enable({emitEvent: false});
}
this.actionButtonConfigForm.get('linkType').enable({onlySelf: false});
} else {
this.actionButtonConfigForm.disable({emitEvent: false});
this.actionButtonConfigForm.get('enabled').enable({emitEvent: false});
}
});
this.actionButtonConfigForm.get('linkType').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
const isEnabled = this.actionButtonConfigForm.get('enabled').value;
if (isEnabled) {
if (value === ActionButtonLinkType.LINK) {
this.actionButtonConfigForm.get('link').enable({emitEvent: false});
this.actionButtonConfigForm.get('dashboardId').disable({emitEvent: false});
this.actionButtonConfigForm.get('dashboardState').disable({emitEvent: false});
this.actionButtonConfigForm.get('setEntityIdInState').disable({emitEvent: false});
} else {
this.actionButtonConfigForm.get('link').disable({emitEvent: false});
this.actionButtonConfigForm.get('dashboardId').enable({emitEvent: false});
this.actionButtonConfigForm.get('dashboardState').enable({emitEvent: false});
this.actionButtonConfigForm.get('setEntityIdInState').enable({emitEvent: false});
}
}
});
this.actionButtonConfigForm.valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(value => this.propagateChange(value));
}
ngOnInit() {
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(fn: any) {
}
setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.actionButtonConfigForm.disable({emitEvent: false});
} else {
this.actionButtonConfigForm.enable({emitEvent: false});
this.actionButtonConfigForm.get('enabled').updateValueAndValidity({onlySelf: true});
}
}
validate(): ValidationErrors | null {
return this.actionButtonConfigForm.valid ? null : {
actionButtonConfigForm: {
valid: false
}
};
}
writeValue(obj) {
if (isDefinedAndNotNull(obj)) {
this.actionButtonConfigForm.patchValue(obj, {emitEvent: false});
this.actionButtonConfigForm.get('enabled').updateValueAndValidity({onlySelf: true});
}
}
}

261
ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html

@ -0,0 +1,261 @@
<!--
Copyright © 2016-2024 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.
-->
<section class="tb-template-header">
<div class="tb-form-panel-title" translate>notification.customize-messages</div>
<div class="tb-form-panel-hint tb-hint-available-params">
<span class="content">{{ 'notification.input-fields-support-templatization' | translate}}</span>
<span tb-help-popup="{{ NotificationTemplateTypeTranslateMap.get(notificationType).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px;font-size:12px;"
[tb-help-popup-style]="{maxWidth: '800px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
</section>
<section [formGroup]="templateConfigurationForm" class="tb-form-panel no-border no-padding">
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.WEB"
*ngIf="templateConfigurationForm.get('WEB.enabled').value">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="templateConfigurationForm.get('WEB.subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="templateConfigurationForm.get('WEB.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<section formGroupName="additionalConfig" class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between" formGroupName="icon">
<mat-slide-toggle formControlName="enabled" class="mat-slide">
{{ 'icon.icon' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-material-icon-select asBoxInput
[color]="templateConfigurationForm.get('WEB.additionalConfig.icon.color').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="color">
</tb-color-input>
</div>
</div>
<tb-notification-action-button-configuration
actionTitle="{{ 'notification.action-button' | translate }}"
formControlName="actionButtonConfig">
</tb-notification-action-button-configuration>
</section>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.MOBILE_APP"
*ngIf="templateConfigurationForm.get('MOBILE_APP.enabled').value">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="templateConfigurationForm.get('MOBILE_APP.subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('MOBILE_APP.subject').hasError('maxlength')">
{{ 'notification.subject-max-length' | translate :
{length: templateConfigurationForm.get('MOBILE_APP.subject').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="templateConfigurationForm.get('MOBILE_APP.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('MOBILE_APP.body').hasError('maxlength')">
{{ 'notification.message-max-length' | translate :
{length: templateConfigurationForm.get('MOBILE_APP.body').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
<div formGroupName="additionalConfig">
<tb-notification-action-button-configuration
formControlName="onClick"
hideButtonText
actionTitle="{{ 'notification.notification-tap-action' | translate }}">
</tb-notification-action-button-configuration>
</div>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.SMS"
*ngIf="templateConfigurationForm.get('SMS.enabled').value">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-hint></mat-hint>
<mat-error *ngIf="templateConfigurationForm.get('SMS.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('SMS.body').hasError('maxlength')">
{{ 'notification.message-max-length' | translate :
{length: templateConfigurationForm.get('SMS.body').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.EMAIL"
*ngIf="templateConfigurationForm.get('EMAIL.enabled').value">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="templateConfigurationForm.get('EMAIL.subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
</mat-form-field>
<mat-label class="tb-title tb-required"
[class.tb-error]="(interacted || templateConfigurationForm.get('EMAIL.body').touched) && templateConfigurationForm.get('EMAIL.body').hasError('required')"
translate>notification.message
</mat-label>
<editor [init]="tinyMceOptions" formControlName="body"></editor>
<mat-error class="tb-mat-error"
*ngIf="(interacted || templateConfigurationForm.get('EMAIL.body').touched) && templateConfigurationForm.get('EMAIL.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.SLACK"
*ngIf="templateConfigurationForm.get('SLACK.enabled').value">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="templateConfigurationForm.get('SLACK.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.MICROSOFT_TEAMS"
*ngIf="templateConfigurationForm.get('MICROSOFT_TEAMS.enabled').value">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="templateConfigurationForm.get('MICROSOFT_TEAMS.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between">
<div translate>notification.theme-color</div>
<tb-color-input asBoxInput formControlName="themeColor"></tb-color-input>
</div>
<tb-notification-action-button-configuration
actionTitle="{{ 'notification.action-button' | translate }}"
formControlName="button">
</tb-notification-action-button-configuration>
</div>
</ng-template>
</mat-expansion-panel>
</section>
</section>

72
ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.scss

@ -0,0 +1,72 @@
/**
* Copyright © 2016-2024 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-template-header {
position: sticky;
top: 0;
z-index: 1;
padding-bottom: 12px;
background-color: var(--mdc-dialog-container-color, white);
.tb-hint-available-params {
color: rgba(0, 0, 0, 0.54);
letter-spacing: 0.4px;
.content {
vertical-align: middle;
}
}
}
.template-tittle {
gap: 12px;
font-weight: normal;
tb-icon {
color: rgba(0, 0, 0, .38);
}
}
.tb-mat-error {
font-size: 13px;
}
.tb-title {
font-size: 16px;
line-height: 24px;
&.tb-required::after {
font-size: initial;
content: "*";
}
&.tb-error {
color: var(--mdc-theme-error, #f44336);
&.tb-required::after {
color: var(--mdc-theme-error, #f44336);
}
}
}
}
:host ::ng-deep {
.tb-form-panel .mat-expansion-panel.tb-settings {
& > .mat-expansion-panel-content > .mat-expansion-panel-body {
gap: 0;
}
}
}

235
ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts

@ -0,0 +1,235 @@
///
/// Copyright © 2016-2024 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 } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import {
DeliveryMethodsTemplates,
NotificationDeliveryMethod,
NotificationDeliveryMethodInfoMap,
NotificationTemplateTypeTranslateMap,
NotificationType
} from '@shared/models/notification.models';
import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { isDefinedAndNotNull } from '@core/utils';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-template-configuration',
templateUrl: './notification-template-configuration.component.html',
styleUrls: ['./notification-template-configuration.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NotificationTemplateConfigurationComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => NotificationTemplateConfigurationComponent),
multi: true,
}
]
})
export class NotificationTemplateConfigurationComponent implements OnDestroy, ControlValueAccessor, Validator {
templateConfigurationForm: FormGroup;
NotificationDeliveryMethodInfoMap = NotificationDeliveryMethodInfoMap;
@Input()
set predefinedDeliveryMethodsTemplate(value: Partial<DeliveryMethodsTemplates>) {
if (isDefinedAndNotNull(value)) {
this.templateConfigurationForm.patchValue(value, {emitEvent: false});
this.updateDisabledForms();
this.templateConfigurationForm.updateValueAndValidity();
}
}
@Input()
notificationType: NotificationType;
@Input()
@coerceBoolean()
interacted: boolean;
readonly NotificationDeliveryMethod = NotificationDeliveryMethod;
readonly NotificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap;
tinyMceOptions: Record<string, any> = {
base_url: '/assets/tinymce',
suffix: '.min',
plugins: ['link table image imagetools code fullscreen'],
menubar: 'edit insert tools view format table',
toolbar: 'fontselect fontsizeselect | formatselect | bold italic strikethrough forecolor backcolor ' +
'| link | table | image | alignleft aligncenter alignright alignjustify ' +
'| numlist bullist outdent indent | removeformat | code | fullscreen',
toolbar_mode: 'sliding',
height: 400,
autofocus: false,
branding: false
};
private propagateChange = (v: any) => { };
private readonly destroy$ = new Subject<void>();
constructor(private fb: FormBuilder) {
this.templateConfigurationForm = this.buildForm();
this.templateConfigurationForm.valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
this.propagateChange(value);
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
writeValue(value: any) {
this.templateConfigurationForm.patchValue(value, {emitEvent: false});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.templateConfigurationForm.disable({emitEvent: false});
} else {
this.updateDisabledForms();
}
}
validate(): ValidationErrors {
return this.templateConfigurationForm.valid ? null : {
templateConfiguration: {
valid: false,
},
};
}
private updateDisabledForms(){
Object.values(NotificationDeliveryMethod).forEach((method) => {
const form = this.templateConfigurationForm.get(method);
if (!form.get('enabled').value) {
form.disable({emitEvent: false});
} else {
form.enable({emitEvent: false});
switch (method) {
case NotificationDeliveryMethod.WEB:
form.get('additionalConfig.icon.enabled').updateValueAndValidity({onlySelf: true});
break;
}
}
});
}
private buildForm(): FormGroup {
const form = this.fb.group({});
Object.values(NotificationDeliveryMethod).forEach((method) => {
form.addControl(method, this.buildDeliveryMethodControl(method), {emitEvent: false});
});
return form;
}
private buildDeliveryMethodControl(deliveryMethod: NotificationDeliveryMethod): FormGroup {
let deliveryMethodForm: FormGroup;
switch (deliveryMethod) {
case NotificationDeliveryMethod.WEB:
deliveryMethodForm = this.fb.group({
subject: ['', Validators.required],
body: ['', Validators.required],
additionalConfig: this.fb.group({
icon: this.fb.group({
enabled: [false],
icon: [{value: 'notifications', disabled: true}, Validators.required],
color: [{value: '#757575', disabled: true}]
}),
actionButtonConfig: [null]
})
});
deliveryMethodForm.get('additionalConfig.icon.enabled').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
if (value) {
deliveryMethodForm.get('additionalConfig.icon.icon').enable({emitEvent: false});
deliveryMethodForm.get('additionalConfig.icon.color').enable({emitEvent: false});
} else {
deliveryMethodForm.get('additionalConfig.icon.icon').disable({emitEvent: false});
deliveryMethodForm.get('additionalConfig.icon.color').disable({emitEvent: false});
}
});
break;
case NotificationDeliveryMethod.EMAIL:
deliveryMethodForm = this.fb.group({
subject: ['', Validators.required],
body: ['', Validators.required]
});
break;
case NotificationDeliveryMethod.SMS:
deliveryMethodForm = this.fb.group({
body: ['', [Validators.required, Validators.maxLength(320)]]
});
break;
case NotificationDeliveryMethod.SLACK:
deliveryMethodForm = this.fb.group({
body: ['', Validators.required]
});
break;
case NotificationDeliveryMethod.MOBILE_APP:
deliveryMethodForm = this.fb.group({
subject: ['', [Validators.required, Validators.maxLength(50)]],
body: ['', [Validators.required, Validators.maxLength(150)]],
additionalConfig: this.fb.group({
onClick: [null]
})
});
break;
case NotificationDeliveryMethod.MICROSOFT_TEAMS:
deliveryMethodForm = this.fb.group({
subject: [''],
body: ['', Validators.required],
themeColor: [''],
button: [null]
});
break;
default:
throw new Error(`Not configured templated for notification delivery method: ${deliveryMethod}`);
}
deliveryMethodForm.addControl('enabled', this.fb.control(false), {emitEvent: false});
deliveryMethodForm.addControl('method', this.fb.control(deliveryMethod), {emitEvent: false});
return deliveryMethodForm;
}
}

145
ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.ts

@ -16,10 +16,9 @@
import { FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms';
import {
ActionButtonLinkType,
ActionButtonLinkTypeTranslateMap,
DeliveryMethodsTemplates,
NotificationDeliveryMethod,
NotificationDeliveryMethodTranslateMap,
NotificationDeliveryMethodInfoMap,
NotificationTemplate,
NotificationTemplateTypeTranslateMap,
NotificationType
@ -27,46 +26,25 @@ import {
import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { Directive, OnDestroy } from '@angular/core';
import { deepClone, deepTrim } from '@core/utils';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog';
import { deepClone, deepTrim } from '@core/utils';
import tinymce from 'tinymce';
@Directive()
// tslint:disable-next-line:directive-class-suffix
export abstract class TemplateConfiguration<T, R = any> extends DialogComponent<T, R> implements OnDestroy{
templateNotificationForm: FormGroup;
webTemplateForm: FormGroup;
emailTemplateForm: FormGroup;
smsTemplateForm: FormGroup;
slackTemplateForm: FormGroup;
microsoftTeamsTemplateForm: FormGroup;
notificationTemplateConfigurationForm: FormGroup;
notificationDeliveryMethods = Object.keys(NotificationDeliveryMethod) as NotificationDeliveryMethod[];
notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap;
notificationDeliveryMethodInfoMap = NotificationDeliveryMethodInfoMap;
notificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap;
actionButtonLinkType = ActionButtonLinkType;
actionButtonLinkTypes = Object.keys(ActionButtonLinkType) as ActionButtonLinkType[];
actionButtonLinkTypeTranslateMap = ActionButtonLinkTypeTranslateMap;
tinyMceOptions: Record<string, any> = {
base_url: '/assets/tinymce',
suffix: '.min',
plugins: ['link table image imagetools code fullscreen'],
menubar: 'edit insert tools view format table',
toolbar: 'fontselect fontsizeselect | formatselect | bold italic strikethrough forecolor backcolor ' +
'| link | table | image | alignleft aligncenter alignright alignjustify ' +
'| numlist bullist outdent indent | removeformat | code | fullscreen',
toolbar_mode: 'sliding',
height: 400,
autofocus: false,
branding: false
};
deliveryConfiguration: Partial<DeliveryMethodsTemplates>;
protected readonly destroy$ = new Subject<void>();
@ -86,63 +64,22 @@ export abstract class TemplateConfiguration<T, R = any> extends DialogComponent<
})
});
this.notificationDeliveryMethods.forEach(method => {
(this.templateNotificationForm.get('configuration.deliveryMethodsTemplates') as FormGroup)
.addControl(method, this.fb.group({enabled: method === NotificationDeliveryMethod.WEB}), {emitEvent: false});
});
this.webTemplateForm = this.fb.group({
subject: ['', Validators.required],
body: ['', Validators.required],
additionalConfig: this.fb.group({
icon: this.fb.group({
enabled: [false],
icon: [{value: 'notifications', disabled: true}, Validators.required],
color: [{value: '#757575', disabled: true}]
}),
actionButtonConfig: this.createButtonConfigForm()
})
});
this.webTemplateForm.get('additionalConfig.icon.enabled').valueChanges.pipe(
this.templateNotificationForm.get('configuration.deliveryMethodsTemplates').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
if (value) {
this.webTemplateForm.get('additionalConfig.icon.icon').enable({emitEvent: false});
this.webTemplateForm.get('additionalConfig.icon.color').enable({emitEvent: false});
} else {
this.webTemplateForm.get('additionalConfig.icon.icon').disable({emitEvent: false});
this.webTemplateForm.get('additionalConfig.icon.color').disable({emitEvent: false});
}
});
this.emailTemplateForm = this.fb.group({
subject: ['', Validators.required],
body: ['', Validators.required]
this.deliveryConfiguration = value;
});
this.smsTemplateForm = this.fb.group({
body: ['', [Validators.required, Validators.maxLength(320)]]
this.notificationTemplateConfigurationForm = this.fb.group({
deliveryMethodsTemplates: null
});
this.slackTemplateForm = this.fb.group({
body: ['', Validators.required]
});
this.microsoftTeamsTemplateForm = this.fb.group({
subject: [''],
body: ['', Validators.required],
themeColor: [''],
button: this.createButtonConfigForm()
this.notificationDeliveryMethods.forEach(method => {
(this.templateNotificationForm.get('configuration.deliveryMethodsTemplates') as FormGroup)
.addControl(method, this.fb.group({enabled: method === NotificationDeliveryMethod.WEB}), {emitEvent: false});
});
this.deliveryMethodFormsMap = new Map<NotificationDeliveryMethod, FormGroup>([
[NotificationDeliveryMethod.WEB, this.webTemplateForm],
[NotificationDeliveryMethod.EMAIL, this.emailTemplateForm],
[NotificationDeliveryMethod.SMS, this.smsTemplateForm],
[NotificationDeliveryMethod.SLACK, this.slackTemplateForm],
[NotificationDeliveryMethod.MICROSOFT_TEAMS, this.microsoftTeamsTemplateForm]
]);
this.deliveryConfiguration = this.templateNotificationForm.get('configuration.deliveryMethodsTemplates').value;
}
ngOnDestroy() {
@ -162,58 +99,8 @@ export abstract class TemplateConfiguration<T, R = any> extends DialogComponent<
}
protected getNotificationTemplateValue(): NotificationTemplate {
const template: NotificationTemplate = deepClone(this.templateNotificationForm.value);
this.notificationDeliveryMethods.forEach(method => {
if (template.configuration.deliveryMethodsTemplates[method]?.enabled) {
Object.assign(template.configuration.deliveryMethodsTemplates[method], this.deliveryMethodFormsMap.get(method).value, {method});
} else {
delete template.configuration.deliveryMethodsTemplates[method];
}
});
const template = deepClone(this.templateNotificationForm.value);
template.configuration = deepClone(this.notificationTemplateConfigurationForm.value);
return deepTrim(template);
}
private createButtonConfigForm(): FormGroup {
const form = this.fb.group({
enabled: [false],
text: [{value: '', disabled: true}, [Validators.required, Validators.maxLength(50)]],
linkType: [ActionButtonLinkType.LINK],
link: [{value: '', disabled: true}, Validators.required],
dashboardId: [{value: null, disabled: true}, Validators.required],
dashboardState: [{value: null, disabled: true}],
setEntityIdInState: [{value: true, disabled: true}],
});
form.get('enabled').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
if (value) {
form.enable({emitEvent: false});
form.get('linkType').updateValueAndValidity({onlySelf: true});
} else {
form.disable({emitEvent: false});
form.get('enabled').enable({emitEvent: false});
}
});
form.get('linkType').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
const isEnabled = form.get('enabled').value;
if (isEnabled) {
if (value === ActionButtonLinkType.LINK) {
form.get('link').enable({emitEvent: false});
form.get('dashboardId').disable({emitEvent: false});
form.get('dashboardState').disable({emitEvent: false});
form.get('setEntityIdInState').disable({emitEvent: false});
} else {
form.get('link').disable({emitEvent: false});
form.get('dashboardId').enable({emitEvent: false});
form.get('dashboardState').enable({emitEvent: false});
form.get('setEntityIdInState').enable({emitEvent: false});
}
}
});
return form;
}
}

312
ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html

@ -29,14 +29,14 @@
</mat-progress-bar>
<div mat-dialog-content>
<mat-horizontal-stepper linear #notificationTemplateStepper
labelPosition="bottom"
labelPosition="end"
[orientation]="(stepperOrientation | async)"
(selectionChange)="changeStep($event)">
<ng-template matStepperIcon="edit">
<mat-icon>check</mat-icon>
</ng-template>
<mat-step [stepControl]="templateNotificationForm">
<ng-template matStepLabel>{{ 'notification.basic-settings' | translate }}</ng-template>
<ng-template matStepLabel>{{ 'notification.setup' | translate }}</ng-template>
<form [formGroup]="templateNotificationForm" style="padding-bottom: 16px;">
<mat-form-field class="mat-block">
<mat-label translate>notification.name</mat-label>
@ -59,309 +59,25 @@
<div class="tb-hint" translate>notification.at-least-one-should-be-selected</div>
<section formGroupName="deliveryMethodsTemplates" class="delivery-methods-container">
<section *ngFor="let deliveryMethods of notificationDeliveryMethods"
class="delivery-method-container"
class="tb-form-panel stroked"
[formGroupName]="deliveryMethods">
<mat-slide-toggle class="delivery-method" formControlName="enabled">
{{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }}
<mat-slide-toggle formControlName="enabled">
{{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }}
</mat-slide-toggle>
</section>
</section>
</section>
</form>
</mat-step>
<mat-step *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.WEB.enabled').value"
[stepControl]="webTemplateForm">
<ng-template matStepLabel>{{ 'notification.delivery-method.web' | translate }}</ng-template>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-fields-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(templateNotificationForm.get('notificationType').value).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '800px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="webTemplateForm">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="webTemplateForm.get('subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="webTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<section formGroupName="additionalConfig" class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between" formGroupName="icon">
<mat-slide-toggle formControlName="enabled" class="mat-slide">
{{ 'icon.icon' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-material-icon-select asBoxInput
[color]="webTemplateForm.get('additionalConfig.icon.color').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="color">
</tb-color-input>
</div>
</div>
<div class="tb-form-panel tb-slide-toggle stroked" formGroupName="actionButtonConfig">
<mat-expansion-panel class="tb-settings" [expanded]="webTemplateForm.get('additionalConfig.actionButtonConfig.enabled').value">
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title fxFlex="60">
<mat-slide-toggle class="mat-slide" formControlName="enabled" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'notification.action-button' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>notification.button-text</mat-label>
<input matInput formControlName="text" required>
<mat-error *ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.text').hasError('required')">
{{ 'notification.button-text-required' | translate }}
</mat-error>
<mat-error *ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.text').hasError('maxlength')">
{{ 'notification.button-text-max-length' | translate :
{length: webTemplateForm.get('additionalConfig.actionButtonConfig.text').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field fxFlex="30" fxFlex.xs="100">
<mat-label translate>notification.action-type</mat-label>
<mat-select formControlName="linkType">
<mat-option *ngFor="let actionButtonLinkType of actionButtonLinkTypes" [value]="actionButtonLinkType">
{{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex
*ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.linkType').value === actionButtonLinkType.LINK; else dashboardSelector">
<mat-label translate>notification.link</mat-label>
<input matInput formControlName="link" required>
<mat-error *ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.link').hasError('required')">
{{ 'notification.link-required' | translate }}
</mat-error>
</mat-form-field>
<ng-template #dashboardSelector>
<tb-dashboard-autocomplete
fxFlex="35" fxFlex.xs="100"
required
formControlName="dashboardId">
</tb-dashboard-autocomplete>
<tb-dashboard-state-autocomplete fxFlex="35" fxFlex.xs="100"
[dashboardId]="webTemplateForm.get('additionalConfig.actionButtonConfig.dashboardId').value"
formControlName="dashboardState">
</tb-dashboard-state-autocomplete>
</ng-template>
</div>
<mat-slide-toggle formControlName="setEntityIdInState" class="toggle"
*ngIf="webTemplateForm.get('additionalConfig.actionButtonConfig.linkType').value === actionButtonLinkType.DASHBOARD">
{{ 'notification.set-entity-from-notification' | translate }}
</mat-slide-toggle>
</ng-template>
</mat-expansion-panel>
</div>
</section>
</form>
</mat-step>
<mat-step *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.EMAIL.enabled').value"
[stepControl]="emailTemplateForm" #emailStep="matStep">
<ng-template matStepLabel>{{ 'notification.delivery-method.email' | translate }}</ng-template>
<ng-template matStepContent>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-fields-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(templateNotificationForm.get('notificationType').value).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '800px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="emailTemplateForm">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="emailTemplateForm.get('subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
</mat-form-field>
<mat-label class="tb-title tb-required"
[ngClass]="{'tb-error': (emailStep.interacted || emailTemplateForm.get('body').dirty) && emailTemplateForm.get('body').hasError('required')}"
translate>notification.message</mat-label>
<editor [init]="tinyMceOptions" formControlName="body"></editor>
<mat-error class="tb-mat-error"
*ngIf="(emailStep.interacted || emailTemplateForm.get('body').dirty) && emailTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</form>
</ng-template>
</mat-step>
<mat-step *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.SMS.enabled').value"
[stepControl]="smsTemplateForm">
<ng-template matStepLabel>{{ 'notification.delivery-method.sms' | translate }}</ng-template>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-field-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(templateNotificationForm.get('notificationType').value).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '800px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="smsTemplateForm">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="smsTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
<mat-error *ngIf="smsTemplateForm.get('body').hasError('maxlength')">
{{ 'notification.message-max-length' | translate :
{length: smsTemplateForm.get('body').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</form>
</mat-step>
<mat-step *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.SLACK.enabled').value"
[stepControl]="slackTemplateForm">
<ng-template matStepLabel>{{ 'notification.delivery-method.slack' | translate }}</ng-template>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-field-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(templateNotificationForm.get('notificationType').value).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '800px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="slackTemplateForm" fxLayoutGap="8px">
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="slackTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
</form>
</mat-step>
<mat-step *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.MICROSOFT_TEAMS.enabled').value"
[stepControl]="microsoftTeamsTemplateForm">
<ng-template matStepLabel>{{ 'notification.delivery-method.microsoft-teams' | translate }}</ng-template>
<div class="tb-hint-available-params mat-body-2">
<span class="content">{{ 'notification.input-fields-support-templatization' | translate}}</span>
<span tb-help-popup="{{ notificationTemplateTypeTranslateMap.get(templateNotificationForm.get('notificationType').value).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '800px'}"
trigger-text="{{ 'notification.see-documentation' | translate }}"></span>
</div>
<form [formGroup]="microsoftTeamsTemplateForm">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="microsoftTeamsTemplateForm.get('body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between">
<div translate>notification.theme-color</div>
<tb-color-input asBoxInput formControlName="themeColor"></tb-color-input>
</div>
<div class="tb-form-panel tb-slide-toggle stroked" formGroupName="button">
<mat-expansion-panel class="tb-settings" [expanded]="microsoftTeamsTemplateForm.get('button.enabled').value">
<mat-expansion-panel-header fxLayout="row wrap" class="fill-width">
<mat-panel-title fxFlex="60">
<mat-slide-toggle class="mat-slide" formControlName="enabled" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'notification.action-button' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>notification.button-text</mat-label>
<input matInput formControlName="text" required>
<mat-error *ngIf="microsoftTeamsTemplateForm.get('button.text').hasError('required')">
{{ 'notification.button-text-required' | translate }}
</mat-error>
<mat-error *ngIf="microsoftTeamsTemplateForm.get('button.text').hasError('maxlength')">
{{ 'notification.button-text-max-length' | translate :
{length: microsoftTeamsTemplateForm.get('button.text').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field fxFlex="30" fxFlex.xs="100">
<mat-label translate>notification.action-type</mat-label>
<mat-select formControlName="linkType">
<mat-option *ngFor="let actionButtonLinkType of actionButtonLinkTypes" [value]="actionButtonLinkType">
{{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex
*ngIf="microsoftTeamsTemplateForm.get('button.linkType').value === actionButtonLinkType.LINK; else dashboardSelector">
<mat-label translate>notification.link</mat-label>
<input matInput formControlName="link" required>
<mat-error *ngIf="microsoftTeamsTemplateForm.get('button.link').hasError('required')">
{{ 'notification.link-required' | translate }}
</mat-error>
</mat-form-field>
<ng-template #dashboardSelector>
<tb-dashboard-autocomplete
fxFlex="35" fxFlex.xs="100"
required
formControlName="dashboardId">
</tb-dashboard-autocomplete>
<tb-dashboard-state-autocomplete fxFlex="35" fxFlex.xs="100"
[dashboardId]="microsoftTeamsTemplateForm.get('button.dashboardId').value"
formControlName="dashboardState">
</tb-dashboard-state-autocomplete>
</ng-template>
</div>
<mat-slide-toggle formControlName="setEntityIdInState" class="toggle"
*ngIf="microsoftTeamsTemplateForm.get('button.linkType').value === actionButtonLinkType.DASHBOARD">
{{ 'notification.set-entity-from-notification' | translate }}
</mat-slide-toggle>
</ng-template>
</mat-expansion-panel>
</div>
</div>
<mat-step [stepControl]="notificationTemplateConfigurationForm" #composeStep=matStep>
<ng-template matStepLabel>{{ 'notification.compose' | translate }}</ng-template>
<form [formGroup]="notificationTemplateConfigurationForm">
<tb-template-configuration
[notificationType]="templateNotificationForm.get('notificationType').value"
[predefinedDeliveryMethodsTemplate]="deliveryConfiguration"
[interacted]="composeStep.interacted"
formControlName="deliveryMethodsTemplates">
</tb-template-configuration>
</form>
</mat-step>
</mat-horizontal-stepper>

44
ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.scss

@ -17,7 +17,7 @@
@import "../../../../../../theme";
:host {
width: 840px;
width: 780px;
height: 100%;
max-width: 100%;
max-height: 100vh;
@ -62,44 +62,15 @@
}
}
.tb-mat-error {
font-size: 13px;
}
.tb-hint {
padding: 0 0 8px;
}
.tb-hint-available-params {
border-radius: 6px;
background-color: rgba(48, 86, 128, 0.04);
margin-bottom: 8px;
padding: 8px 16px;
.content {
vertical-align: middle;
}
}
.delivery-methods-container {
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, auto));
gap: 8px;
.delivery-method-container {
display: inline-flex;
flex: 1 1 calc(50% - 8px);
max-width: calc(50% - 8px);
padding: 16px 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
.delivery-method {
width: 100%;
height: 100%;
}
}
}
}
@ -112,6 +83,7 @@
.mat-horizontal-stepper-wrapper {
flex: 1 1 100%;
width: 100%;
}
.mat-horizontal-content-container {
@ -125,13 +97,5 @@
}
}
}
.tb-form-panel .mat-expansion-panel.tb-settings {
padding: 11px 16px;
& > .mat-expansion-panel-content > .mat-expansion-panel-body {
gap: 0;
}
}
}
}

23
ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts

@ -14,13 +14,13 @@
/// limitations under the License.
///
import { NotificationDeliveryMethod, NotificationTemplate, NotificationType } from '@shared/models/notification.models';
import { NotificationTemplate, NotificationType } from '@shared/models/notification.models';
import { Component, Inject, OnDestroy, ViewChild } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder } from '@angular/forms';
import { FormBuilder, FormGroup } from '@angular/forms';
import { NotificationService } from '@core/http/notification.service';
import { deepClone, isDefinedAndNotNull } from '@core/utils';
import { Observable } from 'rxjs';
@ -31,8 +31,7 @@ import { BreakpointObserver } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants';
import { TranslateService } from '@ngx-translate/core';
import { TemplateConfiguration } from '@home/pages/notification/template/template-configuration';
import { AuthState } from '@core/auth/auth.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { AuthUser } from '@shared/models/user.model';
import { Authority } from '@shared/models/authority.enum';
@ -62,9 +61,10 @@ export class TemplateNotificationDialogComponent
selectedIndex = 0;
hideSelectType = false;
notificationTemplateConfigurationForm: FormGroup;
private readonly templateNotification: NotificationTemplate;
private authState: AuthState = getCurrentAuthState(this.store);
private authUser: AuthUser = this.authState.authUser;
private authUser: AuthUser = getCurrentAuthUser(this.store);
constructor(protected store: Store<AppState>,
protected router: Router,
@ -99,11 +99,9 @@ export class TemplateNotificationDialogComponent
}
this.templateNotificationForm.reset({}, {emitEvent: false});
this.templateNotificationForm.patchValue(this.templateNotification, {emitEvent: false});
// eslint-disable-next-line guard-for-in
for (const method in this.templateNotification.configuration.deliveryMethodsTemplates) {
this.deliveryMethodFormsMap.get(NotificationDeliveryMethod[method])
.patchValue(this.templateNotification.configuration.deliveryMethodsTemplates[method]);
}
this.notificationTemplateConfigurationForm.patchValue({
deliveryMethodsTemplates: this.templateNotification.configuration.deliveryMethodsTemplates
}, {emitEvent: false});
}
}
@ -119,6 +117,9 @@ export class TemplateNotificationDialogComponent
changeStep($event: StepperSelectionEvent) {
this.selectedIndex = $event.selectedIndex;
if ($event.previouslySelectedIndex > $event.selectedIndex) {
$event.previouslySelectedStep.interacted = false;
}
}
backStep() {

2
ui-ngx/src/app/shared/components/notification/template-autocomplete.component.html

@ -51,7 +51,7 @@
<span class="template-option-name" [innerHTML]="template.name | highlight:searchText"></span>
<mat-chip-set>
<mat-chip disabled *ngFor="let method of template.configuration.deliveryMethodsTemplates | keyvalue">
{{ notificationDeliveryMethodTranslateMap.get(method.key) | translate }}
{{ notificationDeliveryMethodInfoMap.get(method.key).name | translate }}
</mat-chip>
</mat-chip-set>
</mat-option>

4
ui-ngx/src/app/shared/components/notification/template-autocomplete.component.ts

@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link';
import { Direction } from '@shared/models/page/sort-order';
import { emptyPageData } from '@shared/models/page/page-data';
import {
NotificationDeliveryMethodTranslateMap,
NotificationDeliveryMethodInfoMap,
NotificationTemplate,
NotificationType
} from '@shared/models/notification.models';
@ -55,7 +55,7 @@ import { coerceBoolean } from '@shared/decorators/coercion';
})
export class TemplateAutocompleteComponent implements ControlValueAccessor, OnInit {
notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap;
notificationDeliveryMethodInfoMap = NotificationDeliveryMethodInfoMap;
selectTemplateFormGroup: FormGroup;
@Input()

7
ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.ts

@ -24,11 +24,7 @@ import { TranslateService } from '@ngx-translate/core';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { EntityService } from '@core/http/entity.service';
import { TruncatePipe } from '@shared/pipe/truncate.pipe';
import {
NotificationDeliveryMethodTranslateMap,
SlackChanelType,
SlackConversation
} from '@shared/models/notification.models';
import { SlackChanelType, SlackConversation } from '@shared/models/notification.models';
import { NotificationService } from '@core/http/notification.service';
import { isEqual } from '@core/utils';
@ -44,7 +40,6 @@ import { isEqual } from '@core/utils';
})
export class SlackConversationAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges {
notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap;
conversationSlackFormGroup: FormGroup;
@Input()

69
ui-ngx/src/app/shared/models/notification.models.ts

@ -92,7 +92,8 @@ export interface NotificationSettings {
deliveryMethodsConfigs: { [key in NotificationDeliveryMethod]: NotificationDeliveryMethodConfig };
}
export interface NotificationDeliveryMethodConfig extends Partial<SlackNotificationDeliveryMethodConfig>{
export interface NotificationDeliveryMethodConfig extends Partial<SlackNotificationDeliveryMethodConfig &
MobileNotificationDeliveryMethodConfig>{
enabled: boolean;
method: NotificationDeliveryMethod;
}
@ -101,6 +102,11 @@ interface SlackNotificationDeliveryMethodConfig {
botToken: string;
}
interface MobileNotificationDeliveryMethodConfig {
firebaseServiceAccountCredentials: string;
firebaseServiceAccountCredentialsFileName: string;
}
export interface SlackConversation {
id: string;
title: string;
@ -305,16 +311,19 @@ export interface NotificationTemplate extends Omit<BaseData<NotificationTemplate
}
interface NotificationTemplateConfig {
deliveryMethodsTemplates: {
[key in NotificationDeliveryMethod]: DeliveryMethodNotificationTemplate
};
deliveryMethodsTemplates: DeliveryMethodsTemplates;
}
export type DeliveryMethodsTemplates = {
[key in NotificationDeliveryMethod]: DeliveryMethodNotificationTemplate
}
export interface DeliveryMethodNotificationTemplate extends
Partial<WebDeliveryMethodNotificationTemplate
& EmailDeliveryMethodNotificationTemplate
& SlackDeliveryMethodNotificationTemplate
& MicrosoftTeamsDeliveryMethodNotificationTemplate>{
& MicrosoftTeamsDeliveryMethodNotificationTemplate
& MobileDeliveryMethodNotificationTemplate>{
body: string;
enabled: boolean;
method: NotificationDeliveryMethod;
@ -358,6 +367,10 @@ interface MicrosoftTeamsDeliveryMethodNotificationTemplate {
button: NotificationButtonConfig;
}
interface MobileDeliveryMethodNotificationTemplate {
subject: string;
}
export enum NotificationStatus {
SENT = 'SENT',
READ = 'READ'
@ -365,18 +378,52 @@ export enum NotificationStatus {
export enum NotificationDeliveryMethod {
WEB = 'WEB',
MOBILE_APP = 'MOBILE_APP',
SMS = 'SMS',
EMAIL = 'EMAIL',
SLACK = 'SLACK',
MICROSOFT_TEAMS = 'MICROSOFT_TEAMS'
}
export const NotificationDeliveryMethodTranslateMap = new Map<NotificationDeliveryMethod, string>([
[NotificationDeliveryMethod.WEB, 'notification.delivery-method.web'],
[NotificationDeliveryMethod.SMS, 'notification.delivery-method.sms'],
[NotificationDeliveryMethod.EMAIL, 'notification.delivery-method.email'],
[NotificationDeliveryMethod.SLACK, 'notification.delivery-method.slack'],
[NotificationDeliveryMethod.MICROSOFT_TEAMS, 'notification.delivery-method.microsoft-teams'],
export interface NotificationDeliveryMethodInfo {
name: string;
icon: string;
}
export const NotificationDeliveryMethodInfoMap = new Map<NotificationDeliveryMethod, NotificationDeliveryMethodInfo>([
[NotificationDeliveryMethod.WEB,
{
name: 'notification.delivery-method.web',
icon: 'mdi:bell-badge'
}
],
[NotificationDeliveryMethod.SMS,
{
name: 'notification.delivery-method.sms',
icon: 'mdi:message-processing'
}
],
[NotificationDeliveryMethod.EMAIL,
{
name: 'notification.delivery-method.email',
icon: 'mdi:email'
}],
[NotificationDeliveryMethod.SLACK,
{
name: 'notification.delivery-method.slack',
icon: 'mdi:slack'
}
],
[NotificationDeliveryMethod.MOBILE_APP,
{
name: 'notification.delivery-method.mobile-app',
icon: 'mdi:cellphone-text'
}],
[NotificationDeliveryMethod.MICROSOFT_TEAMS,
{
name: 'notification.delivery-method.microsoft-teams',
icon: 'mdi:microsoft-teams'
}]
]);
export enum NotificationRequestStatus {

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

@ -478,7 +478,10 @@
"notifications-settings": "Notifications settings",
"slack-api-token": "Slack API token",
"slack": "Slack",
"slack-settings": "Slack settings"
"slack-settings": "Slack settings",
"mobile-settings": "Mobile settings",
"firebase-service-account-file": "Firebase service account credentials JSON file",
"select-firebase-service-account-file": "Drag and drop your Firebase service account credentials file or "
},
"alarm": {
"alarm": "Alarm",
@ -3250,6 +3253,7 @@
"copy-template": "Copy template",
"create-new": "Create new",
"created": "Created",
"customize-messages": "Customize messages",
"delete-notification-text": "Be careful, after the confirmation the notification will become unrecoverable.",
"delete-notification-title": "Are you sure you want to delete the notification?",
"delete-notifications-text": "Be careful, after the confirmation notifications will become unrecoverable.",
@ -3282,7 +3286,9 @@
"sms": "SMS",
"sms-preview": "SMS notification preview",
"web": "Web",
"web-preview": "Web notification preview"
"web-preview": "Web notification preview",
"mobile-app": "Mobile app",
"mobile-app-preview": "Mobile app notification preview"
},
"delivery-method-not-configure-click": "Delivery method is not configured. Click to setup.",
"delivery-method-not-configure-contact": "Delivery method is not configured. Contact your system administrator.",
@ -3341,6 +3347,7 @@
"not-found-slack-recipient": "Slack recipient not found",
"notification": "Notification",
"notification-center": "Notification center",
"notification-tap-action": "Notification tap action",
"notify": "notify",
"notify-again": "Notify again",
"notify-alarm-action": {
@ -3410,6 +3417,7 @@
"selected-template": "{ count, plural, =1 {1 template} other {# templates} } selected",
"send-notification": "Send notification",
"sent": "Sent",
"setup": "Setup",
"notification-sent": "Notifications / Sent",
"set-entity-from-notification": "Set entity from notification to dashboard state",
"slack-chanel-type": "Slack channel type",
@ -3423,6 +3431,7 @@
"stop-escalation-alarm-status-become": "Stop the escalation on the alarm status become:",
"subject": "Subject",
"subject-required": "Subject is required",
"subject-max-length": "Subject should be less than or equal to {{ length }} characters",
"template": "Template",
"template-name": "Template name",
"template-required": "Template is required",

Loading…
Cancel
Save