Demonstrations / Flowchart Builder (Vue 2)

Flowchart Builder (Vue 2)

This is a port of the Flowchart Builder application that demonstrates the Toolkit's Vue 2 ES6 module based integration, in a Vue CLI 3 application.

Flowchart Builder Demonstration

This page gives you an in-depth look at how the application is put together.

package.json

This file was created for us by the CLI, to which we then added these entries:

{
    "dependencies":{
        ...
        "jsplumbtoolkit": "file:../../jsplumbtoolkit.tgz",  
        "jsplumbtoolkit-vue2": "file:../../jsplumbtoolkit-vue2.tgz",
        "jsplumbtoolkit-undo-redo": "file:../../jsplumbtoolkit-undo-redo.tgz"
        ...
    }
}

TOP


Setup

As this application was generated by the CLI, the setup was done for us. We just had to add the appropriate entries to package.json.

TOP


Bootstrap

A CLI application is bootstrapped through src/main.js. Ours looks like this:

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

require('@/assets/css/font-awesome.min.css');
require('@/assets/css/jsplumbtoolkit-defaults.css');
require('@/assets/css/jsplumbtoolkit-demo.css');
require('@/assets/css/app.css');

new Vue({ render: h => h(App) }).$mount('#app')

TOP


Application

The component that acts as the entry point of the application is defined in App.vue, which looks like this:

<template>
  <div id="app">
    <div class="jtk-demo-main" id="jtk-demo-flowchart">
      <div style="display:flex">
        <Palette surface-id="surface" selector="[data-node-type]"/>
        <div id="canvas" class="jtk-demo-canvas">
          <Controls surface-id="surface" />
          <Flowchart surface-id="surface" />
        </div>
      </div>
      <div class="description">
        <p>
          This sample application is a copy of the Flowchart Builder application, using the Toolkit's
          Vue 2 integration components and Vue CLI 3.
        </p>
        <ul>
          <li>Drag new nodes from the palette on the left onto the workspace to add nodes</li>
          <li>Drag from the grey border of any node to any other node to establish a link, then provide a description for the link's label</li>
          <li>Click a link to edit its label.</li>
          <li>Click the 'Pencil' icon to enter 'select' mode, then select several nodes. Click the canvas to exit.</li>
          <li>Click the 'Home' icon to zoom out and see all the nodes.</li>
        </ul>
      </div>
    </div>

  </div>
</template>

<script>

    import Vue from "vue"

    import  { Dialogs, jsPlumbToolkit } from "jsplumbtoolkit"

    import Flowchart from './components/Flowchart.vue'
    import Palette from './components/Palette.vue'
    import Controls from './components/Controls.vue'

    export default {
        name: 'app',
        components: {
            Flowchart, Palette, Controls
        },
        mounted:function() {
            jsPlumbToolkit.ready(() => {
                Dialogs.initialize({
                    selector: ".dlg"
                });
            });
        }
    }
</script>

Points to note:

  • The template uses 3 components that are also declared in this app - Flowchart, Palette and Controls. A discussion of each of these is below.
  • We initialise the Toolkit's Dialogs inside a jsPlumbToolkit.ready(..) function in the mounted callback of this component. You may not wish to use the Toolkit's Dialogs; they are something we created for our demonstrations. It isn't always necessary to wrap their initialisation in a ready(..) call, either, but in this demonstration we load them from an external file called templates.html and need to be sure they have loaded before we try to initialise them.

Flowchart Component

This is where most of the functionality is coordinated. We'll break it up into sections and go through each one.

Template

<template>
    <jsplumb-toolkit 
        ref="toolkitComponent" 
        url="flowchart-1.json" 
        v-bind:render-params="renderParams" 
        v-bind:view="view" 
        id="toolkit" 
        surface-id="surface" 
        v-bind:toolkit-params="toolkitParams">

    </jsplumb-toolkit>
</template>

We use the jsplumb-toolkit component, providing several pieces of information:

  • ref="toolkitComponent" we want to be able to retrieve this component after mounting, as we need a reference to the underlying Toolkit instance for some of our business logic
  • url="flowchart-1.json" we provide the url for an initial dataset to load. This is of course optional.
  • v-bind:render-params="renderParams" These are the parameters passed to the Surface component that renders the dataset. As we will see below, we declare these in the data section of the Flowchart component.
  • v-bind:view="view" This the view passed to the Surface component that renders the dataset. As we will see below, we declare this in the data section of the Flowchart component.
  • id="toolkit" We give an id to the Toolkit instance we are creating, which is optional, and in fact in this app we do not use it.
  • surface-id="surface" We assign an ID to the surface for a couple of reasons: first, we need to nominate which surface to attach our miniview, controls and palette components to. Second, we need to access the surface for some of our app's functionality.
  • v-bind:toolkit-params="toolkitParams" These are the parameters passed to constructor of the Toolkit instance. As we will see below, we declare these in the data section of the Flowchart component.

Script Block

We'll break this up into parts too.

Imports
import Vue from 'vue'
import { jsPlumbToolkit, jsPlumb, Dialogs, DrawingTools, jsPlumbUtil } from 'jsplumbtoolkit'
import { jsPlumbToolkitVue2, Palette } from 'jsplumbtoolkit-vue2'

import StartNode from './StartNode.vue'
import ActionNode from './ActionNode.vue'
import QuestionNode from './QuestionNode.vue'
import OutputNode from './OutputNode.vue'

We import Vue and a few bits and pieces from the Toolkit, and from its Vue integration. We also import each of the components used to render our nodes.

Component Level Functionality

We have a few methods defined in the component's scope - things to perform operations on edges, plus the code we use as our data-model#nodefactory:

let toolkitComponent;
let toolkit;


function maybeRemoveEdge(params) {
    Dialogs.show({
        id: "dlgConfirm",
        data: {
            msg: "Delete Edge"
        },
        onOK: function () {
            params.toolkit.removeEdge(params.edge);
        }
    });
}

function editEdge(params) {
    Dialogs.show({
        id: "dlgText",
        data: {
            text: params.edge.data.label || ""
        },
        onOK: function (data) {
            toolkit.updateEdge(params.edge, {label:data.text});
        }
    });
}

function nodeFactory(type, data, callback)  {
    Dialogs.show({
        id: "dlgText",
        title: "Enter " + type + " name:",
        onOK: function (d) {
            data.text = d.text;
            // if the user entered a name...
            if (data.text) {
                // and it was at least 2 chars
                if (data.text.length >= 2) {
                    // set an id and continue.
                    data.id = jsPlumbUtil.uuid();
                    callback(data);
                }
                else
                // else advise the user.
                    alert(type + " names must be at least 2 characters!");
            }
            // else...do not proceed.
        }
    });
}
Component Definition

The key pieces to note here are:

  • we've declared toolkitParams, renderParams and view in our data object. You can find a discussion of these concepts in the documentation.
  • we map Vue components to node types in the view
  • we initialise the DrawingTools when the component is mounted.
export default {

    name: 'jsp-toolkit',
    props:["surfaceId"],
    data:() => {
        return {
            toolkitParams:{
                nodeFactory:nodeFactory,
                beforeStartConnect:function(node, edgeType) {
                    // limit edges from start node to 1. if any other type of node, return
                    return (node.data.type === "start" && node.getEdges().length > 0) ? false : { label:"..." };
                }
            },
            renderParams:{
              layout:{
                  type:"Spring"
              },
              jsPlumb:{
                  Connector:"StateMachine",
                  Endpoint:"Blank"
              },
              events:{
                  modeChanged:function (mode) {
                      let controls = document.querySelector(".controls");
                      jsPlumb.removeClass(controls.querySelectorAll("[mode]"), "selected-mode");
                      jsPlumb.addClass(controls.querySelectorAll("[mode='" + mode + "']"), "selected-mode");
                  },
                  edgeAdded:(params) => {
                      if (params.addedByMouse) {
                          editEdge(params);
                      }
                  }
              },
              lassoInvert:true,
              elementsDroppable:true,
              consumeRightClick: false,
              dragOptions: {
                  filter: ".jtk-draw-handle, .node-action, .node-action i"
              }
            },
            view:{
                nodes: {
                    "start": {
                        component:StartNode
                    },
                    "selectable": {
                        events: {
                            tap: (params) => params.toolkit.toggleSelection(params.node)
                        }
                    },
                    "question": {
                        parent: "selectable",
                        component:QuestionNode
                    },
                    "action": {
                        parent: "selectable",
                        component:ActionNode
                    },
                    "output":{
                        parent:"selectable",
                        component:OutputNode
                    }
                },
                // There are two edge types defined - 'yes' and 'no', sharing a common
                // parent.
                edges: {
                    "default": {
                        anchor:"AutoDefault",
                        endpoint:"Blank",
                        connector: ["Flowchart", { cornerRadius: 5 } ],
                        paintStyle: { strokeWidth: 2, stroke: "#f76258", outlineWidth: 3, outlineStroke: "transparent" },   //  paint style for this edge type.
                        hoverPaintStyle: { strokeWidth: 2, stroke: "rgb(67,67,67)" }, // hover paint style for this edge type.
                        events: {
                            "dblclick": maybeRemoveEdge
                        },
                        overlays: [
                            [ "Arrow", { location: 1, width: 10, length: 10 }],
                            [ "Arrow", { location: 0.3, width: 10, length: 10 }]
                        ]
                    },
                    "connection":{
                        parent:"default",
                        overlays:[
                            [
                                "Label", {
                                    label: "${label}",
                                    events:{
                                        click:editEdge
                                    }
                                }
                            ]
                        ]
                    }
                },

                ports: {
                    "start": {
                        edgeType: "default"
                    },
                    "source": {
                        maxConnections: -1,
                        edgeType: "connection"
                    },
                    "target": {
                        maxConnections: -1,
                        isTarget: true,
                        dropOptions: {
                            hoverClass: "connection-drop"
                        }
                    }
                }
            }
        };
    },

    mounted() {

        toolkitComponent = this.$refs.toolkitComponent;
        toolkit = toolkitComponent.toolkit;

        jsPlumbToolkitVue2.getSurface(this.surfaceId, (s) => {
            new DrawingTools({
                renderer: s
            });
        });
    }

}

Node Components

Each of the four node types is rendered with a specific Vue component. With the exception of the StartNode component, they each include the BaseEditableNode mixin, whose definition is:

<script>

    import { Dialogs } from 'jsplumbtoolkit'
    import { BaseNodeComponent } from 'jsplumbtoolkit-vue2'

    export default {
        mixins:[ BaseNodeComponent ],
        methods:{
            edit:function() {
                let node = this.getNode();
                Dialogs.show({
                    id: "dlgText",
                    data: node.data,
                    title: "Edit " + node.data.type + " name",
                    onOK: (data) => {
                        if (data.text && data.text.length > 2) {
                            // if name is at least 2 chars long, update the underlying data and
                            // update the UI.
                            this.updateNode(data);
                        }
                    }
                });
            },
            maybeDelete:function() {
                let node = this.getNode();
                Dialogs.show({
                    id: "dlgConfirm",
                    data: {
                        msg: "Delete '" + node.data.text + "'"
                    },
                    onOK:() => {
                        this.removeNode();
                    }
                });
            }
        }
    }

</script>

It offers 2 common methods - to handle editing of a node's label, and to handle a node's deletion.

Note in the node templates we write a v-pre attibrute on jtk-source and jtk-target elements. This instructs Vue to ignore these; without v-pre Vue would try to render these as Vue components.

StartNode

<template>
    <div v-bind:style="{left:obj.left + 'px', top:obj.top + 'px', width:obj.w + 'px', height:obj.h + 'px'}" class="flowchart-object flowchart-start">
        <div style="position:relative">
            <svg :width="obj.w" :height="obj.h">
                <ellipse :cx="obj.w/2" :cy="obj.h/2" :rx="obj.w/2" :ry="obj.h/2" class="outer"/>
                <ellipse :cx="obj.w/2" :cy="obj.h/2" :rx="(obj.w/2) - 10" :ry="(obj.h/2) - 10" class="inner"/>
                <text text-anchor="middle" :x="obj.w / 2" :y="obj.h / 2" dominant-baseline="central"></text>
            </svg>
        </div>
        <jtk-source port-type="start" filter=".outer" filter-negate="true" v-pre/>
    </div>
</template>

<script>
    export default { }
</script>

ActionNode

<template>
    <div v-bind:style="{left:obj.left + 'px', top:obj.top + 'px', width:obj.w + 'px', height:obj.h + 'px'}" class="flowchart-object flowchart-action">
        <div style="position:relative">
            <div class="node-edit node-action">
                <i class="fa fa-pencil-square-o" v-on:click="edit()"/>
            </div>
            <div class="node-delete node-action">
                <i class="fa fa-times" v-on:click="maybeDelete()"/>
            </div>
            <svg :width="obj.w" :height="obj.h">
                <rect x="0" y="0" :width="obj.w" :height="obj.h" class="outer drag-start"/>
                <rect x="10" y="10" :width="obj.w-20" :height="obj.h-20" class="inner"/>
                <text text-anchor="middle" :x="obj.w/2" :y="obj.h/2" dominant-baseline="central"></text>
            </svg>
        </div>
        <jtk-target port-type="target" v-pre/>
        <jtk-source port-type="source" filter=".outer" v-pre/>
    </div>
</template>

<script>
    import BaseEditableNode from './BaseEditableNode.vue'
    export default {
        mixins:[BaseEditableNode]
    }
</script>

OutputNode

<template>
    <div v-bind:style="{left:obj.left + 'px', top:obj.top + 'px', width:obj.w + 'px', height:obj.h + 'px'}" class="flowchart-object flowchart-output">
        <div style="position:relative">
            <div class="node-edit node-action">
                <i class="fa fa-pencil-square-o" v-on:click="edit()"/>
            </div>
            <div class="node-delete node-action">
                <i class="fa fa-times" v-on:click="maybeDelete()"/>
            </div>
            <svg :width="obj.w" :height="obj.h">
                <rect x="0" y="0" :width="obj.w" :height="obj.h"/>
                <text text-anchor="middle" :x="obj.w/2" :y="obj.h/2" dominant-baseline="central"></text>
            </svg>
        </div>
        <jtk-target port-type="target" v-pre/>
    </div>
</template>

<script>
    import BaseEditableNode from './BaseEditableNode.vue'
    export default {
        mixins:[BaseEditableNode]
    }
</script>

QuestionNode

<template>
    <div v-bind:style="{left:obj.left + 'px', top:obj.top + 'px', width:obj.w + 'px', height:obj.h + 'px'}" class="flowchart-object flowchart-action">
        <div style="position:relative">
            <div class="node-edit node-action">
                <i class="fa fa-pencil-square-o" v-on:click="edit()"/>
            </div>
            <div class="node-delete node-action">
                <i class="fa fa-times" v-on:click="maybeDelete()"/>
            </div>
            <svg :width="obj.w" :height="obj.h">
                <path :d="'M ' +  (obj.w/2) + ' 0 L ' + obj.w + ' ' + (obj.h/2) + ' L ' + (obj.w/2) + ' ' + obj.h + ' L 0 ' + (obj.h/2) + ' Z'" class="outer"/>
                <path :d="'M ' + (obj.w/2) + ' 10 L ' + (obj.w-10) + ' ' + (obj.h/2) + ' L ' + (obj.w/2) + ' ' + (obj.h-10) + ' L 10 ' + (obj.h/2) + ' Z'" class="inner"/>
                <text text-anchor="middle" :x="obj.w/2" :y="obj.h/2" dominant-baseline="central"></text>
            </svg>
        </div>
        <jtk-target port-type="target" v-pre/>
        <jtk-source port-type="source" filter=".outer" v-pre/>
    </div>
</template>

<script>
    import BaseEditableNode from './BaseEditableNode.vue'
    export default {
        mixins:[BaseEditableNode]
    }
</script>

In this demonstration, new nodes can be dragged on to whitespace in the canvas, to create new, unconnected, nodes. They can also be dragged onto an existing edge, in which case the new node is injected in between the two nodes at either end of the edge on which the new node was dropped.

We declare a Palette component in the app's template:

<Palette surface-id="surface"
     selector="[data-node-type]"
     v-bind:data-generator="dataGenerator"
     allowDropOnEdges="true">
</Palette>

Palette is a component declared in this application, which uses the DragDrop mixin from the Toolkit's Vue integration:

<template>
    <div class="sidebar node-palette">
        <div class="sidebar-item" :data-node-type="entry.type" title="Drag to add new" v-for="entry in data" :key="entry.type">
            <i :class="entry.icon"></i>
        </div>
    </div>
</template>

<script>

    import { DragDrop } from 'jsplumbtoolkit-vue2-drop';

    export default {
        mixins:[ DragDrop ],
        data:function() {
            return {
                data:[
                    { icon:"icon-tablet", label:"Question", type:"question" },
                    { icon:"icon-eye-open", label:"Action", type:"action" },
                    { type:"output", icon:"icon-eye-open", label:"Output" }
                ]
            };
        },
        methods:{
            onCanvasDrop:function(surface, data, positionOnSurface) {
                data.left = positionOnSurface.left;
                data.top = positionOnSurface.top;
                surface.getToolkit().addFactoryNode(data.type, data);
            },
            // disabling linter so you can see all of the method arguments
            // eslint-disable-next-line
            onEdgeDrop:function(surface, data, edge, positionOnSurface, el, evt, pageLocation) {
                let toolkit = surface.getToolkit();
                toolkit.addFactoryNode(data.type, data,
                    function(newNode) {
                        let currentSource = edge.source; // the current source node
                        let currentTarget = edge.target; // the target node
                        toolkit.removeEdge(edge);
                        toolkit.addEdge({source:currentSource, target:newNode, data:{label:"...", type:"connection"}});
                        toolkit.addEdge({source:newNode, target:currentTarget, data:{label:"...", type:"connection"}});
                        surface.setPosition(newNode, positionOnSurface.left, positionOnSurface.top);
                    }
                );
            }
        }
    }

</script>

Dropping a new node onto whitespace

The onCanvasDrop method here handles dropping a new node onto the canvas. We copy in the left and top values from the positionOnSurface argument to the data object. We then call addFactoryNode on the underlying Toolkit instance, with the type of the new node and the data for the node. addFactoryNode is a method that will cause the current nodeFactory to be invoked - in this demonstration, we provide a nodeFactory that pops up a dialog, requesting the user enter a label for the new node.

Dropping a new node onto an existing edge

The onEdgeDrop method handles this case. There are a number of arguments passed to this callback method. positionOnSurface is an object with { left:.., top:... } values that are in the coordinate space of the surface, adjusted for its current pan and zoom.

In this callback we again set left and top on the data object, and we call addFactoryNode, but here we provide a callback function as the third argument. This method is called at the very end of the process of adding a node via the node factory. We store the source and target of the edge on which the new node was dropped, then we remove that edge. We then add an edge from the original source to the new node, and another edge from the new node to the original target. Finally, we instruct the surface to place the new node at the location on the canvas at which the user dropped the object.