Allow plugins to add entire screens to Gerrit

Add an extension screen which contains a hook that can be used by
GWT and JS plugins to integrate complete screens into the Gerrit UI.

Change-Id: I48a92ea49c4384db034177b04b2e7f0bea6a87b2
This commit is contained in:
Shawn Pearce
2013-12-26 15:32:26 -08:00
committed by David Ostrovsky
parent e9c62bb780
commit d5c844f43e
12 changed files with 515 additions and 17 deletions

View File

@@ -1498,6 +1498,48 @@ In order to be able to do REST calls the GWT module must inherit
<inherits name="com.google.gwt.json.JSON"/>
----
== Add Screen
A GWT plugin can add a menu item that opens a screen that is
implemented by the plugin. This way plugin screens can be fully
integrated into the Gerrit UI.
Example menu item:
[source,java]
----
public class MyMenu implements TopMenu {
private final List<MenuEntry> menuEntries;
@Inject
public MyMenu(@PluginName String name) {
menuEntries = Lists.newArrayList();
menuEntries.add(new MenuEntry("My Menu", Collections.singletonList(
new MenuItem("My Screen", "#/x/" + name + "/my-screen", ""))));
}
@Override
public List<MenuEntry> getEntries() {
return menuEntries;
}
}
----
Example screen:
[source,java]
----
public class MyPlugin extends PluginEntryPoint {
@Override
public void onPluginLoad() {
Plugin.get().screen("my-screen", new Screen.EntryPoint() {
@Override
public void onLoad(Screen screen) {
screen.add(new InlineLabel("My Screen");
screen.show();
}
});
}
}
----
[[http]]
== HTTP Servlets

View File

@@ -139,8 +139,8 @@ Gerrit.on(event, callback);
Supported events:
* `history`: Invoked when the view is changed to a new page within the
Gerrit web application. The token after "#" is passed as the
* `history`: Invoked when the view is changed to a new screen within
the Gerrit web application. The token after "#" is passed as the
argument to the callback function, for example "/c/42/" while
showing change 42.
@@ -164,7 +164,7 @@ on a button associated with a server side `UiAction`.
.Signature
[source,javascript]
----
Gerrit.onAction(type, view_name, callback);
self.onAction(type, view_name, callback);
----
* type: `'change'`, `'revision'` or `'project'`, indicating which type
@@ -177,6 +177,27 @@ Gerrit.onAction(type, view_name, callback);
* callback: JavaScript function to invoke when the user clicks. The
function will be passed a link:#ActionContext[action context].
[[self_screen]]
=== self.screen()
Register a JavaScript callback to be invoked when the user navigates
to an extension screen provided by the plugin. Extension screens are
usually linked from the link:dev-plugins.html#top-menu-extensions[top menu].
The callback can populate the DOM with the screen's contents.
.Signature
[source,javascript]
----
self.screen(pattern, callback);
----
* pattern: URL token pattern to identify the screen. Argument can be
either a string (`'index'`) or a RegExp object (`/list\/(.*)/`).
If a RegExp is used the matching groups will be available inside of
the context as `token_match`.
* callback: JavaScript function to invoke when the user navigates to
the screen. The function will be passed a link:#ScreenContext[screen context].
[[self_url]]
=== self.url()
Returns a URL within the plugin's URL space. If invoked with no
@@ -282,7 +303,7 @@ context.get(function (result) {
[[context_go]]
=== context.go()
Go to a page. Shorthand for link:#Gerrit_go[`Gerrit.go()`].
Go to a screen. Shorthand for link:#Gerrit_go[`Gerrit.go()`].
[[context_hide]]
=== context.hide()
@@ -402,7 +423,7 @@ loaded the change.
When the action is invoked on a specific project,
the name of the project.
== Action Context HTML Helpers
=== HTML Helpers
The link:#ActionContext[action context] includes some HTML helper
functions to make working with DOM based widgets less painful.
@@ -446,6 +467,80 @@ functions to make working with DOM based widgets less painful.
* `msg(label)`: a new label.
[[ScreenContext]]
== Screen Context
A new screen context is passed to the `screen` callback function
each time the user navigates to a matching URL.
[[screen_body]]
=== screen.body
Empty HTML `<div>` node the plugin should add its content to. The
node is already attached to the document, but is invisible. Plugins
must call `screen.show()` to display the DOM node. Deferred display
allows an implementor to partially populate the DOM, make remote HTTP
requests, finish populating when the callbacks arrive, and only then
make the view visible to the user.
[[screen_token]]
=== screen.token
URL token fragment that activated this screen. The value is identical
to `screen.token_match[0]`. If the URL is `/#/x/hello/list` the token
will be `"list"`.
[[screen_token_match]]
=== screen.token_match
Array of matching subgroups from the pattern specified to `screen()`.
This is identical to the result of RegExp.exec. Index 0 contains the
entire matching expression; index 1 the first matching group, etc.
[[screen_onUnload]]
=== screen.onUnload()
Configures an optional callback to be invoked just before the screen
is deleted from the browser DOM. Plugins can use this callback to
remove event listeners from DOM nodes, preventing memory leaks.
.Signature
[source,javascript]
----
screen.onUnload(callback)
----
* callback: JavaScript function to be invoked just before the
`screen.body` DOM element is removed from the browser DOM.
This event happens when the user navigates to another screen.
[[screen.setTitle]]
=== screen.setTitle()
Sets the heading text to be displayed when the screen is visible.
This is presented in a large bold font below the menus, but above the
content in `screen.body`. Setting the title also sets the window
title to the same string, if it has not already been set.
.Signature
[source,javascript]
----
screen.setPageTitle(titleText)
----
[[screen.setWindowTitle]]
=== screen.setWindowTitle()
Sets the text to be displayed in the browser's title bar when the
screen is visible. Plugins should always prefer this method over
trying to set `window.title` directly. The window title defaults to
the title given to `setTitle`.
.Signature
[source,javascript]
----
screen.setWindowTitle(titleText)
----
[[screen_show]]
=== screen.show()
Destroy the currently visible screen and display the plugin's screen.
This method must be called after adding content to `screen.body`.
[[Gerrit]]
== Gerrit
@@ -518,7 +613,7 @@ encouraged to use `self.getPluginName()` whenever possible.
[[Gerrit_go]]
=== Gerrit.go()
Updates the web UI to display the view identified by the supplied
Updates the web UI to display the screen identified by the supplied
URL token. The URL token is the text after `#` in the browser URL.
[source,javascript]
@@ -621,6 +716,27 @@ Gerrit.onAction(type, view_name, callback);
* callback: JavaScript function to invoke when the user clicks. The
function will be passed a link:#ActionContext[ActionContext].
[[Gerrit_screen]]
=== Gerrit.screen()
Register a JavaScript callback to be invoked when the user navigates
to an extension screen provided by the plugin. Extension screens are
usually linked from the link:dev-plugins.html#top-menu-extensions[top menu].
The callback can populate the DOM with the screen's contents.
.Signature
[source,javascript]
----
Gerrit.screen(pattern, callback);
----
* pattern: URL token pattern to identify the screen. Argument can be
either a string (`'index'`) or a RegExp object (`/list\/(.*)/`).
If a RegExp is used the matching groups will be available inside of
the context as `token_match`.
* callback: JavaScript function to invoke when the user navigates to
the screen. The function will be passed link:#ScreenContext[screen context].
[[Gerrit_refresh]]
=== Gerrit.refresh()
Redisplays the current web UI view, refreshing all information.

View File

@@ -60,6 +60,7 @@ import com.google.gerrit.client.admin.ProjectDashboardsScreen;
import com.google.gerrit.client.admin.ProjectInfoScreen;
import com.google.gerrit.client.admin.ProjectListScreen;
import com.google.gerrit.client.admin.ProjectScreen;
import com.google.gerrit.client.api.ExtensionScreen;
import com.google.gerrit.client.change.ChangeScreen2;
import com.google.gerrit.client.changes.AccountDashboardScreen;
import com.google.gerrit.client.changes.ChangeScreen;
@@ -226,6 +227,9 @@ public class Dispatcher {
} else if (matchPrefix("/c/", token)) {
change(token);
} else if (matchPrefix("/x/", token)) {
extension(token);
} else if (matchExact(MINE, token)) {
Gerrit.display(token, mine(token));
@@ -564,6 +568,15 @@ public class Dispatcher {
}
}
private static void extension(final String token) {
ExtensionScreen view = new ExtensionScreen(skip(token));
if (view.isFound()) {
Gerrit.display(token, view);
} else {
Gerrit.display(token, new NotFoundScreen());
}
}
private static boolean isChangeScreen2() {
if (!Gerrit.getConfig().getNewFeatures()) {
return false;

View File

@@ -505,12 +505,9 @@ public class Gerrit implements EntryPoint {
body = new ViewSite<Screen>() {
@Override
protected void onShowView(Screen view) {
lastViewToken = History.getToken();
String token = view.getToken();
if (!token.equals(lastViewToken)) {
History.newItem(token, false);
dispatchHistoryHooks(token);
}
History.newItem(token, false);
dispatchHistoryHooks(token);
if (view instanceof ChangeListScreen) {
lastChangeListToken = token;
@@ -518,6 +515,7 @@ public class Gerrit implements EntryPoint {
super.onShowView(view);
view.onShowView();
lastViewToken = token;
}
};
gBody.add(body);

View File

@@ -15,8 +15,6 @@
package com.google.gerrit.client.api;
import com.google.gerrit.client.Gerrit;
import com.google.gerrit.client.rpc.NativeString;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.user.client.History;
@@ -26,17 +24,20 @@ public class ApiGlue {
private static String pluginName;
public static void init() {
init0(GWT.getHostPageBaseURL(), NativeString.TYPE);
init0();
ActionContext.init();
Plugin.init();
addHistoryHook();
}
private static native void init0(String serverUrl, JavaScriptObject JsonString) /*-{
private static native void init0() /*-{
var serverUrl = @com.google.gwt.core.client.GWT::getHostPageBaseURL()();
var ScreenDefinition = @com.google.gerrit.client.api.ExtensionScreen.Definition::TYPE;
$wnd.Gerrit = {
JsonString: JsonString,
JsonString: @com.google.gerrit.client.rpc.NativeString::TYPE,
events: {},
plugins: {},
screens: {},
change_actions: {},
revision_actions: {},
project_actions: {},
@@ -63,6 +64,12 @@ public class ApiGlue {
if ('change' == t) this.change_actions[i]=c;
else if ('revision' == t) this.revision_actions[i]=c;
else if ('project' == t) this.project_actions[i]=c;
else if ('screen' == t) _screen(p,t,c);
},
screen: function(r,c){this._screen(this.getPluginName(),r,c)},
_screen: function(p,r,c){
var s = new ScreenDefinition(r,c);
(this.screens[p] || (this.screens[p]=[])).push(s);
},
url: function (d) {

View File

@@ -0,0 +1,139 @@
// 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.rpc.Natives;
import com.google.gerrit.client.ui.Screen;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.dom.client.Element;
/** Screen contributed by a plugin. */
public class ExtensionScreen extends Screen {
private Context ctx;
public ExtensionScreen(String token) {
String name;
String rest;
int s = token.indexOf('/');
if (0 < s) {
name = token.substring(0, s);
rest = token.substring(s + 1);
} else {
name = token;
rest = "";
}
ctx = create(name, rest);
}
private Context create(String name, String rest) {
for (Definition def : Natives.asList(Definition.get(name))) {
JsArrayString m = def.match(rest);
if (m != null) {
return Context.create(def, this, m);
}
}
return null;
}
public boolean isFound() {
return ctx != null;
}
@Override
protected void onLoad() {
super.onLoad();
setHeaderVisible(false);
ctx.onLoad();
}
@Override
protected void onUnload() {
super.onUnload();
for (JavaScriptObject u : Natives.asList(ctx.unload())) {
ApiGlue.invoke(u);
}
}
static class Definition extends JavaScriptObject {
static final JavaScriptObject TYPE = init();
private static native JavaScriptObject init() /*-{
function ScreenDefinition(r, c) {
this.pattern = r;
this.onLoad = c;
};
return ScreenDefinition;
}-*/;
static native JsArray<Definition> get(String n)
/*-{ return $wnd.Gerrit.screens[n] || [] }-*/;
final native JsArrayString match(String t)
/*-{
var p = this.pattern;
if (p instanceof $wnd.RegExp) {
var m = p.exec(t);
return m && m[0] == t ? m : null;
}
return p == t ? [t] : null;
}-*/;
protected Definition() {
}
}
static class Context extends JavaScriptObject {
static final Context create(
Definition def,
ExtensionScreen view,
JsArrayString match) {
return create(TYPE, def, view, view.getBody().getElement(), match);
}
final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
private static final native Context create(
JavaScriptObject T,
Definition d,
ExtensionScreen s,
Element e,
JsArrayString m)
/*-{ return new T(d,s,e,m) }-*/;
private static final JavaScriptObject TYPE = init();
private static final native JavaScriptObject init() /*-{
var T = function(d,s,e,m) {
this._d = d;
this._s = s;
this._u = [];
this.body = e;
this.token = m[0];
this.token_match = m;
};
T.prototype = {
setTitle: function(t){this._s.@com.google.gerrit.client.ui.Screen::setPageTitle(Ljava/lang/String;)(t)},
setWindowTitle: function(t){this._s.@com.google.gerrit.client.ui.Screen::setWindowTitle(Ljava/lang/String;)(t)},
show: function(){$entry(this._s.@com.google.gwtexpui.user.client.View::display()())},
onUnload: function(f){this._u.push(f)},
};
return T;
}-*/;
protected Context() {
}
}
}

View File

@@ -54,6 +54,7 @@ final class Plugin extends JavaScriptObject {
refresh: @com.google.gerrit.client.api.ApiGlue::refresh(),
on: function(e,f){G.on(e,f)},
onAction: function(t,n,c){G._onAction(this.name,t,n,c)},
screen: function(p,c){G._screen(this.name,p,c)},
url: function (u){return G.url(this._url(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)},

View File

@@ -137,6 +137,10 @@ public abstract class Screen extends View {
body.add(w);
}
protected FlowPanel getBody() {
return body;
}
protected void setTheme(final ThemeInfo t) {
theme = t;
}

View File

@@ -14,6 +14,8 @@
limitations under the License.
-->
<module>
<inherits name="com.google.gwt.json.JSON"/>
<define-linker name="gerrit_plugin" class="com.google.gerrit.plugin.linker.GerritPluginLinker"/>
<add-linker name="gerrit_plugin"/>
<generate-with class="com.google.gerrit.plugin.rebind.PluginGenerator">

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.plugin.client;
import com.google.gerrit.plugin.client.screen.Screen;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JavaScriptObject;
@@ -49,6 +50,35 @@ public final class Plugin extends JavaScriptObject {
public final native void refresh()
/*-{ return this.refresh() }-*/;
/**
* Register a screen displayed at {@code /#/x/plugin/token}.
*
* @param token literal anchor token appearing after the plugin name. For
* regular expression matching use {@code screenRegex()} .
* @param entry callback function invoked to create the screen widgets.
*/
public final void screen(String token, Screen.EntryPoint entry) {
screen(token, wrap(entry));
}
private final native void screen(String t, JavaScriptObject e)
/*-{ this.screen(t, e) }-*/;
/**
* Register a screen displayed at {@code /#/x/plugin/regex}.
*
* @param regex JavaScript {@code RegExp} expression to match the anchor token
* after the plugin name. Matching groups are exposed through the
* {@code Screen} object passed into the {@code Screen.EntryPoint}.
* @param entry callback function invoked to create the screen widgets.
*/
public final void screenRegex(String regex, Screen.EntryPoint entry) {
screenRegex(regex, wrap(entry));
}
private final native void screenRegex(String p, JavaScriptObject e)
/*-{ this.screen(new $wnd.RegExp(p), e) }-*/;
protected Plugin() {
}
@@ -56,4 +86,11 @@ public final class Plugin extends JavaScriptObject {
native void _loaded() /*-{ this._loadedGwt() }-*/;
private static native final Plugin install(String u)
/*-{ return $wnd.Gerrit.installGwt(u) }-*/;
private static final native JavaScriptObject wrap(Screen.EntryPoint b) /*-{
return $entry(function(c){
b.@com.google.gerrit.plugin.client.screen.Screen.EntryPoint::onLoad(Lcom/google/gerrit/plugin/client/screen/Screen;)(
@com.google.gerrit.plugin.client.screen.Screen::new(Lcom/google/gerrit/plugin/client/screen/Screen$Context;)(c));
});
}-*/;
}

View File

@@ -0,0 +1,140 @@
// 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.plugin.client.screen;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.dom.client.Element;
import com.google.gwt.user.client.ui.SimplePanel;
import com.google.gwt.user.client.ui.Widget;
/**
* Screen contributed by this plugin.
*
* Screens should be registered early at module load:
*
* <pre>
* &#064;Override
* public void onModuleLoad() {
* Plugin.get().screen(&quot;hi&quot;, new Screen.EntryPoint() {
* &#064;Override
* public void onLoad(Screen screen) {
* screen.setPageTitle(&quot;Hi&quot;);
* screen.show(new Label(&quot;World&quot;));
* }
* });
* }
* </pre>
*/
public final class Screen extends SimplePanel {
/** Initializes a screen for display. */
public interface EntryPoint {
/**
* Invoked when the screen has been created, but not yet displayed.
* <p>
* The implementation should create a single widget to define the content of
* this screen and added it to the passed screen instance. When the screen
* is ready to be displayed, call {@link Screen#show()}.
* <p>
* To use multiple widgets, compose them in panels such as {@code FlowPanel}
* and add only the top level widget to the screen.
* <p>
* The screen is already attached to the browser DOM in an invisible area.
* Any widgets added to the screen will immediately receive {@code onLoad()}.
* GWT will fire {@code onUnload()} when the screen is removed from the UI,
* generally caused by the user navigating to another screen.
*
* @param screen panel that will contain the screen widget.
*/
public void onLoad(Screen screen);
}
static final class Context extends JavaScriptObject {
final native Element body() /*-{ return this.body }-*/;
final native JsArrayString token_match() /*-{ return this.token_match }-*/;
final native void show() /*-{ this.show() }-*/;
final native void setTitle(String t) /*-{ this.setTitle(t) }-*/;
final native void setWindowTitle(String t) /*-{ this.setWindowTitle(t) }-*/;
final native void detach(Screen s) /*-{
this.onUnload($entry(function(){
s.@com.google.gwt.user.client.ui.Widget::onDetach()();
}));
}-*/;
protected Context() {
}
}
private final Context ctx;
Screen(Context ctx) {
super(ctx.body());
this.ctx = ctx;
onAttach();
ctx.detach(this);
}
/** @return the token suffix after {@code "/#/x/plugin-name/"}. */
public final String getToken() {
return getToken(0);
}
/**
* @param group groups range from 1 to {@code getTokenGroups() - 1}. Token
* group 0 is the entire token, see {@link #getToken()}.
* @return the token from the regex match group.
*/
public final String getToken(int group) {
return ctx.token_match().get(group);
}
/** @return total number of token groups. */
public final int getTokenGroups() {
return ctx.token_match().length();
}
/**
* Set the page title text; appears above the widget.
*
* @param titleText text to display above the widget.
*/
public final void setPageTitle(String titleText) {
ctx.setTitle(titleText);
}
/**
* Set the window title text; appears in the browser window title bar.
*
* @param titleText text to display in the window title bar.
*/
public final void setWindowTitle(String titleText) {
ctx.setWindowTitle(titleText);
}
/**
* Add the widget and immediately show the screen.
*
* @param w child containing the content.
*/
public final void show(Widget w) {
setWidget(w);
ctx.show();
}
/** Show this screen in the web interface. */
public final void show() {
ctx.show();
}
}

View File

@@ -4,7 +4,6 @@ GWT_COMPILER_OPTS = [
'-optimize', '9',
'-XdisableClassMetadata',
'-XdisableCastChecking',
'-XenableClosureCompiler',
]
GWT_PLUGIN_DEPS = [