AnnotationList: Adds spacing to UI (#31888)

* AnnotationList: Adds spacing to UI

* Tests: updates snapshots

* Chore: updates after PR comments
This commit is contained in:
Hugo Häggmark 2021-03-11 11:59:35 +01:00 committed by GitHub
parent d59574ec1e
commit 9f92eb6859
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 493 additions and 70 deletions

View File

@ -32,6 +32,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,
@ -158,6 +159,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,
@ -255,6 +257,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,
@ -408,6 +411,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,
@ -534,6 +538,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,
@ -631,6 +636,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,
@ -732,6 +738,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,

View File

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

View File

@ -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();

View File

@ -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(<AnnoListPanel {...props} />);
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();
});
});
});
});

View File

@ -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<AnnoOptions> {}
export interface Props extends PanelProps<AnnoOptions> {}
interface State {
annotations: AnnotationEvent[];
timeInfo: string;
@ -103,8 +102,7 @@ export class AnnoListPanel extends PureComponent<Props, State> {
});
}
onAnnoClick = (e: React.SyntheticEvent, anno: AnnotationEvent) => {
e.stopPropagation();
onAnnoClick = (anno: AnnotationEvent) => {
if (!anno.time) {
return;
}
@ -161,15 +159,13 @@ export class AnnoListPanel extends PureComponent<Props, State> {
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<Props, State> {
};
renderTags = (tags?: string[], remove?: boolean): JSX.Element | null => {
if (!tags || !tags.length) {
return null;
}
return (
<>
{tags.map((tag) => {
return (
<span key={tag} onClick={(e) => this.onTagClick(e, tag, remove)} className="pointer">
<TagBadge label={tag} removeIcon={!!remove} count={0} />
</span>
);
})}
</>
);
return <AnnotationListItemTags tags={tags} remove={remove} onClick={this.onTagClick} />;
};
renderItem = (anno: AnnotationEvent, index: number): JSX.Element => {
const { options } = this.props;
const { showUser, showTags, showTime } = options;
const dashboard = getDashboardSrv().getCurrent();
return (
<div className="dashlist-item">
<span
className="dashlist-link pointer"
onClick={(e) => {
this.onAnnoClick(e, anno);
}}
>
<span
className={cx([
'dashlist-title',
css`
margin-right: 8px;
`,
])}
>
{anno.text}
</span>
<span className="pluginlist-message">
{anno.login && showUser && (
<span className="graph-annotation">
<Tooltip
content={
<span>
Created by:
<br /> {anno.email}
</span>
}
theme="info"
placement="top"
>
<span onClick={(e) => this.onUserClick(e, anno)} className="graph-annotation__user">
<img src={anno.avatarUrl} />
</span>
</Tooltip>
</span>
)}
{showTags && this.renderTags(anno.tags, false)}
</span>
<span className="pluginlist-version">
{showTime && anno.time && <span>{dashboard.formatDate(anno.time)}</span>}
</span>
</span>
</div>
<AnnotationListItem
annotation={anno}
formatDate={dashboard.formatDate}
onClick={this.onAnnoClick}
onAvatarClick={this.onUserClick}
onTagClick={this.onTagClick}
options={options}
/>
);
};

View File

@ -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<PanelProps<AnnoOptions>, '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<Props> = ({
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 (
<div>
<span className={cx(styles.item, styles.link, styles.pointer)} onClick={onItemClick}>
<div className={styles.title}>
<span>{text}</span>
{showTimeStamp ? <TimeStamp formatDate={formatDate} time={time!} /> : null}
</div>
<div className={styles.login}>
{showAvatar ? <Avatar email={email} login={login!} avatarUrl={avatarUrl} onClick={onLoginClick} /> : null}
{showTags ? <AnnotationListItemTags tags={tags} remove={false} onClick={onTagClick} /> : null}
</div>
</span>
</div>
);
};
interface AvatarProps {
login: string;
onClick: () => void;
avatarUrl?: string;
email?: string;
}
const Avatar: FC<AvatarProps> = ({ onClick, avatarUrl, login, email }) => {
const styles = useStyles(getStyles);
const onAvatarClick = (e: MouseEvent) => {
e.stopPropagation();
onClick();
};
const tooltipContent = (
<span>
Created by:
<br /> {email}
</span>
);
return (
<div>
<Tooltip content={tooltipContent} theme="info" placement="top">
<span onClick={onAvatarClick} className={styles.avatar}>
<img src={avatarUrl} alt="avatar icon" />
</span>
</Tooltip>
</div>
);
};
interface TimeStampProps {
time: number;
formatDate: (date: DateTimeInput, format?: string) => string;
}
const TimeStamp: FC<TimeStampProps> = ({ time, formatDate }) => {
const styles = useStyles(getStyles);
return (
<span className={styles.time}>
<span>{formatDate(time)}</span>
</span>
);
};
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};
}
`,
};
}

View File

@ -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<Props> = ({ 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 (
<div>
{tags.map((tag) => {
return (
<span key={tag} onClick={(e) => onTagClicked(e, tag)} className={styles.pointer}>
<TagBadge label={tag} removeIcon={Boolean(remove)} count={0} />
</span>
);
})}
</div>
);
};
function getStyles(theme: GrafanaTheme) {
return {
pointer: css`
label: pointer;
cursor: pointer;
padding: ${theme.spacing.xxs};
`,
};
}