Comments: support live comments in dashboards and annotations (#44980)

This commit is contained in:
Alexander Emelin
2022-02-22 10:47:42 +03:00
committed by GitHub
parent 67c1a359d1
commit 28c30a34ad
32 changed files with 1254 additions and 18 deletions

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { GrafanaTheme2, renderMarkdown } from '@grafana/data';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
import DangerouslySetHtmlContent from 'dangerously-set-html-content';
import { Message } from './types';
type Props = {
message: Message;
};
export const Comment = ({ message }: Props) => {
const styles = useStyles2(getStyles);
let senderColor = '#34BA18';
let senderName = 'System';
let avatarUrl = '/public/img/grafana_icon.svg';
if (message.userId > 0) {
senderColor = '#19a2e7';
senderName = message.user.login;
avatarUrl = message.user.avatarUrl;
}
const timeColor = '#898989';
const timeFormatted = new Date(message.created * 1000).toLocaleTimeString();
const markdownContent = renderMarkdown(message.content, { breaks: true });
return (
<div className={styles.comment}>
<div className={styles.avatarContainer}>
<img src={avatarUrl} alt="User avatar" className={styles.avatar} />
</div>
<div>
<div>
<span style={{ color: senderColor }}>{senderName}</span>
&nbsp;
<span style={{ color: timeColor }}>{timeFormatted}</span>
</div>
<div>
<DangerouslySetHtmlContent html={markdownContent} className={styles.content} />
</div>
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
comment: css`
margin-bottom: 10px;
padding-top: 3px;
padding-bottom: 3px;
word-break: break-word;
display: flex;
flex-direction: row;
align-items: top;
:hover {
background-color: #1e1f24;
}
blockquote {
padding: 0 0 0 10px;
margin: 0 0 10px;
}
`,
avatarContainer: css`
align-self: left;
margin-top: 6px;
margin-right: 10px;
`,
avatar: css`
width: 30px;
height: 30px;
`,
content: css`
display: block;
overflow: hidden;
p {
margin: 0;
padding: 0;
}
blockquote p {
font-size: 14px;
padding-top: 4px;
}
a {
color: #43c57e;
}
a:hover {
text-decoration: underline;
}
`,
});

View File

@@ -0,0 +1,109 @@
import React, { PureComponent } from 'react';
import { isLiveChannelMessageEvent, LiveChannelScope } from '@grafana/data';
import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime';
import { Unsubscribable } from 'rxjs';
import { CommentView } from './CommentView';
import { Message, MessagePacket } from './types';
export interface Props {
objectType: string;
objectId: string;
}
export interface State {
messages: Message[];
value: string;
}
export class CommentManager extends PureComponent<Props, State> {
subscription?: Unsubscribable;
packetCounter = 0;
constructor(props: Props) {
super(props);
this.state = {
messages: [],
value: '',
};
}
async componentDidMount() {
const resp = await getBackendSrv().post('/api/comments/get', {
objectType: this.props.objectType,
objectId: this.props.objectId,
});
this.packetCounter++;
this.setState({
messages: resp.comments,
});
this.updateSubscription();
}
getLiveChannel = () => {
const live = getGrafanaLiveSrv();
if (!live) {
console.error('Grafana live not running, enable "live" feature toggle');
return undefined;
}
const address = this.getLiveAddress();
if (!address) {
return undefined;
}
return live.getStream<MessagePacket>(address);
};
getLiveAddress = () => {
return {
scope: LiveChannelScope.Grafana,
namespace: 'comment',
path: `${this.props.objectType}/${this.props.objectId}`,
};
};
updateSubscription = () => {
if (this.subscription) {
this.subscription.unsubscribe();
this.subscription = undefined;
}
const channel = this.getLiveChannel();
if (channel) {
this.subscription = channel.subscribe({
next: (msg) => {
if (isLiveChannelMessageEvent(msg)) {
const { commentCreated } = msg.message;
if (commentCreated) {
this.setState((prevState) => ({
messages: [...prevState.messages, commentCreated],
}));
this.packetCounter++;
}
}
},
});
}
};
addComment = async (comment: string): Promise<boolean> => {
const response = await getBackendSrv().post('/api/comments/create', {
objectType: this.props.objectType,
objectId: this.props.objectId,
content: comment,
});
// TODO: set up error handling
console.log(response);
return true;
};
render() {
return (
<CommentView comments={this.state.messages} packetCounter={this.packetCounter} addComment={this.addComment} />
);
}
}

View File

@@ -0,0 +1,70 @@
import React, { FormEvent, useLayoutEffect, useRef, useState } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { CustomScrollbar, TextArea, useStyles2 } from '@grafana/ui';
import { Comment } from './Comment';
import { Message } from './types';
type Props = {
comments: Message[];
packetCounter: number;
addComment: (comment: string) => Promise<boolean>;
};
export const CommentView = ({ comments, packetCounter, addComment }: Props) => {
const styles = useStyles2(getStyles);
const [comment, setComment] = useState('');
const [scrollTop, setScrollTop] = useState(0);
const commentViewContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (commentViewContainer.current) {
setScrollTop(commentViewContainer.current.offsetHeight);
} else {
setScrollTop(0);
}
}, [packetCounter]);
const onUpdateComment = (event: FormEvent<HTMLTextAreaElement>) => {
const element = event.target as HTMLInputElement;
setComment(element.value);
};
const onKeyPress = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event?.key === 'Enter' && !event?.shiftKey) {
event.preventDefault();
if (comment.length > 0) {
const result = await addComment(comment);
if (result) {
setComment('');
}
}
}
};
return (
<CustomScrollbar scrollTop={scrollTop}>
<div ref={commentViewContainer} className={styles.commentViewContainer}>
{comments.map((msg) => (
<Comment key={msg.id} message={msg} />
))}
<TextArea
placeholder="Write a comment"
value={comment}
onChange={onUpdateComment}
onKeyPress={onKeyPress}
autoFocus={true}
/>
</div>
</CustomScrollbar>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
commentViewContainer: css`
margin: 5px;
`,
});

View File

@@ -0,0 +1,21 @@
export interface MessagePacket {
event: string;
commentCreated: Message;
}
export interface Message {
id: number;
content: string;
created: number;
userId: number;
user: User;
}
// TODO: Interface may exist elsewhere
export interface User {
id: number;
name: string;
login: string;
email: string;
avatarUrl: string;
}

View File

@@ -15,9 +15,11 @@ import { DashboardModel } from '../../state';
import { KioskMode } from 'app/types';
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal';
import { locationService } from '@grafana/runtime';
import { toggleKioskMode } from 'app/core/navigation/kiosk';
import { getDashboardSrv } from '../../services/DashboardSrv';
import config from 'app/core/config';
const mapDispatchToProps = {
updateTimeZoneForSession,
@@ -150,6 +152,26 @@ class DashNav extends PureComponent<Props> {
);
}
if (dashboard.uid && config.featureToggles.dashboardComments) {
buttons.push(
<ModalsController key="button-dashboard-comments">
{({ showModal, hideModal }) => (
<DashNavButton
tooltip="Show dashboard comments"
icon="comment-alt-message"
iconSize="lg"
onClick={() => {
showModal(DashboardCommentsModal, {
dashboard,
onDismiss: hideModal,
});
}}
/>
)}
</ModalsController>
);
}
this.addCustomContent(customLeftActions, buttons);
return buttons;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { css } from '@emotion/css';
import { Modal, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { CommentManager } from 'app/features/comments/CommentManager';
import { DashboardModel } from '../../state/DashboardModel';
type Props = {
dashboard: DashboardModel;
onDismiss: () => void;
};
export const DashboardCommentsModal = ({ dashboard, onDismiss }: Props) => {
const styles = useStyles2(getStyles);
return (
<Modal isOpen={true} title="Dashboard comments" icon="save" onDismiss={onDismiss} className={styles.modal}>
<CommentManager objectType={'dashboard'} objectId={dashboard.uid} />
</Modal>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
modal: css`
width: 500px;
height: 60vh;
`,
});

View File

@@ -1,8 +1,11 @@
import React from 'react';
import { css } from '@emotion/css';
import { HorizontalGroup, IconButton, Tag, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, textUtil } from '@grafana/data';
import alertDef from 'app/features/alerting/state/alertDef';
import { css } from '@emotion/css';
import { CommentManager } from 'app/features/comments/CommentManager';
import config from 'app/core/config';
interface AnnotationTooltipProps {
annotation: AnnotationsDataFrameViewDTO;
@@ -12,13 +15,13 @@ interface AnnotationTooltipProps {
onDelete: () => void;
}
export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
export const AnnotationTooltip = ({
annotation,
timeFormatter,
editable,
onEdit,
onDelete,
}) => {
}: AnnotationTooltipProps) => {
const styles = useStyles2(getStyles);
const time = timeFormatter(annotation.time);
const timeEnd = timeFormatter(annotation.timeEnd);
@@ -57,8 +60,10 @@ export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
);
}
const areAnnotationCommentsEnabled = config.featureToggles.annotationComments;
return (
<div className={styles.wrapper}>
<div className={styles.wrapper} style={areAnnotationCommentsEnabled ? { minWidth: '300px' } : {}}>
<div className={styles.header}>
<HorizontalGroup justify={'space-between'} align={'center'} spacing={'md'}>
<div className={styles.meta}>
@@ -82,6 +87,11 @@ export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
))}
</HorizontalGroup>
</>
{areAnnotationCommentsEnabled && (
<div className={styles.commentWrapper}>
<CommentManager objectType={'annotation'} objectId={annotation.id.toString()} />
</div>
)}
</div>
</div>
);
@@ -94,6 +104,13 @@ const getStyles = (theme: GrafanaTheme2) => {
wrapper: css`
max-width: 400px;
`,
commentWrapper: css`
margin-top: 10px;
border-top: 2px solid #2d2b34;
height: 30vh;
overflow-y: scroll;
padding: 0 3px;
`,
header: css`
padding: ${theme.spacing(0.5, 1)};
border-bottom: 1px solid ${theme.colors.border.weak};