Browse Source

Merge branch 'improvement/rule-chain/note' of github.com:vvlladd28/thingsboard into vvlladd28-improvement/rule-chain/note

pull/15121/head
Viacheslav Klimov 7 months ago
parent
commit
bc202d26f3
Failed to extract signature
  1. 5
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 46
      common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainDetails.java
  3. 5
      common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java
  4. 64
      common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainNote.java
  5. 1
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  6. 127
      dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractRuleChainEntity.java
  7. 65
      dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDetailsEntity.java
  8. 78
      dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java
  9. 20
      dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
  10. 5
      dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java
  11. 15
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java
  12. 25
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainDetailsRepository.java
  13. 1
      dao/src/main/resources/sql/schema-entities.sql
  14. 2
      ui-ngx/package.json
  15. 58
      ui-ngx/src/app/core/services/item-buffer.service.ts
  16. 36
      ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.html
  17. 30
      ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss
  18. 59
      ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html
  19. 24
      ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss
  20. 106
      ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts
  21. 51
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html
  22. 23
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss
  23. 302
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts
  24. 2
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts
  25. 6
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.module.ts
  26. 10
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts
  27. 44
      ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.html
  28. 65
      ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.scss
  29. 88
      ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.ts
  30. 2
      ui-ngx/src/app/shared/components/markdown-editor.component.html
  31. 2
      ui-ngx/src/app/shared/components/markdown-editor.component.ts
  32. 3
      ui-ngx/src/app/shared/models/rule-chain.models.ts
  33. 16
      ui-ngx/src/app/shared/models/rule-node.models.ts
  34. 195
      ui-ngx/src/assets/help/en_US/rulechain/note_content.md
  35. 12
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  36. 6
      ui-ngx/yarn.lock

5
application/src/main/data/upgrade/basic/schema_update.sql

@ -14,3 +14,8 @@
-- limitations under the License.
--
-- RULE CHAIN NOTES MIGRATION START
ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(10000000);
-- RULE CHAIN NOTES MIGRATION END

46
common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainDetails.java

@ -0,0 +1,46 @@
/**
* Copyright © 2016-2026 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.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public class RuleChainDetails extends RuleChain {
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@Schema(description = "List of sticky notes placed on the rule chain canvas")
private List<RuleChainNote> notes;
public RuleChainDetails() {
super();
}
public RuleChainDetails(RuleChain ruleChain) {
super(ruleChain);
}
public RuleChainDetails(RuleChainDetails ruleChainDetails) {
super(ruleChainDetails);
this.notes = ruleChainDetails.getNotes();
}
}

5
common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.common.data.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.thingsboard.server.common.data.HasVersion;
@ -48,6 +49,10 @@ public class RuleChainMetaData implements HasVersion {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of JSON objects that represent connections between rule nodes and other rule chains.")
private List<RuleChainConnectionInfo> ruleChainConnections;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "List of sticky notes placed on the rule chain canvas")
private List<RuleChainNote> notes;
public void addConnectionInfo(int fromIndex, int toIndex, String type) {
NodeConnectionInfo connectionInfo = new NodeConnectionInfo();
connectionInfo.setFromIndex(fromIndex);

64
common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainNote.java

@ -0,0 +1,64 @@
/**
* Copyright © 2016-2026 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.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* Represents a sticky note on the rule chain canvas.
* Notes are purely visual metadata and are stored as part of the rule chain.
*/
@Schema
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RuleChainNote {
@Schema(description = "Unique identifier of the note on the canvas")
private String id;
@Schema(description = "Horizontal position of the note on the canvas, in pixels")
private int x;
@Schema(description = "Vertical position of the note on the canvas, in pixels")
private int y;
@Schema(description = "Width of the note, in pixels")
private int width;
@Schema(description = "Height of the note, in pixels")
private int height;
@Schema(description = "Markdown or HTML content of the note")
private String content;
@Schema(description = "Background color of the note in CSS hex format, e.g. '#FFF9C4'")
private String backgroundColor;
@Schema(description = "Border color of the note in CSS hex format, e.g. '#E6C800'")
private String borderColor;
@Schema(description = "Border width of the note in pixels")
private Integer borderWidth;
@Schema(description = "Whether to apply the default markdown stylesheet to the note content")
private Boolean applyDefaultMarkdownStyle;
@Schema(description = "Custom CSS styles applied to the note content")
private String markdownCss;
}

1
dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java

@ -420,6 +420,7 @@ public class ModelConstants {
public static final String RULE_CHAIN_FIRST_RULE_NODE_ID_PROPERTY = "first_rule_node_id";
public static final String RULE_CHAIN_ROOT_PROPERTY = "root";
public static final String RULE_CHAIN_CONFIGURATION_PROPERTY = "configuration";
public static final String RULE_CHAIN_NOTES_PROPERTY = "notes";
/**
* Rule node constants.

127
dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractRuleChainEntity.java

@ -0,0 +1,127 @@
/**
* Copyright © 2016-2026 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.dao.model.sql;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.MappedSuperclass;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.model.BaseVersionedEntity;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.util.mapping.JsonConverter;
import java.util.UUID;
@Data
@EqualsAndHashCode(callSuper = true)
@MappedSuperclass
public abstract class AbstractRuleChainEntity<T extends RuleChain> extends BaseVersionedEntity<T> {
@Column(name = ModelConstants.RULE_CHAIN_TENANT_ID_PROPERTY)
private UUID tenantId;
@Column(name = ModelConstants.RULE_CHAIN_NAME_PROPERTY)
private String name;
@Enumerated(EnumType.STRING)
@Column(name = ModelConstants.RULE_CHAIN_TYPE_PROPERTY)
private RuleChainType type;
@Column(name = ModelConstants.RULE_CHAIN_FIRST_RULE_NODE_ID_PROPERTY)
private UUID firstRuleNodeId;
@Column(name = ModelConstants.RULE_CHAIN_ROOT_PROPERTY)
private boolean root;
@Column(name = ModelConstants.DEBUG_MODE)
private boolean debugMode;
@Convert(converter = JsonConverter.class)
@Column(name = ModelConstants.RULE_CHAIN_CONFIGURATION_PROPERTY)
private JsonNode configuration;
@Convert(converter = JsonConverter.class)
@Column(name = ModelConstants.ADDITIONAL_INFO_PROPERTY)
private JsonNode additionalInfo;
@Column(name = ModelConstants.EXTERNAL_ID_PROPERTY)
private UUID externalId;
public AbstractRuleChainEntity() {
super();
}
public AbstractRuleChainEntity(T ruleChain) {
super(ruleChain);
this.tenantId = DaoUtil.getId(ruleChain.getTenantId());
this.name = ruleChain.getName();
this.type = ruleChain.getType();
if (ruleChain.getFirstRuleNodeId() != null) {
this.firstRuleNodeId = ruleChain.getFirstRuleNodeId().getId();
}
this.root = ruleChain.isRoot();
this.debugMode = ruleChain.isDebugMode();
this.configuration = ruleChain.getConfiguration();
this.additionalInfo = ruleChain.getAdditionalInfo();
if (ruleChain.getExternalId() != null) {
this.externalId = ruleChain.getExternalId().getId();
}
}
public AbstractRuleChainEntity(AbstractRuleChainEntity<?> ruleChainEntity) {
super(ruleChainEntity);
this.tenantId = ruleChainEntity.getTenantId();
this.name = ruleChainEntity.getName();
this.type = ruleChainEntity.getType();
this.firstRuleNodeId = ruleChainEntity.getFirstRuleNodeId();
this.root = ruleChainEntity.isRoot();
this.debugMode = ruleChainEntity.isDebugMode();
this.configuration = ruleChainEntity.getConfiguration();
this.additionalInfo = ruleChainEntity.getAdditionalInfo();
this.externalId = ruleChainEntity.getExternalId();
}
protected RuleChain toRuleChain() {
RuleChain ruleChain = new RuleChain(new RuleChainId(this.getUuid()));
ruleChain.setCreatedTime(createdTime);
ruleChain.setVersion(version);
ruleChain.setTenantId(TenantId.fromUUID(tenantId));
ruleChain.setName(name);
ruleChain.setType(type);
if (firstRuleNodeId != null) {
ruleChain.setFirstRuleNodeId(new RuleNodeId(firstRuleNodeId));
}
ruleChain.setRoot(root);
ruleChain.setDebugMode(debugMode);
ruleChain.setConfiguration(configuration);
ruleChain.setAdditionalInfo(additionalInfo);
if (externalId != null) {
ruleChain.setExternalId(new RuleChainId(externalId));
}
return ruleChain;
}
}

65
dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDetailsEntity.java

@ -0,0 +1,65 @@
/**
* Copyright © 2016-2026 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.dao.model.sql;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import org.thingsboard.server.common.data.rule.RuleChainNote;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.util.mapping.JsonConverter;
import java.util.Arrays;
@Data
@EqualsAndHashCode(callSuper = true)
@Entity
@Table(name = ModelConstants.RULE_CHAIN_TABLE_NAME)
public class RuleChainDetailsEntity extends AbstractRuleChainEntity<RuleChainDetails> {
@Convert(converter = JsonConverter.class)
@Column(name = ModelConstants.RULE_CHAIN_NOTES_PROPERTY)
private JsonNode notes;
public RuleChainDetailsEntity() {
super();
}
public RuleChainDetailsEntity(RuleChainDetails ruleChainDetails) {
super(ruleChainDetails);
if (ruleChainDetails.getNotes() != null) {
this.notes = JacksonUtil.valueToTree(ruleChainDetails.getNotes());
}
}
@Override
public RuleChainDetails toData() {
RuleChain ruleChain = super.toRuleChain();
RuleChainDetails details = new RuleChainDetails(ruleChain);
if (notes != null && notes.isArray()) {
details.setNotes(Arrays.asList(JacksonUtil.treeToValue(notes, RuleChainNote[].class)));
}
return details;
}
}

78
dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java

@ -15,102 +15,30 @@
*/
package org.thingsboard.server.dao.model.sql;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.model.BaseVersionedEntity;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.util.mapping.JsonConverter;
import java.util.UUID;
@Data
@EqualsAndHashCode(callSuper = true)
@Entity
@Table(name = ModelConstants.RULE_CHAIN_TABLE_NAME)
public class RuleChainEntity extends BaseVersionedEntity<RuleChain> {
@Column(name = ModelConstants.RULE_CHAIN_TENANT_ID_PROPERTY)
private UUID tenantId;
@Column(name = ModelConstants.RULE_CHAIN_NAME_PROPERTY)
private String name;
@Enumerated(EnumType.STRING)
@Column(name = ModelConstants.RULE_CHAIN_TYPE_PROPERTY)
private RuleChainType type;
@Column(name = ModelConstants.RULE_CHAIN_FIRST_RULE_NODE_ID_PROPERTY)
private UUID firstRuleNodeId;
@Column(name = ModelConstants.RULE_CHAIN_ROOT_PROPERTY)
private boolean root;
@Column(name = ModelConstants.DEBUG_MODE)
private boolean debugMode;
@Convert(converter = JsonConverter.class)
@Column(name = ModelConstants.RULE_CHAIN_CONFIGURATION_PROPERTY)
private JsonNode configuration;
@Convert(converter = JsonConverter.class)
@Column(name = ModelConstants.ADDITIONAL_INFO_PROPERTY)
private JsonNode additionalInfo;
@Column(name = ModelConstants.EXTERNAL_ID_PROPERTY)
private UUID externalId;
public class RuleChainEntity extends AbstractRuleChainEntity<RuleChain> {
public RuleChainEntity() {
super();
}
public RuleChainEntity(RuleChain ruleChain) {
super(ruleChain);
this.tenantId = DaoUtil.getId(ruleChain.getTenantId());
this.name = ruleChain.getName();
this.type = ruleChain.getType();
if (ruleChain.getFirstRuleNodeId() != null) {
this.firstRuleNodeId = ruleChain.getFirstRuleNodeId().getId();
}
this.root = ruleChain.isRoot();
this.debugMode = ruleChain.isDebugMode();
this.configuration = ruleChain.getConfiguration();
this.additionalInfo = ruleChain.getAdditionalInfo();
if (ruleChain.getExternalId() != null) {
this.externalId = ruleChain.getExternalId().getId();
}
}
@Override
public RuleChain toData() {
RuleChain ruleChain = new RuleChain(new RuleChainId(this.getUuid()));
ruleChain.setCreatedTime(createdTime);
ruleChain.setVersion(version);
ruleChain.setTenantId(TenantId.fromUUID(tenantId));
ruleChain.setName(name);
ruleChain.setType(type);
if (firstRuleNodeId != null) {
ruleChain.setFirstRuleNodeId(new RuleNodeId(firstRuleNodeId));
}
ruleChain.setRoot(root);
ruleChain.setDebugMode(debugMode);
ruleChain.setConfiguration(configuration);
ruleChain.setAdditionalInfo(additionalInfo);
if (externalId != null) {
ruleChain.setExternalId(new RuleChainId(externalId));
}
return ruleChain;
return super.toRuleChain();
}
}

20
dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java

@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.rule.NodeConnectionInfo;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainConnectionInfo;
import org.thingsboard.server.common.data.rule.RuleChainData;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import org.thingsboard.server.common.data.rule.RuleChainImportResult;
import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleChainType;
@ -316,9 +317,11 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
if (!relations.isEmpty()) {
relationService.saveRelations(tenantId, relations);
}
ruleChain = ruleChainDao.save(tenantId, ruleChain);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChain)
.entityId(ruleChain.getId()).broadcastEvent(publishSaveEvent).build());
RuleChainDetails ruleChainDetails = new RuleChainDetails(ruleChain);
ruleChainDetails.setNotes(ruleChainMetaData.getNotes());
ruleChainDetails = ruleChainDao.saveDetails(ruleChainDetails);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChainDetails)
.entityId(ruleChainDetails.getId()).broadcastEvent(publishSaveEvent).build());
return RuleChainUpdateResult.successful(updatedRuleNodes);
}
@ -341,13 +344,13 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
@Override
public RuleChainMetaData loadRuleChainMetaData(TenantId tenantId, RuleChainId ruleChainId) {
Validator.validateId(ruleChainId, "Incorrect rule chain id.");
RuleChain ruleChain = findRuleChainById(tenantId, ruleChainId);
if (ruleChain == null) {
RuleChainDetails ruleChainDetails = ruleChainDao.findDetailsById(ruleChainId.getId());
if (ruleChainDetails == null) {
return null;
}
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChainId);
ruleChainMetaData.setVersion(ruleChain.getVersion());
ruleChainMetaData.setVersion(ruleChainDetails.getVersion());
List<RuleNode> ruleNodes = getRuleChainNodes(tenantId, ruleChainId);
Collections.sort(ruleNodes, Comparator.comparingLong(RuleNode::getCreatedTime).thenComparing(RuleNode::getId, Comparator.comparing(RuleNodeId::getId)));
Map<RuleNodeId, Integer> ruleNodeIndexMap = new HashMap<>();
@ -355,8 +358,8 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
ruleNodeIndexMap.put(node.getId(), ruleNodes.indexOf(node));
}
ruleChainMetaData.setNodes(ruleNodes);
if (ruleChain.getFirstRuleNodeId() != null) {
ruleChainMetaData.setFirstNodeIndex(ruleNodeIndexMap.get(ruleChain.getFirstRuleNodeId()));
if (ruleChainDetails.getFirstRuleNodeId() != null) {
ruleChainMetaData.setFirstNodeIndex(ruleNodeIndexMap.get(ruleChainDetails.getFirstRuleNodeId()));
}
for (RuleNode node : ruleNodes) {
int fromIndex = ruleNodeIndexMap.get(node.getId());
@ -376,6 +379,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
Collections.sort(ruleChainMetaData.getConnections(), Comparator.comparingInt(NodeConnectionInfo::getFromIndex)
.thenComparing(NodeConnectionInfo::getToIndex).thenComparing(NodeConnectionInfo::getType));
}
ruleChainMetaData.setNotes(ruleChainDetails.getNotes());
return ruleChainMetaData;
}

5
dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java

@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.ExportableEntityDao;
@ -85,4 +86,8 @@ public interface RuleChainDao extends Dao<RuleChain>, TenantEntityDao<RuleChain>
List<RuleChain> findRuleChainsByTenantIdAndIds(UUID tenantId, List<UUID> ruleChainIds);
RuleChainDetails findDetailsById(UUID id);
RuleChainDetails saveDetails(RuleChainDetails ruleChainDetails);
}

15
dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java

@ -29,8 +29,10 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.model.sql.RuleChainDetailsEntity;
import org.thingsboard.server.dao.model.sql.RuleChainEntity;
import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
@ -49,6 +51,9 @@ public class JpaRuleChainDao extends JpaAbstractDao<RuleChainEntity, RuleChain>
@Autowired
private RuleChainRepository ruleChainRepository;
@Autowired
private RuleChainDetailsRepository ruleChainDetailsRepository;
@Override
protected Class<RuleChainEntity> getEntityClass() {
return RuleChainEntity.class;
@ -163,6 +168,16 @@ public class JpaRuleChainDao extends JpaAbstractDao<RuleChainEntity, RuleChain>
return ruleChainRepository.findNextBatch(id, Limit.of(batchSize));
}
@Override
public RuleChainDetails findDetailsById(UUID id) {
return DaoUtil.getData(ruleChainDetailsRepository.findById(id));
}
@Override
public RuleChainDetails saveDetails(RuleChainDetails ruleChainDetails) {
return DaoUtil.getData(ruleChainDetailsRepository.save(new RuleChainDetailsEntity(ruleChainDetails)));
}
@Override
public EntityType getEntityType() {
return EntityType.RULE_CHAIN;

25
dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainDetailsRepository.java

@ -0,0 +1,25 @@
/**
* Copyright © 2016-2026 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.dao.sql.rule;
import org.springframework.data.jpa.repository.JpaRepository;
import org.thingsboard.server.dao.model.sql.RuleChainDetailsEntity;
import java.util.UUID;
public interface RuleChainDetailsRepository extends JpaRepository<RuleChainDetailsEntity, UUID> {
}

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

@ -162,6 +162,7 @@ CREATE TABLE IF NOT EXISTS rule_chain (
created_time bigint NOT NULL,
additional_info varchar,
configuration varchar(10000000),
notes varchar(10000000),
name varchar(255),
type varchar(255),
first_rule_node_id uuid,

2
ui-ngx/package.json

@ -71,7 +71,7 @@
"ngx-clipboard": "^16.0.0",
"ngx-daterangepicker-material": "^6.0.4",
"ngx-drag-drop": "^20.0.1",
"ngx-flowchart": "https://github.com/thingsboard/ngx-flowchart.git#release/4.0.0",
"ngx-flowchart": "https://github.com/thingsboard/ngx-flowchart.git#release/4.1.0",
"ngx-hm-carousel": "^19.0.0",
"ngx-markdown": "^20.1.0",
"ngx-sharebuttons": "^17.0.0",

58
ui-ngx/src/app/core/services/item-buffer.service.ts

@ -16,7 +16,13 @@
import { Injectable } from '@angular/core';
import { BreakpointId, Dashboard, DashboardLayoutId } from '@app/shared/models/dashboard.models';
import { AliasesInfo, EntityAlias, EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models';
import {
AliasesInfo,
EntityAlias,
EntityAliases,
EntityAliasInfo,
getEntityAliasId
} from '@shared/models/alias.models';
import {
Datasource,
DatasourceType,
@ -31,7 +37,7 @@ import { deepClone, isDefinedAndNotNull, isEqual } from '@core/utils';
import { UtilsService } from '@core/services/utils.service';
import { Observable, of, throwError } from 'rxjs';
import { map } from 'rxjs/operators';
import { FcRuleNode, ruleNodeTypeDescriptors } from '@shared/models/rule-node.models';
import { FcRuleNode, FcRuleNote, ruleNodeTypeDescriptors } from '@shared/models/rule-node.models';
import { RuleChainService } from '@core/http/rule-chain.service';
import { RuleChainImport } from '@shared/models/rule-chain.models';
import { Filter, FilterInfo, Filters, FiltersInfo, getFilterId } from '@shared/models/query/query.models';
@ -72,8 +78,11 @@ export interface RuleNodeConnection {
export interface RuleNodesReference {
nodes: FcRuleNode[];
connections: RuleNodeConnection[];
notes?: FcRuleNote[];
originX?: number;
originY?: number;
minX?: number;
minY?: number;
}
@Injectable({
@ -312,10 +321,11 @@ export class ItemBufferService {
return of(theDashboard);
}
public copyRuleNodes(nodes: FcRuleNode[], connections: RuleNodeConnection[]) {
public copyRuleChainObjects(nodes: FcRuleNode[], connections: RuleNodeConnection[], notes: FcRuleNote[] = []) {
const ruleNodes: RuleNodesReference = {
nodes: [],
connections: []
connections: [],
notes: []
};
let top = -1;
let left = -1;
@ -364,23 +374,51 @@ export class ItemBufferService {
right = Math.max(right, node.x + 170);
}
}
for (const origNote of notes) {
ruleNodes.notes.push({
id: '',
x: origNote.x,
y: origNote.y,
width: origNote.width,
height: origNote.height,
content: origNote.content,
backgroundColor: origNote.backgroundColor,
borderWidth: origNote.borderWidth,
borderColor: origNote.borderColor,
applyDefaultMarkdownStyle: origNote.applyDefaultMarkdownStyle,
markdownCss: origNote.markdownCss
});
if (top === -1) {
top = origNote.y;
left = origNote.x;
bottom = origNote.y + (origNote.height || 120);
right = origNote.x + (origNote.width || 200);
} else {
top = Math.min(top, origNote.y);
left = Math.min(left, origNote.x);
bottom = Math.max(bottom, origNote.y + (origNote.height || 120));
right = Math.max(right, origNote.x + (origNote.width || 200));
}
}
ruleNodes.originX = left + (right - left) / 2;
ruleNodes.originY = top + (bottom - top) / 2;
ruleNodes.minX = left;
ruleNodes.minY = top;
connections.forEach(connection => {
ruleNodes.connections.push(connection);
});
this.storeSet(RULE_NODES, ruleNodes);
}
public hasRuleNodes(): boolean {
public hasRuleChainObjects(): boolean {
return this.storeHas(RULE_NODES);
}
public pasteRuleNodes(x: number, y: number): RuleNodesReference {
public pasteRuleChainObjects(x: number, y: number): RuleNodesReference {
const ruleNodes: RuleNodesReference = this.storeGet(RULE_NODES);
if (ruleNodes) {
const deltaX = x - ruleNodes.originX;
const deltaY = y - ruleNodes.originY;
const deltaX = isDefinedAndNotNull(ruleNodes.minX) ? Math.max(x - ruleNodes.originX, -ruleNodes.minX) : x - ruleNodes.originX;
const deltaY = isDefinedAndNotNull(ruleNodes.minY) ? Math.max(y - ruleNodes.originY, -ruleNodes.minY) : y - ruleNodes.originY;
for (const node of ruleNodes.nodes) {
const component = this.ruleChainService.getRuleNodeComponentByClazz(node.ruleChainType, node.componentClazz);
if (component) {
@ -404,6 +442,10 @@ export class ItemBufferService {
return null;
}
}
for (const note of (ruleNodes.notes || [])) {
note.x = Math.round(note.x + deltaX);
note.y = Math.round(note.y + deltaY);
}
return ruleNodes;
}
return null;

36
ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.html

@ -0,0 +1,36 @@
<!--
Copyright © 2016-2026 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.
-->
<mat-toolbar color="primary">
<h2 translate>rulechain.add-note</h2>
<span class="flex-1"></span>
<button mat-icon-button type="button" (click)="cancel()">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<tb-rule-note-editor #tbRuleNote [note]="data">
</tb-rule-note-editor>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary" type="button" (click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="button" (click)="save()">
{{ 'action.create' | translate }}
</button>
</div>

30
ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss

@ -0,0 +1,30 @@
/**
* Copyright © 2016-2026 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 {
display: grid;
width: 650px;
height: 100%;
max-width: 100%;
max-height: 100vh;
grid-template-rows: min-content minmax(auto, 1fr) min-content;
}
:host-context(.tb-fullscreen-dialog .mat-mdc-dialog-container) {
.mat-mdc-dialog-content {
padding: 8px;
}
}

59
ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html

@ -0,0 +1,59 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<form [formGroup]="noteForm" class="tb-form-panel no-border">
<tb-markdown-editor formControlName="content"
label="{{ 'rulechain.note-content' | translate }}"
helpId="rulechain/note_content"
[helpPopupStyle]="{'max-width': '850px'}">
</tb-markdown-editor>
<div class="tb-form-row space-between">
<div>{{ 'rulechain.note-background-color' | translate }}</div>
<tb-color-input asBoxInput
colorClearButton
formControlName="backgroundColor">
</tb-color-input>
</div>
<section class="tb-form-panel stroked">
<mat-expansion-panel #advancedPanel class="tb-settings">
<mat-expansion-panel-header>
<mat-panel-title translate>rule-node-config.advanced-settings</mat-panel-title>
</mat-expansion-panel-header>
<div class="tb-form-row space-between">
<div>{{ 'rulechain.note-border' | translate }}</div>
<div class="flex flex-row items-center gap-2">
<mat-form-field class="number" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="borderWidth">
<span matSuffix>px</span>
</mat-form-field>
<tb-color-input asBoxInput
colorClearButton
formControlName="borderColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="applyDefaultMarkdownStyle">
{{ 'rulechain.note-apply-default-markdown-style' | translate }}
</mat-slide-toggle>
</div>
<tb-css formControlName="markdownCss"
label="{{ 'rulechain.note-custom-css' | translate }}">
</tb-css>
</mat-expansion-panel>
</section>
</form>

24
ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host ::ng-deep {
tb-markdown-editor {
.markdown-content.tb-edit-mode {
&:not(.tb-fullscreen) {
padding-bottom: 0;
}
}
}
}

106
ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts

@ -0,0 +1,106 @@
///
/// Copyright © 2016-2026 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, DestroyRef, inject, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatExpansionPanel } from '@angular/material/expansion';
import { FormBuilder, Validators } from '@angular/forms';
import {
FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE as DEFAULT_APPLY_MARKDOWN_STYLE,
FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR as DEFAULT_BACKGROUND_COLOR,
FC_RULE_NOTE_DEFAULT_BORDER_WIDTH as DEFAULT_BORDER_WIDTH,
FcRuleNote
} from '@shared/models/rule-node.models';
import { isNotEmptyStr } from '@core/utils';
import tinycolor from 'tinycolor2';
@Component({
selector: 'tb-rule-note-editor',
templateUrl: './rule-note-editor.component.html',
styleUrls: ['./rule-note-editor.component.scss'],
standalone: false
})
export class RuleNoteEditorComponent implements OnChanges, OnInit {
@Input()
note: FcRuleNote;
@ViewChild('advancedPanel') advancedPanel: MatExpansionPanel;
private fb = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
noteForm = this.fb.group({
content: ['', Validators.maxLength(65536)],
backgroundColor: [DEFAULT_BACKGROUND_COLOR],
borderColor: [tinycolor(DEFAULT_BACKGROUND_COLOR).darken(20).toString()],
borderWidth: [DEFAULT_BORDER_WIDTH, [Validators.min(0)]],
applyDefaultMarkdownStyle: [DEFAULT_APPLY_MARKDOWN_STYLE],
markdownCss: ['', Validators.maxLength(65536)]
});
ngOnInit(): void {
this.noteForm.get('backgroundColor').valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(color => {
const borderControl = this.noteForm.get('borderColor');
if (borderControl.pristine) {
borderControl.setValue(this.borderColorFrom(color), { emitEvent: false });
}
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.note) {
this.updatedForm();
}
}
private borderColorFrom(bg: string): string {
const color = tinycolor(bg || DEFAULT_BACKGROUND_COLOR);
return color.isDark() ? color.lighten(20).toString() : color.darken(20).toString();
}
private updatedForm(): void {
const bg = this.note?.backgroundColor || DEFAULT_BACKGROUND_COLOR;
this.noteForm.setValue({
content: this.note?.content || '',
backgroundColor: bg,
borderColor: this.note?.borderColor || this.borderColorFrom(bg),
borderWidth: this.note?.borderWidth ?? DEFAULT_BORDER_WIDTH,
applyDefaultMarkdownStyle: this.note?.applyDefaultMarkdownStyle ?? DEFAULT_APPLY_MARKDOWN_STYLE,
markdownCss: this.note?.markdownCss || ''
});
const shouldExpand = this.hasNonDefaultAdvancedSettings(bg);
setTimeout(() => {
if (shouldExpand) {
this.advancedPanel?.open();
} else {
this.advancedPanel?.close();
}
this.noteForm.markAsPristine();
}, 0);
}
private hasNonDefaultAdvancedSettings(bg: string): boolean {
const note = this.note;
if (!note) return false;
return (note.borderColor != null && note.borderColor !== this.borderColorFrom(bg))
|| (note.borderWidth != null && note.borderWidth !== DEFAULT_BORDER_WIDTH)
|| note.applyDefaultMarkdownStyle === false
|| isNotEmptyStr(note.markdownCss);
}
}

51
ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html

@ -84,7 +84,7 @@
</div>
</mat-drawer>
<mat-drawer class="tb-details-drawer"
[opened]="isEditingRuleNode || isEditingRuleNodeLink"
[opened]="isEditingRuleNode || isEditingRuleNodeLink || isEditingNote"
(closed)="onDetailsDrawerClosed()"
mode="over"
position="end">
@ -158,6 +158,20 @@
[sourceRuleChainId]="editingRuleNodeSourceRuleChainId">
</tb-rule-node-link>
</tb-details-panel>
<tb-details-panel *ngIf="editingNote" class="flex-1"
headerTitle="{{ 'rulechain.note' | translate }}"
headerSubtitle="{{ 'rulechain.note-details' | translate }}"
[isReadOnly]="false"
[isAlwaysEdit]="true"
(closeDetails)="onEditNoteClosed()"
(toggleDetailsEditMode)="onRevertNoteEdit()"
(applyDetails)="saveNote()"
[theForm]="tbRuleNote.noteForm">
<tb-rule-note-editor #tbRuleNote
[note]="editingNote"
class="mt-3 flex-1">
</tb-rule-note-editor>
</tb-details-panel>
</mat-drawer>
<mat-drawer-content class="tb-rulechain-graph-content">
<button color="primary"
@ -168,16 +182,25 @@
matTooltipPosition="above">
<mat-icon class="tb-library-node-btn-icon" [class.tb-library-node-btn-icon-toggled]="drawer.opened">chevron_right</mat-icon>
</button>
<button #versionControlButton
*ngIf="!isImport"
color="primary"
type="button"
mat-mini-fab class="version-control-button"
(click)="toggleVersionControl($event, versionControlButton)"
matTooltip="{{'version-control.version-control' | translate}}"
matTooltipPosition="above">
<mat-icon>history</mat-icon>
</button>
@if (!isImport) {
<button color="primary"
type="button"
mat-mini-fab class="add-note-button"
(click)="addNote()"
matTooltip="{{'rulechain.add-note' | translate}}"
matTooltipPosition="above">
<mat-icon>sticky_note_2</mat-icon>
</button>
<button #versionControlButton
color="primary"
type="button"
mat-mini-fab class="version-control-button"
(click)="toggleVersionControl($event, versionControlButton)"
matTooltip="{{'version-control.version-control' | translate}}"
matTooltipPosition="above">
<mat-icon>history</mat-icon>
</button>
}
<button type="button"
mat-icon-button class="tb-fullscreen-button tb-mat-40"
(click)="isFullscreen = !isFullscreen"
@ -198,9 +221,11 @@
<div class="tb-context-menu-header {{contextInfo.headerClass}}">
<mat-icon *ngIf="!contextInfo.iconUrl">{{contextInfo.icon}}</mat-icon>
<img *ngIf="contextInfo.iconUrl" [src]="contextInfo.iconUrl"/>
<div class="flex-1">
<div class="flex-1 self-center">
<div class="tb-context-menu-title">{{contextInfo.title}}</div>
<div class="tb-context-menu-subtitle">{{contextInfo.subtitle}}</div>
@if (contextInfo.subtitle) {
<div class="tb-context-menu-subtitle">{{contextInfo.subtitle}}</div>
}
</div>
</div>
<div *ngFor="let menuItem of contextInfo.menuItems">

23
ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss

@ -54,6 +54,15 @@
z-index: 2;
}
button.mdc-fab.add-note-button {
position: absolute;
top: 10px;
right: 110px;
opacity: .85;
margin: 0 6px;
z-index: 2;
}
.tb-library-node-btn {
width: 20px;
height: 90px;
@ -188,6 +197,20 @@
margin: -3px;
border: solid 3px #f00;
}
&.fc-edit {
margin: -2px;
border: solid 2px rgba(0, 0, 0, 0.35);
}
}
}
.fc-note {
border-radius: 8px;
&.fc-selected:not(.fc-edit) {
box-shadow: 0 0 0 3px #f00;
}
&.fc-selected.fc-edit {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
}
}

302
ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts

@ -51,7 +51,6 @@ import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatExpansionPanel } from '@angular/material/expansion';
import { DialogService } from '@core/services/dialog.service';
import { AuthService } from '@core/auth/auth.service';
import { ActivatedRoute, Router } from '@angular/router';
import {
inputNodeComponent,
@ -63,9 +62,12 @@ import {
} from '@shared/models/rule-chain.models';
import { FcItemInfo, FlowchartConstants, NgxFlowchartComponent, UserCallbacks } from 'ngx-flowchart';
import {
FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE,
FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR,
FcRuleEdge,
FcRuleNode,
FcRuleNodeType,
FcRuleNote,
getRuleNodeHelpLink,
LinkLabel,
outputNodeClazz,
@ -81,14 +83,15 @@ import { RuleChainService } from '@core/http/rule-chain.service';
import { NEVER, Observable, of, ReplaySubject, skip, startWith, Subject, throwError } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, mergeMap, takeUntil, tap } from 'rxjs/operators';
import { ISearchableComponent } from '../../models/searchable-component.models';
import { deepClone, isDefinedAndNotNull } from '@core/utils';
import { deepClone, guid, isDefinedAndNotNull } from '@core/utils';
import { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component';
import { RuleNodeLinkComponent } from './rule-node-link.component';
import { RuleNoteEditorComponent } from './rule-note-editor.component';
import { DialogComponent } from '@shared/components/dialog.component';
import { MatMenuTrigger } from '@angular/material/menu';
import { ItemBufferService, RuleNodeConnection } from '@core/services/item-buffer.service';
import { Hotkey } from 'angular2-hotkeys';
import { DebugEventType, DebugRuleNodeEventBody, EventType } from '@shared/models/event.models';
import { DebugEventType, DebugRuleNodeEventBody } from '@shared/models/event.models';
import { MatMiniFabButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { VersionControlComponent } from '@home/components/vc/version-control.component';
@ -97,10 +100,10 @@ import { MatDrawer } from '@angular/material/sidenav';
import { HttpStatusCode } from '@angular/common/http';
import { TbContextMenuEvent } from '@shared/models/jquery-event.models';
import { EntityDebugSettings } from '@shared/models/entity.models';
import Timeout = NodeJS.Timeout;
import { DomSanitizer } from '@angular/platform-browser';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { EventsDialogComponent } from '@home/dialogs/events-dialog.component';
import Timeout = NodeJS.Timeout;
@Component({
selector: 'tb-rulechain-page',
@ -132,8 +135,6 @@ export class RuleChainPageComponent extends PageComponent
@ViewChild('drawer') drawer: MatDrawer;
eventTypes = EventType;
debugEventTypes = DebugEventType;
ruleChainMenuPosition = { x: '0px', y: '0px' };
@ -163,11 +164,16 @@ export class RuleChainPageComponent extends PageComponent
@ViewChild('tbRuleNode') ruleNodeComponent: RuleNodeDetailsComponent;
@ViewChild('tbRuleNodeLink') ruleNodeLinkComponent: RuleNodeLinkComponent;
@ViewChild('tbRuleNote') ruleNoteComponent: RuleNoteEditorComponent;
editingRuleNodeLink: FcRuleEdge = null;
isEditingRuleNodeLink = false;
editingRuleNodeLinkIndex = -1;
editingNote: FcRuleNote = null;
isEditingNote = false;
editingNoteIndex = -1;
hotKeys: Hotkey[] = [];
enableHotKeys = true;
@ -180,22 +186,23 @@ export class RuleChainPageComponent extends PageComponent
ruleChainModel: FcRuleNodeModel = {
nodes: [],
edges: []
edges: [],
notes: []
};
selectedObjects = [];
editCallbacks: UserCallbacks = {
edgeDoubleClick: (event, edge) => {
edgeDoubleClick: (_event, edge) => {
this.openLinkDetails(edge);
},
edgeEdit: (event, edge) => {
edgeEdit: (_event, edge) => {
this.openLinkDetails(edge);
},
nodeCallbacks: {
doubleClick: (event, node: FcRuleNode) => {
doubleClick: (_event, node: FcRuleNode) => {
this.openNodeDetails(node);
},
nodeEdit: (event, node: FcRuleNode) => {
nodeEdit: (_event, node: FcRuleNode) => {
this.openNodeDetails(node);
},
mouseEnter: this.displayNodeDescriptionTooltip.bind(this),
@ -204,7 +211,7 @@ export class RuleChainPageComponent extends PageComponent
},
isValidEdge: (source, destination) =>
source.type === FlowchartConstants.rightConnectorType && destination.type === FlowchartConstants.leftConnectorType,
createEdge: (event, edge: FcRuleEdge) => {
createEdge: (_event, edge: FcRuleEdge) => {
const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source) as FcRuleNode;
if (sourceNode.component.type === RuleNodeType.INPUT) {
const found = this.ruleChainModel.edges.find(theEdge => theEdge.source === (this.inputConnectorId + ''));
@ -238,8 +245,19 @@ export class RuleChainPageComponent extends PageComponent
}
}
},
dropNode: (event, node: FcRuleNode) => {
dropNode: (_event, node: FcRuleNode) => {
this.addRuleNode(node);
},
noteCallbacks: {
noteEdit: (_event, note) => {
this.openNoteDetails(note);
},
doubleClick: (_event, note) => {
this.openNoteDetails(note);
}
},
noteRemoved: (_note) => {
this.isDirty = true;
}
};
@ -267,11 +285,9 @@ export class RuleChainPageComponent extends PageComponent
private tooltipTimeout: Timeout;
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
constructor(private route: ActivatedRoute,
private router: Router,
private ruleChainService: RuleChainService,
private authService: AuthService,
private translate: TranslateService,
private itembuffer: ItemBufferService,
private popoverService: TbPopoverService,
@ -282,7 +298,7 @@ export class RuleChainPageComponent extends PageComponent
public dialog: MatDialog,
public dialogService: DialogService,
public fb: FormBuilder) {
super(store);
super();
this.route.data.pipe(
takeUntil(this.destroy$)
).subscribe(
@ -363,12 +379,15 @@ export class RuleChainPageComponent extends PageComponent
this.selectedObjects = [];
this.ruleChainModel.nodes = [];
this.ruleChainModel.edges = [];
this.ruleChainModel.notes = [];
this.ruleNodeTypesModel = {};
if (this.ruleChainCanvas) {
this.ruleChainCanvas.adjustCanvasSize(true);
}
this.isEditingRuleNode = false;
this.isEditingRuleNodeLink = false;
this.isEditingNote = false;
this.editingNote = null;
this.updateRuleNodesHighlight();
}
@ -389,7 +408,7 @@ export class RuleChainPageComponent extends PageComponent
new Hotkey(['ctrl+c', 'meta+c'], (event: KeyboardEvent) => {
if (this.enableHotKeys) {
event.preventDefault();
this.copyRuleNodes();
this.copyRuleChainObjects();
return false;
}
return true;
@ -400,8 +419,8 @@ export class RuleChainPageComponent extends PageComponent
new Hotkey(['ctrl+v', 'meta+v'], (event: KeyboardEvent) => {
if (this.enableHotKeys) {
event.preventDefault();
if (this.itembuffer.hasRuleNodes()) {
this.pasteRuleNodes();
if (this.itembuffer.hasRuleChainObjects()) {
this.pasteRuleChainObjects();
}
return false;
}
@ -465,6 +484,17 @@ export class RuleChainPageComponent extends PageComponent
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('rulenode.create-nested-rulechain'))
);
this.hotKeys.push(
new Hotkey('alt+n', (event: KeyboardEvent) => {
if (this.enableHotKeys) {
event.preventDefault();
this.addNote();
return false;
}
return true;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('rulechain.add-note'))
);
}
}
@ -523,7 +553,7 @@ export class RuleChainPageComponent extends PageComponent
});
if (this.expansionPanels) {
for (let i = 0; i < ruleNodeTypesLibrary.length; i++) {
const panel = this.expansionPanels.find((item, index) => index === i);
const panel = this.expansionPanels.find((_item, index) => index === i);
if (panel) {
const type = ruleNodeTypesLibrary[i];
if (!this.ruleNodeTypesModel[type].model.nodes.length) {
@ -657,6 +687,7 @@ export class RuleChainPageComponent extends PageComponent
}
});
}
this.ruleChainModel.notes = deepClone(this.ruleChainMetaData.notes) || [];
if (this.ruleChainCanvas) {
this.ruleChainCanvas.adjustCanvasSize(true);
}
@ -688,10 +719,12 @@ export class RuleChainPageComponent extends PageComponent
}
private prepareContextMenu(item: FcItemInfo): RuleChainMenuContextInfo {
if (this.objectsSelected() || (!item.node && !item.edge)) {
if (this.objectsSelected() || (!item.node && !item.edge && !item.note)) {
return this.prepareRuleChainContextMenu();
} else if (item.node) {
return this.prepareRuleNodeContextMenu(item.node);
} else if (item.note) {
return this.prepareNoteContextMenu(item.note);
} else if (item.edge) {
return this.prepareEdgeContextMenu(item.edge);
}
@ -705,11 +738,12 @@ export class RuleChainPageComponent extends PageComponent
subtitle: this.translate.instant('rulechain.rulechain'),
menuItems: []
};
if (this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length) {
if (this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length ||
this.ruleChainCanvas.modelService.notes.getSelectedNotes().length) {
contextInfo.menuItems.push(
{
action: () => {
this.copyRuleNodes();
this.copyRuleChainObjects();
},
enabled: true,
value: 'rulenode.copy-selected',
@ -721,14 +755,25 @@ export class RuleChainPageComponent extends PageComponent
contextInfo.menuItems.push(
{
action: ($event) => {
this.pasteRuleNodes($event);
this.pasteRuleChainObjects($event);
},
enabled: this.itembuffer.hasRuleNodes(),
enabled: this.itembuffer.hasRuleChainObjects(),
value: 'action.paste',
icon: 'content_paste',
shortcut: 'M-V'
}
);
contextInfo.menuItems.push(
{
action: ($event) => {
this.addNote($event);
},
enabled: true,
value: 'rulechain.add-note',
icon: 'sticky_note_2',
shortcut: 'A-N'
}
);
contextInfo.menuItems.push(
{
divider: true
@ -907,7 +952,7 @@ export class RuleChainPageComponent extends PageComponent
toIndexSet.add(toIndex);
}
});
const noInputNodes = selectedNodes.filter((node, index) => !toIndexSet.has(index));
const noInputNodes = selectedNodes.filter((_node, index) => !toIndexSet.has(index));
return noInputNodes.filter((node: FcRuleNode) => node.component.configurationDescriptor.nodeDefinition.inEnabled).length <= 1;
}
return false;
@ -925,11 +970,13 @@ export class RuleChainPageComponent extends PageComponent
}
}).afterClosed().subscribe((ruleChain) => {
if (ruleChain) {
const selectedNotes: FcRuleNote[] = this.ruleChainCanvas.modelService.notes.getSelectedNotes();
this.ruleChainCanvas.modelService.deselectAll();
const ruleChainMetaData: RuleChainMetaData = {
ruleChainId: ruleChain.id,
nodes: [],
connections: []
connections: [],
notes: deepClone(selectedNotes)
};
let outputEdges: FcRuleEdge[] = [];
let minX: number = null;
@ -993,7 +1040,7 @@ export class RuleChainPageComponent extends PageComponent
}
}
});
const noInputNodes = selectedNodes.filter((node, index) => !toIndexSet.has(index));
const noInputNodes = selectedNodes.filter((_node, index) => !toIndexSet.has(index));
const possibleInputNodes = noInputNodes.filter((node: FcRuleNode) =>
node.component.configurationDescriptor.nodeDefinition.inEnabled);
let inputEdges: FcRuleEdge[] = [];
@ -1039,6 +1086,10 @@ export class RuleChainPageComponent extends PageComponent
node.additionalInfo.layoutX -= deltaX;
node.additionalInfo.layoutY -= deltaY;
});
ruleChainMetaData.notes?.forEach((note) => {
note.x -= deltaX;
note.y -= deltaY;
});
this.ruleChainService.saveRuleChainMetadata(ruleChainMetaData).subscribe(() => {
const component = this.ruleChainService.getRuleNodeComponentByClazz(this.ruleChainType, ruleChainNodeClazz);
const descriptor = ruleNodeTypeDescriptors.get(component.type);
@ -1090,6 +1141,9 @@ export class RuleChainPageComponent extends PageComponent
selectedNodes.forEach((node) => {
this.ruleChainCanvas.modelService.nodes.delete(node);
});
selectedNotes.forEach((note) => {
this.ruleChainCanvas.modelService.notes.delete(note);
});
this.onModelChanged();
this.updateRuleNodesHighlight();
});
@ -1146,12 +1200,15 @@ export class RuleChainPageComponent extends PageComponent
}
private copyNode(node: FcRuleNode) {
this.itembuffer.copyRuleNodes([node], []);
this.itembuffer.copyRuleChainObjects([node], [], []);
}
private copyRuleNodes() {
private copyRuleChainObjects() {
const nodes: FcRuleNode[] = this.ruleChainCanvas.modelService.nodes.getSelectedNodes();
const edges: FcRuleEdge[] = this.ruleChainCanvas.modelService.edges.getSelectedEdges();
const notes: FcRuleNote[] = (this.ruleChainModel.notes || []).filter(
note => this.ruleChainCanvas.modelService.notes.isSelected(note)
);
const connections: RuleNodeConnection[] = [];
edges.forEach((edge) => {
const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source);
@ -1170,10 +1227,10 @@ export class RuleChainPageComponent extends PageComponent
connections.push(connection);
}
});
this.itembuffer.copyRuleNodes(nodes, connections);
this.itembuffer.copyRuleChainObjects(nodes, connections, notes);
}
private pasteRuleNodes(event?: MouseEvent) {
private pasteRuleChainObjects(event?: MouseEvent) {
const canvas = $(this.ruleChainCanvas.modelService.canvasHtmlElement);
let x: number;
let y: number;
@ -1188,11 +1245,11 @@ export class RuleChainPageComponent extends PageComponent
x = scrollLeft + scrollParent.width() / 2;
y = scrollTop + scrollParent.height() / 2;
}
const ruleNodes = this.itembuffer.pasteRuleNodes(x, y);
if (ruleNodes) {
const ruleChainObjects = this.itembuffer.pasteRuleChainObjects(x, y);
if (ruleChainObjects) {
this.ruleChainCanvas.modelService.deselectAll();
const nodes: FcRuleNode[] = [];
ruleNodes.nodes.forEach((node) => {
ruleChainObjects.nodes.forEach((node) => {
node.id = 'rule-chain-node-' + this.nextNodeID++;
const component = node.component;
if (component.configurationDescriptor.nodeDefinition.inEnabled) {
@ -1215,7 +1272,7 @@ export class RuleChainPageComponent extends PageComponent
this.ruleChainModel.nodes.push(node);
this.ruleChainCanvas.modelService.nodes.select(node);
});
ruleNodes.connections.forEach((connection) => {
ruleChainObjects.connections.forEach((connection) => {
const sourceNode = nodes[connection.fromIndex];
const destNode = nodes[connection.toIndex];
if ( (connection.isInputSource || sourceNode) && destNode ) {
@ -1251,15 +1308,142 @@ export class RuleChainPageComponent extends PageComponent
}
}
});
if (ruleChainObjects.notes?.length) {
if (!this.ruleChainModel.notes) {
this.ruleChainModel.notes = [];
}
ruleChainObjects.notes.forEach((note) => {
note.id = guid();
this.ruleChainModel.notes.push(note);
this.ruleChainCanvas.modelService.notes.select(note);
});
}
this.updateRuleNodesHighlight();
this.validate();
this.onModelChanged();
}
}
addNote(event?: MouseEvent): void {
this.dialog.open<AddNoteDialogComponent, FcRuleNote, Partial<FcRuleNote>>(AddNoteDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
id: '', x: 0, y: 0, width: 200, height: 120,
content: '',
backgroundColor: FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR,
applyDefaultMarkdownStyle: FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE,
markdownCss: ''
}
}).afterClosed().subscribe((noteData) => {
if (noteData !== undefined) {
const canvas = $(this.ruleChainCanvas.modelService.canvasHtmlElement);
let x: number;
let y: number;
if (event) {
const offset = canvas.offset();
x = Math.round(event.clientX - offset.left);
y = Math.round(event.clientY - offset.top);
} else {
const scrollParent = canvas.parent();
x = scrollParent.scrollLeft() + scrollParent.width() / 2;
y = scrollParent.scrollTop() + scrollParent.height() / 2;
}
const note: FcRuleNote = {
id: guid(),
x,
y,
width: 200,
height: 120,
...noteData
};
if (!this.ruleChainModel.notes) {
this.ruleChainModel.notes = [];
}
this.ruleChainModel.notes.push(note);
this.isDirty = true;
}
});
}
private prepareNoteContextMenu(note: FcRuleNote): RuleChainMenuContextInfo {
const contextInfo: RuleChainMenuContextInfo = {
headerClass: 'tb-rulechain-header',
icon: 'sticky_note_2',
title: this.translate.instant('rulechain.note'),
menuItems: []
};
if (!note.readonly) {
contextInfo.menuItems.push(
{
action: () => {
this.openNoteDetails(note);
},
enabled: true,
value: 'action.edit',
icon: 'edit'
}
);
contextInfo.menuItems.push(
{
action: () => {
this.itembuffer.copyRuleChainObjects([], [], [note]);
},
enabled: true,
value: 'action.copy',
icon: 'content_copy'
}
);
contextInfo.menuItems.push(
{
action: () => {
this.ruleChainCanvas.modelService.notes.delete(note);
},
enabled: true,
value: 'action.delete',
icon: 'clear',
shortcut: 'Del'
}
);
}
return contextInfo;
}
private openNoteDetails(note: FcRuleNote): void {
this.enableHotKeys = false;
this.updateErrorTooltips(true);
this.isEditingRuleNode = false;
this.editingRuleNode = null;
this.isEditingRuleNodeLink = false;
this.editingRuleNodeLink = null;
this.isEditingNote = true;
this.editingNoteIndex = this.ruleChainModel.notes.indexOf(note);
this.editingNote = deepClone(note);
}
saveNote(): void {
this.ruleNoteComponent.noteForm.markAsPristine();
Object.assign(this.editingNote, this.ruleNoteComponent.noteForm.value);
this.ruleChainModel.notes[this.editingNoteIndex] = this.editingNote;
this.editingNote = deepClone(this.editingNote);
this.onModelChanged();
}
onRevertNoteEdit(): void {
this.ruleNoteComponent.noteForm.markAsPristine();
const note = this.ruleChainModel.notes[this.editingNoteIndex];
this.editingNote = deepClone(note);
}
onEditNoteClosed(): void {
this.editingNote = null;
this.isEditingNote = false;
}
onDetailsDrawerClosed() {
this.onEditRuleNodeClosed();
this.onEditRuleNodeLinkClosed();
this.onEditNoteClosed();
this.enableHotKeys = true;
this.updateErrorTooltips(false);
}
@ -1419,7 +1603,8 @@ export class RuleChainPageComponent extends PageComponent
objectsSelected(): boolean {
return this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length > 0 ||
this.ruleChainCanvas.modelService.edges.getSelectedEdges().length > 0;
this.ruleChainCanvas.modelService.edges.getSelectedEdges().length > 0 ||
this.ruleChainCanvas.modelService.notes.getSelectedNotes().length > 0;
}
deleteSelected() {
@ -1477,6 +1662,7 @@ export class RuleChainPageComponent extends PageComponent
ruleChainId: this.ruleChain.id,
nodes: [],
connections: [],
notes: deepClone(this.ruleChainModel.notes) || [],
version: ruleChain.version
};
const nodes: FcRuleNode[] = [];
@ -1535,6 +1721,9 @@ export class RuleChainPageComponent extends PageComponent
.subscribe((savedRuleChainMetaData) => {
this.ruleChain.version = savedRuleChainMetaData.version;
this.ruleChainMetaData = savedRuleChainMetaData;
if (!this.ruleChainMetaData.notes) {
this.ruleChainMetaData.notes = deepClone(this.ruleChainModel.notes) || [];
}
if (this.isImport) {
this.isDirtyValue = false;
this.isImport = false;
@ -1796,7 +1985,7 @@ export interface AddRuleNodeLinkDialogData {
standalone: false
})
export class AddRuleNodeLinkDialogComponent extends DialogComponent<AddRuleNodeLinkDialogComponent, FcRuleEdge>
implements OnInit, ErrorStateMatcher {
implements ErrorStateMatcher {
ruleNodeLinkFormGroup: UntypedFormGroup;
@ -1826,9 +2015,6 @@ export class AddRuleNodeLinkDialogComponent extends DialogComponent<AddRuleNodeL
);
}
ngOnInit(): void {
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
@ -1861,7 +2047,7 @@ export interface AddRuleNodeDialogData {
standalone: false
})
export class AddRuleNodeDialogComponent extends DialogComponent<AddRuleNodeDialogComponent, FcRuleNode>
implements OnInit, ErrorStateMatcher {
implements ErrorStateMatcher {
@ViewChild('tbRuleNode', {static: true}) ruleNodeDetailsComponent: RuleNodeDetailsComponent;
@ -1883,9 +2069,6 @@ export class AddRuleNodeDialogComponent extends DialogComponent<AddRuleNodeDialo
this.ruleChainType = this.data.ruleChainType;
}
ngOnInit(): void {
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
@ -1980,3 +2163,30 @@ export class CreateNestedRuleChainDialogComponent extends DialogComponent<Create
}
}
@Component({
selector: 'tb-add-note-dialog',
templateUrl: './add-note-dialog.component.html',
styleUrls: ['./add-note-dialog.component.scss'],
standalone: false
})
export class AddNoteDialogComponent extends DialogComponent<AddNoteDialogComponent, Partial<FcRuleNote>> {
@ViewChild('tbRuleNote') ruleNoteComponent: RuleNoteEditorComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: FcRuleNote,
public dialogRef: MatDialogRef<AddNoteDialogComponent, Partial<FcRuleNote>>) {
super(store, router, dialogRef);
}
cancel(): void {
this.dialogRef.close(undefined);
}
save(): void {
if (this.ruleNoteComponent.noteForm.valid) {
this.dialogRef.close(this.ruleNoteComponent.noteForm.value);
}
}
}

2
ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts

@ -40,6 +40,6 @@ export interface RuleChainMenuContextInfo {
icon: string;
iconUrl?: string;
title: string;
subtitle: string;
subtitle?: string;
menuItems: RuleChainMenuItem[];
}

6
ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.module.ts

@ -27,23 +27,27 @@ import {
AddRuleNodeDialogComponent,
AddRuleNodeLinkDialogComponent,
CreateNestedRuleChainDialogComponent,
AddNoteDialogComponent,
RuleChainPageComponent
} from '@home/pages/rulechain/rulechain-page.component';
import { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component';
import { RuleNodeConfigComponent } from '@home/pages/rulechain/rule-node-config.component';
import { LinkLabelsComponent } from '@home/pages/rulechain/link-labels.component';
import { RuleNodeLinkComponent } from '@home/pages/rulechain/rule-node-link.component';
import { RuleNoteEditorComponent } from '@home/pages/rulechain/rule-note-editor.component';
@NgModule({
declarations: [
RuleChainPageComponent,
RuleNodeDetailsComponent,
RuleNoteEditorComponent,
LinkLabelsComponent,
RuleNodeLinkComponent,
RuleNodeConfigComponent,
AddRuleNodeLinkDialogComponent,
AddRuleNodeDialogComponent,
CreateNestedRuleChainDialogComponent
CreateNestedRuleChainDialogComponent,
AddNoteDialogComponent
],
imports: [
CommonModule,

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

@ -22,13 +22,15 @@ import { RuleChainRoutingModule } from '@modules/home/pages/rulechain/rulechain-
import { HomeComponentsModule } from '@modules/home/components/home-components.module';
import { RuleChainTabsComponent } from '@home/pages/rulechain/rulechain-tabs.component';
import { RuleNodeComponent } from '@home/pages/rulechain/rulenode.component';
import { FC_NODE_COMPONENT_CONFIG } from 'ngx-flowchart';
import { RuleNoteComponent } from '@home/pages/rulechain/rulenote.component';
import { FC_NODE_COMPONENT_CONFIG, FC_NOTE_COMPONENT_CONFIG } from 'ngx-flowchart';
@NgModule({
declarations: [
RuleChainComponent,
RuleChainTabsComponent,
RuleNodeComponent,
RuleNoteComponent,
],
providers: [
{
@ -37,6 +39,12 @@ import { FC_NODE_COMPONENT_CONFIG } from 'ngx-flowchart';
nodeComponentType: RuleNodeComponent
}
},
{
provide: FC_NOTE_COMPONENT_CONFIG,
useValue: {
noteComponentType: RuleNoteComponent
}
},
],
imports: [
CommonModule,

44
ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.html

@ -0,0 +1,44 @@
<!--
Copyright © 2016-2026 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.
-->
@if (modelservice.isEditable() && !note.readonly && edit) {
<div class="fc-noselect fc-nodeedit"
(mousedown)="$event.stopPropagation()"
(click)="noteEdit($event)">
<i class="fa fa-pencil" aria-hidden="true"></i>
</div>
<div class="fc-noselect fc-nodedelete"
(mousedown)="$event.stopPropagation()"
(click)="noteDelete($event)">
&times;
</div>
}
<div class="tb-rule-note-bg"
[style.background-color]="note.backgroundColor || defaultBackgroundColor"
[style.border-color]="note.borderColor"
[style.border-width]="note.borderWidth !== null ? note.borderWidth + 'px' : null"
(dblclick)="userNoteCallbacks?.doubleClick?.($event, note)">
@if (note?.content) {
<tb-markdown [data]="note.content"
[applyDefaultMarkdownStyle]="applyDefault"
[additionalStyles]="additionalStyles"
[containerClass]="noteClass"
fallbackToPlainMarkdown
usePlainMarkdown>
</tb-markdown>
}
</div>

65
ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.scss

@ -0,0 +1,65 @@
/**
* Copyright © 2016-2026 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 {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
.fc-nodeedit,
.fc-nodedelete {
display: block;
position: absolute;
border: solid 2px #eee;
border-radius: 50%;
font-weight: 600;
line-height: 20px;
height: 20px;
padding-top: 2px;
width: 22px;
background: #494949;
color: #fff;
text-align: center;
vertical-align: bottom;
cursor: pointer;
z-index: 10;
}
.fc-nodeedit {
top: -24px;
right: 16px;
font-size: 15px;
}
.fc-nodedelete {
top: -24px;
right: -13px;
font-size: 18px;
}
.tb-rule-note-bg {
position: relative;
width: 100%;
height: 100%;
background-color: #FFF9C4;
border: 1px solid #E6D600;
border-radius: 8px;
box-sizing: border-box;
padding: 8px;
overflow: auto;
}
}

88
ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.ts

@ -0,0 +1,88 @@
///
/// Copyright © 2016-2026 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, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { FcNoteComponent } from 'ngx-flowchart';
import cssjs from '@core/css/css';
import { hashCode, isNotEmptyStr } from '@core/utils';
import {
FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE,
FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR,
FcRuleNote
} from '@shared/models/rule-node.models';
@Component({
selector: 'tb-rule-note',
templateUrl: './rulenote.component.html',
styleUrls: ['./rulenote.component.scss'],
standalone: false
})
export class RuleNoteComponent extends FcNoteComponent implements OnInit, OnChanges {
readonly defaultBackgroundColor = FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR;
private static readonly HEADING_STYLE_OVERRIDE = '.tb-markdown-view h1 { font-size: 3rem; }';
private static readonly PADDING_STYLE_OVERRIDE = '.tb-markdown-view p, .tb-markdown-view h1, .tb-markdown-view h2, .tb-markdown-view h3, .tb-markdown-view h4, .tb-markdown-view h5, .tb-markdown-view h6 {padding-left: 0 !important;}';
applyDefault = FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE;
additionalStyles: string[];
noteClass: string;
note: FcRuleNote;
ngOnInit(): void {
super.ngOnInit();
this.applyDefault = this.note?.applyDefaultMarkdownStyle ?? FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE;
this.processCss();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.note) {
this.applyDefault = this.note?.applyDefaultMarkdownStyle ?? FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE;
this.processCss();
}
}
private processCss(): void {
let cssString = this.note?.markdownCss;
const styles: string[] = [];
if (this.applyDefault) {
styles.push(RuleNoteComponent.HEADING_STYLE_OVERRIDE);
styles.push(RuleNoteComponent.PADDING_STYLE_OVERRIDE);
}
if (isNotEmptyStr(cssString)) {
const cssParser = new cssjs();
this.noteClass = 'rule-note-' + hashCode(cssString);
cssParser.cssPreviewNamespace = this.noteClass;
cssParser.testMode = false;
const cssObjects = cssParser.applyNamespacing(cssString);
cssString = cssParser.getCSSForEditor(cssObjects);
styles.push(cssString);
} else {
this.noteClass = undefined;
}
this.additionalStyles = styles.length ? styles : undefined;
}
noteEdit(event: MouseEvent): void {
event.stopPropagation();
this.userNoteCallbacks?.noteEdit?.(event, this.note);
}
noteDelete(event: MouseEvent): void {
event.stopPropagation();
this.modelservice.notes.delete(this.note);
}
}

2
ui-ngx/src/app/shared/components/markdown-editor.component.html

@ -26,7 +26,7 @@
<button [class.!hidden]="!editorMode"
type="button"
mat-button (click)="toggleEditMode()">{{ 'markdown.preview' | translate }}</button>
<div *ngIf = "helpId" [tb-help-popup]="helpId"></div>
<div *ngIf = "helpId" [tb-help-popup]="helpId" [tb-help-popup-style]="helpPopupStyle"></div>
<fieldset style="width: initial">
<div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above"

2
ui-ngx/src/app/shared/components/markdown-editor.component.ts

@ -55,6 +55,8 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
@Input() helpId: string;
@Input() helpPopupStyle: { [klass: string]: any } = {};
@Input()
@coerceBoolean()
required: boolean;

3
ui-ngx/src/app/shared/models/rule-chain.models.ts

@ -18,7 +18,7 @@ import { BaseData, ExportableEntity } from '@shared/models/base-data';
import { TenantId } from '@shared/models/id/tenant-id';
import { RuleChainId } from '@shared/models/id/rule-chain-id';
import { RuleNodeId } from '@shared/models/id/rule-node-id';
import { RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models';
import { FcRuleNote, RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models';
import { ComponentClusteringMode, ComponentType } from '@shared/models/component-descriptor.models';
import { HasTenantId, HasVersion } from '@shared/models/entity.models';
@ -39,6 +39,7 @@ export interface RuleChainMetaData extends HasVersion {
firstNodeIndex?: number;
nodes: Array<RuleNode>;
connections: Array<NodeConnectionInfo>;
notes?: Array<FcRuleNote>;
}
export interface RuleChainImport {

16
ui-ngx/src/app/shared/models/rule-node.models.ts

@ -18,7 +18,7 @@ import { BaseData } from '@shared/models/base-data';
import { RuleChainId } from '@shared/models/id/rule-chain-id';
import { RuleNodeId } from '@shared/models/id/rule-node-id';
import { ComponentDescriptor } from '@shared/models/component-descriptor.models';
import { FcEdge, FcNode } from 'ngx-flowchart';
import { FcEdge, FcNode, FcNote } from 'ngx-flowchart';
import { Observable } from 'rxjs';
import { PageComponent } from '@shared/components/page.component';
import { AfterViewInit, DestroyRef, Directive, EventEmitter, inject, OnInit } from '@angular/core';
@ -27,6 +27,7 @@ import { RuleChainType } from '@shared/models/rule-chain.models';
import { DebugRuleNodeEventBody } from '@shared/models/event.models';
import { EntityTestScriptResult, HasEntityDebugSettings } from '@shared/models/entity.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { strings } from '@angular-devkit/core';
export interface RuleNodeConfiguration {
[key: string]: any;
@ -358,6 +359,19 @@ export interface FcRuleEdge extends FcEdge {
labels?: string[];
}
export const FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR = '#FFF9C4';
export const FC_RULE_NOTE_DEFAULT_BORDER_WIDTH = 1;
export const FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE = true;
export interface FcRuleNote extends FcNote {
content?: string;
backgroundColor?: string;
borderColor?: string;
borderWidth?: number;
applyDefaultMarkdownStyle?: boolean;
markdownCss?: string;
}
export enum ScriptLanguage {
JS = 'JS',
TBEL = 'TBEL'

195
ui-ngx/src/assets/help/en_US/rulechain/note_content.md

@ -0,0 +1,195 @@
#### Markdown/HTML note content
<div class="divider"></div>
<br/>
Notes support **Markdown** and **HTML** markup for rich text formatting directly on the rule chain canvas.
<div class="divider"></div>
<br/>
#### Headings
Use `#` to create headings. The number of `#` characters defines the level:
```markdown
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
{:copy-code}
```
<div class="divider"></div>
<br/>
#### Text formatting
```markdown
**Bold text**
*Italic text*
~~Strikethrough~~
**_Bold and italic_**
{:copy-code}
```
**Bold text**
*Italic text*
~~Strikethrough~~
**_Bold and italic_**
<div class="divider"></div>
<br/>
#### Lists
Unordered list using `-` or `*`:
```markdown
- Step 1: Receive telemetry
- Step 2: Filter by threshold
- Value > 100 → alarm branch
- Value ≤ 100 → success branch
- Step 3: Save to time series
{:copy-code}
```
- Step 1: Receive telemetry
- Step 2: Filter by threshold
- Value > 100 → alarm branch
- Value ≤ 100 → success branch
- Step 3: Save to time series
Ordered list using numbers:
```markdown
1. Validate message type
2. Enrich with device attributes
3. Route to appropriate handler
{:copy-code}
```
1. Validate message type
2. Enrich with device attributes
3. Route to appropriate handler
<div class="divider"></div>
<br/>
#### Blockquotes
Use `>` for notes, warnings, or highlighted information:
```markdown
> **Note:** This node requires a valid device alias to be configured upstream.
> **Warning:** Debug mode increases load — disable in production.
{:copy-code}
```
> **Note:** This node requires a valid device alias to be configured upstream.
> **Warning:** Debug mode increases load — disable in production.
<div class="divider"></div>
<br/>
#### Inline code and code blocks
Use backticks for inline code and triple backticks for blocks:
```markdown
The message type must be `POST_TELEMETRY_REQUEST`.
{:copy-code}
```
The message type must be `POST_TELEMETRY_REQUEST`.
````markdown
```json
{
"temperature": 42.5,
"humidity": 68
}
```
{:copy-code}
````
```json
{
"temperature": 42.5,
"humidity": 68
}
```
<div class="divider"></div>
<br/>
#### Tables
```markdown
| Branch | Condition | Next node |
|----------|------------------|--------------------|
| True | temperature > 80 | Create Alarm |
| False | temperature ≤ 80 | Save Timeseries |
{:copy-code}
```
| Branch | Condition | Next node |
|----------|------------------|--------------------|
| True | temperature > 80 | Create Alarm |
| False | temperature ≤ 80 | Save Timeseries |
<div class="divider"></div>
<br/>
#### Horizontal rule
Use `---` to visually separate sections:
```markdown
### Input
Device telemetry message.
---
### Output
Enriched message with customer attributes.
{:copy-code}
```
<div class="divider"></div>
<br/>
#### HTML elements
When **Apply default markdown style** is enabled, you can also use HTML tags:
```html
<span style="color: #e53935;">🔴 Critical path</span><br/>
<span style="color: #43a047;">🟢 Happy path</span>
{:copy-code}
```
<div class="divider"></div>
<br/>
#### Tips for rule chain notes
Use notes to annotate the canvas with context that helps your team:
```markdown
## 🌡️ Temperature monitoring flow
Processes telemetry from HVAC sensors.
| Threshold | Severity |
|-----------|----------|
| > 85 °C | CRITICAL |
| > 70 °C | WARNING |
> **Owner:** Platform team · **Updated:** 2026-02
{:copy-code}
```

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

@ -5165,7 +5165,17 @@
"unset-auto-assign-to-edge-title": "Are you sure you do not want to assign the edge rule chain '{{ruleChainName}}' to edge(s) on creation?",
"unset-auto-assign-to-edge-text": "After the confirmation the edge rule chain will no longer be automatically assigned to edge(s) on creation.",
"unassign-rulechain-title": "Are you sure you want to unassign the rulechain '{{ruleChainName}}'?",
"unassign-rulechains": "Unassign rulechains"
"unassign-rulechains": "Unassign rulechains",
"add-note": "Add note",
"paste-note": "Paste note",
"note": "Note",
"edit-note": "Edit note",
"note-content": "Markdown/HTML content",
"note-details": "Note details",
"note-background-color": "Background color",
"note-border": "Border",
"note-apply-default-markdown-style": "Apply default markdown style",
"note-custom-css": "Note content CSS"
},
"rulenode": {
"rule-node-events": "Rule node events",

6
ui-ngx/yarn.lock

@ -7989,9 +7989,9 @@ ngx-drag-drop@^20.0.1:
dependencies:
tslib "^2.3.0"
"ngx-flowchart@https://github.com/thingsboard/ngx-flowchart.git#release/4.0.0":
version "4.0.0"
resolved "https://github.com/thingsboard/ngx-flowchart.git#735bc818ef218a169ac50e87edc6a093b3e97715"
"ngx-flowchart@https://github.com/thingsboard/ngx-flowchart.git#release/4.1.0":
version "4.1.0"
resolved "https://github.com/thingsboard/ngx-flowchart.git#a3dee1761ecaa96c7d0ca22b1c6538b9e27103af"
dependencies:
tslib "^2.3.0"

Loading…
Cancel
Save