From 11d1cb54c231af1a92a81c2f4f0a7e5509726d05 Mon Sep 17 00:00:00 2001 From: Joey Date: Tue, 2 Dec 2025 13:21:13 +0000 Subject: [PATCH] Comments --- pkg/services/exploremap/crdt/operations.go | 47 +- .../components/ExploreMapCanvas.tsx | 2 + .../components/ExploreMapComment.tsx | 408 ++++++++++++++++++ public/app/features/explore-map/crdt/index.ts | 3 + public/app/features/explore-map/crdt/state.ts | 140 ++++++ public/app/features/explore-map/crdt/types.ts | 43 ++ .../explore-map/operations/creators.ts | 48 +++ .../features/explore-map/operations/index.ts | 2 + .../explore-map/operations/serialization.ts | 32 +- .../explore-map/operations/validators.ts | 58 +++ .../features/explore-map/state/crdtSlice.ts | 31 +- .../features/explore-map/state/selectors.ts | 13 + 12 files changed, 817 insertions(+), 10 deletions(-) create mode 100644 public/app/features/explore-map/components/ExploreMapComment.tsx diff --git a/pkg/services/exploremap/crdt/operations.go b/pkg/services/exploremap/crdt/operations.go index 76d5901da38..654829c6a09 100644 --- a/pkg/services/exploremap/crdt/operations.go +++ b/pkg/services/exploremap/crdt/operations.go @@ -8,14 +8,16 @@ import ( type OperationType string const ( - OpAddPanel OperationType = "add-panel" - OpRemovePanel OperationType = "remove-panel" - OpUpdatePanelPosition OperationType = "update-panel-position" - OpUpdatePanelSize OperationType = "update-panel-size" - OpUpdatePanelZIndex OperationType = "update-panel-zindex" - OpUpdatePanelExplore OperationType = "update-panel-explore-state" - OpUpdateTitle OperationType = "update-title" - OpBatch OperationType = "batch" + OpAddPanel OperationType = "add-panel" + OpRemovePanel OperationType = "remove-panel" + OpUpdatePanelPosition OperationType = "update-panel-position" + OpUpdatePanelSize OperationType = "update-panel-size" + OpUpdatePanelZIndex OperationType = "update-panel-zindex" + OpUpdatePanelExplore OperationType = "update-panel-explore-state" + OpUpdateTitle OperationType = "update-title" + OpAddComment OperationType = "add-comment" + OpRemoveComment OperationType = "remove-comment" + OpBatch OperationType = "batch" ) // Operation represents a CRDT operation @@ -72,6 +74,25 @@ type UpdateTitlePayload struct { Title string `json:"title"` } +// CommentData represents a comment with text, username, and timestamp +type CommentData struct { + Text string `json:"text"` + Username string `json:"username"` + Timestamp int64 `json:"timestamp"` +} + +// AddCommentPayload represents the payload for add-comment operation +type AddCommentPayload struct { + CommentID string `json:"commentId"` + Comment CommentData `json:"comment"` +} + +// RemoveCommentPayload represents the payload for remove-comment operation +type RemoveCommentPayload struct { + CommentID string `json:"commentId"` + ObservedTags []string `json:"observedTags"` +} + // BatchPayload represents the payload for batch operation type BatchPayload struct { Operations []Operation `json:"operations"` @@ -123,6 +144,16 @@ func (op *Operation) ParsePayload() (interface{}, error) { err := json.Unmarshal(op.Payload, &payload) return payload, err + case OpAddComment: + var payload AddCommentPayload + err := json.Unmarshal(op.Payload, &payload) + return payload, err + + case OpRemoveComment: + var payload RemoveCommentPayload + err := json.Unmarshal(op.Payload, &payload) + return payload, err + case OpBatch: var payload BatchPayload err := json.Unmarshal(op.Payload, &payload) diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx index d84d4cf729b..e2f6f83d412 100644 --- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx +++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx @@ -11,6 +11,7 @@ import { useCursorSync } from '../hooks/useCursorSync'; import { selectPanel as selectPanelCRDT, updateViewport as updateViewportCRDT, selectMultiplePanels as selectMultiplePanelsCRDT } from '../state/crdtSlice'; import { selectPanels, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors'; +import { ExploreMapComment } from './ExploreMapComment'; import { ExploreMapPanelContainer } from './ExploreMapPanelContainer'; import { UserCursor } from './UserCursor'; @@ -271,6 +272,7 @@ export function ExploreMapCanvas() { + ); } diff --git a/public/app/features/explore-map/components/ExploreMapComment.tsx b/public/app/features/explore-map/components/ExploreMapComment.tsx new file mode 100644 index 00000000000..4155513edde --- /dev/null +++ b/public/app/features/explore-map/components/ExploreMapComment.tsx @@ -0,0 +1,408 @@ +import { css, cx } from '@emotion/css'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { dateTime, GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { Button, ConfirmModal, Icon, TextArea, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; +import { useDispatch, useSelector } from 'app/types/store'; + +import { CommentData } from '../crdt/types'; +import { addComment, removeComment } from '../state/crdtSlice'; +import { selectComments } from '../state/selectors'; + +export function ExploreMapComment() { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const comments = useSelector((state) => selectComments(state.exploreMapCRDT)); + const [editing, setEditing] = useState(false); + const [commentValue, setCommentValue] = useState(''); + const [commentToDelete, setCommentToDelete] = useState(null); + const [isCollapsed, setIsCollapsed] = useState(true); + const textAreaRef = useRef(null); + const commentsEndRef = useRef(null); + + useEffect(() => { + if (editing && textAreaRef.current) { + textAreaRef.current.focus(); + } + }, [editing]); + + // Auto-scroll to bottom when new comments are added + useEffect(() => { + if (commentsEndRef.current) { + commentsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [comments.length]); + + const handleAddCommentClick = useCallback(() => { + setEditing(true); + setIsCollapsed(false); // Expand when adding a comment + }, []); + + const handleHeaderClick = useCallback(() => { + setIsCollapsed((prev) => !prev); + }, []); + + const handleHeaderKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleHeaderClick(); + } + }, + [handleHeaderClick] + ); + + const handleSave = useCallback(() => { + const trimmedText = commentValue.trim(); + + if (trimmedText) { + const commentData: CommentData = { + text: trimmedText, + username: contextSrv.user.name || contextSrv.user.login || 'Unknown', + timestamp: Date.now(), + }; + dispatch(addComment({ comment: commentData })); + setCommentValue(''); + } + setEditing(false); + }, [dispatch, commentValue]); + + const handleCancel = useCallback(() => { + setCommentValue(''); + setEditing(false); + }, []); + + const handleCommentKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + handleCancel(); + } + // Allow Ctrl/Cmd+Enter to save + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } + }, + [handleCancel, handleSave] + ); + + const formatTimestamp = useCallback((timestamp: number) => { + if (!timestamp) {return '';} + const dt = dateTime(timestamp); + const now = dateTime(); + const diff = now.diff(dt, 'minutes'); + + if (diff < 1) { + return t('explore-map.comment.just-now', 'Just now'); + } else if (diff < 60) { + return t('explore-map.comment.minutes-ago', '{{minutes}}m ago', { minutes: Math.floor(diff) }); + } else if (diff < 1440) { + return t('explore-map.comment.hours-ago', '{{hours}}h ago', { hours: Math.floor(diff / 60) }); + } else { + return dt.format('MMM D, YYYY HH:mm'); + } + }, []); + + const handleRemoveCommentClick = useCallback( + (commentId: string, e: React.MouseEvent) => { + e.stopPropagation(); + setCommentToDelete(commentId); + }, + [] + ); + + const handleConfirmDelete = useCallback(() => { + if (commentToDelete) { + dispatch(removeComment({ commentId: commentToDelete })); + setCommentToDelete(null); + } + }, [dispatch, commentToDelete]); + + const handleCancelDelete = useCallback(() => { + setCommentToDelete(null); + }, []); + + const currentUsername = contextSrv.user.name || contextSrv.user.login || 'Unknown'; + + if (editing) { + return ( +
+
+