diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap index b81673b2996..6dbfba54109 100644 --- a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -30,6 +30,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` "_eventsCount": 0, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -152,6 +153,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` "_eventsCount": 0, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -248,6 +250,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` "_eventsCount": 0, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -399,6 +402,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti "_eventsCount": 0, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -521,6 +525,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti "_eventsCount": 0, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -617,6 +622,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti "_eventsCount": 0, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -718,6 +724,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti "_eventsCount": 0, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, diff --git a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap index 0a8084cac8e..8ae6c30200a 100644 --- a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap +++ b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap @@ -79,6 +79,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` "_eventsCount": 1, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -302,6 +303,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` "_eventsCount": 1, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -525,6 +527,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` "_eventsCount": 1, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, @@ -748,6 +751,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` "_eventsCount": 1, }, }, + "formatDate": [Function], "getVariables": [Function], "getVariablesFromState": [Function], "gnetId": null, diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 664d2b6c9fa..f71b60845a5 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -98,6 +98,7 @@ export class DashboardModel { panelInEdit: true, panelInView: true, getVariablesFromState: true, + formatDate: true, }; constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariables) { @@ -128,6 +129,7 @@ export class DashboardModel { this.links = data.links || []; this.gnetId = data.gnetId || null; this.panels = _.map(data.panels || [], (panelData: any) => new PanelModel(panelData)); + this.formatDate = this.formatDate.bind(this); this.resetOriginalVariables(true); this.resetOriginalTime(); diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx new file mode 100644 index 00000000000..246896e8656 --- /dev/null +++ b/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx @@ -0,0 +1,264 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; + +import { AnnoListPanel, Props } from './AnnoListPanel'; +import { AnnotationEvent, FieldConfigSource, getDefaultTimeRange, LoadingState } from '@grafana/data'; +import { AnnoOptions } from './types'; +import { backendSrv } from '../../../core/services/backend_srv'; +import userEvent from '@testing-library/user-event'; +import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; +import { setDashboardSrv } from '../../../features/dashboard/services/DashboardSrv'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +const defaultOptions: AnnoOptions = { + limit: 10, + navigateAfter: '10m', + navigateBefore: '20m', + navigateToPanel: true, + onlyFromThisDashboard: true, + onlyInTimeRange: false, + showTags: true, + showTime: true, + showUser: true, + tags: ['tag A', 'tag B'], +}; + +const defaultResult: AnnotationEvent = { + text: 'Result text', + userId: 1, + login: 'Result login', + email: 'Result email', + avatarUrl: 'Result avatarUrl', + tags: ['Result tag A', 'Result tag B'], + time: Date.UTC(2021, 0, 1, 0, 0, 0, 0), + panelId: 13, + dashboardId: 14, // deliberately different from panelId +}; + +async function setupTestContext({ + options = defaultOptions, + results = [defaultResult], +}: { options?: AnnoOptions; results?: AnnotationEvent[] } = {}) { + jest.clearAllMocks(); + + const getMock = jest.spyOn(backendSrv, 'get'); + getMock.mockResolvedValue(results); + + const dash: any = { id: 1, formatDate: (time: number) => new Date(time).toISOString() }; + const dashSrv: any = { getCurrent: () => dash }; + setDashboardSrv(dashSrv); + + const props: Props = { + data: { state: LoadingState.Done, timeRange: getDefaultTimeRange(), series: [] }, + eventBus: { + getStream: jest.fn(), + publish: jest.fn(), + removeAllListeners: jest.fn(), + subscribe: jest.fn(), + }, + fieldConfig: ({} as unknown) as FieldConfigSource, + height: 400, + id: 1, + onChangeTimeRange: jest.fn(), + onFieldConfigChange: jest.fn(), + onOptionsChange: jest.fn(), + options, + renderCounter: 1, + replaceVariables: jest.fn(), + timeRange: getDefaultTimeRange(), + timeZone: 'utc', + title: 'Test Title', + transparent: false, + width: 320, + }; + const { rerender } = render(); + await waitFor(() => expect(getMock).toHaveBeenCalledTimes(1)); + + return { props, rerender, getMock }; +} + +describe('AnnoListPanel', () => { + describe('when mounted', () => { + it('then it should fetch annotations', async () => { + const { getMock } = await setupTestContext(); + + expect(getMock).toHaveBeenCalledWith( + '/api/annotations', + { + dashboardId: 1, + limit: 10, + tags: ['tag A', 'tag B'], + type: 'annotation', + }, + 'anno-list-panel-1' + ); + }); + }); + + describe('when there are no annotations', () => { + it('then it should show a no annotations message', async () => { + await setupTestContext({ results: [] }); + + expect(screen.getByText(/no annotations found/i)).toBeInTheDocument(); + }); + }); + + describe('when there are annotations', () => { + it('then it renders the annotations correctly', async () => { + await setupTestContext(); + + expect(screen.queryByText(/no annotations found/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/result email/i)).not.toBeInTheDocument(); + expect(screen.getByText(/result text/i)).toBeInTheDocument(); + expect(screen.getByRole('img')).toBeInTheDocument(); + expect(screen.getByText('Result tag A')).toBeInTheDocument(); + expect(screen.getByText('Result tag B')).toBeInTheDocument(); + expect(screen.getByText(/2021-01-01T00:00:00.000Z/i)).toBeInTheDocument(); + }); + + describe('and login property is missing in annotation', () => { + it('then it renders the annotations correctly', async () => { + await setupTestContext({ results: [{ ...defaultResult, login: undefined }] }); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByText(/result text/i)).toBeInTheDocument(); + expect(screen.getByText('Result tag A')).toBeInTheDocument(); + expect(screen.getByText('Result tag B')).toBeInTheDocument(); + expect(screen.getByText(/2021-01-01T00:00:00.000Z/i)).toBeInTheDocument(); + }); + }); + + describe('and property is missing in annotation', () => { + it('then it renders the annotations correctly', async () => { + await setupTestContext({ results: [{ ...defaultResult, time: undefined }] }); + + expect(screen.queryByText(/2021-01-01T00:00:00.000Z/i)).not.toBeInTheDocument(); + expect(screen.getByText(/result text/i)).toBeInTheDocument(); + expect(screen.getByRole('img')).toBeInTheDocument(); + expect(screen.getByText('Result tag A')).toBeInTheDocument(); + expect(screen.getByText('Result tag B')).toBeInTheDocument(); + }); + }); + + describe('and show user option is off', () => { + it('then it renders the annotations correctly', async () => { + await setupTestContext({ + options: { ...defaultOptions, showUser: false }, + }); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByText(/result text/i)).toBeInTheDocument(); + expect(screen.getByText('Result tag A')).toBeInTheDocument(); + expect(screen.getByText('Result tag B')).toBeInTheDocument(); + expect(screen.getByText(/2021-01-01T00:00:00.000Z/i)).toBeInTheDocument(); + }); + }); + + describe('and show time option is off', () => { + it('then it renders the annotations correctly', async () => { + await setupTestContext({ + options: { ...defaultOptions, showTime: false }, + }); + + expect(screen.queryByText(/2021-01-01T00:00:00.000Z/i)).not.toBeInTheDocument(); + expect(screen.getByText(/result text/i)).toBeInTheDocument(); + expect(screen.getByRole('img')).toBeInTheDocument(); + expect(screen.getByText('Result tag A')).toBeInTheDocument(); + expect(screen.getByText('Result tag B')).toBeInTheDocument(); + }); + }); + + describe('and show tags option is off', () => { + it('then it renders the annotations correctly', async () => { + await setupTestContext({ + options: { ...defaultOptions, showTags: false }, + }); + + expect(screen.queryByText('Result tag A')).not.toBeInTheDocument(); + expect(screen.queryByText('Result tag B')).not.toBeInTheDocument(); + expect(screen.getByText(/result text/i)).toBeInTheDocument(); + expect(screen.getByRole('img')).toBeInTheDocument(); + expect(screen.getByText(/2021-01-01T00:00:00.000Z/i)).toBeInTheDocument(); + }); + }); + + describe('and the user clicks on the annotation', () => { + it('then it should navigate to the dashboard connected to the annotation', async () => { + const { getMock } = await setupTestContext(); + + getMock.mockClear(); + expect(screen.getByText(/result text/i)).toBeInTheDocument(); + userEvent.click(screen.getByText(/result text/i)); + + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith('/api/search', { dashboardIds: 14 }); + }); + }); + + describe('and the user clicks on a tag', () => { + it('then it should navigate to the dashboard connected to the annotation', async () => { + const { getMock } = await setupTestContext(); + + getMock.mockClear(); + expect(screen.getByText('Result tag B')).toBeInTheDocument(); + userEvent.click(screen.getByText('Result tag B')); + + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith( + '/api/annotations', + { + dashboardId: 1, + limit: 10, + tags: ['tag A', 'tag B', 'Result tag B'], + type: 'annotation', + }, + 'anno-list-panel-1' + ); + expect(screen.getByText(/filter:/i)).toBeInTheDocument(); + expect(screen.getAllByText(/result tag b/i)).toHaveLength(2); + }); + }); + + describe('and the user clicks on the user avatar', () => { + it('then it should filter annotations by login and the filter should show', async () => { + const { getMock } = await setupTestContext(); + + getMock.mockClear(); + expect(screen.getByRole('img')).toBeInTheDocument(); + userEvent.click(screen.getByRole('img')); + + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith( + '/api/annotations', + { + dashboardId: 1, + limit: 10, + tags: ['tag A', 'tag B'], + type: 'annotation', + userId: 1, + }, + 'anno-list-panel-1' + ); + expect(screen.getByText(/filter:/i)).toBeInTheDocument(); + expect(screen.getByText(/result email/i)).toBeInTheDocument(); + }); + }); + + describe('and the user hovers over the user avatar', () => { + silenceConsoleOutput(); // Popper throws an act error, but if we add act around the hover here it doesn't matter + it('then it should filter annotations by login', async () => { + const { getMock } = await setupTestContext(); + + getMock.mockClear(); + expect(screen.getByRole('img')).toBeInTheDocument(); + userEvent.hover(screen.getByRole('img')); + + expect(screen.getByText(/result email/i)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx index 0b2208a5038..823ef3b83e7 100644 --- a/public/app/plugins/panel/annolist/AnnoListPanel.tsx +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -3,13 +3,12 @@ import React, { PureComponent } from 'react'; // Types import { AnnoOptions } from './types'; import { AnnotationEvent, AppEvents, dateTime, DurationUnit, PanelProps } from '@grafana/data'; -import { Tooltip } from '@grafana/ui'; import { getBackendSrv, getLocationSrv } from '@grafana/runtime'; import { AbstractList } from '@grafana/ui/src/components/List/AbstractList'; -import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import appEvents from 'app/core/app_events'; -import { css, cx } from 'emotion'; +import { AnnotationListItem } from './AnnotationListItem'; +import { AnnotationListItemTags } from './AnnotationListItemTags'; interface UserInfo { id?: number; @@ -17,7 +16,7 @@ interface UserInfo { email?: string; } -interface Props extends PanelProps {} +export interface Props extends PanelProps {} interface State { annotations: AnnotationEvent[]; timeInfo: string; @@ -103,8 +102,7 @@ export class AnnoListPanel extends PureComponent { }); } - onAnnoClick = (e: React.SyntheticEvent, anno: AnnotationEvent) => { - e.stopPropagation(); + onAnnoClick = (anno: AnnotationEvent) => { if (!anno.time) { return; } @@ -161,15 +159,13 @@ export class AnnoListPanel extends PureComponent { return t.add(incr, unit as DurationUnit).valueOf(); } - onTagClick = (e: React.SyntheticEvent, tag: string, remove?: boolean) => { - e.stopPropagation(); + onTagClick = (tag: string, remove?: boolean) => { const queryTags = remove ? this.state.queryTags.filter((item) => item !== tag) : [...this.state.queryTags, tag]; this.setState({ queryTags }); }; - onUserClick = (e: React.SyntheticEvent, anno: AnnotationEvent) => { - e.stopPropagation(); + onUserClick = (anno: AnnotationEvent) => { this.setState({ queryUser: { id: anno.userId, @@ -186,73 +182,22 @@ export class AnnoListPanel extends PureComponent { }; renderTags = (tags?: string[], remove?: boolean): JSX.Element | null => { - if (!tags || !tags.length) { - return null; - } - return ( - <> - {tags.map((tag) => { - return ( - this.onTagClick(e, tag, remove)} className="pointer"> - - - ); - })} - - ); + return ; }; renderItem = (anno: AnnotationEvent, index: number): JSX.Element => { const { options } = this.props; - const { showUser, showTags, showTime } = options; const dashboard = getDashboardSrv().getCurrent(); return ( -
- { - this.onAnnoClick(e, anno); - }} - > - - {anno.text} - - - - {anno.login && showUser && ( - - - Created by: -
{anno.email} -
- } - theme="info" - placement="top" - > - this.onUserClick(e, anno)} className="graph-annotation__user"> - - - -
- )} - {showTags && this.renderTags(anno.tags, false)} -
- - - {showTime && anno.time && {dashboard.formatDate(anno.time)}} - - -
+ ); }; diff --git a/public/app/plugins/panel/annolist/AnnotationListItem.tsx b/public/app/plugins/panel/annolist/AnnotationListItem.tsx new file mode 100644 index 00000000000..cda6444da84 --- /dev/null +++ b/public/app/plugins/panel/annolist/AnnotationListItem.tsx @@ -0,0 +1,151 @@ +import React, { FC, MouseEvent } from 'react'; +import { css, cx } from 'emotion'; +import { AnnotationEvent, DateTimeInput, GrafanaTheme, PanelProps } from '@grafana/data'; +import { styleMixins, Tooltip, useStyles } from '@grafana/ui'; +import { AnnoOptions } from './types'; +import { AnnotationListItemTags } from './AnnotationListItemTags'; + +interface Props extends Pick, 'options'> { + annotation: AnnotationEvent; + formatDate: (date: DateTimeInput, format?: string) => string; + onClick: (annotation: AnnotationEvent) => void; + onAvatarClick: (annotation: AnnotationEvent) => void; + onTagClick: (tag: string, remove?: boolean) => void; +} + +export const AnnotationListItem: FC = ({ + options, + annotation, + formatDate, + onClick, + onAvatarClick, + onTagClick, +}) => { + const styles = useStyles(getStyles); + const { showUser, showTags, showTime } = options; + const { text, login, email, avatarUrl, tags, time } = annotation; + const onItemClick = (e: MouseEvent) => { + e.stopPropagation(); + onClick(annotation); + }; + const onLoginClick = () => { + onAvatarClick(annotation); + }; + const showAvatar = login && showUser; + const showTimeStamp = time && showTime; + + return ( +
+ +
+ {text} + {showTimeStamp ? : null} +
+
+ {showAvatar ? : null} + {showTags ? : null} +
+
+
+ ); +}; + +interface AvatarProps { + login: string; + onClick: () => void; + avatarUrl?: string; + email?: string; +} + +const Avatar: FC = ({ onClick, avatarUrl, login, email }) => { + const styles = useStyles(getStyles); + const onAvatarClick = (e: MouseEvent) => { + e.stopPropagation(); + onClick(); + }; + const tooltipContent = ( + + Created by: +
{email} +
+ ); + + return ( +
+ + + avatar icon + + +
+ ); +}; + +interface TimeStampProps { + time: number; + formatDate: (date: DateTimeInput, format?: string) => string; +} + +const TimeStamp: FC = ({ time, formatDate }) => { + const styles = useStyles(getStyles); + + return ( + + {formatDate(time)} + + ); +}; + +function getStyles(theme: GrafanaTheme) { + return { + pointer: css` + label: pointer; + cursor: pointer; + `, + item: css` + label: labelItem; + margin: ${theme.spacing.xs}; + padding: ${theme.spacing.sm}; + ${styleMixins.listItem(theme)}// display: flex; + `, + title: css` + label: title; + flex-basis: 80%; + `, + link: css` + label: link; + display: flex; + + .fa { + padding-top: ${theme.spacing.xs}; + } + + .fa-star { + color: ${theme.palette.orange}; + } + `, + login: css` + label: login; + align-self: center; + flex: auto; + display: flex; + justify-content: flex-end; + font-size: ${theme.typography.size.sm}; + `, + time: css` + label: time; + margin-left: ${theme.spacing.sm}; + font-size: ${theme.typography.size.sm}; + color: ${theme.colors.textWeak}; + `, + avatar: css` + label: avatar; + padding: ${theme.spacing.xs}; + img { + border-radius: 50%; + width: ${theme.spacing.md}; + height: ${theme.spacing.md}; + } + `, + }; +} diff --git a/public/app/plugins/panel/annolist/AnnotationListItemTags.tsx b/public/app/plugins/panel/annolist/AnnotationListItemTags.tsx new file mode 100644 index 00000000000..d6c7bef336c --- /dev/null +++ b/public/app/plugins/panel/annolist/AnnotationListItemTags.tsx @@ -0,0 +1,50 @@ +import React, { FC, MouseEvent, useCallback } from 'react'; + +import { GrafanaTheme } from '@grafana/data'; +import { css } from 'emotion'; +import { useStyles } from '@grafana/ui'; + +import { TagBadge } from '../../../core/components/TagFilter/TagBadge'; + +interface Props { + tags?: string[]; + remove?: boolean; + onClick: (tag: string, remove?: boolean) => void; +} + +export const AnnotationListItemTags: FC = ({ tags, remove, onClick }) => { + const styles = useStyles(getStyles); + const onTagClicked = useCallback( + (e: MouseEvent, tag: string) => { + e.stopPropagation(); + onClick(tag, remove); + }, + [remove] + ); + + if (!tags || !tags.length) { + return null; + } + + return ( +
+ {tags.map((tag) => { + return ( + onTagClicked(e, tag)} className={styles.pointer}> + + + ); + })} +
+ ); +}; + +function getStyles(theme: GrafanaTheme) { + return { + pointer: css` + label: pointer; + cursor: pointer; + padding: ${theme.spacing.xxs}; + `, + }; +}