Merge changes Ife0bef08,I3c3d5da1

* changes:
  Convert some helper classes to TypeScript
  Rename files from js to ts to preserve history
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
deleted file mode 100644
index 3f8aa44..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-/** @constructor */
-export function GrAttributeHelper(element) {
-  this.element = element;
-  this._promises = {};
-}
-
-GrAttributeHelper.prototype._getChangedEventName = function(name) {
-  return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
-};
-
-/**
- * Returns true if the property is defined on wrapped element.
- *
- * @param {string} name
- * @return {boolean}
- */
-GrAttributeHelper.prototype._elementHasProperty = function(name) {
-  return this.element[name] !== undefined;
-};
-
-GrAttributeHelper.prototype._reportValue = function(callback, value) {
-  try {
-    callback(value);
-  } catch (e) {
-    console.info(e);
-  }
-};
-
-/**
- * Binds callback to property updates.
- *
- * @param {string} name Property name.
- * @param {function(?)} callback
- * @return {function()} Unbind function.
- */
-GrAttributeHelper.prototype.bind = function(name, callback) {
-  const attributeChangedEventName = this._getChangedEventName(name);
-  const changedHandler = e => this._reportValue(callback, e.detail.value);
-  const unbind = () => this.element.removeEventListener(
-      attributeChangedEventName, changedHandler);
-  this.element.addEventListener(
-      attributeChangedEventName, changedHandler);
-  if (this._elementHasProperty(name)) {
-    this._reportValue(callback, this.element[name]);
-  }
-  return unbind;
-};
-
-/**
- * Get value of the property from wrapped object. Waits for the property
- * to be initialized if it isn't defined.
- *
- * @param {string} name Property name.
- * @return {!Promise<?>}
- */
-GrAttributeHelper.prototype.get = function(name) {
-  if (this._elementHasProperty(name)) {
-    return Promise.resolve(this.element[name]);
-  }
-  if (!this._promises[name]) {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const unbind = this.bind(name, value => {
-      resolve(value);
-      unbind();
-    });
-    this._promises[name] = promise;
-  }
-  return this._promises[name];
-};
-
-/**
- * Sets value and dispatches event to force notify.
- *
- * @param {string} name Property name.
- * @param {?} value
- */
-GrAttributeHelper.prototype.set = function(name, value) {
-  this.element[name] = value;
-  this.element.dispatchEvent(
-      new CustomEvent(this._getChangedEventName(name), {detail: {value}}));
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
new file mode 100644
index 0000000..6c6321b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * 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.
+ */
+
+export class GrAttributeHelper {
+  private readonly _promises = new Map<string, Promise<any>>();
+
+  constructor(public element: any) {}
+
+  _getChangedEventName(name: string): string {
+    return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
+  }
+
+  /**
+   * Returns true if the property is defined on wrapped element.
+   */
+  _elementHasProperty(name: string) {
+    return this.element[name] !== undefined;
+  }
+
+  _reportValue(callback: (value: any) => void, value: any) {
+    try {
+      callback(value);
+    } catch (e) {
+      console.info(e);
+    }
+  }
+
+  /**
+   * Binds callback to property updates.
+   *
+   * @param {string} name Property name.
+   * @param {function(?)} callback
+   * @return {function()} Unbind function.
+   */
+  bind(name: string, callback: (value: any) => void) {
+    const attributeChangedEventName = this._getChangedEventName(name);
+    const changedHandler = (e: CustomEvent) =>
+      this._reportValue(callback, e.detail.value);
+    const unbind = () =>
+      this.element.removeEventListener(
+        attributeChangedEventName,
+        changedHandler
+      );
+    this.element.addEventListener(attributeChangedEventName, changedHandler);
+    if (this._elementHasProperty(name)) {
+      this._reportValue(callback, this.element[name]);
+    }
+    return unbind;
+  }
+
+  /**
+   * Get value of the property from wrapped object. Waits for the property
+   * to be initialized if it isn't defined.
+   *
+   * @param {string} name Property name.
+   * @return {!Promise<?>}
+   */
+  get(name: string) {
+    if (this._elementHasProperty(name)) {
+      return Promise.resolve(this.element[name]);
+    }
+    if (!this._promises.has(name)) {
+      let resolve: (value: any) => void;
+      const promise = new Promise(r => (resolve = r));
+      const unbind = this.bind(name, value => {
+        resolve(value);
+        unbind();
+      });
+      this._promises.set(name, promise);
+    }
+    return this._promises.get(name);
+  }
+
+  /**
+   * Sets value and dispatches event to force notify.
+   */
+  set(name: string, value: any) {
+    this.element[name] = value;
+    this.element.dispatchEvent(
+      new CustomEvent(this._getChangedEventName(name), {detail: {value}})
+    );
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
deleted file mode 100644
index 466f84a..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-/** @constructor */
-export function GrEventHelper(element) {
-  this.element = element;
-  this._unsubscribers = [];
-}
-
-/**
- * Add a callback to arbitrary event.
- * The callback may return false to prevent event bubbling.
- *
- * @param {string} event Event name
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.on = function(event, callback) {
-  return this._listen(this.element, callback, {event});
-};
-
-/**
- * Alias of onClick
- *
- * @see onClick
- */
-GrEventHelper.prototype.onTap = function(callback) {
-  return this._listen(this.element, callback);
-};
-
-/**
- * Add a callback to element click or touch.
- * The callback may return false to prevent event bubbling.
- *
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.onClick = function(callback) {
-  return this._listen(this.element, callback);
-};
-
-/**
- * Alias of captureClick
- *
- * @see captureClick
- */
-GrEventHelper.prototype.captureTap = function(callback) {
-  return this._listen(this.element.parentElement, callback, {capture: true});
-};
-
-/**
- * Add a callback to element click or touch ahead of normal flow.
- * Callback is installed on parent during capture phase.
- * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
- * The callback may return false to cancel regular event listeners.
- *
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.captureClick = function(callback) {
-  return this._listen(this.element.parentElement, callback, {capture: true});
-};
-
-GrEventHelper.prototype._listen = function(container, callback, opt_options) {
-  const capture = opt_options && opt_options.capture;
-  const event = opt_options && opt_options.event || 'click';
-  const handler = e => {
-    if (e.path.indexOf(this.element) !== -1) {
-      let mayContinue = true;
-      try {
-        mayContinue = callback(e);
-      } catch (e) {
-        console.warn(`Plugin error handing event: ${e}`);
-      }
-      if (mayContinue === false) {
-        e.stopImmediatePropagation();
-        e.stopPropagation();
-        e.preventDefault();
-      }
-    }
-  };
-  container.addEventListener(event, handler, capture);
-  const unsubscribe = () =>
-    container.removeEventListener(event, handler, capture);
-  this._unsubscribers.push(unsubscribe);
-  return unsubscribe;
-};
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
new file mode 100644
index 0000000..bf08b8e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * 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.
+ */
+
+interface EventWithPath extends Event {
+  path?: HTMLElement[];
+}
+
+export interface ListenOptions {
+  event?: string;
+  capture?: boolean;
+}
+
+export class GrEventHelper {
+  constructor(readonly element: HTMLElement) {}
+
+  /**
+   * Add a callback to arbitrary event.
+   * The callback may return false to prevent event bubbling.
+   */
+  on(event: string, callback: (event: Event) => boolean) {
+    return this._listen(this.element, callback, {event});
+  }
+
+  /**
+   * Alias for @see onClick
+   */
+  onTap(callback: (event: Event) => boolean) {
+    return this.onClick(callback);
+  }
+
+  /**
+   * Add a callback to element click or touch.
+   * The callback may return false to prevent event bubbling.
+   */
+  onClick(callback: (event: Event) => boolean) {
+    return this._listen(this.element, callback);
+  }
+
+  /**
+   * Alias for @see captureClick
+   */
+  captureTap(callback: (event: Event) => boolean) {
+    this.captureClick(callback);
+  }
+
+  /**
+   * Add a callback to element click or touch ahead of normal flow.
+   * Callback is installed on parent during capture phase.
+   * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
+   * The callback may return false to cancel regular event listeners.
+   */
+  captureClick(callback: (event: Event) => boolean) {
+    const parent = this.element.parentElement!;
+    return this._listen(parent, callback, {capture: true});
+  }
+
+  _listen(
+    container: HTMLElement,
+    callback: (event: Event) => boolean,
+    opt_options?: ListenOptions | null
+  ) {
+    const capture = opt_options?.capture;
+    const event = opt_options?.event || 'click';
+    const handler = (e: EventWithPath) => {
+      if (!e.path) return;
+      if (e.path.indexOf(this.element) !== -1) {
+        let mayContinue = true;
+        try {
+          mayContinue = callback(e);
+        } catch (exception) {
+          console.warn(`Plugin error handing event: ${exception}`);
+        }
+        if (mayContinue === false) {
+          e.stopImmediatePropagation();
+          e.stopPropagation();
+          e.preventDefault();
+        }
+      }
+    };
+    container.addEventListener(event, handler, capture);
+    const unsubscribe = () =>
+      container.removeEventListener(event, handler, capture);
+    return unsubscribe;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
deleted file mode 100644
index dae8d3e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ /dev/null
@@ -1,228 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import {importHref} from '../../../scripts/import-href.js';
-
-/** @constructor */
-export class GrPluginEndpoints {
-  constructor() {
-    this._endpoints = {};
-    this._callbacks = {};
-    this._dynamicPlugins = {};
-    this._importedUrls = new Set();
-    this._pluginLoaded = false;
-  }
-
-  setPluginsReady() {
-    this._pluginLoaded = true;
-  }
-
-  onNewEndpoint(endpoint, callback) {
-    if (!this._callbacks[endpoint]) {
-      this._callbacks[endpoint] = [];
-    }
-    this._callbacks[endpoint].push(callback);
-  }
-
-  onDetachedEndpoint(endpoint, callback) {
-    if (this._callbacks[endpoint]) {
-      this._callbacks[endpoint] = this._callbacks[endpoint].filter(
-          cb => cb !== callback
-      );
-    }
-  }
-
-  _getOrCreateModuleInfo(plugin, opts) {
-    const {endpoint, slot, type, moduleName, domHook} = opts;
-    const existingModule = this._endpoints[endpoint].find(
-        info =>
-          info.plugin === plugin &&
-        info.moduleName === moduleName &&
-        info.domHook === domHook &&
-        info.slot === slot
-    );
-    if (existingModule) {
-      return existingModule;
-    } else {
-      const newModule = {
-        moduleName,
-        plugin,
-        pluginUrl: plugin._url,
-        type,
-        domHook,
-        slot,
-      };
-      this._endpoints[endpoint].push(newModule);
-      return newModule;
-    }
-  }
-
-  /**
-   * Register a plugin to an endpoint.
-   *
-   * Dynamic plugins are registered to a specific prefix, such as
-   * 'change-list-header'. These plugins are then fetched by prefix to determine
-   * which endpoints to dynamically add to the page.
-   *
-   * @param {Object} plugin
-   * @param {Object} opts
-   */
-  registerModule(plugin, opts) {
-    const {endpoint, dynamicEndpoint} = opts;
-    if (dynamicEndpoint) {
-      if (!this._dynamicPlugins[dynamicEndpoint]) {
-        this._dynamicPlugins[dynamicEndpoint] = new Set();
-      }
-      this._dynamicPlugins[dynamicEndpoint].add(endpoint);
-    }
-    if (!this._endpoints[endpoint]) {
-      this._endpoints[endpoint] = [];
-    }
-    const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
-    // TODO: the logic below seems wrong when:
-    // multiple plugins register to the same endpoint
-    // one register before plugins ready
-    // the other done after, then only the later one will have the callbacks
-    // invoked.
-    if (this._pluginLoaded && this._callbacks[endpoint]) {
-      this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
-    }
-  }
-
-  getDynamicEndpoints(dynamicEndpoint) {
-    const plugins = this._dynamicPlugins[dynamicEndpoint];
-    if (!plugins) return [];
-    return Array.from(plugins);
-  }
-
-  /**
-   * 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 {!Array<{
-   *   moduleName: string,
-   *   plugin: Plugin,
-   *   pluginUrl: String,
-   *   type: EndpointType,
-   *   domHook: !Object
-   * }>}
-   */
-  getDetails(name, opt_options) {
-    const type = opt_options && opt_options.type;
-    const moduleName = opt_options && opt_options.moduleName;
-    if (!this._endpoints[name]) {
-      return [];
-    }
-    return this._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>}
-   */
-  getModules(name, opt_options) {
-    const modulesData = this.getDetails(name, opt_options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return modulesData.map(m => m.moduleName);
-  }
-
-  /**
-   * Get plugin URLs with element and module definitions.
-   *
-   * @param {string} name Endpoint name.
-   * @param {?{
-   *   type: (string|undefined),
-   *   moduleName: (string|undefined)
-   * }} opt_options
-   * @return {!Array<!URL>}
-   */
-  getPlugins(name, opt_options) {
-    const modulesData = this.getDetails(name, opt_options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
-  }
-
-  importUrl(pluginUrl) {
-    let timerId;
-    return Promise
-        .race([
-          new Promise((resolve, reject) => {
-            this._importedUrls.add(pluginUrl.href);
-            importHref(pluginUrl, resolve, reject);
-          }),
-          // Timeout after 3s
-          new Promise(r => timerId = setTimeout(r, 3000)),
-        ])
-        .finally(() => {
-          if (timerId) clearTimeout(timerId);
-        });
-  }
-
-  /**
-   * Get plugin URLs with element and module definitions.
-   *
-   * @param {string} name Endpoint name.
-   * @param {?{
-   *   type: (string|undefined),
-   *   moduleName: (string|undefined)
-   * }} opt_options
-   * @return {!Array<!Promise<void>>}
-   */
-  getAndImportPlugins(name, opt_options) {
-    return Promise.all(
-        this.getPlugins(name, opt_options).map(pluginUrl => {
-          if (this._importedUrls.has(pluginUrl.href)) {
-            return Promise.resolve();
-          }
-
-          // TODO: we will deprecate html plugins entirely
-          // for now, keep the original behavior and import
-          // only for html ones
-          if (pluginUrl && pluginUrl.pathname.endsWith('.html')) {
-            return this.importUrl(pluginUrl);
-          } else {
-            return Promise.resolve();
-          }
-        })
-    );
-  }
-}
-
-// TODO(dmfilippov): Convert to service and add to appContext
-export let pluginEndpoints = new GrPluginEndpoints();
-export function _testOnly_resetEndpoints() {
-  pluginEndpoints = new GrPluginEndpoints();
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
new file mode 100644
index 0000000..6a3f957
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -0,0 +1,223 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {importHref} from '../../../scripts/import-href';
+
+type Callback = (value: any) => void;
+
+interface ModuleInfo {
+  moduleName: string;
+  // TODO(TS): Convert type to GrPlugin.
+  plugin: any;
+  pluginUrl: URL;
+  type?: string;
+  // TODO(TS): Convert type to GrDomHook.
+  domHook: any;
+  slot?: string;
+}
+
+interface Options {
+  endpoint: string;
+  dynamicEndpoint?: string;
+  slot?: string;
+  type?: string;
+  moduleName?: string;
+  // TODO(TS): Convert type to GrDomHook.
+  domHook?: any;
+}
+
+export class GrPluginEndpoints {
+  private readonly _endpoints = new Map<string, ModuleInfo[]>();
+
+  private readonly _callbacks = new Map<string, ((value: any) => void)[]>();
+
+  private readonly _dynamicPlugins = new Map<string, Set<string>>();
+
+  private readonly _importedUrls = new Set<string>();
+
+  private _pluginLoaded = false;
+
+  setPluginsReady() {
+    this._pluginLoaded = true;
+  }
+
+  onNewEndpoint(endpoint: string, callback: Callback) {
+    if (!this._callbacks.has(endpoint)) {
+      this._callbacks.set(endpoint, []);
+    }
+    this._callbacks.get(endpoint)!.push(callback);
+  }
+
+  onDetachedEndpoint(endpoint: string, callback: Callback) {
+    if (this._callbacks.has(endpoint)) {
+      const filteredCallbacks = this._callbacks
+        .get(endpoint)!
+        .filter((cb: Callback) => cb !== callback);
+      this._callbacks.set(endpoint, filteredCallbacks);
+    }
+  }
+
+  _getOrCreateModuleInfo(plugin: any, opts: Options): ModuleInfo {
+    const {endpoint, slot, type, moduleName, domHook} = opts;
+    const existingModule = this._endpoints
+      .get(endpoint!)!
+      .find(
+        (info: ModuleInfo) =>
+          info.plugin === plugin &&
+          info.moduleName === moduleName &&
+          info.domHook === domHook &&
+          info.slot === slot
+      );
+    if (existingModule) {
+      return existingModule;
+    } else {
+      const newModule: ModuleInfo = {
+        moduleName: moduleName!,
+        plugin,
+        pluginUrl: plugin._url,
+        type,
+        domHook,
+        slot,
+      };
+      this._endpoints.get(endpoint!)!.push(newModule);
+      return newModule;
+    }
+  }
+
+  /**
+   * Register a plugin to an endpoint.
+   *
+   * Dynamic plugins are registered to a specific prefix, such as
+   * 'change-list-header'. These plugins are then fetched by prefix to determine
+   * which endpoints to dynamically add to the page.
+   *
+   * @param {Object} plugin
+   * @param {Object} opts
+   */
+  registerModule(plugin: any, opts: Options) {
+    const endpoint = opts.endpoint!;
+    const dynamicEndpoint = opts.dynamicEndpoint;
+    if (dynamicEndpoint) {
+      if (!this._dynamicPlugins.has(dynamicEndpoint)) {
+        this._dynamicPlugins.set(dynamicEndpoint, new Set());
+      }
+      this._dynamicPlugins.get(dynamicEndpoint)!.add(endpoint);
+    }
+    if (!this._endpoints.has(endpoint)) {
+      this._endpoints.set(endpoint, []);
+    }
+    const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
+    // TODO: the logic below seems wrong when:
+    // multiple plugins register to the same endpoint
+    // one register before plugins ready
+    // the other done after, then only the later one will have the callbacks
+    // invoked.
+    if (this._pluginLoaded && this._callbacks.has(endpoint)) {
+      this._callbacks.get(endpoint)!.forEach(callback => callback(moduleInfo));
+    }
+  }
+
+  getDynamicEndpoints(dynamicEndpoint: string): string[] {
+    const plugins = this._dynamicPlugins.get(dynamicEndpoint);
+    if (!plugins) return [];
+    return Array.from(plugins);
+  }
+
+  /**
+   * Get detailed information about modules registered with an extension
+   * endpoint.
+   */
+  getDetails(name: string, options?: Options): ModuleInfo[] {
+    const type = options && options.type;
+    const moduleName = options && options.moduleName;
+    if (!this._endpoints.has(name)) {
+      return [];
+    } else {
+      return this._endpoints
+        .get(name)!
+        .filter(
+          (item: ModuleInfo) =>
+            (!type || item.type === type) &&
+            (!moduleName || moduleName === item.moduleName)
+        );
+    }
+  }
+
+  /**
+   * Get detailed module names for instantiating at the endpoint.
+   */
+  getModules(name: string, options?: Options): string[] {
+    const modulesData = this.getDetails(name, options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return modulesData.map(m => m.moduleName);
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   */
+  getPlugins(name: string, options?: Options): URL[] {
+    const modulesData = this.getDetails(name, options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
+  }
+
+  importUrl(pluginUrl: URL) {
+    let timerId: any;
+    return Promise.race([
+      new Promise((resolve, reject) => {
+        this._importedUrls.add(pluginUrl.href);
+        importHref(pluginUrl.href, resolve, reject);
+      }),
+      // Timeout after 3s
+      new Promise(r => (timerId = setTimeout(r, 3000))),
+    ]).finally(() => {
+      if (timerId) clearTimeout(timerId);
+    });
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   */
+  getAndImportPlugins(name: string, options?: Options) {
+    return Promise.all(
+      this.getPlugins(name, options).map(pluginUrl => {
+        if (this._importedUrls.has(pluginUrl.href)) {
+          return Promise.resolve();
+        }
+
+        // TODO: we will deprecate html plugins entirely
+        // for now, keep the original behavior and import
+        // only for html ones
+        if (pluginUrl && pluginUrl.pathname.endsWith('.html')) {
+          return this.importUrl(pluginUrl);
+        } else {
+          return Promise.resolve();
+        }
+      })
+    );
+  }
+}
+
+// TODO(dmfilippov): Convert to service and add to appContext
+export let pluginEndpoints = new GrPluginEndpoints();
+export function _testOnly_resetEndpoints() {
+  pluginEndpoints = new GrPluginEndpoints();
+}
diff --git a/polygerrit-ui/app/scripts/import-href.js b/polygerrit-ui/app/scripts/import-href.ts
similarity index 74%
rename from polygerrit-ui/app/scripts/import-href.js
rename to polygerrit-ui/app/scripts/import-href.ts
index 6ff40a5..bd1b678 100644
--- a/polygerrit-ui/app/scripts/import-href.js
+++ b/polygerrit-ui/app/scripts/import-href.ts
@@ -20,11 +20,16 @@
 // file contains code inside <script>...</script> and can't be imported
 // in es6 modules.
 
+interface ImportHrefElement extends HTMLLinkElement {
+  __dynamicImportLoaded?: boolean;
+}
+
 // run a callback when HTMLImports are ready or immediately if
 // this api is not available.
-function whenImportsReady(cb) {
-  if (window.HTMLImports) {
-    HTMLImports.whenReady(cb);
+function whenImportsReady(cb: () => void) {
+  const win = window as Window;
+  if (win.HTMLImports) {
+    win.HTMLImports.whenReady(cb);
   } else {
     cb();
   }
@@ -39,37 +44,43 @@
  * element will contain the imported document contents.
  *
  * @memberof Polymer
- * @param {string} href URL to document to load.
- * @param {?function(!Event):void=} onload Callback to notify when an import successfully
+ * @param href URL to document to load.
+ * @param onload Callback to notify when an import successfully
  *   loaded.
- * @param {?function(!ErrorEvent):void=} onerror Callback to notify when an import
+ * @param onerror Callback to notify when an import
  *   unsuccessfully loaded.
- * @param {boolean=} optAsync True if the import should be loaded `async`.
+ * @param async True if the import should be loaded `async`.
  *   Defaults to `false`.
- * @return {!HTMLLinkElement} The link element for the URL to be loaded.
+ * @return The link element for the URL to be loaded.
  */
-export function importHref(href, onload, onerror, optAsync) {
-  let link = /** @type {HTMLLinkElement} */
-      (document.head.querySelector('link[href="' + href + '"][import-href]'));
+export function importHref(
+  href: string,
+  onload: (e: Event) => void,
+  onerror: (e: Event) => void,
+  async = false
+): HTMLLinkElement {
+  let link = document.head.querySelector(
+    'link[href="' + href + '"][import-href]'
+  ) as ImportHrefElement;
   if (!link) {
-    link = /** @type {HTMLLinkElement} */ (document.createElement('link'));
+    link = document.createElement('link') as ImportHrefElement;
     link.rel = 'import';
     link.href = href;
     link.setAttribute('import-href', '');
   }
   // always ensure link has `async` attribute if user specified one,
   // even if it was previously not async. This is considered less confusing.
-  if (optAsync) {
+  if (async) {
     link.setAttribute('async', '');
   }
   // NOTE: the link may now be in 3 states: (1) pending insertion,
   // (2) inflight, (3) already loaded. In each case, we need to add
   // event listeners to process callbacks.
-  const cleanup = function() {
+  const cleanup = function () {
     link.removeEventListener('load', loadListener);
     link.removeEventListener('error', errorListener);
   };
-  const loadListener = function(event) {
+  const loadListener = function (event: Event) {
     cleanup();
     // In case of a successful load, cache the load event on the link so
     // that it can be used to short-circuit this method in the future when
@@ -81,7 +92,7 @@
       });
     }
   };
-  const errorListener = function(event) {
+  const errorListener = function (event: Event) {
     cleanup();
     // In case of an error, remove the link from the document so that it
     // will be automatically created again the next time `importHref` is
@@ -97,7 +108,7 @@
   };
   link.addEventListener('load', loadListener);
   link.addEventListener('error', errorListener);
-  if (link.parentNode == null) {
+  if (link.parentNode === null) {
     document.head.appendChild(link);
     // if the link already loaded, dispatch a fake load event
     // so that listeners are called and get a proper event argument.
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
similarity index 100%
rename from polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.js
rename to polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
diff --git a/polygerrit-ui/app/test/test-app-context-init.js b/polygerrit-ui/app/test/test-app-context-init.js
index 6fffdba..88232cd3 100644
--- a/polygerrit-ui/app/test/test-app-context-init.js
+++ b/polygerrit-ui/app/test/test-app-context-init.js
@@ -17,7 +17,7 @@
 
 // Init app context before any other imports
 import {initAppContext} from '../services/app-context-init.js';
-import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock.js';
 import {appContext} from '../services/app-context.js';
 
 initAppContext();
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 167ccda..a09e651 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -22,6 +22,7 @@
     ShadyCSS?: {
       getComputedStyleValue(el: Element, name: string): string;
     };
+    HTMLImports?: {whenReady: (cb: () => void) => void};
   }
 
   interface Performance {