[Bps-public-commit] rt-extension-lifecycleui branch, master, updated. b26cb45ab608b0d0844cf07e65f5e659a5c65c04

Shawn Moore shawn at bestpractical.com
Thu Sep 7 17:14:04 EDT 2017


The branch, master has been updated
       via  b26cb45ab608b0d0844cf07e65f5e659a5c65c04 (commit)
       via  148ab7307b9a324c883ecfc8f01e50368d30be1d (commit)
       via  3c354f928b258420783d95ca09b54f36eac8be4e (commit)
       via  cdb5f40a3cd02af15389f2fdb3b0ff8c7740c815 (commit)
       via  67e19b975e7ff00da99e4962a48495ce84a01c9d (commit)
       via  4bbc8c5cf9f0405a280795941802435594060f83 (commit)
       via  3bd048b54f289c865b44372ad97475845c7bc9ee (commit)
       via  79eac8b89a49d6b24d9b136539726f9edb708260 (commit)
       via  98c3d84bca1078bc4258588b55b1a2019c5a82cb (commit)
       via  61c70eeb2f7473803629f3fc1a6ea78984c0a04f (commit)
       via  140b2f2f505a19ee1e9d261ffc61be530448ab4d (commit)
       via  df756dda8e2c0e29e145d530e282dab744860a49 (commit)
       via  3039b2f0ef56cf27f5897dfe9ada7f857ba42dff (commit)
       via  273e91f10779f74ca85cac4913415b88ce31ef79 (commit)
       via  c17a7447cc8daf3df328084bfd58439c922223e0 (commit)
       via  a7240ae314e9d63b70ba53ea7db6ea3b166b6c7a (commit)
       via  02488118c00a7a9ab74fa761daedfd5a0ada0123 (commit)
       via  1cc2a858719c054068effe27121ec21ffcf565e4 (commit)
       via  189651226219b8500341698632e3f3f4d266aff5 (commit)
       via  d4aa5976fe376461096988acea99b0c53ef7c73a (commit)
      from  6bf469d8ed93f0b4c3727df8e08b5dcad484ad63 (commit)

Summary of changes:
 .../Ticket/Elements/ShowSummary/AfterReminders     |   5 +-
 html/Elements/LifecycleGraph                       |  39 +++--
 html/Elements/LifecycleInteractive                 |  91 +++++++++++
 lib/RT/Extension/LifecycleUI.pm                    |   2 +
 static/css/lifecycleui-editor.css                  |   3 +
 static/css/lifecycleui-viewer-interactive.css      |  20 +++
 static/css/lifecycleui-viewer.css                  |   2 +-
 static/js/lifecycleui-editor.js                    | 171 ++++++++++-----------
 static/js/lifecycleui-model.js                     | 138 +++++++++++++----
 static/js/lifecycleui-viewer-interactive.js        |  66 ++++++++
 static/js/lifecycleui-viewer.js                    |   4 +-
 11 files changed, 402 insertions(+), 139 deletions(-)
 create mode 100644 html/Elements/LifecycleInteractive
 create mode 100644 static/css/lifecycleui-viewer-interactive.css
 create mode 100644 static/js/lifecycleui-viewer-interactive.js

- Log -----------------------------------------------------------------
commit d4aa5976fe376461096988acea99b0c53ef7c73a
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 16:00:30 2017 +0000

    Add a new ViewerInteractive subclass

diff --git a/html/Callbacks/RT-Extension-LifecycleUI/Ticket/Elements/ShowSummary/AfterReminders b/html/Callbacks/RT-Extension-LifecycleUI/Ticket/Elements/ShowSummary/AfterReminders
index 1d3c1e7..88ac753 100644
--- a/html/Callbacks/RT-Extension-LifecycleUI/Ticket/Elements/ShowSummary/AfterReminders
+++ b/html/Callbacks/RT-Extension-LifecycleUI/Ticket/Elements/ShowSummary/AfterReminders
@@ -9,5 +9,8 @@ return unless $config eq 'readonly' || $config eq 'interactive';
     title => loc("Lifecycle"),
     class => 'ticket-info-lifecycle',
 &>
-    <& /Elements/LifecycleGraph, Ticket => $Ticket &>
+    <& /Elements/LifecycleGraph,
+           Ticket => $Ticket,
+           Interactive => ($config eq 'interactive'),
+    &>
 </&>
diff --git a/html/Elements/LifecycleGraph b/html/Elements/LifecycleGraph
index 7bb5518..546016d 100644
--- a/html/Elements/LifecycleGraph
+++ b/html/Elements/LifecycleGraph
@@ -1,4 +1,4 @@
-<div class="lifecycle-ui<% $Editing ? ' editing' : '' %>" id="lifecycle-<% $id %>">
+<div class="lifecycle-ui<% $Editing ? ' editing' : '' %><% $Interactive ? ' interactive' : '' %>" id="lifecycle-<% $id %>">
     <div class="overlay-buttons">
         <button class="zoom-in">+</button>
         <button class="zoom-reset">0</button>
@@ -32,7 +32,11 @@
                 var editor = new RT.LifecycleEditor();
                 editor.initializeEditor(container, name, config, ticketStatus);
 % } else {
+% if ($Interactive) {
+                var viewer = new RT.LifecycleViewerInteractive();
+% } else {
                 var viewer = new RT.LifecycleViewer();
+% }
                 viewer.initializeViewer(container, name, config, ticketStatus);
 % }
             });
@@ -41,6 +45,7 @@
 </div>
 <%ARGS>
 $Editing => 0
+$Interactive => 0
 $Lifecycle => undef
 $Ticket => undef
 </%ARGS>
diff --git a/lib/RT/Extension/LifecycleUI.pm b/lib/RT/Extension/LifecycleUI.pm
index 464bc8a..0bade1a 100644
--- a/lib/RT/Extension/LifecycleUI.pm
+++ b/lib/RT/Extension/LifecycleUI.pm
@@ -9,10 +9,12 @@ RT->AddJavaScript("d3.min.js");
 RT->AddJavaScript("handlebars-4.0.6.min.js");
 RT->AddJavaScript("lifecycleui-model.js");
 RT->AddJavaScript("lifecycleui-viewer.js");
+RT->AddJavaScript("lifecycleui-viewer-interactive.js");
 RT->AddJavaScript("lifecycleui-editor.js");
 
 RT->AddStyleSheets("lifecycleui.css");
 RT->AddStyleSheets("lifecycleui-viewer.css");
+RT->AddStyleSheets("lifecycleui-viewer-interactive.css");
 RT->AddStyleSheets("lifecycleui-editor.css");
 
 $RT::Config::META{Lifecycles}{EditLink} = RT->Config->Get('WebURL') . 'Admin/Lifecycles/';
diff --git a/static/css/lifecycleui-viewer-interactive.css b/static/css/lifecycleui-viewer-interactive.css
new file mode 100644
index 0000000..e69de29
diff --git a/static/js/lifecycleui-viewer-interactive.js b/static/js/lifecycleui-viewer-interactive.js
new file mode 100644
index 0000000..8069068
--- /dev/null
+++ b/static/js/lifecycleui-viewer-interactive.js
@@ -0,0 +1,18 @@
+jQuery(function () {
+    var Super = RT.LifecycleViewer;
+
+    function Interactive (container) {
+        Super.call(this);
+    };
+    Interactive.prototype = Object.create(Super.prototype);
+
+    Interactive.prototype.clickedStatus = function (d) {
+    };
+
+    Interactive.prototype.initializeViewer = function (node, name, config, focusStatus) {
+         Super.prototype.initializeViewer.call(this, node, name, config, focusStatus);
+    };
+
+    RT.LifecycleViewerInteractive = Interactive;
+});
+

commit 189651226219b8500341698632e3f3f4d266aff5
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 16:00:45 2017 +0000

    Reduce grid size
    
    This makes it easier to make things look just so, without sacrificing
    repeatability too much

diff --git a/static/js/lifecycleui-viewer.js b/static/js/lifecycleui-viewer.js
index 167823b..d203dee 100644
--- a/static/js/lifecycleui-viewer.js
+++ b/static/js/lifecycleui-viewer.js
@@ -3,7 +3,7 @@ jQuery(function () {
         this.width  = 809;
         this.height = 500;
         this.statusCircleRadius = 35;
-        this.gridSize = 25;
+        this.gridSize = 10;
         this.padding = this.statusCircleRadius;
     };
 

commit 1cc2a858719c054068effe27121ec21ffcf565e4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 17:16:43 2017 +0000

    Show/hide undo button based on whether there are undo frames

diff --git a/static/css/lifecycleui-editor.css b/static/css/lifecycleui-editor.css
index 71cdb5a..e047a1d 100644
--- a/static/css/lifecycleui-editor.css
+++ b/static/css/lifecycleui-editor.css
@@ -105,3 +105,6 @@
     color: gray;
 }
 
+.lifecycle-ui .invisible {
+    visibility: hidden;
+}
diff --git a/static/js/lifecycleui-editor.js b/static/js/lifecycleui-editor.js
index 7fba169..c3d21e2 100644
--- a/static/js/lifecycleui-editor.js
+++ b/static/js/lifecycleui-editor.js
@@ -517,6 +517,11 @@ jQuery(function () {
             }
             return payload;
         };
+
+        self.lifecycle.undoStackChangedCallback = function () {
+            d3.select(node).select('button.undo').classed('invisible', !self.lifecycle.hasUndoStack());
+        };
+        self.lifecycle.undoStackChangedCallback();
     };
 
     RT.LifecycleEditor = Editor;
diff --git a/static/js/lifecycleui-model.js b/static/js/lifecycleui-model.js
index b508d18..d6a1fcf 100644
--- a/static/js/lifecycleui-model.js
+++ b/static/js/lifecycleui-model.js
@@ -673,6 +673,14 @@ jQuery(function () {
         }
         undoStack.push([entry, extra]);
         this._undoStack = undoStack;
+
+        if (this.undoStackChangedCallback) {
+            this.undoStackChangedCallback();
+        }
+    };
+
+    Lifecycle.prototype.hasUndoStack = function () {
+        return this._undoStack.length > 0;
     };
 
     Lifecycle.prototype.undo = function () {
@@ -693,6 +701,10 @@ jQuery(function () {
 
         this._undoStack = undoStack;
 
+        if (this.undoStackChangedCallback) {
+            this.undoStackChangedCallback();
+        }
+
         return payload[1];
     };
 

commit 02488118c00a7a9ab74fa761daedfd5a0ada0123
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 18:10:41 2017 +0000

    Piggyback on focusItem/defocus rather than select*
    
    This simplifies the logic of selecting and/or focusing an item

diff --git a/static/js/lifecycleui-editor.js b/static/js/lifecycleui-editor.js
index c3d21e2..6a22b89 100644
--- a/static/js/lifecycleui-editor.js
+++ b/static/js/lifecycleui-editor.js
@@ -151,8 +151,7 @@ jQuery(function () {
             }
             else {
                 lifecycle.deleteItemForKey(self.inspectorNode._key);
-                self.deselectAll(true);
-                self.renderDisplay();
+                self.defocus();
             }
         });
 
@@ -180,13 +179,13 @@ jQuery(function () {
             inspector.find('a.select-transition[data-from="'+fromStatus+'"][data-to="'+toStatus+'"]').closest('li').removeClass('hidden');
 
             self.renderDisplay();
-            self.selectStatus(self.inspectorNode.name);
         });
 
         inspector.on('click', 'a.select-status', function (e) {
             e.preventDefault();
             var statusName = jQuery(this).data('name');
-            self.selectStatus(statusName);
+            var d = self.lifecycle.statusObjectForName(statusName);
+            self.focusItem(d);
         });
 
         inspector.on('click', 'a.select-transition', function (e) {
@@ -195,13 +194,15 @@ jQuery(function () {
             var fromStatus = button.data('from');
             var toStatus   = button.data('to');
 
-            self.selectTransition(fromStatus, toStatus);
+            var d = self.lifecycle.hasTransition(fromStatus, toStatus);
+            self.focusItem(d);
         });
 
         inspector.on('click', 'a.select-decoration', function (e) {
             e.preventDefault();
             var key = jQuery(this).data('key');
-            self.selectDecoration(key);
+            var d = self.lifecycle.itemForKey(key);
+            self.focusItem(d);
         });
 
         inspector.on('click', '.add-status', function (e) {
@@ -236,14 +237,6 @@ jQuery(function () {
                 return;
             }
 
-            if (payload.inspectorKey) {
-                var node = self.lifecycle.itemForKey(payload.inspectorKey);
-                self.setInspectorContent(node);
-            }
-            else {
-                self.setInspectorContent(null);
-            }
-
             if (payload.focusKey) {
                 var node = self.lifecycle.itemForKey(payload.focusKey);
                 self.focusItem(node);
@@ -251,9 +244,6 @@ jQuery(function () {
             else {
                 self.defocus();
             }
-
-            self.renderDisplay();
-
         });
 
         inspector.on('click', 'button.redo', function (e) {
@@ -261,53 +251,6 @@ jQuery(function () {
         });
     };
 
-    Editor.prototype.deselectAll = function (clearSelection) {
-        var svg = this.svg;
-
-        this.removePointHandles();
-
-        if (clearSelection) {
-            this.setInspectorContent(null);
-        }
-
-        this.defocus();
-        this.renderDisplay();
-    };
-
-    Editor.prototype.selectStatus = function (name) {
-        var self = this;
-        var d = self.lifecycle.statusObjectForName(name);
-
-        self.deselectAll(false);
-        self.focusItem(d);
-        self.setInspectorContent(d);
-        self.renderDisplay();
-    };
-
-    Editor.prototype.selectTransition = function (fromStatus, toStatus) {
-        var self = this;
-        var d = self.lifecycle.hasTransition(fromStatus, toStatus);
-
-        self.deselectAll(false);
-        self.focusItem(d);
-        self.setInspectorContent(d);
-        self.renderDisplay();
-    };
-
-    Editor.prototype.selectDecoration = function (key) {
-        var d = this.lifecycle.itemForKey(key);
-
-        this.deselectAll(false);
-        this.focusItem(d);
-        this.setInspectorContent(d);
-
-        if (d._type == 'polygon' || d._type == 'line') {
-            this.addPointHandles(d);
-        }
-
-        this.renderDisplay();
-    };
-
     Editor.prototype.addPointHandles = function (d) {
         var self = this;
         var points = [];
@@ -406,15 +349,15 @@ jQuery(function () {
     };
 
     Editor.prototype.clickedStatus = function (d) {
-        this.selectStatus(d.name);
+        this.focusItem(d);
     };
 
     Editor.prototype.clickedTransition = function (d) {
-        this.selectTransition(d.from, d.to);
+        this.focusItem(d);
     };
 
     Editor.prototype.clickedDecoration = function (d) {
-        this.selectDecoration(d._key);
+        this.focusItem(d);
     };
 
     Editor.prototype.didDragItem = function (d, node) {
@@ -463,27 +406,27 @@ jQuery(function () {
 
     Editor.prototype.addNewStatus = function () {
         var status = this.lifecycle.createStatus();
-        this.selectStatus(status.name);
+        this.focusItem(status);
     };
 
     Editor.prototype.addNewTextDecoration = function () {
         var text = this.lifecycle.createTextDecoration();
-        this.selectDecoration(text._key);
+        this.focusItem(text);
     };
 
     Editor.prototype.addNewPolygonDecoration = function (type) {
         var polygon = this.lifecycle.createPolygonDecoration(type);
-        this.selectDecoration(polygon._key);
+        this.focusItem(polygon);
     };
 
     Editor.prototype.addNewCircleDecoration = function () {
         var circle = this.lifecycle.createCircleDecoration();
-        this.selectDecoration(circle._key);
+        this.focusItem(circle);
     };
 
     Editor.prototype.addNewLineDecoration = function () {
         var line = this.lifecycle.createLineDecoration();
-        this.selectDecoration(line._key);
+        this.focusItem(line);
     };
 
     Editor.prototype.initializeEditor = function (node, name, config, focusStatus) {
@@ -505,13 +448,10 @@ jQuery(function () {
             return true;
         });
 
-        self.svg.on('click', function () { self.deselectAll(true) });
+        self.svg.on('click', function () { self.defocus() });
 
         self.lifecycle.saveUndoCallback = function () {
             var payload = {};
-            if (self.inspectorNode) {
-                payload.inspectorKey = self.inspectorNode._key;
-            }
             if (self._focusItem) {
                 payload.focusKey = self._focusItem._key;
             }
@@ -524,5 +464,23 @@ jQuery(function () {
         self.lifecycle.undoStackChangedCallback();
     };
 
+    Editor.prototype.defocus = function () {
+        Super.prototype.defocus.call(this);
+        this.setInspectorContent(null);
+        this.removePointHandles();
+        this.renderDisplay();
+    };
+
+    Editor.prototype.focusItem = function (item) {
+        Super.prototype.focusItem.call(this, item);
+        this.setInspectorContent(item);
+
+        if (item._type == 'polygon' || item._type == 'line') {
+            this.addPointHandles(item);
+        }
+
+        this.renderDisplay();
+    };
+
     RT.LifecycleEditor = Editor;
 });

commit a7240ae314e9d63b70ba53ea7db6ea3b166b6c7a
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 18:14:10 2017 +0000

    Defocus before refocusing
    
    This should help keep things consistent, even if it causes a bit more
    work

diff --git a/static/js/lifecycleui-viewer.js b/static/js/lifecycleui-viewer.js
index d203dee..3a81e9f 100644
--- a/static/js/lifecycleui-viewer.js
+++ b/static/js/lifecycleui-viewer.js
@@ -314,6 +314,8 @@ jQuery(function () {
     };
 
     Viewer.prototype.focusItem = function (d) {
+        this.defocus();
+
         this._focusItem = d;
         this.svg.classed("has-focus", true)
                 .attr('data-focus-type', d._type);

commit c17a7447cc8daf3df328084bfd58439c922223e0
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 18:33:45 2017 +0000

    Use a _key for pointHandles

diff --git a/static/js/lifecycleui-editor.js b/static/js/lifecycleui-editor.js
index 6a22b89..b1cd069 100644
--- a/static/js/lifecycleui-editor.js
+++ b/static/js/lifecycleui-editor.js
@@ -256,6 +256,7 @@ jQuery(function () {
         var points = [];
         for (var i = 0; i < d.points.length; ++i) {
             points.push({
+                _key: d._key + '-' + i,
                 i: i,
                 x: d.points[i].x,
                 y: d.points[i].y,
@@ -330,7 +331,7 @@ jQuery(function () {
 
         var self = this;
         var handles = self.decorationContainer.selectAll("circle.point-handle")
-                           .data(self.pointHandles || [], function (d) { return d.i });
+                           .data(self.pointHandles || [], function (d) { return d._key });
 
         handles.exit()
               .remove();

commit 273e91f10779f74ca85cac4913415b88ce31ef79
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 18:34:18 2017 +0000

    Avoid generating an undo frame immediately on selection
    
    Selecting an object generates a drag-start event, which was generating
    an undo frame. Instead, wait until we actually move the object for the
    first time before we save state

diff --git a/static/js/lifecycleui-editor.js b/static/js/lifecycleui-editor.js
index b1cd069..d134bb5 100644
--- a/static/js/lifecycleui-editor.js
+++ b/static/js/lifecycleui-editor.js
@@ -286,6 +286,11 @@ jQuery(function () {
             return;
         }
 
+        if (!d._dragging) {
+            this.lifecycle.beginDragging();
+            d._dragging = true;
+        }
+
         d.x = x;
         d.y = y;
 
@@ -340,8 +345,9 @@ jQuery(function () {
                      .classed("point-handle", true)
                      .call(d3.drag()
                          .subject(function (d) { return { x: d.xScale(d.x), y : d.yScale(d.y) } })
-                         .on("start", function (d) { self.lifecycle.beginDragging() })
+                         .on("start", function (d) { self.didBeginDrag(d, this) })
                          .on("drag", function (d) { self.didDragPointHandle(d) })
+                         .on("end", function (d) { self.didEndDrag(d, this) })
                      )
               .merge(handles)
                      .attr("transform", function (d) { return self.inspectorNode._type == 'polygon' ? "translate(" + self.xScale(self.inspectorNode.x) + ", " + self.yScale(self.inspectorNode.y) + ")" : 'translate(0, 20)'})
@@ -361,6 +367,12 @@ jQuery(function () {
         this.focusItem(d);
     };
 
+    Editor.prototype.didBeginDrag = function (d, node) { };
+
+    Editor.prototype.didEndDrag = function (d, node) {
+        d._dragging = false;
+    };
+
     Editor.prototype.didDragItem = function (d, node) {
         if (this.inspectorNode && this.inspectorNode._key != d._key) {
             return;
@@ -373,6 +385,11 @@ jQuery(function () {
             return;
         }
 
+        if (!d._dragging) {
+            this.lifecycle.beginDragging();
+            d._dragging = true;
+        }
+
         this.lifecycle.moveItem(d, x, y);
         this.renderDisplay();
     };
@@ -381,8 +398,9 @@ jQuery(function () {
         var self = this;
         return d3.drag()
                  .subject(function (d) { return { x: self.xScale(d.x), y : self.yScale(d.y) } })
-                 .on("start", function (d) { self.lifecycle.beginDragging() })
-                 .on("drag", function (d) { self.didDragItem(d, this) });
+                 .on("start", function (d) { self.didBeginDrag(d, this) })
+                 .on("drag", function (d) { self.didDragItem(d, this) })
+                 .on("end", function (d) { self.didEndDrag(d, this) })
     };
 
     Editor.prototype.didEnterStatusNodes = function (statuses) {

commit 3039b2f0ef56cf27f5897dfe9ada7f857ba42dff
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 18:58:36 2017 +0000

    Make _initialPointsForPolygon a class method

diff --git a/static/js/lifecycleui-model.js b/static/js/lifecycleui-model.js
index d6a1fcf..187faa0 100644
--- a/static/js/lifecycleui-model.js
+++ b/static/js/lifecycleui-model.js
@@ -14,20 +14,20 @@ jQuery(function () {
         this._undoStack = [];
         this._keyMap = {};
         this._statusMeta = {};
+    };
 
-        this._initialPointsForPolygon = {
-            Triangle: [
-                {x:  .07, y: .2},
-                {x:    0, y:  0},
-                {x: -.06, y: .2}
-            ],
-            Rectangle: [
-                {x: -.06, y: -.06},
-                {x:  .06, y: -.06},
-                {x:  .06, y:  .06},
-                {x: -.06, y:  .06}
-            ]
-        };
+    Lifecycle.prototype._initialPointsForPolygon = {
+        Triangle: [
+            {x:  .07, y: .2},
+            {x:    0, y:  0},
+            {x: -.06, y: .2}
+        ],
+        Rectangle: [
+            {x: -.06, y: -.06},
+            {x:  .06, y: -.06},
+            {x:  .06, y:  .06},
+            {x: -.06, y:  .06}
+        ]
     };
 
     Lifecycle.prototype.initializeFromConfig = function (config) {

commit df756dda8e2c0e29e145d530e282dab744860a49
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 19:07:31 2017 +0000

    Fix object identity being messed up on undo

diff --git a/static/js/lifecycleui-model.js b/static/js/lifecycleui-model.js
index 187faa0..162ad8f 100644
--- a/static/js/lifecycleui-model.js
+++ b/static/js/lifecycleui-model.js
@@ -666,13 +666,17 @@ jQuery(function () {
     Lifecycle.prototype._saveUndoEntry = function () {
         var undoStack = this._undoStack;
         delete this._undoStack;
-        var entry = jQuery.extend(true, {}, this);
+        var keyMap = this._keyMap;
+        delete this._keyMap;
+
+        var entry = JSON.stringify(this);
         var extra = {};
         if (this.saveUndoCallback) {
             extra = this.saveUndoCallback();
         }
         undoStack.push([entry, extra]);
         this._undoStack = undoStack;
+        this._keyMap = keyMap;
 
         if (this.undoStackChangedCallback) {
             this.undoStackChangedCallback();
@@ -683,23 +687,46 @@ jQuery(function () {
         return this._undoStack.length > 0;
     };
 
+    Lifecycle.prototype._rebuildKeyMap = function () {
+        var keyMap = {};
+        jQuery.each(this._statusMeta, function (name, meta) {
+            keyMap[meta._key] = meta;
+        });
+
+        jQuery.each(this.transitions, function (i, transition) {
+            keyMap[transition._key] = transition;
+            jQuery.each(transition.actions, function (j, action) {
+                keyMap[action._key] = action;
+            });
+        });
+
+        jQuery.each(this.decorations, function (type, decorations) {
+            jQuery.each(decorations, function (i, decoration) {
+                keyMap[decoration._key] = decoration;
+            });
+        });
+
+        this._keyMap = keyMap;
+    };
+
+    Lifecycle.prototype._restoreState = function (state) {
+        for (var key in state) {
+            this[key] = state[key];
+        }
+
+        this._rebuildKeyMap();
+    };
+
     Lifecycle.prototype.undo = function () {
         var undoStack = this._undoStack;
         if (undoStack.length == 0) {
             return null;
         }
 
-        delete this._undoStack;
         var payload = undoStack.pop();
-        var entry = payload[0];
+        var entry = JSON.parse(payload[0]);
 
-        for (var key in entry) {
-            if (entry.hasOwnProperty(key)) {
-                this[key] = entry[key];
-            }
-        }
-
-        this._undoStack = undoStack;
+        this._restoreState(entry);
 
         if (this.undoStackChangedCallback) {
             this.undoStackChangedCallback();

commit 140b2f2f505a19ee1e9d261ffc61be530448ab4d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 19:10:41 2017 +0000

    Make _undoState a hash

diff --git a/static/js/lifecycleui-editor.js b/static/js/lifecycleui-editor.js
index d134bb5..d750685 100644
--- a/static/js/lifecycleui-editor.js
+++ b/static/js/lifecycleui-editor.js
@@ -477,10 +477,10 @@ jQuery(function () {
             return payload;
         };
 
-        self.lifecycle.undoStackChangedCallback = function () {
+        self.lifecycle.undoStateChangedCallback = function () {
             d3.select(node).select('button.undo').classed('invisible', !self.lifecycle.hasUndoStack());
         };
-        self.lifecycle.undoStackChangedCallback();
+        self.lifecycle.undoStateChangedCallback();
     };
 
     Editor.prototype.defocus = function () {
diff --git a/static/js/lifecycleui-model.js b/static/js/lifecycleui-model.js
index 162ad8f..cb3eec4 100644
--- a/static/js/lifecycleui-model.js
+++ b/static/js/lifecycleui-model.js
@@ -11,7 +11,7 @@ jQuery(function () {
         this.transitions = [];
         this.decorations = {};
 
-        this._undoStack = [];
+        this._undoState = { undoStack: [] };
         this._keyMap = {};
         this._statusMeta = {};
     };
@@ -664,8 +664,8 @@ jQuery(function () {
     };
 
     Lifecycle.prototype._saveUndoEntry = function () {
-        var undoStack = this._undoStack;
-        delete this._undoStack;
+        var undoState = this._undoState;
+        delete this._undoState;
         var keyMap = this._keyMap;
         delete this._keyMap;
 
@@ -674,17 +674,17 @@ jQuery(function () {
         if (this.saveUndoCallback) {
             extra = this.saveUndoCallback();
         }
-        undoStack.push([entry, extra]);
-        this._undoStack = undoStack;
+        undoState.undoStack.push([entry, extra]);
+        this._undoState = undoState;
         this._keyMap = keyMap;
 
-        if (this.undoStackChangedCallback) {
-            this.undoStackChangedCallback();
+        if (this.undoStateChangedCallback) {
+            this.undoStateChangedCallback();
         }
     };
 
     Lifecycle.prototype.hasUndoStack = function () {
-        return this._undoStack.length > 0;
+        return this._undoState.undoStack.length > 0;
     };
 
     Lifecycle.prototype._rebuildKeyMap = function () {
@@ -718,7 +718,7 @@ jQuery(function () {
     };
 
     Lifecycle.prototype.undo = function () {
-        var undoStack = this._undoStack;
+        var undoStack = this._undoState.undoStack;
         if (undoStack.length == 0) {
             return null;
         }
@@ -728,8 +728,8 @@ jQuery(function () {
 
         this._restoreState(entry);
 
-        if (this.undoStackChangedCallback) {
-            this.undoStackChangedCallback();
+        if (this.undoStateChangedCallback) {
+            this.undoStateChangedCallback();
         }
 
         return payload[1];

commit 61c70eeb2f7473803629f3fc1a6ea78984c0a04f
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 19:28:37 2017 +0000

    Implement redo

diff --git a/static/js/lifecycleui-editor.js b/static/js/lifecycleui-editor.js
index d750685..b43e6f6 100644
--- a/static/js/lifecycleui-editor.js
+++ b/static/js/lifecycleui-editor.js
@@ -232,13 +232,11 @@ jQuery(function () {
 
         inspector.on('click', 'button.undo', function (e) {
             e.preventDefault();
-            var payload = self.lifecycle.undo();
-            if (!payload) {
-                return;
-            }
+            var frame = self.lifecycle.undo();
+            var uiState = frame[1];
 
-            if (payload.focusKey) {
-                var node = self.lifecycle.itemForKey(payload.focusKey);
+            if (uiState.focusKey) {
+                var node = self.lifecycle.itemForKey(uiState.focusKey);
                 self.focusItem(node);
             }
             else {
@@ -248,6 +246,16 @@ jQuery(function () {
 
         inspector.on('click', 'button.redo', function (e) {
             e.preventDefault();
+            var frame = self.lifecycle.redo();
+            var uiState = frame[1];
+
+            if (uiState.focusKey) {
+                var node = self.lifecycle.itemForKey(uiState.focusKey);
+                self.focusItem(node);
+            }
+            else {
+                self.defocus();
+            }
         });
     };
 
@@ -469,16 +477,17 @@ jQuery(function () {
 
         self.svg.on('click', function () { self.defocus() });
 
-        self.lifecycle.saveUndoCallback = function () {
-            var payload = {};
+        self.lifecycle.undoFrameCallback = function (frame) {
+            var uiState = {};
             if (self._focusItem) {
-                payload.focusKey = self._focusItem._key;
+                uiState.focusKey = self._focusItem._key;
             }
-            return payload;
+            frame.push(uiState);
         };
 
         self.lifecycle.undoStateChangedCallback = function () {
             d3.select(node).select('button.undo').classed('invisible', !self.lifecycle.hasUndoStack());
+            d3.select(node).select('button.redo').classed('invisible', !self.lifecycle.hasRedoStack());
         };
         self.lifecycle.undoStateChangedCallback();
     };
diff --git a/static/js/lifecycleui-model.js b/static/js/lifecycleui-model.js
index cb3eec4..f196f51 100644
--- a/static/js/lifecycleui-model.js
+++ b/static/js/lifecycleui-model.js
@@ -11,7 +11,7 @@ jQuery(function () {
         this.transitions = [];
         this.decorations = {};
 
-        this._undoState = { undoStack: [] };
+        this._undoState = { undoStack: [], redoStack: [] };
         this._keyMap = {};
         this._statusMeta = {};
     };
@@ -663,21 +663,30 @@ jQuery(function () {
         }
     };
 
-    Lifecycle.prototype._saveUndoEntry = function () {
+    Lifecycle.prototype._currentUndoFrame = function () {
         var undoState = this._undoState;
-        delete this._undoState;
         var keyMap = this._keyMap;
+        delete this._undoState;
         delete this._keyMap;
 
         var entry = JSON.stringify(this);
-        var extra = {};
-        if (this.saveUndoCallback) {
-            extra = this.saveUndoCallback();
-        }
-        undoState.undoStack.push([entry, extra]);
+
         this._undoState = undoState;
         this._keyMap = keyMap;
 
+        var frame = [entry];
+        if (this.undoFrameCallback) {
+            this.undoFrameCallback(frame);
+        }
+
+        return frame;
+    };
+
+    Lifecycle.prototype._saveUndoEntry = function () {
+        var frame = this._currentUndoFrame();
+        this._undoState.undoStack.push(frame);
+        this._undoState.redoStack = [];
+
         if (this.undoStateChangedCallback) {
             this.undoStateChangedCallback();
         }
@@ -687,6 +696,10 @@ jQuery(function () {
         return this._undoState.undoStack.length > 0;
     };
 
+    Lifecycle.prototype.hasRedoStack = function () {
+        return this._undoState.redoStack.length > 0;
+    };
+
     Lifecycle.prototype._rebuildKeyMap = function () {
         var keyMap = {};
         jQuery.each(this._statusMeta, function (name, meta) {
@@ -723,8 +736,30 @@ jQuery(function () {
             return null;
         }
 
-        var payload = undoStack.pop();
-        var entry = JSON.parse(payload[0]);
+        this._undoState.redoStack.push(this._currentUndoFrame());
+
+        var frame = undoStack.pop();
+        var entry = JSON.parse(frame[0]);
+
+        this._restoreState(entry);
+
+        if (this.undoStateChangedCallback) {
+            this.undoStateChangedCallback();
+        }
+
+        return frame;
+    };
+
+    Lifecycle.prototype.redo = function () {
+        var redoStack = this._undoState.redoStack;
+        if (redoStack.length == 0) {
+            return null;
+        }
+
+        this._undoState.undoStack.push(this._currentUndoFrame());
+
+        var frame = redoStack.pop();
+        var entry = JSON.parse(frame[0]);
 
         this._restoreState(entry);
 
@@ -732,7 +767,7 @@ jQuery(function () {
             this.undoStateChangedCallback();
         }
 
-        return payload[1];
+        return frame;
     };
 
     RT.Lifecycle = Lifecycle;

commit 98c3d84bca1078bc4258588b55b1a2019c5a82cb
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 20:18:48 2017 +0000

    Add lifecycle menu markup for each status for interactive viewer

diff --git a/html/Elements/LifecycleGraph b/html/Elements/LifecycleGraph
index 546016d..de7265b 100644
--- a/html/Elements/LifecycleGraph
+++ b/html/Elements/LifecycleGraph
@@ -13,7 +13,10 @@
         </g>
     </svg>
 % if ($Editing) {
-    <& /Elements/LifecycleInspector &>
+    <& /Elements/LifecycleInspector, %ARGS &>
+% }
+% if ($Interactive) {
+    <& /Elements/LifecycleInteractive, %ARGS &>
 % }
     <script type="text/javascript">
         jQuery(function () {
diff --git a/html/Elements/LifecycleInteractive b/html/Elements/LifecycleInteractive
new file mode 100644
index 0000000..09d8f0c
--- /dev/null
+++ b/html/Elements/LifecycleInteractive
@@ -0,0 +1,68 @@
+<div class="status-menus">
+% for my $status (keys %menus) {
+<div class="status-menu" data-status="<% $status %>">
+% my $menu = $menus{$status};
+<& /Elements/Menu, menu => $menu &>
+</div>
+% }
+</div>
+
+<%INIT>
+my $Lifecycle = $Ticket->LifecycleObj;
+my $id = $Ticket->Id;
+my %menus;
+
+# largely borrowed from /Elements/Tabs
+my $hide_resolve_with_deps = RT->Config->Get('HideResolveActionsWithDependencies')
+    && $Ticket->HasUnresolvedDependencies;
+my $query_string = sub {
+    my %args = @_;
+    my $u    = URI->new();
+    $u->query_form(map { $_ => $args{$_} } sort keys %args);
+    return $u->query;
+};
+
+for my $from ($Lifecycle->Valid) {
+    my $actions = RT::Interface::Web::Menu->new();
+    
+    foreach my $info ( $Lifecycle->Actions($from) ) {
+        my @class;
+        my $next = $info->{'to'};
+
+        if (!$Lifecycle->IsTransition( $from => $next )) {
+            push @class, 'no-transition';
+        }
+        else {
+            my $check = $Lifecycle->CheckRight( $from => $next );
+            if (!$Ticket->CurrentUserHasRight($check)) {
+                push @class, 'no-permission';
+            }
+        }
+    
+        if ($hide_resolve_with_deps
+            && $Lifecycle->IsInactive($next)
+            && !$Lifecycle->IsInactive($from)) {
+            push @class, 'hide-resolve-with-deps';
+        }
+    
+        my $action = $info->{'update'} || '';
+        my $url = '/Ticket/';
+        $url .= "Update.html?". $query_string->(
+            $action
+                ? (Action        => $action)
+                : (SubmitTicket  => 1, Status => $next),
+            DefaultStatus => $next,
+            id            => $id,
+        );
+        my $key = $info->{'label'} || ucfirst($next);
+        $actions->child( $key => title => loc( $key ), path => $url, class => (join " ", @class));
+    }
+
+    $m->callback( CallbackName => 'StatusMenu', TicketObj => $Ticket, LifecycleObj => $Lifecycle, Status => $from, Menu => $actions);
+
+    $menus{$from} = $actions;
+}
+</%INIT>
+<%ARGS>
+$Ticket => undef
+</%ARGS>
diff --git a/static/css/lifecycleui-viewer-interactive.css b/static/css/lifecycleui-viewer-interactive.css
index e69de29..0c5c2e7 100644
--- a/static/css/lifecycleui-viewer-interactive.css
+++ b/static/css/lifecycleui-viewer-interactive.css
@@ -0,0 +1,3 @@
+.lifecycle-ui .status-menu {
+    display: none;
+}

commit 79eac8b89a49d6b24d9b136539726f9edb708260
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 20:28:01 2017 +0000

    Add .not-current for other status menus

diff --git a/html/Elements/LifecycleInteractive b/html/Elements/LifecycleInteractive
index 09d8f0c..5db7546 100644
--- a/html/Elements/LifecycleInteractive
+++ b/html/Elements/LifecycleInteractive
@@ -29,6 +29,10 @@ for my $from ($Lifecycle->Valid) {
         my @class;
         my $next = $info->{'to'};
 
+        if ($from ne $Ticket->Status) {
+            push @class, 'not-current';
+        }
+
         if (!$Lifecycle->IsTransition( $from => $next )) {
             push @class, 'no-transition';
         }

commit 3bd048b54f289c865b44372ad97475845c7bc9ee
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 20:29:45 2017 +0000

    Only include urls for transitions you can really make

diff --git a/html/Elements/LifecycleInteractive b/html/Elements/LifecycleInteractive
index 5db7546..d67afab 100644
--- a/html/Elements/LifecycleInteractive
+++ b/html/Elements/LifecycleInteractive
@@ -27,19 +27,23 @@ for my $from ($Lifecycle->Valid) {
     
     foreach my $info ( $Lifecycle->Actions($from) ) {
         my @class;
+        my $include_url = 1;
         my $next = $info->{'to'};
 
         if ($from ne $Ticket->Status) {
             push @class, 'not-current';
+            $include_url = 0;
         }
 
         if (!$Lifecycle->IsTransition( $from => $next )) {
             push @class, 'no-transition';
+            $include_url = 0;
         }
         else {
             my $check = $Lifecycle->CheckRight( $from => $next );
             if (!$Ticket->CurrentUserHasRight($check)) {
                 push @class, 'no-permission';
+                $include_url = 0;
             }
         }
     
@@ -47,6 +51,7 @@ for my $from ($Lifecycle->Valid) {
             && $Lifecycle->IsInactive($next)
             && !$Lifecycle->IsInactive($from)) {
             push @class, 'hide-resolve-with-deps';
+            $include_url = 0;
         }
     
         my $action = $info->{'update'} || '';
@@ -59,7 +64,12 @@ for my $from ($Lifecycle->Valid) {
             id            => $id,
         );
         my $key = $info->{'label'} || ucfirst($next);
-        $actions->child( $key => title => loc( $key ), path => $url, class => (join " ", @class));
+        $actions->child(
+            $key =>
+            title => loc( $key ),
+            ($include_url ? (path => $url) : ()),
+            class => (join " ", @class),
+        );
     }
 
     $m->callback( CallbackName => 'StatusMenu', TicketObj => $Ticket, LifecycleObj => $Lifecycle, Status => $from, Menu => $actions);

commit 4bbc8c5cf9f0405a280795941802435594060f83
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 20:32:31 2017 +0000

    Grey out any actions you can't take

diff --git a/static/css/lifecycleui-viewer-interactive.css b/static/css/lifecycleui-viewer-interactive.css
index 0c5c2e7..13e274c 100644
--- a/static/css/lifecycleui-viewer-interactive.css
+++ b/static/css/lifecycleui-viewer-interactive.css
@@ -1,3 +1,13 @@
 .lifecycle-ui .status-menu {
     display: none;
 }
+
+.lifecycle-ui .status-menu .not-current,
+.lifecycle-ui .status-menu .no-transition,
+.lifecycle-ui .status-menu .no-permission,
+.lifecycle-ui .status-menu .hide-resolve-with-deps {
+    color: #AAAAAA;
+    cursor: default;
+    text-decoration: none;
+}
+

commit 67e19b975e7ff00da99e4962a48495ce84a01c9d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 20:50:29 2017 +0000

    Open menu near the currently-selected status

diff --git a/html/Elements/LifecycleGraph b/html/Elements/LifecycleGraph
index de7265b..5c291f0 100644
--- a/html/Elements/LifecycleGraph
+++ b/html/Elements/LifecycleGraph
@@ -1,17 +1,20 @@
 <div class="lifecycle-ui<% $Editing ? ' editing' : '' %><% $Interactive ? ' interactive' : '' %>" id="lifecycle-<% $id %>">
-    <div class="overlay-buttons">
-        <button class="zoom-in">+</button>
-        <button class="zoom-reset">0</button>
-        <button class="zoom-out">-</button>
+    <div class="lifecycle-view">
+      <div class="overlay-buttons">
+          <button class="zoom-in">+</button>
+          <button class="zoom-reset">0</button>
+          <button class="zoom-out">-</button>
+      </div>
+      <svg>
+          <& /Elements/LifecycleGraphExtras, %ARGS &>
+          <g class="transform">
+            <g class="decorations"></g>
+            <g class="transitions"></g>
+            <g class="statuses"></g>
+          </g>
+      </svg>
     </div>
-    <svg>
-        <& /Elements/LifecycleGraphExtras, %ARGS &>
-        <g class="transform">
-          <g class="decorations"></g>
-          <g class="transitions"></g>
-          <g class="statuses"></g>
-        </g>
-    </svg>
+
 % if ($Editing) {
     <& /Elements/LifecycleInspector, %ARGS &>
 % }
diff --git a/static/css/lifecycleui-viewer-interactive.css b/static/css/lifecycleui-viewer-interactive.css
index 13e274c..6a9f6c6 100644
--- a/static/css/lifecycleui-viewer-interactive.css
+++ b/static/css/lifecycleui-viewer-interactive.css
@@ -1,7 +1,14 @@
 .lifecycle-ui .status-menu {
+    position: absolute;
+    top: 0;
+    left: 0;
     display: none;
 }
 
+.lifecycle-ui .status-menu.selected {
+    display: block;
+}
+
 .lifecycle-ui .status-menu .not-current,
 .lifecycle-ui .status-menu .no-transition,
 .lifecycle-ui .status-menu .no-permission,
diff --git a/static/css/lifecycleui-viewer.css b/static/css/lifecycleui-viewer.css
index d8bfc50..6e9fb50 100644
--- a/static/css/lifecycleui-viewer.css
+++ b/static/css/lifecycleui-viewer.css
@@ -98,7 +98,7 @@
     font-style: italic;
 }
 
-.lifecycle-ui {
+.lifecycle-ui .lifecycle-view {
     position: relative;
 }
 
diff --git a/static/js/lifecycleui-viewer-interactive.js b/static/js/lifecycleui-viewer-interactive.js
index 8069068..a590616 100644
--- a/static/js/lifecycleui-viewer-interactive.js
+++ b/static/js/lifecycleui-viewer-interactive.js
@@ -6,11 +6,34 @@ jQuery(function () {
     };
     Interactive.prototype = Object.create(Super.prototype);
 
+    Interactive.prototype._setMenuPosition = function () {
+        if (!this.selectedStatus) {
+            return;
+        }
+
+        var d = this.selectedStatus;
+        var circle = this.statusContainer.select('circle[data-key="'+ d._key + '"]');
+        var bbox = circle.node().getBoundingClientRect();
+        var x = bbox.right + window.scrollX;
+        var y = bbox.top + window.scrollY;
+
+        this.selectedMenu.css({top: y, left: x});
+    };
+
     Interactive.prototype.clickedStatus = function (d) {
+        var statusName = d.name;
+        this.selectedMenu = this.menuContainer.find('.status-menu[data-status="'+statusName+'"]');
+        this.selectedStatus = d;
+
+        this.menuContainer.find('.status-menu.selected').removeClass('selected');
+        this.selectedMenu.addClass('selected');
+
+        this._setMenuPosition();
     };
 
     Interactive.prototype.initializeViewer = function (node, name, config, focusStatus) {
          Super.prototype.initializeViewer.call(this, node, name, config, focusStatus);
+         this.menuContainer = jQuery(node).find('.status-menus');
     };
 
     RT.LifecycleViewerInteractive = Interactive;

commit cdb5f40a3cd02af15389f2fdb3b0ff8c7740c815
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 20:50:50 2017 +0000

    Move menu as you pan and zoom

diff --git a/static/js/lifecycleui-viewer-interactive.js b/static/js/lifecycleui-viewer-interactive.js
index a590616..8b21228 100644
--- a/static/js/lifecycleui-viewer-interactive.js
+++ b/static/js/lifecycleui-viewer-interactive.js
@@ -31,6 +31,11 @@ jQuery(function () {
         this._setMenuPosition();
     };
 
+    Interactive.prototype.didZoom = function () {
+        Super.prototype.didZoom.call(this);
+        this._setMenuPosition();
+    };
+
     Interactive.prototype.initializeViewer = function (node, name, config, focusStatus) {
          Super.prototype.initializeViewer.call(this, node, name, config, focusStatus);
          this.menuContainer = jQuery(node).find('.status-menus');

commit 3c354f928b258420783d95ca09b54f36eac8be4e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 20:56:30 2017 +0000

    If the menu has scrolled away, close it

diff --git a/static/js/lifecycleui-viewer-interactive.js b/static/js/lifecycleui-viewer-interactive.js
index 8b21228..650a0b7 100644
--- a/static/js/lifecycleui-viewer-interactive.js
+++ b/static/js/lifecycleui-viewer-interactive.js
@@ -6,6 +6,12 @@ jQuery(function () {
     };
     Interactive.prototype = Object.create(Super.prototype);
 
+    Interactive.prototype.deselectStatus = function () {
+        delete this.selectedStatus;
+        delete this.selectedMenu;
+        this.menuContainer.find('.status-menu.selected').removeClass('selected');
+    };
+
     Interactive.prototype._setMenuPosition = function () {
         if (!this.selectedStatus) {
             return;
@@ -33,7 +39,19 @@ jQuery(function () {
 
     Interactive.prototype.didZoom = function () {
         Super.prototype.didZoom.call(this);
-        this._setMenuPosition();
+        if (this.selectedMenu) {
+            this._setMenuPosition();
+            var svgBox = this.svg.node().getBoundingClientRect();
+            var menuBox = this.selectedMenu[0].getBoundingClientRect();
+
+            var overlap = !(svgBox.right  < menuBox.left ||
+                            svgBox.left   > menuBox.right ||
+                            svgBox.bottom < menuBox.top ||
+                            svgBox.top    > menuBox.bottom);
+            if (!overlap) {
+                this.deselectStatus();
+            }
+        }
     };
 
     Interactive.prototype.initializeViewer = function (node, name, config, focusStatus) {

commit 148ab7307b9a324c883ecfc8f01e50368d30be1d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 21:10:24 2017 +0000

    Refactor menu building
    
    This way we get a menu for every single item

diff --git a/html/Elements/LifecycleInteractive b/html/Elements/LifecycleInteractive
index d67afab..4367453 100644
--- a/html/Elements/LifecycleInteractive
+++ b/html/Elements/LifecycleInteractive
@@ -13,6 +13,7 @@ my $id = $Ticket->Id;
 my %menus;
 
 # largely borrowed from /Elements/Tabs
+my $current = $Ticket->Status;
 my $hide_resolve_with_deps = RT->Config->Get('HideResolveActionsWithDependencies')
     && $Ticket->HasUnresolvedDependencies;
 my $query_string = sub {
@@ -22,60 +23,68 @@ my $query_string = sub {
     return $u->query;
 };
 
-for my $from ($Lifecycle->Valid) {
-    my $actions = RT::Interface::Web::Menu->new();
-    
-    foreach my $info ( $Lifecycle->Actions($from) ) {
-        my @class;
-        my $include_url = 1;
-        my $next = $info->{'to'};
+for my $status ($Lifecycle->Valid) {
+    $menus{$status} = RT::Interface::Web::Menu->new();
+}
 
-        if ($from ne $Ticket->Status) {
-            push @class, 'not-current';
-            $include_url = 0;
-        }
+my %seen_status;
 
-        if (!$Lifecycle->IsTransition( $from => $next )) {
-            push @class, 'no-transition';
-            $include_url = 0;
-        }
-        else {
-            my $check = $Lifecycle->CheckRight( $from => $next );
-            if (!$Ticket->CurrentUserHasRight($check)) {
-                push @class, 'no-permission';
-                $include_url = 0;
-            }
-        }
-    
-        if ($hide_resolve_with_deps
-            && $Lifecycle->IsInactive($next)
-            && !$Lifecycle->IsInactive($from)) {
-            push @class, 'hide-resolve-with-deps';
+my $add_menu = sub {
+    my $next = shift;
+    my $info = shift || {};
+    my @class;
+    my $include_url = 1;
+
+    $seen_status{$next}++;
+
+    if (!$Lifecycle->IsTransition( $current => $next )) {
+        push @class, 'no-transition';
+        $include_url = 0;
+    }
+    else {
+        my $check = $Lifecycle->CheckRight( $current => $next );
+        if (!$Ticket->CurrentUserHasRight($check)) {
+            push @class, 'no-permission';
             $include_url = 0;
         }
-    
-        my $action = $info->{'update'} || '';
-        my $url = '/Ticket/';
-        $url .= "Update.html?". $query_string->(
-            $action
-                ? (Action        => $action)
-                : (SubmitTicket  => 1, Status => $next),
-            DefaultStatus => $next,
-            id            => $id,
-        );
-        my $key = $info->{'label'} || ucfirst($next);
-        $actions->child(
-            $key =>
-            title => loc( $key ),
-            ($include_url ? (path => $url) : ()),
-            class => (join " ", @class),
-        );
     }
 
-    $m->callback( CallbackName => 'StatusMenu', TicketObj => $Ticket, LifecycleObj => $Lifecycle, Status => $from, Menu => $actions);
+    if ($hide_resolve_with_deps
+        && $Lifecycle->IsInactive($next)
+        && !$Lifecycle->IsInactive($current)) {
+        push @class, 'hide-resolve-with-deps';
+        $include_url = 0;
+    }
+
+    my $action = $info->{'update'} || '';
+    my $url = '/Ticket/';
+    $url .= "Update.html?". $query_string->(
+        $action
+            ? (Action        => $action)
+            : (SubmitTicket  => 1, Status => $next),
+        DefaultStatus => $next,
+        id            => $id,
+    );
+    my $key = $info->{'label'} || ucfirst($next);
+    $menus{$next}->child(
+        $key =>
+        title => loc( $key ),
+        ($include_url ? (path => $url) : ()),
+        class => (join " ", @class),
+    );
+};
+
+foreach my $info ( $Lifecycle->Actions($current) ) {
+    $add_menu->($info->{to}, $info);
+}
 
-    $menus{$from} = $actions;
+for my $status ($Lifecycle->Valid) {
+    next if $seen_status{$status};
+    $add_menu->($status);
 }
+
+$m->callback( CallbackName => 'StatusMenus', TicketObj => $Ticket, LifecycleObj => $Lifecycle, Menus => \%menus);
+
 </%INIT>
 <%ARGS>
 $Ticket => undef

commit b26cb45ab608b0d0844cf07e65f5e659a5c65c04
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Sep 7 21:12:12 2017 +0000

    Deselect menu on svg click

diff --git a/static/js/lifecycleui-viewer-interactive.js b/static/js/lifecycleui-viewer-interactive.js
index 650a0b7..ee107d4 100644
--- a/static/js/lifecycleui-viewer-interactive.js
+++ b/static/js/lifecycleui-viewer-interactive.js
@@ -55,8 +55,10 @@ jQuery(function () {
     };
 
     Interactive.prototype.initializeViewer = function (node, name, config, focusStatus) {
-         Super.prototype.initializeViewer.call(this, node, name, config, focusStatus);
-         this.menuContainer = jQuery(node).find('.status-menus');
+         var self = this;
+         Super.prototype.initializeViewer.call(self, node, name, config, focusStatus);
+         self.menuContainer = jQuery(node).find('.status-menus');
+         self.svg.on('click', function () { self.deselectStatus() });
     };
 
     RT.LifecycleViewerInteractive = Interactive;

-----------------------------------------------------------------------


More information about the Bps-public-commit mailing list