Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: custom extension rendering #994

Open
wants to merge 31 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
db17606
Custom extension value render
Apr 19, 2024
a52c230
Merge branch 'master' into feat/custom-extension-value-render
asyncapi-bot Apr 24, 2024
22f07b3
Add parent to ExtensionComponentProps and move changes to Extensions …
Apr 25, 2024
18654d2
Merge remote-tracking branch 'origin/feat/custom-extension-value-rend…
Apr 25, 2024
9b978b8
Address Sonar issue
Apr 25, 2024
de451e8
Merge branch 'master' into feat/custom-extension-value-render
ductaily Apr 30, 2024
13a8a6c
Apply suggestions from code review
ductaily Apr 30, 2024
23401a9
Clean up Extension class
Apr 30, 2024
6c4f19f
Merge branch 'refs/heads/master' into feat/custom-extension-value-render
May 2, 2024
37bc952
Merge branch 'master' into feat/custom-extension-value-render
ductaily May 2, 2024
3b19209
Merge remote-tracking branch 'origin/feat/custom-extension-value-rend…
May 2, 2024
95bfa86
Merge branch 'master' into feat/custom-extension-value-render
derberg May 13, 2024
8492e7a
Merge branch 'master' into feat/custom-extension-value-render
asyncapi-bot May 13, 2024
389e407
Merge remote-tracking branch 'origin/feat/custom-extension-value-rend…
May 13, 2024
973c1da
Address eslint errors
May 13, 2024
4b03c14
Fix handling of concatenatedConfig for extensions
May 21, 2024
3b548f5
Add config modification docs
May 21, 2024
cae5861
Add test
May 22, 2024
8caea6e
Add example for x-x extension
May 22, 2024
f986398
Add viewBox to x logo
May 22, 2024
979777d
Merge branch 'master' into feat/custom-extension-value-render
ductaily Jun 12, 2024
a34f50a
Update package-lock.json
Jun 19, 2024
32cf262
Fix lint error
Jun 19, 2024
c630f89
Merge branch 'master' into feat/custom-extension-value-render
pavelkornev Jun 19, 2024
eed70c4
Merge branch 'refs/heads/master' into feat/custom-extension-value-render
Jul 23, 2024
350646a
Merge remote-tracking branch 'origin/feat/custom-extension-value-rend…
Jul 23, 2024
d91df2a
Merge branch 'master' into feat/custom-extension-value-render
ductaily Jul 31, 2024
3e57a3f
Add x-x extensions example.
Jul 31, 2024
1740980
Merge branch 'master' into feat/custom-extension-value-render
asyncapi-bot Jul 31, 2024
361a2c2
Merge branch 'master' into feat/custom-extension-value-render
ductaily Sep 2, 2024
aa7423b
Merge branch 'master' into feat/custom-extension-value-render
pavelkornev Sep 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion docs/configuration/config-modification.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface ConfigInterface {
receiveLabel?: string;
requestLabel?: string;
replyLabel?: string;
extensions?: Record<string, React.ComponentType<ExtensionComponentProps>>;
}
```

Expand Down Expand Up @@ -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<string, React.ComponentType<ExtensionComponentProps>>**

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.
Expand All @@ -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";

Expand All @@ -126,13 +133,28 @@ const config: ConfigInterface = {
expand: {
messageExamples: false,
},
extensions: {
'x-custom-extension': CustomExtension
}
};

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

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

```tsx
// CustomExtension.tsx
import { ExtensionComponentProps } from '@asyncapi/react-component/lib/types/components/Extensions';

export default function CustomExtension(props: ExtensionComponentProps<string>) {
return <div>
<h1>{props.propertyName}</h1>
<p>{props.propertyValue}</p>
</div>
}
```

### JavaScript

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

```jsx
// CustomExtension.jsx
export default function CustomExtension(props) {
return <div>
<h1>{props.propertyName}</h1>
<p>{props.propertyValue}</p>
</div>
}
```

In the above examples, after concatenation with the default configuration, the resulting configuration looks as follows:

```js
Expand All @@ -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
}
}
```
51 changes: 51 additions & 0 deletions library/src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => (
<div id="custom-extension">{props.propertyValue}</div>
);

const result = render(
<AsyncApiComponent
schema={schema}
config={{
extensions: {
'x-custom-extension': CustomExtension,
},
}}
/>,
);

await waitFor(() => {
expect(result.container.querySelector('#introduction')).toBeDefined();
expect(result.container.querySelector('#custom-extension')).toBeDefined();
});
});
});
85 changes: 79 additions & 6 deletions library/src/components/Extensions.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,107 @@
/* 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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
item: any;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface ExtensionComponentProps<V = any> {
propertyName: string;
propertyValue: V;
document: AsyncAPIDocumentInterface;
parent: BaseModel;
}

export const Extensions: React.FunctionComponent<Props> = ({
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 && (
<div className="mt-2">
<Schema schemaName={name} schema={schema} onlyTitle={true} />
</div>
)
);
}

return (
schema && (
<div className="mt-2">
<Schema schemaName={name} schema={schema} onlyTitle />
<div>
<div className="flex py-2">
<div className="min-w-1/4">
<>
<CollapseButton
onClick={() => setExpanded((prev) => !prev)}
expanded={expanded}
>
<span className={`break-anywhere text-sm ${name}`}>{name}</span>
</CollapseButton>
<button
type="button"
onClick={() => setDeepExpand((prev) => !prev)}
className="ml-1 text-sm text-gray-500"
>
{deepExpand ? 'Collapse all' : 'Expand all'}
</button>
</>
</div>
</div>
<div
className={`rounded p-4 py-2 border bg-gray-100 ${expanded ? 'block' : 'hidden'}`}
>
{Object.keys(extensions)
.sort((extension1, extension2) =>
extension1.localeCompare(extension2),
)
.map((extensionKey) => {
if (config.extensions?.[extensionKey]) {
const CustomExtensionComponent = config.extensions[extensionKey];
return (
<CustomExtensionComponent
key={extensionKey}
propertyName={extensionKey}
propertyValue={extensions[extensionKey]}
document={document}
parent={item}
/>
);
} else {
const extensionSchema = SchemaHelpers.jsonToSchema(
extensions[extensionKey],
);
return (
<div key={extensionKey} className="mt-2">
<Schema schemaName={extensionKey} schema={extensionSchema} />
</div>
);
}
})}
</div>
)
</div>
);
};
35 changes: 35 additions & 0 deletions library/src/components/supportedExtensions/XExtension.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { ExtensionComponentProps } from '../Extensions';

/**
* See <https://github.com/asyncapi/extensions-catalog/blob/master/extensions/x.md>.
*/
export default function XExtension({
propertyValue,
}: ExtensionComponentProps<string>) {
const onClickHandler = () => {
window.open(`https://x.com/${propertyValue}`, '_blank');
};

return (
<div
title={`https://x.com/${propertyValue}`}
style={{ display: 'inline-block' }}
>
<svg
onClick={onClickHandler}
style={{ cursor: 'pointer' }}
width="15px"
height="15px"
viewBox="0 0 1200 1227"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
fill="black"
/>
</svg>
</div>
);
}
3 changes: 3 additions & 0 deletions library/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ExtensionComponentProps } from '../components';

export interface ConfigInterface {
schemaID?: string;
show?: ShowConfig;
Expand All @@ -11,6 +13,7 @@ export interface ConfigInterface {
receiveLabel?: string;
requestLabel?: string;
replyLabel?: string;
extensions?: Record<string, React.ComponentType<ExtensionComponentProps>>;
}

export interface ShowConfig {
Expand Down
4 changes: 4 additions & 0 deletions library/src/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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,
},
};
4 changes: 4 additions & 0 deletions library/src/containers/AsyncApi/Standalone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ class AsyncApiComponent extends Component<AsyncApiProps, AsyncAPIState> {
...defaultConfig.sidebar,
...(!!config && config.sidebar),
},
extensions: {
...defaultConfig.extensions,
...(!!config && config.extensions),
},
};

if (!asyncapi) {
Expand Down
1 change: 1 addition & 0 deletions playground/specs/streetlights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading