Skip to main content

Schema Builder

This application is designed to serve as a base from which you can build your own applications. It uses a number of features of the Toolkit, and we ship versions of it for all of the library integrations.

On this page we discuss the "vanilla" implementation. There are implementations available for each of our library integrations:

The source code for this application is available on Github at https://github.com/jsplumb/jsplumbtoolkit-applications.

Overview

This application is a basic editor for database schemas, offering the ability to manage tables and views. Tables have columns which can be of a few different datatypes, and the mechanism used to declare supported datatypes is easy for you to extend.

The main features of this application are:

  • A drawing canvas which renders tables and views

  • A palette from which new tables or views can be dragged onto the canvas

  • Editable table/column/view names

  • A miniview

  • A set of controls for managing zoom, undo, redo, etc.

Note that the code snippets on this page refer to constants in many places, which, for the sake of brevity, we have not included. You can find these in the source code on Github.

Imports


"dependencies":{
"@jsplumbtoolkit/browser-ui":"^6.2.0"
}

Setup

At the heart of any application using the Toolkit is some instance of the Toolkit edition. In this application we create a Toolkit with these parameters:


export const COLUMNS = "columns"

/**
* Default edge cardinalities.
* @public
*/
export const cardinalities:Array<Cardinality> = [
{ id:ONE_TO_ONE, name:ONE_TO_ONE_NAME, labels:["1", "1"] },
{ id:ONE_TO_N, name:ONE_TO_N_NAME, labels:["1", "N"] },
{ id:N_TO_M, name:N_TO_M_NAME, labels:["N", "M"] }
]

const toolkit = newInstance({
// the name of the property in each node's data that is the key for the data for the ports for that node.
// for more complex setups you can use `portExtractor` and `portUpdater` functions - see the documentation for examples.
portDataProperty:COLUMNS,
//
// set `cardinality` to be the first entry in the list by default.
beforeStartConnect:(source: Vertex, type: string) => {
return {
cardinality:cardinalities[0].id
}
},
//
// Prevent connections from a column to itself or to another column on the same table.
//
beforeConnect:(source:Vertex, target:Vertex) => {
return isPort(source) && isPort(target) && source !== target && source.getParent() !== target.getParent()
}
})

The parameters we pass in to the Toolkit are:

  • portDataProperty This informs the toolkit what the name of the key is that points to the set of ports in some node. In this app we use a value of "columns", meaning each node's data is expected to have a columns array containing port data, or rather that if a columns array exists, it identifies port data. Table nodes have "columns"; view nodes do not.

  • beforeStartConnect This method lets us return an initial payload for new edges dragged by the user. We return a default cardinality.

  • beforeConnect This is an interceptor: we can return false from this to veto a connection. In this app we do not allow connections from a column to any other column on the table, or to itself.


Rendering the canvas

This is the call to render the Toolkit instance. There are a number of things going on in here; we've documented this inline but also in subsequent sections we'll expand on various bits and pieces from here.

const canvasElement = document.querySelector(".jtk-demo-canvas")
const renderParams:VanillaSurfaceRenderOptions = {
dragOptions: {
filter:[
"jtk-delete-button", "jtk-add-button", "jtk-schema-add"
].join(",")
},
plugins:[
{
type:MiniviewPlugin.type,
options:{
container:miniviewElement
}
},
LassoPlugin.type
],
propertyMappings:{
edgeMappings
},
events: {
[EVENT_CANVAS_CLICK]: (e:Event) => {
toolkit.clearSelection()
}
},
zoomToFit:true,
layout:{
type: ForceDirectedLayout.type,
options: {
padding: {x:150, y:150}
}
},
defaults:{
endpoint:{
type:DotEndpoint.type,
options:{
cssClass:".jtk-schema-endpoint"
}
}
},
view:{
nodes:{
table:{
template:`<div class="jtk-schema-table jtk-schema-element">
<div class="jtk-schema-element-name">
<div class="jtk-schema-delete jtk-schema-delete-vertex" title="Delete table"/>
<span>{{name}}</span>
<div class="jtk-schema-buttons">
<div class="jtk-schema-edit-name jtk-schema-edit" title="Edit table name"/>
<div class="jtk-schema-new-column jtk-schema-add" title="Add table column"/>
</div>
</div>
<div class="jtk-schema-columns">
<r-each in="columns" key="id">
<r-tmpl id="tmplColumn"/>
</r-each>
</div>
</div>`
},
view:{
template:`<div class="jtk-schema-view jtk-schema-element">
<div class="jtk-schema-element-name">
<div class="jtk-schema-view-delete jtk-schema-delete jtk-schema-delete-vertex" title="Delete view"/>
<span>{{name}}</span>
<div class="jtk-schema-buttons">
<div class="jtk-schema-edit-name jtk-schema-edit" title="Edit view"/>
</div>
</div>
<div class="jtk-schema-view-details">{{query}}</div>
</div>`
}
},
ports: {
[DEFAULT]: {
templateId: "tmplColumn",
edgeType: COMMON, // the type of edge for connections from this port type
maxConnections: -1 // no limit on connections
}
},
edges:{
[DEFAULT]: {
detachable: false,
anchor: [AnchorLocations.Left, AnchorLocations.Right],
connector: StateMachineConnector.type,
cssClass: "jtk-schema-common-edge",
events: {
[EVENT_CLICK]: (params: { edge: Edge, e:Event }) => {
// defaultPrevented is true when this was a delete edge click.
if (!params.e.defaultPrevented) {
toolkit.setSelection(params.edge)
}
}
},
overlays: [
{
type: LabelOverlay.type,
options: {
cssClass: "jtk-schema-delete-relationship",
label: "x",
events: {
[EVENT_TAP]: (params: { edge: Relationship, e:Event }) => {
consume(params.e)
toolkit.removeEdge(params.edge.id)
}
}
}
}
]
}
}
},
templates:{
tmplColumn:`<div class="jtk-schema-table-column" data-type="{{datatype}}"
data-primary-key="{{primaryKey}}" data-jtk-port="{{id}}" data-jtk-scope="{{datatype}}" data-jtk-source="true" data-jtk-target="true">
<div class="jtk-schema-table-column-delete jtk-schema-delete"/>
<div><span>{{name}}</span></div>
<div class="jtk-schema-table-column-edit jtk-schema-edit"/>
</div>`
},
modelEvents:[
{
event:EVENT_TAP,
selector:".jtk-schema-edit-name",
callback:(event: Event, eventTarget: HTMLElement, modelObject: SurfaceObjectInfo<Vertex>) => {
toolkit.setSelection(modelObject.obj)
}
},
{
event:EVENT_TAP,
selector:".jtk-schema-table-column-edit",
callback:(event: Event, eventTarget: HTMLElement, modelObject: SurfaceObjectInfo<Port>) => {
toolkit.setSelection(modelObject.obj)
}
},
{
event:EVENT_TAP,
selector:".jtk-schema-delete-vertex",
callback:(event: Event, eventTarget: HTMLElement, modelObject: SurfaceObjectInfo<Vertex>) => {
toolkit.removeNode(modelObject.obj)
}
},
{
event:EVENT_TAP,
selector:".jtk-schema-table-column-delete",
callback:(event: Event, eventTarget: HTMLElement, modelObject: SurfaceObjectInfo<Port>) => {
toolkit.removePort(modelObject.obj)
}
},
{
event:EVENT_TAP,
selector:'.jtk-schema-new-column',
callback:(event: Event, eventTarget: HTMLElement, modelObject: SurfaceObjectInfo<Node>) => {
toolkit.setSelection(toolkit.addPort(modelObject.obj, {
id:uuid(),
name:"new column",
datatype:DATATYPE_VARCHAR
}))
}
}
],
consumeRightClick:false
}

Nodes

Rendering nodes

There are two supported node types mapped in the nodes section of the view shown above. For users of a library integration this will be one key difference. For more information on views, see this page.

nodes:{
table:{
template:`<div class="jtk-schema-table jtk-schema-element">
<div class="jtk-schema-element-name">
<div class="jtk-schema-delete jtk-schema-delete-vertex" title="Delete table"/>
<span>{{name}}</span>
<div class="jtk-schema-buttons">
<div class="jtk-schema-edit-name jtk-schema-edit" title="Edit table name"/>
<div class="jtk-schema-new-column jtk-schema-add" title="Add table column"/>
</div>
</div>
<div class="jtk-schema-columns">
<r-each in="columns" key="id">
<r-tmpl id="tmplColumn"/>
</r-each>
</div>
</div>`
},
view:{
template:`<div class="jtk-schema-view jtk-schema-element">
<div class="jtk-schema-element-name">
<div class="jtk-schema-view-delete jtk-schema-delete jtk-schema-delete-vertex" title="Delete view"/>
<span>{{name}}</span>
<div class="jtk-schema-buttons">
<div class="jtk-schema-edit-name jtk-schema-edit" title="Edit view"/>
</div>
</div>
<div class="jtk-schema-view-details">{{query}}</div>
</div>`
}
}

Table nodes

Table nodes render the table name, a delete button, an edit button and 'add column' button. Handlers for these are specified in the modelEvents section of the render params.

Editing table name
{
event:EVENT_TAP,
selector:".jtk-schema-edit-name",
callback:(event: Event, eventTarget: HTMLElement, modelObject: SurfaceObjectInfo<Vertex>) => {
toolkit.setSelection(modelObject.obj)
}
}

To edit a table name, we simply set that table as the Toolkit's current selection. The inspector picks this up and displays an appropriate editor.

Deleting a table
{
event:EVENT_TAP,
selector:".jtk-schema-delete-vertex",
callback:(event: Event, eventTarget: HTMLElement, modelObject: SurfaceObjectInfo<Vertex>) => {
toolkit.removeNode(modelObject.obj)
}
}

To delete a table we call removeNode(..) on the toolkit instance, passing in modelObject.obj - which is the table vertex.

Adding a column
{
event:EVENT_TAP,
selector:'.jtk-schema-new-column',
callback:(event: Event, eventTarget: HTMLElement, modelObject: SurfaceObjectInfo<Node>) => {
toolkit.setSelection(toolkit.addPort(modelObject.obj, {
id:uuid(),
name:"new column",
datatype:DATATYPE_VARCHAR
}))
}
}

To add a new column we first call addPort on the Toolkit:

toolkit.addPort(modelObject.obj, {
id:uuid(),
name:"new column",
datatype:DATATYPE_VARCHAR
}

It is important to remember here that we specified a portDataProperty when we created the Toolkit instance. So the Toolkit will add this data to the table's columns array.

We then set this new port as the Toolkit's current selection:

toolkit.setSelection(toolkit.addPort(....))

The inspector will pick this up and display an appropriate editor.

Rendering columns

Columns are rendered inside each node's template like this:

<r-each in="columns" key="id">
<r-tmpl id="tmplColumn"/>
</r-each>

tmplColumn refers to a template that we have included in the templates section of the render parameters:

<div class="jtk-schema-table-column" 
data-type="{{datatype}}"
data-primary-key="{{primaryKey}}"
data-jtk-port="{{id}}"
data-jtk-scope="{{datatype}}"
data-jtk-source="true"
data-jtk-target="true">
<div class="jtk-schema-table-column-delete jtk-schema-delete"/>
<div><span>{{name}}</span></div>
<div class="jtk-schema-table-column-edit jtk-schema-edit"/>
</div>

The key things to note in this template are the various data-* attributes:

  • data-type We write this out so that we can target it via CSS. We use a different background color for each type.
  • data-primary-key This is also for CSS. This attribute results in a key icon being shown on the column's element.
  • data-jtk-port This identifies the given element to the Toolkit, and is used when dragging new edges or when loading data.
  • data-jtk-scope In this app, we only want to be able to connect columns of the same type. Edges dragged from some element with a data-jtk-scope attribute can only be dropped on other elements that have a data-jtk-scope attribute with the same value.
  • data-jtk-source="true" Instructs the Toolkit that edges can be dragged from this element.
  • data-jtk-target="true" Instructs the Toolkit that edges can be dropped on this element.

Dragging new nodes

This application uses a surface drop manager to support dragging new tables/views onto the canvas. This is created in the root level of the app code, after the surface has been rendered:

new SurfaceDropManager({
surface,
source: paletteElement,
selector: ".jtk-schema-palette-item",
dataGenerator: (el: Element) => {
const type = el.getAttribute("data-type")
return {
type,
name:type,
w: 120,
h: 80
}
},
allowDropOnEdge: false,
onVertexAdded:(v:Vertex):any => {
toolkit.setSelection(v)
}
})

The arguments are:

  • surface Surface to attach to
  • source The DOM element containing elements that can be dragged
  • selector A CSS3 selector identify the individual elements within source that can be dragged
  • dataGenerator Invoked when the user starts to drag an element, this method is responsible for generating an initial dataset to represent the new vertex.
  • allowDropOnEdge It is possible to drop new nodes on existing edges, in which case the Toolkit will split the given edge, and connect the original source to the new node, and the new node to the original target. But this app does not allow that.
  • onVertexAdded A callback that is invoked when the user has dropped a new vertex on the canvas. Here we set it as the Toolkit's current selection, which will result in the new vertex being displayed in the inspector.

Editing nodes

Nodes are edited via an inspector, declared in schema-inspector.js. This is a class that extends VanillaInspector:

const inspectorTemplates = {
[TMPL_TABLE_INSPECTOR] : `
<div class="jtk-inspector jtk-node-inspector">
<div>Name</div>
<input type="text" jtk-att="name" jtk-focus/>
</div>`,
[TMPL_VIEW_INSPECTOR] : `
<div class="jtk-inspector jtk-node-inspector">
<div>Name</div>
<input type="text" jtk-att="name" jtk-focus/>
<div>Query</div>
<textarea jtk-att="query" rows="10"/>
</div>`,
[TMPL_COLUMN_INSPECTOR] : `
<div class="jtk-inspector jtk-node-inspector">
<div>Name</div>
<input type="text" jtk-att="name" jtk-focus/>
<div>Datatype</div>
${datatypes.map(d =>`<label><input type="radio" jtk-att="datatype" name="datatype" value="${d.id}"/>${d.description}</label>`).join("")}
</div>`,
[TMPL_EDGE_INSPECTOR] : `
<div class="jtk-inspector jtk-edge-inspector">
<div>Cardinality</div>
${cardinalities.map(c => `<label><input type="radio" name="${PROPERTY_CARDINALITY}" jtk-att="${PROPERTY_CARDINALITY}" value="${c.id}"/>${c.name}</label>`).join("")}
</div>`
}

/**
* Inspector for nodes/edges. We extend `VanillaInspector` here and provide a resolver to get an appropriate
* template based on whether the inspector is editing a node/nodes or an edge.
*/
export class SchemaBuilderInspector extends VanillaInspector {

constructor(options:SchemaInspectorOptions) {
super(Object.assign(options, {
templateResolver:(obj:Base) => {
if (isNode(obj)) {
if (obj.type === TABLE) {
return inspectorTemplates[TMPL_TABLE_INSPECTOR]
} else if (obj.type === VIEW) {
return inspectorTemplates[TMPL_VIEW_INSPECTOR]
}
} else if (isEdge(obj)) {
return inspectorTemplates[TMPL_EDGE_INSPECTOR]
} else if (isPort(obj)) {
return inspectorTemplates[TMPL_COLUMN_INSPECTOR]
}
}
}) as VanillaInspectorOptions)
}
}

The key piece here is the templateResolver: VanillaInspector will call this method when it needs to render an inspector for some object (or set of objects).

In this application we have one inspector for table nodes, one for view nodes, one for edges, and one for columns (ports on table nodes).

For notes on the edge inspector, see editing edges below.


Edges

Rendering edges

Edges are rendered via the DEFAULT mapping in the edges section of the view shown above. For more information on views, see this page.

edges:{
[DEFAULT]: {
detachable: false,
anchor: [AnchorLocations.Left, AnchorLocations.Right],
connector: StateMachineConnector.type,
cssClass: "jtk-schema-common-edge",
events: {
[EVENT_CLICK]: (params: { edge: Edge, e:Event }) => {
// defaultPrevented is true when this was a delete edge click.
if (!params.e.defaultPrevented) {
toolkit.setSelection(params.edge)
}
}
},
overlays: [
{
type: LabelOverlay.type,
options: {
cssClass: "jtk-schema-delete-relationship",
label: "x",
events: {
[EVENT_TAP]: (params: { edge: Relationship, e:Event }) => {
consume(params.e)
toolkit.removeEdge(params.edge.id)
}
}
}
}
]
}
}

In this app, our anchor defines two locations - the left of the element, and the right of the element. This is a dynamic anchor, which will pick its location based upon the location of the other node in any edge connected to the element.

On click (of the edge path) we set the edge to be the Toolkit's current selection, which will result in the edge being edited in the inspector.

The edge has an 'x' overlay added to it, and a tap on this overlay will cause the edge to be deleted. Note the consume method: this is a helper method provided by the Toolkit to stop propagation and prevent the default response to an event.

Edge overlays

The default edge type mapping declares the 'X' overlay as discussed above:

{
type: LabelOverlay.type,
options: {
cssClass: "jtk-schema-delete-relationship",
label: "x",
events: {
[EVENT_TAP]: (params: { edge: Relationship, e:Event }) => {
consume(params.e)
toolkit.removeEdge(params.edge.id)
}
}
}
}

The other overlays you see in this app are added by configuring edge property mappings. We map the cardinality of each edge to a set of overlays that display the cardinality on the edge.

We declare the available cardinalities in an array:

export const cardinalities:Array<Cardinality> = [
{ id:ONE_TO_ONE, name:ONE_TO_ONE_NAME, labels:["1", "1"] },
{ id:ONE_TO_N, name:ONE_TO_N_NAME, labels:["1", "N"] },
{ id:N_TO_M, name:N_TO_M_NAME, labels:["N", "M"] }
]

From this array we create a list of mappings, with each mapping having an overlay at 0.1 and an overlay at 0.9, showing the cardinality at each side of the relationship:

export const cardinalityMappings = {}
cardinalities.forEach(c => {
cardinalityMappings[c.id] = {
overlays:[
{ type:LabelOverlay.type, options:{ label:c.labels[0], location:0.1, cssClass:CLASS_SCHEMA_RELATIONSHIP_CARDINALITY }},
{ type:LabelOverlay.type, options:{ label:c.labels[1], location:0.9, cssClass:CLASS_SCHEMA_RELATIONSHIP_CARDINALITY }}
]
}
})

Lastly, we use this list of cardinality mappings to create a set of edge mappings, keyed by the cardinality member of each edge's backing data:

export const edgeMappings = [
{
property:PROPERTY_CARDINALITY,
mappings:cardinalityMappings
}
]

These are passed to the render call:

propertyMappings:{
edgeMappings
}

Default edge type

The beforeStartConnect method that we pass in to the Toolkit's constructor is the means we use to set the default payload for a new edge dragged by the user:

  beforeStartConnect:(node, edgeType) => {
return {
cardinality:cardinalities[0].id
}
}

Editing edges

When the user clicks an edge, the inspector detects the click and displays the edge, allowing the user to edit the edge's cardinality from a set of radio buttons.


Miniview

We use a miniview in this starter app. It is declared as one of the plugins in the render call:


const miniviewElement = mainElement.querySelector(".miniview"),

...

plugins:[
{
type: MiniviewPlugin.type,
options: {
container: miniviewElement
}
},
]


Controls

Here we use another component that was created for our demonstrations but which now ships with the Toolkit, as we thought it might be useful for others:

import { ControlsComponent } from "@jsplumbtoolkit/browser-ui"

const renderer = toolkit.render(....)
const controlsElement = document.querySelector(".jtk-controls-container")


CSS

Styles for the app itself are in app.css.

This app also imports a few of the CSS files that ship with the Toolkit:

<link rel="stylesheet" href="node_modules/@jsplumbtoolkit/browser-ui/css/jsplumbtoolkit.css">
<link rel="stylesheet" href="node_modules/@jsplumbtoolkit/browser-ui/css/jsplumbtoolkit-connector-editors.css">
<link rel="stylesheet" href="node_modules/@jsplumbtoolkit/browser-ui/css/jsplumbtoolkit-controls.css">

jsplumbtoolkit.css provides basic 'structural' CSS, and it is recommended that you include this stylesheet in your cascade, at least when you first start developing with the Toolkit.

jsplumbtoolkit-connector-editors.css contains a mixture of structural and also theming styles, for use by the EdgePathEditor.

jsplumbtoolkit-controls.css contains styles for the controls component, which was created for our demonstrations but since 6.2.0 has been shipped with the Toolkit as it may be useful for others.