UI side of patchset descriptions

Adds an editable label allowing the change owner to edit patchset
descriptions within the UI.

Also re-styles the dropdowns pertaining to patchsets for better UX.

Feature: Issue 4544
Change-Id: I15c0f8ab6a60c3a06482fe152539615d31eeae0f
This commit is contained in:
Kasper Nilsson 2016-11-18 10:54:24 -08:00
parent 27d3f29137
commit f081d7236f
13 changed files with 199 additions and 55 deletions

View File

@ -23,11 +23,11 @@ limitations under the License.
* Given an object of revisions, get a particular revision based on patch * Given an object of revisions, get a particular revision based on patch
* num. * num.
* *
* @param {Object} revisions * @param {Object} revisions The object of revisions given by the API
* @param {number|string} patchNum * @param {number|string} patchNum The number index of the revision
* @return {Object} * @return {Object} The correspondent revision obj from {revisions}
*/ */
getRevisionNumber: function(revisions, patchNum) { getRevisionByPatchNum: function(revisions, patchNum) {
patchNum = parseInt(patchNum, 10); patchNum = parseInt(patchNum, 10);
for (var rev in revisions) { for (var rev in revisions) {
if (revisions.hasOwnProperty(rev) && if (revisions.hasOwnProperty(rev) &&

View File

@ -23,8 +23,8 @@ limitations under the License.
<script> <script>
suite('gr-path-list-behavior tests', function() { suite('gr-path-list-behavior tests', function() {
test('getRevisionNumber', function() { test('getRevisionByPatchNum', function() {
var get = Gerrit.PatchSetBehavior.getRevisionNumber; var get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
var revisions = [ var revisions = [
{_number: 0}, {_number: 0},
{_number: 1}, {_number: 1},

View File

@ -454,7 +454,7 @@
if (!this._canSubmitChange()) { if (!this._canSubmitChange()) {
return; return;
} }
/* falls through */ // required by JSHint /* falls through */ // required by JSHint
default: default:
this._fireAction(this._prependSlash(key), this._fireAction(this._prependSlash(key),
this.revisionActions[key], true); this.revisionActions[key], true);

View File

@ -24,6 +24,7 @@ limitations under the License.
<link rel="import" href="../../shared/gr-change-star/gr-change-star.html"> <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html"> <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
<link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html"> <link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html"> <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html"> <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
<link rel="import" href="../../shared/gr-overlay/gr-overlay.html"> <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
@ -161,7 +162,10 @@ limitations under the License.
display: none; display: none;
} }
.patchSetSelect { .patchSetSelect {
max-width: 25em; max-width: 8em;
}
gr-editable-label.descriptionLabel {
max-width: 15em;
} }
@media screen and (max-width: 50em) { @media screen and (max-width: 50em) {
.header { .header {
@ -296,15 +300,25 @@ limitations under the License.
class="patchSetSelect" class="patchSetSelect"
on-change="_handlePatchChange"> on-change="_handlePatchChange">
<template is="dom-repeat" items="[[_allPatchSets]]" <template is="dom-repeat" items="[[_allPatchSets]]"
as="patchNumber"> as="patchNum">
<option value$="[[patchNumber]]"> <option value$="[[patchNum.num]]" label>
[[patchNumber]] [[patchNum.num]]
/ /
[[_computeLatestPatchNum(_allPatchSets)]] [[_computeLatestPatchNum(_allPatchSets)]]
[[_computePatchSetDescription(_change, patchNumber)]] [[patchNum.desc]]
</option> </option>
</template> </template>
</select> </select>
<span class="descriptionContainer">
/
<gr-editable-label
id="descriptionLabel"
class="descriptionLabel"
value="[[_computePatchSetDescription(_change, _selectedPatchSet)]]"
placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
read-only="[[_descriptionReadOnly]]"
on-changed="_handleDescriptionChanged"></gr-editable-label>
</span>
<span class="downloadContainer"> <span class="downloadContainer">
/ /
<gr-button link <gr-button link

View File

@ -49,6 +49,10 @@
value: function() { return document.body; }, value: function() { return document.body; },
}, },
_account: {
type: Object,
value: {},
},
_comments: Object, _comments: Object,
_change: { _change: {
type: Object, type: Object,
@ -81,7 +85,7 @@
_currentRevisionActions: Object, _currentRevisionActions: Object,
_allPatchSets: { _allPatchSets: {
type: Array, type: Array,
computed: '_computeAllPatchSets(_change)', computed: '_computeAllPatchSets(_change, _change.revisions.*)',
}, },
_loggedIn: { _loggedIn: {
type: Boolean, type: Boolean,
@ -99,6 +103,10 @@
type: Boolean, type: Boolean,
value: false, value: false,
}, },
_descriptionReadOnly: {
type: Boolean,
computed: '_computeDescriptionReadOnly(_loggedIn, _change, _account)',
},
}, },
behaviors: [ behaviors: [
@ -122,6 +130,11 @@
attached: function() { attached: function() {
this._getLoggedIn().then(function(loggedIn) { this._getLoggedIn().then(function(loggedIn) {
this._loggedIn = loggedIn; this._loggedIn = loggedIn;
if (loggedIn) {
this.$.restAPI.getAccount().then(function(acct) {
this._account = acct;
}.bind(this));
}
}.bind(this)); }.bind(this));
this.addEventListener('comment-save', this._handleCommentSave.bind(this)); this.addEventListener('comment-save', this._handleCommentSave.bind(this));
@ -505,7 +518,7 @@
_computeChangeStatus: function(change, patchNum) { _computeChangeStatus: function(change, patchNum) {
var statusString; var statusString;
if (change.status === this.ChangeStatus.NEW) { if (change.status === this.ChangeStatus.NEW) {
var rev = this.getRevisionNumber(change.revisions, patchNum); var rev = this.getRevisionByPatchNum(change.revisions, patchNum);
if (rev && rev.draft === true) { if (rev && rev.draft === true) {
statusString = 'Draft'; statusString = 'Draft';
} }
@ -516,7 +529,7 @@
}, },
_computeLatestPatchNum: function(allPatchSets) { _computeLatestPatchNum: function(allPatchSets) {
return allPatchSets[allPatchSets.length - 1]; return allPatchSets[allPatchSets.length - 1].num;
}, },
_computePatchInfoClass: function(patchNum, allPatchSets) { _computePatchInfoClass: function(patchNum, allPatchSets) {
@ -529,12 +542,15 @@
_computeAllPatchSets: function(change) { _computeAllPatchSets: function(change) {
var patchNums = []; var patchNums = [];
for (var rev in change.revisions) { for (var commit in change.revisions) {
patchNums.push(change.revisions[rev]._number); if (change.revisions.hasOwnProperty(commit)) {
patchNums.push({
num: change.revisions[commit]._number,
desc: change.revisions[commit].description,
});
}
} }
return patchNums.sort(function(a, b) { return patchNums.sort(function(a, b) { return a.num - b.num; });
return a - b;
});
}, },
_computeLabelNames: function(labels) { _computeLabelNames: function(labels) {
@ -581,7 +597,7 @@
_switchToMostRecentPatchNum: function() { _switchToMostRecentPatchNum: function() {
this._getChangeDetail().then(function() { this._getChangeDetail().then(function() {
var patchNum = this._allPatchSets[this._allPatchSets.length - 1]; var patchNum = this._computeLatestPatchNum(this._allPatchSets);
if (patchNum !== this._patchRange.patchNum) { if (patchNum !== this._patchRange.patchNum) {
this._changePatchNum(patchNum); this._changePatchNum(patchNum);
} }
@ -811,8 +827,45 @@
}, },
_computePatchSetDescription: function(change, patchNum) { _computePatchSetDescription: function(change, patchNum) {
var rev = this.getRevisionNumber(change.revisions, patchNum); var rev = this.getRevisionByPatchNum(change.revisions, patchNum);
return (rev && rev.description) ? rev.description : ''; return (rev && rev.description) ? rev.description : '';
}, },
_computeDescriptionPlaceholder: function(readOnly) {
return (readOnly ? 'No' : 'Add a') + ' patch set description';
},
_handleDescriptionChanged: function(e) {
var desc = e.detail.trim();
var rev = this.getRevisionByPatchNum(this._change.revisions,
this._selectedPatchSet);
var sha = this._getPatchsetHash(this._change.revisions, rev);
this.$.restAPI.setDescription(this._changeNum,
this._selectedPatchSet, desc)
.then(function(res) {
if (res.ok) {
this.set(['_change', 'revisions', sha, 'description'], desc);
}
}.bind(this));
},
/**
* @param {Object} revisions The revisions object keyed by revision hashes
* @param {Object} patchSet A revision already fetched from {revisions}
* @return {string} the SHA hash corresponding to the revision.
*/
_getPatchsetHash: function(revisions, patchSet) {
for (var rev in revisions) {
if (revisions.hasOwnProperty(rev) &&
revisions[rev] === patchSet) {
return rev;
}
}
},
_computeDescriptionReadOnly: function(loggedIn, change, account) {
return !(loggedIn && (account._account_id === change.owner._account_id));
},
}); });
})(); })();

View File

@ -127,6 +127,58 @@ limitations under the License.
}); });
}); });
test('_computeDescriptionReadOnly', function() {
assert.equal(element._computeDescriptionReadOnly(false,
{owner: {_account_id: 1}}, {_account_id: 1}), true);
assert.equal(element._computeDescriptionReadOnly(true,
{owner: {_account_id: 0}}, {_account_id: 1}), true);
assert.equal(element._computeDescriptionReadOnly(true,
{owner: {_account_id: 1}}, {_account_id: 1}), false);
});
test('_computeDescriptionPlaceholder', function() {
assert.equal(element._computeDescriptionPlaceholder(true),
'No patch set description');
assert.equal(element._computeDescriptionPlaceholder(false),
'Add a patch set description');
});
test('_handleDescriptionChanged', function() {
var putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
.returns(Promise.resolve({ok: true}));
sandbox.stub(element, '_computeDescriptionReadOnly');
element._changeNum = '42';
element._patchRange = {
basePatchNum: 'PARENT',
patchNum: 1,
};
element._selectedPatchNum = '1';
element._change = {
change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
revisions: {
rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
},
current_revision: 'rev1',
status: 'NEW',
labels: {},
actions: {},
owner: {_account_id: 1},
};
element._account = {_account_id: 1};
element._loggedIn = true;
flushAsynchronousOperations();
var label = element.$.descriptionLabel;
assert.equal(label.value, 'test');
label.editing = true;
label._inputText = 'test2';
label._save();
flushAsynchronousOperations();
assert.isTrue(putDescStub.called);
assert.equal(putDescStub.args[0][2], 'test2');
});
test('_reload is called when an approved label is removed', function() { test('_reload is called when an approved label is removed', function() {
var vote = {_account_id: 1, name: 'bojack', value: 1}; var vote = {_account_id: 1, name: 'bojack', value: 1};
element._changeNum = '42'; element._changeNum = '42';
@ -531,12 +583,12 @@ limitations under the License.
}); });
test('class is applied to file list on old patch set', function() { test('class is applied to file list on old patch set', function() {
var allPatcheSets = [1, 2, 4]; var allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
assert.equal(element._computePatchInfoClass('1', allPatcheSets), assert.equal(element._computePatchInfoClass('1', allPatchSets),
'patchInfo--oldPatchSet'); 'patchInfo--oldPatchSet');
assert.equal(element._computePatchInfoClass('2', allPatcheSets), assert.equal(element._computePatchInfoClass('2', allPatchSets),
'patchInfo--oldPatchSet'); 'patchInfo--oldPatchSet');
assert.equal(element._computePatchInfoClass('4', allPatcheSets), ''); assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
}); });
test('getUrlParameter functionality', function() { test('getUrlParameter functionality', function() {

View File

@ -143,7 +143,7 @@ limitations under the License.
margin: .25em 0 1em; margin: .25em 0 1em;
} }
.patchSetSelect { .patchSetSelect {
max-width: 25em; max-width: 8em;
} }
@media screen and (max-width: 50em) { @media screen and (max-width: 50em) {
.row.selected { .row.selected {
@ -192,12 +192,12 @@ limitations under the License.
<option value="PARENT">Base</option> <option value="PARENT">Base</option>
<template <template
is="dom-repeat" is="dom-repeat"
items="[[_computePatchSets(revisions, patchRange.*)]]" items="[[_computePatchSets(revisions.*, patchRange.*)]]"
as="patchNum"> as="patchNum">
<option value$="[[patchNum]]" <option value$="[[patchNum.num]]"
disabled$="[[_computePatchSetDisabled(patchNum, patchRange.patchNum)]]"> disabled$="[[_computePatchSetDisabled(patchNum.num, patchRange.patchNum)]]">
[[patchNum]] [[patchNum.num]]
[[_computePatchSetDescription(revisions, patchNum)]] [[patchNum.desc]]
</option> </option>
</template> </template>
</select> </select>

View File

@ -199,12 +199,18 @@
return this.$.restAPI.getPreferences(); return this.$.restAPI.getPreferences();
}, },
_computePatchSets: function(revisions) { _computePatchSets: function(revisionRecord) {
var revisions = revisionRecord.base;
var patchNums = []; var patchNums = [];
for (var commit in revisions) { for (var commit in revisions) {
patchNums.push(revisions[commit]._number); if (revisions.hasOwnProperty(commit)) {
patchNums.push({
num: revisions[commit]._number,
desc: revisions[commit].description,
});
}
} }
return patchNums.sort(function(a, b) { return a - b; }); return patchNums.sort(function(a, b) { return a.num - b.num; });
}, },
_computePatchSetDisabled: function(patchNum, currentPatchNum) { _computePatchSetDisabled: function(patchNum, currentPatchNum) {
@ -617,7 +623,7 @@
}, },
_computePatchSetDescription: function(revisions, patchNum) { _computePatchSetDescription: function(revisions, patchNum) {
var rev = this.getRevisionNumber(revisions, patchNum); var rev = this.getRevisionByPatchNum(revisions, patchNum);
return (rev && rev.description) ? rev.description : ''; return (rev && rev.description) ? rev.description : '';
}, },
}); });

View File

@ -446,13 +446,24 @@ limitations under the License.
}); });
test('patch set from revisions', function() { test('patch set from revisions', function() {
var expected = [
{num: 1, desc: 'test'},
{num: 2, desc: 'test'},
{num: 3, desc: 'test'},
{num: 4, desc: 'test'},
];
var patchNums = element._computePatchSets({ var patchNums = element._computePatchSets({
rev3: {_number: 3}, base: {
rev1: {_number: 1}, rev3: {_number: 3, description: 'test'},
rev4: {_number: 4}, rev1: {_number: 1, description: 'test'},
rev2: {_number: 2}, rev4: {_number: 4, description: 'test'},
rev2: {_number: 2, description: 'test'},
}
}); });
assert.deepEqual(patchNums, [1, 2, 3, 4]); assert.equal(patchNums.length, expected.length);
for (var i = 0; i < expected.length; i++) {
assert.deepEqual(patchNums[i], expected[i]);
}
}); });
test('patch range string', function() { test('patch range string', function() {

View File

@ -28,7 +28,7 @@ limitations under the License.
display: inline-block; display: inline-block;
} }
select { select {
max-width: 25em; max-width: 8em;
} }
</style> </style>
Patch set: Patch set:

View File

@ -72,7 +72,7 @@
}, },
_computePatchSetDescription: function(revisions, patchNum) { _computePatchSetDescription: function(revisions, patchNum) {
var rev = this.getRevisionNumber(revisions, patchNum); var rev = this.getRevisionByPatchNum(revisions, patchNum);
return (rev && rev.description) ? rev.description : ''; return (rev && rev.description) ? rev.description : '';
}, },
}); });

View File

@ -19,10 +19,12 @@ limitations under the License.
<template> <template>
<style> <style>
:host { :host {
display: inline-block; align-items: center;
display: inline-flex;
} }
input, input,
label { label,
.container {
width: 100%; width: 100%;
} }
input { input {
@ -43,16 +45,16 @@ limitations under the License.
text-decoration: underline; text-decoration: underline;
} }
</style> </style>
<input <input
is="iron-input" is="iron-input"
id="input" id="input"
hidden$="[[!editing]]" hidden$="[[!editing]]"
on-keydown="_handleInputKeydown" on-keydown="_handleInputKeydown"
bind-value="{{_inputText}}"> bind-value="{{_inputText}}">
<label <label
hidden$="[[editing]]" hidden$="[[editing]]"
class$="[[_computeLabelClass(readOnly, value, placeholder)]]" class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
on-tap="_open">[[_computeLabel(value, placeholder)]]</label> on-tap="_open">[[_computeLabel(value, placeholder)]]</label>
</template> </template>
<script src="gr-editable-label.js"></script> <script src="gr-editable-label.js"></script>
</dom-module> </dom-module>

View File

@ -905,5 +905,11 @@
return this.send('DELETE', '/changes/' + changeID + return this.send('DELETE', '/changes/' + changeID +
'/reviewers/' + account + '/votes/' + encodeURIComponent(label)); '/reviewers/' + account + '/votes/' + encodeURIComponent(label));
}, },
setDescription: function(changeNum, patchNum, desc) {
return this.send('PUT',
this.getChangeActionURL(changeNum, patchNum, '/description'),
{description: desc});
},
}); });
})(); })();