Vikrant Gupta 191d9b0648
feat: introducing collapsable rows for dashboards (#4806)
* feat: dashboard panel grouping initial setup

* feat: added panel map to the dashboard response and subsequent types for the same

* feat: added panel map to the dashboard response and subsequent types for the same

* feat: added settings modal

* feat: handle panel collapse and open changes

* feat: handle creating panel map when dashboard layout changes

* feat: handle creating panel map when dashboard layout changes

* feat: refactor code

* feat: handle multiple collapsable rows

* fix: type issues

* feat: handle row collapse button and scroll

* feat: handle y axis movement for rows

* feat: handle delete row

* feat: handle settings name change

* feat: disable collapse/uncollapse when dashboard loading to avoid async states

* feat: decrease the height of the grouping row

* fix: row height management

* fix: handle empty row case

* feat: remove resize handle from the row

* feat: handle re-arrangement of panels

* feat: increase height of default new widget

* feat: added safety checks
2024-04-30 14:36:47 +05:30

557 lines
15 KiB
TypeScript

/* eslint-disable sonarjs/cognitive-complexity */
import { LockFilled, WarningOutlined } from '@ant-design/icons';
import { Button, Flex, Modal, Space, Tooltip, Typography } from 'antd';
import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DashboardShortcuts } from 'constants/shortcuts/DashboardShortcuts';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { MESSAGE, useIsFeatureDisabled } from 'hooks/useFeatureFlag';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { defaultTo, isUndefined } from 'lodash-es';
import { DashboardWidgetPageParams } from 'pages/DashboardWidget';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
getNextWidgets,
getPreviousWidgets,
getSelectedWidgetIndex,
} from 'providers/Dashboard/util';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { generatePath, useParams } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { IField } from 'types/api/logs/fields';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import AppReducer from 'types/reducer/app';
import LeftContainer from './LeftContainer';
import QueryTypeTag from './LeftContainer/QueryTypeTag';
import RightContainer from './RightContainer';
import { ThresholdProps } from './RightContainer/Threshold/types';
import TimeItems, { timePreferance } from './RightContainer/timeItems';
import {
ButtonContainer,
Container,
LeftContainerWrapper,
PanelContainer,
RightContainerWrapper,
} from './styles';
import { NewWidgetProps } from './types';
import {
getDefaultWidgetData,
getIsQueryModified,
handleQueryChange,
} from './utils';
function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
const {
selectedDashboard,
setSelectedDashboard,
setToScrollWidgetId,
} = useDashboard();
const { t } = useTranslation(['dashboard']);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const {
currentQuery,
stagedQuery,
redirectWithQueryBuilderData,
supersetQuery,
} = useQueryBuilder();
const isQueryModified = useMemo(
() => getIsQueryModified(currentQuery, stagedQuery),
[currentQuery, stagedQuery],
);
const { featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const { widgets = [] } = selectedDashboard?.data || {};
const query = useUrlQuery();
const { dashboardId } = useParams<DashboardWidgetPageParams>();
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
useEffect(() => {
const widgetId = query.get('widgetId');
const selectedWidget = widgets?.find((e) => e.id === widgetId);
const isWidgetNotPresent = isUndefined(selectedWidget);
if (isWidgetNotPresent) {
setIsNewDashboard(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getWidget = useCallback(() => {
const widgetId = query.get('widgetId');
const selectedWidget = widgets?.find((e) => e.id === widgetId);
return defaultTo(
selectedWidget,
getDefaultWidgetData(widgetId || '', selectedGraph),
) as Widgets;
}, [query, selectedGraph, widgets]);
const [selectedWidget, setSelectedWidget] = useState(getWidget());
const [title, setTitle] = useState<string>(
selectedWidget?.title?.toString() || '',
);
const [description, setDescription] = useState<string>(
selectedWidget?.description || '',
);
const [yAxisUnit, setYAxisUnit] = useState<string>(
selectedWidget?.yAxisUnit || 'none',
);
const [stacked, setStacked] = useState<boolean>(
selectedWidget?.isStacked || false,
);
const [opacity, setOpacity] = useState<string>(selectedWidget?.opacity || '1');
const [thresholds, setThresholds] = useState<ThresholdProps[]>(
selectedWidget?.thresholds || [],
);
const [selectedNullZeroValue, setSelectedNullZeroValue] = useState<string>(
selectedWidget?.nullZeroValues || 'zero',
);
const [isFillSpans, setIsFillSpans] = useState<boolean>(
selectedWidget?.fillSpans || false,
);
const [saveModal, setSaveModal] = useState(false);
const [discardModal, setDiscardModal] = useState(false);
const [softMin, setSoftMin] = useState<number | null>(
selectedWidget?.softMin === null || selectedWidget?.softMin === undefined
? null
: selectedWidget?.softMin || 0,
);
const [selectedLogFields, setSelectedLogFields] = useState<IField[] | null>(
selectedWidget?.selectedLogFields || null,
);
const [selectedTracesFields, setSelectedTracesFields] = useState(
selectedWidget?.selectedTracesFields || null,
);
const [softMax, setSoftMax] = useState<number | null>(
selectedWidget?.softMax === null || selectedWidget?.softMax === undefined
? null
: selectedWidget?.softMax || 0,
);
useEffect(() => {
setSelectedWidget((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
query: currentQuery,
title,
description,
isStacked: stacked,
opacity,
nullZeroValues: selectedNullZeroValue,
yAxisUnit,
thresholds,
softMin,
softMax,
fillSpans: isFillSpans,
selectedLogFields,
selectedTracesFields,
};
});
}, [
currentQuery,
description,
isFillSpans,
opacity,
selectedLogFields,
selectedNullZeroValue,
selectedTracesFields,
softMax,
softMin,
stacked,
thresholds,
title,
yAxisUnit,
]);
const closeModal = (): void => {
setSaveModal(false);
setDiscardModal(false);
};
const [graphType, setGraphType] = useState(selectedGraph);
const getSelectedTime = useCallback(
() =>
TimeItems.find(
(e) => e.enum === (selectedWidget?.timePreferance || 'GLOBAL_TIME'),
),
[selectedWidget],
);
const [selectedTime, setSelectedTime] = useState<timePreferance>({
name: getSelectedTime()?.name || '',
enum: selectedWidget?.timePreferance || 'GLOBAL_TIME',
});
const updateDashboardMutation = useUpdateDashboard();
const { afterWidgets, preWidgets } = useMemo(() => {
if (!selectedDashboard) {
return {
selectedWidget: {} as Widgets,
preWidgets: [],
afterWidgets: [],
};
}
const widgetId = query.get('widgetId');
const selectedWidgetIndex = getSelectedWidgetIndex(
selectedDashboard,
widgetId,
);
const preWidgets = getPreviousWidgets(selectedDashboard, selectedWidgetIndex);
const afterWidgets = getNextWidgets(selectedDashboard, selectedWidgetIndex);
const selectedWidget = (selectedDashboard.data.widgets || [])[
selectedWidgetIndex || 0
];
return { selectedWidget, preWidgets, afterWidgets };
}, [selectedDashboard, query]);
const { notifications } = useNotifications();
const onClickSaveHandler = useCallback(() => {
if (!selectedDashboard) {
return;
}
const widgetId = query.get('widgetId');
let updatedLayout = selectedDashboard.data.layout || [];
if (isNewDashboard) {
updatedLayout = [
{
i: widgetId || '',
w: 6,
x: 0,
h: 6,
y: 0,
},
...updatedLayout,
];
}
const dashboard: Dashboard = {
...selectedDashboard,
uuid: selectedDashboard.uuid,
data: {
...selectedDashboard.data,
widgets: [
...preWidgets,
{
...(selectedWidget || ({} as Widgets)),
description: selectedWidget?.description || '',
timePreferance: selectedTime.enum,
isStacked: selectedWidget?.isStacked || false,
opacity: selectedWidget?.opacity || '1',
nullZeroValues: selectedWidget?.nullZeroValues || 'zero',
title: selectedWidget?.title,
yAxisUnit: selectedWidget?.yAxisUnit,
panelTypes: graphType,
query: currentQuery,
thresholds: selectedWidget?.thresholds,
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
selectedLogFields: selectedWidget?.selectedLogFields || [],
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
},
...afterWidgets,
],
layout: [...updatedLayout],
},
};
updateDashboardMutation.mutateAsync(dashboard, {
onSuccess: () => {
setSelectedDashboard(dashboard);
setToScrollWidgetId(selectedWidget?.id || '');
featureResponse.refetch();
history.push({
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
});
},
onError: () => {
notifications.error({
message: SOMETHING_WENT_WRONG,
});
},
});
}, [
selectedDashboard,
query,
isNewDashboard,
preWidgets,
selectedWidget,
selectedTime.enum,
graphType,
currentQuery,
afterWidgets,
updateDashboardMutation,
setSelectedDashboard,
setToScrollWidgetId,
featureResponse,
dashboardId,
notifications,
]);
const onClickDiscardHandler = useCallback(() => {
if (isQueryModified) {
setDiscardModal(true);
return;
}
history.push(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [dashboardId, isQueryModified]);
const discardChanges = useCallback(() => {
history.push(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [dashboardId]);
const setGraphHandler = (type: PANEL_TYPES): void => {
const updatedQuery = handleQueryChange(type as any, supersetQuery);
setGraphType(type);
redirectWithQueryBuilderData(
updatedQuery,
{ [QueryParams.graphType]: type },
undefined,
true,
);
};
const onSaveDashboard = useCallback((): void => {
setSaveModal(true);
}, []);
const isQueryBuilderActive = useIsFeatureDisabled(
FeatureKeys.QUERY_BUILDER_PANELS,
);
const isNewTraceLogsAvailable =
isQueryBuilderActive &&
currentQuery.queryType === EQueryType.QUERY_BUILDER &&
currentQuery.builder.queryData.find(
(query) => query.dataSource !== DataSource.METRICS,
) !== undefined;
const isSaveDisabled = useMemo(() => {
// new created dashboard
if (selectedWidget?.id === 'empty') {
return isNewTraceLogsAvailable;
}
const isTraceOrLogsQueryBuilder =
currentQuery.builder.queryData.find(
(query) =>
query.dataSource === DataSource.TRACES ||
query.dataSource === DataSource.LOGS,
) !== undefined;
if (isTraceOrLogsQueryBuilder) {
return false;
}
return isNewTraceLogsAvailable;
}, [
currentQuery.builder.queryData,
selectedWidget?.id,
isNewTraceLogsAvailable,
]);
useEffect(() => {
registerShortcut(DashboardShortcuts.SaveChanges, onSaveDashboard);
registerShortcut(DashboardShortcuts.DiscardChanges, onClickDiscardHandler);
return (): void => {
deregisterShortcut(DashboardShortcuts.SaveChanges);
deregisterShortcut(DashboardShortcuts.DiscardChanges);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onSaveDashboard]);
return (
<Container>
<Flex justify="space-between" align="center">
<FacingIssueBtn
attributes={{
uuid: selectedDashboard?.uuid,
title: selectedDashboard?.data.title,
panelType: graphType,
widgetId: query.get('widgetId'),
queryType: currentQuery.queryType,
screen: 'Dashboard list page',
}}
eventName="Dashboard: Facing Issues in dashboard"
buttonText="Facing Issues in dashboard"
message={`Hi Team,
I am facing issues configuring dashboard in SigNoz. Here are my dashboard details
Name: ${selectedDashboard?.data.title || ''}
Panel type: ${graphType}
Dashboard Id: ${selectedDashboard?.uuid || ''}
Thanks`}
/>
<ButtonContainer>
{isSaveDisabled && (
<Tooltip title={MESSAGE.PANEL}>
<Button
icon={<LockFilled />}
type="primary"
disabled={isSaveDisabled}
onClick={onSaveDashboard}
>
Save Changes
</Button>
</Tooltip>
)}
{!isSaveDisabled && (
<Button
type="primary"
data-testid="new-widget-save"
loading={updateDashboardMutation.isLoading}
disabled={isSaveDisabled}
onClick={onSaveDashboard}
>
Save Changes
</Button>
)}
<Button onClick={onClickDiscardHandler}>Discard Changes</Button>
</ButtonContainer>
</Flex>
<PanelContainer>
<LeftContainerWrapper flex={5}>
{selectedWidget && (
<LeftContainer
selectedGraph={graphType}
selectedLogFields={selectedLogFields}
setSelectedLogFields={setSelectedLogFields}
selectedTracesFields={selectedTracesFields}
setSelectedTracesFields={setSelectedTracesFields}
selectedWidget={selectedWidget}
selectedTime={selectedTime}
/>
)}
</LeftContainerWrapper>
<RightContainerWrapper flex={1}>
<RightContainer
setGraphHandler={setGraphHandler}
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
stacked={stacked}
setStacked={setStacked}
opacity={opacity}
yAxisUnit={yAxisUnit}
setOpacity={setOpacity}
selectedNullZeroValue={selectedNullZeroValue}
setSelectedNullZeroValue={setSelectedNullZeroValue}
selectedGraph={graphType}
setSelectedTime={setSelectedTime}
selectedTime={selectedTime}
setYAxisUnit={setYAxisUnit}
thresholds={thresholds}
setThresholds={setThresholds}
selectedWidget={selectedWidget}
isFillSpans={isFillSpans}
setIsFillSpans={setIsFillSpans}
softMin={softMin}
setSoftMin={setSoftMin}
softMax={softMax}
setSoftMax={setSoftMax}
/>
</RightContainerWrapper>
</PanelContainer>
<Modal
title={
isQueryModified ? (
<Space>
<WarningOutlined style={{ fontSize: '16px', color: '#fdd600' }} />
Unsaved Changes
</Space>
) : (
'Save Widget'
)
}
focusTriggerAfterClose
forceRender
destroyOnClose
closable
onCancel={closeModal}
onOk={onClickSaveHandler}
confirmLoading={updateDashboardMutation.isLoading}
centered
open={saveModal}
width={600}
>
{!isQueryModified ? (
<Typography>
{t('your_graph_build_with')}{' '}
<QueryTypeTag queryType={currentQuery.queryType} />
{t('dashboard_ok_confirm')}
</Typography>
) : (
<Typography>{t('dashboard_unsave_changes')} </Typography>
)}
</Modal>
<Modal
title={
<Space>
<WarningOutlined style={{ fontSize: '16px', color: '#fdd600' }} />
Unsaved Changes
</Space>
}
focusTriggerAfterClose
forceRender
destroyOnClose
closable
onCancel={closeModal}
onOk={discardChanges}
centered
open={discardModal}
width={600}
>
<Typography>{t('dashboard_unsave_changes')}</Typography>
</Modal>
</Container>
);
}
export default NewWidget;