import { onDebug } from '@atlassian/clientside-extensions-debug'; import type { Observable } from '@atlassian/clientside-extensions-base'; import type { InitialAttributes, LocationsSubjectPayload, ExtensionAttributesProvider, ExtensionDescriptor, ExtensionHandler, } from './types'; import LocationsSubject from './LocationsSubject'; import { getLocationExtensions, getLocationResources } from './httpUtils'; /** * Registry of Client-side Extensions and Client Locations */ export default class ClientExtensionsRegistry { private isAtlassianDevModeEnabled: boolean = false; /** * Map of locations and their subjects. */ private locations = new Map(); /** * Map of registered extensions. */ private extensions = new Map(); /** * Internal cache to check which locations have received an initialization call */ private initializedLocations = new Map(); /** * Map of registered handlers. */ private handlers = new Map(); /** * Get a Location observable. The observer will be notified when new descriptors are available to render. */ getLocation(locationName: string): Observable { if (!locationName) { onDebug(({ warn }) => ({ level: warn, message: 'No location name specified. Creating empty subject.', components: 'Registry', })); // notify subject with an empty list and finish loading state const emptySubject = new LocationsSubject(); emptySubject.notify({ descriptors: [], loadingState: false, }); return emptySubject.asObservable(); } // If the location subject is already initialized we want to return the existing one if (this.hasSubjectForLocation(locationName)) { return (this.locations.get(locationName) as LocationsSubject).asObservable(); } const locationsSubject = this.ensureLocationsSubject(locationName); // send a loading signal to the consumer to notify we're loading the descriptors and resources locationsSubject.notify({ descriptors: [], loadingState: locationsSubject.getLoadingState(), }); // Fire-up the async code! this.initializeExtensionLocation(locationName); // Return the Observable right away and let the Fetch notify the LocationsSubject when the descriptors are fetched. return locationsSubject.asObservable(); } /** * Extension entry-points use this API to register their attribute providers. */ registerExtension(key: string, attributesProvider: ExtensionAttributesProvider, initialDescriptor?: InitialAttributes) { const extensionDescriptor = this.createOrGetExtension(key, initialDescriptor); if (!extensionDescriptor) { return; } onDebug(({ debug }) => ({ level: debug, message: `Registering a new extension for "${extensionDescriptor.location}" extension location`, components: 'Registry', meta: { key, extensionDescriptor, }, })); if (!initialDescriptor) { onDebug(({ deprecation }) => ({ level: deprecation, message: `Registering an extension without initial attributes is deprecated. Please update your extensions. Please either: - Update to the latest version of @atlassian/clientside-extensions including the webpack-plugin. Or… - Ensure you provide a third parameter of initial attributes. Examples can be found here: https://bitbucket.org/atlassian/atlassian-clientside-extensions/src/master/environments/refapp/src/main/resources/`, components: 'Registry', meta: { key, }, })); } extensionDescriptor.attributesProvider = attributesProvider; const locationsSubject = this.ensureLocationsSubject(extensionDescriptor.location); locationsSubject.notify({ descriptors: this.getExtensionsByLocation(extensionDescriptor.location), loadingState: locationsSubject.getLoadingState(), }); } /** * Toggles the {@link ClientExtensionsRegistry#isAtlassianDevModeEnabled} flag. * Check the {@link getLocationResources} function for more details about the dev mode. * * @since 2.1.0 * @param isEnabled */ toggleDevMode(isEnabled: boolean) { const prevIsAtlassianDevModeEnabled = this.isAtlassianDevModeEnabled; this.isAtlassianDevModeEnabled = isEnabled; onDebug(({ debug }) => ({ level: debug, message: `The dev mode flag was ${isEnabled ? 'enabled' : 'disabled'}`, components: 'Registry', meta: { prevIsAtlassianDevModeEnabled, isAtlassianDevModeEnabled: this.isAtlassianDevModeEnabled, }, })); } /** * Registers a handler for web-fragments that can be referenced via the custom-elements provided since 2.1.0 * see {@link registerCustomElement} * * @since 2.1.0 */ registerHandler(identifier: string, handler: ExtensionHandler): void { this.handlers.set(identifier, handler as ExtensionHandler); } /** * Retrieve a registered web-fragment handler {@link registerCustomElement} * @since 2.1.0 */ getHandler(identifier: string): ExtensionHandler | null { if (!this.handlers.has(identifier)) { onDebug(({ error }) => ({ level: error, message: `No handler found for identifier: "${identifier}".`, components: 'Registry', meta: { identifier, }, })); return null; } return this.handlers.get(identifier)!; } private createOrGetExtension(key: string, attributes?: InitialAttributes): ExtensionDescriptor | null { const extension = this.getExtensionByKey(key); // Someone did not specify a deprecated web-fragment but also did not pass any initial attributes on registration. // We don't handle this case and just ignore it! if (!attributes && !extension) { onDebug(({ error }) => ({ level: error, message: `Incompatible extension registration found. Neither deprecated definitions nor attributes on registration found. Ignoring extension "${key}"`, components: 'Registry', meta: { key, }, })); return null; } // Deprecated registration of an extension. No initial attributes available if (!attributes && extension) { return extension; } const { location, weight, label } = attributes as InitialAttributes; const extensionDescriptor = { key, location, attributes: { label, weight: weight ?? Infinity } }; // update if extension is already registered if (extension && attributes) { return this.updateExtensionByKey(key, extensionDescriptor); } this.addExtension(extensionDescriptor); return extensionDescriptor; } private getExtensionsByLocation(locationName: string): ExtensionDescriptor[] { const extensionsOfLocation: ExtensionDescriptor[] = []; this.extensions.forEach((value) => { if (value.location === locationName) { extensionsOfLocation.push(value); } }); return ClientExtensionsRegistry.sortByWeight(extensionsOfLocation); } private getExtensionByKey(key: string): ExtensionDescriptor | undefined { return this.extensions.get(key); } private updateExtensionByKey(key: string, newDescriptor: ExtensionDescriptor): ExtensionDescriptor { const extension = this.getExtensionByKey(key); const newExtension = { ...extension, ...newDescriptor }; this.extensions.set(key, newExtension); return newExtension; } private ensureLocationsSubject(location: string): LocationsSubject { if (this.hasSubjectForLocation(location)) { return this.locations.get(location) as LocationsSubject; } this.locations.set(location, new LocationsSubject()); return this.locations.get(location) as LocationsSubject; } private hasSubjectForLocation(location: string): boolean { return this.locations.has(location); } private addExtension(...newExtensions: ExtensionDescriptor[]): void { newExtensions.forEach((extensionDescriptor) => { this.extensions.set(extensionDescriptor.key, extensionDescriptor); }); } private static sortByWeight(extensions: ExtensionDescriptor[]): ExtensionDescriptor[] { extensions.sort((a, b) => { // @ts-expect-error TODO: Weight might be undefined return a.attributes?.weight - b.attributes?.weight; }); return extensions; } private async initializeExtensionLocation(locationName: string) { if (this.initializedLocations.has(locationName)) { return; } onDebug(({ debug }) => ({ level: debug, message: `Initialize and load "${locationName}" extension location...`, components: 'Registry', meta: { locationName, }, })); this.initializedLocations.set(locationName, true); const locationsSubject = this.ensureLocationsSubject(locationName); try { // With the CSE 3.0 we could potentially remove the need of loading the legacy web-items but fot now we need to do that... const legacyWebItemsDescriptors = await getLocationExtensions(locationName); this.addExtension(...legacyWebItemsDescriptors); // load resources for location and notify await getLocationResources(locationName, this.isAtlassianDevModeEnabled); onDebug(({ debug }) => ({ level: debug, message: `Finished initializing and loading "${locationName}" extension location.`, components: 'Registry', meta: { locationName, }, })); // Get all the extensions that were registered with the `registerExtension` function const extensionDescriptors = this.getExtensionsByLocation(locationName); locationsSubject.notify({ descriptors: extensionDescriptors, loadingState: false, }); } catch (err) { onDebug(({ error }) => ({ level: error, message: `Failed to load extensions for location ${locationName}`, components: 'Registry', meta: { location: locationName, error: err as Error, }, })); locationsSubject.notify({ descriptors: [], loadingState: false, }); } } }