From a91d77003d1db2477ecdb43f66c62051fbea2f28 Mon Sep 17 00:00:00 2001
From: Leo <108552997+lpskdl@users.noreply.github.com>
Date: Tue, 11 Oct 2022 13:35:03 +0200
Subject: [PATCH] AnnotationList: Support html content (#54916)
* support html content in annolistpanel
* improve panel tests
* add RenderUserContentAsHTML ui
* sanitize content
---
.../RenderUserContentAsHTML.mdx | 22 +++++++++++++
.../RenderUserContentAsHTML.story.tsx | 33 +++++++++++++++++++
.../RenderUserContentAsHTML.test.tsx | 15 +++++++++
.../RenderUserContentAsHTML.tsx | 20 +++++++++++
packages/grafana-ui/src/components/index.ts | 1 +
.../panel/annolist/AnnoListPanel.test.tsx | 19 ++++++++---
.../panel/annolist/AnnotationListItem.tsx | 22 +++++++++++--
7 files changed, 125 insertions(+), 7 deletions(-)
create mode 100644 packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.mdx
create mode 100644 packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.story.tsx
create mode 100644 packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.test.tsx
create mode 100644 packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx
diff --git a/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.mdx b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.mdx
new file mode 100644
index 00000000000..0325002ba1b
--- /dev/null
+++ b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.mdx
@@ -0,0 +1,22 @@
+import { Props } from '@storybook/addon-docs/blocks';
+import { RenderUserContentAsHTML } from './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
+
+```
+
+### Props
+
+
diff --git a/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.story.tsx b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.story.tsx
new file mode 100644
index 00000000000..9c34521f869
--- /dev/null
+++ b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.story.tsx
@@ -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 = {
+ title: 'General/RenderUserContentAsHTML',
+ component: RenderUserContentAsHTML,
+ parameters: {
+ docs: {
+ page: mdx,
+ },
+ },
+ argTypes: {
+ content: {
+ control: { type: 'text' },
+ },
+ component: {
+ control: { type: 'text' },
+ },
+ },
+};
+
+export const Basic: ComponentStory = (props) => {
+ return ;
+};
+
+Basic.args = {
+ content: 'sample html anchor tag link',
+};
+
+export default meta;
diff --git a/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.test.tsx b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.test.tsx
new file mode 100644
index 00000000000..7962ed9fd8c
--- /dev/null
+++ b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.test.tsx
@@ -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();
+ expect(screen.getByRole('link', { name: /sample content/ })).toBeInTheDocument();
+ });
+ it('should render a raw string content', () => {
+ render();
+ expect(screen.getByText(/sample content/)).toBeInTheDocument();
+ });
+});
diff --git a/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx
new file mode 100644
index 00000000000..2d4e3e87b99
--- /dev/null
+++ b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx
@@ -0,0 +1,20 @@
+import React, { HTMLAttributes, PropsWithChildren } from 'react';
+
+import { textUtil } from '@grafana/data';
+
+export interface RenderUserContentAsHTMLProps
+ extends Omit, 'dangerouslySetInnerHTML'> {
+ component?: keyof React.ReactHTML;
+ content: string;
+}
+
+export function RenderUserContentAsHTML({
+ component,
+ content,
+ ...rest
+}: PropsWithChildren>): JSX.Element {
+ return React.createElement(component || 'span', {
+ dangerouslySetInnerHTML: { __html: textUtil.sanitize(content) },
+ ...rest,
+ });
+}
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index 7eb16538dab..2401b7ed81f 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -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 {
diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx
index 853e20da60e..c6689e688cb 100644
--- a/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx
+++ b/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx
@@ -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: 'test link ' }],
+ });
+
+ 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(
diff --git a/public/app/plugins/panel/annolist/AnnotationListItem.tsx b/public/app/plugins/panel/annolist/AnnotationListItem.tsx
index 48e4f959a1a..a16553a2e06 100644
--- a/public/app/plugins/panel/annolist/AnnotationListItem.tsx
+++ b/public/app/plugins/panel/annolist/AnnotationListItem.tsx
@@ -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 = ({
}) => {
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 = ({
return (
- {text}
+ {
+ e.stopPropagation();
+ }}
+ content={text}
+ />
{showTimeStamp && (
@@ -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',