basic sample project
This commit is contained in:
62
samples/spring-flo-sample/README.md
Normal file
62
samples/spring-flo-sample/README.md
Normal file
@@ -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
|
||||
|
||||
115
samples/spring-flo-sample/pom.xml
Executable file
115
samples/spring-flo-sample/pom.xml
Executable file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-flo-sample</artifactId>
|
||||
<version>0.0.1.BUILD-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>spring-flo-sample</name>
|
||||
<description>Spring Flo Sample</description>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>1.3.2.RELEASE</version>
|
||||
<relativePath /> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<!-- TODO maybe adjust flos dependencies so this configuration isn't so complicated -->
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>codemirror</artifactId>
|
||||
<version>5.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>angular</artifactId>
|
||||
<version>1.3.8</version> <!-- flo wants 1.3.5 -->
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>jshint</artifactId>
|
||||
<version>2.8.0</version> <!-- flo wants 2.6.3 -->
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>requirejs</artifactId>
|
||||
<version>2.1.18</version> <!-- flo wants 2.1.15 -->
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>jquery</artifactId>
|
||||
<version>2.2.0</version> <!-- jointjs wants 2.0.3 -->
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>lodash</artifactId>
|
||||
<version>3.10.1</version> <!-- jointjs -> graphlib -> wants 3.10.1-amd -->
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>webjars-locator</artifactId>
|
||||
<version>0.31</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>requirejs-domready</artifactId>
|
||||
<version>2.0.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>requirejs-text</artifactId>
|
||||
<version>2.0.15</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>jointjs</artifactId>
|
||||
<version>0.9.7</version> <!-- flo wants 0.9.6 -->
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>json5</artifactId>
|
||||
<version>0.4.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>spring-flo</artifactId>
|
||||
<version>0.5.0</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.webjars.bower</groupId>
|
||||
<artifactId>joint</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<start-class>org.springframework.flo.Application</start-class>
|
||||
<java.version>1.7</java.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" class="collapse-handle" viewBox="0 0 24 24">
|
||||
<line x1="4" y1="4" x2="20" y2="20" stroke="black" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="20" y1="4" x2="4" y2="20" stroke="black" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="27.963px" height="27.963px" viewBox="0 0 27.963 27.963" style="enable-background:new 0 0 27.963 27.963;"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<circle cx="13.9815" cy="13.9815" r="12.9815" stroke="black" stroke-width="1" fill="red" />
|
||||
<polygon style="fill:white;" points="15.578,17.158 16.19,4.579 11.803,4.579 12.413,17.158 "/>
|
||||
<path style="fill:white;" d="M13.997,18.546c-1.471,0-2.5,1.029-2.5,2.526c0,1.443,0.999,2.528,2.444,2.528h0.056
|
||||
c1.499,0,2.469-1.085,2.469-2.528C16.44,19.575,15.467,18.546,13.997,18.546z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 791 B |
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="1000px" height="1000px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M139.2,264.6c-47.5,71.7-72.7,155.2-72.7,241.5c0,59,11.6,116.3,34.4,170.3c22,52.1,53.6,98.9,93.7,139
|
||||
c40.1,40.1,86.9,71.7,139,93.7c54,22.8,111.3,34.4,170.3,34.4s116.3-11.6,170.3-34.4c52.1-22,98.9-53.6,139-93.7
|
||||
c40.1-40.1,71.7-86.9,93.7-139c22.8-54,34.4-111.3,34.4-170.3c0-50.4-8.5-99.8-25.3-146.9c-16.2-45.5-39.8-87.8-70.1-125.7
|
||||
c-30-37.5-65.6-69.7-106-95.6c-41.1-26.4-86-45.6-133.4-57l-39.8,165.3c57.3,13.8,109.3,46.9,146.3,93.4
|
||||
c38.1,47.8,58.3,105.4,58.3,166.7c0,71.4-27.8,138.6-78.3,189.1c-50.5,50.5-117.7,78.3-189.1,78.3s-138.6-27.8-189.1-78.3
|
||||
c-50.5-50.5-78.3-117.7-78.3-189.1c0-52.8,15.3-103.8,44.3-147.6c6.5-9.9,13.7-19.2,21.4-28.1L399.1,456L508,70.1l-406.4,0.1
|
||||
l95.6,124C175.7,215.5,156.2,239,139.2,264.6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
51
samples/spring-flo-sample/src/main/resources/static/index.html
Executable file
51
samples/spring-flo-sample/src/main/resources/static/index.html
Executable file
@@ -0,0 +1,51 @@
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
<title>Spring Flo Sample</title>
|
||||
<link href="http://fonts.googleapis.com/css?family=Varela+Round|Montserrat:400,700" rel="stylesheet" type="text/css" />
|
||||
<link href="/webjars/spring-flo/dist/spring-flo.css" rel="stylesheet" />
|
||||
<link href="css/sample.css" rel="stylesheet" />
|
||||
|
||||
<script data-main="js/main" src="/webjars/requirejs/require.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header" class="header">
|
||||
Spring Flo Sample
|
||||
</div>
|
||||
<div id="flo-container">
|
||||
<flo-editor ng-cloak metamodel-service-name="SampleMetamodelService" render-service-name="SampleRenderService" editor-service-name="SampleEditorService" palette-size="170"
|
||||
min-zoom="10" max-zoom="300" zoom-step="10" ng-init="canvasControls={zoom:true};">
|
||||
<div id="controls" class="controls">
|
||||
<button class="button" id="clearGraph" ng-click="flo.clearGraph()">Create New Flow</button>
|
||||
<!-- let's use the 'x' control on the nodes themselves when they are selected
|
||||
<button class="button" id="deleteSelectedNode" ng-click="flo.deleteSelectedNode()">Delete Selected Node</button>
|
||||
-->
|
||||
<button class="button" id="performLayout" ng-click="flo.performLayout(); flo.fitToPage();">Reset Layout</button>
|
||||
<button class="button" id="sync" ng-click="flo.updateTextRepresentation()">Synchronize</button>
|
||||
<button class="button" id="readOnly" ng-click="flo.readOnlyCanvas(!flo.readOnlyCanvas())" ng-class="{on:flo.readOnlyCanvas()}">Read-Only</button>
|
||||
<button class="button" id="noPalette" ng-click="flo.noPalette = !flo.noPalette" ng-class="{on:!flo.noPalette}">Palette</button>
|
||||
<button class="button" id="editor" ng-click="editor = !editor" ng-lcass="{on:editor}">{{editor ? 'TextArea' : 'Editor'}}</button>
|
||||
<!-- Defer to using zoom control on the canvas
|
||||
<span class="button">
|
||||
<label>Zoom: </label>
|
||||
<input id="zoomInput" type="text" ng-model="flo.zoomPercent" ng-model-options="{ getterSetter: true, updateOn: 'blur change' }" size="3"></input>
|
||||
<label>%</label>
|
||||
<input type="range" ng-model="flo.zoomPercent" ng-model-options="{ getterSetter: true }" step="10" max="300" min="10" data-type="range" name="range" class="range"></input>
|
||||
</span>
|
||||
-->
|
||||
<span class="button">
|
||||
<label>Grid Size: </label>
|
||||
<input id="gridSizeInput" type="text" ng-model="flo.gridSize" ng-model-options="{ getterSetter: true, updateOn: 'blur change' }" size="2"></input>
|
||||
<label>pixels</label>
|
||||
<input type="range" ng-model="flo.gridSize" ng-model-options="{ getterSetter: true }" step="1" max="50" min="1" data-type="range" name="range" class="range"></input>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flow-definition-container">
|
||||
<textarea ng-if="editor" dsl-editor="true" id="flow-definition" class="flow-definition"></textarea>
|
||||
<textarea ng-if="!editor" id="flow-definition" class="flow-definition" placeholder="Enter stream definition..."
|
||||
ng-model="definition.text" ng-keyup="flo.scheduleUpdateGraphRepresentation(); $event.stopPropagation();" ng-blur="flo.enableSyncing(true)" ng-focus="flo.enableSyncing(false)"></textarea>
|
||||
</div>
|
||||
</flo-editor>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
};
|
||||
|
||||
}];
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
});
|
||||
83
samples/spring-flo-sample/src/main/resources/static/js/main.js
Executable file
83
samples/spring-flo-sample/src/main/resources/static/js/main.js
Executable file
@@ -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']);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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 }
|
||||
}]
|
||||
@@ -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
|
||||
};
|
||||
|
||||
}];
|
||||
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
}];
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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<lines.length;l++) {
|
||||
var line = lines[l];
|
||||
var elements = line.split('>');
|
||||
var lastNode = null;
|
||||
for (var e=0;e<elements.length;e++) {
|
||||
var element = elements[e].trim();
|
||||
// Has properties?
|
||||
var startOfProps = element.indexOf(' ');
|
||||
var name = element;
|
||||
var properties = {};
|
||||
if (startOfProps !== -1) {
|
||||
name = element.substring(0,startOfProps);
|
||||
var propValues = element.substring(startOfProps+1).trim().split(' ');
|
||||
for (var p=0;p<propValues.length;p++) {
|
||||
var propValue = propValues[p].trim();
|
||||
if (propValue.length===0) {
|
||||
// allows for multiple spaces between options
|
||||
continue;
|
||||
}
|
||||
var equalsIndex = propValue.indexOf('=');
|
||||
// The 2 skips the '--'
|
||||
var key = propValue.substring(2,equalsIndex);
|
||||
var value = propValue.substring(equalsIndex+1);
|
||||
properties[key] = value;
|
||||
}
|
||||
}
|
||||
var group = metamodelUtils.matchGroup(metamodel, name, 1, 1);
|
||||
var newNode = flo.createNode(metamodelUtils.getMetadata(metamodel,name,group),properties);
|
||||
newNode.attr('.label/text',name);
|
||||
if (lastNode) {
|
||||
flo.createLink({'id': lastNode.id,'selector': '.output-port'},
|
||||
{'id': newNode.id,'selector': '.input-port'});
|
||||
}
|
||||
lastNode = newNode;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user