import React, { useEffect, useMemo, useRef, useState } from "react"; import { EllipsisVerticalIcon } from "@heroicons/react/20/solid"; import { toast } from "sonner"; import { LogType, Widget } from "@onvo-ai/js"; import { ArrowsRightLeftIcon, DocumentChartBarIcon, DocumentDuplicateIcon, FunnelIcon, PencilSquareIcon, PhotoIcon, TrashIcon, } from "@heroicons/react/24/outline"; import ChartBase from "./ChartBase"; import { Card } from "../../tremor/Card"; import { Icon } from "../../tremor/Icon"; import { useDashboard } from "../../layouts/Dashboard/useDashboard"; import { useBackend } from "../../layouts/Wrapper"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuIconWrapper, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSubMenu, DropdownMenuSubMenuContent, DropdownMenuSubMenuTrigger, DropdownMenuTrigger, } from "../../tremor/DropdownMenu"; import { useTextWidgetModal } from "../TextWidgetModal"; import { useImageWidgetModal } from "../ImageWidgetModal"; import { useEditWidgetModal } from "../EditWidgetModal"; import { useTheme } from "../../layouts/Dashboard/useTheme"; import { ExclamationTriangleIcon } from "@heroicons/react/16/solid"; import { SortDirection } from "react-data-grid"; import { usePagination } from "./usePagination"; import { PaginationComponent } from "./PaginationComponent"; import { ChartPlaceholder } from "../ChartLoader"; import { useDrilldownModal } from "../DrilldownModal"; const DragHandle = () => { return (
); }; const ChartCard: React.FC<{ widget: Widget; className?: string; hideOptions?: boolean; footer?: React.ReactNode; }> = ({ widget, className, footer, hideOptions }) => { const { dashboard, refreshWidgets, tab, queue } = useDashboard(); const { setOpen: setEditModalOpen } = useEditWidgetModal(); const { setOpen: setTextModalOpen } = useTextWidgetModal(); const { setOpen: setImageModalOpen } = useImageWidgetModal(); const { setOpen: setDrilldownModalOpen } = useDrilldownModal(); const { backend, adminMode, containerRef } = useBackend(); const theme = useTheme(); const cardRef = useRef(null); const [cache, setCache] = useState(null); // Use pagination store const { pagination, setStats, stats, setPagination, resetPagination } = usePagination(); const duplicate = async () => { let newObj: any = { ...widget }; delete newObj.id; if (!backend) return; toast.promise( async () => { let wid = await backend.widgets.create(newObj); let data = await backend.widget(wid.id).updateCache(); return wid; }, { loading: "Duplicating widget...", success: (newWidget) => { refreshWidgets(backend); backend.logs.create({ type: LogType.CreateWidget, dashboard: widget.dashboard, widget: newWidget.id, }) return "Widget duplicated"; }, error: (error) => "Error duplicating widget: " + error.message, } ); }; const moveTab = async (newTab: number) => { let newObj: any = { ...widget, tab: newTab }; if (!backend) return; toast.promise( () => { return backend.widgets.update(widget.id, newObj) as Promise; }, { loading: "Moving widget...", success: (newWidget) => { refreshWidgets(backend); return "Widget moved"; }, error: (error) => "Error moving widget: " + error.message, } ); }; const deleteWidget = async () => { if (!backend) return; toast.promise( () => { return backend.widgets.delete(widget.id); }, { loading: "Deleting widget...", success: () => { backend.logs.create({ type: LogType.DeleteWidget, dashboard: widget.dashboard }); refreshWidgets(backend); return "Widget deleted"; }, error: (error) => "Error deleting widget: " + error.message, } ); }; const exportWidget = (format: "svg" | "png" | "csv" | "xlsx" | "jpeg") => { if (!backend) return; toast.promise( () => { return backend.widget(widget.id).export(format, theme, (event) => { console.log("EVENT: ", event); }); }, { loading: `Exporting widget as ${format}...`, success: async (output: any) => { try { // Get the file URL, replacing host.docker.internal with localhost if needed const fileUrl = output.url.replace("host.docker.internal", "localhost"); // Create a link element const link = document.createElement('a'); link.href = fileUrl; link.download = output.fileName || `export.${format}`; // Set the filename link.style.display = 'none'; // Add to document, click and remove document.body.appendChild(link); link.click(); // Clean up setTimeout(() => { document.body.removeChild(link); }, 100); } catch (error) { console.error('Download failed:', error); toast.error('Download failed. Please try again.'); } return "Widget exported"; }, error: (error) => "Error exporting widget: " + error.message, } ); }; // Function to fetch paginated data from server const fetchPaginatedData = async (page: number, size: number, column?: string, direction?: SortDirection) => { if (!backend || !widget || !widget.code || widget.code.trim() === "") return; try { // Fetch paginated data from server const { data, pagination: paginationData } = await queue.add(() => backend.widget(widget.id).cache({ page, pageSize: size, sortField: column, sortDirection: direction === "DESC" ? "desc" : "asc" })); setCache(data); // Update cache with paginated data if (paginationData) { // Update pagination metadata setStats({ totalRows: paginationData.totalRows || 0, totalPages: paginationData.totalPages || 1 }); } } catch (error) { console.error("Error fetching paginated data:", error); } }; // Handle sort change from pagination store const updatePagination = (newPagination: any) => { if (!newPagination) return; fetchPaginatedData(pagination.currentPage, pagination.pageSize, pagination.sortColumn || undefined, pagination.sortDirection || undefined); setPagination(newPagination); }; useEffect(() => { if (!widget || !backend) return; // Function to fetch cache from server const fetchServerCache = async () => { if (!backend || !widget) return null; try { const { data: cacheData } = await queue.add(() => backend.widget(widget.id).cache()); // Update component state setCache(cacheData); } catch (error) { console.error("Error fetching widget cache from server:", error); } }; // Fetch data from server fetchServerCache(); // Reset pagination when widget changes return () => { resetPagination(); }; }, [widget, backend]); const layout_editable = adminMode || dashboard?.settings?.can_edit_widget_layout; const widget_editable = adminMode || dashboard?.settings?.can_edit_widgets; const addable = adminMode || dashboard?.settings?.can_create_widgets; const deletable = adminMode || dashboard?.settings?.can_delete_widgets; const ImageDownloadEnabled = useMemo(() => { if (adminMode) return true; if (dashboard?.settings && dashboard.settings.disable_download_images) return false; return true; }, [dashboard, widget, adminMode]); const ReportDownloadEnabled = useMemo(() => { if (adminMode) return true; if (dashboard?.settings && dashboard.settings.disable_download_reports) return false; return true; }, [dashboard, widget, adminMode]); const error = useMemo(() => { if (widget && widget.error && widget_editable) { return (
setEditModalOpen(true, widget)} className="onvo-absolute onvo-right-4 onvo-bottom-4 onvo-rounded-full onvo-shadow-lg onvo-foreground-color onvo-z-10 onvo-border onvo-border-black/5 dark:onvo-border-white/10">
) } return <>; }, [widget]); const moveDropdown = useMemo(() => { const editable = adminMode || dashboard?.settings?.can_edit_tabs; if ((dashboard?.tabs || []).length <= 1) { return <>; } if (!editable) return; return ( Move widget
{dashboard?.tabs.map(a => ( moveTab(a.id)}> {a.label} ))}
) }, [tab, dashboard]); const border_radius = useMemo(() => { return dashboard?.settings?.widget_border_radius === undefined ? 16 : dashboard?.settings?.widget_border_radius; }, [dashboard]); if (widget.type === "divider") { return (
{layout_editable && !hideOptions && } {deletable && !hideOptions && (
{deletable && ( Delete widget )}
)} {footer}
); } if (widget.type === "text") { let config = (widget.config as any); let sub = config?.options?.plugins?.subtitle.text || ""; if (typeof sub !== "string") { sub = sub.join("
"); } return (
{layout_editable && !hideOptions && }
{widget.drilldown_widget && ( { e.stopPropagation(); e.preventDefault(); if (widget.drilldown_widget) { setDrilldownModalOpen(true, widget.drilldown_widget); } }} onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }} onMouseUp={(e) => { e.stopPropagation(); e.preventDefault(); }} className="!onvo-rounded-full onvo-z-20 onvo-transition-all group-hover/chartcard:onvo-opacity-100 onvo-opacity-0 onvo-cursor-pointer !onvo-text-gray-500" icon={FunnelIcon} variant="shadow" />)} {(widget_editable || deletable) && !hideOptions && (
{widget_editable && ( { setTimeout(() => { setTextModalOpen(true, widget); }, 30); }} > Edit widget {moveDropdown} )} {(widget_editable) && ImageDownloadEnabled && } {ImageDownloadEnabled && ( <> Download images exportWidget("svg")}> Download as SVG exportWidget("png")}> Download as PNG exportWidget("jpeg")}> Download as JPEG )} {(widget_editable || ImageDownloadEnabled) && deletable && } {deletable && ( Delete widget )}
)}
{footer}
); } if (widget.type === "image") { return (
{layout_editable && !hideOptions && }
{widget.drilldown_widget && ( { e.stopPropagation(); e.preventDefault(); if (widget.drilldown_widget) { setDrilldownModalOpen(true, widget.drilldown_widget); } }} onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }} onMouseUp={(e) => { e.stopPropagation(); e.preventDefault(); }} className="!onvo-rounded-full onvo-z-20 onvo-transition-all group-hover/chartcard:onvo-opacity-100 onvo-opacity-0 onvo-cursor-pointer !onvo-text-gray-500" icon={FunnelIcon} variant="shadow" />)} {(widget_editable || deletable) && !hideOptions && (
{widget_editable && ( { setTimeout(() => { setImageModalOpen(true, widget); }, 30); }} > Edit widget {moveDropdown} )} {widget_editable && deletable && } {deletable && ( Delete widget )}
)}
{footer}
); } const paginationEnabled = widget.engine === "manual-v1" && widget.type === "table"; return ( { const startX = e.clientX; const startY = e.clientY; const handleMouseUp = (upEvent: any) => { if (!cardRef.current) return; // Remove the event listener // @ts-ignore cardRef.current.removeEventListener('mouseup', handleMouseUp); // Calculate distance between mousedown and mouseup const deltaX = Math.abs(upEvent.clientX - startX); const deltaY = Math.abs(upEvent.clientY - startY); const threshold = 5; // 5px threshold for considering it a click vs. drag // Only trigger if the mouse hasn't moved beyond the threshold if (deltaX <= threshold && deltaY <= threshold) { if (widget.settings?.link && widget.settings.link.trim() !== "") { console.log("CLICKED: ", upEvent, widget.settings?.link); window.open(widget.settings.link, "_blank"); } } }; // Add mouseup listener to document to catch mouseup events outside the element // @ts-ignore cardRef.current?.addEventListener('mouseup', handleMouseUp, { once: true }); }} > {error} {layout_editable && !hideOptions && }
{widget.drilldown_widget && ( { e.stopPropagation(); e.preventDefault(); if (widget.drilldown_widget) { setDrilldownModalOpen(true, widget.drilldown_widget); } }} onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }} onMouseUp={(e) => { e.stopPropagation(); e.preventDefault(); }} className="!onvo-rounded-full onvo-z-20 onvo-transition-all group-hover/chartcard:onvo-opacity-100 onvo-opacity-0 onvo-cursor-pointer !onvo-text-gray-500" icon={FunnelIcon} variant="shadow" />)} {(addable || deletable || widget_editable || ImageDownloadEnabled || ReportDownloadEnabled) && !hideOptions && ( { e.stopPropagation(); e.preventDefault(); }} onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }} onMouseUp={(e) => { e.stopPropagation(); e.preventDefault(); }} className="!onvo-rounded-full onvo-z-20 onvo-transition-all group-hover/chartcard:onvo-opacity-100 onvo-opacity-0 onvo-cursor-pointer !onvo-text-gray-500" icon={EllipsisVerticalIcon} variant="shadow" />
{(addable || widget_editable) && ( <> Edit widget {widget_editable && ( <> { e.stopPropagation(); e.preventDefault(); setEditModalOpen(true, widget); }} > Edit widget {moveDropdown} )} {addable && ( { e.stopPropagation(); e.preventDefault(); duplicate(); }}> Duplicate widget )} )} {(addable || widget_editable) && ReportDownloadEnabled && ( )} {ReportDownloadEnabled && ( <> Download reports { e.stopPropagation(); e.preventDefault(); exportWidget("xlsx"); }}> Download as excel { e.stopPropagation(); e.preventDefault(); exportWidget("csv"); }}> Download as CSV )} {(addable || widget_editable || ReportDownloadEnabled) && ImageDownloadEnabled && } {ImageDownloadEnabled && ( <> Download images { e.stopPropagation(); e.preventDefault(); exportWidget("svg"); }}> Download as SVG { e.stopPropagation(); e.preventDefault(); exportWidget("png"); }}> Download as PNG { e.stopPropagation(); e.preventDefault(); exportWidget("jpeg"); }}> Download as JPEG )} {(addable || widget_editable || ImageDownloadEnabled || ReportDownloadEnabled) && deletable && } {deletable && ( { e.stopPropagation(); e.preventDefault(); deleteWidget(); }}> Delete widget )}
)}
{(widget.code === "") && (
)} {(!cache && widget.code !== "") && (
)} {(cache && widget.code !== "") && (
)} {footer} {paginationEnabled && ( )}
); }; export default ChartCard;