blob: 9cacaeaddd3feff5bd90f56e65042e57ed50d9ad [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {
EndpointType,
ModuleInfo,
} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {PluginApi} from '../../../api/plugin';
import {HookApi, PluginElement} from '../../../api/hook';
import {getAppContext} from '../../../services/app-context';
import {assertIsDefined} from '../../../utils/common-util';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {resolve} from '../../../models/dependency';
const INIT_PROPERTIES_TIMEOUT_MS = 10000;
@customElement('gr-endpoint-decorator')
export class GrEndpointDecorator extends LitElement {
/**
* If set, then this endpoint only invokes callbacks registered by the target
* plugin. For example this is used for the `check-result-expanded` endpoint.
* In that case Gerrit knows which plugin has provided the check result, and
* only that plugin has an interest to hook into the endpoint.
*/
@property({type: String})
targetPlugin?: string;
/** Required. */
@property({type: String})
name?: string;
private readonly domHooks = new Map<PluginElement, HookApi<PluginElement>>();
private readonly initializedPlugins = new Map<string, boolean>();
private readonly reporting = getAppContext().reportingService;
private readonly getPluginLoader = resolve(this, pluginLoaderToken);
override render() {
return html`<slot></slot>`;
}
override connectedCallback() {
super.connectedCallback();
assertIsDefined(this.name);
this.getPluginLoader().pluginEndPoints.onNewEndpoint(
this.name,
this.initModule
);
this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
assertIsDefined(this.name);
const modules = this.getPluginLoader().pluginEndPoints.getDetails(
this.name
);
for (const module of modules) {
this.initModule(module);
}
});
}
override disconnectedCallback() {
for (const [el, domHook] of this.domHooks) {
domHook.handleInstanceDetached(el);
}
assertIsDefined(this.name);
this.getPluginLoader().pluginEndPoints.onDetachedEndpoint(
this.name,
this.initModule
);
super.disconnectedCallback();
}
private initDecoration(
name: string,
plugin: PluginApi,
slot?: string
): Promise<HTMLElement> {
const el = document.createElement(name) as PluginElement;
return this.initProperties(
el,
plugin,
// The direct children are slotted into <slot>, so this is identical to
// this.shadowRoot.querySelector('slot').assignedElements()[0].
this.firstElementChild
).then(el => {
const slotEl = slot
? this.querySelector(`gr-endpoint-slot[name=${slot}]`)
: null;
if (slot && slotEl?.parentNode) {
slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
} else {
this.appendChild(el);
}
return el;
});
}
private initReplacement(
name: string,
plugin: PluginApi
): Promise<HTMLElement> {
// The direct children are slotted into <slot>, so they are identical to
// this.shadowRoot.querySelector('slot').assignedElements().
const directChildren = [...this.childNodes];
const shadowChildren = [...(this.shadowRoot?.childNodes ?? [])];
[...directChildren, ...shadowChildren]
.filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
.filter(node => node.nodeName !== 'SLOT')
.forEach(node => node.remove());
const el = document.createElement(name);
return this.initProperties(el, plugin).then((el: HTMLElement) =>
this.appendChild(el)
);
}
private getEndpointParams() {
return Array.from(this.querySelectorAll('gr-endpoint-param'));
}
private initProperties(
el: PluginElement,
plugin: PluginApi,
content?: Element | null
) {
const pluginName = plugin.getPluginName();
el.plugin = plugin;
// The content is (only?) used in ChangeReplyPluginApi.
// Maybe it would be better for the consumer side to figure out the content
// with something like el.getRootNode().host, etc.
// Also note that the content element could easily end up being an instance
// of <gr-endpoint-param>.
if (content) {
el.content = content as HTMLElement;
}
const expectProperties = this.getEndpointParams().map(paramEl => {
const helper = plugin.attributeHelper(paramEl);
const paramName = paramEl.name;
if (!paramName) {
this.reporting.error(
`Plugin '${pluginName}', endpoint '${this.name}'`,
new Error(
`Plugin '${pluginName}', endpoint '${this.name}': param is missing a name.`
)
);
return;
}
return helper.get('value').then(() =>
helper.bind('value', value =>
// Note that despite the naming this sets the property, not the
// attribute. :-)
plugin.attributeHelper(el).set(paramName, value)
)
);
});
let timeoutId: number;
const timeout = new Promise(
() =>
// specify window here so that TS pulls the correct setTimeout method
// if window is not specified, then the function is pulled from node
// and the return type is NodeJS.Timeout object
(timeoutId = window.setTimeout(() => {
this.reporting.error(
`Plugin '${pluginName}', endpoint '${this.name}'`,
new Error(
`Plugin ${pluginName}, endpoint ${this.name}: ` +
'Timeout waiting for endpoint properties initialization'
)
);
}, INIT_PROPERTIES_TIMEOUT_MS))
);
return Promise.race([timeout, Promise.all(expectProperties)])
.then(() => el)
.finally(() => {
if (timeoutId) clearTimeout(timeoutId);
});
}
private readonly initModule = ({
moduleName,
plugin,
type,
domHook,
slot,
}: ModuleInfo) => {
const name = plugin.getPluginName() + '.' + moduleName;
if (this.targetPlugin) {
if (this.targetPlugin !== plugin.getPluginName()) return;
}
if (this.initializedPlugins.get(name)) {
return;
}
let initPromise;
switch (type) {
case EndpointType.DECORATE:
initPromise = this.initDecoration(moduleName, plugin, slot);
break;
case EndpointType.REPLACE:
initPromise = this.initReplacement(moduleName, plugin);
break;
}
if (!initPromise) {
throw Error(`unknown endpoint type ${type} used by plugin ${name}`);
}
this.initializedPlugins.set(name, true);
initPromise.then(el => {
if (domHook) {
domHook.handleInstanceAttached(el);
this.domHooks.set(el, domHook);
}
});
};
}
declare global {
interface HTMLElementTagNameMap {
'gr-endpoint-decorator': GrEndpointDecorator;
}
}