Browse Source

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

Rule chain notes
pull/15309/head
Viacheslav Klimov 6 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; ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar;
-- CALCULATED FIELD ADDITIONAL INFO ADDITION END -- 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.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainMetaData; 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.RuleChainType;
import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.Authority;
@ -442,6 +443,201 @@ public class RuleChainControllerTest extends AbstractControllerTest {
assertThat(ruleChain.getVersion()).isEqualTo(5); 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) { private RuleChain createRuleChain(String name) {
RuleChain ruleChain = new RuleChain(); RuleChain ruleChain = new RuleChain();
ruleChain.setName(name); 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; package org.thingsboard.server.common.data.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import org.thingsboard.server.common.data.HasVersion; 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.") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of JSON objects that represent connections between rule nodes and other rule chains.")
private List<RuleChainConnectionInfo> ruleChainConnections; 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) { public void addConnectionInfo(int fromIndex, int toIndex, String type) {
NodeConnectionInfo connectionInfo = new NodeConnectionInfo(); NodeConnectionInfo connectionInfo = new NodeConnectionInfo();
connectionInfo.setFromIndex(fromIndex); 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) { public static JsonNode toJsonNode(String value) {
return toJsonNode(value, OBJECT_MAPPER); 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_FIRST_RULE_NODE_ID_PROPERTY = "first_rule_node_id";
public static final String RULE_CHAIN_ROOT_PROPERTY = "root"; public static final String RULE_CHAIN_ROOT_PROPERTY = "root";
public static final String RULE_CHAIN_CONFIGURATION_PROPERTY = "configuration"; public static final String RULE_CHAIN_CONFIGURATION_PROPERTY = "configuration";
public static final String RULE_CHAIN_NOTES_PROPERTY = "notes";
/** /**
* Rule node constants. * 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; 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.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; 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.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.model.ModelConstants;
import org.thingsboard.server.dao.util.mapping.JsonConverter;
import java.util.UUID;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@Entity @Entity
@Table(name = ModelConstants.RULE_CHAIN_TABLE_NAME) @Table(name = ModelConstants.RULE_CHAIN_TABLE_NAME)
public class RuleChainEntity extends BaseVersionedEntity<RuleChain> { public class RuleChainEntity extends AbstractRuleChainEntity<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 RuleChainEntity() { public RuleChainEntity() {
super();
} }
public RuleChainEntity(RuleChain ruleChain) { public RuleChainEntity(RuleChain ruleChain) {
super(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 @Override
public RuleChain toData() { public RuleChain toData() {
RuleChain ruleChain = new RuleChain(new RuleChainId(this.getUuid())); return super.toRuleChain();
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;
} }
} }

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.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainConnectionInfo; import org.thingsboard.server.common.data.rule.RuleChainConnectionInfo;
import org.thingsboard.server.common.data.rule.RuleChainData; 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.RuleChainImportResult;
import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleChainType;
@ -104,6 +105,9 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
@Autowired @Autowired
private RuleChainDao ruleChainDao; private RuleChainDao ruleChainDao;
@Autowired
private RuleChainDetailsDao ruleChainDetailsDao;
@Autowired @Autowired
private RuleNodeDao ruleNodeDao; private RuleNodeDao ruleNodeDao;
@ -316,9 +320,11 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
if (!relations.isEmpty()) { if (!relations.isEmpty()) {
relationService.saveRelations(tenantId, relations); relationService.saveRelations(tenantId, relations);
} }
ruleChain = ruleChainDao.save(tenantId, ruleChain); RuleChainDetails ruleChainDetails = new RuleChainDetails(ruleChain);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChain) ruleChainDetails.setNotes(ruleChainMetaData.getNotes());
.entityId(ruleChain.getId()).broadcastEvent(publishSaveEvent).build()); ruleChainDetails = ruleChainDetailsDao.save(tenantId, ruleChainDetails);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChainDetails)
.entityId(ruleChainDetails.getId()).broadcastEvent(publishSaveEvent).build());
return RuleChainUpdateResult.successful(updatedRuleNodes); return RuleChainUpdateResult.successful(updatedRuleNodes);
} }
@ -341,13 +347,13 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
@Override @Override
public RuleChainMetaData loadRuleChainMetaData(TenantId tenantId, RuleChainId ruleChainId) { public RuleChainMetaData loadRuleChainMetaData(TenantId tenantId, RuleChainId ruleChainId) {
Validator.validateId(ruleChainId, "Incorrect rule chain id."); Validator.validateId(ruleChainId, "Incorrect rule chain id.");
RuleChain ruleChain = findRuleChainById(tenantId, ruleChainId); RuleChainDetails ruleChainDetails = ruleChainDetailsDao.findById(tenantId, ruleChainId.getId());
if (ruleChain == null) { if (ruleChainDetails == null) {
return null; return null;
} }
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChainId); ruleChainMetaData.setRuleChainId(ruleChainId);
ruleChainMetaData.setVersion(ruleChain.getVersion()); ruleChainMetaData.setVersion(ruleChainDetails.getVersion());
List<RuleNode> ruleNodes = getRuleChainNodes(tenantId, ruleChainId); List<RuleNode> ruleNodes = getRuleChainNodes(tenantId, ruleChainId);
Collections.sort(ruleNodes, Comparator.comparingLong(RuleNode::getCreatedTime).thenComparing(RuleNode::getId, Comparator.comparing(RuleNodeId::getId))); Collections.sort(ruleNodes, Comparator.comparingLong(RuleNode::getCreatedTime).thenComparing(RuleNode::getId, Comparator.comparing(RuleNodeId::getId)));
Map<RuleNodeId, Integer> ruleNodeIndexMap = new HashMap<>(); Map<RuleNodeId, Integer> ruleNodeIndexMap = new HashMap<>();
@ -355,8 +361,8 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
ruleNodeIndexMap.put(node.getId(), ruleNodes.indexOf(node)); ruleNodeIndexMap.put(node.getId(), ruleNodes.indexOf(node));
} }
ruleChainMetaData.setNodes(ruleNodes); ruleChainMetaData.setNodes(ruleNodes);
if (ruleChain.getFirstRuleNodeId() != null) { if (ruleChainDetails.getFirstRuleNodeId() != null) {
ruleChainMetaData.setFirstNodeIndex(ruleNodeIndexMap.get(ruleChain.getFirstRuleNodeId())); ruleChainMetaData.setFirstNodeIndex(ruleNodeIndexMap.get(ruleChainDetails.getFirstRuleNodeId()));
} }
for (RuleNode node : ruleNodes) { for (RuleNode node : ruleNodes) {
int fromIndex = ruleNodeIndexMap.get(node.getId()); 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) Collections.sort(ruleChainMetaData.getConnections(), Comparator.comparingInt(NodeConnectionInfo::getFromIndex)
.thenComparing(NodeConnectionInfo::getToIndex).thenComparing(NodeConnectionInfo::getType)); .thenComparing(NodeConnectionInfo::getToIndex).thenComparing(NodeConnectionInfo::getType));
} }
ruleChainMetaData.setNotes(ruleChainDetails.getNotes());
return ruleChainMetaData; 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, created_time bigint NOT NULL,
additional_info varchar, additional_info varchar,
configuration varchar(10000000), configuration varchar(10000000),
notes varchar(1000000),
name varchar(255), name varchar(255),
type varchar(255), type varchar(255),
first_rule_node_id uuid, first_rule_node_id uuid,

2
ui-ngx/package.json

@ -71,7 +71,7 @@
"ngx-clipboard": "^16.0.0", "ngx-clipboard": "^16.0.0",
"ngx-daterangepicker-material": "^6.0.4", "ngx-daterangepicker-material": "^6.0.4",
"ngx-drag-drop": "^20.0.1", "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-hm-carousel": "^19.0.0",
"ngx-markdown": "^20.1.0", "ngx-markdown": "^20.1.0",
"ngx-sharebuttons": "^17.0.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 { Injectable } from '@angular/core';
import { BreakpointId, Dashboard, DashboardLayoutId } from '@app/shared/models/dashboard.models'; 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 { import {
Datasource, Datasource,
DatasourceType, DatasourceType,
@ -31,7 +37,7 @@ import { deepClone, isDefinedAndNotNull, isEqual } from '@core/utils';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { Observable, of, throwError } from 'rxjs'; import { Observable, of, throwError } from 'rxjs';
import { map } from 'rxjs/operators'; 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 { RuleChainService } from '@core/http/rule-chain.service';
import { RuleChainImport } from '@shared/models/rule-chain.models'; import { RuleChainImport } from '@shared/models/rule-chain.models';
import { Filter, FilterInfo, Filters, FiltersInfo, getFilterId } from '@shared/models/query/query.models'; import { Filter, FilterInfo, Filters, FiltersInfo, getFilterId } from '@shared/models/query/query.models';
@ -72,8 +78,11 @@ export interface RuleNodeConnection {
export interface RuleNodesReference { export interface RuleNodesReference {
nodes: FcRuleNode[]; nodes: FcRuleNode[];
connections: RuleNodeConnection[]; connections: RuleNodeConnection[];
notes?: FcRuleNote[];
originX?: number; originX?: number;
originY?: number; originY?: number;
minX?: number;
minY?: number;
} }
@Injectable({ @Injectable({
@ -312,10 +321,11 @@ export class ItemBufferService {
return of(theDashboard); return of(theDashboard);
} }
public copyRuleNodes(nodes: FcRuleNode[], connections: RuleNodeConnection[]) { public copyRuleChainObjects(nodes: FcRuleNode[], connections: RuleNodeConnection[], notes: FcRuleNote[] = []) {
const ruleNodes: RuleNodesReference = { const ruleNodes: RuleNodesReference = {
nodes: [], nodes: [],
connections: [] connections: [],
notes: []
}; };
let top = -1; let top = -1;
let left = -1; let left = -1;
@ -364,23 +374,51 @@ export class ItemBufferService {
right = Math.max(right, node.x + 170); 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.originX = left + (right - left) / 2;
ruleNodes.originY = top + (bottom - top) / 2; ruleNodes.originY = top + (bottom - top) / 2;
ruleNodes.minX = left;
ruleNodes.minY = top;
connections.forEach(connection => { connections.forEach(connection => {
ruleNodes.connections.push(connection); ruleNodes.connections.push(connection);
}); });
this.storeSet(RULE_NODES, ruleNodes); this.storeSet(RULE_NODES, ruleNodes);
} }
public hasRuleNodes(): boolean { public hasRuleChainObjects(): boolean {
return this.storeHas(RULE_NODES); 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); const ruleNodes: RuleNodesReference = this.storeGet(RULE_NODES);
if (ruleNodes) { if (ruleNodes) {
const deltaX = x - ruleNodes.originX; const deltaX = isDefinedAndNotNull(ruleNodes.minX) ? Math.max(x - ruleNodes.originX, -ruleNodes.minX) : x - ruleNodes.originX;
const deltaY = y - ruleNodes.originY; const deltaY = isDefinedAndNotNull(ruleNodes.minY) ? Math.max(y - ruleNodes.originY, -ruleNodes.minY) : y - ruleNodes.originY;
for (const node of ruleNodes.nodes) { for (const node of ruleNodes.nodes) {
const component = this.ruleChainService.getRuleNodeComponentByClazz(node.ruleChainType, node.componentClazz); const component = this.ruleChainService.getRuleNodeComponentByClazz(node.ruleChainType, node.componentClazz);
if (component) { if (component) {
@ -404,6 +442,10 @@ export class ItemBufferService {
return null; 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 ruleNodes;
} }
return null; 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> </div>
</mat-drawer> </mat-drawer>
<mat-drawer class="tb-details-drawer" <mat-drawer class="tb-details-drawer"
[opened]="isEditingRuleNode || isEditingRuleNodeLink" [opened]="isEditingRuleNode || isEditingRuleNodeLink || isEditingNote"
(closed)="onDetailsDrawerClosed()" (closed)="onDetailsDrawerClosed()"
mode="over" mode="over"
position="end"> position="end">
@ -158,6 +158,20 @@
[sourceRuleChainId]="editingRuleNodeSourceRuleChainId"> [sourceRuleChainId]="editingRuleNodeSourceRuleChainId">
</tb-rule-node-link> </tb-rule-node-link>
</tb-details-panel> </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>
<mat-drawer-content class="tb-rulechain-graph-content"> <mat-drawer-content class="tb-rulechain-graph-content">
<button color="primary" <button color="primary"
@ -168,16 +182,25 @@
matTooltipPosition="above"> matTooltipPosition="above">
<mat-icon class="tb-library-node-btn-icon" [class.tb-library-node-btn-icon-toggled]="drawer.opened">chevron_right</mat-icon> <mat-icon class="tb-library-node-btn-icon" [class.tb-library-node-btn-icon-toggled]="drawer.opened">chevron_right</mat-icon>
</button> </button>
<button #versionControlButton @if (!isImport) {
*ngIf="!isImport" <button color="primary"
color="primary" type="button"
type="button" mat-mini-fab class="add-note-button"
mat-mini-fab class="version-control-button" (click)="addNote()"
(click)="toggleVersionControl($event, versionControlButton)" matTooltip="{{'rulechain.add-note' | translate}}"
matTooltip="{{'version-control.version-control' | translate}}" matTooltipPosition="above">
matTooltipPosition="above"> <mat-icon>sticky_note_2</mat-icon>
<mat-icon>history</mat-icon> </button>
</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" <button type="button"
mat-icon-button class="tb-fullscreen-button tb-mat-40" mat-icon-button class="tb-fullscreen-button tb-mat-40"
(click)="isFullscreen = !isFullscreen" (click)="isFullscreen = !isFullscreen"
@ -198,9 +221,11 @@
<div class="tb-context-menu-header {{contextInfo.headerClass}}"> <div class="tb-context-menu-header {{contextInfo.headerClass}}">
<mat-icon *ngIf="!contextInfo.iconUrl">{{contextInfo.icon}}</mat-icon> <mat-icon *ngIf="!contextInfo.iconUrl">{{contextInfo.icon}}</mat-icon>
<img *ngIf="contextInfo.iconUrl" [src]="contextInfo.iconUrl"/> <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-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> </div>
<div *ngFor="let menuItem of contextInfo.menuItems"> <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; 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 { .tb-library-node-btn {
width: 20px; width: 20px;
height: 90px; height: 90px;
@ -188,6 +197,20 @@
margin: -3px; margin: -3px;
border: solid 3px #f00; 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 { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatExpansionPanel } from '@angular/material/expansion'; import { MatExpansionPanel } from '@angular/material/expansion';
import { DialogService } from '@core/services/dialog.service'; import { DialogService } from '@core/services/dialog.service';
import { AuthService } from '@core/auth/auth.service';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { import {
inputNodeComponent, inputNodeComponent,
@ -63,9 +62,12 @@ import {
} from '@shared/models/rule-chain.models'; } from '@shared/models/rule-chain.models';
import { FcItemInfo, FlowchartConstants, NgxFlowchartComponent, UserCallbacks } from 'ngx-flowchart'; import { FcItemInfo, FlowchartConstants, NgxFlowchartComponent, UserCallbacks } from 'ngx-flowchart';
import { import {
FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE,
FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR,
FcRuleEdge, FcRuleEdge,
FcRuleNode, FcRuleNode,
FcRuleNodeType, FcRuleNodeType,
FcRuleNote,
getRuleNodeHelpLink, getRuleNodeHelpLink,
LinkLabel, LinkLabel,
outputNodeClazz, 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 { NEVER, Observable, of, ReplaySubject, skip, startWith, Subject, throwError } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, mergeMap, takeUntil, tap } from 'rxjs/operators'; import { catchError, debounceTime, distinctUntilChanged, mergeMap, takeUntil, tap } from 'rxjs/operators';
import { ISearchableComponent } from '../../models/searchable-component.models'; 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 { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component';
import { RuleNodeLinkComponent } from './rule-node-link.component'; import { RuleNodeLinkComponent } from './rule-node-link.component';
import { RuleNoteEditorComponent } from './rule-note-editor.component';
import { DialogComponent } from '@shared/components/dialog.component'; import { DialogComponent } from '@shared/components/dialog.component';
import { MatMenuTrigger } from '@angular/material/menu'; import { MatMenuTrigger } from '@angular/material/menu';
import { ItemBufferService, RuleNodeConnection } from '@core/services/item-buffer.service'; import { ItemBufferService, RuleNodeConnection } from '@core/services/item-buffer.service';
import { Hotkey } from 'angular2-hotkeys'; 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 { MatMiniFabButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service'; import { TbPopoverService } from '@shared/components/popover.service';
import { VersionControlComponent } from '@home/components/vc/version-control.component'; 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 { HttpStatusCode } from '@angular/common/http';
import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; import { TbContextMenuEvent } from '@shared/models/jquery-event.models';
import { EntityDebugSettings } from '@shared/models/entity.models'; import { EntityDebugSettings } from '@shared/models/entity.models';
import Timeout = NodeJS.Timeout;
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; import { EventsDialogComponent } from '@home/dialogs/events-dialog.component';
import Timeout = NodeJS.Timeout;
@Component({ @Component({
selector: 'tb-rulechain-page', selector: 'tb-rulechain-page',
@ -132,8 +135,6 @@ export class RuleChainPageComponent extends PageComponent
@ViewChild('drawer') drawer: MatDrawer; @ViewChild('drawer') drawer: MatDrawer;
eventTypes = EventType;
debugEventTypes = DebugEventType; debugEventTypes = DebugEventType;
ruleChainMenuPosition = { x: '0px', y: '0px' }; ruleChainMenuPosition = { x: '0px', y: '0px' };
@ -163,11 +164,16 @@ export class RuleChainPageComponent extends PageComponent
@ViewChild('tbRuleNode') ruleNodeComponent: RuleNodeDetailsComponent; @ViewChild('tbRuleNode') ruleNodeComponent: RuleNodeDetailsComponent;
@ViewChild('tbRuleNodeLink') ruleNodeLinkComponent: RuleNodeLinkComponent; @ViewChild('tbRuleNodeLink') ruleNodeLinkComponent: RuleNodeLinkComponent;
@ViewChild('tbRuleNote') ruleNoteComponent: RuleNoteEditorComponent;
editingRuleNodeLink: FcRuleEdge = null; editingRuleNodeLink: FcRuleEdge = null;
isEditingRuleNodeLink = false; isEditingRuleNodeLink = false;
editingRuleNodeLinkIndex = -1; editingRuleNodeLinkIndex = -1;
editingNote: FcRuleNote = null;
isEditingNote = false;
editingNoteIndex = -1;
hotKeys: Hotkey[] = []; hotKeys: Hotkey[] = [];
enableHotKeys = true; enableHotKeys = true;
@ -180,22 +186,23 @@ export class RuleChainPageComponent extends PageComponent
ruleChainModel: FcRuleNodeModel = { ruleChainModel: FcRuleNodeModel = {
nodes: [], nodes: [],
edges: [] edges: [],
notes: []
}; };
selectedObjects = []; selectedObjects = [];
editCallbacks: UserCallbacks = { editCallbacks: UserCallbacks = {
edgeDoubleClick: (event, edge) => { edgeDoubleClick: (_event, edge) => {
this.openLinkDetails(edge); this.openLinkDetails(edge);
}, },
edgeEdit: (event, edge) => { edgeEdit: (_event, edge) => {
this.openLinkDetails(edge); this.openLinkDetails(edge);
}, },
nodeCallbacks: { nodeCallbacks: {
doubleClick: (event, node: FcRuleNode) => { doubleClick: (_event, node: FcRuleNode) => {
this.openNodeDetails(node); this.openNodeDetails(node);
}, },
nodeEdit: (event, node: FcRuleNode) => { nodeEdit: (_event, node: FcRuleNode) => {
this.openNodeDetails(node); this.openNodeDetails(node);
}, },
mouseEnter: this.displayNodeDescriptionTooltip.bind(this), mouseEnter: this.displayNodeDescriptionTooltip.bind(this),
@ -204,7 +211,7 @@ export class RuleChainPageComponent extends PageComponent
}, },
isValidEdge: (source, destination) => isValidEdge: (source, destination) =>
source.type === FlowchartConstants.rightConnectorType && destination.type === FlowchartConstants.leftConnectorType, 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; const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source) as FcRuleNode;
if (sourceNode.component.type === RuleNodeType.INPUT) { if (sourceNode.component.type === RuleNodeType.INPUT) {
const found = this.ruleChainModel.edges.find(theEdge => theEdge.source === (this.inputConnectorId + '')); 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); 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; private tooltipTimeout: Timeout;
constructor(protected store: Store<AppState>, constructor(private route: ActivatedRoute,
private route: ActivatedRoute,
private router: Router, private router: Router,
private ruleChainService: RuleChainService, private ruleChainService: RuleChainService,
private authService: AuthService,
private translate: TranslateService, private translate: TranslateService,
private itembuffer: ItemBufferService, private itembuffer: ItemBufferService,
private popoverService: TbPopoverService, private popoverService: TbPopoverService,
@ -282,7 +298,7 @@ export class RuleChainPageComponent extends PageComponent
public dialog: MatDialog, public dialog: MatDialog,
public dialogService: DialogService, public dialogService: DialogService,
public fb: FormBuilder) { public fb: FormBuilder) {
super(store); super();
this.route.data.pipe( this.route.data.pipe(
takeUntil(this.destroy$) takeUntil(this.destroy$)
).subscribe( ).subscribe(
@ -363,12 +379,15 @@ export class RuleChainPageComponent extends PageComponent
this.selectedObjects = []; this.selectedObjects = [];
this.ruleChainModel.nodes = []; this.ruleChainModel.nodes = [];
this.ruleChainModel.edges = []; this.ruleChainModel.edges = [];
this.ruleChainModel.notes = [];
this.ruleNodeTypesModel = {}; this.ruleNodeTypesModel = {};
if (this.ruleChainCanvas) { if (this.ruleChainCanvas) {
this.ruleChainCanvas.adjustCanvasSize(true); this.ruleChainCanvas.adjustCanvasSize(true);
} }
this.isEditingRuleNode = false; this.isEditingRuleNode = false;
this.isEditingRuleNodeLink = false; this.isEditingRuleNodeLink = false;
this.isEditingNote = false;
this.editingNote = null;
this.updateRuleNodesHighlight(); this.updateRuleNodesHighlight();
} }
@ -389,7 +408,7 @@ export class RuleChainPageComponent extends PageComponent
new Hotkey(['ctrl+c', 'meta+c'], (event: KeyboardEvent) => { new Hotkey(['ctrl+c', 'meta+c'], (event: KeyboardEvent) => {
if (this.enableHotKeys) { if (this.enableHotKeys) {
event.preventDefault(); event.preventDefault();
this.copyRuleNodes(); this.copyRuleChainObjects();
return false; return false;
} }
return true; return true;
@ -400,8 +419,8 @@ export class RuleChainPageComponent extends PageComponent
new Hotkey(['ctrl+v', 'meta+v'], (event: KeyboardEvent) => { new Hotkey(['ctrl+v', 'meta+v'], (event: KeyboardEvent) => {
if (this.enableHotKeys) { if (this.enableHotKeys) {
event.preventDefault(); event.preventDefault();
if (this.itembuffer.hasRuleNodes()) { if (this.itembuffer.hasRuleChainObjects()) {
this.pasteRuleNodes(); this.pasteRuleChainObjects();
} }
return false; return false;
} }
@ -465,6 +484,17 @@ export class RuleChainPageComponent extends PageComponent
}, ['INPUT', 'SELECT', 'TEXTAREA'], }, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('rulenode.create-nested-rulechain')) 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) { if (this.expansionPanels) {
for (let i = 0; i < ruleNodeTypesLibrary.length; i++) { 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) { if (panel) {
const type = ruleNodeTypesLibrary[i]; const type = ruleNodeTypesLibrary[i];
if (!this.ruleNodeTypesModel[type].model.nodes.length) { 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) { if (this.ruleChainCanvas) {
this.ruleChainCanvas.adjustCanvasSize(true); this.ruleChainCanvas.adjustCanvasSize(true);
} }
@ -688,10 +719,12 @@ export class RuleChainPageComponent extends PageComponent
} }
private prepareContextMenu(item: FcItemInfo): RuleChainMenuContextInfo { private prepareContextMenu(item: FcItemInfo): RuleChainMenuContextInfo {
if (this.objectsSelected() || (!item.node && !item.edge)) { if (this.objectsSelected() || (!item.node && !item.edge && !item.note)) {
return this.prepareRuleChainContextMenu(); return this.prepareRuleChainContextMenu();
} else if (item.node) { } else if (item.node) {
return this.prepareRuleNodeContextMenu(item.node); return this.prepareRuleNodeContextMenu(item.node);
} else if (item.note) {
return this.prepareNoteContextMenu(item.note);
} else if (item.edge) { } else if (item.edge) {
return this.prepareEdgeContextMenu(item.edge); return this.prepareEdgeContextMenu(item.edge);
} }
@ -705,11 +738,12 @@ export class RuleChainPageComponent extends PageComponent
subtitle: this.translate.instant('rulechain.rulechain'), subtitle: this.translate.instant('rulechain.rulechain'),
menuItems: [] menuItems: []
}; };
if (this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length) { if (this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length ||
this.ruleChainCanvas.modelService.notes.getSelectedNotes().length) {
contextInfo.menuItems.push( contextInfo.menuItems.push(
{ {
action: () => { action: () => {
this.copyRuleNodes(); this.copyRuleChainObjects();
}, },
enabled: true, enabled: true,
value: 'rulenode.copy-selected', value: 'rulenode.copy-selected',
@ -721,14 +755,25 @@ export class RuleChainPageComponent extends PageComponent
contextInfo.menuItems.push( contextInfo.menuItems.push(
{ {
action: ($event) => { action: ($event) => {
this.pasteRuleNodes($event); this.pasteRuleChainObjects($event);
}, },
enabled: this.itembuffer.hasRuleNodes(), enabled: this.itembuffer.hasRuleChainObjects(),
value: 'action.paste', value: 'action.paste',
icon: 'content_paste', icon: 'content_paste',
shortcut: 'M-V' 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( contextInfo.menuItems.push(
{ {
divider: true divider: true
@ -907,7 +952,7 @@ export class RuleChainPageComponent extends PageComponent
toIndexSet.add(toIndex); 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 noInputNodes.filter((node: FcRuleNode) => node.component.configurationDescriptor.nodeDefinition.inEnabled).length <= 1;
} }
return false; return false;
@ -925,11 +970,13 @@ export class RuleChainPageComponent extends PageComponent
} }
}).afterClosed().subscribe((ruleChain) => { }).afterClosed().subscribe((ruleChain) => {
if (ruleChain) { if (ruleChain) {
const selectedNotes: FcRuleNote[] = this.ruleChainCanvas.modelService.notes.getSelectedNotes();
this.ruleChainCanvas.modelService.deselectAll(); this.ruleChainCanvas.modelService.deselectAll();
const ruleChainMetaData: RuleChainMetaData = { const ruleChainMetaData: RuleChainMetaData = {
ruleChainId: ruleChain.id, ruleChainId: ruleChain.id,
nodes: [], nodes: [],
connections: [] connections: [],
notes: deepClone(selectedNotes)
}; };
let outputEdges: FcRuleEdge[] = []; let outputEdges: FcRuleEdge[] = [];
let minX: number = null; 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) => const possibleInputNodes = noInputNodes.filter((node: FcRuleNode) =>
node.component.configurationDescriptor.nodeDefinition.inEnabled); node.component.configurationDescriptor.nodeDefinition.inEnabled);
let inputEdges: FcRuleEdge[] = []; let inputEdges: FcRuleEdge[] = [];
@ -1039,6 +1086,10 @@ export class RuleChainPageComponent extends PageComponent
node.additionalInfo.layoutX -= deltaX; node.additionalInfo.layoutX -= deltaX;
node.additionalInfo.layoutY -= deltaY; node.additionalInfo.layoutY -= deltaY;
}); });
ruleChainMetaData.notes?.forEach((note) => {
note.x -= deltaX;
note.y -= deltaY;
});
this.ruleChainService.saveRuleChainMetadata(ruleChainMetaData).subscribe(() => { this.ruleChainService.saveRuleChainMetadata(ruleChainMetaData).subscribe(() => {
const component = this.ruleChainService.getRuleNodeComponentByClazz(this.ruleChainType, ruleChainNodeClazz); const component = this.ruleChainService.getRuleNodeComponentByClazz(this.ruleChainType, ruleChainNodeClazz);
const descriptor = ruleNodeTypeDescriptors.get(component.type); const descriptor = ruleNodeTypeDescriptors.get(component.type);
@ -1090,6 +1141,9 @@ export class RuleChainPageComponent extends PageComponent
selectedNodes.forEach((node) => { selectedNodes.forEach((node) => {
this.ruleChainCanvas.modelService.nodes.delete(node); this.ruleChainCanvas.modelService.nodes.delete(node);
}); });
selectedNotes.forEach((note) => {
this.ruleChainCanvas.modelService.notes.delete(note);
});
this.onModelChanged(); this.onModelChanged();
this.updateRuleNodesHighlight(); this.updateRuleNodesHighlight();
}); });
@ -1146,12 +1200,15 @@ export class RuleChainPageComponent extends PageComponent
} }
private copyNode(node: FcRuleNode) { 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 nodes: FcRuleNode[] = this.ruleChainCanvas.modelService.nodes.getSelectedNodes();
const edges: FcRuleEdge[] = this.ruleChainCanvas.modelService.edges.getSelectedEdges(); 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[] = []; const connections: RuleNodeConnection[] = [];
edges.forEach((edge) => { edges.forEach((edge) => {
const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source);
@ -1170,10 +1227,10 @@ export class RuleChainPageComponent extends PageComponent
connections.push(connection); 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); const canvas = $(this.ruleChainCanvas.modelService.canvasHtmlElement);
let x: number; let x: number;
let y: number; let y: number;
@ -1188,11 +1245,11 @@ export class RuleChainPageComponent extends PageComponent
x = scrollLeft + scrollParent.width() / 2; x = scrollLeft + scrollParent.width() / 2;
y = scrollTop + scrollParent.height() / 2; y = scrollTop + scrollParent.height() / 2;
} }
const ruleNodes = this.itembuffer.pasteRuleNodes(x, y); const ruleChainObjects = this.itembuffer.pasteRuleChainObjects(x, y);
if (ruleNodes) { if (ruleChainObjects) {
this.ruleChainCanvas.modelService.deselectAll(); this.ruleChainCanvas.modelService.deselectAll();
const nodes: FcRuleNode[] = []; const nodes: FcRuleNode[] = [];
ruleNodes.nodes.forEach((node) => { ruleChainObjects.nodes.forEach((node) => {
node.id = 'rule-chain-node-' + this.nextNodeID++; node.id = 'rule-chain-node-' + this.nextNodeID++;
const component = node.component; const component = node.component;
if (component.configurationDescriptor.nodeDefinition.inEnabled) { if (component.configurationDescriptor.nodeDefinition.inEnabled) {
@ -1215,7 +1272,7 @@ export class RuleChainPageComponent extends PageComponent
this.ruleChainModel.nodes.push(node); this.ruleChainModel.nodes.push(node);
this.ruleChainCanvas.modelService.nodes.select(node); this.ruleChainCanvas.modelService.nodes.select(node);
}); });
ruleNodes.connections.forEach((connection) => { ruleChainObjects.connections.forEach((connection) => {
const sourceNode = nodes[connection.fromIndex]; const sourceNode = nodes[connection.fromIndex];
const destNode = nodes[connection.toIndex]; const destNode = nodes[connection.toIndex];
if ( (connection.isInputSource || sourceNode) && destNode ) { 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.updateRuleNodesHighlight();
this.validate(); this.validate();
this.onModelChanged(); 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() { onDetailsDrawerClosed() {
this.onEditRuleNodeClosed(); this.onEditRuleNodeClosed();
this.onEditRuleNodeLinkClosed(); this.onEditRuleNodeLinkClosed();
this.onEditNoteClosed();
this.enableHotKeys = true; this.enableHotKeys = true;
this.updateErrorTooltips(false); this.updateErrorTooltips(false);
} }
@ -1419,7 +1603,8 @@ export class RuleChainPageComponent extends PageComponent
objectsSelected(): boolean { objectsSelected(): boolean {
return this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length > 0 || 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() { deleteSelected() {
@ -1477,6 +1662,7 @@ export class RuleChainPageComponent extends PageComponent
ruleChainId: this.ruleChain.id, ruleChainId: this.ruleChain.id,
nodes: [], nodes: [],
connections: [], connections: [],
notes: deepClone(this.ruleChainModel.notes) || [],
version: ruleChain.version version: ruleChain.version
}; };
const nodes: FcRuleNode[] = []; const nodes: FcRuleNode[] = [];
@ -1535,6 +1721,9 @@ export class RuleChainPageComponent extends PageComponent
.subscribe((savedRuleChainMetaData) => { .subscribe((savedRuleChainMetaData) => {
this.ruleChain.version = savedRuleChainMetaData.version; this.ruleChain.version = savedRuleChainMetaData.version;
this.ruleChainMetaData = savedRuleChainMetaData; this.ruleChainMetaData = savedRuleChainMetaData;
if (!this.ruleChainMetaData.notes) {
this.ruleChainMetaData.notes = deepClone(this.ruleChainModel.notes) || [];
}
if (this.isImport) { if (this.isImport) {
this.isDirtyValue = false; this.isDirtyValue = false;
this.isImport = false; this.isImport = false;
@ -1796,7 +1985,7 @@ export interface AddRuleNodeLinkDialogData {
standalone: false standalone: false
}) })
export class AddRuleNodeLinkDialogComponent extends DialogComponent<AddRuleNodeLinkDialogComponent, FcRuleEdge> export class AddRuleNodeLinkDialogComponent extends DialogComponent<AddRuleNodeLinkDialogComponent, FcRuleEdge>
implements OnInit, ErrorStateMatcher { implements ErrorStateMatcher {
ruleNodeLinkFormGroup: UntypedFormGroup; ruleNodeLinkFormGroup: UntypedFormGroup;
@ -1826,9 +2015,6 @@ export class AddRuleNodeLinkDialogComponent extends DialogComponent<AddRuleNodeL
); );
} }
ngOnInit(): void {
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form); const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted); const customErrorState = !!(control && control.invalid && this.submitted);
@ -1861,7 +2047,7 @@ export interface AddRuleNodeDialogData {
standalone: false standalone: false
}) })
export class AddRuleNodeDialogComponent extends DialogComponent<AddRuleNodeDialogComponent, FcRuleNode> export class AddRuleNodeDialogComponent extends DialogComponent<AddRuleNodeDialogComponent, FcRuleNode>
implements OnInit, ErrorStateMatcher { implements ErrorStateMatcher {
@ViewChild('tbRuleNode', {static: true}) ruleNodeDetailsComponent: RuleNodeDetailsComponent; @ViewChild('tbRuleNode', {static: true}) ruleNodeDetailsComponent: RuleNodeDetailsComponent;
@ -1883,9 +2069,6 @@ export class AddRuleNodeDialogComponent extends DialogComponent<AddRuleNodeDialo
this.ruleChainType = this.data.ruleChainType; this.ruleChainType = this.data.ruleChainType;
} }
ngOnInit(): void {
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form); const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted); 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; icon: string;
iconUrl?: string; iconUrl?: string;
title: string; title: string;
subtitle: string; subtitle?: string;
menuItems: RuleChainMenuItem[]; menuItems: RuleChainMenuItem[];
} }

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

@ -27,23 +27,27 @@ import {
AddRuleNodeDialogComponent, AddRuleNodeDialogComponent,
AddRuleNodeLinkDialogComponent, AddRuleNodeLinkDialogComponent,
CreateNestedRuleChainDialogComponent, CreateNestedRuleChainDialogComponent,
AddNoteDialogComponent,
RuleChainPageComponent RuleChainPageComponent
} from '@home/pages/rulechain/rulechain-page.component'; } from '@home/pages/rulechain/rulechain-page.component';
import { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component'; import { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component';
import { RuleNodeConfigComponent } from '@home/pages/rulechain/rule-node-config.component'; import { RuleNodeConfigComponent } from '@home/pages/rulechain/rule-node-config.component';
import { LinkLabelsComponent } from '@home/pages/rulechain/link-labels.component'; import { LinkLabelsComponent } from '@home/pages/rulechain/link-labels.component';
import { RuleNodeLinkComponent } from '@home/pages/rulechain/rule-node-link.component'; import { RuleNodeLinkComponent } from '@home/pages/rulechain/rule-node-link.component';
import { RuleNoteEditorComponent } from '@home/pages/rulechain/rule-note-editor.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
RuleChainPageComponent, RuleChainPageComponent,
RuleNodeDetailsComponent, RuleNodeDetailsComponent,
RuleNoteEditorComponent,
LinkLabelsComponent, LinkLabelsComponent,
RuleNodeLinkComponent, RuleNodeLinkComponent,
RuleNodeConfigComponent, RuleNodeConfigComponent,
AddRuleNodeLinkDialogComponent, AddRuleNodeLinkDialogComponent,
AddRuleNodeDialogComponent, AddRuleNodeDialogComponent,
CreateNestedRuleChainDialogComponent CreateNestedRuleChainDialogComponent,
AddNoteDialogComponent
], ],
imports: [ imports: [
CommonModule, 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 { HomeComponentsModule } from '@modules/home/components/home-components.module';
import { RuleChainTabsComponent } from '@home/pages/rulechain/rulechain-tabs.component'; import { RuleChainTabsComponent } from '@home/pages/rulechain/rulechain-tabs.component';
import { RuleNodeComponent } from '@home/pages/rulechain/rulenode.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({ @NgModule({
declarations: [ declarations: [
RuleChainComponent, RuleChainComponent,
RuleChainTabsComponent, RuleChainTabsComponent,
RuleNodeComponent, RuleNodeComponent,
RuleNoteComponent,
], ],
providers: [ providers: [
{ {
@ -37,6 +39,12 @@ import { FC_NODE_COMPONENT_CONFIG } from 'ngx-flowchart';
nodeComponentType: RuleNodeComponent nodeComponentType: RuleNodeComponent
} }
}, },
{
provide: FC_NOTE_COMPONENT_CONFIG,
useValue: {
noteComponentType: RuleNoteComponent
}
},
], ],
imports: [ imports: [
CommonModule, 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" <button [class.!hidden]="!editorMode"
type="button" type="button"
mat-button (click)="toggleEditMode()">{{ 'markdown.preview' | translate }}</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"> <fieldset style="width: initial">
<div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}" <div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above" 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() helpId: string;
@Input() helpPopupStyle: { [klass: string]: any } = {};
@Input() @Input()
@coerceBoolean() @coerceBoolean()
required: boolean; 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 { TenantId } from '@shared/models/id/tenant-id';
import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id';
import { RuleNodeId } from '@shared/models/id/rule-node-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 { ComponentClusteringMode, ComponentType } from '@shared/models/component-descriptor.models';
import { HasTenantId, HasVersion } from '@shared/models/entity.models'; import { HasTenantId, HasVersion } from '@shared/models/entity.models';
@ -39,6 +39,7 @@ export interface RuleChainMetaData extends HasVersion {
firstNodeIndex?: number; firstNodeIndex?: number;
nodes: Array<RuleNode>; nodes: Array<RuleNode>;
connections: Array<NodeConnectionInfo>; connections: Array<NodeConnectionInfo>;
notes?: Array<FcRuleNote>;
} }
export interface RuleChainImport { 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 { RuleChainId } from '@shared/models/id/rule-chain-id';
import { RuleNodeId } from '@shared/models/id/rule-node-id'; import { RuleNodeId } from '@shared/models/id/rule-node-id';
import { ComponentDescriptor } from '@shared/models/component-descriptor.models'; 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 { Observable } from 'rxjs';
import { PageComponent } from '@shared/components/page.component'; import { PageComponent } from '@shared/components/page.component';
import { AfterViewInit, DestroyRef, Directive, EventEmitter, inject, OnInit } from '@angular/core'; 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 { DebugRuleNodeEventBody } from '@shared/models/event.models';
import { EntityTestScriptResult, HasEntityDebugSettings } from '@shared/models/entity.models'; import { EntityTestScriptResult, HasEntityDebugSettings } from '@shared/models/entity.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { strings } from '@angular-devkit/core';
export interface RuleNodeConfiguration { export interface RuleNodeConfiguration {
[key: string]: any; [key: string]: any;
@ -358,6 +359,19 @@ export interface FcRuleEdge extends FcEdge {
labels?: string[]; 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 { export enum ScriptLanguage {
JS = 'JS', JS = 'JS',
TBEL = 'TBEL' 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-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.", "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-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": { "rulenode": {
"rule-node-events": "Rule node events", "rule-node-events": "Rule node events",

6
ui-ngx/yarn.lock

@ -7976,9 +7976,9 @@ ngx-drag-drop@^20.0.1:
dependencies: dependencies:
tslib "^2.3.0" tslib "^2.3.0"
"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":
version "4.0.0" version "4.1.0"
resolved "https://github.com/thingsboard/ngx-flowchart.git#735bc818ef218a169ac50e87edc6a093b3e97715" resolved "https://github.com/thingsboard/ngx-flowchart.git#a3dee1761ecaa96c7d0ca22b1c6538b9e27103af"
dependencies: dependencies:
tslib "^2.3.0" tslib "^2.3.0"

Loading…
Cancel
Save