Expose reply dialog and label score value to plugins.

Provides sample plugin that sets Code-Review+1 on entering LGTM into
reply dialog.

Adds a getLabelValue(labelName) to plugin.changeReply().

Adds support for plugin dom hooks in general, and for reply dialog text
area as a first instance.

Adds plugin.changeReply().addReplyTextChangedCallback() which uses dom
plugin hook.

Feature: Issue 6280
Change-Id: I2b8d52c0d8000ea5d217268f6e6d7ef4137b2213
This commit is contained in:
Viktar Donich
2017-05-17 09:25:06 -07:00
parent 060f70f188
commit 7d23f225f7
12 changed files with 399 additions and 62 deletions

View File

@@ -14,12 +14,13 @@ 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="../../../behaviors/base-url-behavior/base-url-behavior.html">
<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
<link rel="import" href="../../../bower_components/polymer/polymer.html">
<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.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-formatted-text/gr-formatted-text.html">
@@ -95,6 +96,9 @@ limitations under the License.
min-height: 6em;
position: relative;
}
.textareaContainer > * {
flex: 1;
}
iron-autogrow-textarea {
padding: 0;
font-family: var(--monospace-font-family);
@@ -202,17 +206,19 @@ limitations under the License.
</gr-overlay>
</section>
<section class="textareaContainer">
<iron-autogrow-textarea
id="textarea"
class="message"
autocomplete="on"
placeholder=[[_messagePlaceholder]]
disabled="{{disabled}}"
rows="4"
max-rows="15"
bind-value="{{draft}}"
on-bind-value-changed="_handleHeightChanged">
</iron-autogrow-textarea>
<gr-endpoint-decorator name="reply-text">
<iron-autogrow-textarea
id="textarea"
class="message"
autocomplete="on"
placeholder=[[_messagePlaceholder]]
disabled="{{disabled}}"
rows="4"
max-rows="15"
bind-value="{{draft}}"
on-bind-value-changed="_handleHeightChanged">
</iron-autogrow-textarea>
</gr-endpoint-decorator>
</section>
<section class="previewContainer">
<label>

View File

@@ -198,14 +198,24 @@
setLabelValue(label, value) {
const selectorEl =
this.$.labelScores.$$('iron-selector[data-label="' + label + '"]');
this.$.labelScores.$$(`iron-selector[data-label="${label}"]`);
// The selector may not be present if its not at the latest patch set.
if (!selectorEl) { return; }
const item = selectorEl.$$('gr-button[data-value="' + value + '"]');
const item = selectorEl.$$(`gr-button[data-value="${value}"]`);
if (!item) { return; }
selectorEl.selectIndex(selectorEl.indexOf(item));
},
getLabelValue(label) {
const selectorEl =
this.$.labelScores.$$(`iron-selector[data-label="${label}"]`);
// The selector may not be present if its not at the latest patch set.
if (!selectorEl) { return null; }
const item = selectorEl.querySelector('gr-button.iron-selected');
if (!item) { return null; }
return item.getAttribute('data-value');
},
_handleEscKey(e) {
this.cancel();
},

View File

@@ -211,6 +211,26 @@ limitations under the License.
});
});
test('getlabelValue returns value', done => {
flush(() => {
MockInteractions.tap(element.$$('gr-label-scores').$$(
'iron-selector[data-label="Verified"] > ' +
'gr-button[data-value="-1"]'));
assert.equal('-1', element.getLabelValue('Verified'));
done();
});
});
test('getlabelValue when no score is selected', done => {
flush(() => {
MockInteractions.tap(element.$$('gr-label-scores').$$(
'iron-selector[data-label="Code-Review"] > ' +
'gr-button[data-value="-1"]'));
assert.isNull(element.getLabelValue('Verified'));
done();
});
});
test('setlabelValue', () => {
element._account = {_account_id: 1};
flushAsynchronousOperations();

View File

@@ -0,0 +1,25 @@
<!--
Copyright (C) 2017 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-js-api-interface/gr-js-api-interface.html">
<dom-module id="gr-endpoint-decorator">
<template>
<content></content>
</template>
<script src="gr-endpoint-decorator.js"></script>
</dom-module>

View File

@@ -0,0 +1,49 @@
// Copyright (C) 2017 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-endpoint-decorator',
properties: {
name: String,
},
_import(url) {
return new Promise((resolve, reject) => {
this.importHref(url, resolve, reject);
});
},
_initPluginDomHook(name, plugin) {
const el = document.createElement(name);
el.plugin = plugin;
el.content = this.getContentChildren()[0];
return Polymer.dom(this.root).appendChild(el);
},
ready() {
Gerrit.awaitPluginsLoaded().then(() => Promise.all(
Gerrit._getPluginsForEndpoint(this.name).map(
pluginUrl => this._import(pluginUrl)))
).then(() => {
const modulesData = Gerrit._getEndpointDetails(this.name);
for (const {moduleName, plugin} of modulesData) {
this._initPluginDomHook(moduleName, plugin);
}
});
},
});
})();

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<!--
Copyright (C) 2017 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-endpoint-decorator</title>
<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
<script src="../../../bower_components/web-component-tester/browser.js"></script>
<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
<link rel="import" href="gr-endpoint-decorator.html">
<test-fixture id="basic">
<template>
<gr-endpoint-decorator name="foo"></gr-endpoint-decorator>
</template>
</test-fixture>
<script>
suite('gr-endpoint-decorator', () => {
let sandbox;
let element;
let plugin;
setup(done => {
sandbox = sinon.sandbox.create();
// NB: Order is important.
Gerrit.install(p => {
plugin = p;
plugin.registerCustomComponent('foo', 'some-module');
}, '0.1', 'http://some/plugin/url.html');
sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
element = fixture('basic');
sandbox.stub(element, '_initPluginDomHook');
sandbox.stub(element, 'importHref', (url, resolve) => { resolve(); });
flush(done);
});
teardown(() => {
sandbox.restore();
});
test('imports plugin-provided module', () => {
assert.isTrue(
element.importHref.calledWith(new URL('http://some/plugin/url.html')));
});
test('inits plugin-provided dom hook', () => {
assert.isTrue(
element._initPluginDomHook.calledWith('some-module', plugin));
});
});
</script>

View File

@@ -34,24 +34,13 @@
},
ready() {
Gerrit.awaitPluginsLoaded().then(() => {
const sharedStyles = Gerrit._styleModules[this.name];
if (sharedStyles) {
const pluginUrls = [];
const moduleNames = [];
sharedStyles.reduce((result, item) => {
if (!result.pluginUrls.includes(item.pluginUrl)) {
result.pluginUrls.push(item.pluginUrl);
}
result.moduleNames.push(item.moduleName);
return result;
}, {pluginUrls, moduleNames});
Promise.all(pluginUrls.map(this._import.bind(this)))
.then(() => {
for (const name of moduleNames) {
this._applyStyle(name);
}
});
Gerrit.awaitPluginsLoaded().then(() => Promise.all(
Gerrit._getPluginsForEndpoint(this.name).map(
pluginUrl => this._import(pluginUrl)))
).then(() => {
const moduleNames = Gerrit._getModulesForEndoint(this.name);
for (const name of moduleNames) {
this._applyStyle(name);
}
});
},

View File

@@ -31,18 +31,26 @@ limitations under the License.
</test-fixture>
<script>
suite('gr-change-metadata integration tests', () => {
suite('gr-external-style integration tests', () => {
let sandbox;
let element;
setup(done => {
sandbox = sinon.sandbox.create();
// NB: Order is important.
let plugin;
Gerrit.install(p => {
plugin = p;
plugin.registerStyleModule('foo', 'some-module');
}, '0.1', 'http://some/plugin/url.html');
sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
Gerrit._styleModules = {foo: [{pluginUrl: 'bar', moduleName: 'baz'}]};
element = fixture('basic');
sandbox.stub(element, '_applyStyle');
sandbox.stub(element, 'importHref', (url, resolve) => { resolve(); });
flush(done);
});
@@ -51,11 +59,12 @@ limitations under the License.
});
test('imports plugin-provided module', () => {
assert.isTrue(element.importHref.calledWith('bar'));
assert.isTrue(element.importHref.calledWith(
new URL('http://some/plugin/url.html')));
});
test('applies plugin-provided styles', () => {
assert.isTrue(element._applyStyle.calledWith('baz'));
assert.isTrue(element._applyStyle.calledWith('some-module'));
});
});
</script>

View File

@@ -14,17 +14,44 @@
(function(window) {
'use strict';
function GrChangeReplyInterface(el) {
this._el = el;
/**
* Don't add new API methods to GrChangeReplyInterfaceOld.
* All new API should be added to GrChangeReplyInterface.
* @deprecated
*/
class GrChangeReplyInterfaceOld {
constructor(el) {
this._el = el;
}
getLabelValue(label) {
return this._el.getLabelValue(label);
}
setLabelValue(label, value) {
this._el.setLabelValue(label, value);
}
send(opt_includeComments) {
return this._el.send(opt_includeComments);
}
}
GrChangeReplyInterface.prototype.setLabelValue = function(label, value) {
this._el.setLabelValue(label, value);
};
class GrChangeReplyInterface extends GrChangeReplyInterfaceOld {
constructor(plugin, el) {
super(el);
this.plugin = plugin;
}
GrChangeReplyInterface.prototype.send = function(opt_includeComments) {
return this._el.send(opt_includeComments);
};
addReplyTextChangedCallback(handler) {
this.plugin.getDomHook('reply-text').then(el => {
if (!el.content) { return; }
el.content.addEventListener('value-changed', e => {
handler(e.detail.value);
});
});
}
}
window.GrChangeReplyInterface = GrChangeReplyInterface;
})(window);

View File

@@ -23,9 +23,9 @@ limitations under the License.
<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
<!--
This must refer to the element this interface is wrapping around. Otherwise
breaking changes to gr-reply-dialog wont be noticed.
-->
This must refer to the element this interface is wrapping around. Otherwise
breaking changes to gr-reply-dialog wont be noticed.
-->
<link rel="import" href="../../change/gr-reply-dialog/gr-reply-dialog.html">
<script>void(0);</script>
@@ -61,13 +61,17 @@ limitations under the License.
});
test('calls', () => {
const setLabelValueStub = sinon.stub(element, 'setLabelValue');
changeReply.setLabelValue('My-Label', '+1337');
assert(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
sandbox.stub(element, 'getLabelValue').returns('+123');
assert.equal(changeReply.getLabelValue('My-Label'), '+123');
const sendStub = sinon.stub(element, 'send');
sandbox.stub(element, 'setLabelValue');
changeReply.setLabelValue('My-Label', '+1337');
assert.isTrue(
element.setLabelValue.calledWithExactly('My-Label', '+1337'));
sandbox.stub(element, 'send');
changeReply.send(false);
assert(sendStub.calledWithExactly(false));
assert.isTrue(element.send.calledWithExactly(false));
});
});
</script>

View File

@@ -34,6 +34,11 @@
const API_VERSION = '0.1';
const EndpointType = {
STYLE: 'style',
DOM_DECORATION: 'dom',
};
// GWT JSNI uses $wnd to refer to window.
// http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
window.$wnd = window;
@@ -52,6 +57,8 @@
return;
}
this._name = this._url.pathname.split('/')[2];
this._generatedHookNames = [];
this._hooks = [];
}
Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -62,14 +69,27 @@
return this._name;
};
Plugin.prototype.registerStyleModule = function(stylingEndpointName,
moduleName) {
if (!Gerrit._styleModules[stylingEndpointName]) {
Gerrit._styleModules[stylingEndpointName] = [];
Plugin.prototype.registerStyleModule = function(endpointName, moduleName) {
this._registerEndpointModule(
endpointName, EndpointType.STYLE, moduleName);
};
Plugin.prototype.registerCustomComponent =
function(endpointName, moduleName) {
this._registerEndpointModule(
endpointName, EndpointType.DOM_DECORATION, moduleName);
};
Plugin.prototype._registerEndpointModule = function(endpoint, type, module) {
const endpoints = Gerrit._endpoints;
if (!endpoints[endpoint]) {
endpoints[endpoint] = [];
}
Gerrit._styleModules[stylingEndpointName].push({
endpoints[endpoint].push({
moduleName: module,
plugin: this,
pluginUrl: this._url,
moduleName,
type,
});
};
@@ -105,8 +125,37 @@
};
Plugin.prototype.changeReply = function() {
return new GrChangeReplyInterface(Plugin._sharedAPIElement.getElement(
Plugin._sharedAPIElement.Element.REPLY_DIALOG));
return new GrChangeReplyInterface(this,
Plugin._sharedAPIElement.getElement(
Plugin._sharedAPIElement.Element.REPLY_DIALOG));
};
Plugin.prototype._getGeneratedHookName = function(endpointName) {
if (!this._generatedHookNames[endpointName]) {
this._generatedHookNames[endpointName] = this.getPluginName() +
'-autogenerated-' + endpointName;
}
return this._generatedHookNames[endpointName];
};
Plugin.prototype.getDomHook = function(endpointName) {
const hookName = this._getGeneratedHookName(endpointName);
if (!this._hooks[hookName]) {
this._hooks[hookName] = new Promise((resolve, reject) => {
Polymer({
is: hookName,
properties: {
plugin: Object,
content: Object,
},
attached() {
resolve(this);
},
});
this.registerCustomComponent(endpointName, hookName);
});
}
return this._hooks[hookName];
};
const Gerrit = window.Gerrit || {};
@@ -114,8 +163,8 @@
// Number of plugins to initialize, -1 means 'not yet known'.
Gerrit._pluginsPending = -1;
// Hash of style modules to be applied, insertion point to shared style name.
Gerrit._styleModules = {};
// Hash of custom components to be instantiated for extension endpoints.
Gerrit._endpoints = {};
Gerrit.getPluginName = function() {
console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
@@ -204,5 +253,67 @@
return Gerrit._pluginsPending === 0;
};
/**
* Get detailed information about modules registered with an extension
* endpoint.
* @param {string} name Endpoint name.
* @param {?{
* type: (string|undefined),
* moduleName: (string|undefined)
* }} opt_options
* @return {{
* moduleName: string,
* plugin: Plugin,
* pluginUrl: String,
* type: EndpointType,
* }}
*/
Gerrit._getEndpointDetails = function(name, opt_options) {
const type = opt_options && opt_options.type;
const moduleName = opt_options && opt_options.moduleName;
if (!Gerrit._endpoints[name]) {
return [];
}
return Gerrit._endpoints[name]
.filter(item => (!type || item.type === type) &&
(!moduleName || moduleName == item.moduleName));
};
/**
* Get detailed module names for instantiating at the endpoint
* @param {string} name Endpoint name.
* @param {?{
* type: (string|undefined),
* moduleName: (string|undefined)
* }} opt_options
* @return {!Array<string>}
*/
Gerrit._getModulesForEndoint = function(name, opt_options) {
const modulesData = Gerrit._getEndpointDetails(name, opt_options);
if (!modulesData.length) {
return [];
}
return modulesData.map(m => m.moduleName);
};
/**
* Get .html plugin URLs with element and module definitions.
* @param {string} name Endpoint name.
* @param {?{
* type: (string|undefined),
* moduleName: (string|undefined)
* }} opt_options
* @return {!Array<!URL>}
*/
Gerrit._getPluginsForEndpoint = function(name, opt_options) {
const modulesData =
Gerrit._getEndpointDetails(name, opt_options).filter(
data => data.pluginUrl.pathname.indexOf('.html') !== -1);
if (!modulesData.length) {
return [];
}
return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
};
window.Gerrit = Gerrit;
})(window);

View File

@@ -0,0 +1,16 @@
<dom-module id="lgtm-plugin">
<script>
Gerrit.install(plugin => {
const replyApi = plugin.changeReply();
replyApi.addReplyTextChangedCallback(text => {
const label = 'Code-Review';
const labelValue = replyApi.getLabelValue(label);
if (labelValue &&
labelValue === ' 0' &&
text.indexOf('LGTM') === 0) {
replyApi.setLabelValue(label, '+1');
}
});
});
</script>
</dom-module>