From fe8a7c052af2543c2bbd00fccef470fc892953fb Mon Sep 17 00:00:00 2001 From: Christian Simon Date: Thu, 4 Dec 2025 11:18:57 +0000 Subject: [PATCH] Add colour and emoji to frames --- .../components/ExploreMapFrame.tsx | 261 +++++++++++++++++- public/app/features/explore-map/crdt/state.ts | 105 ++++++- public/app/features/explore-map/crdt/types.ts | 32 +++ .../features/explore-map/state/crdtSlice.ts | 60 +++- .../app/features/explore-map/state/types.ts | 2 + 5 files changed, 447 insertions(+), 13 deletions(-) diff --git a/public/app/features/explore-map/components/ExploreMapFrame.tsx b/public/app/features/explore-map/components/ExploreMapFrame.tsx index 3625071ddeb..e50aa4b6cc7 100644 --- a/public/app/features/explore-map/components/ExploreMapFrame.tsx +++ b/public/app/features/explore-map/components/ExploreMapFrame.tsx @@ -1,15 +1,20 @@ -import { css } from '@emotion/css'; -import React, { useCallback, useState } from 'react'; +import { css, cx } from '@emotion/css'; +import React, { useCallback, useState, useRef } from 'react'; +import ReactDOM from 'react-dom'; import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd'; +import { useClickAway } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { IconButton, useStyles2 } from '@grafana/ui'; + import { useDispatch, useSelector } from 'app/types/store'; import { updateFramePosition, updateFrameSize, updateFrameTitle, + updateFrameColor, + updateFrameEmoji, setActiveFrameDrag, clearActiveFrameDrag, associatePanelWithFrame, @@ -25,6 +30,26 @@ interface ExploreMapFrameProps { frame: Frame; } +// Predefined color palette for frames +const FRAME_COLORS = [ + { name: 'Blue', value: '#6e9fff' }, + { name: 'Green', value: '#73bf69' }, + { name: 'Yellow', value: '#fade2a' }, + { name: 'Orange', value: '#ff9830' }, + { name: 'Red', value: '#f2495c' }, + { name: 'Purple', value: '#b877d9' }, + { name: 'Pink', value: '#fe85b4' }, + { name: 'Cyan', value: '#5dc4cd' }, + { name: 'Gray', value: '#9fa7b3' }, +]; + +// Predefined emoji options for frames +const FRAME_EMOJIS = [ + '📦', '📊', '📈', '🎯', '🔥', '⭐', '💡', '🚀', + '📝', '🎨', '🔧', '⚙️', '🏆', '🎪', '🌟', '💼', + '📌', '🔍', '📱', '💻', '🖥️', '⚡', '🌈', '🎭', +]; + function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) { const styles = useStyles2(getStyles); const dispatch = useDispatch(); @@ -36,6 +61,14 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) { const [titleValue, setTitleValue] = useState(frame.title); const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showColorPicker, setShowColorPicker] = useState(false); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const colorPickerRef = useRef(null); + const emojiPickerRef = useRef(null); + const colorButtonRef = useRef(null); + const emojiButtonRef = useRef(null); + const [colorPickerPosition, setColorPickerPosition] = useState<{ top: number; left: number } | null>(null); + const [emojiPickerPosition, setEmojiPickerPosition] = useState<{ top: number; left: number } | null>(null); // Track current size during resize for visual feedback const [currentSize, setCurrentSize] = useState({ @@ -159,7 +192,7 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) { // Update panel-frame associations based on current frame bounds // This should only handle NEW panels entering the frame, not existing associations const updatePanelAssociations = useCallback( - (frameX: number, frameY: number, frameWidth: number, frameHeight: number, skipExisting: boolean = false) => { + (frameX: number, frameY: number, frameWidth: number, frameHeight: number, skipExisting = false) => { for (const [panelId, panel] of Object.entries(allPanels)) { // Skip panels that are already associated with this frame if skipExisting is true // This prevents recalculating offsets for panels that moved with the frame @@ -367,6 +400,73 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) { setShowDeleteDialog(false); }, []); + // Close pickers when clicking outside + useClickAway(colorPickerRef, () => { + setShowColorPicker(false); + }); + + useClickAway(emojiPickerRef, () => { + setShowEmojiPicker(false); + }); + + const handleColorButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!showColorPicker && colorButtonRef.current) { + const rect = colorButtonRef.current.getBoundingClientRect(); + setColorPickerPosition({ + top: rect.bottom + 4, + left: rect.left, + }); + } + setShowColorPicker(!showColorPicker); + setShowEmojiPicker(false); + }, + [showColorPicker] + ); + + const handleColorSelect = useCallback( + (color: string) => { + dispatch( + updateFrameColor({ + frameId: frame.id, + color, + }) + ); + setShowColorPicker(false); + }, + [dispatch, frame.id] + ); + + const handleEmojiButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!showEmojiPicker && emojiButtonRef.current) { + const rect = emojiButtonRef.current.getBoundingClientRect(); + setEmojiPickerPosition({ + top: rect.bottom + 4, + left: rect.left, + }); + } + setShowEmojiPicker(!showEmojiPicker); + setShowColorPicker(false); + }, + [showEmojiPicker] + ); + + const handleEmojiSelect = useCallback( + (emoji: string) => { + dispatch( + updateFrameEmoji({ + frameId: frame.id, + emoji, + }) + ); + setShowEmojiPicker(false); + }, + [dispatch, frame.id] + ); + return (
+ {/* Emoji button */} + + + {/* Title display/input */} {isEditingTitle ? ( e.stopPropagation()} /> ) : ( @@ -418,6 +530,17 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) { {frame.title}
)} + + {/* Color picker button */} + + ))} + , + document.body + )} + + {showColorPicker && colorPickerPosition && ReactDOM.createPortal( +
+ {FRAME_COLORS.map((color) => ( +
, + document.body + )}
); } @@ -485,6 +655,51 @@ const getStyles = (theme: GrafanaTheme2) => ({ deleteButton: css({ cursor: 'pointer', }), + emojiButton: css({ + fontSize: '20px', + cursor: 'pointer', + userSelect: 'none', + padding: theme.spacing(0.25, 0.5), + border: `1px solid ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.default, + backgroundColor: theme.colors.background.primary, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + '&:hover': { + backgroundColor: theme.colors.background.secondary, + }, + }), + emojiPicker: css({ + display: 'grid', + gridTemplateColumns: 'repeat(8, 1fr)', + gap: theme.spacing(0.5), + padding: theme.spacing(1), + backgroundColor: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.default, + boxShadow: theme.shadows.z3, + zIndex: 10000, + minWidth: '240px', + }), + emojiOption: css({ + fontSize: '20px', + padding: theme.spacing(0.5), + border: `1px solid transparent`, + borderRadius: theme.shape.radius.default, + backgroundColor: 'transparent', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + '&:hover': { + backgroundColor: theme.colors.background.secondary, + }, + }), + emojiOptionSelected: css({ + border: `1px solid ${theme.colors.primary.border}`, + backgroundColor: theme.colors.action.selected, + }), frameTitle: css({ fontSize: theme.typography.body.fontSize, fontWeight: theme.typography.fontWeightMedium, @@ -493,11 +708,47 @@ const getStyles = (theme: GrafanaTheme2) => ({ frameTitleInput: css({ fontSize: theme.typography.body.fontSize, fontWeight: theme.typography.fontWeightMedium, - padding: 0, + padding: theme.spacing(0.5), border: 'none', outline: `2px solid ${theme.colors.primary.border}`, backgroundColor: theme.colors.background.primary, borderRadius: theme.shape.radius.default, minWidth: '100px', }), + colorButton: css({ + width: '24px', + height: '24px', + padding: 0, + border: `1px solid ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.default, + cursor: 'pointer', + '&:hover': { + opacity: 0.8, + }, + }), + colorPicker: css({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: theme.spacing(0.5), + padding: theme.spacing(1), + backgroundColor: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.default, + boxShadow: theme.shadows.z3, + zIndex: 10000, + }), + colorOption: css({ + width: '32px', + height: '32px', + padding: 0, + border: `2px solid transparent`, + borderRadius: theme.shape.radius.default, + cursor: 'pointer', + '&:hover': { + opacity: 0.8, + }, + }), + colorOptionSelected: css({ + border: `2px solid ${theme.colors.text.primary}`, + }), }); diff --git a/public/app/features/explore-map/crdt/state.ts b/public/app/features/explore-map/crdt/state.ts index ae8fdb2e2ba..fe196f61c20 100644 --- a/public/app/features/explore-map/crdt/state.ts +++ b/public/app/features/explore-map/crdt/state.ts @@ -34,6 +34,8 @@ import { UpdateFramePositionOperation, UpdateFrameSizeOperation, UpdateFrameTitleOperation, + UpdateFrameColorOperation, + UpdateFrameEmojiOperation, AssociatePanelWithFrameOperation, DisassociatePanelFromFrameOperation, AddPostItOperation, @@ -201,6 +203,8 @@ export class CRDTStateManager { zIndex: data.zIndex.get(), }, createdBy: data.createdBy.get(), + color: data.color.get(), + emoji: data.emoji.get(), remoteVersion: data.remoteVersion, }; } @@ -475,7 +479,9 @@ export class CRDTStateManager { frameId: string, title: string, position: { x: number; y: number; width: number; height: number }, - createdBy?: string + createdBy?: string, + color?: string, + emoji?: string ): AddFrameOperation { const timestamp = this.clock.tick(); return { @@ -484,7 +490,7 @@ export class CRDTStateManager { operationId: uuidv4(), timestamp, nodeId: this.nodeId, - payload: { frameId, title, position, createdBy }, + payload: { frameId, title, position, createdBy, color, emoji }, }; } @@ -579,6 +585,50 @@ export class CRDTStateManager { }; } + /** + * Create an update frame color operation + */ + createUpdateFrameColorOperation( + frameId: string, + color: string | undefined + ): UpdateFrameColorOperation | null { + if (!this.state.frames.contains(frameId)) { + return null; + } + + const timestamp = this.clock.tick(); + return { + type: 'update-frame-color', + mapUid: this.mapUid, + operationId: uuidv4(), + timestamp, + nodeId: this.nodeId, + payload: { frameId, color }, + }; + } + + /** + * Create an update frame emoji operation + */ + createUpdateFrameEmojiOperation( + frameId: string, + emoji: string | undefined + ): UpdateFrameEmojiOperation | null { + if (!this.state.frames.contains(frameId)) { + return null; + } + + const timestamp = this.clock.tick(); + return { + type: 'update-frame-emoji', + mapUid: this.mapUid, + operationId: uuidv4(), + timestamp, + nodeId: this.nodeId, + payload: { frameId, emoji }, + }; + } + /** * Create an associate panel with frame operation */ @@ -928,6 +978,10 @@ export class CRDTStateManager { return this.applyUpdateFrameSize(operation); case 'update-frame-title': return this.applyUpdateFrameTitle(operation); + case 'update-frame-color': + return this.applyUpdateFrameColor(operation); + case 'update-frame-emoji': + return this.applyUpdateFrameEmoji(operation); case 'associate-panel-with-frame': return this.applyAssociatePanelWithFrame(operation); case 'disassociate-panel-from-frame': @@ -948,12 +1002,15 @@ export class CRDTStateManager { return this.applyUpdatePostItColor(operation); case 'batch': return this.applyBatchOperation(operation); - default: + default: { + // TypeScript exhaustiveness check - this should never happen at runtime + const exhaustiveCheck: never = operation; return { success: false, applied: false, - error: `Unknown operation type: ${(operation as CRDTOperation).type}`, + error: `Unknown operation type: ${(exhaustiveCheck as { type: string }).type}`, }; + } } } catch (error) { return { @@ -1309,7 +1366,7 @@ export class CRDTStateManager { } private applyAddFrame(operation: AddFrameOperation): OperationResult { - const { frameId, title, position, createdBy } = operation.payload; + const { frameId, title, position, createdBy, color, emoji } = operation.payload; // Add to OR-Set with operation ID as tag this.state.frames.add(frameId, operation.operationId); @@ -1328,6 +1385,8 @@ export class CRDTStateManager { width: new LWWRegister(position.width, operation.timestamp), height: new LWWRegister(position.height, operation.timestamp), zIndex: new LWWRegister(zIndex, operation.timestamp), + color: new LWWRegister(color, operation.timestamp), + emoji: new LWWRegister(emoji, operation.timestamp), createdBy: new LWWRegister(createdBy, operation.timestamp), remoteVersion: 0, }); @@ -1402,6 +1461,32 @@ export class CRDTStateManager { return { success: true, applied: updated }; } + private applyUpdateFrameColor(operation: UpdateFrameColorOperation): OperationResult { + const { frameId, color } = operation.payload; + const frameData = this.state.frameData.get(frameId); + + if (!frameData) { + return { success: true, applied: false, error: 'Frame not found' }; + } + + const updated = frameData.color.set(color, operation.timestamp); + + return { success: true, applied: updated }; + } + + private applyUpdateFrameEmoji(operation: UpdateFrameEmojiOperation): OperationResult { + const { frameId, emoji } = operation.payload; + const frameData = this.state.frameData.get(frameId); + + if (!frameData) { + return { success: true, applied: false, error: 'Frame not found' }; + } + + const updated = frameData.emoji.set(emoji, operation.timestamp); + + return { success: true, applied: updated }; + } + private applyAssociatePanelWithFrame(operation: AssociatePanelWithFrameOperation): OperationResult { const { panelId, frameId, offsetX, offsetY } = operation.payload; const panelData = this.state.panelData.get(panelId); @@ -1542,6 +1627,8 @@ export class CRDTStateManager { width: otherFrameData.width.clone(), height: otherFrameData.height.clone(), zIndex: otherFrameData.zIndex.clone(), + color: otherFrameData.color.clone(), + emoji: otherFrameData.emoji.clone(), createdBy: otherFrameData.createdBy.clone(), remoteVersion: otherFrameData.remoteVersion, }); @@ -1553,6 +1640,8 @@ export class CRDTStateManager { myFrameData.width.merge(otherFrameData.width); myFrameData.height.merge(otherFrameData.height); myFrameData.zIndex.merge(otherFrameData.zIndex); + myFrameData.color.merge(otherFrameData.color); + myFrameData.emoji.merge(otherFrameData.emoji); myFrameData.createdBy.merge(otherFrameData.createdBy); } } @@ -1594,7 +1683,7 @@ export class CRDTStateManager { } } - const frameData: Record = {}; + const frameData: CRDTExploreMapStateJSON['frameData'] = {}; for (const [frameId, data] of this.state.frameData.entries()) { if (this.state.frames.contains(frameId)) { frameData[frameId] = { @@ -1605,6 +1694,8 @@ export class CRDTStateManager { width: data.width.toJSON(), height: data.height.toJSON(), zIndex: data.zIndex.toJSON(), + color: data.color.toJSON(), + emoji: data.emoji.toJSON(), createdBy: data.createdBy.toJSON(), remoteVersion: data.remoteVersion, }; @@ -1715,6 +1806,8 @@ export class CRDTStateManager { width: LWWRegister.fromJSON(data.width), height: LWWRegister.fromJSON(data.height), zIndex: LWWRegister.fromJSON(data.zIndex), + color: data.color ? LWWRegister.fromJSON(data.color) : new LWWRegister(undefined, defaultTimestamp), + emoji: data.emoji ? LWWRegister.fromJSON(data.emoji) : new LWWRegister(undefined, defaultTimestamp), createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp), remoteVersion: data.remoteVersion || 0, }); diff --git a/public/app/features/explore-map/crdt/types.ts b/public/app/features/explore-map/crdt/types.ts index a12cbdf5df7..1a98fbda13b 100644 --- a/public/app/features/explore-map/crdt/types.ts +++ b/public/app/features/explore-map/crdt/types.ts @@ -62,6 +62,8 @@ export interface CRDTFrameData { width: LWWRegister; height: LWWRegister; zIndex: LWWRegister; + color: LWWRegister; + emoji: LWWRegister; // Creator metadata (username of who created the frame) createdBy: LWWRegister; @@ -214,6 +216,8 @@ export interface CRDTExploreMapStateJSON { width: { value: number; timestamp: HLCTimestamp }; height: { value: number; timestamp: HLCTimestamp }; zIndex: { value: number; timestamp: HLCTimestamp }; + color?: { value: string | undefined; timestamp: HLCTimestamp }; + emoji?: { value: string | undefined; timestamp: HLCTimestamp }; createdBy?: { value: string | undefined; timestamp: HLCTimestamp }; remoteVersion?: number; }>; @@ -242,6 +246,8 @@ export type CRDTOperationType = | 'update-frame-position' | 'update-frame-size' | 'update-frame-title' + | 'update-frame-color' + | 'update-frame-emoji' | 'associate-panel-with-frame' | 'disassociate-panel-from-frame' | 'add-postit' @@ -399,6 +405,8 @@ export interface AddFrameOperation extends CRDTOperationBase { height: number; }; createdBy?: string; + color?: string; + emoji?: string; }; } @@ -450,6 +458,28 @@ export interface UpdateFrameTitleOperation extends CRDTOperationBase { }; } +/** + * Update frame color operation + */ +export interface UpdateFrameColorOperation extends CRDTOperationBase { + type: 'update-frame-color'; + payload: { + frameId: string; + color: string | undefined; + }; +} + +/** + * Update frame emoji operation + */ +export interface UpdateFrameEmojiOperation extends CRDTOperationBase { + type: 'update-frame-emoji'; + payload: { + frameId: string; + emoji: string | undefined; + }; +} + /** * Associate panel with frame operation */ @@ -592,6 +622,8 @@ export type CRDTOperation = | UpdateFramePositionOperation | UpdateFrameSizeOperation | UpdateFrameTitleOperation + | UpdateFrameColorOperation + | UpdateFrameEmojiOperation | AssociatePanelWithFrameOperation | DisassociatePanelFromFrameOperation | AddPostItOperation diff --git a/public/app/features/explore-map/state/crdtSlice.ts b/public/app/features/explore-map/state/crdtSlice.ts index ca62dfabe78..5a7a781cc58 100644 --- a/public/app/features/explore-map/state/crdtSlice.ts +++ b/public/app/features/explore-map/state/crdtSlice.ts @@ -767,6 +767,8 @@ const crdtSlice = createSlice({ position?: { x: number; y: number; width: number; height: number }; title?: string; createdBy?: string; + color?: string; + emoji?: string; }>) => { const manager = getCRDTManager(state); @@ -775,12 +777,16 @@ const crdtSlice = createSlice({ const position = action.payload.position || { x: 200, y: 200, width: 800, height: 600 }; + const color = action.payload.color || '#6e9fff'; // Default blue color + const emoji = action.payload.emoji || '🔍'; // Default magnifying glass emoji const operation = manager.createAddFrameOperation( frameId, title, position, - action.payload.createdBy + action.payload.createdBy, + color, + emoji ); manager.applyOperation(operation); @@ -852,7 +858,7 @@ const crdtSlice = createSlice({ } // Create operations for all child panels - const panelOps: any[] = []; + const panelOps: CRDTOperation[] = []; const childPanelIds = manager.getPanelsInFrame(action.payload.frameId); for (const panelId of childPanelIds) { @@ -930,6 +936,54 @@ const crdtSlice = createSlice({ state.pendingOperations.push(operation); }, + /** + * Update frame color + */ + updateFrameColor: (state, action: PayloadAction<{ + frameId: string; + color: string | undefined; + }>) => { + const manager = getCRDTManager(state); + + const operation = manager.createUpdateFrameColorOperation( + action.payload.frameId, + action.payload.color + ); + + if (!operation) { + return; + } + + manager.applyOperation(operation); + saveCRDTManager(state, manager); + + state.pendingOperations.push(operation); + }, + + /** + * Update frame emoji + */ + updateFrameEmoji: (state, action: PayloadAction<{ + frameId: string; + emoji: string | undefined; + }>) => { + const manager = getCRDTManager(state); + + const operation = manager.createUpdateFrameEmojiOperation( + action.payload.frameId, + action.payload.emoji + ); + + if (!operation) { + return; + } + + manager.applyOperation(operation); + saveCRDTManager(state, manager); + + state.pendingOperations.push(operation); + }, + /** * Associate panel with frame */ @@ -1130,6 +1184,8 @@ export const { updateFramePosition, updateFrameSize, updateFrameTitle, + updateFrameColor, + updateFrameEmoji, associatePanelWithFrame, disassociatePanelFromFrame, clearPendingOperations, diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts index 739480bb023..f0c76cd4ab8 100644 --- a/public/app/features/explore-map/state/types.ts +++ b/public/app/features/explore-map/state/types.ts @@ -55,6 +55,8 @@ export interface ExploreMapFrame { position: PanelPosition; // Reuse position type createdBy?: string; remoteVersion?: number; + color?: string; // Frame border color (hex color string) + emoji?: string; // Emoji to display on frame } export interface CanvasViewport {