Add gr-account-entry

This new element wraps the gr-autocomplete used by gr-reviewer-list.
The default logic for suggesting accounts in the context of reviewers
on a change now lives in gr-account-entry, and can be customized for
other contexts (e.g., CCed accounts).

Change-Id: I00fc48acc66a72fd93d3a45a2805e8b6908b2629
This commit is contained in:
Logan Hanks
2016-07-19 15:33:55 -07:00
parent 246271ab83
commit 3f9ea17fc0
10 changed files with 360 additions and 132 deletions

View File

@@ -0,0 +1,41 @@
<!--
Copyright (C) 2016 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<link rel="import" href="../../../bower_components/polymer/polymer.html">
<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
<dom-module id="gr-account-entry">
<template>
<style>
gr-autocomplete {
display: inline-block;
overflow: hidden;
}
</style>
<gr-autocomplete
id="input"
borderless="[[borderless]]"
placeholder="[[placeholder]]"
threshold="[[suggestFrom]]"
query="[[query]]"
on-commit="_handleInputCommit"
clear-on-commit>
</gr-autocomplete>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
</template>
<script src="gr-account-entry.js"></script>
</dom-module>

View File

@@ -0,0 +1,122 @@
// Copyright (C) 2016 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
(function() {
'use strict';
Polymer({
is: 'gr-account-entry',
/**
* Fired when an account is entered.
*
* @event add
*/
properties: {
borderless: Boolean,
change: Object,
placeholder: String,
suggestFrom: {
type: Number,
value: 3,
},
filter: {
type: Function,
value: function() {
return this.notOwnerOrReviewer.bind(this);
},
},
query: {
type: Function,
value: function() {
return this._getReviewerSuggestions.bind(this);
},
},
_reviewers: {
type: Array,
value: function() { return []; },
},
},
observers: [
'_reviewersChanged(change.reviewers.*, change.owner)',
],
focus: function() {
this.$.input.focus();
},
clear: function() {
this.$.input.clear();
},
_handleInputCommit: function(e) {
this.fire('add', {value: e.detail.value});
},
_reviewersChanged: function(changeRecord, owner) {
var reviewerSet = {};
reviewerSet[owner._account_id] = true;
var addReviewers = function(reviewers) {
if (!reviewers) {
return;
}
reviewers.forEach(function(reviewer) {
reviewerSet[reviewer._account_id] = true;
});
};
var reviewers = changeRecord.base;
addReviewers(reviewers.CC);
addReviewers(reviewers.REVIEWER);
this._reviewers = reviewerSet;
},
notOwnerOrReviewer: function(reviewer) {
var account = reviewer.account;
if (!account) { return true; }
return !this._reviewers[reviewer.account._account_id];
},
_makeSuggestion: function(reviewer) {
if (reviewer.account) {
return {
name: reviewer.account.name + ' (' + reviewer.account.email + ')',
value: reviewer,
};
} else if (reviewer.group) {
return {
name: reviewer.group.name + ' (group)',
value: reviewer,
};
}
},
_getReviewerSuggestions: function(input) {
var xhr = this.$.restAPI.getChangeSuggestedReviewers(
this.change._number, input);
return xhr.then(function(reviewers) {
if (!reviewers) { return []; }
return reviewers
.filter(this.filter)
.map(this._makeSuggestion);
}.bind(this));
},
});
})();

View File

@@ -0,0 +1,146 @@
<!DOCTYPE html>
<!--
Copyright (C) 2016 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
<title>gr-account-entry</title>
<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
<script src="../../../bower_components/web-component-tester/browser.js"></script>
<script src="../../../scripts/util.js"></script>
<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
<link rel="import" href="gr-account-entry.html">
<test-fixture id="basic">
<template>
<gr-account-entry></gr-account-entry>
</template>
</test-fixture>
<script>
suite('gr-account-entry tests', function() {
var _nextAccountId = 0;
var makeAccount = function() {
var accountId = ++_nextAccountId;
return {
_account_id: accountId,
name: 'name ' + accountId,
email: 'email ' + accountId,
};
};
var owner;
var existingReviewer1;
var existingReviewer2;
var suggestion1;
var suggestion2;
var suggestion3;
var element;
setup(function() {
owner = makeAccount();
existingReviewer1 = makeAccount();
existingReviewer2 = makeAccount();
suggestion1 = {account: makeAccount()};
suggestion2 = {account: makeAccount()};
suggestion3 = {
group: {
id: 'suggested group id',
name: 'suggested group',
},
};
element = fixture('basic');
element.change = {
owner: owner,
reviewers: {
CC: [existingReviewer1],
REVIEWER: [existingReviewer2],
},
};
stub('gr-rest-api-interface', {
getChangeSuggestedReviewers: function() {
var redundantSuggestion1 = {account: existingReviewer1};
var redundantSuggestion2 = {account: existingReviewer2};
var redundantSuggestion3 = {account: owner};
return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
},
});
});
test('notOwnerOrReviewer', function() {
var account = makeAccount();
assert.isTrue(element.notOwnerOrReviewer({}));
assert.isTrue(element.notOwnerOrReviewer({account: account}));
assert.isFalse(element.notOwnerOrReviewer({account: owner}));
assert.isFalse(element.notOwnerOrReviewer({account: existingReviewer1}));
assert.isFalse(element.notOwnerOrReviewer({account: existingReviewer2}));
});
test('_makeSuggestion formats account or group accordingly', function() {
var account = makeAccount();
var suggestion = element._makeSuggestion({account: account});
assert.deepEqual(suggestion, {
name: account.name + ' (' + account.email + ')',
value: {account: account},
});
var group = {name: 'test'};
suggestion = element._makeSuggestion({group: group});
assert.deepEqual(suggestion, {
name: group.name + ' (group)',
value: {group: group},
});
});
test('_getReviewerSuggestions excludes owner+reviewers', function(done) {
element._getReviewerSuggestions().then(function(reviewers) {
assert.deepEqual(reviewers, [
element._makeSuggestion(suggestion1),
element._makeSuggestion(suggestion2),
element._makeSuggestion(suggestion3),
]);
done();
});
});
test('_updateReviewers', function() {
// delete existingReviewer1
element.splice('change.reviewers.CC', 0, 1);
var expected = {};
expected[owner._account_id] = true;
expected[existingReviewer2._account_id] = true;
assert.deepEqual(element._reviewers, expected);
// delete existingReviewer2
element.splice('change.reviewers.REVIEWER', 0, 1);
delete expected[existingReviewer2._account_id];
assert.deepEqual(element._reviewers, expected);
// add two new reviewers
var account1 = makeAccount();
var account2 = makeAccount();
element.push('change.reviewers.CC', account1);
element.push('change.reviewers.REVIEWER', account2);
expected[account1._account_id] = true;
expected[account2._account_id] = true;
assert.deepEqual(element._reviewers, expected);
});
});
</script>

View File

@@ -18,8 +18,8 @@ limitations under the License.
<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
<link rel="import" href="../../shared/gr-button/gr-button.html">
<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
<link rel="import" href="../gr-account-entry/gr-account-entry.html">
<dom-module id="gr-reviewer-list">
<template>
@@ -74,14 +74,14 @@ limitations under the License.
<div class="controlsContainer" hidden$="[[!mutable]]">
<div class="autocompleteContainer" hidden$="[[!_showInput]]">
<div class="inputContainer">
<gr-autocomplete
id="input"
threshold="[[suggestFrom]]"
<gr-account-entry
id="accountEntry"
suggestFrom="[[suggestFrom]]"
clear-on-commit
query="[[_query]]"
change="[[change]]"
disabled="[[disabled]]"
on-commit="_sendAddRequest"
on-cancel="_handleCancelTap"></gr-autocomplete>
on-cancel="_handleCancelTap"></gr-account-entry>
<gr-button
link
class="cancel"

View File

@@ -19,15 +19,15 @@
properties: {
change: Object,
mutable: {
type: Boolean,
value: false,
},
disabled: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
mutable: {
type: Boolean,
value: false,
},
suggestFrom: {
type: Number,
value: 3,
@@ -42,13 +42,6 @@
value: false,
},
_query: {
type: Function,
value: function() {
return this._getReviewerSuggestions.bind(this);
},
},
// Used for testing.
_lastAutocompleteRequest: Object,
_xhrPromise: Object,
@@ -112,18 +105,18 @@
_handleAddTap: function(e) {
e.preventDefault();
this._showInput = true;
this.$.input.focus();
this.$.accountEntry.focus();
},
_handleCancelTap: function(e) {
e.preventDefault();
this.$.input.clear();
this.$.accountEntry.clear();
this._cancel();
},
_cancel: function() {
this._showInput = false;
this.$.input.clear();
this.$.accountEntry.clear();
this.$.addReviewer.focus();
},
@@ -147,7 +140,7 @@
this.push('change.removable_reviewers', r);
this.push('change.reviewers.CC', r);
}, this);
this.$.input.focus();
this.$.accountEntry.focus();
}.bind(this));
}.bind(this)).catch(function(err) {
this.disabled = false;
@@ -162,47 +155,5 @@
_removeReviewer: function(id) {
return this.$.restAPI.removeChangeReviewer(this.change._number, id);
},
_notInList: function(reviewer) {
var account = reviewer.account;
if (!account) { return true; }
if (account._account_id === this.change.owner._account_id) {
return false;
}
for (var i = 0; i < this._reviewers.length; i++) {
if (account._account_id === this._reviewers[i]._account_id) {
return false;
}
}
return true;
},
_makeSuggestion: function(reviewer) {
if (reviewer.account) {
return {
name: reviewer.account.name + ' (' + reviewer.account.email + ')',
value: reviewer,
};
} else if (reviewer.group) {
return {
name: reviewer.group.name + ' (group)',
value: reviewer,
};
}
},
_getReviewerSuggestions: function(input) {
var xhr = this.$.restAPI.getChangeSuggestedReviewers(
this.change._number, input);
this._lastAutocompleteRequest = xhr;
return xhr.then(function(reviewers) {
if (!reviewers) { return []; }
return reviewers
.filter(this._notInList.bind(this))
.map(this._makeSuggestion);
}.bind(this));
},
});
})();

View File

@@ -38,7 +38,8 @@ limitations under the License.
setup(function() {
element = fixture('basic');
autocompleteInput = element.$.input.$.input;
// TODO(logan): Rewrite this test to not delve so deeply into internals.
autocompleteInput = element.$.accountEntry.$.input.$.input;
stub('gr-rest-api-interface', {
getChangeSuggestedReviewers: function() {
return Promise.resolve([
@@ -98,17 +99,24 @@ limitations under the License.
});
function getActiveElement() {
return document.activeElement.shadowRoot ?
document.activeElement.shadowRoot.activeElement :
document.activeElement;
var root = document;
while (root && root.activeElement.shadowRoot) {
var shadowRoot = root.activeElement.shadowRoot;
if (!shadowRoot.activeElement) {
break;
}
root = shadowRoot;
}
return root.activeElement;
}
test('show/hide input', function() {
test('show/hide accountEntry', function() {
element.mutable = true;
assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden'));
assert.isTrue(
element.$$('.autocompleteContainer').hasAttribute('hidden'));
assert.notEqual(getActiveElement().id, 'input');
MockInteractions.tap(element.$$('.addReviewer'));
assert.isTrue(element.$$('.addReviewer').hasAttribute('hidden'));
assert.isFalse(
@@ -184,7 +192,7 @@ limitations under the License.
element._mutable = true;
element.change = {_number: 123};
element.$.input.text = 'fo';
element.$.accountEntry.text = 'fo';
flushAsynchronousOperations();
@@ -202,9 +210,10 @@ limitations under the License.
element._mutable = true;
MockInteractions.tap(element.$$('.addReviewer'));
flushAsynchronousOperations();
element.$.input.text = 'andy';
element.$.accountEntry.$.input.text = 'andy';
element._lastAutocompleteRequest.then(function() {
var stub = element.$.restAPI.getChangeSuggestedReviewers;
stub.lastCall.returnValue.then(function() {
flushAsynchronousOperations();
MockInteractions.pressAndReleaseKeyOn(autocompleteInput, 27); // 'esc'
@@ -213,9 +222,8 @@ limitations under the License.
MockInteractions.tap(element.$$('.addReviewer'));
element.$.input.text = 'andyb';
element._lastAutocompleteRequest.then(function() {
element.$.accountEntry.text = 'andyb';
stub.lastCall.returnValue.then(function() {
MockInteractions.pressAndReleaseKeyOn(
autocompleteInput, 13); // 'enter'
assert.isTrue(element.disabled);
@@ -242,63 +250,5 @@ limitations under the License.
});
});
});
test('_makeSuggestion', function() {
var account = {
_account_id: 123456,
name: 'name',
email: 'email'
};
var group = {
id: '123456',
name: 'name',
};
var suggestion = element._makeSuggestion({account: account});
assert.deepEqual(suggestion, {
name: 'name (email)',
value: {account: account},
});
suggestion = element._makeSuggestion({group: group});
assert.deepEqual(suggestion, {
name: 'name (group)',
value: {group: group},
});
});
test('_notInList', function() {
var group = {
id: '123456',
name: 'name',
};
var account = {
_account_id: 123456,
name: 'name',
email: 'email',
};
element.change = {owner: {_account_id: 123456}};
// Is true when passing a group.
assert.isTrue(element._notInList({group: group}));
// Is false when passing the change owner.
assert.isFalse(element._notInList({account: account}));
element.change.owner._account_id = 789;
// Is true when passing a different user than the change owner, and is not
// in the reviewer list.
assert.isTrue(element._notInList({account: account}));
element._reviewers = [{_account_id: 123456}];
// Is false when passing a different user than the change owner, but *is*
// the reviewer list.
assert.isFalse(element._notInList({account: account}));
});
});
</script>

View File

@@ -23,6 +23,11 @@ limitations under the License.
input {
font-size: 1em;
}
input.borderless,
input.borderless:focus {
border: none;
outline: none;
}
#suggestions {
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
@@ -42,6 +47,7 @@ limitations under the License.
</style>
<input
id="input"
class$="[[_computeClass(borderless)]]"
is="iron-input"
disabled$="[[disabled]]"
bind-value="{{text}}"

View File

@@ -59,6 +59,7 @@
value: 1,
},
borderless: Boolean,
disabled: Boolean,
text: {
@@ -136,6 +137,10 @@
return !suggestions.length;
},
_computeClass: function(borderless) {
return borderless ? 'borderless' : '';
},
_getSuggestionElems: function() {
Polymer.dom.flush();
return this.$.suggestions.querySelectorAll('li');

View File

@@ -208,5 +208,11 @@ limitations under the License.
assert.isTrue(queryStub.called);
});
test('_computeClass respects border property', function() {
assert.equal(element._computeClass(), '');
assert.equal(element._computeClass(false), '');
assert.equal(element._computeClass(true), 'borderless');
});
});
</script>

View File

@@ -27,6 +27,7 @@ limitations under the License.
[
'change-list/gr-change-list-item/gr-change-list-item_test.html',
'change-list/gr-change-list/gr-change-list_test.html',
'change/gr-account-entry/gr-account-entry_test.html',
'change/gr-change-actions/gr-change-actions_test.html',
'change/gr-change-metadata/gr-change-metadata_test.html',
'change/gr-change-view/gr-change-view_test.html',