diff --git a/docs/configuration/config-modification.md b/docs/configuration/config-modification.md index 3eb36a4cc..dce729574 100644 --- a/docs/configuration/config-modification.md +++ b/docs/configuration/config-modification.md @@ -36,6 +36,7 @@ interface ConfigInterface { receiveLabel?: string; requestLabel?: string; replyLabel?: string; + extensions?: Record>; } ``` @@ -100,6 +101,11 @@ interface ConfigInterface { This field contains configuration responsible for customizing the label for response operation. This takes effect when rendering AsyncAPI v3 documents. This field is set to `REPLY` by default. +- **extensions?: Record>** + + This field contains configuration responsible for adding custom extension components. + This field will contain default components. + ## Examples See exemplary component configuration in TypeScript and JavaScript. @@ -110,6 +116,7 @@ See exemplary component configuration in TypeScript and JavaScript. import * as React from "react"; import { render } from "react-dom"; import AsyncAPIComponent, { ConfigInterface } from "@asyncapi/react-component"; +import CustomExtension from "./CustomExtension"; import { schema } from "./mock"; @@ -126,6 +133,9 @@ const config: ConfigInterface = { expand: { messageExamples: false, }, + extensions: { + 'x-custom-extension': CustomExtension + } }; const App = () => ; @@ -133,6 +143,18 @@ const App = () => ; render(, document.getElementById("root")); ``` +```tsx +// CustomExtension.tsx +import { ExtensionComponentProps } from '@asyncapi/react-component/lib/types/components/Extensions'; + +export default function CustomExtension(props: ExtensionComponentProps) { + return
+

{props.propertyName}

+

{props.propertyValue}

+
+} +``` + ### JavaScript ```jsx @@ -162,6 +184,16 @@ const App = () => ; render(, document.getElementById("root")); ``` +```jsx +// CustomExtension.jsx +export default function CustomExtension(props) { + return
+

{props.propertyName}

+

{props.propertyValue}

+
+} +``` + In the above examples, after concatenation with the default configuration, the resulting configuration looks as follows: ```js @@ -188,6 +220,10 @@ In the above examples, after concatenation with the default configuration, the r sendLabel: 'SEND', receiveLabel: 'RECEIVE', requestLabel: 'REQUEST', - replyLabel: 'REPLY' + replyLabel: 'REPLY', + extensions: { + // default extensions... + 'x-custom-extension': CustomExtension + } } ``` diff --git a/library/src/__tests__/index.test.tsx b/library/src/__tests__/index.test.tsx index 3bb01e8b3..ab4ea610b 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 { ExtensionComponentProps } from '../components'; jest.mock('use-resize-observer', () => ({ __esModule: true, @@ -216,4 +217,54 @@ describe('AsyncAPI component', () => { expect(result.container.querySelector('#introduction')).toBeDefined(), ); }); + + test('should work with custom extensions', async () => { + const schema = { + asyncapi: '2.0.0', + info: { + title: 'Example AsyncAPI', + version: '0.1.0', + }, + channels: { + 'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured': + { + subscribe: { + message: { + $ref: '#/components/messages/lightMeasured', + }, + }, + }, + }, + components: { + messages: { + lightMeasured: { + name: 'lightMeasured', + title: 'Light measured', + contentType: 'application/json', + 'x-custom-extension': 'Custom extension value', + }, + }, + }, + }; + + const CustomExtension = (props: ExtensionComponentProps) => ( +
{props.propertyValue}
+ ); + + const result = render( + , + ); + + await waitFor(() => { + expect(result.container.querySelector('#introduction')).toBeDefined(); + expect(result.container.querySelector('#custom-extension')).toBeDefined(); + }); + }); }); diff --git a/library/src/components/Extensions.tsx b/library/src/components/Extensions.tsx index b98d44c00..0ca9db69f 100644 --- a/library/src/components/Extensions.tsx +++ b/library/src/components/Extensions.tsx @@ -1,11 +1,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import React from 'react'; +import React, { useState } from 'react'; import { Schema } from './Schema'; import { SchemaHelpers } from '../helpers'; +import { AsyncAPIDocumentInterface, BaseModel } from '@asyncapi/parser'; +import { useConfig, useSpec } from '../contexts'; +import { CollapseButton } from './CollapseButton'; interface Props { name?: string; @@ -13,22 +16,92 @@ interface Props { item: any; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface ExtensionComponentProps { + propertyName: string; + propertyValue: V; + document: AsyncAPIDocumentInterface; + parent: BaseModel; +} + export const Extensions: React.FunctionComponent = ({ name = 'Extensions', item, }) => { + const [expanded, setExpanded] = useState(false); + const [deepExpand, setDeepExpand] = useState(false); + + const config = useConfig(); + const document = useSpec(); + const extensions = SchemaHelpers.getCustomExtensions(item); if (!extensions || !Object.keys(extensions).length) { return null; } - const schema = SchemaHelpers.jsonToSchema(extensions); + if (!config.extensions || !Object.keys(config.extensions).length) { + const schema = SchemaHelpers.jsonToSchema(extensions); + return ( + schema && ( +
+ +
+ ) + ); + } return ( - schema && ( -
- +
+
+
+ <> + setExpanded((prev) => !prev)} + expanded={expanded} + > + {name} + + + +
+
+
+ {Object.keys(extensions) + .sort((extension1, extension2) => + extension1.localeCompare(extension2), + ) + .map((extensionKey) => { + if (config.extensions?.[extensionKey]) { + const CustomExtensionComponent = config.extensions[extensionKey]; + return ( + + ); + } else { + const extensionSchema = SchemaHelpers.jsonToSchema( + extensions[extensionKey], + ); + return ( +
+ +
+ ); + } + })}
- ) +
); }; diff --git a/library/src/components/supportedExtensions/XExtension.tsx b/library/src/components/supportedExtensions/XExtension.tsx new file mode 100644 index 000000000..bc74e945f --- /dev/null +++ b/library/src/components/supportedExtensions/XExtension.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { ExtensionComponentProps } from '../Extensions'; + +/** + * See . + */ +export default function XExtension({ + propertyValue, +}: ExtensionComponentProps) { + const onClickHandler = () => { + window.open(`https://x.com/${propertyValue}`, '_blank'); + }; + + return ( +
+ + + +
+ ); +} diff --git a/library/src/config/config.ts b/library/src/config/config.ts index 9ad84af18..354eff869 100644 --- a/library/src/config/config.ts +++ b/library/src/config/config.ts @@ -1,3 +1,5 @@ +import { ExtensionComponentProps } from '../components'; + export interface ConfigInterface { schemaID?: string; show?: ShowConfig; @@ -11,6 +13,7 @@ export interface ConfigInterface { receiveLabel?: string; requestLabel?: string; replyLabel?: string; + extensions?: Record>; } export interface ShowConfig { diff --git a/library/src/config/default.ts b/library/src/config/default.ts index 12e6dffa6..26025bba0 100644 --- a/library/src/config/default.ts +++ b/library/src/config/default.ts @@ -7,6 +7,7 @@ import { SEND_LABEL_DEFAULT_TEXT, SUBSCRIBE_LABEL_DEFAULT_TEXT, } from '../constants'; +import XExtension from '../components/supportedExtensions/XExtension'; export const defaultConfig: ConfigInterface = { schemaID: '', @@ -33,4 +34,7 @@ export const defaultConfig: ConfigInterface = { receiveLabel: RECEIVE_TEXT_LABEL_DEFAULT_TEXT, requestLabel: REQUEST_LABEL_DEFAULT_TEXT, replyLabel: REPLIER_LABEL_DEFAULT_TEXT, + extensions: { + 'x-x': XExtension, + }, }; diff --git a/library/src/containers/AsyncApi/Standalone.tsx b/library/src/containers/AsyncApi/Standalone.tsx index 658aa9b4e..e441afd6a 100644 --- a/library/src/containers/AsyncApi/Standalone.tsx +++ b/library/src/containers/AsyncApi/Standalone.tsx @@ -69,6 +69,10 @@ class AsyncApiComponent extends Component { ...defaultConfig.sidebar, ...(!!config && config.sidebar), }, + extensions: { + ...defaultConfig.extensions, + ...(!!config && config.extensions), + }, }; if (!asyncapi) { diff --git a/playground/specs/streetlights.ts b/playground/specs/streetlights.ts index 519aa26d2..44563ec3c 100644 --- a/playground/specs/streetlights.ts +++ b/playground/specs/streetlights.ts @@ -136,6 +136,7 @@ servers: channels: smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured: + x-x: AsyncAPISpec x-security: $ref: '#/components/securitySchemes/supportedOauthFlows/flows/clientCredentials' description: The topic on which measured values may be produced and consumed.