2025-07-31 12:16:55 +05:30

673 lines
20 KiB
TypeScript

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<Layout[]>([]);
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState<boolean>(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
const [currentSelectRowId, setCurrentSelectRowId] = useState<string | null>(
null,
);
const [currentPanelMap, setCurrentPanelMap] = useState<
Record<string, { widgets: Layout[]; collapsed: boolean }>
>({});
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<boolean>(false);
return isDashboardEmpty ? (
<DashboardEmptyState />
) : (
<FullScreen
handle={handle}
className="fullscreen-grid-container"
data-overlayscrollbars-initialize
>
<ReactGridLayout
cols={12}
rowHeight={45}
autoSize
width={100}
useCSSTransforms
isDraggable={!isDashboardLocked && addPanelPermission}
isDroppable={!isDashboardLocked && addPanelPermission}
isResizable={!isDashboardLocked && addPanelPermission}
allowOverlap={false}
onLayoutChange={handleLayoutChange}
onDragStop={handleDragStop}
draggableHandle=".drag-handle"
layout={dashboardLayout}
style={{ backgroundColor: isDarkMode ? '' : themeColors.snowWhite }}
>
{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 (
<CardContainer
className="row-card"
isDarkMode={isDarkMode}
key={id}
data-grid={JSON.stringify(currentWidget)}
>
<div className={cx('row-panel')}>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
{rowWidgetProperties.collapsed && (
<GripVertical
size={14}
className="drag-handle"
color={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300}
cursor="move"
/>
)}
<Typography.Text className="section-title">{title}</Typography.Text>
{rowWidgetProperties.collapsed ? (
<ChevronDown
size={14}
onClick={(): void => handleRowCollapse(id)}
className="row-icon"
/>
) : (
<ChevronUp
size={14}
onClick={(): void => handleRowCollapse(id)}
className="row-icon"
/>
)}
</div>
<WidgetRowHeader
id={id}
rowWidgetProperties={rowWidgetProperties}
setCurrentSelectRowId={setCurrentSelectRowId}
setIsDeleteModalOpen={setIsDeleteModalOpen}
setIsSettingsModalOpen={setIsSettingsModalOpen}
editWidget={editWidget}
deleteWidget={deleteWidget}
/>
</div>
</CardContainer>
);
}
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 (
<CardContainer
className={isDashboardLocked ? '' : 'enable-resize'}
isDarkMode={isDarkMode}
key={id}
data-grid={JSON.stringify(currentWidget)}
>
<Card
className="grid-item"
isDarkMode={isDarkMode}
$panelType={currentWidget?.panelTypes || PANEL_TYPES.TIME_SERIES}
>
<GridCard
widget={(currentWidget as Widgets) || ({ id, query: {} } as Widgets)}
headerMenuList={widgetActions}
variables={variables}
// version={selectedDashboard?.data?.version}
version={ENTITY_VERSION_V5}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}
/>
</Card>
</CardContainer>
);
})}
</ReactGridLayout>
{isDashboardLocked && (
<div className="footer">
<Button
type="text"
icon={<LockKeyhole size={14} />}
className="locked-text"
>
Locked
</Button>
<div className="locked-bar" />
</div>
)}
<Modal
open={isSettingsModalOpen}
title="Rename Section"
rootClassName="rename-section"
destroyOnClose
footer={null}
onCancel={(): void => {
setIsSettingsModalOpen(false);
setCurrentSelectRowId(null);
}}
>
<Form form={form} onFinish={onSettingsModalSubmit} requiredMark>
<Typography.Text className="typography">
Enter section name
</Typography.Text>
<Form.Item required name={['title']}>
<Input
placeholder="Enter row name here..."
defaultValue={defaultTo(
widgets?.find((widget) => widget.id === currentSelectRowId)
?.title as string,
'Sample Title',
)}
/>
</Form.Item>
<Form.Item>
<div className="action-btns">
<Button
type="primary"
htmlType="submit"
className="ok-btn"
icon={<Check size={14} />}
disabled={updateDashboardMutation.isLoading}
>
Apply Changes
</Button>
<Button
type="text"
className="cancel-btn"
icon={<X size={14} />}
onClick={(): void => {
setIsSettingsModalOpen(false);
setCurrentSelectRowId(null);
}}
>
Cancel
</Button>
</div>
</Form.Item>
</Form>
</Modal>
<Modal
open={isDeleteModalOpen}
title="Delete Row"
destroyOnClose
onCancel={(): void => {
setIsDeleteModalOpen(false);
setCurrentSelectRowId(null);
}}
onOk={(): void => handleRowDelete()}
>
<Typography.Text>Are you sure you want to delete this row</Typography.Text>
</Modal>
</FullScreen>
);
}
export default GraphLayout;