/** * ContextMenu — right-click radial menu for analyst actions on cells / rows. * Re-usable across DatasetViewer, NetworkMap, ProcessTree, StorylineGraph, etc. * * Actions: * - Annotate (open annotation dialog) * - Copy value * - Search for value (navigates to Search page) * - Enrich IOC (stub for future) * - Add to hypothesis * - Mark as suspicious / benign */ import React, { useState, useCallback } from 'react'; import { Menu, MenuItem, ListItemIcon, ListItemText, Divider, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, FormControl, InputLabel, Select, Stack, Typography, Chip, } from '@mui/material'; import BookmarkAddIcon from '@mui/icons-material/BookmarkAdd'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import SearchIcon from '@mui/icons-material/Search'; import FlagIcon from '@mui/icons-material/Flag'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import ScienceIcon from '@mui/icons-material/Science'; import { useSnackbar } from 'notistack'; import { useNavigate } from 'react-router-dom'; import { annotations } from '../api/client'; // ── Types ──────────────────────────────────────────────────────────── export interface ContextTarget { /** The cell / node value the user right-clicked on */ value: string; /** The field / column name */ field?: string; /** Dataset ID if applicable */ datasetId?: string; /** Row index if applicable */ rowIndex?: number; /** Extra context for display */ extra?: Record; } interface ContextMenuProps { anchorPosition: { top: number; left: number } | null; target: ContextTarget | null; onClose: () => void; /** Optional callback after an annotation is created */ onAnnotated?: () => void; } // ── Severity + Tag options ─────────────────────────────────────────── const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'] as const; const TAGS = ['suspicious', 'benign', 'needs-review', 'false-positive', 'true-positive', 'escalate'] as const; const SEV_COLORS: Record = { info: 'info', low: 'success', medium: 'warning', high: 'error', critical: 'error', }; // ── Component ──────────────────────────────────────────────────────── export default function ContextMenu({ anchorPosition, target, onClose, onAnnotated }: ContextMenuProps) { const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); const [annOpen, setAnnOpen] = useState(false); const [form, setForm] = useState({ text: '', severity: 'medium', tag: 'suspicious' }); const handleCopy = useCallback(() => { if (target?.value) { navigator.clipboard.writeText(target.value); enqueueSnackbar('Copied to clipboard', { variant: 'info' }); } onClose(); }, [target, enqueueSnackbar, onClose]); const handleSearch = useCallback(() => { if (target?.value) { // Navigate to search page with the value pre-loaded via query param navigate(`/search?q=${encodeURIComponent(target.value)}`); } onClose(); }, [target, navigate, onClose]); const handleQuickAnnotate = useCallback(async (tag: string, severity: string) => { if (!target) return; try { const text = target.field ? `${tag}: ${target.field}="${target.value}"` : `${tag}: "${target.value}"`; await annotations.create({ text, severity, tag, dataset_id: target.datasetId, row_id: target.rowIndex, }); enqueueSnackbar(`Marked as ${tag}`, { variant: 'success' }); onAnnotated?.(); } catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); } onClose(); }, [target, enqueueSnackbar, onClose, onAnnotated]); const handleAnnotateOpen = () => { setForm({ text: target?.field ? `${target.field}="${target.value}"` : target?.value || '', severity: 'medium', tag: 'suspicious', }); setAnnOpen(true); onClose(); }; const handleAnnotateSubmit = async () => { if (!target) return; try { await annotations.create({ text: form.text, severity: form.severity, tag: form.tag, dataset_id: target.datasetId, row_id: target.rowIndex, }); enqueueSnackbar('Annotation created', { variant: 'success' }); onAnnotated?.(); } catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); } setAnnOpen(false); }; const handleHypothesis = () => { // Navigate to hypotheses page — user can create there navigate('/hypotheses'); onClose(); }; return ( <> {/* Context menu */} {target && ( {target.field ? `${target.field}: ` : ''} {String(target.value).slice(0, 60)} )} Copy value Search for this Annotate… handleQuickAnnotate('suspicious', 'high')}> Mark suspicious handleQuickAnnotate('benign', 'info')}> Mark benign handleQuickAnnotate('escalate', 'critical')}> Escalate Add to hypothesis {/* Full annotation dialog */} setAnnOpen(false)} maxWidth="sm" fullWidth> Annotate {target?.field && ( )} setForm(f => ({ ...f, text: e.target.value }))} /> Severity Tag {target?.datasetId && ( Dataset: {target.datasetId} {target.rowIndex != null && ` · Row: ${target.rowIndex}`} )} ); } // ── Hook for easy integration ──────────────────────────────────────── export function useContextMenu() { const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null); const [menuTarget, setMenuTarget] = useState(null); const openMenu = useCallback((e: React.MouseEvent, target: ContextTarget) => { e.preventDefault(); e.stopPropagation(); setMenuPos({ top: e.clientY, left: e.clientX }); setMenuTarget(target); }, []); const closeMenu = useCallback(() => { setMenuPos(null); setMenuTarget(null); }, []); return { menuPos, menuTarget, openMenu, closeMenu }; }