import './GridCardLayout.styles.scss'; import * as Sentry from '@sentry/react'; import { Color } from '@signozhq/design-tokens'; import { Button, Form, Input, Modal, Typography } from 'antd'; import { useForm } from 'antd/es/form/Form'; import logEvent from 'api/common/logEvent'; import cx from 'classnames'; import { ENTITY_VERSION_V5 } from 'constants/app'; import { QueryParams } from 'constants/query'; import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder'; import { themeColors } from 'constants/theme'; import { DEFAULT_ROW_NAME } from 'container/NewDashboard/DashboardDescription/utils'; import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard'; import useComponentPermission from 'hooks/useComponentPermission'; import { useIsDarkMode } from 'hooks/useDarkMode'; import { useSafeNavigate } from 'hooks/useSafeNavigate'; import useUrlQuery from 'hooks/useUrlQuery'; import { defaultTo, isUndefined } from 'lodash-es'; import isEqual from 'lodash-es/isEqual'; import { Check, ChevronDown, ChevronUp, GripVertical, LockKeyhole, X, } from 'lucide-react'; import { useAppContext } from 'providers/App/App'; import { useDashboard } from 'providers/Dashboard/Dashboard'; import { sortLayout } from 'providers/Dashboard/util'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FullScreen, FullScreenHandle } from 'react-full-screen'; import { ItemCallback, Layout } from 'react-grid-layout'; import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { UpdateTimeInterval } from 'store/actions'; import { Widgets } from 'types/api/dashboard/getAll'; import { Props } from 'types/api/dashboard/update'; import { ROLES, USER_ROLES } from 'types/roles'; import { ComponentTypes } from 'utils/permission'; import { EditMenuAction, ViewMenuAction } from './config'; import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState'; import GridCard from './GridCard'; import { Card, CardContainer, ReactGridLayout } from './styles'; import { hasColumnWidthsChanged, removeUndefinedValuesFromLayout, } from './utils'; import { MenuItemKeys } from './WidgetHeader/contants'; import { WidgetRowHeader } from './WidgetRow'; interface GraphLayoutProps { handle: FullScreenHandle; } // eslint-disable-next-line sonarjs/cognitive-complexity function GraphLayout(props: GraphLayoutProps): JSX.Element { const { handle } = props; const { safeNavigate } = useSafeNavigate(); const { selectedDashboard, layouts, setLayouts, panelMap, setPanelMap, setSelectedDashboard, isDashboardLocked, dashboardQueryRangeCalled, setDashboardQueryRangeCalled, setSelectedRowWidgetId, isDashboardFetching, columnWidths, } = useDashboard(); const { data } = selectedDashboard || {}; const { pathname } = useLocation(); const dispatch = useDispatch(); const { widgets, variables } = data || {}; const { user } = useAppContext(); const isDarkMode = useIsDarkMode(); const [dashboardLayout, setDashboardLayout] = useState([]); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [currentSelectRowId, setCurrentSelectRowId] = useState( null, ); const [currentPanelMap, setCurrentPanelMap] = useState< Record >({}); useEffect(() => { setCurrentPanelMap(panelMap); }, [panelMap]); const [form] = useForm<{ title: string; }>(); const updateDashboardMutation = useUpdateDashboard(); const urlQuery = useUrlQuery(); let permissions: ComponentTypes[] = ['save_layout', 'add_panel']; if (isDashboardLocked) { permissions = ['edit_locked_dashboard', 'add_panel_locked_dashboard']; } const userRole: ROLES | null = selectedDashboard?.createdBy === user?.email ? (USER_ROLES.AUTHOR as ROLES) : user.role; const [saveLayoutPermission, addPanelPermission] = useComponentPermission( permissions, userRole, ); const [deleteWidget, editWidget] = useComponentPermission( ['delete_widget', 'edit_widget'], user.role, ); useEffect(() => { setDashboardLayout(sortLayout(layouts)); }, [layouts]); useEffect(() => { setDashboardQueryRangeCalled(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const timeoutId = setTimeout(() => { // Send Sentry event if query_range is not called within expected timeframe (2 mins) when there are widgets if (!dashboardQueryRangeCalled && data?.widgets?.length) { Sentry.captureEvent({ message: `Dashboard query range not called within expected timeframe even when there are ${data?.widgets?.length} widgets`, level: 'warning', }); } }, 120000); return (): void => clearTimeout(timeoutId); }, [dashboardQueryRangeCalled, data?.widgets?.length]); const logEventCalledRef = useRef(false); useEffect(() => { if (!logEventCalledRef.current && !isUndefined(data)) { logEvent('Dashboard Detail: Opened', { dashboardId: selectedDashboard?.id, dashboardName: data.title, numberOfPanels: data.widgets?.length, numberOfVariables: Object.keys(data?.variables || {}).length || 0, }); logEventCalledRef.current = true; } }, [data, selectedDashboard?.id]); const onSaveHandler = (): void => { if (!selectedDashboard) return; const updatedDashboard: Props = { id: selectedDashboard.id, data: { ...selectedDashboard.data, panelMap: { ...currentPanelMap }, layout: dashboardLayout.filter((e) => e.i !== PANEL_TYPES.EMPTY_WIDGET), widgets: selectedDashboard?.data?.widgets?.map((widget) => { if (columnWidths?.[widget.id]) { return { ...widget, columnWidths: columnWidths[widget.id], }; } return widget; }), }, }; updateDashboardMutation.mutate(updatedDashboard, { onSuccess: (updatedDashboard) => { setSelectedRowWidgetId(null); if (updatedDashboard.data) { if (updatedDashboard.data.data.layout) setLayouts(sortLayout(updatedDashboard.data.data.layout)); setSelectedDashboard(updatedDashboard.data); setPanelMap(updatedDashboard.data?.data?.panelMap || {}); } }, }); }; const widgetActions = !isDashboardLocked ? [...ViewMenuAction, ...EditMenuAction] : [...ViewMenuAction, MenuItemKeys.CreateAlerts]; const handleLayoutChange = (layout: Layout[]): void => { const filterLayout = removeUndefinedValuesFromLayout(layout); const filterDashboardLayout = removeUndefinedValuesFromLayout( dashboardLayout, ); if (!isEqual(filterLayout, filterDashboardLayout)) { const updatedLayout = sortLayout(layout); setDashboardLayout(updatedLayout); } }; const onDragSelect = useCallback( (start: number, end: number) => { const startTimestamp = Math.trunc(start); const endTimestamp = Math.trunc(end); urlQuery.set(QueryParams.startTime, startTimestamp.toString()); urlQuery.set(QueryParams.endTime, endTimestamp.toString()); const generatedUrl = `${pathname}?${urlQuery.toString()}`; safeNavigate(generatedUrl); if (startTimestamp !== endTimestamp) { dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp])); } }, [dispatch, pathname, safeNavigate, urlQuery], ); useEffect(() => { if ( isDashboardLocked || !saveLayoutPermission || updateDashboardMutation.isLoading || isDashboardFetching ) { return; } const shouldSaveLayout = dashboardLayout && Array.isArray(dashboardLayout) && dashboardLayout.length > 0 && !isEqual(layouts, dashboardLayout); const shouldSaveColumnWidths = dashboardLayout && Array.isArray(dashboardLayout) && dashboardLayout.length > 0 && hasColumnWidthsChanged(columnWidths, selectedDashboard); if (shouldSaveLayout || shouldSaveColumnWidths) { onSaveHandler(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dashboardLayout, columnWidths]); const onSettingsModalSubmit = (): void => { const newTitle = form.getFieldValue('title'); if (!selectedDashboard) return; if (!currentSelectRowId) return; const currentWidget = selectedDashboard?.data?.widgets?.find( (e) => e.id === currentSelectRowId, ); if (!currentWidget) return; currentWidget.title = newTitle; const updatedWidgets = selectedDashboard?.data?.widgets?.filter( (e) => e.id !== currentSelectRowId, ); updatedWidgets?.push(currentWidget); const updatedSelectedDashboard: Props = { id: selectedDashboard.id, data: { ...selectedDashboard.data, widgets: updatedWidgets, }, }; updateDashboardMutation.mutateAsync(updatedSelectedDashboard, { onSuccess: (updatedDashboard) => { if (setLayouts) setLayouts(updatedDashboard.data?.data?.layout || []); if (setSelectedDashboard && updatedDashboard.data) { setSelectedDashboard(updatedDashboard.data); } if (setPanelMap) setPanelMap(updatedDashboard.data?.data?.panelMap || {}); form.setFieldValue('title', ''); setIsSettingsModalOpen(false); setCurrentSelectRowId(null); }, }); }; useEffect(() => { if (!currentSelectRowId) return; form.setFieldValue( 'title', (widgets?.find((widget) => widget.id === currentSelectRowId) ?.title as string) || DEFAULT_ROW_NAME, ); }, [currentSelectRowId, form, widgets]); // eslint-disable-next-line sonarjs/cognitive-complexity const handleRowCollapse = (id: string): void => { if (!selectedDashboard) return; const rowProperties = { ...currentPanelMap[id] }; const updatedPanelMap = { ...currentPanelMap }; let updatedDashboardLayout = [...dashboardLayout]; if (rowProperties.collapsed === true) { rowProperties.collapsed = false; const widgetsInsideTheRow = rowProperties.widgets; let maxY = 0; widgetsInsideTheRow.forEach((w) => { maxY = Math.max(maxY, w.y + w.h); }); const currentRowWidget = dashboardLayout.find((w) => w.i === id); if (currentRowWidget && widgetsInsideTheRow.length) { maxY -= currentRowWidget.h + currentRowWidget.y; } const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id); for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) { updatedDashboardLayout[j].y += maxY; if (updatedPanelMap[updatedDashboardLayout[j].i]) { updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[ updatedDashboardLayout[j].i // eslint-disable-next-line @typescript-eslint/no-loop-func ].widgets.map((w) => ({ ...w, y: w.y + maxY, })); } } updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow]; } else { rowProperties.collapsed = true; const currentIdx = dashboardLayout.findIndex((w) => w.i === id); let widgetsInsideTheRow: Layout[] = []; let isPanelMapUpdated = false; for (let j = currentIdx + 1; j < dashboardLayout.length; j++) { if (currentPanelMap[dashboardLayout[j].i]) { rowProperties.widgets = widgetsInsideTheRow; widgetsInsideTheRow = []; isPanelMapUpdated = true; break; } else { widgetsInsideTheRow.push(dashboardLayout[j]); } } if (!isPanelMapUpdated) { rowProperties.widgets = widgetsInsideTheRow; } let maxY = 0; widgetsInsideTheRow.forEach((w) => { maxY = Math.max(maxY, w.y + w.h); }); const currentRowWidget = dashboardLayout[currentIdx]; if (currentRowWidget && widgetsInsideTheRow.length) { maxY -= currentRowWidget.h + currentRowWidget.y; } for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) { updatedDashboardLayout[j].y += maxY; if (updatedPanelMap[updatedDashboardLayout[j].i]) { updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[ updatedDashboardLayout[j].i // eslint-disable-next-line @typescript-eslint/no-loop-func ].widgets.map((w) => ({ ...w, y: w.y + maxY, })); } } updatedDashboardLayout = updatedDashboardLayout.filter( (widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i), ); } setCurrentPanelMap((prev) => ({ ...prev, ...updatedPanelMap, [id]: { ...rowProperties, }, })); setDashboardLayout(sortLayout(updatedDashboardLayout)); }; const handleDragStop: ItemCallback = (_, oldItem, newItem): void => { if (oldItem?.i && currentPanelMap?.[oldItem.i]) { const differenceY = newItem.y - oldItem.y; const widgetsInsideRow = (currentPanelMap[oldItem.i]?.widgets ?? []).map( (w) => ({ ...w, y: w.y + differenceY, }), ); setCurrentPanelMap((prev) => ({ ...prev, [oldItem.i]: { ...prev[oldItem.i], widgets: widgetsInsideRow, }, })); } }; const handleRowDelete = (): void => { if (!selectedDashboard) return; if (!currentSelectRowId) return; const updatedWidgets = selectedDashboard?.data?.widgets?.filter( (e) => e.id !== currentSelectRowId, ); const updatedLayout = selectedDashboard.data.layout?.filter((e) => e.i !== currentSelectRowId) || []; const updatedPanelMap = { ...currentPanelMap }; delete updatedPanelMap[currentSelectRowId]; const updatedSelectedDashboard: Props = { id: selectedDashboard.id, data: { ...selectedDashboard.data, widgets: updatedWidgets, layout: updatedLayout, panelMap: updatedPanelMap, }, }; updateDashboardMutation.mutateAsync(updatedSelectedDashboard, { onSuccess: (updatedDashboard) => { if (setLayouts) setLayouts(updatedDashboard.data?.data?.layout || []); if (setSelectedDashboard && updatedDashboard.data) { setSelectedDashboard(updatedDashboard.data); } if (setPanelMap) setPanelMap(updatedDashboard.data?.data?.panelMap || {}); setIsDeleteModalOpen(false); setCurrentSelectRowId(null); }, }); }; const isDashboardEmpty = useMemo( () => selectedDashboard?.data.layout ? selectedDashboard?.data.layout?.length === 0 : true, [selectedDashboard], ); let isDataAvailableInAnyWidget = false; const isLogEventCalled = useRef(false); return isDashboardEmpty ? ( ) : ( {dashboardLayout.map((layout) => { const { i: id } = layout; const currentWidget = (widgets || [])?.find((e) => e.id === id); if (currentWidget?.panelTypes === PANEL_GROUP_TYPES.ROW) { const rowWidgetProperties = currentPanelMap[id] || {}; let { title } = currentWidget; if (rowWidgetProperties.collapsed) { const widgetCount = rowWidgetProperties.widgets?.length || 0; const collapsedText = `(${widgetCount} widget${ widgetCount > 1 ? 's' : '' })`; title += ` ${collapsedText}`; } return (
{rowWidgetProperties.collapsed && ( )} {title} {rowWidgetProperties.collapsed ? ( handleRowCollapse(id)} className="row-icon" /> ) : ( handleRowCollapse(id)} className="row-icon" /> )}
); } const checkIfDataExists = (isDataAvailable: boolean): void => { if (!isDataAvailableInAnyWidget && isDataAvailable) { isDataAvailableInAnyWidget = true; } if (!isLogEventCalled.current && isDataAvailableInAnyWidget) { isLogEventCalled.current = true; logEvent('Dashboard Detail: Panel data fetched', { isDataAvailableInAnyWidget, }); } }; return ( ); })}
{isDashboardLocked && (
)} { setIsSettingsModalOpen(false); setCurrentSelectRowId(null); }} >
Enter section name widget.id === currentSelectRowId) ?.title as string, 'Sample Title', )} />
{ setIsDeleteModalOpen(false); setCurrentSelectRowId(null); }} onOk={(): void => handleRowDelete()} > Are you sure you want to delete this row ); } export default GraphLayout;