This commit is contained in:
Joey
2025-12-02 13:27:41 +00:00
parent ecfe2bd4b2
commit 11d1cb54c2
12 changed files with 817 additions and 10 deletions
+39 -8
View File
@@ -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)
@@ -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() {
</div>
</TransformComponent>
</TransformWrapper>
<ExploreMapComment />
</div>
);
}
@@ -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<string | null>(null);
const [isCollapsed, setIsCollapsed] = useState(true);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const commentsEndRef = useRef<HTMLDivElement>(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 (
<div className={styles.commentContainer}>
<div className={styles.commentEditor}>
<TextArea
ref={textAreaRef}
value={commentValue}
onChange={(e) => setCommentValue(e.currentTarget.value)}
onKeyDown={handleCommentKeyDown}
placeholder={t('explore-map.comment.placeholder', 'Add a comment...')}
rows={3}
className={styles.commentTextArea}
/>
<div className={styles.editorActions}>
<Button
variant="secondary"
size="sm"
onClick={handleCancel}
icon="times"
>
{t('explore-map.comment.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
icon="check"
>
{t('explore-map.comment.save', 'Save')}
</Button>
</div>
<div className={styles.commentHint}>
{t('explore-map.comment.hint', 'Press Ctrl+Enter to save, Esc to cancel')}
</div>
</div>
</div>
);
}
return (
<div className={styles.commentContainer}>
<div
className={cx(styles.commentsHeader, isCollapsed && styles.commentsHeaderCollapsed)}
onClick={handleHeaderClick}
onKeyDown={handleHeaderKeyDown}
role="button"
tabIndex={0}
>
<Icon name="comment-alt" className={styles.headerIcon} />
<span className={styles.headerText}>
{t('explore-map.comment.comments', 'Comments')} ({comments.length})
</span>
<Button
variant="primary"
size="sm"
icon="plus"
onClick={(e) => {
e.stopPropagation();
handleAddCommentClick();
}}
className={styles.addButton}
>
{t('explore-map.comment.add', 'Add')}
</Button>
</div>
{!isCollapsed && (
<div className={styles.commentsList}>
{comments.length === 0 ? (
<div className={styles.emptyState}>
<Icon name="comment-alt" className={styles.emptyIcon} />
<span className={styles.emptyText}>
{t('explore-map.comment.no-comments', 'No comments yet. Be the first to comment!')}
</span>
</div>
) : (
comments.map(({ id, data }) => (
<div key={id} className={styles.commentItem}>
<div className={styles.commentContent}>
<span className={styles.commentText}>{data.text}</span>
<div className={styles.commentMeta}>
<span className={styles.commentUsername}>{data.username}</span>
{data.timestamp && (
<span className={styles.commentTimestamp}>
{formatTimestamp(data.timestamp)}
</span>
)}
</div>
</div>
<div className={styles.commentActions}>
{data.username === currentUsername && (
<Button
variant="secondary"
size="sm"
fill="text"
icon="trash-alt"
onClick={(e) => handleRemoveCommentClick(id, e)}
className={styles.deleteButton}
tooltip={t('explore-map.comment.delete', 'Delete comment')}
/>
)}
</div>
</div>
))
)}
<div ref={commentsEndRef} />
</div>
)}
<ConfirmModal
isOpen={commentToDelete !== null}
title={t('explore-map.comment.delete-title', 'Delete comment')}
body={t('explore-map.comment.delete-body', 'Are you sure you want to delete this comment? This action cannot be undone.')}
confirmText={t('explore-map.comment.delete-confirm', 'Delete')}
dismissText={t('explore-map.comment.delete-cancel', 'Cancel')}
onConfirm={handleConfirmDelete}
onDismiss={handleCancelDelete}
icon="exclamation-triangle"
confirmButtonVariant="destructive"
/>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
commentContainer: css({
position: 'fixed',
bottom: theme.spacing(3),
right: theme.spacing(3),
width: '300px',
maxHeight: '500px',
zIndex: 1001,
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
}),
commentsHeader: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1.5),
borderBottom: `1px solid ${theme.colors.border.weak}`,
cursor: 'pointer',
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'background-color 0.2s',
},
'&:hover': {
backgroundColor: theme.colors.background.primary,
},
}),
commentsHeaderCollapsed: css({
borderBottom: 'none',
}),
headerIcon: css({
color: theme.colors.text.secondary,
}),
headerText: css({
flex: 1,
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
}),
addButton: css({
flexShrink: 0,
}),
commentsList: css({
flex: 1,
overflowY: 'auto',
maxHeight: '400px',
padding: theme.spacing(1),
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
emptyState: css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(3),
textAlign: 'center',
color: theme.colors.text.secondary,
}),
emptyIcon: css({
fontSize: theme.spacing(4),
marginBottom: theme.spacing(1),
opacity: 0.5,
}),
emptyText: css({
fontSize: theme.typography.bodySmall.fontSize,
fontStyle: 'italic',
}),
commentItem: css({
display: 'flex',
alignItems: 'flex-start',
gap: theme.spacing(1),
padding: theme.spacing(1.5),
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'background-color 0.2s',
},
'&:hover': {
backgroundColor: theme.colors.background.canvas,
},
}),
commentContent: css({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
}),
commentText: css({
fontSize: theme.typography.body.fontSize,
color: theme.colors.text.primary,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
lineHeight: theme.typography.body.lineHeight,
}),
commentMeta: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
fontSize: theme.typography.bodySmall.fontSize,
marginTop: theme.spacing(0.5),
}),
commentUsername: css({
color: theme.colors.text.secondary,
fontWeight: theme.typography.fontWeightMedium,
}),
commentTimestamp: css({
color: theme.colors.text.disabled,
fontSize: theme.typography.bodySmall.fontSize,
}),
commentActions: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
flexShrink: 0,
}),
deleteButton: css({
opacity: 0.6,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 0.2s',
},
'&:hover': {
opacity: 1,
color: theme.colors.error.text,
},
}),
commentEditor: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(1.5),
}),
commentTextArea: css({
width: '100%',
resize: 'vertical',
minHeight: '60px',
marginBottom: theme.spacing(1),
}),
editorActions: css({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(1),
marginTop: theme.spacing(1),
}),
commentHint: css({
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
fontStyle: 'italic',
marginTop: theme.spacing(0.5),
textAlign: 'right',
}),
};
};
@@ -34,6 +34,9 @@ export type {
UpdatePanelZIndexOperation,
UpdatePanelExploreStateOperation,
UpdateTitleOperation,
AddCommentOperation,
RemoveCommentOperation,
BatchOperation,
OperationResult,
CommentData,
} from './types';
@@ -25,8 +25,11 @@ import {
UpdatePanelExploreStateOperation,
UpdatePanelIframeUrlOperation,
UpdateTitleOperation,
AddCommentOperation,
RemoveCommentOperation,
OperationResult,
CRDTExploreMapStateJSON,
CommentData,
} from './types';
export class CRDTStateManager {
@@ -48,6 +51,8 @@ export class CRDTStateManager {
return {
uid: this.mapUid,
title: createLWWRegister('Untitled Map', this.nodeId),
comments: new ORSet<string>(),
commentData: new Map(),
panels: new ORSet<string>(),
panelData: new Map(),
zIndexCounter: new PNCounter(),
@@ -328,6 +333,76 @@ export class CRDTStateManager {
};
}
/**
* Create an add comment operation
*/
createAddCommentOperation(commentId: string, comment: CommentData): AddCommentOperation {
const timestamp = this.clock.tick();
return {
type: 'add-comment',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: {
commentId,
comment,
},
};
}
/**
* Create a remove comment operation
*/
createRemoveCommentOperation(commentId: string): RemoveCommentOperation {
const timestamp = this.clock.tick();
const observedTags = this.state.comments.getTags(commentId);
return {
type: 'remove-comment',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: {
commentId,
observedTags,
},
};
}
/**
* Get all comment IDs
*/
getCommentIds(): string[] {
return this.state.comments.values();
}
/**
* Get comment data by ID
*/
getCommentData(commentId: string): CommentData | undefined {
if (!this.state.comments.contains(commentId)) {
return undefined;
}
return this.state.commentData.get(commentId);
}
/**
* Get all comments as an array, sorted by timestamp (newest first)
*/
getCommentsForUI(): Array<{ id: string; data: CommentData }> {
const commentIds = this.getCommentIds();
const comments = commentIds
.map((id) => {
const data = this.getCommentData(id);
return data ? { id, data } : null;
})
.filter((c): c is { id: string; data: CommentData } => c !== null);
// Sort by timestamp, newest first
return comments.sort((a, b) => b.data.timestamp - a.data.timestamp);
}
/**
* Apply a CRDT operation to the state
*/
@@ -353,6 +428,10 @@ export class CRDTStateManager {
return this.applyUpdatePanelIframeUrl(operation);
case 'update-title':
return this.applyUpdateTitle(operation);
case 'add-comment':
return this.applyAddComment(operation);
case 'remove-comment':
return this.applyRemoveComment(operation);
case 'batch':
return this.applyBatchOperation(operation);
default:
@@ -509,6 +588,41 @@ export class CRDTStateManager {
};
}
private applyAddComment(operation: AddCommentOperation): OperationResult {
const { commentId, comment } = operation.payload;
// Add to OR-Set using operation ID as the tag
this.state.comments.add(commentId, operation.operationId);
// Store comment data
this.state.commentData.set(commentId, comment);
return {
success: true,
applied: true,
};
}
private applyRemoveComment(operation: RemoveCommentOperation): OperationResult {
const { commentId, observedTags } = operation.payload;
// Check if comment exists before removing
const existed = this.state.comments.contains(commentId);
// Remove from OR-Set
this.state.comments.remove(commentId, observedTags);
// Remove comment data if it existed
if (existed) {
this.state.commentData.delete(commentId);
}
return {
success: true,
applied: existed,
};
}
private applyBatchOperation(operation: any): OperationResult {
let anyApplied = false;
const errors: string[] = [];
@@ -537,6 +651,16 @@ export class CRDTStateManager {
// Merge title
this.state.title.merge(other.title);
// Merge comments OR-Set
this.state.comments.merge(other.comments);
// Merge comment data
for (const [commentId, commentData] of other.commentData.entries()) {
if (this.state.comments.contains(commentId)) {
this.state.commentData.set(commentId, commentData);
}
}
// Merge panel OR-Set
this.state.panels.merge(other.panels);
@@ -598,9 +722,18 @@ export class CRDTStateManager {
};
}
const commentData: Record<string, CommentData> = {};
for (const [commentId, data] of this.state.commentData.entries()) {
if (this.state.comments.contains(commentId)) {
commentData[commentId] = data;
}
}
return {
uid: this.state.uid,
title: this.state.title.toJSON(),
comments: this.state.comments.toJSON(),
commentData,
panels: this.state.panels.toJSON(),
panelData,
zIndexCounter: this.state.zIndexCounter.toJSON(),
@@ -615,6 +748,13 @@ export class CRDTStateManager {
manager.state.uid = json.uid;
manager.state.title = LWWRegister.fromJSON(json.title);
manager.state.comments = json.comments ? ORSet.fromJSON(json.comments) : new ORSet<string>();
manager.state.commentData = new Map();
if (json.commentData) {
for (const [commentId, data] of Object.entries(json.commentData)) {
manager.state.commentData.set(commentId, data);
}
}
manager.state.panels = ORSet.fromJSON(json.panels);
manager.state.zIndexCounter = PNCounter.fromJSON(json.zIndexCounter);
@@ -43,11 +43,23 @@ export interface CRDTPanelData {
/**
* Complete CRDT-based Explore Map state
*/
export interface CommentData {
text: string;
username: string;
timestamp: number; // Unix timestamp in milliseconds
}
export interface CRDTExploreMapState {
// Map metadata
uid?: string;
title: LWWRegister<string>;
// Comment collection (OR-Set for add/remove operations)
comments: ORSet<string>; // Set of comment IDs
// Comment data (text, username, timestamp)
commentData: Map<string, CommentData>;
// Panel collection (OR-Set for add/remove operations)
panels: ORSet<string>; // Set of panel IDs
@@ -85,6 +97,11 @@ export interface CRDTExploreMapStateJSON {
value: string;
timestamp: HLCTimestamp;
};
comments?: {
adds: Record<string, string[]>;
removes: string[];
};
commentData?: Record<string, CommentData>;
panels: {
adds: Record<string, string[]>;
removes: string[];
@@ -120,6 +137,8 @@ export type CRDTOperationType =
| 'update-panel-explore-state'
| 'update-panel-iframe-url'
| 'update-title'
| 'add-comment'
| 'remove-comment'
| 'batch'; // For batching multiple operations
/**
@@ -229,6 +248,28 @@ export interface UpdateTitleOperation extends CRDTOperationBase {
};
}
/**
* Add comment operation
*/
export interface AddCommentOperation extends CRDTOperationBase {
type: 'add-comment';
payload: {
commentId: string;
comment: CommentData;
};
}
/**
* Remove comment operation
*/
export interface RemoveCommentOperation extends CRDTOperationBase {
type: 'remove-comment';
payload: {
commentId: string;
observedTags: string[]; // Tags from OR-Set
};
}
/**
* Batch operation (multiple operations in one)
*/
@@ -251,6 +292,8 @@ export type CRDTOperation =
| UpdatePanelExploreStateOperation
| UpdatePanelIframeUrlOperation
| UpdateTitleOperation
| AddCommentOperation
| RemoveCommentOperation
| BatchOperation;
/**
@@ -6,6 +6,7 @@
*/
import { v4 as uuidv4 } from 'uuid';
import { HLCTimestamp } from '../crdt/hlc';
import {
CRDTOperation,
@@ -16,7 +17,10 @@ import {
UpdatePanelZIndexOperation,
UpdatePanelExploreStateOperation,
UpdateTitleOperation,
AddCommentOperation,
RemoveCommentOperation,
BatchOperation,
CommentData,
} from '../crdt/types';
import { SerializedExploreState } from '../state/types';
@@ -181,6 +185,50 @@ export function createUpdateTitleOperation(
};
}
/**
* Create an add comment operation
*/
export function createAddCommentOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
commentId: string;
comment: CommentData;
}
): AddCommentOperation {
return {
type: 'add-comment',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create a remove comment operation
*/
export function createRemoveCommentOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
commentId: string;
observedTags: string[];
}
): RemoveCommentOperation {
return {
type: 'remove-comment',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create a batch operation containing multiple sub-operations
*/
@@ -21,6 +21,8 @@ export {
createUpdatePanelZIndexOperation,
createUpdatePanelExploreStateOperation,
createUpdateTitleOperation,
createAddCommentOperation,
createRemoveCommentOperation,
createBatchOperation,
createMultiPanelMoveOperation,
createDuplicatePanelOperation,
@@ -145,6 +145,20 @@ function compressPayload(operation: CRDTOperation): any {
t: payload.title,
};
case 'add-comment':
return {
cid: payload.commentId,
ct: payload.comment.text,
cu: payload.comment.username,
cts: payload.comment.timestamp,
};
case 'remove-comment':
return {
cid: payload.commentId,
ot: payload.observedTags,
};
case 'batch':
return {
ops: payload.operations.map(compressOperation),
@@ -234,6 +248,22 @@ function decompressPayload(type: string, compressed: any): any {
title: compressed.t,
};
case 'add-comment':
return {
commentId: compressed.cid,
comment: {
text: compressed.ct,
username: compressed.cu,
timestamp: compressed.cts,
},
};
case 'remove-comment':
return {
commentId: compressed.cid,
observedTags: compressed.ot,
};
case 'batch':
return {
operations: compressed.ops.map(decompressOperation),
@@ -260,7 +290,7 @@ export function estimateOperationSize(operation: CRDTOperation): number {
*/
export function batchOperations(
operations: CRDTOperation[],
maxBatchSize: number = 10
maxBatchSize = 10
): CRDTOperation[][] {
const batches: CRDTOperation[][] = [];
@@ -79,6 +79,12 @@ export function validateOperation(
case 'update-title':
validateUpdateTitle(operation, opts, errors);
break;
case 'add-comment':
validateAddComment(operation, opts, errors);
break;
case 'remove-comment':
validateRemoveComment(operation, errors);
break;
case 'batch':
validateBatchOperation(operation, opts, errors);
break;
@@ -269,6 +275,58 @@ function validateUpdateTitle(operation: any, opts: Required<ValidationOptions>,
}
}
function validateAddComment(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
if (!operation.payload) {
errors.push('Add comment operation requires payload');
return;
}
const { commentId, comment } = operation.payload;
if (!commentId || typeof commentId !== 'string') {
errors.push('Comment ID is required and must be a string');
}
if (!comment || typeof comment !== 'object') {
errors.push('Comment must be an object');
return;
}
if (typeof comment.text !== 'string') {
errors.push('Comment text must be a string');
}
if (typeof comment.username !== 'string') {
errors.push('Comment username must be a string');
}
if (typeof comment.timestamp !== 'number') {
errors.push('Comment timestamp must be a number');
}
// Comments can be longer than titles, so we use a higher limit
// Using 10x the title limit for comments
const maxCommentLength = opts.maxTitleLength * 10;
if (comment.text && comment.text.length > maxCommentLength) {
errors.push(`Comment text exceeds maximum length of ${maxCommentLength} characters`);
}
}
function validateRemoveComment(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
if (!operation.payload) {
errors.push('Remove comment operation requires payload');
return;
}
const { commentId, observedTags } = operation.payload;
if (!commentId || typeof commentId !== 'string') {
errors.push('Comment ID is required and must be a string');
}
if (!Array.isArray(observedTags)) {
errors.push('Observed tags must be an array');
}
}
function validateBatchOperation(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
if (!operation.payload) {
errors.push('Batch operation requires payload');
@@ -11,7 +11,7 @@ import { v4 as uuidv4 } from 'uuid';
import { generateExploreId } from 'app/core/utils/explore';
import { CRDTStateManager } from '../crdt/state';
import { CRDTOperation } from '../crdt/types';
import { CRDTOperation, CommentData } from '../crdt/types';
import { CanvasViewport, SerializedExploreState, UserCursor } from './types';
@@ -455,6 +455,33 @@ const crdtSlice = createSlice({
state.pendingOperations.push(operation);
},
/**
* Add a comment
*/
addComment: (state, action: PayloadAction<{ comment: CommentData }>) => {
const manager = getCRDTManager(state);
const commentId = uuidv4();
const operation = manager.createAddCommentOperation(commentId, action.payload.comment);
manager.applyOperation(operation);
saveCRDTManager(state, manager);
state.pendingOperations.push(operation);
},
/**
* Remove a comment
*/
removeComment: (state, action: PayloadAction<{ commentId: string }>) => {
const manager = getCRDTManager(state);
const operation = manager.createRemoveCommentOperation(action.payload.commentId);
manager.applyOperation(operation);
saveCRDTManager(state, manager);
state.pendingOperations.push(operation);
},
/**
* Duplicate a panel
*/
@@ -610,6 +637,8 @@ export const {
savePanelExploreState,
updatePanelIframeUrl,
updateMapTitle,
addComment,
removeComment,
duplicatePanel,
clearPendingOperations,
updateViewport,
@@ -6,7 +6,9 @@
*/
import { createSelector } from '@reduxjs/toolkit';
import { CRDTStateManager } from '../crdt/state';
import { ExploreMapCRDTState } from './crdtSlice';
import { ExploreMapPanel } from './types';
@@ -98,6 +100,17 @@ export const selectMapTitle = createSelector(
}
);
/**
* Select all comments as an array, sorted by timestamp (newest first)
*/
export const selectComments = createSelector(
[(state: ExploreMapCRDTState) => state],
(state) => {
const manager = getCRDTManager(state);
return manager.getCommentsForUI();
}
);
/**
* Select map UID
*/