diff --git a/samples/spring-flo-sample/README.md b/samples/spring-flo-sample/README.md new file mode 100644 index 0000000..6995b7b --- /dev/null +++ b/samples/spring-flo-sample/README.md @@ -0,0 +1,62 @@ +# A sample app for spring-flo + +This is a very basic example of spring-flo usage. + +# Running the sample + +A basic Spring Boot app is used to serve the sample. Launch it with: + + mvn spring-boot:run + +then open http://localhost:8080 then you can type text into the box +(e.g. "filewatch > ftp") or drag elements from the palette and link them +together to build a pipeline. + +# Understanding the sample + +Spring Flo allows simultaneous synchronization between some textual domain +specific language (DSL) and the graphical representation. You can type in the +text version or you can interactively build the graph version. Any custom +usage of spring-flo therefore needs three things: + +- a metamodel of the domain. What are the 'things' you are connecting in your + pipeline and what are their properties? +- a converter from the text form to the graph form. +- a converter from the graph form to the text form. + +The same here includes a very basic domain that you can easily customize: + +- there are a handful of nodes with basic properties. These are defined in + `metamodel-sample.json` - that is a JSON array of the modules supported. +- the DSL form is `module --key=value --key=value | nextModule --key=value`. +- the conversion from text to graph is done in `text-to-graph.js` +- the conversion from graph to text is done in `graph-to-text.js` + +Note the convertors (and the DSL) do not describe or support linking multiple nodes +to a single node, but spring-flo supports that, you just need to enhance the +convertors and have a representation in the DSL. + +If you wanted to use this sample in your domain, perhaps you would enhance `index.html` +to include some kind of 'execute' button that used the text representation to +drive some other (perhaps backend) API. + +The included `index.html` has basic buttons that exercise some of the control features +of flo - for example turning off the palette and switching it to a read-only mode (for +use when embedding flo into something that should just be showing a read only view of a +pipeline). + +# Navigating the sample + +In order to keep the project to a single build file, spring-flo is referenced +through the maven pom using webjars dependencies. + +- `index.html` the single HTML file that embeds sping-flo +- `main.js` the launch config file for requirejs, referenced from index.html +- `sample-app.js` setup the angular app with custom services +- `metamodel-service.js` the custom metamodel service for this usage of spring-flo +- `editor-service.js` the custom editor service for this usage of spring-flo +- `render-service.js` the custom render service for this usage of spring-flo +- `metamodel-sample.json` a basic json description of the domain +- `graph-to-text.js` convert from the graph form to our custom DSL text +- `text-to-graph.js` convert from the custom DSL text to our graph form + diff --git a/samples/spring-flo-sample/pom.xml b/samples/spring-flo-sample/pom.xml new file mode 100755 index 0000000..e19da96 --- /dev/null +++ b/samples/spring-flo-sample/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + org.springframework + spring-flo-sample + 0.0.1.BUILD-SNAPSHOT + jar + + spring-flo-sample + Spring Flo Sample + + + org.springframework.boot + spring-boot-starter-parent + 1.3.2.RELEASE + + + + + + + + org.webjars.bower + codemirror + 5.1.0 + + + org.webjars.bower + angular + 1.3.8 + + + org.webjars.bower + jshint + 2.8.0 + + + org.webjars.bower + requirejs + 2.1.18 + + + org.webjars.bower + jquery + 2.2.0 + + + org.webjars.bower + lodash + 3.10.1 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.webjars + webjars-locator + 0.31 + + + org.webjars.bower + requirejs-domready + 2.0.1 + + + org.webjars.bower + requirejs-text + 2.0.15 + + + org.webjars.bower + jointjs + 0.9.7 + + + org.webjars.bower + json5 + 0.4.0 + + + org.webjars.bower + spring-flo + 0.5.0 + + + org.webjars.bower + joint + + + + + + + UTF-8 + org.springframework.flo.Application + 1.7 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/samples/spring-flo-sample/src/main/java/org/springframework/flo/Application.java b/samples/spring-flo-sample/src/main/java/org/springframework/flo/Application.java new file mode 100755 index 0000000..2497283 --- /dev/null +++ b/samples/spring-flo-sample/src/main/java/org/springframework/flo/Application.java @@ -0,0 +1,33 @@ +/* + * Copyright 2016 the original author or 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.springframework.flo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * + * @author Andy Clement + * @author Alex Boyko + */ +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/samples/spring-flo-sample/src/main/resources/application.properties b/samples/spring-flo-sample/src/main/resources/application.properties new file mode 100644 index 0000000..e69de29 diff --git a/samples/spring-flo-sample/src/main/resources/static/css/sample.css b/samples/spring-flo-sample/src/main/resources/static/css/sample.css new file mode 100644 index 0000000..5127332 --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/css/sample.css @@ -0,0 +1,141 @@ + +.header { + font-weight: 400; + font-family: "Varela Round",sans-serif; + font-size: 36px; + color: #eeeeee; + padding: 2px; + background-color: #34302d; + border: none; + border-top: 4px solid #6db33f; + z-index: 1; +} + +body { + background-color: #eeeeee; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -o-user-select: none; + user-select: none; +} + +.control-button { + width: 16px; + height: 16px; +} + +.header-small { + font: 300 24px "Helvetica Neue"; +} + +pre { + font-size: 18px; +} + +.border-selected { + stroke: #34302d; + stroke-width: 3; +} + +.controls { + border-radius: 2px; + border: solid; + border-color: #6db33f; + padding: 5px; + margin-top: 3px; + background-color: #eeeeee; + border-width: 1px; +} + +.button { + background-color: #34302d; + background-image: none; + border-radius: 2px; + color: #f1f1f1; + font-size: 14px; + line-height: 14px; + font-family: Montserrat,sans-serif; + border: 2px solid #6db33f; + padding: 5px 20px; + text-shadow: none; +} + +.button span { + background-color: #34302d; + background-image: none; + border-radius: 2px; + color: #f1f1f1; + font-size: 14px; + line-height: 14px; + font-family: Montserrat,sans-serif; + border: 2px solid #6db33f; + padding: 5px 20px; + text-shadow: none; +} + +.button input { + background-color: #34302d; + background-image: none; + color: #f1f1f1; + font-size: 14px; + font-family: Montserrat,sans-serif; + text-shadow: none; + border: 0px; + text-align:right; +} + +button.on { + background-color: #5fa134; +} + +.flow-definition-container { + border: 1px solid; + border-color: #6db33f; + border-radius: 2px; + margin-top: 3px; + background-color: #ffffff; + font-family: monospace; + z-index: 2; + width:100%; + height:100px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +textarea:input { + outline: none; + border: 1px solid #6db33f; +} + +textarea:input:focus { + outline: none; + border: 1px solid #000000; +} + +.flow-definition { + border: 5px; + height:100%; + width:100%; + font-size: 16px; + resize: none; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +/* The text label on the nodes */ +.label { + font-family: 'Lucida Console'; + font-size: 12px; +} + +/* The class for the 'icon/unicode_char' on the nodes */ +.label2 { + font-size: 18px; +} + +[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { + display: none !important; +} diff --git a/samples/spring-flo-sample/src/main/resources/static/icons/delete.svg b/samples/spring-flo-sample/src/main/resources/static/icons/delete.svg new file mode 100644 index 0000000..5b3f988 --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/icons/delete.svg @@ -0,0 +1,4 @@ + + + + diff --git a/samples/spring-flo-sample/src/main/resources/static/icons/error.svg b/samples/spring-flo-sample/src/main/resources/static/icons/error.svg new file mode 100644 index 0000000..c6c8388 --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/icons/error.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/samples/spring-flo-sample/src/main/resources/static/icons/rotate.svg b/samples/spring-flo-sample/src/main/resources/static/icons/rotate.svg new file mode 100644 index 0000000..9248e1b --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/icons/rotate.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/samples/spring-flo-sample/src/main/resources/static/index.html b/samples/spring-flo-sample/src/main/resources/static/index.html new file mode 100755 index 0000000..f1a4ef4 --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/index.html @@ -0,0 +1,51 @@ + + + + Spring Flo Sample + + + + + + + + +
+ +
+ + + + + + + + + + + + + + +
+
+ + +
+
+
+ + diff --git a/samples/spring-flo-sample/src/main/resources/static/js/editor-service.js b/samples/spring-flo-sample/src/main/resources/static/js/editor-service.js new file mode 100644 index 0000000..9aaf82a --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/js/editor-service.js @@ -0,0 +1,505 @@ +/* + * Copyright 2016 the original author or 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. + */ + +/** + * @author Alex Boyko + * @author Andy Clement + */ +define(function(require) { + 'use strict'; + + var joint = require('joint'); + + return ['$log', function($log) { + + function createHandles(flo, createHandle, element) { + var bbox = element.getBBox(); + var pt = bbox.origin().offset(bbox.width + 3, bbox.height + 3); + createHandle(element, 'remove', flo.deleteSelectedNode, pt); + } + + function validatePort(/*flo, cellView, magnet*/) { + return true; + } + + function validateLink(flo, cellViewS, magnetS, cellViewT, magnetT/*, end, linkView*/) { + // Prevent linking from input ports. + if (magnetS && magnetS.getAttribute('type') === 'input') { + return false; + } + // Prevent linking from output ports to input ports within one element. + if (cellViewS === cellViewT) { + return false; + } + // Prevent linking to input ports. + if (magnetT && magnetT.getAttribute('type') === 'output') { + return false; + } + return cellViewS.model && cellViewT.model && !(cellViewS.model instanceof joint.shapes.flo.ErrorDecoration) && !(cellViewT.model instanceof joint.shapes.flo.ErrorDecoration); + } + + function preDelete(flo, cell) { + repairDamage(flo, cell); + } + + function handleNodeDropping(flo, dragDescriptor) { + var relinking = dragDescriptor.context && dragDescriptor.context.palette; + var graph = flo.getGraph(); + var source = dragDescriptor.source ? dragDescriptor.source.cell : undefined; + var target = dragDescriptor.target ? dragDescriptor.target.cell : undefined; + if (target instanceof joint.dia.Element && target.attr('metadata/name')) { + var type = source.attr('metadata/name'); + if (type === 'tap') { + // Fill in the channel on the new node + // work out tap name, something like: tap:stream:mystream.filter (filter optional if on head of stream) + // 1. work your way back from node that was dropped on to the first node (the one with no further links) + var oncell = target; + var headerCell = oncell; + var incomingLinks = graph.getConnectedLinks(oncell, { inbound: true }); + while (incomingLinks.length !== 0) { + headerCell = graph.getCell(incomingLinks[0].get('source').id); + incomingLinks = graph.getConnectedLinks(headerCell, { inbound: true }); + } + var streamname = 'STREAM'; + if (headerCell.attr('stream-name')) { + streamname = headerCell.attr('stream-name'); + } + else { + var streamId = headerCell.attr('stream-id'); + if (streamId) { + streamname = 'STREAM'+streamId; + } else { + streamname = 'STREAM'; + } + headerCell.attr('stream-name',streamname); + } + var channel = 'tap:stream:'+streamname; + var label2 = 'tap:stream:\n' + streamname; + if (oncell.id !== headerCell.id) { + channel = channel + '.' + oncell.attr('.label/text'); + label2 = label2 + '.' + oncell.attr('.label/text'); + } + source.attr('.label2/text', label2); + source.attr('props/channel', channel); + relinking = true; + } else { + if (dragDescriptor.target.selector === '.output-port') { + moveNodeOnNode(flo, source, target, 'right', true); + relinking = true; + } else if (dragDescriptor.target.selector === '.input-port') { + moveNodeOnNode(flo, source, target, 'left', true); + relinking = true; + } + } + } else if (target instanceof joint.dia.Link) { // jshint ignore:line + moveNodeOnLink(flo, source, target); + relinking = true; + } + // Turn off auto layout +// if (relinking) { +// flo.performLayout(); +// } + } + + function calculateDragDescriptor(flo, draggedView, targetUnderMouse, point, context) { + // check if it's a tap being dragged + var source = draggedView.model; + if ((targetUnderMouse instanceof joint.dia.Element) && source.attr('metadata/name') === 'tap') { // jshint ignore:line + return { + context: context, + source: { + cell: draggedView.model, + }, + target: { + cell: targetUnderMouse, + } + }; + } + + // Find closest port + var range = 30; + var graph = flo.getGraph(); + var paper = flo.getPaper(); + var closestData; + var minDistance = Number.MAX_VALUE; + var maxIcomingLinks = draggedView.model.attr('metadata/constraints/maxIncomingLinksNumber'); + var maxOutgoingLinks = draggedView.model.attr('metadata/constraints/maxOutgoingLinksNumber'); + var hasIncomingPort = typeof maxIcomingLinks !== 'number' || maxIcomingLinks > 0; + var hasOutgoingPort = typeof maxOutgoingLinks !== 'number' || maxOutgoingLinks > 0; + if (!hasIncomingPort && !hasOutgoingPort) { + return; + } + var elements = graph.findModelsInArea(joint.g.rect(point.x - range, point.y - range, 2 * range, 2 * range)); // jshint ignore:line + if (Array.isArray(elements)) { + elements.forEach(function(model) { + var view = paper.findViewByModel(model); + if (view && view !== draggedView && model instanceof joint.dia.Element) { // jshint ignore:line + var targetMaxIcomingLinks = view.model.attr('metadata/constraints/maxIncomingLinksNumber'); + var targetMaxOutgoingLinks = view.model.attr('metadata/constraints/maxOutgoingLinksNumber'); + var targetHasIncomingPort = typeof targetMaxIcomingLinks !== 'number' || targetMaxIcomingLinks > 0; + var targetHasOutgoingPort = typeof targetMaxOutgoingLinks !== 'number' || targetMaxOutgoingLinks > 0; + if (view.model.attr('metadata/constraints/xorSourceSink')) { + if (targetHasIncomingPort) { + targetHasIncomingPort = targetHasIncomingPort && graph.getConnectedLinks(view.model, { outbound: true }).length === 0; + } + if (targetHasOutgoingPort) { + targetHasOutgoingPort = targetHasOutgoingPort && graph.getConnectedLinks(view.model, { inbound: true }).length === 0; + } + } + if (draggedView.model.attr('metadata/constraints/xorSourceSink')) { + if (hasIncomingPort) { + targetHasOutgoingPort = targetHasOutgoingPort && graph.getConnectedLinks(view.model, { outbound: true }).length === 0; + } + if (hasOutgoingPort) { + targetHasIncomingPort = targetHasIncomingPort && graph.getConnectedLinks(view.model, { inbound: true }).length === 0; + } + } + view.$('[magnet]').each(function(index, magnet) { + var type = magnet.getAttribute('type'); + if ((type === 'input' && targetHasIncomingPort && hasOutgoingPort) || (type === 'output' && targetHasOutgoingPort && hasIncomingPort)) { + var bbox = joint.V(magnet).bbox(false, paper.viewport); // jshint ignore:line + var distance = point.distance({ + x: bbox.x + bbox.width / 2, + y: bbox.y + bbox.height / 2 + }); + if (distance < range && distance < minDistance) { + minDistance = distance; + closestData = { + context: context, + source: { + cell: draggedView.model, + selector: type === 'output' ? '.input-port' : '.output-port' + }, + target: { + cell: model, + selector: '.' + type+'-port' + }, + range: minDistance + }; + } + } + }); + } + }); + } + if (closestData) { + return closestData; + } + + // Check if drop on a link is allowed + if (targetUnderMouse instanceof joint.dia.Link && !(source.attr('metadata/constraints/xorSourceSink') || source.attr('metadata/constraints/maxOutgoingLinksNumber') === 0 || source.attr('metadata/constraints/maxIncomingLinksNumber') === 0) && graph.getConnectedLinks(source).length === 0) { // jshint ignore:line + return { + context: context, + source: { + cell: source + }, + target: { + cell: targetUnderMouse + } + }; + } + + return { + context: context, + source: { + cell: source + }, + }; + } + + function validateNode(flo, element) { + var errors = []; + var graph = flo.getGraph(); + var constraints = element.attr('metadata/constraints'); + if (constraints) { + var incoming = graph.getConnectedLinks(element, {inbound: true}); + var outgoing = graph.getConnectedLinks(element, {outbound: true}); + if (typeof constraints.maxIncomingLinksNumber === 'number' || typeof constraints.minIncomingLinksNumber === 'number') { + if (typeof constraints.maxIncomingLinksNumber === 'number' && constraints.maxIncomingLinksNumber < incoming.length) { + if (constraints.maxIncomingLinksNumber === 0) { + errors.push({ + message: 'Sources must appear at the start of a stream', + range: element.attr('range') + }); + } else { + errors.push({ + message: 'Max allowed number of incoming links is ' + constraints.maxIncomingLinksNumber, + range: element.attr('range') + }); + } + } + if (typeof constraints.minIncomingLinksNumber === 'number' && constraints.minIncomingLinksNumber > incoming.length) { + errors.push({ + message: 'Min allowed number of incoming links is ' + constraints.minIncomingLinksNumber, + range: element.attr('range') + }); + } + } + if (typeof constraints.maxOutgoingLinksNumber === 'number' || typeof constraints.minOutgoingLinksNumber === 'number') { + if (typeof constraints.maxOutgoingLinksNumber === 'number' && constraints.maxOutgoingLinksNumber < outgoing.length) { + if (constraints.maxOutgoingLinksNumber === 0) { + errors.push({ + message: 'Sinks must appear at the end of a stream', + range: element.attr('range') + }); + } else { + errors.push({ + message: 'Max allowed number of outgoing links is ' + constraints.maxOutgoingLinksNumber, + range: element.attr('range') + }); + } + } + if (typeof constraints.minOutgoingLinksNumber === 'number' && constraints.minOutgoingLinksNumber > outgoing.length) { + errors.push({ + message: 'Min allowed number of outgoing links is ' + constraints.minOutgoingLinksNumber, + range: element.attr('range') + }); + } + } + if (constraints.xorSourceSink && incoming.length && outgoing.length) { + errors.push({ + message: 'Node can either have incoming or outgoing links, but not both', + range: element.attr('range') + }); + } + } + if (!element.attr('metadata') || element.attr('metadata/unresolved')) { + var msg = 'Unknown element \'' + element.attr('metadata/name') + '\''; + if (element.attr('metadata/group')) { + msg += ' from group \'' + element.attr('metadata/group') + '\'.'; + } + errors.push({ + message: msg, + range: element.attr('range') + }); + } + + // If possible, verify the properties specified match those allowed on this type of element + // propertiesRanges are the ranges for each property included the entire '--name=value'. + // The format of a range is {'start':{'ch':NNNN,'line':NNNN},'end':{'ch':NNNN,'line':NNNN}} + var propertiesRanges = element.attr('propertiesranges'); + if (propertiesRanges) { + var moduleSchema = element.attr('metadata'); + // Grab the list of supported properties for this module type + moduleSchema.get('properties').then(function(moduleSchemaProperties) { + if (!moduleSchemaProperties) { + moduleSchemaProperties = {}; + } + // Example moduleSchemaProperties: + // {"host":{"name":"host","type":"String","description":"the hostname of the mail server","defaultValue":"localhost","hidden":false}, + // "password":{"name":"password","type":"String","description":"the password to use to connect to the mail server ","defaultValue":null,"hidden":false} + var specifiedProperties = element.attr('props'); + Object.keys(specifiedProperties).forEach(function(propertyName) { + if (!moduleSchemaProperties[propertyName]) { + // The schema does not mention that property + var propertyRange = propertiesRanges[propertyName]; + if (propertyRange) { + errors.push({ + message: 'unrecognized option \''+propertyName+'\' for module \''+element.attr('metadata/name')+'\'', + range: propertyRange + }); + } + } + }); + }); + } + + return errors; + } + + function moveNodeOnNode(flo, node, pivotNode, side, shouldRepairDamage) { + side = side || 'left'; + if (canSwap(flo, node, pivotNode, side)) { + var link; + var i; + if (side === 'left') { + var sources = []; + if (shouldRepairDamage) { + /* + * Commented out because it doesn't prevent cycles. + */ +// if (graph.getConnectedLinks(pivotNode, {inbound: true}).length > 0 || graph.getConnectedLinks(node, {outbound: true}).length > 0) { + repairDamage(flo, node); +// } + } + var pivotTargetLinks = flo.getGraph().getConnectedLinks(pivotNode, {inbound: true}); + for (i = 0; i < pivotTargetLinks.length; i++) { + link = pivotTargetLinks[i]; + sources.push(link.get('source').id); + link.remove(); + } + for (i = 0; i < sources.length; i++) { + flo.createLink({ + 'id': sources[i], + 'selector': '.output-port' + }, { + 'id': node.id, + 'selector': '.input-port' + }); + } + flo.createLink({ + 'id': node.id, + 'selector': '.output-port' + }, { + 'id': pivotNode.id, + 'selector': '.input-port' + }); + } else if (side === 'right') { + var targets = []; + if (shouldRepairDamage) { + /* + * Commented out because it doesn't prevent cycles. + */ +// if (graph.getConnectedLinks(pivotNode, {outbound: true}).length > 0 || graph.getConnectedLinks(node, {inbound: true}).length > 0) { + repairDamage(flo, node); +// } + } + var pivotSourceLinks = flo.getGraph().getConnectedLinks(pivotNode, {outbound: true}); + for (i = 0; i < pivotSourceLinks.length; i++) { + link = pivotSourceLinks[i]; + targets.push(link.get('target').id); + link.remove(); + } + for (i = 0; i < targets.length; i++) { + flo.createLink({ + 'id': node.id, + 'selector': '.output-port' + }, { + 'id': targets[i], + 'selector': '.input-port' + }); + } + flo.createLink({ + 'id': pivotNode.id, + 'selector': '.output-port' + }, { + 'id': node.id, + 'selector': '.input-port' + }); + } + } + } + + function moveNodeOnLink(flo, node, link, shouldRepairDamage) { + var source = link.get('source').id; + var target = link.get('target').id; + + if (shouldRepairDamage) { + repairDamage(flo, node); + } + link.remove(); + + if (source) { + flo.createLink({ + 'id': source, + 'selector': '.output-port' + }, { + 'id': node.id, + 'selector': '.input-port' + }); + } + if (target) { + flo.createLink({ + 'id': node.id, + 'selector': '.output-port' + }, { + 'id': target, + 'selector': '.input-port' + }); + } + } + + function repairDamage(flo, node) { + /* + * remove incoming, outgoing links and cache their sources and targets not equal to current node + */ + var sources = []; + var targets = []; + var i = 0; + var links = flo.getGraph().getConnectedLinks(node); + for (i = 0; i < links.length; i++) { + var targetId = links[i].get('target').id; + var sourceId = links[i].get('source').id; + if (targetId === node.id) { + links[i].remove(); + sources.push(sourceId); + } else if (sourceId === node.id) { + links[i].remove(); + targets.push(targetId); + } + } + /* + * best attempt to connect source and targets bypassing the node + */ + if (sources.length === 1) { + var source = sources[0]; + for (i = 0; i < targets.length; i++) { + flo.createLink({ + 'id': source, + 'selector': '.output-port' + }, { + 'id': targets[i], + 'selector': '.input-port' + }); + } + } else if (targets.length === 1) { + var target = targets[0]; + for (i = 0; i < sources.length; i++) { + flo.createLink({ + 'id': sources[i], + 'selector': '.output-port' + }, { + 'id': target, + 'selector': '.input-port' + }); + } + } + } + + /** + * Check if node being dropped and drop target node next to each other such that they won't be swapped by the drop + */ + function canSwap(flo, dropee, target, side) { + var i, targetId, sourceId, noSwap = (dropee.id === target.id); + if (dropee === target) { + $log.debug('What!??? Dragged == Dropped!!! id = ' + target); + } + var links = flo.getGraph().getConnectedLinks(dropee); + for (i = 0; i < links.length && !noSwap; i++) { + targetId = links[i].get('target').id; + sourceId = links[i].get('source').id; + noSwap = (side === 'left' && targetId === target.id && sourceId === dropee.id) || (side === 'right' && targetId === dropee.id && sourceId === target.id); + } + return !noSwap; + } + + return { + 'createHandles': createHandles, + 'validatePort': validatePort, + 'validateLink': validateLink, + 'calculateDragDescriptor': calculateDragDescriptor, + 'handleNodeDropping': handleNodeDropping, + 'validateNode': validateNode, + 'preDelete': preDelete, + 'interactive': { + 'vertexAdd': false + }, + 'allowLinkVertexEdit': false + }; + + }]; + +}); diff --git a/samples/spring-flo-sample/src/main/resources/static/js/graph-to-text.js b/samples/spring-flo-sample/src/main/resources/static/js/graph-to-text.js new file mode 100644 index 0000000..cb375bb --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/js/graph-to-text.js @@ -0,0 +1,179 @@ +/* + * Copyright 2016 the original author or 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. + */ + +/** + * Convert a graph to a text representation. + * + * @author Alex Boyko + * @author Andy Clement + */ +define(function() { + 'use strict'; + + // Graph + var g; + + // Number of Links left to visit + var numberOfLinksToVisit; + + // Number of nodes left to visit + var numberOfNodesToVisit; + + // Map of links left to visit indexed by id + var linksToVisit; + + // Map of nodes left to visit indexed by id + var nodesToVisit; + + // Map of nodes incoming non-visited links degrees index by node id + var nodesInDegrees; + + // Priority: + // 1. find links whose source has no other links pointing at it + // 2. find links whose source has already been processed (not currently needed in sample DSL since + // can't create graphs like that due to metamodel constraints) + // 3. find remaining links + function nextLink() { + var indegree = Number.MAX_INT; + var currentBest; + for (var id in linksToVisit) { + var link = g.getCell(id); + var source = g.getCell(link.get('source').id); + var currentInDegree = nodesInDegrees[source.get('id')]; + if (currentInDegree === 0) { + return visit(link); + } else if (indegree > currentInDegree) { + indegree = currentInDegree; + currentBest = link; + } + } + if (currentBest) { + return visit(currentBest); + } + } + + function visit(e) { + if (e.isLink()) { + delete linksToVisit[e.get('id')]; + nodesInDegrees[e.get('target').id]--; + numberOfLinksToVisit--; + } else { + delete nodesToVisit[e.get('id')]; + numberOfNodesToVisit--; + } + return e; + } + + function init(graph) { + numberOfLinksToVisit = 0; + numberOfNodesToVisit = 0; + linksToVisit = {}; + nodesToVisit = {}; + nodesInDegrees = {}; + g = graph; + g.getElements().forEach(function(element) { + if (element.attr('metadata/name')) { // is it a node? + nodesToVisit[element.get('id')] = element; + var indegree = 0; + g.getConnectedLinks(element, {inbound: true}).forEach(function(link) { + if (link.get('source') && link.get('source').id && g.getCell(link.get('source').id) && + g.getCell(link.get('source').id).attr('metadata/name')) { + linksToVisit[link.get('id')] = link; + numberOfLinksToVisit++; + indegree++; + } + }); + nodesInDegrees[element.get('id')] = indegree; + numberOfNodesToVisit++; + } + }); + } + + /** + * Starts at a link and proceeds down a chain. Converts each node to + * text and then joins them with a ' > '. + */ + function chainToText(link) { + var text = ''; + var source = g.getCell(link.get('source').id); + text += nodeToText(source, true); + while (link) { + var target = g.getCell(link.get('target').id); + text += ' > '; + text += nodeToText(target, false); + + // Find next not visited link to follow + link = null; + var outgoingLinks = g.getConnectedLinks(target, {outbound: true}); + for (var i = 0; i < outgoingLinks.length && !link; i++) { + if (linksToVisit[outgoingLinks[i].get('id')]) { + source = target; + link = visit(outgoingLinks[i]); + } + } + } + return text; + } + + /** + * Very basic format. From a node to the text: + * "name --key=value --key=value" + */ + function nodeToText(element) { + var text = ''; + var props = element.attr('props'); + if (!element) { + return; + } + text += element.attr('metadata/name'); + if (props) { + Object.keys(props).forEach(function(propertyName) { + text += ' --' + propertyName + '=' + props[propertyName]; + }); + } + visit(element); + return text; + } + + function appendChainText(text, chainText) { + if (chainText) { + if (text) { + text += '\n'; + } + text += chainText; + } + return text; + } + + // Translate a graph into a basic string + return function(g) { + var text = ''; + var chainText; + var id; + init(g); + while (numberOfLinksToVisit) { + chainText = chainToText(nextLink()); + text = appendChainText(text, chainText); + } + // Visit all disconnected nodes + for (id in nodesToVisit) { + chainText = nodeToText(nodesToVisit[id], true); + text = appendChainText(text, chainText); + } + return text; + }; + +}); \ No newline at end of file diff --git a/samples/spring-flo-sample/src/main/resources/static/js/main.js b/samples/spring-flo-sample/src/main/resources/static/js/main.js new file mode 100755 index 0000000..5255b3b --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/js/main.js @@ -0,0 +1,83 @@ +/* + * Copyright 2016 the original author or 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. + */ + +/** + * @author Alex Boyko + * @author Andy Clement + */ +requirejs.config({ + baseUrl:'js', + paths: { + joint: '/webjars/jointjs/dist/joint', + backbone: '/webjars/backbone/backbone', + domReady: '/webjars/requirejs-domready/domReady', + angular: '/webjars/angular/angular', + jquery: '/webjars/jquery/dist/jquery', + bootstrap:'/webjars/bootstrap/bootstrap', + lodash: '/webjars/lodash/lodash', // lodash.compat + dagre: '/webjars/dagre/dist/dagre.core', + graphlib: '/webjars/graphlib/graphlib.core', + text : '/webjars/requirejs-text/text', + flo : '/webjars/spring-flo/dist/spring-flo', + json5 : '/webjars/json5/json5' + }, + map: { + '*': { + // Backbone requires underscore. This forces requireJS to load lodash instead: + 'underscore': 'lodash' + } + }, + packages: [ + { + name: 'codemirror', + location: '../lib/codemirror', + main: 'lib/codemirror' + } + ], + shim: { + angular: { + deps: ['bootstrap'], + exports: 'angular' + }, + bootstrap: { + deps: ['jquery'] + }, + graphlib: { + deps: ['underscore'] + }, + dagre: { + deps: ['graphlib', 'underscore'] + }, + joint: { + deps: ['jquery', 'underscore', 'backbone'], + }, + underscore: { + exports: '_' + }, + 'flo': { + deps: ['angular', 'jquery', 'joint', 'underscore'] + } + } +}); + +define(['require','angular'], function (require, angular) { + 'use strict'; + require(['domReady!', 'sample-app'], + function (document) { + angular.bootstrap(document, ['floSampleApp']); + } + ); +}); diff --git a/samples/spring-flo-sample/src/main/resources/static/js/metamodel-sample.json b/samples/spring-flo-sample/src/main/resources/static/js/metamodel-sample.json new file mode 100644 index 0000000..2c39505 --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/js/metamodel-sample.json @@ -0,0 +1,46 @@ +[{ + 'name':'http', 'group':'source', 'description':'Receive HTTP input', + 'properties':[ + { 'id':'port','name':'port', 'defaultValue':'80', 'description':'Port on which to listen' } + ], + 'constraints':{ 'maxIncomingLinksNumber':0, 'maxOutgoingLinksNumber':1 } +},{ + 'name':'rabbit', 'group':'source', 'description':'Receives messages from RabbitMQ', + 'properties':[ + { 'id':'queue','name':'queue', 'description':'the queue(s) from which messages will be received' } + ], + 'constraints':{ 'maxIncomingLinksNumber':0, 'maxOutgoingLinksNumber':1 } +},{ + 'name':'filewatch', 'group':'source', 'description':'Produce messages from the content of files created in a directory', + 'properties':[ + {'id':'dir','name':'dir','description':'the absolute path to monitor for files'} + ], + 'constraints':{ 'maxIncomingLinksNumber':0, 'maxOutgoingLinksNumber':1 } +},{ + 'name':'transform', 'group':'processor', 'description':'Apply an expression to modify incoming messages', + 'properties':[ + { 'id':'expression','name':'expression', 'defaultValue':'payload', 'description':'SpEL expression to apply' } + ], + 'constraints':{ 'maxIncomingLinksNumber':1, 'maxOutgoingLinksNumber':1 } +},{ + 'name':'filter', 'group':'processor', 'description':'Only allow messages through that pass the filter expression', + 'properties':[ + { 'id':'expression','name':'expression', 'defaultValue':'true', 'description':'SpEL expression to use for filtering' } + ], + 'constraints':{ 'maxIncomingLinksNumber':1, 'maxOutgoingLinksNumber':1 } +},{ + 'name':'filesave', 'group':'sink', 'description':'Writes messages to a file', + 'properties':[ + { 'id':'dir','name':'dir', 'description':'Absolute path to directory' }, + { 'id':'name','name':'name', 'description':'The name of the file to create' } + ], + 'constraints':{ 'maxIncomingLinksNumber':1, 'maxOutgoingLinksNumber':0 } +},{ + 'name':'ftp', 'group':'sink', 'description':'Send messages over FTP', + 'properties':[ + { 'id':'host','name':'host', 'description':'the host name for the FTP server' }, + { 'id':'port','name':'port', 'description':'The port for the FTP server' }, + { 'id':'remoteDir','name':'remoteDir', 'description':'The remote directory on the server' }, + ], + 'constraints':{ 'maxIncomingLinksNumber':1, 'maxOutgoingLinksNumber':0 } +}] \ No newline at end of file diff --git a/samples/spring-flo-sample/src/main/resources/static/js/metamodel-service.js b/samples/spring-flo-sample/src/main/resources/static/js/metamodel-service.js new file mode 100644 index 0000000..609bfe7 --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/js/metamodel-service.js @@ -0,0 +1,114 @@ +/* + * Copyright 2016 the original author or 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. + */ + +/** + * @author Alex Boyko + * @author Andy Clement + */ +define(function(require) { + 'use strict'; + + require('json5'); + + var convertGraphToText = require('graph-to-text'); + var convertTextToGraph = require('text-to-graph'); + + return ['$http', '$q', '$timeout', '$log', 'MetamodelUtils', function($http, $q, $timeout, $log, metamodelUtils) { + + var metamodel; + + // Internally stored metamodel load promise + var request; + + /** + * Helper that goes from basic JSON to a lazy getter structure. Useful when the + * metamodel is 'cheap' to build. If it is costly to discover the actual properties + * the getter may be more complex (e.g. make a REST request). + */ + function createMetadata(entry) { + var props = {}; + if (Array.isArray(entry.properties)) { + entry.properties.forEach(function(property) { + if (!property.id) { + property.id = property.name; + } + props[property.id] = property; + }); + } + entry.properties = props; + return { + name: entry.name, + group: entry.group, + icon: entry.icon, + constraints: entry.constraints, + description: entry.description, + metadata: entry.metadata, + properties: entry.properties, + get: function(property) { + var deferred = $q.defer(); + if (entry.hasOwnProperty(property)) { + deferred.resolve(entry[property]); + } else { + deferred.reject(); + } + return deferred.promise; + } + }; + } + + function load() { + // COULDDO: to cache the result here, check result before doing this processing + // and simply return it if it is set. If doing that may want to override refresh + // in this service + var metamodelData = JSON5.parse(require('text!metamodel-sample.json')); + var deferred = $q.defer(); + var newData = {}; + metamodelData.forEach(function(data) { + var metadata = createMetadata(data); + if (!newData[metadata.group]) { + newData[metadata.group] = {}; + } + newData[metadata.group][metadata.name] = metadata; + }); + metamodel = newData; + deferred.resolve(metamodel); + request = deferred.promise; + return request; + } + + function graphToText(flo, definition) { + definition.text = convertGraphToText(flo.getGraph()); + } + + function textToGraph(flo, definition) { + // TODO perhaps push these flo operations into the 'caller' to make this simpler + flo.getGraph().clear(); + load().then(function(metamodel) { + convertTextToGraph(definition.text, flo, metamodel, metamodelUtils); + flo.performLayout(); + flo.fitToPage(); + }); + } + + return { + 'load': load, + 'textToGraph': textToGraph, + 'graphToText': graphToText + }; + + }]; + +}); diff --git a/samples/spring-flo-sample/src/main/resources/static/js/render-service.js b/samples/spring-flo-sample/src/main/resources/static/js/render-service.js new file mode 100644 index 0000000..cf3b383 --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/js/render-service.js @@ -0,0 +1,206 @@ +/* + * Copyright 2016 the original author or 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. + */ + +/** + * @author Alex Boyko + * @author Andy Clement + */ +define(function(require) { + 'use strict'; + + var joint = require('joint'); + + var dagre = require('dagre'); + + var HANDLE_ICON_MAP = { + 'remove': 'icons/delete.svg', + }; + + var DECORATION_ICON_MAP = { + 'error': 'icons/error.svg' + }; + + return ['$log', function($log) { + + function createHandle(kind) { + return new joint.shapes.flo.ErrorDecoration({ + size: {width: 10, height: 10}, + attrs: { + 'image': { + 'xlink:href': HANDLE_ICON_MAP[kind] + } + } + }); + } + + function createDecoration(kind) { + return new joint.shapes.flo.ErrorDecoration({ + size: {width: 16, height: 16}, + attrs: { + 'image': { + 'xlink:href': DECORATION_ICON_MAP[kind] + } + } + }); + } + + function createNode(/*metadata, props*/) { + return new joint.shapes.flo.Node(); + } + + function initializeNewNode(node, context) { + var metadata = node.attr('metadata'); + if (metadata) { + node.attr('.label/text', node.attr('metadata/name')); + if (node.attr('metadata/constraints/maxIncomingLinksNumber') === 0) { + node.attr('.input-port/display','none'); + } + if (node.attr('metadata/constraints/maxOutgoingLinksNumber') === 0) { + node.attr('.output-port/display','none'); + } + + var type = node.attr('metadata/name'); + if (type === 'tap') { + if (!node.attr('props/channel')) { + node.attr('props/channel', 'tap:stream:STREAM'); + } + refreshVisuals(node, 'props/channel', context.paper); + } else if (type === 'named-channel') { + // Default channel for named channel is 'queue:default' + if (!node.attr('props/channel')) { + node.attr('props/channel', 'queue:default'); + } + refreshVisuals(node, 'props/channel', context.paper); + } + } + + } + + function createLink() { + var link = new joint.shapes.flo.Link(joint.util.deepSupplement({ + smooth: true, + attrs: { + '.': { + //filter: { name: 'dropShadow', args: { dx: 1, dy: 1, blur: 2 } } + }, + '.connection': { 'stroke-width': 3, 'stroke': 'black', 'stroke-linecap': 'round' }, + '.marker-arrowheads': { display: 'none' }, + '.tool-options': { display: 'none' } + }, + }, joint.shapes.flo.Link.prototype.defaults)); + return link; + } + + function isSemanticProperty(propertyPath) { + return propertyPath === '.label/text'; + } + + function refreshVisuals(element, changedPropertyPath/*, paper*/) { + var type = element.attr('metadata/name'); + + if (type === 'tap' && changedPropertyPath === 'props/channel') { + var name = element.attr('props/channel'); + if (name) { + var colon = name.indexOf(':'); + if (colon !== -1) { + colon = name.indexOf(':',colon+1); + if (colon !== -1) { + var displayValue = name.substring(0,colon) + '\n' + name.substring(colon); + element.attr('.label2/text', displayValue); + } + } + } else { + element.removeAttr('.label2/text'); + } + } + + if (type === 'named-channel' && changedPropertyPath === 'props/channel') { + element.attr('.label2/text', element.attr('props/channel')); + } + } + + function layout(paper) { + var graph = paper.model; + var i; + var g = new dagre.graphlib.Graph(); + g.setGraph({}); + g.setDefaultEdgeLabel(function() {return{};}); + + var nodes = graph.getElements(); + for (i = 0; i < nodes.length; i++) { + var node = nodes[i]; + if (node.get('type') === joint.shapes.flo.NODE_TYPE) { + g.setNode(node.id, node.get('size')); + } + } + + var links = graph.getLinks(); + for (i = 0; i < links.length; i++) { + var link = links[i]; + if (link.get('type') === joint.shapes.flo.LINK_TYPE) { + var options = { + minlen: 1.5 + }; +// if (link.get('labels') && link.get('labels').length > 0) { +// options.minlen = 1 + link.get('labels').length * 0.5; +// } + g.setEdge(link.get('source').id, link.get('target').id, options); + link.set('vertices', []); + } + } + + g.graph().rankdir = 'LR'; + dagre.layout(g); + g.nodes().forEach(function(v) { + var node = graph.getCell(v); + if (node) { + var bbox = node.getBBox(); + node.translate(g.node(v).x - bbox.x, g.node(v).y - bbox.y); + } + }); + } + + function getLinkAnchorPoint(linkView, view, magnet, reference) { + if (magnet) { + var type = magnet.getAttribute('type'); + var bbox = joint.V(magnet).bbox(false, linkView.paper.viewport); + var rect = joint.g.rect(bbox); + if (type === 'input') { + return joint.g.point(rect.x, rect.y + rect.height / 2); + } else { + return joint.g.point(rect.x + rect.width, rect.y + rect.height / 2); + } + } else { + $log.debug('No magnet!'); + return reference; + } + } + + return { + 'createHandle': createHandle, + 'createDecoration': createDecoration, + 'createNode': createNode, + 'createLink': createLink, + 'initializeNewNode': initializeNewNode, + 'isSemanticProperty': isSemanticProperty, + 'refreshVisuals': refreshVisuals, + 'layout': layout, + 'getLinkAnchorPoint': getLinkAnchorPoint + }; + + }]; + +}); diff --git a/samples/spring-flo-sample/src/main/resources/static/js/sample-app.js b/samples/spring-flo-sample/src/main/resources/static/js/sample-app.js new file mode 100644 index 0000000..5500cda --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/js/sample-app.js @@ -0,0 +1,30 @@ +/* + * Copyright 2016 the original author or 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. + */ + +/** + * @author Alex Boyko + * @author Andy Clement + */ +define(function(require) { + 'use strict'; + var angular = require('angular'); + require('flo'); + var app = angular.module('floSampleApp', [ 'spring.flo' ]); + app.factory('SampleMetamodelService', require('metamodel-service')); + app.factory('SampleRenderService', require('render-service')); + app.factory('SampleEditorService', require('editor-service')); + return app; +}); diff --git a/samples/spring-flo-sample/src/main/resources/static/js/text-to-graph.js b/samples/spring-flo-sample/src/main/resources/static/js/text-to-graph.js new file mode 100644 index 0000000..98b85d4 --- /dev/null +++ b/samples/spring-flo-sample/src/main/resources/static/js/text-to-graph.js @@ -0,0 +1,71 @@ +/* + * Copyright 2016 the original author or 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. + */ + +/** + * Convert a text representation to a graph. + * + * @author Alex Boyko + * @author Andy Clement + */ +define(function() { + 'use strict'; + + return function(input, flo, metamodel, metamodelUtils) { + // input is a string like this (3 nodes: foo, goo and hoo): foo --a=b --c=d > goo --d=e --f=g>hoo + var trimmed = input.trim(); + if (trimmed.length===0) { + return; + } + var lines = trimmed.split('\n'); + for (var l=0;l'); + var lastNode = null; + for (var e=0;e