Skip to content

Commit 70ad009

Browse files
ductailyDuc Tai Lyasyncapi-botmagicmatatjahuderberg
authored
feat: custom extension rendering (#994)
* Custom extension value render * Add parent to ExtensionComponentProps and move changes to Extensions component * Address Sonar issue * Apply suggestions from code review Co-authored-by: Maciej Urbańczyk <[email protected]> * Clean up Extension class * Address eslint errors * Fix handling of concatenatedConfig for extensions * Add config modification docs * Add test * Add example for x-x extension * Add viewBox to x logo * Update package-lock.json * Fix lint error * Add x-x extensions example. * Add x-x extensions example in info component * Address review comments * Address comments, use anchor tag and noopener, norefferer * Remove unused code --------- Co-authored-by: Duc Tai Ly <[email protected]> Co-authored-by: asyncapi-bot <[email protected]> Co-authored-by: Maciej Urbańczyk <[email protected]> Co-authored-by: Lukasz Gornicki <[email protected]> Co-authored-by: Pavel Kornev <[email protected]> Co-authored-by: Cody's Dad <[email protected]>
1 parent 67c321b commit 70ad009

File tree

11 files changed

+215
-11
lines changed

11 files changed

+215
-11
lines changed

docs/configuration/config-modification.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface ConfigInterface {
3636
receiveLabel?: string;
3737
requestLabel?: string;
3838
replyLabel?: string;
39+
extensions?: Record<string, React.ComponentType<ExtensionComponentProps>>;
3940
}
4041
```
4142

@@ -100,6 +101,11 @@ interface ConfigInterface {
100101
This field contains configuration responsible for customizing the label for response operation. This takes effect when rendering AsyncAPI v3 documents.
101102
This field is set to `REPLY` by default.
102103

104+
- **extensions?: Record<string, React.ComponentType<ExtensionComponentProps>>**
105+
106+
This field contains configuration responsible for adding custom extension components.
107+
This field will contain default components.
108+
103109
## Examples
104110

105111
See exemplary component configuration in TypeScript and JavaScript.
@@ -110,6 +116,7 @@ See exemplary component configuration in TypeScript and JavaScript.
110116
import * as React from "react";
111117
import { render } from "react-dom";
112118
import AsyncAPIComponent, { ConfigInterface } from "@asyncapi/react-component";
119+
import CustomExtension from "./CustomExtension";
113120

114121
import { schema } from "./mock";
115122

@@ -126,13 +133,28 @@ const config: ConfigInterface = {
126133
expand: {
127134
messageExamples: false,
128135
},
136+
extensions: {
137+
'x-custom-extension': CustomExtension
138+
}
129139
};
130140

131141
const App = () => <AsyncAPIComponent schema={schema} config={config} />;
132142

133143
render(<App />, document.getElementById("root"));
134144
```
135145

146+
```tsx
147+
// CustomExtension.tsx
148+
import { ExtensionComponentProps } from '@asyncapi/react-component/lib/types/components/Extensions';
149+
150+
export default function CustomExtension(props: ExtensionComponentProps<string>) {
151+
return <div>
152+
<h1>{props.propertyName}</h1>
153+
<p>{props.propertyValue}</p>
154+
</div>
155+
}
156+
```
157+
136158
### JavaScript
137159

138160
```jsx
@@ -162,6 +184,16 @@ const App = () => <AsyncAPIComponent schema={schema} config={config} />;
162184
render(<App />, document.getElementById("root"));
163185
```
164186

187+
```jsx
188+
// CustomExtension.jsx
189+
export default function CustomExtension(props) {
190+
return <div>
191+
<h1>{props.propertyName}</h1>
192+
<p>{props.propertyValue}</p>
193+
</div>
194+
}
195+
```
196+
165197
In the above examples, after concatenation with the default configuration, the resulting configuration looks as follows:
166198

167199
```js
@@ -188,6 +220,10 @@ In the above examples, after concatenation with the default configuration, the r
188220
sendLabel: 'SEND',
189221
receiveLabel: 'RECEIVE',
190222
requestLabel: 'REQUEST',
191-
replyLabel: 'REPLY'
223+
replyLabel: 'REPLY',
224+
extensions: {
225+
// default extensions...
226+
'x-custom-extension': CustomExtension
227+
}
192228
}
193229
```

library/src/__tests__/index.test.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import React from 'react';
88
import { render, waitFor } from '@testing-library/react';
9-
import AsyncApiComponent from '..';
9+
import AsyncApiComponent, { ExtensionComponentProps } from '..';
1010
import adoaKafka from './docs/v3/adeo-kafka-request-reply.json';
1111
import krakenMessageFilter from './docs/v3/kraken-websocket-request-reply-message-filter-in-reply.json';
1212
import krakenMultipleChannels from './docs/v3/kraken-websocket-request-reply-multiple-channels.json';
@@ -216,4 +216,54 @@ describe('AsyncAPI component', () => {
216216
expect(result.container.querySelector('#introduction')).toBeDefined(),
217217
);
218218
});
219+
220+
test('should work with custom extensions', async () => {
221+
const schema = {
222+
asyncapi: '2.0.0',
223+
info: {
224+
title: 'Example AsyncAPI',
225+
version: '0.1.0',
226+
},
227+
channels: {
228+
'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured':
229+
{
230+
subscribe: {
231+
message: {
232+
$ref: '#/components/messages/lightMeasured',
233+
},
234+
},
235+
},
236+
},
237+
components: {
238+
messages: {
239+
lightMeasured: {
240+
name: 'lightMeasured',
241+
title: 'Light measured',
242+
contentType: 'application/json',
243+
'x-custom-extension': 'Custom extension value',
244+
},
245+
},
246+
},
247+
};
248+
249+
const CustomExtension = (props: ExtensionComponentProps) => (
250+
<div id="custom-extension">{props.propertyValue}</div>
251+
);
252+
253+
const result = render(
254+
<AsyncApiComponent
255+
schema={schema}
256+
config={{
257+
extensions: {
258+
'x-custom-extension': CustomExtension,
259+
},
260+
}}
261+
/>,
262+
);
263+
264+
await waitFor(() => {
265+
expect(result.container.querySelector('#introduction')).toBeDefined();
266+
expect(result.container.querySelector('#custom-extension')).toBeDefined();
267+
});
268+
});
219269
});

library/src/components/Extensions.tsx

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-return */
22
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
33

4-
import React from 'react';
4+
import React, { useState } from 'react';
55

66
import { Schema } from './Schema';
77

88
import { SchemaHelpers } from '../helpers';
9+
import { useConfig, useSpec } from '../contexts';
10+
import { CollapseButton } from './CollapseButton';
911

1012
interface Props {
1113
name?: string;
@@ -17,18 +19,72 @@ export const Extensions: React.FunctionComponent<Props> = ({
1719
name = 'Extensions',
1820
item,
1921
}) => {
22+
const [expanded, setExpanded] = useState(false);
23+
24+
const config = useConfig();
25+
const document = useSpec();
26+
2027
const extensions = SchemaHelpers.getCustomExtensions(item);
2128
if (!extensions || !Object.keys(extensions).length) {
2229
return null;
2330
}
2431

25-
const schema = SchemaHelpers.jsonToSchema(extensions);
32+
if (!config.extensions || !Object.keys(config.extensions).length) {
33+
const schema = SchemaHelpers.jsonToSchema(extensions);
34+
return (
35+
schema && (
36+
<div className="mt-2">
37+
<Schema schemaName={name} schema={schema} onlyTitle={true} />
38+
</div>
39+
)
40+
);
41+
}
2642

2743
return (
28-
schema && (
29-
<div className="mt-2">
30-
<Schema schemaName={name} schema={schema} onlyTitle />
44+
<div>
45+
<div className="flex py-2">
46+
<div className="min-w-1/4">
47+
<>
48+
<CollapseButton
49+
onClick={() => setExpanded((prev) => !prev)}
50+
expanded={expanded}
51+
>
52+
<span className={`break-anywhere text-sm ${name}`}>{name}</span>
53+
</CollapseButton>
54+
</>
55+
</div>
56+
</div>
57+
<div
58+
className={`rounded p-4 py-2 border bg-gray-100 ${expanded ? 'block' : 'hidden'}`}
59+
>
60+
{Object.keys(extensions)
61+
.sort((extension1, extension2) =>
62+
extension1.localeCompare(extension2),
63+
)
64+
.map((extensionKey) => {
65+
if (config.extensions?.[extensionKey]) {
66+
const CustomExtensionComponent = config.extensions[extensionKey];
67+
return (
68+
<CustomExtensionComponent
69+
key={extensionKey}
70+
propertyName={extensionKey}
71+
propertyValue={extensions[extensionKey]}
72+
document={document}
73+
parent={item}
74+
/>
75+
);
76+
} else {
77+
const extensionSchema = SchemaHelpers.jsonToSchema(
78+
extensions[extensionKey],
79+
);
80+
return (
81+
<div key={extensionKey} className="mt-2">
82+
<Schema schemaName={extensionKey} schema={extensionSchema} />
83+
</div>
84+
);
85+
}
86+
})}
3187
</div>
32-
)
88+
</div>
3389
);
3490
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React from 'react';
2+
3+
import { ExtensionComponentProps } from '../../types';
4+
5+
/**
6+
* See <https://github.com/asyncapi/extensions-catalog/blob/master/extensions/x.md>.
7+
*/
8+
export default function XExtension({
9+
propertyValue,
10+
}: ExtensionComponentProps<string>) {
11+
return (
12+
<a
13+
title={`https://x.com/${propertyValue}`}
14+
style={{ display: 'inline-block' }}
15+
href={`https://x.com/${propertyValue}`}
16+
rel="noopener noreferrer"
17+
target="_blank"
18+
>
19+
<svg
20+
// onClick={onClickHandler}
21+
style={{ cursor: 'pointer' }}
22+
width="15px"
23+
height="15px"
24+
viewBox="0 0 1200 1227"
25+
fill="none"
26+
xmlns="http://www.w3.org/2000/svg"
27+
>
28+
<path
29+
d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z"
30+
fill="black"
31+
/>
32+
</svg>
33+
</a>
34+
);
35+
}

library/src/config/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ExtensionComponentProps } from '../types';
2+
13
export interface ConfigInterface {
24
schemaID?: string;
35
show?: ShowConfig;
@@ -11,6 +13,7 @@ export interface ConfigInterface {
1113
receiveLabel?: string;
1214
requestLabel?: string;
1315
replyLabel?: string;
16+
extensions?: Record<string, React.ComponentType<ExtensionComponentProps>>;
1417
}
1518

1619
export interface ShowConfig {

library/src/config/default.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
SEND_LABEL_DEFAULT_TEXT,
88
SUBSCRIBE_LABEL_DEFAULT_TEXT,
99
} from '../constants';
10+
import XExtension from '../components/supportedExtensions/XExtension';
1011

1112
export const defaultConfig: ConfigInterface = {
1213
schemaID: '',
@@ -33,4 +34,7 @@ export const defaultConfig: ConfigInterface = {
3334
receiveLabel: RECEIVE_TEXT_LABEL_DEFAULT_TEXT,
3435
requestLabel: REQUEST_LABEL_DEFAULT_TEXT,
3536
replyLabel: REPLIER_LABEL_DEFAULT_TEXT,
37+
extensions: {
38+
'x-x': XExtension,
39+
},
3640
};

library/src/containers/AsyncApi/Standalone.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ class AsyncApiComponent extends Component<AsyncApiProps, AsyncAPIState> {
6969
...defaultConfig.sidebar,
7070
...(!!config && config.sidebar),
7171
},
72+
extensions: {
73+
...defaultConfig.extensions,
74+
...(!!config && config.extensions),
75+
},
7276
};
7377

7478
if (!asyncapi) {

library/src/containers/Info/Info.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22

3-
import { Href, Markdown, Tags } from '../../components';
3+
import { Extensions, Href, Markdown, Tags } from '../../components';
44
import { useSpec } from '../../contexts';
55
import {
66
TERMS_OF_SERVICE_TEXT,
@@ -23,6 +23,7 @@ export const Info: React.FunctionComponent = () => {
2323
const termsOfService = info.termsOfService();
2424
const defaultContentType = asyncapi.defaultContentType();
2525
const contact = info.contact();
26+
const extensions = info.extensions();
2627

2728
const showInfoList =
2829
license ?? termsOfService ?? defaultContentType ?? contact ?? externalDocs;
@@ -128,6 +129,12 @@ export const Info: React.FunctionComponent = () => {
128129
<Tags tags={asyncapi.info().tags()} />
129130
</div>
130131
)}
132+
133+
{extensions.length > 0 && (
134+
<div className="mt-4">
135+
<Extensions name="Info Extensions" item={info} />
136+
</div>
137+
)}
131138
</div>
132139

133140
<div className="panel-item--right" />

library/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import AsyncApiComponentWP from './containers/AsyncApi/Standalone';
33

44
export { AsyncApiProps } from './containers/AsyncApi/AsyncApi';
55
export { ConfigInterface } from './config/config';
6-
export { FetchingSchemaInterface } from './types';
6+
export { FetchingSchemaInterface, ExtensionComponentProps } from './types';
77

88
import { hljs } from './helpers';
99

library/src/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AsyncAPIDocumentInterface } from '@asyncapi/parser';
1+
import { AsyncAPIDocumentInterface, BaseModel } from '@asyncapi/parser';
22

33
export type PropsSchema =
44
| string
@@ -77,3 +77,11 @@ export interface ErrorObject {
7777
endOffset: number;
7878
}[];
7979
}
80+
81+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
82+
export interface ExtensionComponentProps<V = any> {
83+
propertyName: string;
84+
propertyValue: V;
85+
document: AsyncAPIDocumentInterface;
86+
parent: BaseModel;
87+
}

0 commit comments

Comments
 (0)