From 621a8cf6bfa4fcc216bb9c22ed45c6ceca2a07c1 Mon Sep 17 00:00:00 2001 From: acethecreator Date: Fri, 24 Oct 2025 01:41:58 +0100 Subject: [PATCH 01/13] add plugin configurations --- .../src/containers/AsyncApi/Standalone.tsx | 72 ++++++++- library/src/helpers/pluginManager.ts | 148 ++++++++++++++++++ library/src/types.ts | 55 +++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 library/src/helpers/pluginManager.ts diff --git a/library/src/containers/AsyncApi/Standalone.tsx b/library/src/containers/AsyncApi/Standalone.tsx index 6228f93a8..59e7251b9 100644 --- a/library/src/containers/AsyncApi/Standalone.tsx +++ b/library/src/containers/AsyncApi/Standalone.tsx @@ -2,27 +2,36 @@ import React, { Component } from 'react'; import { AsyncAPIDocumentInterface } from '@asyncapi/parser'; import { SpecificationHelpers } from '../../helpers'; -import { ErrorObject, PropsSchema } from '../../types'; +import { AsyncApiPlugin, ErrorObject, PropsSchema } from '../../types'; import { ConfigInterface, defaultConfig } from '../../config'; import AsyncApiLayout from './Layout'; import { Error } from '../Error/Error'; +import { PluginManager } from '../../helpers/pluginManager'; export interface AsyncApiProps { schema: PropsSchema; config?: Partial; + plugins?: AsyncApiPlugin[]; + onPluginEvent?: (eventName: string, data: unknown) => void; + onPluginManagerReady?: (pluginManager: PluginManager) => void; error?: ErrorObject; } interface AsyncAPIState { asyncapi?: AsyncAPIDocumentInterface; error?: ErrorObject; + pm?: PluginManager; } class AsyncApiComponent extends Component { + private registeredPlugins = new Set(); state: AsyncAPIState = { asyncapi: undefined, error: undefined, + pm: new PluginManager({ + schema: {}, + }), }; constructor(props: AsyncApiProps) { @@ -38,6 +47,9 @@ class AsyncApiComponent extends Component { if (!this.state.asyncapi) { this.updateState(this.props.schema); } + if (this.props.onPluginManagerReady) { + this.props.onPluginManagerReady(this.state.pm!); + } } componentDidUpdate(prevProps: AsyncApiProps) { @@ -91,6 +103,63 @@ class AsyncApiComponent extends Component { return ; } + private handler(eventName: string) { + return (data: unknown) => { + this.props.onPluginEvent!(eventName, data); + }; + } + private setupEventListeners() { + const { onPluginEvent } = this.props; + const { pm } = this.state; + + if (!onPluginEvent) return; + + // eslint-disable-next-line sonarjs/no-duplicate-string + const events = ['plugin:ready', 'plugin:error']; + events.forEach((event) => { + pm?.on(event, this.handler(event)); + }); + } + + private cleanupEventListeners() { + const { pm } = this.state; + const events = ['plugin:ready', 'plugin:error']; + events.forEach((event) => { + pm?.off(event, this.handler(event)); + }); + } + + private registerPlugins() { + const { plugins } = this.props; + const { pm } = this.state; + + plugins?.forEach((plugin) => { + try { + pm?.register(plugin); + this.registeredPlugins.add(plugin.name); + } catch (error) { + console.error(`Failed to register plugin ${plugin.name}:`, error); + pm?.emit('plugin:error', { + pluginName: plugin.name, + // error: error instanceof Error ? error.message : String(error as any), + }); + } + }); + } + + private unregisterPlugins() { + const { pm } = this.state; + + this.registeredPlugins.forEach((pluginName) => { + try { + pm?.unregister(pluginName); + } catch (error) { + console.error(`Failed to unregister plugin ${pluginName}:`, error); + } + }); + this.registeredPlugins.clear(); + } + private updateState(schema: PropsSchema) { const parsedSpec = SpecificationHelpers.retrieveParsedSpec(schema); if (!parsedSpec) { @@ -98,6 +167,7 @@ class AsyncApiComponent extends Component { return; } this.setState({ asyncapi: parsedSpec }); + this.state.pm?.updateContext({ schema: parsedSpec }); } } diff --git a/library/src/helpers/pluginManager.ts b/library/src/helpers/pluginManager.ts new file mode 100644 index 000000000..dca21adca --- /dev/null +++ b/library/src/helpers/pluginManager.ts @@ -0,0 +1,148 @@ +import { + AsyncApiPlugin, + ComponentSlotProps, + EventListener, + MessageBus, + PluginAPI, + PluginContext, + PluginSlot, +} from '../types'; + +class PluginManager implements MessageBus { + private plugins = new Map(); + private slotComponents = new Map< + PluginSlot, + { + component: React.ComponentType; + priority: number; + label?: string; + }[] + >(); + private eventListeners = new Map>(); + private context: PluginContext; + + constructor(initialContext: PluginContext) { + this.context = initialContext; + } + + register(plugin: AsyncApiPlugin): void { + if (this.plugins.has(plugin.name)) { + console.warn(`Plugin ${plugin.name} is already registered`); + return; + } + + const api = this.createPluginAPI(); + plugin.install(api); + this.plugins.set(plugin.name, plugin); + console.log(`Plugin ${plugin.name}@${plugin.version} registered`); + } + + unregister(pluginName: string): void { + const plugin = this.plugins.get(pluginName); + if (!plugin) return; + + plugin.uninstall?.(); + this.plugins.delete(pluginName); + + // Remove all components from this plugin + this.slotComponents.forEach((components) => { + const index = components.findIndex((c) => + c.component.displayName?.includes(pluginName), + ); + if (index > -1) { + components.splice(index, 1); + } + }); + } + + private createPluginAPI(): PluginAPI { + return { + registerComponent: (slot, component, options = {}) => { + if (!this.slotComponents.has(slot)) { + this.slotComponents.set(slot, []); + } + + const priority = options.priority ?? 100; + this.slotComponents + .get(slot)! + .push({ component, priority, label: options.label }); + + this.slotComponents.get(slot)!.sort((a, b) => b.priority - a.priority); + }, + + onSpecLoaded: (callback) => { + this.on('specLoaded', callback); + }, + + getContext: () => this.context, + + on: (eventName, callback) => { + this.on(eventName, callback); + }, + + off: (eventName, callback) => { + this.off(eventName, callback); + }, + + emit: (eventName, data) => { + this.emit(eventName, data); + }, + }; + } + + on(eventName: string, callback: (data: unknown) => void): void { + if (!this.eventListeners.has(eventName)) { + this.eventListeners.set(eventName, new Set()); + } + this.eventListeners.get(eventName)!.add(callback); + } + + off(eventName: string, callback: (data: unknown) => void): void { + const listeners = this.eventListeners.get(eventName); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + this.eventListeners.delete(eventName); + } + } + } + + public emit(eventName: string, data: unknown): void { + const eventListeners = this.eventListeners.get(eventName); + if (eventListeners) { + eventListeners.forEach((callback) => callback(data)); + } + } + + listeners(eventName: string): EventListener[] { + const listeners = this.eventListeners.get(eventName); + return listeners ? Array.from(listeners) : []; + } + + eventNames(): string[] { + return Array.from(this.eventListeners.keys()); + } + + getComponentsForSlot( + slot: PluginSlot, + ): React.ComponentType[] { + return (this.slotComponents.get(slot) ?? []).map((c) => c.component); + } + + updateContext(updates: PluginContext): void { + this.context = { schema: updates }; + } + + getPlugin(name: string): AsyncApiPlugin | undefined { + return this.plugins.get(name); + } + + listPlugins(): { name: string; version: string }[] { + return Array.from(this.plugins.values()).map((p) => ({ + name: p.name, + version: p.version, + })); + } +} + +export { PluginManager }; diff --git a/library/src/types.ts b/library/src/types.ts index 3d3c2cdbb..2ac4fe92c 100644 --- a/library/src/types.ts +++ b/library/src/types.ts @@ -85,3 +85,58 @@ export interface ExtensionComponentProps { document: AsyncAPIDocumentInterface; parent: BaseModel; } + +// Plugin interface + +export enum PluginSlot { + OPERATION = 'operation', +} + +export interface PluginContext { + schema?: PropsSchema; +} + +export interface ComponentSlotProps { + context: PluginContext; + onClose?: () => void; +} + +export interface AsyncApiPlugin { + name: string; + version: string; + description?: string; + + install(api: PluginAPI): void; + + uninstall?(): void; +} + +export type PluginInstance = + | AsyncApiPlugin + | React.ComponentType; + +export type EventListener = (...args: unknown[]) => void; + +export interface MessageBus { + on(eventName: string, callback: (data: unknown) => void): void; + off(eventName: string, callback: (data: unknown) => void): void; + emit(eventName: string, data: unknown): void; + listeners(eventName: string): EventListener[]; + eventNames(): string[]; +} + +export interface PluginAPI { + registerComponent( + slot: PluginSlot, + component: React.ComponentType, + options?: { priority?: number; label?: string }, + ): void; + + onSpecLoaded(callback: (spec: unknown) => void): void; + + getContext(): PluginContext; + + on(eventName: string, callback: (data: unknown) => void): void; + off(eventName: string, callback: (data: unknown) => void): void; + emit(eventName: string, data: unknown): void; +} From 99544f008e5ef8d01a8a29f4af483701f594146f Mon Sep 17 00:00:00 2001 From: acethecreator Date: Fri, 24 Oct 2025 03:37:37 +0100 Subject: [PATCH 02/13] add plugin slot renderer --- library/src/components/PluginSlotRenderer.tsx | 36 +++++++++++++++ library/src/containers/AsyncApi/Layout.tsx | 44 +++++++++++-------- .../src/containers/AsyncApi/Standalone.tsx | 27 ++++++++++-- .../src/containers/Operations/Operation.tsx | 16 ++++++- library/src/contexts/usePlugin.ts | 10 +++++ 5 files changed, 109 insertions(+), 24 deletions(-) create mode 100644 library/src/components/PluginSlotRenderer.tsx create mode 100644 library/src/contexts/usePlugin.ts diff --git a/library/src/components/PluginSlotRenderer.tsx b/library/src/components/PluginSlotRenderer.tsx new file mode 100644 index 000000000..1c5e57f08 --- /dev/null +++ b/library/src/components/PluginSlotRenderer.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { PluginManager } from '../helpers/pluginManager'; +import { PluginContext, PluginSlot } from '../types'; + +interface SlotRendererProps { + slot: PluginSlot; + context: PluginContext; + pluginManager?: PluginManager; +} + +const SlotRenderer: React.FC = ({ + slot, + context, + pluginManager, +}) => { + const components = pluginManager?.getComponentsForSlot(slot); + + if (components?.length === 0) { + return null; + } + + return ( +
+ {components?.map((Component, index) => ( + Loading plugin...
} + > + + + ))} + + ); +}; + +export { SlotRenderer }; diff --git a/library/src/containers/AsyncApi/Layout.tsx b/library/src/containers/AsyncApi/Layout.tsx index b6105b392..15d951407 100644 --- a/library/src/containers/AsyncApi/Layout.tsx +++ b/library/src/containers/AsyncApi/Layout.tsx @@ -12,15 +12,19 @@ import { Schemas } from '../Schemas/Schemas'; import { ConfigInterface } from '../../config'; import { SpecificationContext, ConfigContext } from '../../contexts'; import AsyncApiErrorBoundary from '../ApplicationErrorHandler/ErrorBoundary'; +import { PluginManager } from '../../helpers/pluginManager'; +import { PluginContext } from '../../contexts/usePlugin'; interface Props { asyncapi: AsyncAPIDocumentInterface; config: ConfigInterface; + pluginManager?: PluginManager; } const AsyncApiLayout: React.FunctionComponent = ({ asyncapi, config, + pluginManager, }) => { const [observerClassName, setObserverClassName] = useState('container:xl'); @@ -44,27 +48,29 @@ const AsyncApiLayout: React.FunctionComponent = ({ return ( -
- -
- {configShow.sidebar && } -
-
- {configShow.info && } - {configShow.servers && } - {configShow.operations && } - {configShow.messages && } - {configShow.schemas && } + +
+ +
+ {configShow.sidebar && } +
+
+ {configShow.info && } + {configShow.servers && } + {configShow.operations && } + {configShow.messages && } + {configShow.schemas && } +
+
-
-
- -
+ +
+
); diff --git a/library/src/containers/AsyncApi/Standalone.tsx b/library/src/containers/AsyncApi/Standalone.tsx index 59e7251b9..033c2fc42 100644 --- a/library/src/containers/AsyncApi/Standalone.tsx +++ b/library/src/containers/AsyncApi/Standalone.tsx @@ -50,20 +50,35 @@ class AsyncApiComponent extends Component { if (this.props.onPluginManagerReady) { this.props.onPluginManagerReady(this.state.pm!); } + + this.setupEventListeners(); + + this.registerPlugins(); } componentDidUpdate(prevProps: AsyncApiProps) { + const { schema, plugins, onPluginEvent } = this.props; const oldSchema = prevProps.schema; - const newSchema = this.props.schema; + const newSchema = schema; if (oldSchema !== newSchema) { this.updateState(newSchema); } + + if (onPluginEvent !== prevProps.onPluginEvent) { + this.cleanupEventListeners(); + this.setupEventListeners(); + } + + if (plugins !== prevProps.plugins) { + this.unregisterPlugins(); + this.registerPlugins(); + } } render() { const { config, error: propError } = this.props; - const { asyncapi, error: stateError } = this.state; + const { asyncapi, error: stateError, pm } = this.state; const error = propError ?? stateError; const concatenatedConfig: ConfigInterface = { @@ -100,7 +115,13 @@ class AsyncApiComponent extends Component { ); } - return ; + return ( + + ); } private handler(eventName: string) { diff --git a/library/src/containers/Operations/Operation.tsx b/library/src/containers/Operations/Operation.tsx index cb1b76bf6..5603500f0 100644 --- a/library/src/containers/Operations/Operation.tsx +++ b/library/src/containers/Operations/Operation.tsx @@ -14,7 +14,10 @@ import { Href } from '../../components/Href'; import { useConfig, useSpec } from '../../contexts'; import { CommonHelpers, SchemaHelpers } from '../../helpers'; import { EXTERAL_DOCUMENTATION_TEXT } from '../../constants'; -import { PayloadType } from '../../types'; +import { PayloadType, PluginSlot } from '../../types'; +import { PluginManager } from '../../helpers/pluginManager'; +import { SlotRenderer } from '../../components/PluginSlotRenderer'; +import { usePlugin } from '../../contexts/usePlugin'; interface Props { type: PayloadType; @@ -45,7 +48,6 @@ export const Operation: React.FunctionComponent = (props) => {
- {servers && servers.length > 0 ? (

Available only on servers:

@@ -164,6 +166,7 @@ export const Operation: React.FunctionComponent = (props) => { export const OperationInfo: React.FunctionComponent = (props) => { const { type = PayloadType.SEND, operation, channelName, channel } = props; const config = useConfig(); + const pluginManager = usePlugin(); const operationSummary = operation.summary(); const externalDocs = operation.externalDocs(); const operationId = operation.id(); @@ -229,6 +232,15 @@ export const OperationInfo: React.FunctionComponent = (props) => {
)} + {PluginManager && ( + + )} ); }; diff --git a/library/src/contexts/usePlugin.ts b/library/src/contexts/usePlugin.ts new file mode 100644 index 000000000..7948ae569 --- /dev/null +++ b/library/src/contexts/usePlugin.ts @@ -0,0 +1,10 @@ +import { createContext, useContext } from 'react'; +import { PluginManager } from '../helpers/pluginManager'; + +export const PluginContext = createContext( + undefined, +); + +export function usePlugin() { + return useContext(PluginContext); +} From 83dd35903b702536fccc66a1574ee73ea8b4a45b Mon Sep 17 00:00:00 2001 From: acethecreator Date: Fri, 24 Oct 2025 04:06:11 +0100 Subject: [PATCH 03/13] passed props from asyncapi so standalone can access --- library/src/containers/AsyncApi/AsyncApi.tsx | 5 ++++- library/src/index.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/library/src/containers/AsyncApi/AsyncApi.tsx b/library/src/containers/AsyncApi/AsyncApi.tsx index dd3d37d18..9d76912f4 100644 --- a/library/src/containers/AsyncApi/AsyncApi.tsx +++ b/library/src/containers/AsyncApi/AsyncApi.tsx @@ -7,6 +7,7 @@ import { isFetchingSchemaInterface, ErrorObject, PropsSchema, + AsyncApiPlugin, } from '../../types'; import { ConfigInterface } from '../../config'; import { SpecificationHelpers, Parser } from '../../helpers'; @@ -14,6 +15,7 @@ import { SpecificationHelpers, Parser } from '../../helpers'; export interface AsyncApiProps { schema: PropsSchema; config?: Partial; + plugins?: AsyncApiPlugin[]; } interface AsyncAPIState { @@ -45,7 +47,7 @@ class AsyncApiComponent extends Component { } render() { - const { schema, config } = this.props; + const { schema, config, plugins } = this.props; const { asyncapi, error } = this.state; return ( @@ -53,6 +55,7 @@ class AsyncApiComponent extends Component { schema={asyncapi ?? schema} config={config} error={error} + plugins={plugins} /> ); } diff --git a/library/src/index.ts b/library/src/index.ts index be2b7ef65..a69daca9b 100644 --- a/library/src/index.ts +++ b/library/src/index.ts @@ -3,7 +3,14 @@ import AsyncApiComponentWP from './containers/AsyncApi/Standalone'; export { AsyncApiProps } from './containers/AsyncApi/AsyncApi'; export { ConfigInterface } from './config/config'; -export { FetchingSchemaInterface, ExtensionComponentProps } from './types'; +export { + FetchingSchemaInterface, + ExtensionComponentProps, + AsyncApiPlugin, + PluginAPI, + PluginSlot, + PluginContext, +} from './types'; import { hljs } from './helpers'; From 393abda4ebe844c4f8b235c803ea418162b23d23 Mon Sep 17 00:00:00 2001 From: acethecreator Date: Mon, 3 Nov 2025 16:11:54 +0100 Subject: [PATCH 04/13] test plugin event reciever --- library/src/containers/AsyncApi/Standalone.tsx | 4 +++- library/src/helpers/pluginManager.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/library/src/containers/AsyncApi/Standalone.tsx b/library/src/containers/AsyncApi/Standalone.tsx index 033c2fc42..8a153a36a 100644 --- a/library/src/containers/AsyncApi/Standalone.tsx +++ b/library/src/containers/AsyncApi/Standalone.tsx @@ -136,8 +136,10 @@ class AsyncApiComponent extends Component { if (!onPluginEvent) return; // eslint-disable-next-line sonarjs/no-duplicate-string - const events = ['plugin:ready', 'plugin:error']; + const events = ['plugin:ready', 'plugin:error', 'NOTIFICATION']; events.forEach((event) => { + console.log('event appears here'); + console.log(event); pm?.on(event, this.handler(event)); }); } diff --git a/library/src/helpers/pluginManager.ts b/library/src/helpers/pluginManager.ts index dca21adca..b35453185 100644 --- a/library/src/helpers/pluginManager.ts +++ b/library/src/helpers/pluginManager.ts @@ -44,7 +44,6 @@ class PluginManager implements MessageBus { plugin.uninstall?.(); this.plugins.delete(pluginName); - // Remove all components from this plugin this.slotComponents.forEach((components) => { const index = components.findIndex((c) => c.component.displayName?.includes(pluginName), From f7124f7c0a7d5ac0ce4e1ff36629292f56e255da Mon Sep 17 00:00:00 2001 From: acethecreator Date: Tue, 4 Nov 2025 13:29:57 +0100 Subject: [PATCH 05/13] add support for ref plugin registration --- library/src/constants.ts | 2 + library/src/containers/AsyncApi/AsyncApi.tsx | 8 +- .../src/containers/AsyncApi/Standalone.tsx | 119 +++++++++++++++--- library/src/helpers/pluginManager.ts | 27 ++-- library/src/types.ts | 2 - 5 files changed, 125 insertions(+), 33 deletions(-) diff --git a/library/src/constants.ts b/library/src/constants.ts index 142441ad9..a21d153e7 100644 --- a/library/src/constants.ts +++ b/library/src/constants.ts @@ -84,3 +84,5 @@ export const COLLAPSE_ERROR_BUTTON_TEXT = 'Collapse'; export const SECURITY_TEXT = 'Security'; export const URL_VARIABLES_TEXT = 'URL Variables'; + +export const PLUGINEVENTS = ['plugin:ready', 'plugin:error']; diff --git a/library/src/containers/AsyncApi/AsyncApi.tsx b/library/src/containers/AsyncApi/AsyncApi.tsx index 9d76912f4..64ee9c796 100644 --- a/library/src/containers/AsyncApi/AsyncApi.tsx +++ b/library/src/containers/AsyncApi/AsyncApi.tsx @@ -11,11 +11,14 @@ import { } from '../../types'; import { ConfigInterface } from '../../config'; import { SpecificationHelpers, Parser } from '../../helpers'; +import { PluginManager } from '../../helpers/pluginManager'; export interface AsyncApiProps { schema: PropsSchema; config?: Partial; plugins?: AsyncApiPlugin[]; + onPluginEvent?: (eventName: string, data: unknown) => void; + onPluginManagerReady?: (pluginManager: PluginManager) => void; } interface AsyncAPIState { @@ -47,7 +50,8 @@ class AsyncApiComponent extends Component { } render() { - const { schema, config, plugins } = this.props; + const { schema, config, plugins, onPluginEvent, onPluginManagerReady } = + this.props; const { asyncapi, error } = this.state; return ( @@ -56,6 +60,8 @@ class AsyncApiComponent extends Component { config={config} error={error} plugins={plugins} + onPluginEvent={onPluginEvent} + onPluginManagerReady={onPluginManagerReady} /> ); } diff --git a/library/src/containers/AsyncApi/Standalone.tsx b/library/src/containers/AsyncApi/Standalone.tsx index 8a153a36a..d7e386a0d 100644 --- a/library/src/containers/AsyncApi/Standalone.tsx +++ b/library/src/containers/AsyncApi/Standalone.tsx @@ -8,6 +8,7 @@ import { ConfigInterface, defaultConfig } from '../../config'; import AsyncApiLayout from './Layout'; import { Error } from '../Error/Error'; import { PluginManager } from '../../helpers/pluginManager'; +import { PLUGINEVENTS } from '../../constants'; export interface AsyncApiProps { schema: PropsSchema; @@ -26,6 +27,8 @@ interface AsyncAPIState { class AsyncApiComponent extends Component { private registeredPlugins = new Set(); + private propsPlugins = new Set(); + private dynamicPlugins = new Set(); state: AsyncAPIState = { asyncapi: undefined, error: undefined, @@ -50,7 +53,6 @@ class AsyncApiComponent extends Component { if (this.props.onPluginManagerReady) { this.props.onPluginManagerReady(this.state.pm!); } - this.setupEventListeners(); this.registerPlugins(); @@ -71,8 +73,7 @@ class AsyncApiComponent extends Component { } if (plugins !== prevProps.plugins) { - this.unregisterPlugins(); - this.registerPlugins(); + this.updatePlugins(prevProps.plugins, plugins); } } @@ -135,19 +136,14 @@ class AsyncApiComponent extends Component { if (!onPluginEvent) return; - // eslint-disable-next-line sonarjs/no-duplicate-string - const events = ['plugin:ready', 'plugin:error', 'NOTIFICATION']; - events.forEach((event) => { - console.log('event appears here'); - console.log(event); + PLUGINEVENTS.forEach((event) => { pm?.on(event, this.handler(event)); }); } private cleanupEventListeners() { const { pm } = this.state; - const events = ['plugin:ready', 'plugin:error']; - events.forEach((event) => { + PLUGINEVENTS.forEach((event) => { pm?.off(event, this.handler(event)); }); } @@ -160,27 +156,51 @@ class AsyncApiComponent extends Component { try { pm?.register(plugin); this.registeredPlugins.add(plugin.name); + this.propsPlugins.add(plugin.name); // Track as props-managed } catch (error) { console.error(`Failed to register plugin ${plugin.name}:`, error); - pm?.emit('plugin:error', { + pm?.emit(PLUGINEVENTS[1], { pluginName: plugin.name, - // error: error instanceof Error ? error.message : String(error as any), }); } }); } - private unregisterPlugins() { + private updatePlugins( + prevPlugins: AsyncApiPlugin[] | undefined, + newPlugins: AsyncApiPlugin[] | undefined, + ) { const { pm } = this.state; - this.registeredPlugins.forEach((pluginName) => { - try { - pm?.unregister(pluginName); - } catch (error) { - console.error(`Failed to unregister plugin ${pluginName}:`, error); + const prevPluginMap = new Map((prevPlugins ?? []).map((p) => [p.name, p])); + const newPluginMap = new Map((newPlugins ?? []).map((p) => [p.name, p])); + + prevPluginMap.forEach((_plugin, name) => { + if (!newPluginMap.has(name) && this.propsPlugins.has(name)) { + try { + pm?.unregister(name); + this.registeredPlugins.delete(name); + this.propsPlugins.delete(name); + } catch (error) { + console.error(`Failed to unregister plugin ${name}:`, error); + } + } + }); + + newPluginMap.forEach((plugin, name) => { + if (!prevPluginMap.has(name)) { + try { + pm?.register(plugin); + this.registeredPlugins.add(name); + this.propsPlugins.add(name); + } catch (error) { + console.error(`Failed to register plugin ${name}:`, error); + pm?.emit(PLUGINEVENTS[1], { + pluginName: name, + }); + } } }); - this.registeredPlugins.clear(); } private updateState(schema: PropsSchema) { @@ -192,6 +212,67 @@ class AsyncApiComponent extends Component { this.setState({ asyncapi: parsedSpec }); this.state.pm?.updateContext({ schema: parsedSpec }); } + + // Public API for managing plugins via refs + public registerPlugin(plugin: AsyncApiPlugin): void { + const { pm } = this.state; + if (this.propsPlugins.has(plugin.name)) { + console.warn( + `Plugin "${plugin.name}" is already managed by props. ` + + `Remove it from the plugins prop if you want to manage it dynamically.`, + ); + return; + } + + if (this.dynamicPlugins.has(plugin.name)) { + console.warn( + `Plugin "${plugin.name}" is already registered dynamically.`, + ); + return; + } + + try { + pm?.register(plugin); + this.dynamicPlugins.add(plugin.name); + this.registeredPlugins.add(plugin.name); + pm?.emit(PLUGINEVENTS[0], { pluginName: plugin.name }); + } catch (error) { + console.error(`Failed to register plugin ${plugin.name}:`, error); + pm?.emit(PLUGINEVENTS[1], { + pluginName: plugin.name, + }); + } + } + + public unregisterPlugin(pluginName: string): void { + const { pm } = this.state; + + if (this.propsPlugins.has(pluginName)) { + console.warn( + `Plugin "${pluginName}" is managed by props. ` + + `Remove it from the plugins prop to unregister it.`, + ); + return; + } + + if (!this.dynamicPlugins.has(pluginName)) { + console.warn( + `Plugin "${pluginName}" is not registered as a dynamic plugin.`, + ); + return; + } + + try { + pm?.unregister(pluginName); + this.dynamicPlugins.delete(pluginName); + this.registeredPlugins.delete(pluginName); + } catch (error) { + console.error(`Failed to unregister plugin ${pluginName}:`, error); + pm?.emit(PLUGINEVENTS[1], { + pluginName, + }); + } + } } export default AsyncApiComponent; diff --git a/library/src/helpers/pluginManager.ts b/library/src/helpers/pluginManager.ts index b35453185..5fbe50c8e 100644 --- a/library/src/helpers/pluginManager.ts +++ b/library/src/helpers/pluginManager.ts @@ -16,6 +16,7 @@ class PluginManager implements MessageBus { component: React.ComponentType; priority: number; label?: string; + pluginName: string; }[] >(); private eventListeners = new Map>(); @@ -31,30 +32,31 @@ class PluginManager implements MessageBus { return; } - const api = this.createPluginAPI(); + const api = this.createPluginAPI(plugin); plugin.install(api); this.plugins.set(plugin.name, plugin); - console.log(`Plugin ${plugin.name}@${plugin.version} registered`); } unregister(pluginName: string): void { const plugin = this.plugins.get(pluginName); - if (!plugin) return; + if (!plugin) { + console.log(`Plugin "${pluginName}" not found`); + return; + } - plugin.uninstall?.(); this.plugins.delete(pluginName); this.slotComponents.forEach((components) => { - const index = components.findIndex((c) => - c.component.displayName?.includes(pluginName), - ); + const index = components.findIndex((c) => { + return c.pluginName === pluginName; + }); if (index > -1) { components.splice(index, 1); } }); } - private createPluginAPI(): PluginAPI { + private createPluginAPI(plugin: AsyncApiPlugin): PluginAPI { return { registerComponent: (slot, component, options = {}) => { if (!this.slotComponents.has(slot)) { @@ -62,9 +64,12 @@ class PluginManager implements MessageBus { } const priority = options.priority ?? 100; - this.slotComponents - .get(slot)! - .push({ component, priority, label: options.label }); + this.slotComponents.get(slot)!.push({ + component, + priority, + label: options.label, + pluginName: plugin?.name, + }); this.slotComponents.get(slot)!.sort((a, b) => b.priority - a.priority); }, diff --git a/library/src/types.ts b/library/src/types.ts index 2ac4fe92c..c5721b434 100644 --- a/library/src/types.ts +++ b/library/src/types.ts @@ -107,8 +107,6 @@ export interface AsyncApiPlugin { description?: string; install(api: PluginAPI): void; - - uninstall?(): void; } export type PluginInstance = From d44ea921c0c8e45ad0889da1c3f0bf67b1cc6aa2 Mon Sep 17 00:00:00 2001 From: acethecreator Date: Tue, 4 Nov 2025 16:02:21 +0100 Subject: [PATCH 06/13] added test cases forslot and plugin manager --- library/package.json | 1 + library/src/components/PluginSlotRenderer.tsx | 11 +- .../__tests__/PluginSlotRenderer.test.tsx | 273 ++++++++++++ .../helpers/__tests__/pluginManager.test.ts | 387 ++++++++++++++++++ package-lock.json | 55 +-- 5 files changed, 671 insertions(+), 56 deletions(-) create mode 100644 library/src/components/__tests__/PluginSlotRenderer.test.tsx create mode 100644 library/src/helpers/__tests__/pluginManager.test.ts diff --git a/library/package.json b/library/package.json index d5f316afd..9557b2ec9 100644 --- a/library/package.json +++ b/library/package.json @@ -95,6 +95,7 @@ "@types/node": "^12.7.2", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", + "@types/testing-library__jest-dom": "^5.14.9", "autoprefixer": "^10.2.5", "cross-env": "^7.0.3", "cssnano": "^4.1.11", diff --git a/library/src/components/PluginSlotRenderer.tsx b/library/src/components/PluginSlotRenderer.tsx index 1c5e57f08..ef6d88320 100644 --- a/library/src/components/PluginSlotRenderer.tsx +++ b/library/src/components/PluginSlotRenderer.tsx @@ -13,15 +13,20 @@ const SlotRenderer: React.FC = ({ context, pluginManager, }) => { - const components = pluginManager?.getComponentsForSlot(slot); + if (!pluginManager) { + return null; + } + + const components = pluginManager.getComponentsForSlot(slot); - if (components?.length === 0) { + if (!components || components.length === 0) { + console.log('no component detected here'); return null; } return (
- {components?.map((Component, index) => ( + {components.map((Component, index) => ( Loading plugin...
} diff --git a/library/src/components/__tests__/PluginSlotRenderer.test.tsx b/library/src/components/__tests__/PluginSlotRenderer.test.tsx new file mode 100644 index 000000000..d65b1176a --- /dev/null +++ b/library/src/components/__tests__/PluginSlotRenderer.test.tsx @@ -0,0 +1,273 @@ +/** + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SlotRenderer } from '../PluginSlotRenderer'; +import '@testing-library/jest-dom'; +import { PluginManager } from '../../helpers/pluginManager'; +import { + PluginSlot, + PluginContext, + ComponentSlotProps, + PluginAPI, +} from '../../types'; + +const pluginName = 'test-plugin'; + +describe('PluginSlotRenderer', () => { + let pluginManager: PluginManager; + let context: PluginContext; + + beforeEach(() => { + pluginManager = new PluginManager({ schema: {} }); + context = { schema: {} }; + }); + + describe('Rendering', () => { + it('should render nothing when no plugin manager is provided', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when no components are registered for the slot', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('should render the slot container with correct class and data attribute', () => { + // Register a simple component + const TestComponent: React.FC = () => ( +
Test Plugin
+ ); + + const plugin = { + name: pluginName, + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, TestComponent); + }, + }; + + pluginManager.register(plugin); + + const { container } = render( + , + ); + + const slotContainer = container.querySelector( + `.asyncapi-react-plugin-slot-${PluginSlot.OPERATION}`, + ); + expect(slotContainer).toBeInTheDocument(); + expect(slotContainer).toHaveAttribute('data-slot', 'operation'); + }); + + it('should render a single plugin component', () => { + const TestComponent: React.FC = () => ( +
Test Plugin Content
+ ); + + const plugin = { + name: 'test-plugin', + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, TestComponent); + }, + }; + + pluginManager.register(plugin); + + render( + , + ); + + expect(screen.getByText('Test Plugin Content')).toBeInTheDocument(); + }); + + it('should render multiple plugin components in the same slot', () => { + const TestComponent1: React.FC = () => ( +
Plugin 1
+ ); + const TestComponent2: React.FC = () => ( +
Plugin 2
+ ); + + const plugin1 = { + name: 'plugin-1', + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, TestComponent1); + }, + }; + + const plugin2 = { + name: 'plugin-2', + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, TestComponent2); + }, + }; + + pluginManager.register(plugin1); + pluginManager.register(plugin2); + + render( + , + ); + + expect(screen.getByText('Plugin 1')).toBeInTheDocument(); + expect(screen.getByText('Plugin 2')).toBeInTheDocument(); + }); + }); + + describe('Context Passing', () => { + it('should pass context to plugin components', () => { + const contextData = { schema: { title: 'Test API' } }; + const TestComponent: React.FC = ({ context }) => ( +
{JSON.stringify(context)}
+ ); + + const plugin = { + name: pluginName, + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, TestComponent); + }, + }; + + pluginManager.register(plugin); + + render( + , + ); + + expect(screen.getByText(JSON.stringify(contextData))).toBeInTheDocument(); + }); + }); + + describe('Component Priority', () => { + it('should render components in priority order (highest first)', () => { + const LowPriorityComponent: React.FC = () => ( +
Low Priority
+ ); + const HighPriorityComponent: React.FC = () => ( +
High Priority
+ ); + + const lowPlugin = { + name: 'low-priority-plugin', + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, LowPriorityComponent, { + priority: 10, + }); + }, + }; + + const highPlugin = { + name: 'high-priority-plugin', + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, HighPriorityComponent, { + priority: 100, + }); + }, + }; + + pluginManager.register(lowPlugin); + pluginManager.register(highPlugin); + + const { container } = render( + , + ); + + const children = container.querySelectorAll('[data-testid]'); + expect(children[0]).toHaveAttribute('data-testid', 'high'); + expect(children[1]).toHaveAttribute('data-testid', 'low'); + }); + }); + + describe('Edge Cases', () => { + it('should re-render when pluginManager changes', () => { + const TestComponent: React.FC = () => ( +
Initial Component
+ ); + + const plugin = { + name: pluginName, + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, TestComponent); + }, + }; + + pluginManager.register(plugin); + + const { rerender } = render( + , + ); + + expect(screen.getByText('Initial Component')).toBeInTheDocument(); + + // Create new plugin manager with different component + const newPluginManager = new PluginManager({ schema: {} }); + const NewComponent: React.FC = () => ( +
New Component
+ ); + + const newPlugin = { + name: 'new-plugin', + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, NewComponent); + }, + }; + + newPluginManager.register(newPlugin); + + rerender( + , + ); + + expect(screen.getByText('New Component')).toBeInTheDocument(); + expect(screen.queryByText('Initial Component')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/library/src/helpers/__tests__/pluginManager.test.ts b/library/src/helpers/__tests__/pluginManager.test.ts new file mode 100644 index 000000000..8fe8dc672 --- /dev/null +++ b/library/src/helpers/__tests__/pluginManager.test.ts @@ -0,0 +1,387 @@ +import { PluginManager } from '../pluginManager'; +import { AsyncApiPlugin, PluginSlot, PluginAPI } from '../../types'; + +const TEST_PLUGIN_NAME = 'test-plugin'; +const TEST_EVENT = 'test-event'; + +describe('PluginManager', () => { + let pluginManager: PluginManager; + const mockContext = { schema: { title: 'Test API' } }; + + beforeEach(() => { + pluginManager = new PluginManager(mockContext); + // disable console logging + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Plugin Registration', () => { + it('should register a plugin', () => { + const installMock = jest.fn(); + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: installMock, + }; + + pluginManager.register(plugin); + + expect(installMock).toHaveBeenCalled(); + expect(pluginManager.getPlugin(TEST_PLUGIN_NAME)).toBe(plugin); + }); + + it('should call plugin install with correct API', () => { + let capturedAPI: PluginAPI | null = null; + const plugin: AsyncApiPlugin = { + name: 'test-plugin', + version: '1.0.0', + install: (api) => { + capturedAPI = api; + }, + }; + + pluginManager.register(plugin); + + expect(capturedAPI).toBeDefined(); + expect(capturedAPI).toHaveProperty('registerComponent'); + expect(capturedAPI).toHaveProperty('on'); + expect(capturedAPI).toHaveProperty('off'); + expect(capturedAPI).toHaveProperty('emit'); + expect(capturedAPI).toHaveProperty('getContext'); + expect(capturedAPI).toHaveProperty('onSpecLoaded'); + }); + + it('should not call install twice for duplicate plugin', () => { + const installMock = jest.fn(); + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: installMock, + }; + + pluginManager.register(plugin); + pluginManager.register(plugin); + + expect(installMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('Plugin Unregistration', () => { + it('should unregister a plugin', () => { + const installMock = jest.fn(); + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: installMock, + }; + + pluginManager.register(plugin); + pluginManager.unregister(TEST_PLUGIN_NAME); + + expect(pluginManager.getPlugin(TEST_PLUGIN_NAME)).toBeUndefined(); + }); + + it('should remove plugin components when unregistering', () => { + const TestComponent = () => null; + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + api.registerComponent(PluginSlot.OPERATION, TestComponent); + }, + }; + + pluginManager.register(plugin); + expect( + pluginManager.getComponentsForSlot(PluginSlot.OPERATION), + ).toHaveLength(1); + + pluginManager.unregister(TEST_PLUGIN_NAME); + expect( + pluginManager.getComponentsForSlot(PluginSlot.OPERATION), + ).toHaveLength(0); + }); + + it('should only remove components from the unregistered plugin', () => { + const Component1 = () => null; + const Component2 = () => null; + + const plugin1: AsyncApiPlugin = { + name: 'plugin-1', + version: '1.0.0', + install: (api) => { + api.registerComponent(PluginSlot.OPERATION, Component1); + }, + }; + + const plugin2: AsyncApiPlugin = { + name: 'plugin-2', + version: '1.0.0', + install: (api) => { + api.registerComponent(PluginSlot.OPERATION, Component2); + }, + }; + + pluginManager.register(plugin1); + pluginManager.register(plugin2); + expect( + pluginManager.getComponentsForSlot(PluginSlot.OPERATION), + ).toHaveLength(2); + + pluginManager.unregister('plugin-1'); + expect( + pluginManager.getComponentsForSlot(PluginSlot.OPERATION), + ).toHaveLength(1); + expect(pluginManager.getComponentsForSlot(PluginSlot.OPERATION)[0]).toBe( + Component2, + ); + }); + }); + + describe('Component Registration', () => { + it('should register component in a slot', () => { + const TestComponent = () => null; + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + api.registerComponent(PluginSlot.OPERATION, TestComponent); + }, + }; + + pluginManager.register(plugin); + + const components = pluginManager.getComponentsForSlot( + PluginSlot.OPERATION, + ); + expect(components).toHaveLength(1); + expect(components[0]).toBe(TestComponent); + }); + + it('should register multiple components in the same slot', () => { + const Component1 = () => null; + const Component2 = () => null; + + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + api.registerComponent(PluginSlot.OPERATION, Component1); + api.registerComponent(PluginSlot.OPERATION, Component2); + }, + }; + + pluginManager.register(plugin); + + const components = pluginManager.getComponentsForSlot( + PluginSlot.OPERATION, + ); + expect(components).toHaveLength(2); + }); + + it('should use default priority of 100for components without priority', () => { + const DefaultComponent = () => null; + const HighComponent = () => null; + const LowComponent = () => null; + + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + api.registerComponent(PluginSlot.OPERATION, LowComponent, { + priority: 50, + }); + api.registerComponent(PluginSlot.OPERATION, DefaultComponent); + api.registerComponent(PluginSlot.OPERATION, HighComponent, { + priority: 150, + }); + }, + }; + + pluginManager.register(plugin); + + const components = pluginManager.getComponentsForSlot( + PluginSlot.OPERATION, + ); + expect(components[0]).toBe(HighComponent); + expect(components[1]).toBe(DefaultComponent); + expect(components[2]).toBe(LowComponent); + }); + + it('should return empty array for slot with no components', () => { + const components = pluginManager.getComponentsForSlot( + PluginSlot.OPERATION, + ); + expect(components).toEqual([]); + }); + }); + + describe('Event System', () => { + it('should register event listener', () => { + const callback = jest.fn(); + + pluginManager.on(TEST_EVENT, callback); + + expect(pluginManager.listeners(TEST_EVENT)).toHaveLength(1); + }); + + it('should emit event to listeners', () => { + const callback = jest.fn(); + const eventData = { message: 'test' }; + + pluginManager.on(TEST_EVENT, callback); + pluginManager.emit(TEST_EVENT, eventData); + + expect(callback).toHaveBeenCalledWith(eventData); + }); + + it('should emit to multiple listeners', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const eventData = { message: 'test' }; + + pluginManager.on(TEST_EVENT, callback1); + pluginManager.on(TEST_EVENT, callback2); + pluginManager.emit(TEST_EVENT, eventData); + + expect(callback1).toHaveBeenCalledWith(eventData); + expect(callback2).toHaveBeenCalledWith(eventData); + }); + + it('should remove event listener', () => { + const callback = jest.fn(); + + pluginManager.on(TEST_EVENT, callback); + pluginManager.off(TEST_EVENT, callback); + + pluginManager.emit(TEST_EVENT, { message: 'test' }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should remove event name when last listener is removed', () => { + const callback = jest.fn(); + + pluginManager.on(TEST_EVENT, callback); + pluginManager.off(TEST_EVENT, callback); + + expect(pluginManager.eventNames()).toEqual([]); + }); + + it('should list all event names', () => { + pluginManager.on('event1', jest.fn()); + pluginManager.on('event2', jest.fn()); + + const eventNames = pluginManager.eventNames(); + expect(eventNames).toContain('event1'); + expect(eventNames).toContain('event2'); + expect(eventNames).toHaveLength(2); + }); + }); + + describe('Context Management', () => { + it('should return initial context', () => { + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + expect(api.getContext()).toEqual(mockContext); + }, + }; + + pluginManager.register(plugin); + }); + + it('should update context', () => { + const newContext = { schema: { title: 'Updated API' } }; + + pluginManager.updateContext(newContext); + + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + expect(api.getContext()).toEqual({ schema: newContext }); + }, + }; + + pluginManager.register(plugin); + }); + }); + + describe('Plugin Retrieval', () => { + it('should get plugin by name', () => { + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: jest.fn(), + }; + + pluginManager.register(plugin); + + expect(pluginManager.getPlugin(TEST_PLUGIN_NAME)).toBe(plugin); + }); + + it('should return undefined for non-existent plugin', () => { + expect(pluginManager.getPlugin('non-existent')).toBeUndefined(); + }); + }); + + describe('Plugin API Integration', () => { + it('should allow plugins to emit events', () => { + const callback = jest.fn(); + pluginManager.on('custom-event', callback); + + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + api.emit('custom-event', { data: 'test' }); + }, + }; + + pluginManager.register(plugin); + + expect(callback).toHaveBeenCalledWith({ data: 'test' }); + }); + + it('should allow plugins to listen to events', () => { + const callback = jest.fn(); + + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + api.on('external-event', callback); + }, + }; + + pluginManager.register(plugin); + pluginManager.emit('external-event', { message: 'hello' }); + + expect(callback).toHaveBeenCalledWith({ message: 'hello' }); + }); + + it('should allow plugins to unsubscribe from events', () => { + const callback = jest.fn(); + + const plugin: AsyncApiPlugin = { + name: TEST_PLUGIN_NAME, + version: '1.0.0', + install: (api) => { + api.on(TEST_EVENT, callback); + api.off(TEST_EVENT, callback); + }, + }; + + pluginManager.register(plugin); + pluginManager.emit(TEST_EVENT, { data: 'test' }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 8b0ff991d..b7d8b9bff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "@types/node": "^12.7.2", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", + "@types/testing-library__jest-dom": "^5.14.9", "autoprefixer": "^10.2.5", "cross-env": "^7.0.3", "cssnano": "^4.1.11", @@ -5538,6 +5539,7 @@ "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", "dev": true, + "license": "MIT", "dependencies": { "@types/jest": "*" } @@ -12634,14 +12636,6 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, - "node_modules/growly": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", - "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/gulp-header": { "version": "1.8.12", "resolved": "https://registry.npmjs.org/gulp-header/-/gulp-header-1.8.12.tgz", @@ -13397,18 +13391,6 @@ } } }, - "node_modules/inquirer/node_modules/@types/node": { - "version": "24.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", - "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.10.0" - } - }, "node_modules/inquirer/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -13437,15 +13419,6 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true }, - "node_modules/inquirer/node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/inquirer/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -18201,22 +18174,6 @@ "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==", "dev": true }, - "node_modules/node-notifier": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.2.tgz", - "integrity": "sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "growly": "^1.3.0", - "is-wsl": "^2.2.0", - "semver": "^7.3.2", - "shellwords": "^0.1.1", - "uuid": "^8.3.0", - "which": "^2.0.2" - } - }, "node_modules/node-polyfill-webpack-plugin": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/node-polyfill-webpack-plugin/-/node-polyfill-webpack-plugin-2.0.1.tgz", @@ -23646,14 +23603,6 @@ "node": ">=8" } }, - "node_modules/shellwords": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", - "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", From 9e98377e14711ddf164ea5bf18a852bc8cf8f99a Mon Sep 17 00:00:00 2001 From: acethecreator Date: Wed, 12 Nov 2025 04:55:34 +0100 Subject: [PATCH 07/13] add plugin quick started docs --- docs/features/plugins.md | 96 +++++++++++++++++++ library/src/__tests__/index.test.tsx | 48 ++++++++++ .../src/containers/AsyncApi/Standalone.tsx | 6 -- 3 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 docs/features/plugins.md diff --git a/docs/features/plugins.md b/docs/features/plugins.md new file mode 100644 index 000000000..2bb068a26 --- /dev/null +++ b/docs/features/plugins.md @@ -0,0 +1,96 @@ +# Plugin System + +The AsyncAPI React component supports a flexible plugin system to extend and customize your documentation. + +> Currently supports Operation level slots only. + +## Usage + +### Static Registration (via props) + +Use this when you know all plugins upfront: + +```typescript +import { AsyncApiPlugin, PluginAPI, PluginSlot } from '@asyncapi/react-component'; + +const myPlugin: AsyncApiPlugin = { + name: 'my-plugin', + version: '1.0.0', + install(api: PluginAPI) { + api.registerComponent(PluginSlot.OPERATION, MyComponent); + api.onSpecLoaded((spec) => console.log('Spec loaded:', spec)); + } +}; + + +``` + +### Dynamic Registration + +Use this when you need to add/remove plugins at runtime: + +```typescript +import { useState } from 'react'; + +function MyApp() { + const [pluginManager, setPluginManager] = useState(null); + + const handleEnablePlugin = () => { + pluginManager?.register(myPlugin); + }; + + const handleDisablePlugin = () => { + pluginManager?.unregister('my-plugin'); + }; + + return ( + <> + + + setPluginManager(pm)} + /> + + ); +} +``` + +## Plugin Structure + +```typescript +interface AsyncApiPlugin { + name: string; // Unique identifier + version: string; // Semantic version + description?: string; // Optional description + install(api: PluginAPI): void; +} +``` + +## PluginAPI Methods + +| Method | Purpose | +|--------|---------| +| `registerComponent(slot, component, options?)` | Register a React component in a slot. `options`: `{ priority?: number; label?: string }` | +| `onSpecLoaded(callback)` | Called when AsyncAPI spec loads | +| `getContext()` | Get current plugin context with schema | +| `on(eventName, callback)` | Subscribe to events | +| `off(eventName, callback)` | Unsubscribe from events | +| `emit(eventName, data)` | Emit custom events | + +## Component Props + +```typescript +interface ComponentSlotProps { + context: PluginContext; + onClose?: () => void; +} + +const MyComponent: React.FC = ({ context, onClose }) => ( +
Custom content here
+); +``` + +## Available Slots + +- `PluginSlot.OPERATION` - Renders within operation sections \ No newline at end of file diff --git a/library/src/__tests__/index.test.tsx b/library/src/__tests__/index.test.tsx index d8c11ba63..913d9ee45 100644 --- a/library/src/__tests__/index.test.tsx +++ b/library/src/__tests__/index.test.tsx @@ -13,6 +13,7 @@ import krakenMultipleChannels from './docs/v3/kraken-websocket-request-reply-mul import streetlightsKafka from './docs/v3/streetlights-kafka.json'; import streetlightsMqtt from './docs/v3/streetlights-mqtt.json'; import websocketGemini from './docs/v3/websocket-gemini.json'; +import { PluginAPI, PluginSlot } from '../types'; jest.mock('use-resize-observer', () => ({ __esModule: true, @@ -266,4 +267,51 @@ describe('AsyncAPI component', () => { expect(result.container.querySelector('#custom-extension')).toBeDefined(); }); }); + + test('should work with plugin registration', async () => { + const TestPluginComponent = () => ( +
Test Plugin Rendered
+ ); + + const testPlugin = { + name: 'test-plugin', + version: '1.0.0', + install: (api: PluginAPI) => { + api.registerComponent(PluginSlot.OPERATION, TestPluginComponent); + }, + }; + + const schema = { + asyncapi: '2.0.0', + info: { + title: 'Test API with Plugins', + version: '1.0.0', + }, + channels: { + 'test/channel': { + subscribe: { + message: { + payload: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }; + + const result = render( + , + ); + + await waitFor(() => { + expect(result.container.querySelector('#introduction')).toBeDefined(); + const pluginComponent = result.getByTestId('plugin-component'); + expect(pluginComponent).toBeDefined(); + expect(pluginComponent.textContent).toContain('Test Plugin Rendered'); + }); + }); }); diff --git a/library/src/containers/AsyncApi/Standalone.tsx b/library/src/containers/AsyncApi/Standalone.tsx index d7e386a0d..81c6f3151 100644 --- a/library/src/containers/AsyncApi/Standalone.tsx +++ b/library/src/containers/AsyncApi/Standalone.tsx @@ -238,9 +238,6 @@ class AsyncApiComponent extends Component { pm?.emit(PLUGINEVENTS[0], { pluginName: plugin.name }); } catch (error) { console.error(`Failed to register plugin ${plugin.name}:`, error); - pm?.emit(PLUGINEVENTS[1], { - pluginName: plugin.name, - }); } } @@ -268,9 +265,6 @@ class AsyncApiComponent extends Component { this.registeredPlugins.delete(pluginName); } catch (error) { console.error(`Failed to unregister plugin ${pluginName}:`, error); - pm?.emit(PLUGINEVENTS[1], { - pluginName, - }); } } } From 14ec2e4c5c18e091c23ed6ea7c8206b3775b3696 Mon Sep 17 00:00:00 2001 From: acethecreator Date: Wed, 12 Nov 2025 05:12:25 +0100 Subject: [PATCH 08/13] removed ccomment --- library/src/containers/AsyncApi/Standalone.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/library/src/containers/AsyncApi/Standalone.tsx b/library/src/containers/AsyncApi/Standalone.tsx index 81c6f3151..95e6cad57 100644 --- a/library/src/containers/AsyncApi/Standalone.tsx +++ b/library/src/containers/AsyncApi/Standalone.tsx @@ -213,7 +213,6 @@ class AsyncApiComponent extends Component { this.state.pm?.updateContext({ schema: parsedSpec }); } - // Public API for managing plugins via refs public registerPlugin(plugin: AsyncApiPlugin): void { const { pm } = this.state; if (this.propsPlugins.has(plugin.name)) { From a6403c923cb56623d9e116934bd2d6178f974916 Mon Sep 17 00:00:00 2001 From: acethecreator Date: Wed, 12 Nov 2025 05:15:46 +0100 Subject: [PATCH 09/13] . --- package-lock.json | 55 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 854145e19..27fa1e870 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,6 @@ "@types/node": "^12.7.2", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", - "@types/testing-library__jest-dom": "^5.14.9", "autoprefixer": "^10.2.5", "cross-env": "^7.0.3", "cssnano": "^4.1.11", @@ -5539,7 +5538,6 @@ "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", "dev": true, - "license": "MIT", "dependencies": { "@types/jest": "*" } @@ -12636,6 +12634,14 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/growly": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", + "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/gulp-header": { "version": "1.8.12", "resolved": "https://registry.npmjs.org/gulp-header/-/gulp-header-1.8.12.tgz", @@ -13391,6 +13397,18 @@ } } }, + "node_modules/inquirer/node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.10.0" + } + }, "node_modules/inquirer/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -13419,6 +13437,15 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true }, + "node_modules/inquirer/node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/inquirer/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -18174,6 +18201,22 @@ "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==", "dev": true }, + "node_modules/node-notifier": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.2.tgz", + "integrity": "sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "growly": "^1.3.0", + "is-wsl": "^2.2.0", + "semver": "^7.3.2", + "shellwords": "^0.1.1", + "uuid": "^8.3.0", + "which": "^2.0.2" + } + }, "node_modules/node-polyfill-webpack-plugin": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/node-polyfill-webpack-plugin/-/node-polyfill-webpack-plugin-2.0.1.tgz", @@ -23603,6 +23646,14 @@ "node": ">=8" } }, + "node_modules/shellwords": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", From 7289cd693579f3c936f75109741310a2360d9b3f Mon Sep 17 00:00:00 2001 From: acethecreator Date: Wed, 26 Nov 2025 17:08:15 +0100 Subject: [PATCH 10/13] removed unused console --- library/src/components/PluginSlotRenderer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/library/src/components/PluginSlotRenderer.tsx b/library/src/components/PluginSlotRenderer.tsx index ef6d88320..4085201e8 100644 --- a/library/src/components/PluginSlotRenderer.tsx +++ b/library/src/components/PluginSlotRenderer.tsx @@ -20,7 +20,6 @@ const SlotRenderer: React.FC = ({ const components = pluginManager.getComponentsForSlot(slot); if (!components || components.length === 0) { - console.log('no component detected here'); return null; } From 842de9466c95831f605484a81fce750fa072dc8c Mon Sep 17 00:00:00 2001 From: acethecreator Date: Wed, 26 Nov 2025 17:19:05 +0100 Subject: [PATCH 11/13] trying custom cypress timeout to fix test failure --- library/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/package.json b/library/package.json index f4803a809..99d865d25 100644 --- a/library/package.json +++ b/library/package.json @@ -58,8 +58,8 @@ "test": "npm run test:unit && npm run test:e2e", "test:unit": "jest --detectOpenHandles", "test:unit:watch": "jest --detectOpenHandles --watch", - "test:e2e:dev": "cypress run", - "test:e2e": "npm run build:standalone && cypress run", + "test:e2e:dev": "cross-env CYPRESS_VERIFY_TIMEOUT=100000 cypress run", + "test:e2e": "npm run build:standalone && cross-env CYPRESS_VERIFY_TIMEOUT=100000 cypress run", "prepare": "npm run build:dev", "prepublishOnly": "npm run prepack", "prepack": "npm run build:prod && cp ../README.md ./README.md && cp ../LICENSE ./LICENSE", From bb03e4c1f588e71dd290af8f5d250554fd95b9a3 Mon Sep 17 00:00:00 2001 From: acethecreator Date: Wed, 26 Nov 2025 17:26:16 +0100 Subject: [PATCH 12/13] revert cypress timeout --- library/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/package.json b/library/package.json index 99d865d25..f4803a809 100644 --- a/library/package.json +++ b/library/package.json @@ -58,8 +58,8 @@ "test": "npm run test:unit && npm run test:e2e", "test:unit": "jest --detectOpenHandles", "test:unit:watch": "jest --detectOpenHandles --watch", - "test:e2e:dev": "cross-env CYPRESS_VERIFY_TIMEOUT=100000 cypress run", - "test:e2e": "npm run build:standalone && cross-env CYPRESS_VERIFY_TIMEOUT=100000 cypress run", + "test:e2e:dev": "cypress run", + "test:e2e": "npm run build:standalone && cypress run", "prepare": "npm run build:dev", "prepublishOnly": "npm run prepack", "prepack": "npm run build:prod && cp ../README.md ./README.md && cp ../LICENSE ./LICENSE", From e119e052eca63e361d1dcd2c4d1690ec07356900 Mon Sep 17 00:00:00 2001 From: acethecreator Date: Wed, 3 Dec 2025 23:40:20 +0100 Subject: [PATCH 13/13] chore: trigger CI rebuild