Allow JavaScript plugins to manage their own action RPCs

This is the first step in building a public JavaScript API that
can be utilized by plugins to manage interaction with the web UI.

This commit makes the following plugin JavaScript possible:

  Gerrit.install(function(self) {
    function onCallMe(c) {
      var f = c.textfield();
      var t = c.checkbox();
      var b = c.button('Phone', {onclick: function(){
        c.call(
          {message: f.value, dial: t.checked},
          function(r) {
            c.hide();
            window.alert("Said " + r);
            c.refresh();
          });
      }});
      c.popup(c.div(
        f, c.br(),
        c.label(t, 'Dial'), c.br(),
        b));
      f.focus();
    }
    self.onAction('revision', 'callme', onCallMe);
  });

JavaScript plugins are encouraged to protect the global namespace
by running Gerrit.install(f), passing an anonymous function Gerrit
can call to initialize the plugin.

Gerrit.install() performs some black magic to identify which plugin
the JavaScript is running from, exporting as Gerrit.getPluginName().
Obtaining the name is faster within the install() invocation as the
string is cached in a global variable.

A plugin's install function is given a reference to the plugin
JavaScript object. This object has many helper methods to support
communication with the server.

Gerrit.onAction() accepts the type of view and the RestView name
as defined in the plugin and a JavaScript function to execute when
the user has activated the action button.

An action callback is given a context object declaring a number of
useful properties:

  change:      The ChangeInfo object.
  revision:    The RevisionInfo object.
  action:      The ActionInfo object that invoked this callback.

  popup(e):    Display a popup containing element e.
  hide():      Hide the popup that was created by popup.

  call(in,cb): Executes the RPC using action.method.
  get(cb):     Executes the RPC using GET.
  post(in,cb): Executes the RPC using POST.
  put(in,cb):  Executes the RPC using PUT.
  delete(cb):  Executes the RPC using DELETE.

  HTML: br, hr, button, checkbox, div, label, span, textfield
  These make it easier to construct a tiny UI for use in a
  popup, such as gathering a small amount if input from the
  user to supply to the RPC.

  go(token):   Navigate the web UI to the application URL.
  refresh():   Redisplay the current web UI view.

Any result data from the server is parsed as JSON and passed as-is
to the callback function. A JSON string result is given as a string,
a JSON object or array is passed as-is.

A corresponding REST API view on revisions can be declared:

  class Module extends RestApiModule {
    @Override
    protected void configure() {
      post(RevisionResource.REVISION_KIND, "callme").to(CallMe.class);
    }
  }

  class CallMe implements
      RestModifyView<RevisionResource, CallMe.Input>,
      UiAction<RevisionResource> {
    static class Input {
      String message;
    }

    @Override
    public String apply(RevisionResource resource, Input input) {
      return "You said: " + input.message;
    }

    @Override
    public UiAction.Description getDescription(RevisionResource resource) {
      return new UiAction.Description().setLabel("Call Me");
    }
  }

Currently the JavaScript code must be registered at the server side
to appear in the host page download:

  class Web extends AbstractModule {
    @Override
    protected void configure() {
      DynamicSet.bind(binder(), WebUiPlugin.class)
        .toInstance(new JavaScriptPlugin("maybe.js"));
    }

I find this binding method for JavaScript code is awkard and would
like to improve on it in the future.

Finally both modules must be registered in the plugin manifest:

  gerrit_plugin(
    name = 'actiondemo',
    srcs = glob(['src/main/java/**/*.java']),
    resources = glob(['src/main/resources/**/*']),
    manifest_entries = [
      "Gerrit-Module: com.googlesource.gerrit.plugins.actiondemo.Module",
      "Gerrit-HttpModule: com.googlesource.gerrit.plugins.actiondemo.Web",
    ],
  )

Change-Id: I2d572279ac978e644772b1cedbecc080746a2306
This commit is contained in:
Shawn Pearce 2013-07-16 15:01:40 -07:00
parent 72ec0a4f08
commit 92a1fa89f2
16 changed files with 1278 additions and 58 deletions

View File

@ -72,6 +72,7 @@ Index
.. link:i18n-readme.html[i18n Support]
.. Plugin development
... link:dev-plugins.html[Developing Plugins]
... link:js-api.html[JavaScript Plugin API]
... link:config-validation.html[Commit Validation]
. Maintainer
.. link:dev-release.html[Developer Release]

593
Documentation/js-api.txt Normal file
View File

@ -0,0 +1,593 @@
Gerrit Code Review - JavaScript API
===================================
Gerrit Code Review supports an API for JavaScript plugins to interact
with the web UI and the server process.
Entry Point
-----------
JavaScript is loaded using a standard `<script src='...'>` HTML tag.
Plugins should protect the global namespace by defining their code
within an anonymous function passed to `Gerrit.install()`. The plugin
will be passed an object describing its registration with Gerrit:
----
Gerrit.install(function (self) {
// ... plugin JavaScript code here ...
});
----
[[self]]
Plugin Instance
---------------
The plugin instance is passed to the plugin's initialization function
and provides a number of utility services to plugin authors.
[[self.delete]]
self.delete()
~~~~~~~~~~~~~
Issues a DELETE REST API request to the Gerrit server.
.Signature
----
Gerrit.delete(url, callback)
----
* url: URL relative to the plugin's URL space. The JavaScript
library prefixes the supplied URL with `/plugins/{getPluginName}/`.
* callback: JavaScript function to be invoked with the parsed
JSON result of the API call. DELETE methods often return
`204 No Content`, which is passed as null.
[[self.get]]
self.get()
~~~~~~~~~~
Issues a GET REST API request to the Gerrit server.
.Signature
----
self.get(url, callback)
----
* url: URL relative to the plugin's URL space. The JavaScript
library prefixes the supplied URL with `/plugins/{getPluginName}/`.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
[[self.getPluginName]]
self.getPluginName()
~~~~~~~~~~~~~~~~~~~~
Returns the name this plugin was installed as by the server
administrator. The plugin name is required to access REST API
views installed by the plugin, or to access resources.
[[self.post]]
self.post()
~~~~~~~~~~~
Issues a POST REST API request to the Gerrit server.
.Signature
----
self.post(url, input, callback)
----
* url: URL relative to the plugin's URL space. The JavaScript
library prefixes the supplied URL with `/plugins/{getPluginName}/`.
* input: JavaScript object to serialize as the request payload.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
self.post(
'/my-servlet',
{start_build: true, platform_type: 'Linux'},
function (r) {});
----
[[self.put]]
self.put()
~~~~~~~~~~
Issues a PUT REST API request to the Gerrit server.
.Signature
----
self.put(url, input, callback)
----
* url: URL relative to the plugin's URL space. The JavaScript
library prefixes the supplied URL with `/plugins/{getPluginName}/`.
* input: JavaScript object to serialize as the request payload.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
self.put(
'/builds',
{start_build: true, platform_type: 'Linux'},
function (r) {});
----
[[self.onAction]]
self.onAction()
~~~~~~~~~~~~~~~
Register a JavaScript callback to be invoked when the user clicks
on a button associated with a server side `UiAction`.
.Signature
----
Gerrit.onAction(type, view_name, callback);
----
* type: `'change'` or `'revision'`, indicating what sort of resource
the `UiAction` was bound to in the server.
* view_name: string appearing in URLs to name the view. This is the
second argument of the `get()`, `post()`, `put()`, and `delete()`
binding methods in a `RestApiModule`.
* callback: JavaScript function to invoke when the user clicks. The
function will be passed a link:#ActionContext[action context].
[[self.url]]
self.url()
~~~~~~~~~~
Returns a URL within the plugin's URL space. If invoked with no
parameter the the URL of the plugin is returned. If passed a string
the argument is appended to the plugin URL.
----
self.url(); // "https://gerrit-review.googlesource.com/plugins/demo/"
self.url('/static/icon.png'); // "https://gerrit-review.googlesource.com/plugins/demo/static/icon.png"
----
[[ActionContext]]
Action Context
--------------
A new action context is passed to the `onAction` callback function
each time the associated action button is clicked by the user. A
context is initialized with sufficient state to issue the associated
REST API RPC.
[[context.action]]
context.action
~~~~~~~~~~~~~~
A link:rest-api-changes.html#action-info[ActionInfo] object instance
supplied by the server describing the UI button the user used to
invoke the action.
[[context.call]]
context.call()
~~~~~~~~~~~~~~
Issues the REST API call associated with the action. The HTTP method
used comes from `context.action.method`, hiding the JavaScript from
needing to care.
.Signature
----
context.call(input, callback)
----
* input: JavaScript object to serialize as the request payload. This
parameter is ignored for GET and DELETE methods.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
context.call(
{message: "..."},
function (result) {
// ... use result here ...
});
----
[[context.change]]
context.change
~~~~~~~~~~~~~~
When the action is invoked on a change a
link:rest-api-changes.html#change-info[ChangeInfo] object instance
describing the change. Available fields of the ChangeInfo may vary
based on the options used by the UI when it loaded the change.
[[context.delete]]
context.delete()
~~~~~~~~~~~~~~~~
Issues a DELETE REST API call to the URL associated with the action.
.Signature
----
context.delete(callback)
----
* callback: JavaScript function to be invoked with the parsed
JSON result of the API call. DELETE methods often return
`204 No Content`, which is passed as null.
----
context.delete(function () {});
----
[[context.get]]
context.get()
~~~~~~~~~~~~~
Issues a GET REST API call to the URL associated with the action.
.Signature
----
context.get(callback)
----
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
context.get(function (result) {
// ... use result here ...
});
----
[[context.go]]
context.go()
~~~~~~~~~~~~
Go to a page. Shorthand for link:#Gerrit.go[`Gerrit.go()`].
[[context.hide]]
context.hide()
~~~~~~~~~~~~~~
Hide the currently visible popup displayed by
link:#context.popup[`context.popup()`].
[[context.post]]
context.post()
~~~~~~~~~~~~~~
Issues a POST REST API call to the URL associated with the action.
.Signature
----
context.post(input, callback)
----
* input: JavaScript object to serialize as the request payload.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
context.post(
{message: "..."},
function (result) {
// ... use result here ...
});
----
[[context.popup]]
context.popup()
~~~~~~~~~~~~~~~
Displays a small popup near the activation button to gather
additional input from the user before executing the REST API RPC.
The caller is always responsible for closing the popup with
link#context.hide[`context.hide()`]. Gerrit will handle closing a
popup if the user presses `Escape` while keyboard focus is within
the popup.
.Signature
----
context.popup(element)
----
* element: an HTML DOM element to display as the body of the
popup. This is typically a `div` element but can be any valid HTML
element. CSS can be used to style the element beyond the defaults.
A common usage is to gather more input:
----
self.onAction('revision', 'start-build', function (c) {
var l = c.checkbox();
var m = c.checkbox();
c.popup(c.div(
c.div(c.label(l, 'Linux')),
c.div(c.label(m, 'Mac OS X')),
c.button('Build', {onclick: function() {
c.call(
{
commit: c.revision.name,
linux: l.checked,
mac: m.checked,
},
function() { c.hide() });
});
});
----
[[context.put]]
context.put()
~~~~~~~~~~~~~
Issues a PUT REST API call to the URL associated with the action.
.Signature
----
context.put(input, callback)
----
* input: JavaScript object to serialize as the request payload.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
context.put(
{message: "..."},
function (result) {
// ... use result here ...
});
----
[[context.refresh]]
context.refresh()
~~~~~~~~~~~~~~~~~
Refresh the current display. Shorthand for
link:#Gerrit.refresh[`Gerrit.refresh()`].
[[context.revision]]
context.revision
~~~~~~~~~~~~~~~~
When the action is invoked on a specific revision of a change,
a link:rest-api-changes.html#revision-info[RevisionInfo]
object instance describing the revision. Available fields of the
RevisionInfo may vary based on the options used by the UI when it
loaded the change.
Action Context HTML Helpers
---------------------------
The link:#ActionContext[action context] includes some HTML helper
functions to make working with DOM based widgets less painful.
* `br()`: new `<br>` element.
* `button(label, options)`: new `<button>` with the string `label`
wrapped inside of a `div`. The optional `options` object may
define `onclick` as a function to be invoked upon clicking. This
calling pattern avoids circular references between the element
and the onclick handler.
* `checkbox()`: new `<input type='checkbox'>` element.
* `div(...)`: a new `<div>` wrapping the (optional) arguments.
* `hr()`: new `<hr>` element.
* `label(c, label)`: a new `<label>` element wrapping element `c`
and the string `label`. Used to wrap a checkbox with its label,
`label(checkbox(), 'Click Me')`.
* `textarea(options)`: new `<textarea>` element. The options
object may optionally include `rows` and `cols`. The textarea
comes with an onkeypress handler installed to play nicely with
Gerrit's keyboard binding system.
* `textfield()`: new `<input type='text'>` element. The text field
comes with an onkeypress handler installed to play nicely with
Gerrit's keyboard binding system.
* `span(...)`: a new `<span>` wrapping the (optional) arguments.
[[Gerrit]]
Gerrit
------
The `Gerrit` object is the only symbol provided into the global
namespace by Gerrit Code Review. All top-level functions can be
accessed through this name.
[[Gerrit.delete]]
Gerrit.delete()
~~~~~~~~~~~~~~~
Issues a DELETE REST API request to the Gerrit server. For plugin
private REST API URLs see link:#self.delete[self.delete()].
.Signature
----
Gerrit.delete(url, callback)
----
* url: URL relative to the Gerrit server. For example to access the
link:rest-api-changes.html[changes REST API] use `'/changes/'`.
* callback: JavaScript function to be invoked with the parsed
JSON result of the API call. DELETE methods often return
`204 No Content`, which is passed as null.
----
Gerrit.delete(
'/changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/topic',
function () {});
----
[[Gerrit.get]]
Gerrit.get()
~~~~~~~~~~~~
Issues a GET REST API request to the Gerrit server. For plugin
private REST API URLs see link:#self.get[self.get()].
.Signature
----
Gerrit.get(url, callback)
----
* url: URL relative to the Gerrit server. For example to access the
link:rest-api-changes.html[changes REST API] use `'/changes/'`.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
Gerrit.get('/changes/?q=status:open', function (open) {
for (var i = 0; i < open.length; i++) {
console.log(open.get(i).change_id);
}
});
----
[[Gerrit.getPluginName]]
Gerrit.getPluginName()
~~~~~~~~~~~~~~~~~~~~~~
Returns the name this plugin was installed as by the server
administrator. The plugin name is required to access REST API
views installed by the plugin, or to access resources.
Unlike link:#self.getPluginName[`self.getPluginName()`] this method
must guess the name from the JavaScript call stack. Plugins are
encouraged to use `self.getPluginName()` whenever possible.
[[Gerrit.go]]
Gerrit.go()
~~~~~~~~~~~
Updates the web UI to display the view identified by the supplied
URL token. The URL token is the text after `#` in the browser URL.
----
Gerrit.go('/admin/projects/');
----
If the URL passed matches `http://...`, `https://...`, or `//...`
the current browser window will navigate to the non-Gerrit URL.
The user can return to Gerrit with the back button.
[[Gerrit.install]]
Gerrit.install()
~~~~~~~~~~~~~~~~
Registers a new plugin by invoking the supplied initialization
function. The function is passed the link:#self[plugin instance].
----
Gerrit.install(function (self) {
// ... plugin JavaScript code here ...
});
----
[[Gerrit.post]]
Gerrit.post()
~~~~~~~~~~~~~
Issues a POST REST API request to the Gerrit server. For plugin
private REST API URLs see link:#self.post[self.post()].
.Signature
----
Gerrit.post(url, input, callback)
----
* url: URL relative to the Gerrit server. For example to access the
link:rest-api-changes.html[changes REST API] use `'/changes/'`.
* input: JavaScript object to serialize as the request payload.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
Gerrit.post(
'/changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/topic',
{topic: 'tests', message: 'Classify work as for testing.'},
function (r) {});
----
[[Gerrit.put]]
Gerrit.put()
~~~~~~~~~~~~
Issues a PUT REST API request to the Gerrit server. For plugin
private REST API URLs see link:#self.put[self.put()].
.Signature
----
Gerrit.put(url, input, callback)
----
* url: URL relative to the Gerrit server. For example to access the
link:rest-api-changes.html[changes REST API] use `'/changes/'`.
* input: JavaScript object to serialize as the request payload.
* callback: JavaScript function to be invoked with the parsed JSON
result of the API call. If the API returns a string the result is
a string, otherwise the result is a JavaScript object or array,
as described in the relevant REST API documentation.
----
Gerrit.put(
'/changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/topic',
{topic: 'tests', message: 'Classify work as for testing.'},
function (r) {});
----
[[Gerrit.onAction]]
Gerrit.onAction()
~~~~~~~~~~~~~~~~~
Register a JavaScript callback to be invoked when the user clicks
on a button associated with a server side `UiAction`.
.Signature
----
Gerrit.onAction(type, view_name, callback);
----
* type: `'change'` or `'revision'`, indicating what sort of resource
the `UiAction` was bound to in the server.
* view_name: string appearing in URLs to name the view. This is the
second argument of the `get()`, `post()`, `put()`, and `delete()`
binding methods in a `RestApiModule`.
* callback: JavaScript function to invoke when the user clicks. The
function will be passed a link:#ActionContext[ActionContext].
[[Gerrit.refresh]]
Gerrit.refresh()
~~~~~~~~~~~~~~~~
Redisplays the current web UI view, refreshing all information.
[[Gerrit.url]]
Gerrit.url()
~~~~~~~~~~~~
Returns the URL of the Gerrit Code Review server. If invoked with
no parameter the URL of the site is returned. If passed a string
the argument is appended to the site URL.
----
Gerrit.url(); // "https://gerrit-review.googlesource.com/"
Gerrit.url('/123'); // "https://gerrit-review.googlesource.com/123"
----
For a plugin specific version see link:#self.url()[`self.url()`].
GERRIT
------
Part of link:index.html[Gerrit Code Review]

View File

@ -14,6 +14,13 @@
limitations under the License.
-->
<module>
<replace-with class="com.google.gerrit.client.api.PluginName.PluginNameMoz">
<when-type-is class="com.google.gerrit.client.api.PluginName" />
<when-property-is name="compiler.stackMode" value="native" />
<when-property-is name="user.agent" value="safari" />
<when-property-is name="user.agent" value="gecko1_8" />
</replace-with>
<replace-with class="com.google.gerrit.client.ui.FancyFlexTableImplIE6">
<when-type-is class="com.google.gerrit.client.ui.FancyFlexTableImpl" />
<any>

View File

@ -21,6 +21,7 @@ import static com.google.gerrit.common.data.GlobalCapability.CREATE_PROJECT;
import com.google.gerrit.client.account.AccountCapabilities;
import com.google.gerrit.client.account.AccountInfo;
import com.google.gerrit.client.admin.ProjectScreen;
import com.google.gerrit.client.api.ApiGlue;
import com.google.gerrit.client.changes.ChangeConstants;
import com.google.gerrit.client.changes.ChangeListScreen;
import com.google.gerrit.client.patches.PatchScreen;
@ -558,7 +559,8 @@ public class Gerrit implements EntryPoint {
}
private void loadPlugins(HostPageData hpd, final String token) {
if (hpd.plugins != null) {
if (hpd.plugins != null && !hpd.plugins.isEmpty()) {
ApiGlue.init();
for (final String url : hpd.plugins) {
ScriptInjector.fromUrl(url)
.setWindow(ScriptInjector.TOP_WINDOW)

View File

@ -0,0 +1,149 @@
// Copyright (C) 2013 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.
package com.google.gerrit.client.api;
import com.google.gerrit.client.change.ActionButton;
import com.google.gerrit.client.changes.ChangeInfo;
import com.google.gerrit.client.changes.ChangeInfo.ActionInfo;
import com.google.gerrit.client.changes.ChangeInfo.RevisionInfo;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.rpc.NativeString;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gwt.core.client.JavaScriptObject;
public class ActionContext extends JavaScriptObject {
static final native void init() /*-{
var Gerrit = $wnd.Gerrit;
var doc = $wnd.document;
var stopPropagation = function (e) {
if (e && e.stopPropagation) e.stopPropagation();
else $wnd.event.cancelBubble = true;
};
Gerrit.ActionContext = function(u){this._u=u};
Gerrit.ActionContext.prototype = {
go: Gerrit.go,
refresh: Gerrit.refresh,
br: function(){return doc.createElement('br')},
hr: function(){return doc.createElement('hr')},
button: function(label, o) {
var e = doc.createElement('button');
e.appendChild(this.div(doc.createTextNode(label)));
if (o && o.onclick) e.onclick = o.onclick;
return e;
},
checkbox: function() {
var e = doc.createElement('input');
e.type = 'checkbox';
return e;
},
div: function() {
var e = doc.createElement('div');
for (var i = 0; i < arguments.length; i++)
e.appendChild(arguments[i]);
return e;
},
label: function(c,label) {
var e = doc.createElement('label');
e.appendChild(c);
e.appendChild(doc.createTextNode(label));
return e;
},
span: function() {
var e = doc.createElement('span');
for (var i = 0; i < arguments.length; i++)
e.appendChild(arguments[i]);
return e;
},
textarea: function(o) {
var e = doc.createElement('textarea');
e.onkeypress = stopPropagation;
if (o && o.rows) e.rows = o.rows;
if (o && o.cols) e.cols = o.cols;
return e;
},
textfield: function() {
var e = doc.createElement('input');
e.type = 'text';
e.onkeypress = stopPropagation;
return e;
},
popup: function(e){this._p=@com.google.gerrit.client.api.PopupHelper::popup(Lcom/google/gerrit/client/api/ActionContext;Lcom/google/gwt/dom/client/Element;)(this,e)},
hide: function() {
this._p.@com.google.gerrit.client.api.PopupHelper::hide()();
delete this['_p'];
},
call: function(i,b) {
var m = this.action.method.toLowerCase();
if (m == 'get' || m == 'delete' || i==null) this[m](b);
else this[m](i,b);
},
get: function(b){@com.google.gerrit.client.api.ActionContext::get(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._u,b)},
post: function(i,b){@com.google.gerrit.client.api.ActionContext::post(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(this._u,i,b)},
put: function(i,b){@com.google.gerrit.client.api.ActionContext::put(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(this._u,i,b)},
'delete': function(b){@com.google.gerrit.client.api.ActionContext::delete(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._u,b)},
};
}-*/;
static final native ActionContext create(RestApi f)/*-{
return new $wnd.Gerrit.ActionContext(f);
}-*/;
final native void set(ActionInfo a) /*-{ this.action=a; }-*/;
final native void set(ChangeInfo c) /*-{ this.change=c; }-*/;
final native void set(RevisionInfo r) /*-{ this.revision=r; }-*/;
final native void button(ActionButton b) /*-{ this._b=b; }-*/;
final native ActionButton button() /*-{ return this._b; }-*/;
public final native boolean has_popup() /*-{ return this.hasOwnProperty('_p') }-*/;
public final native void hide() /*-{ this.hide(); }-*/;
protected ActionContext() {
}
static final void get(RestApi api, JavaScriptObject cb) {
api.get(wrap(cb));
}
static final void post(RestApi api, JavaScriptObject in, JavaScriptObject cb) {
api.post(in, wrap(cb));
}
static final void put(RestApi api, JavaScriptObject in, JavaScriptObject cb) {
api.put(in, wrap(cb));
}
static final void delete(RestApi api, JavaScriptObject cb) {
api.delete(wrap(cb));
}
private static GerritCallback<JavaScriptObject> wrap(final JavaScriptObject cb) {
return new GerritCallback<JavaScriptObject>() {
@Override
public void onSuccess(JavaScriptObject result) {
if (NativeString.is(result)) {
NativeString s = result.cast();
ApiGlue.invoke(cb, s.asString());
} else {
ApiGlue.invoke(cb, result);
}
}
};
}
}

View File

@ -0,0 +1,125 @@
// Copyright (C) 2013 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.
package com.google.gerrit.client.api;
import com.google.gerrit.client.Gerrit;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.user.client.History;
import com.google.gwt.user.client.Window;
public class ApiGlue {
private static String pluginName;
public static void init() {
init0();
ActionContext.init();
}
private static native void init0() /*-{
var serverUrl = @com.google.gwt.core.client.GWT::getHostPageBaseURL()();
var Plugin = function (name){this.name = name};
var Gerrit = {
getPluginName: @com.google.gerrit.client.api.ApiGlue::getPluginName(),
install: function (f) {
var p = new Plugin(this.getPluginName());
@com.google.gerrit.client.api.ApiGlue::install(Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(f,p);
},
go: @com.google.gerrit.client.api.ApiGlue::go(Ljava/lang/String;),
refresh: @com.google.gerrit.client.api.ApiGlue::refresh(),
change_actions: {},
revision_actions: {},
onAction: function (t,n,c){this._onAction(this.getPluginName(),t,n,c)},
_onAction: function (p,t,n,c) {
var i = p+'~'+n;
if ('change' == t) this.change_actions[i]=c;
else if ('revision' == t) this.revision_actions[i]=c;
},
url: function (d) {
if (d && d.length > 0)
return serverUrl + (d.charAt(0)=='/' ? d.substring(1) : d);
return serverUrl;
},
_api: function(u) {return @com.google.gerrit.client.rpc.RestApi::new(Ljava/lang/String;)(u)},
get: function(u,b){@com.google.gerrit.client.api.ActionContext::get(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),b)},
post: function(u,i,b){@com.google.gerrit.client.api.ActionContext::post(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),i,b)},
put: function(u,i,b){@com.google.gerrit.client.api.ActionContext::put(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),i,b)},
'delete': function(u,b){@com.google.gerrit.client.api.ActionContext::delete(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),b)},
};
Plugin.prototype = {
getPluginName: function(){return this.name},
go: @com.google.gerrit.client.api.ApiGlue::go(Ljava/lang/String;),
refresh: Gerrit.refresh,
onAction: function(t,n,c) {Gerrit._onAction(this.name,t,n,c)},
url: function (d) {
var u = serverUrl + 'plugins/' + this.name + '/';
if (d && d.length > 0) u += d.charAt(0)=='/' ? d.substring(1) : d;
return u;
},
_api: function(d) {
var u = 'plugins/' + this.name + '/';
if (d && d.length > 0) u += d.charAt(0)=='/' ? d.substring(1) : d;
return @com.google.gerrit.client.rpc.RestApi::new(Ljava/lang/String;)(u);
},
get: function(u,b){@com.google.gerrit.client.api.ActionContext::get(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),b)},
post: function(u,i,b){@com.google.gerrit.client.api.ActionContext::post(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),i,b)},
put: function(u,i,b){@com.google.gerrit.client.api.ActionContext::put(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),i,b)},
'delete': function(u,b){@com.google.gerrit.client.api.ActionContext::delete(Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),b)},
};
$wnd.Gerrit = Gerrit;
}-*/;
private static void install(JavaScriptObject cb, JavaScriptObject p) {
try {
pluginName = PluginName.get();
invoke(cb, p);
} finally {
pluginName = null;
}
}
private static final String getPluginName() {
return pluginName != null ? pluginName : PluginName.get();
}
private static final void go(String urlOrToken) {
if (urlOrToken.startsWith("http:")
|| urlOrToken.startsWith("https:")
|| urlOrToken.startsWith("//")) {
Window.Location.assign(urlOrToken);
} else {
Gerrit.display(urlOrToken);
}
}
private static final void refresh() {
Gerrit.display(History.getToken());
}
static final native void invoke(JavaScriptObject f) /*-{ f(); }-*/;
static final native void invoke(JavaScriptObject f, JavaScriptObject a) /*-{ f(a); }-*/;
static final native void invoke(JavaScriptObject f, String a) /*-{ f(a); }-*/;
private ApiGlue() {
}
}

View File

@ -0,0 +1,48 @@
// Copyright (C) 2013 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.
package com.google.gerrit.client.api;
import com.google.gerrit.client.change.ActionButton;
import com.google.gerrit.client.changes.ChangeApi;
import com.google.gerrit.client.changes.ChangeInfo;
import com.google.gerrit.client.changes.ChangeInfo.ActionInfo;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gwt.core.client.JavaScriptObject;
public class ChangeGlue {
public static void onAction(
ChangeInfo change,
ActionInfo action,
ActionButton button) {
RestApi api = ChangeApi.change(change.legacy_id().get()).view(action.id());
JavaScriptObject f = get(action.id());
if (f != null) {
ActionContext c = ActionContext.create(api);
c.set(action);
c.set(change);
c.button(button);
ApiGlue.invoke(f, c);
} else {
DefaultActions.invoke(change, action, api);
}
}
private static final native JavaScriptObject get(String id) /*-{
return $wnd.Gerrit.change_actions[id];
}-*/;
private ChangeGlue() {
}
}

View File

@ -0,0 +1,47 @@
// Copyright (C) 2013 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.
package com.google.gerrit.client.api;
import com.google.gerrit.client.Gerrit;
import com.google.gerrit.client.changes.ChangeInfo;
import com.google.gerrit.client.changes.ChangeInfo.ActionInfo;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.user.client.rpc.AsyncCallback;
class DefaultActions {
static void invoke(ChangeInfo change, ActionInfo action, RestApi api) {
final Change.Id id = change.legacy_id();
AsyncCallback<JavaScriptObject> cb = new GerritCallback<JavaScriptObject>() {
@Override
public void onSuccess(JavaScriptObject msg) {
Gerrit.display(PageLinks.toChange2(id));
}
};
if ("PUT".equalsIgnoreCase(action.method())) {
api.put(JavaScriptObject.createObject(), cb);
} else if ("DELETE".equalsIgnoreCase(action.method())) {
api.delete(cb);
} else {
api.post(JavaScriptObject.createObject(), cb);
}
}
private DefaultActions() {
}
}

View File

@ -0,0 +1,98 @@
// Copyright (C) 2013 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.
package com.google.gerrit.client.api;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JavaScriptException;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.core.client.impl.StackTraceCreator;
/**
* Determines the name a plugin has been installed under.
*
* This implementation guesses the name a plugin runs under by looking at the
* JavaScript call stack and identifying the URL of the script file calling
* {@code Gerrit.install()}. The simple approach applied here is looking at
* the source URLs and extracting the name out of the string, e.g.:
* {@code "http://localhost:8080/plugins/{name}/static/foo.js"}.
*/
class PluginName {
private static final String UNKNOWN = "<unknown>";
static String get() {
return GWT.<PluginName> create(PluginName.class).guessName();
}
String guessName() {
JavaScriptException err = makeException();
if (hasStack(err)) {
return PluginNameMoz.guessName(err);
}
String baseUrl = baseUrl();
StackTraceElement[] trace = getTrace(err);
for (int i = trace.length - 1; i >= 0; i--) {
String u = trace[i].getFileName();
if (u != null && u.startsWith(baseUrl)) {
int s = u.indexOf('/', baseUrl.length());
if (s > 0) {
return u.substring(baseUrl.length(), s);
}
}
}
return UNKNOWN;
}
private static String baseUrl() {
return GWT.getHostPageBaseURL() + "plugins/";
}
private static StackTraceElement[] getTrace(JavaScriptException err) {
StackTraceCreator.fillInStackTrace(err);
return err.getStackTrace();
}
protected static final native JavaScriptException makeException()
/*-{ try { null.a() } catch (e) { return e } }-*/;
private static final native boolean hasStack(JavaScriptException e)
/*-{ return !!e.stack }-*/;
/** Extracts URL from the stack frame. */
static class PluginNameMoz extends PluginName {
String guessName() {
return guessName(makeException());
}
static String guessName(JavaScriptException e) {
String baseUrl = baseUrl();
JsArrayString stack = getStack(e);
for (int i = stack.length() - 1; i >= 0; i--) {
String frame = stack.get(i);
int at = frame.indexOf(baseUrl);
if (at >= 0) {
int s = frame.indexOf('/', at + baseUrl.length());
if (s > 0) {
return frame.substring(at + baseUrl.length(), s);
}
}
}
return UNKNOWN;
}
private static final native JsArrayString getStack(JavaScriptException e)
/*-{ return e.stack ? e.stack.split('\n') : [] }-*/;
}
}

View File

@ -0,0 +1,72 @@
// Copyright (C) 2013 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.
package com.google.gerrit.client.api;
import com.google.gerrit.client.change.ActionButton;
import com.google.gerrit.client.change.Resources;
import com.google.gwt.dom.client.Element;
import com.google.gwt.event.logical.shared.CloseEvent;
import com.google.gwt.event.logical.shared.CloseHandler;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwtexpui.globalkey.client.GlobalKey;
import com.google.gwtexpui.user.client.PluginSafePopupPanel;
class PopupHelper {
static PopupHelper popup(ActionContext ctx, Element panel) {
PopupHelper helper = new PopupHelper(ctx.button(), panel);
helper.show();
ctx.button().link(ctx);
return helper;
}
private final ActionButton activatingButton;
private final FlowPanel panel;
private PluginSafePopupPanel popup;
PopupHelper(ActionButton button, Element child) {
activatingButton = button;
panel = new FlowPanel();
panel.setStyleName(Resources.I.style().popupContent());
panel.getElement().appendChild(child);
}
void show() {
final PluginSafePopupPanel p = new PluginSafePopupPanel(true);
p.setStyleName(Resources.I.style().popup());
p.addAutoHidePartner(activatingButton.getElement());
p.addCloseHandler(new CloseHandler<PopupPanel>() {
@Override
public void onClose(CloseEvent<PopupPanel> event) {
activatingButton.unlink();
if (popup == p) {
popup = null;
}
}
});
p.add(panel);
p.showRelativeTo(activatingButton);
GlobalKey.dialog(p);
popup = p;
}
void hide() {
if (popup != null) {
activatingButton.unlink();
popup.hide();
popup = null;
}
}
}

View File

@ -0,0 +1,55 @@
// Copyright (C) 2013 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.
package com.google.gerrit.client.api;
import com.google.gerrit.client.change.ActionButton;
import com.google.gerrit.client.changes.ChangeApi;
import com.google.gerrit.client.changes.ChangeInfo;
import com.google.gerrit.client.changes.ChangeInfo.ActionInfo;
import com.google.gerrit.client.changes.ChangeInfo.RevisionInfo;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gwt.core.client.JavaScriptObject;
public class RevisionGlue {
public static void onAction(
ChangeInfo change,
RevisionInfo revision,
ActionInfo action,
ActionButton button) {
RestApi api = ChangeApi.revision(
change.legacy_id().get(),
revision.name())
.view(action.id());
JavaScriptObject f = get(action.id());
if (f != null) {
ActionContext c = ActionContext.create(api);
c.set(action);
c.set(change);
c.set(revision);
c.button(button);
ApiGlue.invoke(f, c);
} else {
DefaultActions.invoke(change, action, api);
}
}
private static final native JavaScriptObject get(String id) /*-{
return $wnd.Gerrit.revision_actions[id];
}-*/;
private RevisionGlue() {
}
}

View File

@ -14,32 +14,28 @@
package com.google.gerrit.client.change;
import com.google.gerrit.client.ErrorDialog;
import com.google.gerrit.client.Gerrit;
import com.google.gerrit.client.changes.ChangeApi;
import com.google.gerrit.client.api.ActionContext;
import com.google.gerrit.client.api.ChangeGlue;
import com.google.gerrit.client.api.RevisionGlue;
import com.google.gerrit.client.changes.ChangeInfo;
import com.google.gerrit.client.changes.ChangeInfo.ActionInfo;
import com.google.gerrit.client.rpc.NativeString;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gerrit.client.changes.ChangeInfo.RevisionInfo;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.ui.Button;
import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
class ActionButton extends Button implements ClickHandler {
private final Change.Id changeId;
private final String revision;
public class ActionButton extends Button implements ClickHandler {
private final ChangeInfo change;
private final RevisionInfo revision;
private final ActionInfo action;
private ActionContext ctx;
ActionButton(Change.Id changeId, ActionInfo action) {
this(changeId, null, action);
ActionButton(ChangeInfo change, ActionInfo action) {
this(change, null, action);
}
ActionButton(Change.Id changeId, String revision, ActionInfo action) {
ActionButton(ChangeInfo change, RevisionInfo revision, ActionInfo action) {
super(new SafeHtmlBuilder()
.openDiv()
.append(action.label())
@ -49,44 +45,42 @@ class ActionButton extends Button implements ClickHandler {
setEnabled(action.enabled());
addClickHandler(this);
this.changeId = changeId;
this.change = change;
this.revision = revision;
this.action = action;
}
@Override
public void onClick(ClickEvent event) {
setEnabled(false);
if (ctx != null && ctx.has_popup()) {
ctx.hide();
ctx = null;
return;
}
AsyncCallback<NativeString> cb = new AsyncCallback<NativeString>() {
@Override
public void onFailure(Throwable caught) {
setEnabled(true);
new ErrorDialog(caught).center();
}
@Override
public void onSuccess(NativeString msg) {
setEnabled(true);
if (msg != null && !msg.asString().isEmpty()) {
// TODO Support better UI on UiAction results.
Window.alert(msg.asString());
}
Gerrit.display(PageLinks.toChange2(changeId));
}
};
RestApi api = revision != null
? ChangeApi.revision(changeId.get(), revision)
: ChangeApi.change(changeId.get());
api.view(action.id());
if ("PUT".equalsIgnoreCase(action.method())) {
api.put(JavaScriptObject.createObject(), cb);
} else if ("DELETE".equalsIgnoreCase(action.method())) {
api.delete(cb);
if (revision != null) {
RevisionGlue.onAction(change, revision, action, this);
} else {
api.post(JavaScriptObject.createObject(), cb);
ChangeGlue.onAction(change, action, this);
}
}
@Override
public void onUnload() {
if (ctx != null) {
if (ctx.has_popup()) {
ctx.hide();
}
ctx = null;
}
super.onUnload();
}
public void link(ActionContext ctx) {
this.ctx = ctx;
}
public void unlink() {
ctx = null;
}
}

View File

@ -89,7 +89,7 @@ class Actions extends Composite {
if (hasUser) {
for (String id : filterNonCore(actions)) {
add(new ActionButton(changeId, actions.get(id)));
add(new ActionButton(info, actions.get(id)));
}
}
}
@ -112,7 +112,7 @@ class Actions extends Composite {
if (hasUser) {
for (String id : filterNonCore(actions)) {
add(new ActionButton(changeId, revision, actions.get(id)));
add(new ActionButton(info, revInfo, actions.get(id)));
}
}
}

View File

@ -19,8 +19,8 @@ import com.google.gwt.resources.client.ClientBundle;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.ImageResource;
interface Resources extends ClientBundle {
static final Resources I = GWT.create(Resources.class);
public interface Resources extends ClientBundle {
public static final Resources I = GWT.create(Resources.class);
@Source("star_open.png") ImageResource star_open();
@Source("star_filled.png") ImageResource star_filled();
@ -28,7 +28,9 @@ interface Resources extends ClientBundle {
@Source("reload_white.png") ImageResource reload_white();
@Source("common.css") Style style();
interface Style extends CssResource {
public interface Style extends CssResource {
String button();
String popup();
String popupContent();
}
}

View File

@ -13,7 +13,21 @@
* limitations under the License.
*/
.button {
@eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
.popup {
background-color: trimColor;
min-width: 300px;
min-height: 90px;
}
.popupContent {
padding: 5px;
}
.button,
.popup button,
.popup input[type='button'] {
margin: 0 3px 0 0;
border-color: rgba(0, 0, 0, 0.1);
text-align: center;
@ -28,7 +42,7 @@
-webkit-box-sizing: content-box;
}
.button div {
.button div, .popup button div {
width: 54px;
white-space: nowrap;
color: #fff;

View File

@ -19,12 +19,18 @@ import com.google.gwt.user.client.rpc.AsyncCallback;
/** Wraps a String that was returned from a JSON API. */
public final class NativeString extends JavaScriptObject {
static NativeString wrap(String value) {
NativeString ns = (NativeString) createObject();
ns.set(value);
return ns;
private static final JavaScriptObject TYPE = init();
private static final native JavaScriptObject init()
/*-{ return function(s){this.s=s} }-*/;
static final NativeString wrap(String s) {
return wrap0(TYPE, s);
}
private static final native NativeString wrap0(JavaScriptObject T, String s)
/*-{ return new T(s) }-*/;
public final native String asString() /*-{ return this.s; }-*/;
private final native void set(String v) /*-{ this.s = v; }-*/;
@ -43,6 +49,13 @@ public final class NativeString extends JavaScriptObject {
};
}
public static final boolean is(JavaScriptObject o) {
return is(TYPE, o);
}
private static final native boolean is(JavaScriptObject T, JavaScriptObject o)
/*-{ return o instanceof T }-*/;
protected NativeString() {
}
}