Save top-level draft change comments in localstorage

Unsaved diff comment drafts are automatically backed-up in local-storage
if the user discards them on accident or navigates away without saving
them. This change extends the same functionality to change-level
comments and adds tests accordingly.

Bug: Issue 4258
Change-Id: Iecfdecfc10cd92f798f1f7306af994b6ec8f74db
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index ac1855b..e3f7eb7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -509,7 +509,7 @@
     _openReplyDialog: function(opt_section) {
       this.$.replyOverlay.open().then(function() {
         this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
-        this.$.replyDialog.focusOn(opt_section);
+        this.$.replyDialog.open(opt_section);
       }.bind(this));
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index e923d48..66a1bd0 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -22,6 +22,7 @@
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../gr-account-list/gr-account-list.html">
 
 <dom-module id="gr-reply-dialog">
@@ -228,6 +229,7 @@
     </div>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-reply-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index b1f5d17..fa57770 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  var STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+
   var FocusTarget = {
     BODY: 'body',
     REVIEWERS: 'reviewers',
@@ -46,6 +48,7 @@
       draft: {
         type: String,
         value: '',
+        observer: '_draftChanged',
       },
       diffDrafts: Object,
       labels: Object,
@@ -80,18 +83,15 @@
       this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
     },
 
-    focus: function() {
-      this.focusOn(FocusTarget.BODY);
+    open: function(opt_focusTarget) {
+      this._focusOn(opt_focusTarget);
+      if (!this.draft || !this.draft.length) {
+        this.draft = this._loadStoredDraft();
+      }
     },
 
-    focusOn: function(section) {
-      if (section === FocusTarget.BODY) {
-        var textarea = this.$.textarea;
-        textarea.async(textarea.textarea.focus.bind(textarea.textarea));
-      } else if (section === FocusTarget.REVIEWERS) {
-        var reviewerEntry = this.$.reviewers.focusStart;
-        reviewerEntry.async(reviewerEntry.focus);
-      }
+    focus: function() {
+      this._focusOn(FocusTarget.BODY);
     },
 
     getFocusStops: function() {
@@ -160,6 +160,16 @@
       }.bind(this));
     },
 
+    _focusOn: function(section) {
+      if (section === FocusTarget.BODY) {
+        var textarea = this.$.textarea;
+        textarea.async(textarea.textarea.focus.bind(textarea.textarea));
+      } else if (section === FocusTarget.REVIEWERS) {
+        var reviewerEntry = this.$.reviewers.focusStart;
+        reviewerEntry.async(reviewerEntry.focus);
+      }
+    },
+
     _computeShowLabels: function(patchNum, revisions) {
       var num = parseInt(patchNum, 10);
       for (var rev in revisions) {
@@ -279,12 +289,38 @@
 
     _confirmPendingReviewer: function() {
       this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
-      this.focusOn(FocusTarget.REVIEWERS);
+      this._focusOn(FocusTarget.REVIEWERS);
     },
 
     _cancelPendingReviewer: function() {
       this._reviewerPendingConfirmation = null;
-      this.focusOn(FocusTarget.REVIEWERS);
+      this._focusOn(FocusTarget.REVIEWERS);
+    },
+
+    _getStorageLocation: function() {
+      return {
+        changeNum: this.change._number,
+        patchNum: this.patchNum,
+        path: '@change',
+      };
+    },
+
+    _loadStoredDraft: function() {
+      var draft = this.$.storage.getDraftComment(this._getStorageLocation());
+      return draft ? draft.message : '';
+    },
+
+    _draftChanged: function(newDraft, oldDraft) {
+      this.debounce('store', function() {
+        if (!newDraft.length && oldDraft) {
+          // If the draft has been modified to be empty, then erase the storage
+          // entry.
+          this.$.storage.eraseDraftComment(this._getStorageLocation());
+        } else if (newDraft.length) {
+          this.$.storage.setDraftComment(this._getStorageLocation(),
+              this.draft);
+        }
+      }, STORAGE_DEBOUNCE_INTERVAL_MS);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 0456be1..e65cc04 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -34,14 +34,27 @@
 <script>
   suite('gr-reply-dialog tests', function() {
     var element;
+    var changeNum;
+    var patchNum;
+
+    var sandbox;
+    var getDraftCommentStub;
+    var setDraftCommentStub;
+    var eraseDraftCommentStub;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
+
+      changeNum = 42;
+      patchNum = 1;
+
       stub('gr-rest-api-interface', {
         getAccount: function() { return Promise.resolve({}); },
       });
+
       element = fixture('basic');
-      element.changeNum = 42;
-      element.patchNum = 1;
+      element.change = { _number: changeNum };
+      element.patchNum = patchNum;
       element.labels = {
         Verified: {
           values: {
@@ -75,10 +88,19 @@
         ]
       };
 
+      getDraftCommentStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      setDraftCommentStub = sandbox.stub(element.$.storage, 'setDraftComment');
+      eraseDraftCommentStub = sandbox.stub(element.$.storage,
+          'eraseDraftComment');
+
       // Allow the elements created by dom-repeat to be stamped.
       flushAsynchronousOperations();
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('cancel event', function(done) {
       element.addEventListener('cancel', function() { done(); });
       MockInteractions.tap(element.$$('.cancel'));
@@ -231,5 +253,42 @@
         assert.equal(getActiveElement().id, 'input');
       }).then(done);
     });
+
+    test('_getStorageLocation', function() {
+      var actual = element._getStorageLocation();
+      assert.equal(actual.changeNum, changeNum);
+      assert.equal(actual.patchNum, patchNum);
+      assert.equal(actual.path, '@change');
+    });
+
+    test('gets draft from storage on open', function() {
+      var storedDraft = 'hello world';
+      getDraftCommentStub.returns({message: storedDraft});
+      element.open();
+      assert.isTrue(getDraftCommentStub.called);
+      assert.equal(element.draft, storedDraft);
+    });
+
+    test('blank if no stored draft', function() {
+      getDraftCommentStub.returns(null);
+      element.open();
+      assert.isTrue(getDraftCommentStub.called);
+      assert.equal(element.draft, '');
+    });
+
+    test('updates stored draft on edits', function() {
+      var firstEdit = 'hello';
+      var location = element._getStorageLocation();
+
+      element.draft = firstEdit;
+      element.flushDebouncer('store');
+
+      assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
+
+      element.draft = '';
+      element.flushDebouncer('store');
+
+      assert.isTrue(eraseDraftCommentStub.calledWith(location));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index a2afb89..ff41a74 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -58,7 +58,7 @@
 
     _getDraftKey: function(location) {
       return ['draft', location.changeNum, location.patchNum, location.path,
-          location.line].join(':');
+          location.line || ''].join(':');
     },
 
     _cleanupDrafts: function() {