390 lines
18 KiB
HTML
390 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Kanban Board</title>
|
|
<meta name="description" content="An interactive Kanban board editor, a visual control used for organizing work items." />
|
|
<!-- Copyright 1998-2017 by Northwoods Software Corporation. -->
|
|
<meta charset="UTF-8">
|
|
<script src="../release/go.js"></script>
|
|
<link href='https://fonts.googleapis.com/css?family=Lato:300,400,700' rel='stylesheet' type='text/css'>
|
|
<script src="../assets/js/goSamples.js"></script> <!-- this is only for the GoJS Samples framework -->
|
|
<script id="code">
|
|
// For the layout
|
|
var MINLENGTH = 200; // this controls the minimum length of any swimlane
|
|
var MINBREADTH = 100; // this controls the minimum breadth of any non-collapsed swimlane
|
|
|
|
// some shared functions
|
|
|
|
// this is called after nodes have been moved
|
|
function relayoutDiagram() {
|
|
myDiagram.selection.each(function(n) { n.invalidateLayout(); });
|
|
myDiagram.layoutDiagram();
|
|
}
|
|
|
|
// compute the minimum size of the whole diagram needed to hold all of the Lane Groups
|
|
function computeMinPoolSize() {
|
|
var len = MINLENGTH;
|
|
myDiagram.findTopLevelGroups().each(function(lane) {
|
|
var holder = lane.placeholder;
|
|
if (holder !== null) {
|
|
var sz = holder.actualBounds;
|
|
len = Math.max(len, sz.height);
|
|
}
|
|
var box = lane.selectionObject;
|
|
// naturalBounds instead of actualBounds to disregard the shape's stroke width
|
|
len = Math.max(len, box.naturalBounds.height);
|
|
});
|
|
return new go.Size(NaN, len);
|
|
}
|
|
|
|
// compute the minimum size for a particular Lane Group
|
|
function computeLaneSize(lane) {
|
|
// assert(lane instanceof go.Group);
|
|
var sz = computeMinLaneSize(lane);
|
|
if (lane.isSubGraphExpanded) {
|
|
var holder = lane.placeholder;
|
|
if (holder !== null) {
|
|
var hsz = holder.actualBounds;
|
|
sz.width = Math.max(sz.width, hsz.width);
|
|
}
|
|
}
|
|
// minimum breadth needs to be big enough to hold the header
|
|
var hdr = lane.findObject("HEADER");
|
|
if (hdr !== null) sz.width = Math.max(sz.width, hdr.actualBounds.width);
|
|
return sz;
|
|
}
|
|
|
|
// determine the minimum size of a Lane Group, even if collapsed
|
|
function computeMinLaneSize(lane) {
|
|
if (!lane.isSubGraphExpanded) return new go.Size(1, MINLENGTH);
|
|
return new go.Size(MINBREADTH, MINLENGTH);
|
|
}
|
|
|
|
|
|
// define a custom grid layout that makes sure the length of each lane is the same
|
|
// and that each lane is broad enough to hold its subgraph
|
|
function PoolLayout() {
|
|
go.GridLayout.call(this);
|
|
this.cellSize = new go.Size(1, 1);
|
|
this.wrappingColumn = Infinity;
|
|
this.wrappingWidth = Infinity;
|
|
this.spacing = new go.Size(0, 0);
|
|
this.alignment = go.GridLayout.Position;
|
|
}
|
|
go.Diagram.inherit(PoolLayout, go.GridLayout);
|
|
|
|
/** @override */
|
|
PoolLayout.prototype.doLayout = function(coll) {
|
|
var diagram = this.diagram;
|
|
if (diagram === null) return;
|
|
diagram.startTransaction("PoolLayout");
|
|
// make sure all of the Group Shapes are big enough
|
|
var minsize = computeMinPoolSize();
|
|
diagram.findTopLevelGroups().each(function(lane) {
|
|
if (!(lane instanceof go.Group)) return;
|
|
var shape = lane.selectionObject;
|
|
if (shape !== null) { // change the desiredSize to be big enough in both directions
|
|
var sz = computeLaneSize(lane);
|
|
shape.width = (!isNaN(shape.width)) ? Math.max(shape.width, sz.width) : sz.width;
|
|
shape.height = (isNaN(shape.height) ? minsize.height : Math.max(shape.height, minsize.height));
|
|
var cell = lane.resizeCellSize;
|
|
if (!isNaN(shape.width) && !isNaN(cell.width) && cell.width > 0) shape.width = Math.ceil(shape.width / cell.width) * cell.width;
|
|
if (!isNaN(shape.height) && !isNaN(cell.height) && cell.height > 0) shape.height = Math.ceil(shape.height / cell.height) * cell.height;
|
|
}
|
|
});
|
|
// now do all of the usual stuff, according to whatever properties have been set on this GridLayout
|
|
go.GridLayout.prototype.doLayout.call(this, coll);
|
|
diagram.commitTransaction("PoolLayout");
|
|
};
|
|
// end PoolLayout class
|
|
|
|
|
|
function init() {
|
|
if (window.goSamples) goSamples(); // init for these samples -- you don't need to call this
|
|
var $ = go.GraphObject.make;
|
|
|
|
myDiagram =
|
|
$(go.Diagram, "myDiagramDiv",
|
|
{
|
|
// start everything in the middle of the viewport
|
|
contentAlignment: go.Spot.TopCenter,
|
|
// use a simple layout to stack the top-level Groups next to each other
|
|
layout: $(PoolLayout),
|
|
// disallow nodes to be dragged to the diagram's background
|
|
mouseDrop: function(e) {
|
|
e.diagram.currentTool.doCancel();
|
|
},
|
|
// a clipboard copied node is pasted into the original node's group (i.e. lane).
|
|
"commandHandler.copiesGroupKey": true,
|
|
// automatically re-layout the swim lanes after dragging the selection
|
|
"SelectionMoved": relayoutDiagram, // this DiagramEvent listener is
|
|
"SelectionCopied": relayoutDiagram, // defined above
|
|
"animationManager.isEnabled": false,
|
|
"undoManager.isEnabled": true
|
|
});
|
|
|
|
// Customize the dragging tool:
|
|
// When dragging a Node set its opacity to 0.7 and move it to the foreground layer
|
|
myDiagram.toolManager.draggingTool.doActivate = function() {
|
|
go.DraggingTool.prototype.doActivate.call(this);
|
|
this.currentPart.opacity = 0.7;
|
|
this.currentPart.layerName = "Foreground";
|
|
}
|
|
myDiagram.toolManager.draggingTool.doDeactivate = function() {
|
|
this.currentPart.opacity = 1;
|
|
this.currentPart.layerName = "";
|
|
go.DraggingTool.prototype.doDeactivate.call(this);
|
|
}
|
|
|
|
// There are only three note colors by default, blue, red, and yellow but you could add more here:
|
|
var noteColors = ['#009CCC', '#CC293D', '#FFD700'];
|
|
function getNoteColor(num) {
|
|
return noteColors[Math.min(num, noteColors.length - 1)];
|
|
}
|
|
|
|
myDiagram.nodeTemplate =
|
|
$(go.Node, "Horizontal",
|
|
new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
|
|
$(go.Shape, "Rectangle", {
|
|
fill: '#009CCC', strokeWidth: 1, stroke: '#009CCC',
|
|
width: 6, stretch: go.GraphObject.Vertical, alignment: go.Spot.Left,
|
|
// if a user clicks the colored portion of a node, cycle through colors
|
|
click: function (e, obj) {
|
|
myDiagram.startTransaction("Update node color");
|
|
var newColor = parseInt(obj.part.data.color) + 1;
|
|
if (newColor > noteColors.length-1) newColor = 0;
|
|
myDiagram.model.setDataProperty(obj.part.data, "color", newColor);
|
|
myDiagram.commitTransaction("Update node color");
|
|
}
|
|
},
|
|
new go.Binding("fill", "color", getNoteColor),
|
|
new go.Binding("stroke", "color", getNoteColor)
|
|
),
|
|
$(go.Panel, "Auto",
|
|
$(go.Shape, "Rectangle", { fill: "white", stroke: '#CCCCCC' }),
|
|
$(go.Panel, "Table",
|
|
{ width: 130, minSize: new go.Size(NaN, 50) },
|
|
$(go.TextBlock,
|
|
{
|
|
name: 'TEXT',
|
|
margin: 6, font: '11px Lato, sans-serif', editable: true,
|
|
stroke: "#000", maxSize: new go.Size(130, NaN),
|
|
alignment: go.Spot.TopLeft
|
|
},
|
|
new go.Binding("text", "text").makeTwoWay())
|
|
)
|
|
)
|
|
);
|
|
|
|
// unmovable node that acts as a button
|
|
myDiagram.nodeTemplateMap.add('newbutton',
|
|
$(go.Node, "Horizontal",
|
|
{
|
|
selectable: false,
|
|
click: function(e, node) {
|
|
myDiagram.startTransaction('add node');
|
|
var newdata = { group:"Problems", loc:"0 50", text: "New item " + node.containingGroup.memberParts.count, color: 0 };
|
|
myDiagram.model.addNodeData(newdata);
|
|
myDiagram.commitTransaction('add node');
|
|
var node = myDiagram.findNodeForData(newdata);
|
|
myDiagram.select(node);
|
|
myDiagram.commandHandler.editTextBlock();
|
|
},
|
|
background: 'white'
|
|
},
|
|
$(go.Panel, "Auto",
|
|
$(go.Shape, "Rectangle", { strokeWidth: 0, stroke: null, fill: '#6FB583' }),
|
|
$(go.Shape, "PlusLine", { margin: 6, strokeWidth: 2, width: 12, height: 12, stroke: 'white', background: '#6FB583' })
|
|
),
|
|
$(go.TextBlock, "New item", { font: '10px Lato, sans-serif', margin: 6, })
|
|
)
|
|
);
|
|
|
|
// While dragging, highlight the dragged-over group
|
|
function highlightGroup(grp, show) {
|
|
if (show) {
|
|
var part = myDiagram.toolManager.draggingTool.currentPart;
|
|
if (part.containingGroup !== grp) {
|
|
grp.isHighlighted = true;
|
|
return;
|
|
}
|
|
}
|
|
grp.isHighlighted = false;
|
|
}
|
|
|
|
myDiagram.groupTemplate =
|
|
$(go.Group, "Vertical",
|
|
{
|
|
selectable: false,
|
|
selectionObjectName: "SHAPE", // even though its not selectable, this is used in the layout
|
|
layerName: "Background", // all lanes are always behind all nodes and links
|
|
layout: $(go.GridLayout, // automatically lay out the lane's subgraph
|
|
{
|
|
wrappingColumn: 1,
|
|
cellSize: new go.Size(1, 1),
|
|
spacing: new go.Size(5, 5),
|
|
alignment: go.GridLayout.Position,
|
|
comparer: function(a, b) { // can re-order tasks within a lane
|
|
var ay = a.location.y;
|
|
var by = b.location.y;
|
|
if (isNaN(ay) || isNaN(by)) return 0;
|
|
if (ay < by) return -1;
|
|
if (ay > by) return 1;
|
|
return 0;
|
|
}
|
|
}),
|
|
click: function(e, grp) { // allow simple click on group to clear selection
|
|
if (!e.shift && !e.control && !e.meta) e.diagram.clearSelection();
|
|
},
|
|
computesBoundsAfterDrag: true, // needed to prevent recomputing Group.placeholder bounds too soon
|
|
handlesDragDropForMembers: true, // don't need to define handlers on member Nodes and Links
|
|
mouseDragEnter: function(e, grp, prev) { highlightGroup(grp, true); },
|
|
mouseDragLeave: function(e, grp, next) { highlightGroup(grp, false); },
|
|
mouseDrop: function(e, grp) { // dropping a copy of some Nodes and Links onto this Group adds them to this Group
|
|
// don't allow drag-and-dropping a mix of regular Nodes and Groups
|
|
if (e.diagram.selection.all(function(n) { return !(n instanceof go.Group); })) {
|
|
var ok = grp.addMembers(grp.diagram.selection, true);
|
|
if (!ok) grp.diagram.currentTool.doCancel();
|
|
}
|
|
},
|
|
subGraphExpandedChanged: function(grp) {
|
|
var shp = grp.selectionObject;
|
|
if (grp.diagram.undoManager.isUndoingRedoing) return;
|
|
if (grp.isSubGraphExpanded) {
|
|
shp.width = grp._savedBreadth;
|
|
} else {
|
|
grp._savedBreadth = shp.width;
|
|
shp.width = NaN;
|
|
}
|
|
}
|
|
},
|
|
new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
|
|
new go.Binding("isSubGraphExpanded", "expanded").makeTwoWay(),
|
|
// the lane header consisting of a TextBlock and an expander button
|
|
$(go.Panel, "Horizontal",
|
|
{ name: "HEADER",
|
|
angle: 0, // maybe rotate the header to read sideways going up
|
|
alignment: go.Spot.Left },
|
|
$("SubGraphExpanderButton", { margin: 5 }), // this remains always visible
|
|
$(go.Panel, "Horizontal", // this is hidden when the swimlane is collapsed
|
|
new go.Binding("visible", "isSubGraphExpanded").ofObject(),
|
|
$(go.TextBlock, // the lane label
|
|
{ font: "15px Lato, sans-serif", editable: true, margin: new go.Margin(2, 0, 0, 0) },
|
|
new go.Binding("text", "text").makeTwoWay())
|
|
)
|
|
), // end Horizontal Panel
|
|
$(go.Panel, "Auto", // the lane consisting of a background Shape and a Placeholder representing the subgraph
|
|
$(go.Shape, "Rectangle", // this is the resized object
|
|
{ name: "SHAPE", fill: "#F1F1F1", stroke: null, strokeWidth: 4 },
|
|
new go.Binding("fill", "isHighlighted", function(h) { return h ? "#D6D6D6" : "#F1F1F1"; }).ofObject(),
|
|
new go.Binding("desiredSize", "size", go.Size.parse).makeTwoWay(go.Size.stringify)),
|
|
$(go.Placeholder,
|
|
{ padding: 12, alignment: go.Spot.TopLeft }),
|
|
$(go.TextBlock, // this TextBlock is only seen when the swimlane is collapsed
|
|
{ name: "LABEL",
|
|
font: "15px Lato, sans-serif", editable: true,
|
|
angle: 90, alignment: go.Spot.TopLeft, margin: new go.Margin(4, 0, 0, 2) },
|
|
new go.Binding("visible", "isSubGraphExpanded", function(e) { return !e; }).ofObject(),
|
|
new go.Binding("text", "text").makeTwoWay())
|
|
) // end Auto Panel
|
|
); // end Group
|
|
|
|
load();
|
|
|
|
// Set up a Part as a legend, and place it directly on the diagram
|
|
myDiagram.add(
|
|
$(go.Part, "Table",
|
|
{ position: new go.Point(300, 10), selectable: false },
|
|
$(go.TextBlock, "Key",
|
|
{ row: 0, font: "700 14px Droid Serif, sans-serif" }), // end row 0
|
|
$(go.Panel, "Horizontal",
|
|
{ row: 1, alignment: go.Spot.Left },
|
|
$(go.Shape, "Rectangle",
|
|
{ desiredSize: new go.Size(10, 10), fill: '#CC293D', margin: 5 }),
|
|
$(go.TextBlock, "Halted",
|
|
{ font: "700 13px Droid Serif, sans-serif" })
|
|
), // end row 1
|
|
$(go.Panel, "Horizontal",
|
|
{ row: 2, alignment: go.Spot.Left },
|
|
$(go.Shape, "Rectangle",
|
|
{ desiredSize: new go.Size(10, 10), fill: '#FFD700', margin: 5 }),
|
|
$(go.TextBlock, "In Progress",
|
|
{ font: "700 13px Droid Serif, sans-serif" })
|
|
), // end row 2
|
|
$(go.Panel, "Horizontal",
|
|
{ row: 3, alignment: go.Spot.Left },
|
|
$(go.Shape, "Rectangle",
|
|
{ desiredSize: new go.Size(10, 10), fill: '#009CCC', margin: 5 }),
|
|
$(go.TextBlock, "Completed",
|
|
{ font: "700 13px Droid Serif, sans-serif" })
|
|
) // end row 3
|
|
));
|
|
|
|
} // end init
|
|
|
|
// Show the diagram's model in JSON format
|
|
function save() {
|
|
document.getElementById("mySavedModel").value = myDiagram.model.toJson();
|
|
myDiagram.isModified = false;
|
|
}
|
|
function load() {
|
|
myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);
|
|
myDiagram.delayInitialization(relayoutDiagram);
|
|
}
|
|
</script>
|
|
</head>
|
|
<body onload="init()">
|
|
<div id="sample">
|
|
<div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:500px;"></div>
|
|
<p>A Kanban board is a work and workflow visualization used to communicate the status and progress of work items. Click on the color of a note to cycle through colors.</p>
|
|
<p>
|
|
This design and implementation were adapted from the <a href="swimLanesVertical.html">Swim Lanes (vertical)</a> sample.
|
|
Unlike that sample:
|
|
<ul>
|
|
<li>there are no Links</li>
|
|
<li>lanes cannot be nested into "pools"</li>
|
|
<li>lanes cannot be resized</li>
|
|
<li>the user cannot drop tasks into the diagram's background</li>
|
|
<li>all tasks are ordered within a single column; the user can rearrange the order</li>
|
|
<li>tasks can freely be moved into other lanes</li>
|
|
<li>lanes are not movable or copyable or deletable</li>
|
|
</ul>
|
|
</p>
|
|
<button id="SaveButton" onclick="save()">Save</button>
|
|
<button onclick="load()">Load</button>
|
|
Diagram Model saved in JSON format:
|
|
<br />
|
|
<textarea id="mySavedModel" style="width:100%;height:300px">
|
|
{ "class": "go.GraphLinksModel",
|
|
"nodeDataArray": [
|
|
{"key":"Problems", "text":"Problems", "isGroup":true, "loc":"0 23.52284749830794" },
|
|
{"key":"Reproduced", "text":"Reproduced", "isGroup":true, "color":"0", "loc":"109 23.52284749830794" },
|
|
{"key":"Identified", "text":"Identified", "isGroup":true, "color":"0", "loc":"235 23.52284749830794" },
|
|
{"key":"Fixing", "text":"Fixing", "isGroup":true, "color":"0", "loc":"343 23.52284749830794" },
|
|
{"key":"Reviewing", "text":"Reviewing", "isGroup":true, "color":"0", "loc":"451 23.52284749830794"},
|
|
{"key":"Testing", "text":"Testing", "isGroup":true, "color":"0", "loc":"562 23.52284749830794" },
|
|
{"key":"Customer", "text":"Customer", "isGroup":true, "color":"0", "loc":"671 23.52284749830794" },
|
|
{"key":-1, "group":"Problems", "category":"newbutton", "loc":"12 35.52284749830794" },
|
|
{"key":1, "text":"text for oneA", "group":"Problems", "color":"0", "loc":"12 35.52284749830794"},
|
|
{"key":2, "text":"text for oneB", "group":"Problems", "color":"1", "loc":"12 65.52284749830794"},
|
|
{"key":3, "text":"text for oneC", "group":"Problems", "color":"0", "loc":"12 95.52284749830794"},
|
|
{"key":4, "text":"text for oneD", "group":"Problems", "color":"1", "loc":"12 125.52284749830794"},
|
|
{"key":5, "text":"text for twoA", "group":"Reproduced", "color":"1", "loc":"121 35.52284749830794"},
|
|
{"key":6, "text":"text for twoB", "group":"Reproduced", "color":"1", "loc":"121 65.52284749830794"},
|
|
{"key":7, "text":"text for twoC", "group":"Identified", "color":"0", "loc":"247 35.52284749830794"},
|
|
{"key":8, "text":"text for twoD", "group":"Fixing", "color":"0", "loc":"355 35.52284749830794"},
|
|
{"key":9, "text":"text for twoE", "group":"Reviewing", "color":"0", "loc":"463 35.52284749830794"},
|
|
{"key":10, "text":"text for twoF", "group":"Reviewing", "color":"1", "loc":"463 65.52284749830794"},
|
|
{"key":11, "text":"text for twoG", "group":"Testing", "color":"0", "loc":"574 35.52284749830794"},
|
|
{"key":12, "text":"text for fourA", "group":"Customer", "color":"1", "loc":"683 35.52284749830794"},
|
|
{"key":13, "text":"text for fourB", "group":"Customer", "color":"1", "loc":"683 65.52284749830794"},
|
|
{"key":14, "text":"text for fourC", "group":"Customer", "color":"1", "loc":"683 95.52284749830794"},
|
|
{"key":15, "text":"text for fourD", "group":"Customer", "color":"0", "loc":"683 125.52284749830794"},
|
|
{"key":16, "text":"text for fiveA", "group":"Customer", "color":"0", "loc":"683 155.52284749830795"}
|
|
],
|
|
"linkDataArray": []}
|
|
</textarea>
|
|
</div>
|
|
</body>
|
|
</html> |