Support for .screen() plugin API

Adds `.screen()` plugin API method for adding plugin-provided
screens. Such screens only have common headers and footers, while rest
of the page is filled with the registered web component.

Any navigation to URL containing `#/x/` hash results in client-only
redirect to `/x/` and showing appropriate plugin-provided screen.

Adds a `plugin.screenUrl()` method for generating consistent URLs for
such screens.

Adds an example plugin with number of ways the provided API can be used.

Adds partial support for GWT UI `.screen()` method. Notable difference -
does not support RegExp for the screen matching.

Change-Id: I0be3dee8eba6f8535a1fb2be05f473f3649bad8f
This commit is contained in:
Viktar Donich 2017-12-12 14:41:47 -08:00
parent c35670bf62
commit 6ea8c8a31e
10 changed files with 211 additions and 7 deletions

View File

@ -161,11 +161,14 @@ Note: TODO
`plugin.hook(endpointName, opt_options)`
See list of supported link:pg-plugin-endpoints.html[endpoints].
Note: TODO
=== registerCustomComponent
`plugin.registerCustomComponent(endpointName, opt_moduleName, opt_options)`
See list of supported link:pg-plugin-endpoints.html[endpoints].
Note: TODO
=== registerStyleModule
@ -239,6 +242,28 @@ Note: TODO
Note: TODO
=== screen
`plugin.screen(screenName, opt_moduleName)`
.Params:
- `*string* screenName` URL path fragment of the screen, e.g.
`/x/pluginname/*screenname*`
- `*string* opt_moduleName` (Optional) Web component to be instantiated for this
screen.
.Returns:
- Instance of GrDomHook.
=== screenUrl
`plugin.url(opt_screenName)`
.Params:
- `*string* screenName` (optional) URL path fragment of the screen, e.g.
`/x/pluginname/*screenname*`
.Returns:
- Absolute URL for the screen, e.g. `http://localhost/base/x/pluginname/screenname`
=== theme
`plugin.theme()`

View File

@ -72,14 +72,15 @@ limitations under the License.
View: {
ADMIN: 'admin',
CHANGE: 'change',
AGREEMENTS: 'agreements',
CHANGE: 'change',
DASHBOARD: 'dashboard',
DIFF: 'diff',
EDIT: 'edit',
GROUP: 'group',
PLUGIN_SCREEN: 'plugin-screen',
SEARCH: 'search',
SETTINGS: 'settings',
GROUP: 'group',
},
GroupDetailView: {

View File

@ -130,6 +130,8 @@
// Matches /c/<changeNum>/ /<URL tail>
// Catches improperly encoded URLs (context: Issue 7100)
IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
};
/**
@ -621,6 +623,14 @@
page((ctx, next) => {
document.body.scrollTop = 0;
if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
// Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
// This is needed to allow plugins to add basic #/x/ screen links to
// any location.
this._redirect(ctx.hash);
return;
}
// Fire asynchronously so that the URL is changed by the time the event
// is processed.
this.async(() => {
@ -748,6 +758,8 @@
this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
'_handleImproperlyEncodedPlusRoute');
this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
// Note: this route should appear last so it only catches URLs unmatched
// by other patterns.
this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
@ -1276,6 +1288,13 @@
this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
},
_handlePluginScreen(ctx) {
const view = Gerrit.Nav.View.PLUGIN_SCREEN;
const plugin = ctx.params[0];
const screen = ctx.params[1];
this._setParams({view, plugin, screen});
},
/**
* Catchall route for when no other route is matched.
*/

View File

@ -168,6 +168,7 @@ limitations under the License.
'_handleTagListFilterOffsetRoute',
'_handleTagListFilterRoute',
'_handleTagListOffsetRoute',
'_handlePluginScreen',
];
// Handler names that check authentication themselves, and thus don't need
@ -1332,6 +1333,16 @@ limitations under the License.
assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
});
});
test('_handlePluginScreen', () => {
const ctx = {params: ['foo', 'bar']};
assertDataToParams(ctx, '_handlePluginScreen', {
view: Gerrit.Nav.View.PLUGIN_SCREEN,
plugin: 'foo',
screen: 'bar',
});
assert.isFalse(redirectStub.called);
});
});
suite('_parseQueryString', () => {

View File

@ -48,6 +48,7 @@ limitations under the License.
<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
<link rel="import" href="./plugins/gr-endpoint-param/gr-endpoint-param.html">
<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
<link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
@ -172,6 +173,11 @@ limitations under the License.
<gr-admin-view path="[[_path]]"
params=[[params]]></gr-admin-view>
</template>
<template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
<gr-endpoint-decorator name="[[_pluginScreenName]]">
<gr-endpoint-param name="token" value="[[params.screen]]"></gr-endpoint-param>
</gr-endpoint-decorator>
</template>
<template is="dom-if" if="[[_showCLAView]]" restamp="true">
<gr-cla-view path="[[_path]]"></gr-cla-view>
</template>

View File

@ -36,7 +36,7 @@
properties: {
/**
* @type {{ query: string, view: string }}
* @type {{ query: string, view: string, screen: string }}
*/
params: Object,
keyEventTarget: {
@ -72,6 +72,7 @@
_showAdminView: Boolean,
_showCLAView: Boolean,
_showEditorView: Boolean,
_showPluginScreen: Boolean,
/** @type {?} */
_viewState: Object,
/** @type {?} */
@ -79,6 +80,10 @@
_lastSearchPage: String,
_path: String,
_isShadowDom: Boolean,
_pluginScreenName: {
type: String,
computed: '_computePluginScreenName(params)',
},
},
listeners: {
@ -160,6 +165,14 @@
view === Gerrit.Nav.View.GROUP);
this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
this.set('_showPluginScreen', false);
// Navigation within plugin screens does not restamp gr-endpoint-decorator
// because _showPluginScreen value does not change. To force restamp,
// change _showPluginScreen value between true and false.
if (isPluginScreen) {
this.async(() => this.set('_showPluginScreen', true), 1);
}
if (this.params.justRegistered) {
this.$.registration.open();
}
@ -282,5 +295,9 @@
Gerrit.Nav.navigateToStatusSearch(status);
}
},
_computePluginScreenName({plugin, screen}) {
return Gerrit._getPluginScreenName(plugin, screen);
},
});
})();

View File

@ -456,5 +456,33 @@ limitations under the License.
assert.isFalse(stub.called);
});
});
suite('screen', () => {
test('screenUrl()', () => {
sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/base');
assert.equal(plugin.screenUrl(), 'http://test.com/base/x/testplugin');
assert.equal(
plugin.screenUrl('foo'), 'http://test.com/base/x/testplugin/foo');
});
test('deprecated works', () => {
const stub = sandbox.stub();
const hookStub = {onAttached: sandbox.stub()};
sandbox.stub(plugin, 'hook').returns(hookStub);
plugin.deprecated.screen('foo', stub);
assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
const fakeEl = {style: {display: ''}};
hookStub.onAttached.callArgWith(0, fakeEl);
assert.isTrue(stub.called);
assert.equal(fakeEl.style.display, 'none');
});
test('works', () => {
sandbox.stub(plugin, 'registerCustomComponent');
plugin.screen('foo', 'some-module');
assert.isTrue(plugin.registerCustomComponent.calledWith(
'testplugin-screen-foo', 'some-module'));
});
});
});
</script>

View File

@ -23,7 +23,7 @@
*/
const plugins = {};
const stubbedMethods = ['_loadedGwt', 'screen', 'settingsScreen', 'panel'];
const stubbedMethods = ['_loadedGwt', 'settingsScreen', 'panel'];
const GWT_PLUGIN_STUB = {};
for (const name of stubbedMethods) {
GWT_PLUGIN_STUB[name] = warnNotSupported.bind(null, name);
@ -92,7 +92,11 @@
url.href, '— Unable to determine name.');
return;
}
return pathname.split('/')[2];
// Pathname should normally look like this:
// /plugins/PLUGINNAME/static/SCRIPTNAME.html
// Or, for app/samples:
// /plugins/PLUGINNAME.html
return pathname.split('/')[2].split('.')[0];
}
function Plugin(opt_url) {
@ -105,8 +109,9 @@
}
this.deprecated = {
install: deprecatedAPI.install.bind(this),
popup: deprecatedAPI.popup.bind(this),
onAction: deprecatedAPI.onAction.bind(this),
popup: deprecatedAPI.popup.bind(this),
screen: deprecatedAPI.screen.bind(this),
};
this._url = new URL(opt_url);
@ -159,6 +164,13 @@
this._name + (opt_path || '/');
};
Plugin.prototype.screenUrl = function(opt_screenName) {
const origin = this._url.origin;
const base = Gerrit.BaseUrlBehavior.getBaseUrl();
const tokenPart = opt_screenName ? '/' + opt_screenName : '';
return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
};
Plugin.prototype._send = function(method, url, opt_callback, opt_payload) {
return send(method, this.url(url), opt_callback, opt_payload);
};
@ -237,6 +249,15 @@
return api.open();
};
Plugin.prototype.screen = function(screenName, opt_moduleName) {
if (opt_moduleName && typeof opt_moduleName !== 'string') {
throw new Error('deprecated, use deprecated.screen');
}
return this.registerCustomComponent(
Gerrit._getPluginScreenName(this.getPluginName(), screenName),
opt_moduleName);
};
const deprecatedAPI = {
install() {
console.log('Installing deprecated APIs is deprecated!');
@ -277,6 +298,29 @@
});
},
screen(pattern, callback) {
console.warn('plugin.deprecated.screen is deprecated,' +
' use plugin.screen instead!');
if (pattern instanceof RegExp) {
console.error('deprecated.screen() does not support RegExp. ' +
'Please use strings for patterns.');
return;
}
this.hook(Gerrit._getPluginScreenName(this.getPluginName(), pattern))
.onAttached(el => {
el.style.display = 'none';
callback({
body: el,
token: el.token,
onUnload: () => {},
setTitle: () => {},
setWindowTitle: () => {},
show: () => {
el.style.display = 'initial';
},
});
});
},
};
const Gerrit = window.Gerrit || {};
@ -420,5 +464,9 @@
return Gerrit._pluginsPending === 0;
};
Gerrit._getPluginScreenName = function(pluginName, screenName) {
return `${pluginName}-screen-${screenName}`;
};
window.Gerrit = Gerrit;
})(window);

View File

@ -0,0 +1,49 @@
<dom-module id="some-screen">
<script>
Gerrit.install(plugin => {
// Recommended approach for screen() API.
plugin.screen('main', 'some-screen-main');
const mainUrl = plugin.screenUrl('main');
// Support for deprecated screen API.
plugin.deprecated.screen('foo', ({token, body, show}) => {
body.innerHTML = `This is a plugin screen at ${token}<br/>` +
`<a href="${mainUrl}">Go to main plugin screen</a>`;
show();
});
// Quick and dirty way to get something on screen.
plugin.screen('bar').onAttached(el => {
el.innerHTML = `This is a plugin screen at ${el.token}<br/>` +
`<a href="${mainUrl}">Go to main plugin screen</a>`;
});
// Add a "Plugin screen" link to the change view screen.
plugin.hook('change-metadata-item').onAttached(el => {
el.innerHTML = `<a href="${mainUrl}">Plugin screen</a>`;
});
});
</script>
</dom-module>
<dom-module id="some-screen-main">
<template>
This is the <b>main</b> plugin screen at [[token]]
<ul>
<li><a href$="[[rootUrl]]/foo">via deprecated</a></li>
<li><a href$="[[rootUrl]]/bar">without component</a></li>
</ul>
</template>
<script>
Polymer({
is: 'some-screen-main',
properties: {
rootUrl: String,
},
attached() {
this.rootUrl = `${this.plugin.screenUrl()}`;
},
});
</script>
</dom-module>

View File

@ -200,7 +200,7 @@ type server struct{}
// Any path prefixes that should resolve to index.html.
var (
fePaths = []string{"/q/", "/c/", "/p/", "/dashboard/", "/admin/"}
fePaths = []string{"/q/", "/c/", "/p/", "/x/", "/dashboard/", "/admin/"}
issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
)