blueocean-plugin/js-extensions/src/ExtensionStore.js

284 lines
10 KiB
JavaScript

/**
* ExtensionStore is responsible for maintaining extension metadata
* including type/capability info
*/
export class ExtensionStore {
/**
* FIXME this is NOT a constructor, as there's no common way to
* pass around a DI singleton at the moment across everything
* that needs it (e.g. redux works for the app, not for other
* things in this module).
*
* NOTE: this is currently called from `blueocean-web/src/main/js/init.jsx`
*
* Needs:
* args = {
* extensionDataProvider: callback => {
* ... // get the data
* callback(extensionData); // array of extensions
* },
* classMetadataStore: {
* getClassMetadata(dataType, callback) => {
* ... // get the data based on 'dataType'
* callback(typeInfo);
* }
* }
* }
*/
init(args) {
// This data should come from <jenkins>/blue/js-extensions
this.extensionDataProvider = args.extensionDataProvider;
this.extensionPointList = undefined; // cache from extensionDataProvider...
/**
* The registered ExtensionPoint metadata + instance refs
*/
this.extensionPoints = {};
/**
* Used to fetch type information
*/
this.classMetadataStore = args.classMetadataStore;
}
/**
* Register the extension script object
*/
_registerComponentInstance(extensionPointId, pluginId, component, instance) {
var extensions = this.extensionPoints[extensionPointId];
if (!extensions) {
this._loadBundles(extensionPointId, () => this._registerComponentInstance(extensionPointId, pluginId, component, instance));
return;
}
var extension = this._findPlugin(extensionPointId, pluginId, component);
if (extension) {
extension.instance = instance;
return;
}
throw new Error(`Unable to locate plugin for ${extensionPointId} / ${pluginId} / ${component}`);
}
/**
* Finds a plugin by extension point id, plugin id, component name
*/
_findPlugin(extensionPointId, pluginId, component) {
var extensions = this.extensionPoints[extensionPointId];
if (extensions) {
for (var i = 0; i < extensions.length; i++) {
var extension = extensions[i];
if (extension.pluginId == pluginId && extension.component == component) {
return extension;
}
}
}
}
/**
* The primary function to use in order to get extensions,
* will call the onload callback with a list of exported extension
* objects (e.g. React classes or otherwise).
*/
getExtensions(extensionPoint, filter, onload) {
// Allow calls like: getExtensions('something', a => ...)
if (arguments.length === 2 && typeof(filter) === 'function') {
onload = filter;
filter = undefined;
}
// And calls like: getExtensions(['a','b'], (a,b) => ...)
if (extensionPoint instanceof Array) {
var args = [];
var nextArg = ext => {
if(ext) args.push(ext);
if (extensionPoint.length === 0) {
onload(...args);
} else {
var arg = extensionPoint[0];
extensionPoint = extensionPoint.slice(1);
this.getExtensions(arg, filter, nextArg);
}
};
nextArg();
return;
}
this._loadBundles(extensionPoint, extensions => this._filterExtensions(extensions, filter, onload));
}
_filterExtensions(extensions, filters, onload) {
if (extensions.length === 0) {
onload(extensions); // no extensions to filter
return;
}
if (filters) {
// allow calls like: getExtensions('abcd', dataType(something), ext => ...)
if (!filters.length) {
filters = [ filters ];
}
var remaining = [].concat(filters);
var nextFilter = extensions => {
if (remaining.length === 0) {
// Map to instances and proceed
onload(extensions.map(m => m.instance));
} else {
var filter = remaining[0];
remaining = remaining.slice(1);
filter(extensions, nextFilter);
}
};
nextFilter(extensions);
} else {
// Map to instances and proceed
onload(extensions.map(m => m.instance));
}
}
/**
* Fetch all the extension data
*/
_loadExtensionData(oncomplete) {
if (!this.extensionDataProvider) {
throw new Error("Must call ExtensionStore.init({ extensionDataProvider: (cb) => ..., typeInfoProvider: (type, cb) => ... }) first");
}
if (this.extensionPointList) {
onconplete(this.extensionPointList);
return;
}
this.extensionDataProvider(data => {
// We clone the data because we add to it.
this.extensionPointList = JSON.parse(JSON.stringify(data));
for(var i1 = 0; i1 < this.extensionPointList.length; i1++) {
var pluginMetadata = this.extensionPointList[i1];
var extensions = pluginMetadata.extensions || [];
for(var i2 = 0; i2 < extensions.length; i2++) {
var extensionMetadata = extensions[i2];
extensionMetadata.pluginId = pluginMetadata.hpiPluginId;
var extensionPointMetadatas = this.extensionPoints[extensionMetadata.extensionPoint] = this.extensionPoints[extensionMetadata.extensionPoint] || [];
extensionPointMetadatas.push(extensionMetadata);
}
}
var ResourceLoadTracker = require('./ResourceLoadTracker').instance;
ResourceLoadTracker.setExtensionPointMetadata(this.extensionPointList);
if (oncomplete) oncomplete(this.extensionPointList);
});
}
/**
* Load the bundles for the given type
*/
_loadBundles(extensionPointId, onload) {
// Make sure this has been initialized first
if (!this.extensionPointList) {
this._loadExtensionData(() => {
this._loadBundles(extensionPointId, onload);
});
return;
}
var extensionPointMetadatas = this.extensionPoints[extensionPointId];
if (extensionPointMetadatas && extensionPointMetadatas.loaded) {
onload(extensionPointMetadatas);
return;
}
extensionPointMetadatas = this.extensionPoints[extensionPointId] = this.extensionPoints[extensionPointId] || [];
var jsModules = require('@jenkins-cd/js-modules');
var loadCountMonitor = new LoadCountMonitor();
var loadPluginBundle = (pluginMetadata) => {
loadCountMonitor.inc();
// The plugin bundle for this plugin may already be in the process of loading (async extension
// point rendering). If it's not, pluginMetadata.loadCountMonitors will not be undefined,
// which means we can go ahead with the async loading. If it is, pluginMetadata.loadCountMonitors
// is defined, we just add "this" loadCountMonitor to pluginMetadata.loadCountMonitors.
// It will get called as soon as the script loading is complete.
if (!pluginMetadata.loadCountMonitors) {
pluginMetadata.loadCountMonitors = [];
pluginMetadata.loadCountMonitors.push(loadCountMonitor);
jsModules.import(pluginMetadata.hpiPluginId + ':jenkins-js-extension')
.onFulfilled(() => {
pluginMetadata.bundleLoaded = true;
for (var i = 0; i < pluginMetadata.loadCountMonitors.length; i++) {
pluginMetadata.loadCountMonitors[i].dec();
}
delete pluginMetadata.loadCountMonitors;
});
} else {
pluginMetadata.loadCountMonitors.push(loadCountMonitor);
}
};
var checkLoading = () => {
if (loadCountMonitor.counter === 0) {
extensionPointMetadatas.loaded = true;
onload(extensionPointMetadatas);
}
};
// Iterate over each plugin in extensionPointMetadata, async loading
// the extension point .js bundle (if not already loaded) for each of the
// plugins that implement the specified extensionPointId.
for(var i1 = 0; i1 < this.extensionPointList.length; i1++) {
var pluginMetadata = this.extensionPointList[i1];
var extensions = pluginMetadata.extensions || [];
for(var i2 = 0; i2 < extensions.length; i2++) {
var extensionMetadata = extensions[i2];
if (extensionMetadata.extensionPoint === extensionPointId) {
// This plugin implements the ExtensionPoint.
// If we haven't already loaded the extension point
// bundle for this plugin, lets load it now.
if (!pluginMetadata.bundleLoaded) {
loadPluginBundle(pluginMetadata);
}
}
}
}
// Listen to the inc/dec calls now that we've iterated
// over all of the plugins.
loadCountMonitor.onchange( () => {
checkLoading();
});
// Call checkLoading immediately in case all plugin
// bundles have been loaded already.
checkLoading();
}
}
/**
* Maintains load counts for components
*/
class LoadCountMonitor {
constructor() {
this.counter = 0;
this.callback = undefined;
}
inc() {
this.counter++;
if (this.callback) {
this.callback();
}
}
dec() {
this.counter--;
if (this.callback) {
this.callback();
}
}
onchange(callback) {
this.callback = callback;
}
}
// should figure out DI with singletons so we can move
// required providers to other injection points, ideally
export const instance = new ExtensionStore();