Add restore action to edit controls

This change adds the restore action, dialog, and button to
gr-edit-controls. By default, the button itself is hidden -- it is
intended to only be functional with a path. As opposed to the dialog
taking a file path as user input, it functions as a confirm dialog for
the provided file.

An <input> element is used in the restore dialog exclusively for visual
alignment with the rest of the dialogs.

TODO:
- Implement per-file actions for use of the restore dialog, where the
  public `openRestoreDialog` will be called with a file path.
- Make the _path property of gr-edit-controls public to allow a default
  value.
- Add gr-edit-controls to the diff-view and editor-view, which both need
  the restore button shown.

Bug: Issue 4437
Change-Id: Ideb2f9df30a3a0393c031ba9ad01264c930ac467
This commit is contained in:
Kasper Nilsson
2017-10-13 13:03:41 -07:00
parent 5c27db596f
commit 7db7304a5b
3 changed files with 133 additions and 21 deletions

View File

@@ -17,6 +17,7 @@ limitations under the License.
<link rel="import" href="../../../bower_components/polymer/polymer.html">
<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
@@ -71,6 +72,7 @@ limitations under the License.
<template is="dom-repeat" items="[[_actions]]" as="action">
<gr-button
id$="[[action.key]]"
class$="[[_computeIsInvisible(action.key, hiddenActions)]]"
link
on-tap="_handleTap">[[action.label]]</gr-button>
</template>
@@ -125,6 +127,20 @@ limitations under the License.
placeholder="Enter the new path."/>
</div>
</gr-confirm-dialog>
<gr-confirm-dialog
id="restoreDialog"
class="invisible dialog"
confirm-label="Restore"
on-confirm="_handleRestoreConfirm"
on-cancel="_handleDialogCancel">
<div class="header">Restore this file?</div>
<div class="main">
<input
is="iron-input"
disabled
bind-value="{{_path}}"/>
</div>
</gr-confirm-dialog>
</gr-overlay>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
</template>

View File

@@ -14,20 +14,30 @@
(function() {
'use strict';
/**
* TODO(kaspern): move this dictionary to a shareable constants file.
*/
const Actions = {
EDIT: {label: 'Edit', key: 'edit'},
DELETE: {label: 'Delete', key: 'delete'},
RENAME: {label: 'Rename', key: 'rename'},
/* TODO(kaspern): Implement these actions.
REVERT: {label: 'Revert', key: 'revert'},
CHECKOUT: {label: 'Check out', key: 'checkout'},
*/
RESTORE: {label: 'Restore', key: 'restore'},
};
Polymer({
is: 'gr-edit-controls',
properties: {
change: Object,
/**
* TODO(kaspern): by default, the RESTORE action should be hidden in the
* file-list as it is a per-file action only. Remove this default value
* when the Actions dictionary is moved to a shared constants file and
* use the hiddenActions property in the parent component.
*/
hiddenActions: {
type: Array,
value() { return [Actions.RESTORE.key]; },
},
_actions: {
type: Array,
@@ -67,6 +77,9 @@
case Actions.RENAME.key:
this.openRenameDialog();
return;
case Actions.RESTORE.key:
this.openRestoreDialog();
return;
}
},
@@ -85,6 +98,11 @@
return this._showDialog(this.$.renameDialog);
},
openRestoreDialog(opt_path) {
if (opt_path) { this._path = opt_path; }
return this._showDialog(this.$.restoreDialog);
},
/**
* Given a path string, checks that it is a valid file path.
* @param {string} path
@@ -114,19 +132,22 @@
_showDialog(dialog) {
return this.$.overlay.open().then(() => {
dialog.classList.toggle('invisible', false);
dialog.querySelector('gr-autocomplete').focus();
const autocomplete = dialog.querySelector('gr-autocomplete');
if (autocomplete) { autocomplete.focus(); }
this.async(() => { this.$.overlay.center(); }, 1);
});
},
_closeDialog(dialog) {
// Dialog may have autocompletes and plain inputs -- as these have
// different properties representing their bound text, it is easier to
// just make two separate queries.
dialog.querySelectorAll('gr-autocomplete')
.forEach(input => { input.text = ''; });
dialog.querySelectorAll('input')
.forEach(input => { input.bindValue = ''; });
_closeDialog(dialog, clearInputs) {
if (clearInputs) {
// Dialog may have autocompletes and plain inputs -- as these have
// different properties representing their bound text, it is easier to
// just make two separate queries.
dialog.querySelectorAll('gr-autocomplete')
.forEach(input => { input.text = ''; });
dialog.querySelectorAll('input')
.forEach(input => { input.bindValue = ''; });
}
dialog.classList.toggle('invisible', true);
return this.$.overlay.close();
@@ -139,14 +160,23 @@
_handleEditConfirm(e) {
const url = Gerrit.Nav.getEditUrlForDiff(this.change, this._path);
Gerrit.Nav.navigateToRelativeUrl(url);
this._closeDialog(this._getDialogFromEvent(e));
this._closeDialog(this._getDialogFromEvent(e), true);
},
_handleDeleteConfirm(e) {
this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
.then(res => {
if (!res.ok) { return; }
this._closeDialog(this._getDialogFromEvent(e));
this._closeDialog(this._getDialogFromEvent(e), true);
Gerrit.Nav.navigateToChange(this.change);
});
},
_handleRestoreConfirm(e) {
this.$.restAPI.restoreFileInChangeEdit(this.change._number, this._path)
.then(res => {
if (!res.ok) { return; }
this._closeDialog(this._getDialogFromEvent(e), true);
Gerrit.Nav.navigateToChange(this.change);
});
},
@@ -155,7 +185,7 @@
return this.$.restAPI.renameFileInChangeEdit(this.change._number,
this._path, this._newPath).then(res => {
if (!res.ok) { return; }
this._closeDialog(this._getDialogFromEvent(e));
this._closeDialog(this._getDialogFromEvent(e), true);
Gerrit.Nav.navigateToChange(this.change);
});
},
@@ -166,5 +196,9 @@
return {name: file};
}));
},
_computeIsInvisible(key, hiddenActions) {
return hiddenActions.includes(key) ? 'invisible' : '';
},
});
})();

View File

@@ -100,7 +100,7 @@ suite('gr-edit-controls tests', () => {
MockInteractions.tap(element.$.editDialog.$$('gr-button'));
for (const stub of navStubs) { assert.isFalse(stub.called); }
assert.isTrue(closeDialogSpy.called);
assert.equal(element._path, '');
assert.equal(element._path, 'src/test.cpp');
});
});
});
@@ -130,6 +130,7 @@ suite('gr-edit-controls tests', () => {
assert.isTrue(deleteStub.called);
return deleteStub.lastCall.returnValue.then(() => {
assert.equal(element._path, '');
assert.isTrue(navStub.called);
assert.isTrue(closeDialogSpy.called);
});
@@ -168,7 +169,7 @@ suite('gr-edit-controls tests', () => {
MockInteractions.tap(element.$.deleteDialog.$$('gr-button'));
assert.isFalse(navStub.called);
assert.isTrue(closeDialogSpy.called);
assert.equal(element._path, '');
assert.equal(element._path, 'src/test.cpp');
});
});
});
@@ -203,6 +204,7 @@ suite('gr-edit-controls tests', () => {
assert.isTrue(renameStub.called);
return renameStub.lastCall.returnValue.then(() => {
assert.equal(element._path, '');
assert.isTrue(navStub.called);
assert.isTrue(closeDialogSpy.called);
});
@@ -248,8 +250,68 @@ suite('gr-edit-controls tests', () => {
MockInteractions.tap(element.$.renameDialog.$$('gr-button'));
assert.isFalse(navStub.called);
assert.isTrue(closeDialogSpy.called);
assert.equal(element._path, '');
assert.equal(element._newPath, '');
assert.equal(element._path, 'src/test.cpp');
assert.equal(element._newPath, 'src/test.newPath');
});
});
});
suite('restore button CUJ', () => {
let navStub;
let restoreStub;
setup(() => {
navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
restoreStub = sandbox.stub(element.$.restAPI, 'restoreFileInChangeEdit');
});
test('restore hidden by default', () => {
assert.isTrue(element.$$('#restore').classList.contains('invisible'));
});
test('restore', () => {
restoreStub.returns(Promise.resolve({ok: true}));
element._path = 'src/test.cpp';
MockInteractions.tap(element.$$('#restore'));
return showDialogSpy.lastCall.returnValue.then(() => {
MockInteractions.tap(element.$.restoreDialog.$$('gr-button[primary]'));
flushAsynchronousOperations();
assert.isTrue(restoreStub.called);
assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
return restoreStub.lastCall.returnValue.then(() => {
assert.equal(element._path, '');
assert.isTrue(navStub.called);
assert.isTrue(closeDialogSpy.called);
});
});
});
test('restore fails', () => {
restoreStub.returns(Promise.resolve({ok: false}));
element._path = 'src/test.cpp';
MockInteractions.tap(element.$$('#restore'));
return showDialogSpy.lastCall.returnValue.then(() => {
MockInteractions.tap(element.$.restoreDialog.$$('gr-button[primary]'));
flushAsynchronousOperations();
assert.isTrue(restoreStub.called);
assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
return restoreStub.lastCall.returnValue.then(() => {
assert.isFalse(navStub.called);
assert.isFalse(closeDialogSpy.called);
});
});
});
test('cancel', () => {
element._path = 'src/test.cpp';
MockInteractions.tap(element.$$('#restore'));
return showDialogSpy.lastCall.returnValue.then(() => {
MockInteractions.tap(element.$.restoreDialog.$$('gr-button'));
assert.isFalse(navStub.called);
assert.isTrue(closeDialogSpy.called);
assert.equal(element._path, 'src/test.cpp');
});
});
});
@@ -284,4 +346,4 @@ suite('gr-edit-controls tests', () => {
assert.notOk(spy.lastCall.returnValue);
});
});
</script>
</script>