Browse Source

Merge pull request #15121 from vvlladd28/improvement/rule-chain/note

Rule chain notes
pull/15309/head
Viacheslav Klimov 2 months ago
committed by GitHub
parent
commit
4a03ac490a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 196
      application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
  3. 47
      common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainDetails.java
  4. 5
      common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java
  5. 67
      common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainNote.java
  6. 8
      common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java
  7. 1
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  8. 127
      dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractRuleChainEntity.java
  9. 63
      dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDetailsEntity.java
  10. 78
      dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java
  11. 23
      dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
  12. 29
      dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java
  13. 65
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java
  14. 25
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainDetailsRepository.java
  15. 1
      dao/src/main/resources/sql/schema-entities.sql
  16. 2
      ui-ngx/package.json
  17. 58
      ui-ngx/src/app/core/services/item-buffer.service.ts
  18. 36
      ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.html
  19. 30
      ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss
  20. 59
      ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html
  21. 24
      ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss
  22. 106
      ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts
  23. 51
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html
  24. 23
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss
  25. 302
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts
  26. 2
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts
  27. 6
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.module.ts
  28. 10
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts
  29. 44
      ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.html
  30. 65
      ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.scss
  31. 88
      ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.ts
  32. 2
      ui-ngx/src/app/shared/components/markdown-editor.component.html
  33. 2
      ui-ngx/src/app/shared/components/markdown-editor.component.ts
  34. 3
      ui-ngx/src/app/shared/models/rule-chain.models.ts
  35. 16
      ui-ngx/src/app/shared/models/rule-node.models.ts
  36. 195
      ui-ngx/src/assets/help/en_US/rulechain/note_content.md
  37. 12
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  38. 6
      ui-ngx/yarn.lock

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

@ -19,3 +19,9 @@
ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar;
-- CALCULATED FIELD ADDITIONAL INFO ADDITION END
-- RULE CHAIN NOTES MIGRATION START
ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000);
-- RULE CHAIN NOTES MIGRATION END

196
application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java

@ -44,6 +44,7 @@ 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.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleChainNote;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.security.Authority;
@ -442,6 +443,201 @@ public class RuleChainControllerTest extends AbstractControllerTest {
assertThat(ruleChain.getVersion()).isEqualTo(5);
}
@Test
public void testSaveAndLoadRuleChainMetaDataWithNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain with notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
List<RuleChainNote> notes = new ArrayList<>();
RuleChainNote note1 = new RuleChainNote();
note1.setId("note-1");
note1.setX(100);
note1.setY(200);
note1.setWidth(300);
note1.setHeight(150);
note1.setContent("# Test Note\nSome markdown content");
note1.setBackgroundColor("#FFF9C4");
note1.setBorderColor("#E6C800");
note1.setBorderWidth(2);
note1.setApplyDefaultMarkdownStyle(true);
notes.add(note1);
RuleChainNote note2 = new RuleChainNote();
note2.setId("note-2");
note2.setX(500);
note2.setY(300);
note2.setWidth(200);
note2.setHeight(100);
note2.setContent("Simple note");
note2.setBackgroundColor("#C8E6C9");
notes.add(note2);
ruleChainMetaData.setNotes(notes);
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
Assert.assertNotNull(savedMetaData);
Assert.assertNotNull(savedMetaData.getNotes());
Assert.assertEquals(2, savedMetaData.getNotes().size());
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertNotNull(loadedMetaData);
Assert.assertNotNull(loadedMetaData.getNotes());
Assert.assertEquals(2, loadedMetaData.getNotes().size());
RuleChainNote loadedNote1 = loadedMetaData.getNotes().stream()
.filter(n -> "note-1".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(loadedNote1);
Assert.assertEquals(100, loadedNote1.getX());
Assert.assertEquals(200, loadedNote1.getY());
Assert.assertEquals(300, loadedNote1.getWidth());
Assert.assertEquals(150, loadedNote1.getHeight());
Assert.assertEquals("# Test Note\nSome markdown content", loadedNote1.getContent());
Assert.assertEquals("#FFF9C4", loadedNote1.getBackgroundColor());
Assert.assertEquals("#E6C800", loadedNote1.getBorderColor());
Assert.assertEquals(Integer.valueOf(2), loadedNote1.getBorderWidth());
Assert.assertEquals(Boolean.TRUE, loadedNote1.getApplyDefaultMarkdownStyle());
RuleChainNote loadedNote2 = loadedMetaData.getNotes().stream()
.filter(n -> "note-2".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(loadedNote2);
Assert.assertEquals(500, loadedNote2.getX());
Assert.assertEquals(300, loadedNote2.getY());
Assert.assertEquals("Simple note", loadedNote2.getContent());
Assert.assertEquals("#C8E6C9", loadedNote2.getBackgroundColor());
}
@Test
public void testUpdateRuleChainNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain update notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Original content");
ruleChainMetaData.setNotes(List.of(note));
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
note.setContent("Updated content");
note.setX(50);
RuleChainNote newNote = new RuleChainNote();
newNote.setId("note-2");
newNote.setX(200);
newNote.setY(300);
newNote.setWidth(150);
newNote.setHeight(75);
newNote.setContent("New note");
savedMetaData.setNotes(List.of(note, newNote));
RuleChainMetaData updatedMetaData = doPost("/api/ruleChain/metadata", savedMetaData, RuleChainMetaData.class);
Assert.assertEquals(2, updatedMetaData.getNotes().size());
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertEquals(2, loadedMetaData.getNotes().size());
RuleChainNote updatedNote = loadedMetaData.getNotes().stream()
.filter(n -> "note-1".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(updatedNote);
Assert.assertEquals("Updated content", updatedNote.getContent());
Assert.assertEquals(50, updatedNote.getX());
}
@Test
public void testSaveRuleChainDoesNotOverwriteNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain preserve notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Persistent note");
ruleChainMetaData.setNotes(List.of(note));
doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
// Save the RuleChain itself (not metadata) — e.g. rename
ruleChain = doGet("/api/ruleChain/" + ruleChain.getId().getId(), RuleChain.class);
ruleChain.setName("Renamed RuleChain");
ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class);
Assert.assertEquals("Renamed RuleChain", ruleChain.getName());
// Notes must still be present
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertNotNull(loadedMetaData.getNotes());
Assert.assertEquals(1, loadedMetaData.getNotes().size());
Assert.assertEquals("Persistent note", loadedMetaData.getNotes().get(0).getContent());
}
@Test
public void testRemoveNotesByUpdatingMetadata() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain remove notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Will be removed");
ruleChainMetaData.setNotes(List.of(note));
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
Assert.assertEquals(1, savedMetaData.getNotes().size());
// Save metadata without notes — should clear them
savedMetaData.setNotes(null);
RuleChainMetaData updatedMetaData = doPost("/api/ruleChain/metadata", savedMetaData, RuleChainMetaData.class);
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertTrue(loadedMetaData.getNotes() == null || loadedMetaData.getNotes().isEmpty());
}
@Test
public void testSaveRuleChainNotesExceedsSizeLimit() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain oversized notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
List<RuleChainNote> notes = new ArrayList<>();
for (int i = 0; i < 20; i++) {
RuleChainNote note = new RuleChainNote();
note.setId("note-" + i);
note.setX(i * 10);
note.setY(i * 10);
note.setWidth(300);
note.setHeight(150);
note.setContent(StringUtils.randomAlphabetic(60000));
notes.add(note);
}
ruleChainMetaData.setNotes(notes);
String error = getErrorMessage(doPost("/api/ruleChain/metadata", ruleChainMetaData)
.andExpect(status().isBadRequest()));
assertThat(error).contains("Rule chain notes data is too large");
}
private RuleChain createRuleChain(String name) {
RuleChain ruleChain = new RuleChain();
ruleChain.setName(name);

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

@ -0,0 +1,47 @@
/**
* 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.ArrayList;
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() != null ? new ArrayList<>(ruleChainDetails.getNotes()) : null;
}
}

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);

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

@ -0,0 +1,67 @@
/**
* 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.ToString;
/**
* 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;
@ToString.Exclude
@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;
@ToString.Exclude
@Schema(description = "Custom CSS styles applied to the note content")
private String markdownCss;
}

8
common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java

@ -243,6 +243,14 @@ public class JacksonUtil {
}
}
public static <T> T treeToValue(JsonNode node, TypeReference<T> type) {
try {
return OBJECT_MAPPER.treeToValue(node, type);
} catch (IOException e) {
throw new IllegalArgumentException("Can't convert value: " + node.toString(), e);
}
}
public static JsonNode toJsonNode(String value) {
return toJsonNode(value, OBJECT_MAPPER);
}

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;
}
}

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

@ -0,0 +1,63 @@
/**
* 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.core.type.TypeReference;
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.dao.model.ModelConstants;
import org.thingsboard.server.dao.util.mapping.JsonConverter;
@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(JacksonUtil.treeToValue(notes, new TypeReference<>() {}));
}
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();
}
}

23
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;
@ -104,6 +105,9 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
@Autowired
private RuleChainDao ruleChainDao;
@Autowired
private RuleChainDetailsDao ruleChainDetailsDao;
@Autowired
private RuleNodeDao ruleNodeDao;
@ -316,9 +320,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 = ruleChainDetailsDao.save(tenantId, ruleChainDetails);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChainDetails)
.entityId(ruleChainDetails.getId()).broadcastEvent(publishSaveEvent).build());
return RuleChainUpdateResult.successful(updatedRuleNodes);
}
@ -341,13 +347,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 = ruleChainDetailsDao.findById(tenantId, 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 +361,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 +382,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;
}

29
dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java

@ -0,0 +1,29 @@
/**
* 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.rule;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import java.util.UUID;
public interface RuleChainDetailsDao {
RuleChainDetails findById(TenantId tenantId, UUID id);
RuleChainDetails save(TenantId tenantId, RuleChainDetails ruleChainDetails);
}

65
dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.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.sql.rule;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import org.thingsboard.server.dao.model.sql.RuleChainDetailsEntity;
import org.thingsboard.server.dao.rule.RuleChainDetailsDao;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
import org.thingsboard.server.dao.util.SqlDao;
import org.thingsboard.server.exception.DataValidationException;
import java.util.UUID;
@Slf4j
@Component
@SqlDao
@RequiredArgsConstructor
public class JpaRuleChainDetailsDao extends JpaAbstractDao<RuleChainDetailsEntity, RuleChainDetails> implements RuleChainDetailsDao {
private final RuleChainDetailsRepository ruleChainDetailsRepository;
@Override
public RuleChainDetails save(TenantId tenantId, RuleChainDetails ruleChainDetails) {
try {
return super.save(tenantId, ruleChainDetails);
} catch (Exception e) {
String rootMsg = ExceptionUtils.getRootCauseMessage(e);
if (StringUtils.contains(rootMsg, "value too long")) {
throw new DataValidationException("Rule chain notes data is too large. Please reduce the number or size of notes.");
}
throw e;
}
}
@Override
protected Class<RuleChainDetailsEntity> getEntityClass() {
return RuleChainDetailsEntity.class;
}
@Override
protected JpaRepository<RuleChainDetailsEntity, UUID> getRepository() {
return ruleChainDetailsRepository;
}
}

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(1000000),
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

@ -7976,9 +7976,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