Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional width to node layout. #831

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,11 @@

<xs:element name="NodePos">
<xs:complexType>
<xs:attribute name="id" type="xs:string" />
<xs:attribute name="x" type="xs:string" />
<xs:attribute name="y" type="xs:string" />
<xs:attribute name="id" type="xs:string" use="required" />
<xs:attribute name="x" type="xs:string" use="required" />
<xs:attribute name="y" type="xs:string" use="required" />
<xs:attribute name="width" type="xs:string" />
<xs:attribute name="height" type="xs:string" />
</xs:complexType>
</xs:element>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 18 additions & 6 deletions silk-rules/src/main/scala/org/silkframework/rule/RuleLayout.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,19 +19,31 @@ 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)
}

override def write(value: RuleLayout)(implicit writeContext: WriteContext[Node]): Node = {
<RuleLayout>
<NodePositions>
{value.nodePositions.map { case (nodeId, (x, y)) =>
<NodePos id={nodeId} x={x.toString} y={y.toString} />
{ value.nodePositions.map { case (nodeId, pos) =>
<NodePos id={nodeId} x={pos.x.toString} y={pos.y.toString} width={pos.width.map(_.toString).orNull} height={pos.height.map(_.toString).orNull} />
}}
</NodePositions>
</RuleLayout>
}
}
}
}

/**
* 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)
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ const MappingEditorModal = ({
size="fullscreen"
preventSimpleClosing={unsavedChanges}
onClose={onClose}
wrapperDivProps={{
onMouseUp: () => {},
}}
headerOptions={
<IconButton
name="navigation-close"
Expand Down
8 changes: 6 additions & 2 deletions workspace/src/app/views/shared/RuleEditor/RuleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import utils from "./RuleEditor.utils";
import { IStickyNote } from "views/taskViews/shared/task.typings";
import { DatasetCharacteristics } from "../typings";
import { ReactFlowHotkeyContext } from "@eccenca/gui-elements/src/cmem/react-flow/extensions/ReactFlowHotkeyContext";
import {Notification} from "@eccenca/gui-elements"
import {diErrorMessage} from "@ducks/error/typings";
import { Notification } from "@eccenca/gui-elements";
import { diErrorMessage } from "@ducks/error/typings";

/** Function to fetch the rule operator spec. */
export type RuleOperatorFetchFnType = (
Expand Down Expand Up @@ -89,6 +89,8 @@ export interface RuleEditorProps<RULE_TYPE, OPERATOR_TYPE> {
) => Map<string, DatasetCharacteristics> | Promise<Map<string, DatasetCharacteristics>>;
/** 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";
Expand Down Expand Up @@ -118,6 +120,7 @@ const RuleEditor = <TASK_TYPE extends object, OPERATOR_TYPE extends object>({
instanceId,
fetchDatasetCharacteristics,
inputPathPluginPathType,
allowFlexibleSize,
}: RuleEditorProps<TASK_TYPE, OPERATOR_TYPE>) => {
// The task that contains the rule, e.g. transform or linking task
const [taskData, setTaskData] = React.useState<TASK_TYPE | undefined>(undefined);
Expand Down Expand Up @@ -278,6 +281,7 @@ const RuleEditor = <TASK_TYPE extends object, OPERATOR_TYPE extends object>({
instanceId,
datasetCharacteristics,
inputPathPluginPathType,
allowFlexibleSize,
}}
>
<ReactFlowHotkeyContext.Provider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface IRuleOperatorNode extends IRuleOperatorBase {
parameters: RuleOperatorNodeParameters;
/** The position on the canvas. */
position?: NodePosition;
/** The node size */
dimension?: NodeDimension;
/** The input node IDs. */
inputs: (string | undefined)[];
/** Tags that will be displayed inside the node. */
Expand Down Expand Up @@ -129,6 +131,11 @@ interface NodePosition {
y: number;
}

interface NodeDimension {
width: number | null;
height: number | null;
}

export interface RuleOperatorNodeParameters {
[parameterKey: string]: RuleEditorNodeParameterValue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export interface RuleEditorContextProps {
datasetCharacteristics: Map<string, DatasetCharacteristics>;
/** 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. */
Expand All @@ -99,4 +101,5 @@ export const RuleEditorContext = React.createContext<RuleEditorContextProps>({
instanceId: "uniqueId",
datasetCharacteristics: new Map(),
inputPathPluginPathType: () => undefined,
allowFlexibleSize: false,
});
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,8 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => {
updateNodeParameters: changeNodeParametersSingleTransaction,
readOnlyMode: ruleEditorContext.readOnlyMode ?? false,
languageFilterEnabled,
allowFlexibleSize: ruleEditorContext.allowFlexibleSize ?? false,
changeNodeSize: changeSize,
});

/** Auto-layout the rule nodes.
Expand Down Expand Up @@ -1613,6 +1615,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,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { IHandleProps } from "@eccenca/gui-elements/src/extensions/react-flow/nodes/NodeContent";
import { IHandleProps, NodeDimensions } from "@eccenca/gui-elements/src/extensions/react-flow/nodes/NodeContent";
import { ArrowHeadType, Edge, FlowElement, Position } from "react-flow-renderer";
import { rangeArray } from "../../../../utils/basicUtils";
import {
Expand Down Expand Up @@ -73,6 +73,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: NodeDimensions) => void;
}

/** Creates a new react-flow rule operator node. */
Expand Down Expand Up @@ -118,7 +122,7 @@ function createOperatorNode(
);
const type = nodeType(node.pluginType, node.pluginId);

const data: NodeContentPropsWithBusinessData<IRuleNodeData> = {
let data: NodeContentPropsWithBusinessData<IRuleNodeData> = {
size: "medium",
label: node.label,
minimalShape: "none",
Expand Down Expand Up @@ -162,6 +166,19 @@ function createOperatorNode(
: undefined,
};

if (operatorContext.allowFlexibleSize) {
data = {
...data,
onNodeResize: (data) => operatorContext.changeNodeSize(node.nodeId, data),
resizeDirections: { right: true },
resizeMaxDimensions: { width: 1400 },
};
}

if (node.dimension?.width) {
data = { ...data, nodeDimensions: node.dimension as NodeDimensions };
}

return {
id: node.nodeId,
type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
}

Expand Down
Loading