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',