diff --git a/libs/gui-elements b/libs/gui-elements index f2d447c0f9..3016f54232 160000 --- a/libs/gui-elements +++ b/libs/gui-elements @@ -1 +1 @@ -Subproject commit f2d447c0f9e3ce9b29f70075c47190b7cda328ad +Subproject commit 3016f542321427dd250b117b8e8a04c308d48d5a diff --git a/silk-core/src/main/resources/org/silkframework/LinkSpecificationLanguage.xsd b/silk-core/src/main/resources/org/silkframework/LinkSpecificationLanguage.xsd index 4cee981cfb..7b8ec63dd5 100644 --- a/silk-core/src/main/resources/org/silkframework/LinkSpecificationLanguage.xsd +++ b/silk-core/src/main/resources/org/silkframework/LinkSpecificationLanguage.xsd @@ -190,9 +190,11 @@ - - - + + + + + diff --git a/silk-plugins/silk-serialization-json/src/main/scala/org/silkframework/serialization/json/JsonSerializers.scala b/silk-plugins/silk-serialization-json/src/main/scala/org/silkframework/serialization/json/JsonSerializers.scala index 60229cd036..df430bfc27 100644 --- a/silk-plugins/silk-serialization-json/src/main/scala/org/silkframework/serialization/json/JsonSerializers.scala +++ b/silk-plugins/silk-serialization-json/src/main/scala/org/silkframework/serialization/json/JsonSerializers.scala @@ -30,6 +30,7 @@ import org.silkframework.workspace.annotation.{StickyNote, UiAnnotations} import org.silkframework.workspace.{LoadedTask, TaskLoadingError} import play.api.libs.json._ +import scala.collection.IndexedSeq import scala.reflect.ClassTag import scala.util.control.NonFatal @@ -458,18 +459,46 @@ object JsonSerializers { } } + implicit object NodePositionJsonFormat extends JsonFormat[NodePosition] { + + override def read(value: JsValue)(implicit readContext: ReadContext): NodePosition = { + value match { + case node: JsObject => + NodePosition( + x = numberValue(node , "x").toInt, + y = numberValue(node, "y").toInt, + width = numberValueOption(node, "width").map(_.toInt), + height = numberValueOption(node, "height").map(_.toInt) + ) + case JsArray(IndexedSeq(JsNumber(x), JsNumber(y))) => + NodePosition(x.toInt, y.toInt) + case _ => + throw JsonParseException("Invalid node position (must either be an array with two integers or an object): " + value) + } + } + + override def write(value: NodePosition)(implicit writeContext: WriteContext[JsValue]): JsValue = { + Json.obj( + "x" -> value.x, + "y" -> value.y, + "width" -> value.width, + "height" -> value.height + ) + } + } + /** Rule layout */ implicit object RuleLayoutJsonFormat extends JsonFormat[RuleLayout] { final val NODE_POSITIONS = "nodePositions" override def read(value: JsValue)(implicit readContext: ReadContext): RuleLayout = { - val nodePositions = JsonHelpers.fromJsonValidated[Map[String, (Int, Int)]](mustBeDefined(value, NODE_POSITIONS)) + val nodePositions = objectValue(value, NODE_POSITIONS).value.view.mapValues(NodePositionJsonFormat.read).toMap RuleLayout(nodePositions) } override def write(value: RuleLayout)(implicit writeContext: WriteContext[JsValue]): JsValue = { Json.obj( - NODE_POSITIONS -> Json.toJson(value.nodePositions) + NODE_POSITIONS -> JsObject(value.nodePositions.view.mapValues(NodePositionJsonFormat.write).toSeq) ) } } diff --git a/silk-plugins/silk-serialization-json/src/test/scala/JsonSerializersTest.scala b/silk-plugins/silk-serialization-json/src/test/scala/JsonSerializersTest.scala index 6034647982..d3ca6d92c2 100644 --- a/silk-plugins/silk-serialization-json/src/test/scala/JsonSerializersTest.scala +++ b/silk-plugins/silk-serialization-json/src/test/scala/JsonSerializersTest.scala @@ -2,7 +2,7 @@ import org.silkframework.dataset._ import org.silkframework.entity.ValueType import org.silkframework.rule.vocab._ -import org.silkframework.rule.{MappingTarget, RuleLayout} +import org.silkframework.rule.{MappingTarget, NodePosition, RuleLayout} import org.silkframework.runtime.activity.UserContext import org.silkframework.runtime.plugin.PluginRegistry import org.silkframework.runtime.serialization.{ReadContext, Serialization, TestReadContext, TestWriteContext, WriteContext} @@ -63,9 +63,10 @@ class JsonSerializersTest extends AnyFlatSpec with Matchers { "RuleLayout" should "be serializable to and from JSON" in { val layout = RuleLayout( Map( - "nodeA" -> (1, 2), - "nodeB" -> (3, 4), - "nodeC" -> (5, 6) + "nodeA" -> NodePosition(1, 2), + "nodeB" -> NodePosition(3, 4, Some(10), None), + "nodeC" -> NodePosition(5, 6, None, Some(10)), + "nodeD" -> NodePosition(7, 8, Some(100), Some(200)) ) ) testSerialization(layout) diff --git a/silk-rules/src/main/scala/org/silkframework/rule/RuleLayout.scala b/silk-rules/src/main/scala/org/silkframework/rule/RuleLayout.scala index 2386f99872..d4814442ef 100644 --- a/silk-rules/src/main/scala/org/silkframework/rule/RuleLayout.scala +++ b/silk-rules/src/main/scala/org/silkframework/rule/RuleLayout.scala @@ -6,9 +6,9 @@ import scala.xml.Node /** Rule layout data, i.e. how a linkage rule should be shown in a UI. * - * @param nodePositions The positions (x, y) of each rule node in the rule editor. + * @param nodePositions The position (x, y) and dimensions of each rule node in the rule editor. */ -case class RuleLayout(nodePositions: Map[String, (Int, Int)] = Map.empty) +case class RuleLayout(nodePositions: Map[String, NodePosition] = Map.empty) object RuleLayout { private def textToInt(text: String): Int = Math.round(text.toDouble).toInt @@ -19,7 +19,9 @@ object RuleLayout { val nodeId = (nodePos \ "@id").text val x = textToInt((nodePos \ "@x").text) val y = textToInt((nodePos \ "@y").text) - (nodeId, (x, y)) + val width = (nodePos \ "@width").headOption.map(n => textToInt(n.text)) + val height = (nodePos \ "@height").headOption.map(n => textToInt(n.text)) + (nodeId, NodePosition(x, y, width, height)) }) RuleLayout(positions.toMap) } @@ -27,11 +29,21 @@ object RuleLayout { override def write(value: RuleLayout)(implicit writeContext: WriteContext[Node]): Node = { - {value.nodePositions.map { case (nodeId, (x, y)) => - + { value.nodePositions.map { case (nodeId, pos) => + }} } } -} \ No newline at end of file +} + +/** + * Holds the position and the width of an operator. + * + * @param x The x coordinate + * @param y The y coordinate + * @param width An optional used-defined width. If not provided, the width should be determined by the UI. + * @param height An optional used-defined height. If not provided, the width should be determined by the UI. + */ +case class NodePosition(x: Int, y: Int, width: Option[Int] = None, height: Option[Int] = None) \ No newline at end of file diff --git a/silk-rules/src/test/scala/org/silkframework/rule/RuleLayoutTest.scala b/silk-rules/src/test/scala/org/silkframework/rule/RuleLayoutTest.scala index acab76d87d..cc97e7b2dc 100644 --- a/silk-rules/src/test/scala/org/silkframework/rule/RuleLayoutTest.scala +++ b/silk-rules/src/test/scala/org/silkframework/rule/RuleLayoutTest.scala @@ -1,20 +1,22 @@ -package org.silkframework.rule - +package org.silkframework.rule + + import org.silkframework.util.XmlSerializationHelperTrait import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.must.Matchers - -class RuleLayoutTest extends AnyFlatSpec with Matchers with XmlSerializationHelperTrait { - behavior of "Rule layout" - - it should "serialize and deserialize" in { - val layout = RuleLayout( - Map( - "nodeA" -> (1, 2), - "nodeB" -> (3, 4), - "nodeC" -> (5, 6) - ) - ) - testRoundTripSerialization(layout) - } -} +import org.scalatest.matchers.must.Matchers + +class RuleLayoutTest extends AnyFlatSpec with Matchers with XmlSerializationHelperTrait { + behavior of "Rule layout" + + it should "serialize and deserialize" in { + val layout = RuleLayout( + Map( + "nodeA" -> NodePosition(1, 2), + "nodeB" -> NodePosition(3, 4, Some(10), None), + "nodeC" -> NodePosition(5, 6, None, Some(10)), + "nodeD" -> NodePosition(7, 8, Some(100), Some(200)) + ) + ) + testRoundTripSerialization(layout) + } +} diff --git a/silk-workspace/src/test/scala/org/silkframework/workspace/WorkspaceProviderTestTrait.scala b/silk-workspace/src/test/scala/org/silkframework/workspace/WorkspaceProviderTestTrait.scala index ccde608ac3..47b7a497ab 100644 --- a/silk-workspace/src/test/scala/org/silkframework/workspace/WorkspaceProviderTestTrait.scala +++ b/silk-workspace/src/test/scala/org/silkframework/workspace/WorkspaceProviderTestTrait.scala @@ -84,7 +84,7 @@ trait WorkspaceProviderTestTrait extends AnyFlatSpec with Matchers with MockitoS inverseLinkType = Some("http://www.w3.org/2002/07/owl#sameAsInv"), excludeSelfReferences = true, layout = RuleLayout( - nodePositions = Map("compareNames" -> (1, 2)) + nodePositions = Map("compareNames" -> NodePosition(1, 2)) ), uiAnnotations = UiAnnotations( stickyNotes = Seq(StickyNote("compareNames", "content", "#fff", (0, 0), (1, 1))) @@ -147,9 +147,9 @@ trait WorkspaceProviderTestTrait extends AnyFlatSpec with Matchers with MockitoS target = Some(MappingTarget(Uri("urn:complex:target"))), layout = RuleLayout( nodePositions = Map( - "lower" -> (0, 1), - "concat" -> (3, 4), - "path" -> (5, 6) + "lower" -> NodePosition(0, 1), + "concat" -> NodePosition(3, 4, Some(250)), + "path" -> NodePosition(5, 6, Some(250), Some(300)) ) ), uiAnnotations = UiAnnotations( diff --git a/workspace/src/app/views/pages/MappingEditor/HierarchicalMapping/MappingEditorModal.tsx b/workspace/src/app/views/pages/MappingEditor/HierarchicalMapping/MappingEditorModal.tsx index 72a6aa976f..2a91910340 100644 --- a/workspace/src/app/views/pages/MappingEditor/HierarchicalMapping/MappingEditorModal.tsx +++ b/workspace/src/app/views/pages/MappingEditor/HierarchicalMapping/MappingEditorModal.tsx @@ -92,6 +92,9 @@ const MappingEditorModal = ({ size="fullscreen" preventSimpleClosing={unsavedChanges} onClose={onClose} + wrapperDivProps={{ + onMouseUp: () => {}, + }} headerOptions={ { ) => Map | Promise>; /** Returns for a path input plugin and a path the type of the given path. Returns undefined if either the plugin does not exist or the path data is unknown. */ inputPathPluginPathType?: (inputPathPluginId: string, path: string) => string | undefined; + /** allow the width and height of nodes to be adjustable */ + allowFlexibleSize?: boolean; } const READ_ONLY_QUERY_PARAMETER = "readOnly"; @@ -118,6 +120,7 @@ const RuleEditor = ({ instanceId, fetchDatasetCharacteristics, inputPathPluginPathType, + allowFlexibleSize, }: RuleEditorProps) => { // The task that contains the rule, e.g. transform or linking task const [taskData, setTaskData] = React.useState(undefined); @@ -278,6 +281,7 @@ const RuleEditor = ({ instanceId, datasetCharacteristics, inputPathPluginPathType, + allowFlexibleSize, }} > ; /** Returns for a path input plugin and a path the type of the given path. Returns undefined if either the plugin does not exist or the path data is unknown. */ inputPathPluginPathType?: (inputPathPluginId: string, path: string) => string | undefined; + /** allow the width of nodes to be adjustable */ + allowFlexibleSize?: boolean; } /** Creates a rule editor model context that contains the actual rule model and low-level update functions. */ @@ -99,4 +101,5 @@ export const RuleEditorContext = React.createContext({ instanceId: "uniqueId", datasetCharacteristics: new Map(), inputPathPluginPathType: () => undefined, + allowFlexibleSize: false, }); diff --git a/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx b/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx index 9e2cde145c..bb47341a32 100644 --- a/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx +++ b/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx @@ -1543,6 +1543,8 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => { updateNodeParameters: changeNodeParametersSingleTransaction, readOnlyMode: ruleEditorContext.readOnlyMode ?? false, languageFilterEnabled, + allowFlexibleSize: ruleEditorContext.allowFlexibleSize ?? false, + changeNodeSize: changeSize, }); /** Auto-layout the rule nodes. @@ -1622,6 +1624,7 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => { pluginType: originalNode.pluginType, portSpecification: originalNode.portSpecification, position: node.position, + dimension: node.data?.nodeDimensions, description: originalNode.description, inputsCanBeSwitched: originalNode.inputsCanBeSwitched, }; diff --git a/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.utils.tsx b/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.utils.tsx index 544d165765..4b9ef2111f 100644 --- a/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.utils.tsx +++ b/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.utils.tsx @@ -14,7 +14,7 @@ import { RuleEditorNode, RuleEditorNodeParameterValue } from "./RuleEditorModel. import { Connection, Elements, XYPosition } from "react-flow-renderer/dist/types"; import dagre from "dagre"; import { NodeContent, RuleNodeContentProps } from "../view/ruleNode/NodeContent"; -import { IconButton, NodeContentHandleProps } from "@eccenca/gui-elements"; +import { IconButton, NodeContentHandleProps, NodeContentProps } from "@eccenca/gui-elements"; import { RuleEditorEvaluationContextProps } from "../contexts/RuleEditorEvaluationContext"; import { LanguageFilterProps } from "../view/ruleNode/PathInputOperator"; @@ -72,6 +72,10 @@ export interface IOperatorCreateContext { readOnlyMode: boolean; /** If for this operator there is a language filter supported. Currently only path operators are affected by this option. */ languageFilterEnabled: (nodeId: string) => LanguageFilterProps | undefined; + /** allow the width of nodes to be adjustable */ + allowFlexibleSize?: boolean; + /** change node size */ + changeNodeSize: (nodeId: string, newNodeDimensions: NodeContentProps["nodeDimensions"]) => void; } /** Creates a new react-flow rule operator node. */ @@ -117,7 +121,7 @@ function createOperatorNode( ); const type = nodeType(node.pluginType, node.pluginId); - const data: NodeContentPropsWithBusinessData = { + let data: NodeContentPropsWithBusinessData = { size: "medium", label: node.label, minimalShape: "none", @@ -161,6 +165,18 @@ function createOperatorNode( : undefined, }; + if (operatorContext.allowFlexibleSize) { + data = { + ...data, + onNodeResize: (data) => operatorContext.changeNodeSize(node.nodeId, data), + resizeDirections: { right: true }, + resizeMaxDimensions: { width: 1400 }, + nodeDimensions: { + width: node.dimension?.width ?? undefined, + } as NodeContentProps["nodeDimensions"], + }; + } + return { id: node.nodeId, type, diff --git a/workspace/src/app/views/taskViews/linking/LinkingRuleEditor.tsx b/workspace/src/app/views/taskViews/linking/LinkingRuleEditor.tsx index ef122a052a..c33623d166 100644 --- a/workspace/src/app/views/taskViews/linking/LinkingRuleEditor.tsx +++ b/workspace/src/app/views/taskViews/linking/LinkingRuleEditor.tsx @@ -419,6 +419,7 @@ export const LinkingRuleEditor = ({ projectId, linkingTaskId, viewActions, insta instanceId={instanceId} fetchDatasetCharacteristics={fetchDatasetCharacteristics} inputPathPluginPathType={inputPathPluginPathType} + allowFlexibleSize /> ); diff --git a/workspace/src/app/views/taskViews/linking/LinkingRuleEditor.utils.ts b/workspace/src/app/views/taskViews/linking/LinkingRuleEditor.utils.ts index 5bfebc0ec2..cd6575433f 100644 --- a/workspace/src/app/views/taskViews/linking/LinkingRuleEditor.utils.ts +++ b/workspace/src/app/views/taskViews/linking/LinkingRuleEditor.utils.ts @@ -132,8 +132,11 @@ const convertLinkingRuleToRuleOperatorNodes = ( extractSimilarityOperatorNode(linkRule.operator, operatorNodes, ruleOperator); const nodePositions = linkRule.layout.nodePositions; operatorNodes.forEach((node) => { - const [x, y] = nodePositions[node.nodeId] ?? [null, null]; - node.position = x !== null ? { x, y } : undefined; + const { x, y, width } = nodePositions[node.nodeId] ?? { x: null, y: null }; + if (x !== null) { + node.position = { x, y }; + node.dimension = { width, height: null }; + } }); return operatorNodes; }; diff --git a/workspace/src/app/views/taskViews/shared/rules/rule.typings.ts b/workspace/src/app/views/taskViews/shared/rules/rule.typings.ts index ba2d450e47..4681ca61d8 100644 --- a/workspace/src/app/views/taskViews/shared/rules/rule.typings.ts +++ b/workspace/src/app/views/taskViews/shared/rules/rule.typings.ts @@ -42,7 +42,12 @@ export interface IOperatorNodeParameters { /** Rule layout information. */ export interface RuleLayout { nodePositions: { - [nodeId: string]: [number, number]; + [nodeId: string]: { + x: number; + y: number; + width: number | null; + height: number | null; + }; }; } diff --git a/workspace/src/app/views/taskViews/shared/rules/rule.utils.tsx b/workspace/src/app/views/taskViews/shared/rules/rule.utils.tsx index cd8e879185..119bc8c8ed 100644 --- a/workspace/src/app/views/taskViews/shared/rules/rule.utils.tsx +++ b/workspace/src/app/views/taskViews/shared/rules/rule.utils.tsx @@ -466,10 +466,16 @@ const findCycles = ( /** Extract rule layout from rule operator nodes. */ const ruleLayout = (nodes: IRuleOperatorNode[]): RuleLayout => { - const nodePositions: { [key: string]: [number, number] } = Object.create(null); + const nodePositions: RuleLayout["nodePositions"] = Object.create(null); nodes.forEach((node) => { if (node.position) { - nodePositions[node.nodeId] = [Math.round(node.position.x), Math.round(node.position.y)]; + // nodePositions[node.nodeId] = [Math.round(node.position.x), Math.round(node.position.y)]; + nodePositions[node.nodeId] = { + x: Math.round(node.position.x), + y: Math.round(node.position.y), + width: node.dimension?.width || null, + height: null, + }; } }); return { diff --git a/workspace/src/app/views/taskViews/transform/TransformRuleEditor.tsx b/workspace/src/app/views/taskViews/transform/TransformRuleEditor.tsx index 16184a75ee..41acec893d 100644 --- a/workspace/src/app/views/taskViews/transform/TransformRuleEditor.tsx +++ b/workspace/src/app/views/taskViews/transform/TransformRuleEditor.tsx @@ -158,8 +158,13 @@ export const TransformRuleEditor = ({ const pos = nodePositions[node.nodeId]; if (pos) { node.position = { - x: pos[0], - y: pos[1], + x: pos.x, + y: pos.y, + }; + node.dimension = { + ...node.dimension, + width: pos.width, + height: null, }; } }); @@ -265,6 +270,7 @@ export const TransformRuleEditor = ({ getStickyNotes={getStickyNotes} additionalRuleOperators={[sourcePathInput()]} validateConnection={ruleUtils.validateConnection} + allowFlexibleSize tabs={tabs} showRuleOnly={false} initialFitToViewZoomLevel={initialFitToViewZoomLevel}