import React, { useRef } from "react"; import { Icon } from "../../tremor/Icon"; import { Input } from "../../tremor/Input"; import { Button } from "../../tremor/Button"; import { useEffect, useState } from "react"; import { create } from "zustand"; import { useDashboard } from "../../layouts/Dashboard/useDashboard"; import { useBackend } from "../../layouts/Wrapper"; import { Text, Label, Title } from "../../tremor/Text"; import { ArrowUturnLeftIcon, ArrowUturnRightIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; import Editor from "@monaco-editor/react"; import { twMerge } from "tailwind-merge"; import { ChevronLeftIcon, ChevronRightIcon, PlayIcon, SparklesIcon, } from "@heroicons/react/20/solid"; import ChartBase from "../Chart/ChartBase"; import { Card } from "../../tremor/Card"; import { toast } from "sonner"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { WidgetWizard } from "../WidgetWizard"; import { LogType, Message, Widget, WidgetSettings } from "@onvo-ai/js"; import { useTheme } from "../../layouts/Dashboard/useTheme"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../tremor/Select"; import { MultiSelect } from "../../tremor/MultiSelect"; import { Divider } from "../../tremor/Divider"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../tremor/Tabs"; import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRoot, TableRow, } from "../../tremor/Table"; import { QuestionMessage } from "../QuestionMessage"; import { PromptInput } from "../PromptInput"; import { Popover, PopoverContent, PopoverTrigger } from "../../tremor/Popover"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ChartPlaceholder } from "../ChartLoader"; const hashCode = function (s: string) { return s.split("").reduce(function (a, b) { a = ((a << 5) - a) + b.charCodeAt(0); return a & a; }, 0); } export const useEditWidgetModal = create<{ open: boolean; widget?: Widget; setOpen: ( open: boolean, widget?: Widget ) => void; }>((set) => ({ open: false, setOpen: ( op: boolean, wid?: Widget ) => set({ open: op, widget: wid }), })); function useDebounce(cb: any, delay: number) { const [debounceValue, setDebounceValue] = useState(cb); useEffect(() => { const handler = setTimeout(() => { setDebounceValue(cb); }, delay); return () => { clearTimeout(handler); }; }, [cb, delay]); return debounceValue; } const getErrorDetails = (err: string) => { if (err.startsWith("FileNotFoundError")) { const regex = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; const datasourceIds = err.match(regex); const deduplicatedIds = [...new Set(datasourceIds)] as string[]; return { label: `The datasource with id ${deduplicatedIds[0]} has been removed from the dashboard or deleted.`, prompt: `Replace the datasource with id ${deduplicatedIds[0]} with an existing datasource.` } } if (err.startsWith("NameError: name 'main' is not defined")) { return { label: `There is no code for this widget to execute.`, prompt: `Rewrite the function 'main' again.` } } if (err.startsWith("ValueError: Usecols do not match columns")) { const regex = /\['([^']+)'\]|\s*'([^']+)'/g; const matches = [...err.matchAll(regex)].map(m => m[1] || m[2]); return { label: `The following columns no longer exist: ${matches.join(", ")}.`, prompt: `The following columns no longer exist: ${matches.join(", ")}. Rewrite using existing columns only.` } } return { label: err, prompt: `Fix the error: \`${err}\`` } } const uuidRegex = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi; export const EditWidgetModal: React.FC<{}> = ({ }) => { const { dashboard, datasources, refreshWidgets, widgets } = useDashboard(); const { backend, adminMode } = useBackend(); const theme = useTheme(); const { open, setOpen, widget } = useEditWidgetModal(); const container = useRef(null); const editorRef = useRef(null); const monacoRef = useRef(null); const decorationsRef = useRef([]); const handleEditorDidMount = (editor: any, monaco: any) => { editorRef.current = editor; monacoRef.current = monaco; // Create a context key service const contextKeyService = editor._contextKeyService; const isUUIDContext = contextKeyService.createKey('isUUIDContext', false); // Update the context key based on the position when context menu is opened editor.onContextMenu((e: any) => { const position = e.target.position; const model = editor.getModel(); if (!position || !model) { isUUIDContext.set(false); return; } // Check if cursor is on or near a UUID const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i; // Get the current line text const lineContent = model.getLineContent(position.lineNumber); // Find all UUIDs in the line const matches = [...lineContent.matchAll(new RegExp(uuidRegex, 'gi'))]; // Check if the cursor is within any UUID const isOnUUID = matches.some(match => { const start = match.index; const end = start + match[0].length; return position.column > start && position.column <= end; }); isUUIDContext.set(isOnUUID); }); // Add the action with a precondition based on the context key editor.addAction({ id: 'go-to-datasource', label: 'Go to datasource', contextMenuGroupId: 'navigation', contextMenuOrder: 0, precondition: 'isUUIDContext', run: (ed: any) => { const position = ed.getPosition(); const model = ed.getModel(); if (!position || !model) return; // Get the current line content const lineContent = model.getLineContent(position.lineNumber); // Find UUIDs in the line const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i; const matches = [...lineContent.matchAll(new RegExp(uuidRegex, 'gi'))]; // Find the UUID at or nearest to cursor position for (const match of matches) { const start = match.index; const end = start + match[0].length; if (position.column > start && position.column <= end) { const uuid = match[0]; const url = `https://dashboard.onvo.ai/datasources/${uuid}`; window.open(url, '_blank'); break; } } }, }); highlightUUIDs(); }; const highlightUUIDs = () => { const editor = editorRef.current as any; const monaco = monacoRef.current as any; if (!editor || !monaco) return; const model = editor.getModel(); if (!model) return; const value = model.getValue(); const matches = [...value.matchAll(uuidRegex)]; const decorations = matches.map((match) => { const start = model.getPositionAt(match.index); const end = model.getPositionAt(match.index + match[0].length); return { range: new monaco.Range( start.lineNumber, start.column, end.lineNumber, end.column ), options: { inlineClassName: "uuid-highlight", }, }; }); decorationsRef.current = editor.deltaDecorations( decorationsRef.current, decorations ); }; // REVERT CHANGES STATES const [history, setHistory] = useState< { code: string, cache: any, messages: any[] }[] >([]); const [historyIndex, setHistoryIndex] = useState(-1); const [tab, setTab] = useState("chat"); const [output, setOutput] = useState(null); const [title, setTitle] = useState(""); const [settings, setSettings] = useState({}); const [code, setCode] = useState(""); const [messages, setMessages] = useState<(Message)[]>([]); const [config, setConfig] = useState({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [explainingCode, setExplainingCode] = useState(false); const [explainedCode, setExplainedCode] = useState(null); const deleteWidget = async (hideToast?: boolean) => { if (!widget || !backend) return; if (hideToast) { await backend.widgets.delete(widget.id); refreshWidgets(backend); setOpen(false); return; } toast.promise( async () => { await backend.widgets.delete(widget.id); refreshWidgets(backend); setOpen(false); }, { loading: "Deleting widget...", success: "Widget deleted", error: (err) => `Failed to delete widget: ${err.message}`, } ); }; const [datasource, setDatasource] = useState<{ label: string, value: string }>({ label: "", value: "" }); const [columns, setColumns] = useState([]); const [groupBy, setGroupBy] = useState(null); const [data, setData] = useState([]); useEffect(() => { highlightUUIDs(); }, [code]); const constructCode = () => { let dfColumns = columns.map(a => `'${a}'`).join(", "); let groupByCode = ""; if (groupBy) { const otherColumns = columns.filter(a => a !== groupBy); groupByCode = ` # Group by ${groupBy} df = df.groupby('${groupBy}').agg({${otherColumns.map(a => `'${a}': 'sum'`).join(',')}}) df = df.reset_index() ` } let newCode = ` def main(): # Load necessary columns from the first CSV file df = pd.read_csv('/tmp/${datasource.value}.csv', usecols=[${dfColumns}]) ${groupByCode} return df `; // @ts-ignore // if (widget?.config?._internal) { if (widget?.engine === "manual-v1") { setCode(newCode); } }; useEffect(() => { constructCode(); }, [ datasource, groupBy, columns, widget ]); const getCache = async () => { if (!widget || !backend) return; let { data: cache } = await backend.widget(widget.id).cache(); if (cache) { setOutput(cache); } setHistory([{ cache: cache, code: widget.code, messages: widget.messages, }]); setHistoryIndex(0); let { data: rawCache } = await backend?.widget(widget.id).cache({ raw: true }); if (rawCache) { setData(rawCache); } }; const updateStates = (widget: Widget | undefined) => { if (widget) { setTitle(widget.title || ""); setCode(widget.code); setSettings( widget.settings || {} ); setError(widget.error || null); setMessages((widget.messages || []) as any[]); let conf = widget.config as any; let meta = conf._internal; delete conf._internal; setConfig(conf); if (meta) { setDatasource({ label: meta.datasource, value: meta.datasource }); setColumns(meta.columns); setGroupBy(meta.groupBy); } } else { setTitle(""); setCode(""); setOutput(null); setMessages([]); setError(null); setConfig({}); setColumns([]); setGroupBy(null); } }; useEffect(() => { if (widget) { updateStates(widget); } else { updateStates(undefined); } }, [widget]); useEffect(() => { if (!widget) return; if (!backend) return; getCache(); }, [widget, backend]); useEffect(() => { if (!data || data.length === 0) return; if (!config || Object.keys(config).length == 0 || !config.options || !config.options.plugins) { return; } let columns = Object.keys(data[0]); let out = JSON.stringify(config); columns.forEach(col => { out = out.replace(`"{{data['${col}']}}"`, JSON.stringify(data.map((a: any) => a[col]))) }); setOutput(JSON.parse(out)); }, [data, config]); const saveChanges = () => { if (!widget || !backend) return; toast.promise( async () => { await backend.widgets.update(widget.id, { code: code, settings: settings, messages: [], config: { ...config, _internal: { datasource: datasource.value, columns: columns, groupBy: groupBy } }, error: null, }); let data = await backend?.widget(widget.id).updateCache(); return true; }, { loading: "Saving changes...", success: () => { refreshWidgets(backend); return "Changes saved!"; }, error: (error) => "Failed to save changes: " + error.message, } ); }; const requestEditWidget = async (msg: Message[]) => { if (!widget || !backend) return; setCode(""); setLoading(true); try { let wid = await backend.widget(widget.id).updatePrompts(msg as any[], (event) => { // console.log("PROGRESS: ", event); }); updateStates(wid); let { data: cacheData } = await backend?.widget(widget.id).cache(); setHistory(hist => [...hist, { cache: cacheData, code: wid?.code || "", messages: msg, }]); setHistoryIndex(hist => hist + 1); toast.success("Your widget has been updated!"); setOutput(cacheData); setLoading(false); if (backend) { refreshWidgets(backend); backend.logs.create({ type: LogType.EditWidget, dashboard: widget.dashboard, widget: widget.id, }) } } catch (e: any) { toast.error("Failed to create widget: " + e.message); setLoading(false); } }; const goBack = async () => { if (!backend) return; if (code === "" && messages.length === 0) { await deleteWidget(true); } refreshWidgets(backend, widget?.id); setOpen(false); } const debounceValue = useDebounce(title, 200); useEffect(() => { if (widget) { backend?.widgets.update(widget?.id, { title: debounceValue, }); } }, [debounceValue]); useEffect(() => { const handleBeforeUnload = (event: any) => { if (code === "" && messages.length === 0 && open) { // Call your cleanup or save function here console.log("Tab is closing — running cleanup!"); myCleanupFunction(); // If you want to show a confirmation dialog: event.preventDefault(); event.returnValue = ""; // Required for Chrome } }; window.addEventListener("beforeunload", handleBeforeUnload); // Cleanup the event listener when component unmounts return () => { window.removeEventListener("beforeunload", handleBeforeUnload); }; }, [open, code, messages]); const myCleanupFunction = async () => { if (!backend || !widget) return; await backend.widgets.delete(widget.id); refreshWidgets(backend); }; const addToLibrary = async () => { if (!backend) return; let widgetCopy = { ...widget, use_in_library: true }; toast.promise( async () => { let wid = await backend?.widgets.create(widgetCopy as any); let data = await backend.widget(wid.id).updateCache(); return wid; }, { loading: "Adding widget to library...", success: (widget) => { refreshWidgets(backend); return "Widget added to library"; }, error: (error) => "Failed to add widget to library: " + error.message, } ); } const explainCode = async (str: string) => { if (!widget || !backend) return; setExplainingCode(true); setExplainedCode(null); try { let result = await backend.widget(widget.id).annotateCode(); setExplainedCode(result.insights); } catch (e: any) { toast.error("Failed to explain code: " + e.message); } setExplainingCode(false); }; const executeCode = async (str: string) => { if (!widget || !backend) return; if (str.search("pd.read_csv") === -1) return; setLoading(true); toast.promise( async () => { await backend.widgets.update(widget.id, { code: str }); let data = await backend.widget(widget.id).updateCache(); let { data: newData } = await backend.widget(widget.id).cache(); setOutput(newData); setHistory(hist => [...hist, { cache: newData, code: code, messages: widget.messages, }]); setHistoryIndex(hist => hist + 1); let { data: rawCache } = await backend?.widget(widget.id).cache({ raw: true }); if (rawCache) { setData(rawCache); } if (backend) { backend.logs.create({ type: LogType.EditWidget, dashboard: widget.dashboard, widget: widget.id, }) } }, { loading: "Executing code...", success: () => { setLoading(false); return "Successfully executed code"; }, error: (e: any) => { setLoading(false); return "Failed to execute code: " + e.message; }, } ); }; if (!dashboard?.settings?.can_create_widgets && !adminMode) return <>; const Preview = loading ? ( ) : (code === "" && messages.length === 0) ? ( ) : output ? ( ) : <> return ( <>
{dashboard?.title} {widget ? setTitle(val.target.value)} /> : }
0) ? 80 : 100} minSize={20}>
{Preview}
{(data && data.length > 0) && (<>
{columns.map(a => ( {a} ))} {data.map((item, index) => ( {columns.map(a => ( {item[a]} ))} ))}
)}
setTab(value)} className="onvo-flex onvo-flex-col onvo-h-full">
{widget?.engine !== "manual-v1" && ( Chat )} {widget?.engine === "manual-v1" && ( Configure )} {adminMode && ( Code )} {adminMode && ( Settings )}
{tab === "chat" && (
0 ? "onvo-opacity-100" : "onvo-opacity-30" } onClick={() => { if (historyIndex === 0) { return; } let newIndex = Math.max(0, historyIndex - 1); setHistoryIndex(newIndex); let cachedWidget = history[newIndex]; setCode(cachedWidget.code); setMessages(cachedWidget.messages || []); setOutput( cachedWidget && cachedWidget.cache ? cachedWidget.cache : {} ); }} /> { if (historyIndex === history.length - 1) { return; } let newIndex = Math.min(history.length - 1, historyIndex + 1); setHistoryIndex(newIndex); let cachedWidget = history[newIndex]; setCode(cachedWidget.code); setMessages(cachedWidget.messages || []); setOutput( cachedWidget && cachedWidget.cache ? cachedWidget.cache : {} ); }} />
)} {tab === "code" && (
{ if (!open) setExplainedCode(null); }} > {explainingCode ? (
Explaining code...
) : (
{explainedCode || ""}
)}
)} {tab === "settings" && ( )} {widget?.engine !== "manual-v1" && (
{messages.map((message, index) => ( { }} onReply={(msg) => { let newMessages = [...messages, { role: "user" as const, content: msg, }]; requestEditWidget(newMessages as any[]); setMessages(newMessages as any[]); }} onEdit={(msg) => { let newMessages = messages.map((m, i) => { if (i === index) { return { ...m, content: msg, }; } return m; }); requestEditWidget(newMessages); setMessages(newMessages); }} /> ))} {(error && !loading) ? (
The widget failed to update during the last cache refresh. Would you like me to attempt to fix the issue?
STACKTRACE
{getErrorDetails(error).label}
) : <>}
{ setMessages([...messages, { role: "user", content: msg }] as any[]) requestEditWidget([...messages, { role: "user", content: msg }] as any[]) }} />
)} {widget?.engine === "manual-v1" && (
{(datasource && datasource.value) && (<>
a.id === datasource.value)?.columns.map((item) => ({ label: item.title, value: item.title })) || []} />
{columns.length > 0 && (
{ setGroupBy(null); }} />
)} )}
{(columns.length > 0) && (<> { setConfig(val); }} config={config} />
)}
)} {adminMode && (
setCode(val || "")} />
)} {adminMode && (
Widget ID
CSS ID setSettings({ ...settings, css_id: e.target.value })} inputClassName="onvo-background-color onvo-border-black/10 dark:onvo-border-white/10" />
CSS classes setSettings({ ...settings, css_classnames: e.target.value })} inputClassName="onvo-background-color onvo-border-black/10 dark:onvo-border-white/10" />
Link setSettings({ ...settings, link: e.target.value })} inputClassName="onvo-background-color onvo-border-black/10 dark:onvo-border-white/10" />
Drilldown widget
)}
); };