AnnotationList: Support html content (#54916)

* support html content in annolistpanel

* improve panel tests

* add RenderUserContentAsHTML ui

* sanitize content
This commit is contained in:
Leo 2022-10-11 13:35:03 +02:00 committed by GitHub
parent 6969354490
commit a91d77003d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 125 additions and 7 deletions

View File

@ -0,0 +1,22 @@
import { Props } from '@storybook/addon-docs/blocks';
import { RenderUserContentAsHTML } from './RenderUserContentAsHTML';
<Meta title="MDX|RenderUserContentAsHTML" component={RenderUserContentAsHTML} />
# RenderUserContentAsHTML
Abstraction layer component for sanitizing and rendering an html content.
### When to use
This should be use as replacement for `dangerouslySetInnerHTML` as this centralizes the sanitation of contents over the app.
### Usage
```jsx
<RenderUserContentAsHTML content="sample content" />
```
### Props
<Props of={RenderUserContentAsHTML} />

View File

@ -0,0 +1,33 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { RenderUserContentAsHTML } from './RenderUserContentAsHTML';
import mdx from './RenderUserContentAsHTML.mdx';
const meta: ComponentMeta<typeof RenderUserContentAsHTML> = {
title: 'General/RenderUserContentAsHTML',
component: RenderUserContentAsHTML,
parameters: {
docs: {
page: mdx,
},
},
argTypes: {
content: {
control: { type: 'text' },
},
component: {
control: { type: 'text' },
},
},
};
export const Basic: ComponentStory<typeof RenderUserContentAsHTML> = (props) => {
return <RenderUserContentAsHTML {...props} />;
};
Basic.args = {
content: '<a href="#">sample html anchor tag link</a>',
};
export default meta;

View File

@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { RenderUserContentAsHTML } from './RenderUserContentAsHTML';
describe('RenderUserContentAsHTML', () => {
it('should render html content', () => {
render(<RenderUserContentAsHTML content='<a href="#">sample content</a>' />);
expect(screen.getByRole('link', { name: /sample content/ })).toBeInTheDocument();
});
it('should render a raw string content', () => {
render(<RenderUserContentAsHTML content="sample content" />);
expect(screen.getByText(/sample content/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,20 @@
import React, { HTMLAttributes, PropsWithChildren } from 'react';
import { textUtil } from '@grafana/data';
export interface RenderUserContentAsHTMLProps<T = HTMLSpanElement>
extends Omit<HTMLAttributes<T>, 'dangerouslySetInnerHTML'> {
component?: keyof React.ReactHTML;
content: string;
}
export function RenderUserContentAsHTML<T>({
component,
content,
...rest
}: PropsWithChildren<RenderUserContentAsHTMLProps<T>>): JSX.Element {
return React.createElement(component || 'span', {
dangerouslySetInnerHTML: { __html: textUtil.sanitize(content) },
...rest,
});
}

View File

@ -84,6 +84,7 @@ export { Tab } from './Tabs/Tab';
export { VerticalTab } from './Tabs/VerticalTab';
export { TabContent } from './Tabs/TabContent';
export { Counter } from './Tabs/Counter';
export { RenderUserContentAsHTML } from './RenderUserContentAsHTML/RenderUserContentAsHTML';
// Visualizations
export {

View File

@ -132,6 +132,16 @@ describe('AnnoListPanel', () => {
expect(screen.getByText(/2021-01-01T00:00:00.000Z/i)).toBeInTheDocument();
});
it("renders annotation item's html content", async () => {
const { getMock } = await setupTestContext({
results: [{ ...defaultResult, text: '<a href="">test link </a> ' }],
});
getMock.mockClear();
expect(screen.getByRole('link')).toBeInTheDocument();
expect(getMock).not.toHaveBeenCalled();
});
describe('and login property is missing in annotation', () => {
it('then it renders the annotations correctly', async () => {
await setupTestContext({ results: [{ ...defaultResult, login: undefined }] });
@ -203,8 +213,8 @@ describe('AnnoListPanel', () => {
const { getMock, pushSpy } = await setupTestContext();
getMock.mockClear();
expect(screen.getByText(/result text/i)).toBeInTheDocument();
await userEvent.click(screen.getByText(/result text/i));
expect(screen.getByRole('button', { name: /result text/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /result text/i }));
await waitFor(() => expect(getMock).toHaveBeenCalledTimes(1));
expect(getMock).toHaveBeenCalledWith('/api/search', { dashboardUIDs: '7MeksYbmk' });
@ -218,8 +228,9 @@ describe('AnnoListPanel', () => {
const { getMock } = await setupTestContext();
getMock.mockClear();
expect(screen.getByText('Result tag B')).toBeInTheDocument();
await userEvent.click(screen.getByText('Result tag B'));
expect(screen.getByRole('button', { name: /result tag b/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /result tag b/i }));
expect(getMock).toHaveBeenCalledTimes(1);
expect(getMock).toHaveBeenCalledWith(

View File

@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import React, { FC, MouseEvent } from 'react';
import { AnnotationEvent, DateTimeInput, GrafanaTheme2, PanelProps } from '@grafana/data';
import { Card, TagList, Tooltip, useStyles2 } from '@grafana/ui';
import { Card, TagList, Tooltip, RenderUserContentAsHTML, useStyles2 } from '@grafana/ui';
import { PanelOptions } from './models.gen';
@ -24,7 +24,7 @@ export const AnnotationListItem: FC<Props> = ({
}) => {
const styles = useStyles2(getStyles);
const { showUser, showTags, showTime } = options;
const { text, login, email, avatarUrl, tags, time, timeEnd } = annotation;
const { text = '', login, email, avatarUrl, tags, time, timeEnd } = annotation;
const onItemClick = () => {
onClick(annotation);
};
@ -38,7 +38,13 @@ export const AnnotationListItem: FC<Props> = ({
return (
<Card className={styles.card} onClick={onItemClick}>
<Card.Heading>
<span>{text}</span>
<RenderUserContentAsHTML
className={styles.heading}
onClick={(e) => {
e.stopPropagation();
}}
content={text}
/>
</Card.Heading>
{showTimeStamp && (
<Card.Description className={styles.timestamp}>
@ -118,6 +124,16 @@ function getStyles(theme: GrafanaTheme2) {
margin: theme.spacing(0.5),
width: 'inherit',
}),
heading: css({
a: {
zIndex: 1,
position: 'relative',
color: theme.colors.text.link,
'&:hover': {
textDecoration: 'underline',
},
},
}),
meta: css({
margin: 0,
position: 'relative',