Feat: back navigation support throughout the app (#6701)

* feat: custom hook to prevent redundant navigation and handle default params with URL comparison

* feat: implement useSafeNavigation to QB, to ensure that the back navigation works properly

* fix: handle syncing the relativeTime param with the time picker selected relative time

* feat: add support for absolute and relative time sync with time picker component

* refactor: integrate safeNavigate in LogsExplorerChart and deprecate the existing back navigation

* feat: update pagination query params on pressing next/prev page

* fix: fix the issue of NOOP getting converted to Count on coming back from alert creation page

* refactor: replace history navigation with safeNavigate in DateTimeSelectionV2 component

it also fixes the issue of relativeTime not being added to the url on mounting

* feat: integrate useSafeNavigate across service details tabs

* fix: fix duplicate redirections by converting the timestamp to milliseconds

* fix: replace history navigation with useSafeNavigate in LogsExplorerViews and useUrlQueryData

* fix: replace history navigation with useSafeNavigate across dashboard components

* fix: use safeNavigate in alert components

* fix: fix the issue of back navigation in alert table and sync the pagination with url param

* fix: handle back navigation for resource filter and sync the state with url query

* fix: fix the issue of double redirection from top operations to traces

* fix: replace history.push with safeNavigate in TracesExplorer's updateDashboard

* fix: prevent unnecessary query re-runs by checking stagedQuery before redirecting in NewWidget

* chore: cleanup

* fix: fix the failing tests

* fix: fix the documentation redirection failing tests

* test: mock useSafeNavigate hook in WidgetGraphComponent test

* test: mock useSafeNavigate hook in ExplorerCard test
This commit is contained in:
Shaheer Kochai 2025-02-14 08:24:49 +04:30 committed by GitHub
parent ef635b6b60
commit 82d84c041c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 564 additions and 132 deletions

View File

@ -20,6 +20,12 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
}));

View File

@ -6,6 +6,8 @@ import { ColumnGroupType, ColumnType } from 'antd/es/table';
import { ColumnsType } from 'antd/lib/table';
import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { SlidersHorizontal } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
import { popupContainer } from 'utils/selectPopupContainer';
@ -25,8 +27,12 @@ function DynamicColumnTable({
onDragColumn,
facingIssueBtn,
shouldSendAlertsLogEvent,
pagination,
...restProps
}: DynamicColumnTableProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const [columnsData, setColumnsData] = useState<ColumnsType | undefined>(
columns,
);
@ -93,6 +99,28 @@ function DynamicColumnTable({
type: 'checkbox',
})) || [];
// Get current page from URL or default to 1
const currentPage = Number(urlQuery.get('page')) || 1;
const handlePaginationChange = (page: number, pageSize?: number): void => {
// Update URL with new page number while preserving other params
urlQuery.set('page', page.toString());
const newUrl = `${window.location.pathname}?${urlQuery.toString()}`;
safeNavigate(newUrl);
// Call original pagination handler if provided
if (pagination?.onChange && !!pageSize) {
pagination.onChange(page, pageSize);
}
};
const enhancedPagination = {
...pagination,
current: currentPage, // Ensure the pagination component shows the correct page
onChange: handlePaginationChange,
};
return (
<div className="DynamicColumnTable">
<Flex justify="flex-end" align="center" gap={8}>
@ -116,6 +144,7 @@ function DynamicColumnTable({
<ResizeTable
columns={columnsData}
onDragColumn={onDragColumn}
pagination={enhancedPagination}
{...restProps}
/>
</div>

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TableProps } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { PaginationProps } from 'antd/lib';
import { ColumnGroupType, ColumnType } from 'antd/lib/table';
import { LaunchChatSupportProps } from 'components/LaunchChatSupport/LaunchChatSupport';
@ -15,6 +16,7 @@ export interface DynamicColumnTableProps extends TableProps<any> {
onDragColumn?: (fromIndex: number, toIndex: number) => void;
facingIssueBtn?: LaunchChatSupportProps;
shouldSendAlertsLogEvent?: boolean;
pagination?: PaginationProps;
}
export type GetVisibleColumnsFunction = (

View File

@ -27,6 +27,12 @@ jest.mock('uplot', () => {
};
});
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
let mockWindowOpen: jest.Mock;
window.ResizeObserver =

View File

@ -36,6 +36,11 @@ window.ResizeObserver =
unobserve: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
describe('Anomaly Alert Documentation Redirection', () => {
let mockWindowOpen: jest.Mock;

View File

@ -17,8 +17,8 @@ import { BuilderUnitsFilter } from 'container/QueryBuilder/filters';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEqual } from 'lodash-es';
@ -87,7 +87,7 @@ function FormAlertRules({
// init namespace for translations
const { t } = useTranslation('alerts');
const { featureFlags } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
@ -224,7 +224,7 @@ function FormAlertRules({
const generatedUrl = `${location.pathname}?${queryParams.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detectionMethod]);
@ -295,8 +295,8 @@ function FormAlertRules({
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
history.replace(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
}, [urlQuery]);
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
}, [safeNavigate, urlQuery]);
// onQueryCategoryChange handles changes to query category
// in state as well as sets additional defaults
@ -515,7 +515,7 @@ function FormAlertRules({
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
history.replace(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
}, 2000);
} else {
logData = {

View File

@ -20,11 +20,11 @@ import {
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useChartMutable } from 'hooks/useChartMutable';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import history from 'lib/history';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@ -49,6 +49,7 @@ function FullView({
isDependedDataLoaded = false,
onToggleModelHandler,
}: FullViewProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { selectedTime: globalSelectedTime } = useSelector<
AppState,
GlobalReducer
@ -137,9 +138,9 @@ function FullView({
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
safeNavigate(generatedUrl);
},
[dispatch, location.pathname, urlQuery],
[dispatch, location.pathname, safeNavigate, urlQuery],
);
const [graphsVisibilityStates, setGraphsVisibilityStates] = useState<

View File

@ -23,6 +23,12 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),

View File

@ -10,9 +10,9 @@ import { placeWidgetAtBottom } from 'container/NewWidget/utils';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
@ -51,6 +51,7 @@ function WidgetGraphComponent({
customSeries,
customErrorMessage,
}: WidgetGraphComponentProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [deleteModal, setDeleteModal] = useState(false);
const [hovered, setHovered] = useState(false);
const { notifications } = useNotifications();
@ -173,7 +174,7 @@ function WidgetGraphComponent({
graphType: widget?.panelTypes,
widgetId: uuid,
};
history.push(`${pathname}/new?${createQueryParams(queryParams)}`);
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
},
},
);
@ -194,7 +195,7 @@ function WidgetGraphComponent({
const separator = existingSearch.toString() ? '&' : '';
const newSearch = `${existingSearch}${separator}${updatedSearch}`;
history.push({
safeNavigate({
pathname,
search: newSearch,
});
@ -221,7 +222,7 @@ function WidgetGraphComponent({
});
setGraphVisibility(localStoredVisibilityState);
}
history.push({
safeNavigate({
pathname,
search: createQueryParams(updatedQueryParams),
});

View File

@ -15,8 +15,8 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { defaultTo, isUndefined } from 'lodash-es';
import isEqual from 'lodash-es/isEqual';
import {
@ -55,6 +55,7 @@ interface GraphLayoutProps {
// eslint-disable-next-line sonarjs/cognitive-complexity
function GraphLayout(props: GraphLayoutProps): JSX.Element {
const { handle } = props;
const { safeNavigate } = useSafeNavigate();
const {
selectedDashboard,
layouts,
@ -215,13 +216,13 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
safeNavigate(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
[dispatch, pathname, safeNavigate, urlQuery],
);
useEffect(() => {

View File

@ -18,8 +18,8 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { CircleX, X } from 'lucide-react';
@ -72,6 +72,7 @@ function WidgetHeader({
setSearchTerm,
}: IWidgetHeaderProps): JSX.Element | null {
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const onEditHandler = useCallback((): void => {
const widgetId = widget.id;
urlQuery.set(QueryParams.widgetId, widgetId);
@ -81,8 +82,8 @@ function WidgetHeader({
encodeURIComponent(JSON.stringify(widget.query)),
);
const generatedUrl = `${window.location.pathname}/new?${urlQuery}`;
history.push(generatedUrl);
}, [urlQuery, widget.id, widget.panelTypes, widget.query]);
safeNavigate(generatedUrl);
}, [safeNavigate, urlQuery, widget.id, widget.panelTypes, widget.query]);
const onCreateAlertsHandler = useCreateAlerts(widget, 'dashboardView');

View File

@ -35,7 +35,7 @@ import dayjs from 'dayjs';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { get, isEmpty, isUndefined } from 'lodash-es';
import {
ArrowDownWideNarrow,
@ -74,7 +74,7 @@ import {
} from 'react';
import { Layout } from 'react-grid-layout';
import { useTranslation } from 'react-i18next';
import { generatePath, Link } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import {
Dashboard,
@ -105,7 +105,7 @@ function DashboardsList(): JSX.Element {
} = useGetAllDashboard();
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const {
listSortOrder: sortOrder,
setListSortOrder: setSortOrder,
@ -293,7 +293,7 @@ function DashboardsList(): JSX.Element {
});
if (response.statusCode === 200) {
history.push(
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: response.payload.uuid,
}),
@ -313,7 +313,7 @@ function DashboardsList(): JSX.Element {
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
});
}
}, [newDashboardState, t]);
}, [newDashboardState, safeNavigate, t]);
const onModalHandler = (uploadedGrafana: boolean): void => {
logEvent('Dashboard List: Import JSON clicked', {});
@ -418,7 +418,7 @@ function DashboardsList(): JSX.Element {
if (event.metaKey || event.ctrlKey) {
window.open(getLink(), '_blank');
} else {
history.push(getLink());
safeNavigate(getLink());
}
logEvent('Dashboard List: Clicked on dashboard', {
dashboardId: dashboard.id,
@ -444,10 +444,12 @@ function DashboardsList(): JSX.Element {
placement="left"
overlayClassName="title-toolip"
>
<Link
to={getLink()}
<div
className="title-link"
onClick={(e): void => e.stopPropagation()}
onClick={(e): void => {
e.stopPropagation();
safeNavigate(getLink());
}}
>
<img
src={dashboard?.image || Base64Icons[0]}
@ -460,7 +462,7 @@ function DashboardsList(): JSX.Element {
>
{dashboard.name}
</Typography.Text>
</Link>
</div>
</Tooltip>
</div>

View File

@ -18,8 +18,8 @@ import createDashboard from 'api/dashboard/create';
import ROUTES from 'constants/routes';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import history from 'lib/history';
import { ExternalLink, Github, MonitorDot, MoveRight, X } from 'lucide-react';
// #TODO: Lucide will be removing brand icons like GitHub in the future. In that case, we can use Simple Icons. https://simpleicons.org/
// See more: https://github.com/lucide-icons/lucide/issues/94
@ -33,6 +33,7 @@ function ImportJSON({
uploadedGrafana,
onModalHandler,
}: ImportJSONProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [jsonData, setJsonData] = useState<Record<string, unknown>>();
const { t } = useTranslation(['dashboard', 'common']);
const [isUploadJSONError, setIsUploadJSONError] = useState<boolean>(false);
@ -97,7 +98,7 @@ function ImportJSON({
});
if (response.statusCode === 200) {
history.push(
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: response.payload.uuid,
}),

View File

@ -2,14 +2,12 @@ import Graph from 'components/Graph';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import { themeColors } from 'constants/theme';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import getChartData, { GetChartDataProps } from 'lib/getChartData';
import GetMinMax from 'lib/getMinMax';
import { colors } from 'lib/getRandomColor';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { UpdateTimeInterval } from 'store/actions';
@ -28,6 +26,7 @@ function LogsExplorerChart({
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] = useCallback(
(element, index, allLabels) => ({
data: element,
@ -62,41 +61,13 @@ function LogsExplorerChart({
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
urlQuery.delete(QueryParams.relativeTime);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
safeNavigate(generatedUrl);
},
[dispatch, location.pathname, urlQuery],
[dispatch, location.pathname, safeNavigate, urlQuery],
);
const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search);
const startTime = searchParams.get(QueryParams.startTime);
const endTime = searchParams.get(QueryParams.endTime);
const relativeTime = searchParams.get(
QueryParams.relativeTime,
) as CustomTimeType;
if (relativeTime) {
dispatch(UpdateTimeInterval(relativeTime));
} else if (startTime && endTime && startTime !== endTime) {
dispatch(
UpdateTimeInterval('custom', [
parseInt(getTimeString(startTime), 10),
parseInt(getTimeString(endTime), 10),
]),
);
}
};
useEffect(() => {
window.addEventListener('popstate', handleBackNavigation);
return (): void => {
window.removeEventListener('popstate', handleBackNavigation);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const graphData = useMemo(
() =>
getChartData({

View File

@ -38,6 +38,7 @@ import useAxiosError from 'hooks/useAxiosError';
import useClickOutside from 'hooks/useClickOutside';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { FlatLogData } from 'lib/logs/flatLogData';
import { getPaginationQueryData } from 'lib/newQueryBuilder/getPaginationQueryData';
@ -62,7 +63,6 @@ import {
useState,
} from 'react';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { Dashboard } from 'types/api/dashboard/getAll';
import { ILog } from 'types/api/logs/log';
@ -98,7 +98,7 @@ function LogsExplorerViews({
chartQueryKeyRef: MutableRefObject<any>;
}): JSX.Element {
const { notifications } = useNotifications();
const history = useHistory();
const { safeNavigate } = useSafeNavigate();
// this is to respect the panel type present in the URL rather than defaulting it to list always.
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
@ -486,7 +486,7 @@ function LogsExplorerViews({
widgetId,
});
history.push(dashboardEditView);
safeNavigate(dashboardEditView);
},
onError: handleAxisError,
});
@ -495,7 +495,7 @@ function LogsExplorerViews({
getUpdatedQueryForExport,
exportDefaultQuery,
options.selectColumns,
history,
safeNavigate,
notifications,
panelType,
updateDashboard,

View File

@ -75,6 +75,12 @@ jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
useGetExplorerQueryRange: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
// Set up the specific behavior for useGetExplorerQueryRange in individual test cases
beforeEach(() => {
(useGetExplorerQueryRange as jest.Mock).mockReturnValue({

View File

@ -13,6 +13,7 @@ import {
convertRawQueriesToTraceSelectedTags,
resourceAttributesToTagFilterItems,
} from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import getStep from 'lib/getStep';
import history from 'lib/history';
@ -157,6 +158,7 @@ function DBCall(): JSX.Element {
servicename,
isDBCall: true,
});
const { safeNavigate } = useSafeNavigate();
return (
<Row gutter={24}>
@ -171,6 +173,7 @@ function DBCall(): JSX.Element {
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
@ -206,6 +209,7 @@ function DBCall(): JSX.Element {
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces

View File

@ -15,6 +15,7 @@ import {
convertRawQueriesToTraceSelectedTags,
resourceAttributesToTagFilterItems,
} from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import getStep from 'lib/getStep';
import history from 'lib/history';
@ -220,6 +221,8 @@ function External(): JSX.Element {
isExternalCall: true,
});
const { safeNavigate } = useSafeNavigate();
return (
<>
<Row gutter={24}>
@ -234,6 +237,7 @@ function External(): JSX.Element {
timestamp: selectedTimeStamp,
apmToTraceQuery: errorApmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
@ -270,6 +274,7 @@ function External(): JSX.Element {
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
@ -309,6 +314,7 @@ function External(): JSX.Element {
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
@ -345,6 +351,7 @@ function External(): JSX.Element {
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces

View File

@ -13,6 +13,7 @@ import {
convertRawQueriesToTraceSelectedTags,
resourceAttributesToTagFilterItems,
} from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import getStep from 'lib/getStep';
import history from 'lib/history';
@ -290,6 +291,7 @@ function Application(): JSX.Element {
},
],
});
const { safeNavigate } = useSafeNavigate();
return (
<>
@ -317,6 +319,7 @@ function Application(): JSX.Element {
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
@ -346,6 +349,7 @@ function Application(): JSX.Element {
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces

View File

@ -12,6 +12,7 @@ import { latency } from 'container/MetricsApplication/MetricsPageQueries/Overvie
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTagFilterItems } from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { useAppContext } from 'providers/App/App';
import { useMemo } from 'react';
@ -85,6 +86,8 @@ function ServiceOverview({
const apmToLogQuery = useGetAPMToLogsQueries({ servicename });
const { safeNavigate } = useSafeNavigate();
return (
<>
<GraphControlsPanel
@ -96,6 +99,7 @@ function ServiceOverview({
apmToTraceQuery: apmToLogQuery,
isViewLogsClicked: true,
stepInterval,
safeNavigate,
})}
onViewTracesClick={onViewTracePopupClick({
servicename,
@ -103,6 +107,7 @@ function ServiceOverview({
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
/>
<Card data-testid="service_latency">

View File

@ -1,5 +1,6 @@
import { Tooltip, Typography } from 'antd';
import { navigateToTrace } from 'container/MetricsApplication/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { v4 as uuid } from 'uuid';
@ -14,6 +15,7 @@ function ColumnWithLink({
record,
}: LinkColumnProps): JSX.Element {
const text = record.toString();
const { safeNavigate } = useSafeNavigate();
const apmToTraceQuery = useGetAPMToTracesQueries({
servicename,
@ -42,6 +44,7 @@ function ColumnWithLink({
maxTime,
selectedTraceTags,
apmToTraceQuery,
safeNavigate,
});
};

View File

@ -6,7 +6,6 @@ import { getQueryString } from 'container/SideNav/helper';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import history from 'lib/history';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
import { Dispatch, SetStateAction, useMemo } from 'react';
@ -36,6 +35,7 @@ interface OnViewTracePopupClickProps {
apmToTraceQuery: Query;
isViewLogsClicked?: boolean;
stepInterval?: number;
safeNavigate: (url: string) => void;
}
export function generateExplorerPath(
@ -63,6 +63,7 @@ export function onViewTracePopupClick({
apmToTraceQuery,
isViewLogsClicked,
stepInterval,
safeNavigate,
}: OnViewTracePopupClickProps): VoidFunction {
return (): void => {
const endTime = timestamp;
@ -88,7 +89,7 @@ export function onViewTracePopupClick({
queryString,
);
history.push(newPath);
safeNavigate(newPath);
};
}
@ -111,7 +112,7 @@ export function onGraphClickHandler(
buttonElement.style.display = 'block';
buttonElement.style.left = `${mouseX}px`;
buttonElement.style.top = `${mouseY}px`;
setSelectedTimeStamp(xValue);
setSelectedTimeStamp(Math.floor(xValue * 1_000));
}
} else if (buttonElement && buttonElement.style.display === 'block') {
buttonElement.style.display = 'none';

View File

@ -8,6 +8,7 @@ import Download from 'container/Download/Download';
import { filterDropdown } from 'container/ServiceApplication/Filter/FilterDropdown';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useRef } from 'react';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
@ -31,7 +32,7 @@ function TopOperationsTable({
}: TopOperationsTableProps): JSX.Element {
const searchInput = useRef<InputRef>(null);
const { servicename: encodedServiceName } = useParams<IServiceName>();
const { safeNavigate } = useSafeNavigate();
const servicename = decodeURIComponent(encodedServiceName);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@ -87,6 +88,7 @@ function TopOperationsTable({
maxTime,
selectedTraceTags,
apmToTraceQuery: preparedQuery,
safeNavigate,
});
};
@ -126,7 +128,7 @@ function TopOperationsTable({
key: 'p50',
width: 50,
sorter: (a: TopOperationList, b: TopOperationList): number => a.p50 - b.p50,
render: (value: number): string => (value / 1000000).toFixed(2),
render: (value: number): string => (value / 1_000_000).toFixed(2),
},
{
title: 'P95 (in ms)',
@ -134,7 +136,7 @@ function TopOperationsTable({
key: 'p95',
width: 50,
sorter: (a: TopOperationList, b: TopOperationList): number => a.p95 - b.p95,
render: (value: number): string => (value / 1000000).toFixed(2),
render: (value: number): string => (value / 1_000_000).toFixed(2),
},
{
title: 'P99 (in ms)',
@ -142,7 +144,7 @@ function TopOperationsTable({
key: 'p99',
width: 50,
sorter: (a: TopOperationList, b: TopOperationList): number => a.p99 - b.p99,
render: (value: number): string => (value / 1000000).toFixed(2),
render: (value: number): string => (value / 1_000_000).toFixed(2),
},
{
title: 'Number of Calls',

View File

@ -21,6 +21,7 @@ export interface NavigateToTraceProps {
maxTime: number;
selectedTraceTags: string;
apmToTraceQuery: Query;
safeNavigate: (path: string) => void;
}
export interface DatabaseCallsRPSProps extends DatabaseCallProps {

View File

@ -1,6 +1,5 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { TopOperationList } from './TopOperationsTable';
import { NavigateToTraceProps } from './types';
@ -19,10 +18,14 @@ export const navigateToTrace = ({
maxTime,
selectedTraceTags,
apmToTraceQuery,
safeNavigate,
}: NavigateToTraceProps): void => {
const urlParams = new URLSearchParams();
urlParams.set(QueryParams.startTime, (minTime / 1000000).toString());
urlParams.set(QueryParams.endTime, (maxTime / 1000000).toString());
urlParams.set(
QueryParams.startTime,
Math.floor(minTime / 1_000_000).toString(),
);
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
@ -32,7 +35,7 @@ export const navigateToTrace = ({
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
history.push(newTraceExplorerPath);
safeNavigate(newTraceExplorerPath);
};
export const getNearestHighestBucketValue = (

View File

@ -25,6 +25,12 @@ jest.mock(
},
);
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
describe('Dashboard landing page actions header tests', () => {
it('unlock dashboard should be disabled for integrations created dashboards', async () => {
const mockLocation = {

View File

@ -21,8 +21,8 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import {
Check,
@ -89,6 +89,7 @@ export function sanitizeDashboardData(
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { handle } = props;
const {
selectedDashboard,
@ -311,7 +312,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
urlQuery.delete(QueryParams.relativeTime);
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlQuery.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
}
return (

View File

@ -3,11 +3,11 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import {
Dispatch,
SetStateAction,
@ -33,6 +33,7 @@ function WidgetGraph({
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search);
@ -71,9 +72,9 @@ function WidgetGraph({
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
safeNavigate(generatedUrl);
},
[dispatch, location.pathname, urlQuery],
[dispatch, location.pathname, safeNavigate, urlQuery],
);
useEffect(() => {

View File

@ -87,6 +87,12 @@ jest.mock('hooks/queryBuilder/useGetCompositeQueryParam', () => ({
useGetCompositeQueryParam: (): Query => compositeQueryParam as Query,
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
describe('Column unit selector panel unit test', () => {
it('unit selectors should be rendered for queries and formula', () => {
const mockLocation = {

View File

@ -20,10 +20,10 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useAxiosError from 'hooks/useAxiosError';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import history from 'lib/history';
import { defaultTo, isEmpty, isUndefined } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { DashboardWidgetPageParams } from 'pages/DashboardWidget';
@ -67,6 +67,7 @@ import {
} from './utils';
function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const {
selectedDashboard,
setSelectedDashboard,
@ -328,7 +329,11 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
}
const updatedQuery = { ...(stagedQuery || initialQueriesMap.metrics) };
updatedQuery.builder.queryData[0].pageSize = 10;
redirectWithQueryBuilderData(updatedQuery);
// If stagedQuery exists, don't re-run the query (e.g. when clicking on Add to Dashboard from logs and traces explorer)
if (!stagedQuery) {
redirectWithQueryBuilderData(updatedQuery);
}
return {
query: updatedQuery,
graphType: PANEL_TYPES.LIST,
@ -469,7 +474,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
setSelectedRowWidgetId(null);
setSelectedDashboard(dashboard);
setToScrollWidgetId(selectedWidget?.id || '');
history.push({
safeNavigate({
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
});
},
@ -492,6 +497,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
setSelectedDashboard,
setToScrollWidgetId,
setSelectedRowWidgetId,
safeNavigate,
dashboardId,
]);
@ -500,12 +506,12 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
setDiscardModal(true);
return;
}
history.push(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [dashboardId, isQueryModified]);
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [dashboardId, isQueryModified, safeNavigate]);
const discardChanges = useCallback(() => {
history.push(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [dashboardId]);
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [dashboardId, safeNavigate]);
const setGraphHandler = (type: PANEL_TYPES): void => {
setIsLoadingPanelData(true);

View File

@ -23,6 +23,12 @@ jest.mock('providers/Dashboard/Dashboard', () => ({
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
describe('QueryTable -', () => {
it('should render correctly with all the data rows', () => {
const { container } = render(<QueryTable {...QueryTableProps} />);

View File

@ -23,17 +23,18 @@ import { QueryHistoryState } from 'container/LiveLogs/types';
import NewExplorerCTA from 'container/NewExplorerCTA';
import dayjs, { Dayjs } from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { isObject } from 'lodash-es';
import { Check, Copy, Info, Send, Undo } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { connect, useSelector } from 'react-redux';
import { connect, useDispatch, useSelector } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { useNavigationType } from 'react-router-dom-v5-compat';
import { useCopyToClipboard } from 'react-use';
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
@ -43,6 +44,7 @@ import AppActions from 'types/actions';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';
import { normalizeTimeToMs } from 'utils/timeUtils';
import AutoRefresh from '../AutoRefreshV2';
import { DateTimeRangeType } from '../CustomDateTimeModal';
@ -75,6 +77,9 @@ function DateTimeSelection({
modalSelectedInterval,
}: Props): JSX.Element {
const [formSelector] = Form.useForm();
const { safeNavigate } = useSafeNavigate();
const navigationType = useNavigationType(); // Returns 'POP' for back/forward navigation
const dispatch = useDispatch();
const [hasSelectedTimeError, setHasSelectedTimeError] = useState(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
@ -189,8 +194,8 @@ function DateTimeSelection({
const path = `${ROUTES.LIVE_LOGS}?${QueryParams.compositeQuery}=${JSONCompositeQuery}`;
history.push(path, queryHistoryState);
}, [panelType, queryClient, stagedQuery]);
safeNavigate(path, { state: queryHistoryState });
}, [panelType, queryClient, safeNavigate, stagedQuery]);
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
@ -349,7 +354,7 @@ function DateTimeSelection({
urlQuery.set(QueryParams.relativeTime, value);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
}
// For logs explorer - time range handling is managed in useCopyLogLink.ts:52
@ -368,6 +373,7 @@ function DateTimeSelection({
location.pathname,
onTimeChange,
refreshButtonHidden,
safeNavigate,
stagedQuery,
updateLocalStorageForRoutes,
updateTimeInterval,
@ -440,7 +446,7 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
}
}
}
@ -467,7 +473,7 @@ function DateTimeSelection({
urlQuery.set(QueryParams.relativeTime, dateTimeStr);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
}
if (!stagedQuery) {
@ -509,6 +515,77 @@ function DateTimeSelection({
return time;
};
const handleAbsoluteTimeSync = useCallback(
(
startTime: string,
endTime: string,
currentMinTime: number,
currentMaxTime: number,
): void => {
const startTs = normalizeTimeToMs(startTime);
const endTs = normalizeTimeToMs(endTime);
const timeComparison = {
url: {
start: dayjs(startTs).startOf('minute'),
end: dayjs(endTs).startOf('minute'),
},
current: {
start: dayjs(normalizeTimeToMs(currentMinTime)).startOf('minute'),
end: dayjs(normalizeTimeToMs(currentMaxTime)).startOf('minute'),
},
};
const hasTimeChanged =
!timeComparison.current.start.isSame(timeComparison.url.start) ||
!timeComparison.current.end.isSame(timeComparison.url.end);
if (hasTimeChanged) {
dispatch(UpdateTimeInterval('custom', [startTs, endTs]));
}
},
[dispatch],
);
const handleRelativeTimeSync = useCallback(
(relativeTime: string): void => {
updateTimeInterval(relativeTime as Time);
setIsValidteRelativeTime(true);
setRefreshButtonHidden(false);
},
[updateTimeInterval],
);
// Sync time picker state with URL on browser navigation
useEffect(() => {
if (navigationType !== 'POP') return;
if (searchStartTime && searchEndTime) {
handleAbsoluteTimeSync(searchStartTime, searchEndTime, minTime, maxTime);
return;
}
if (
relativeTimeFromUrl &&
isValidTimeFormat(relativeTimeFromUrl) &&
relativeTimeFromUrl !== selectedTime
) {
handleRelativeTimeSync(relativeTimeFromUrl);
}
}, [
navigationType,
searchStartTime,
searchEndTime,
relativeTimeFromUrl,
selectedTime,
minTime,
maxTime,
dispatch,
updateTimeInterval,
handleAbsoluteTimeSync,
handleRelativeTimeSync,
]);
// this is triggred when we change the routes and based on that we are changing the default options
useEffect(() => {
const metricsTimeDuration = getLocalStorageKey(
@ -524,6 +601,16 @@ function DateTimeSelection({
const currentRoute = location.pathname;
// Give priority to relativeTime from URL if it exists and start /end time are not present in the url, to sync the relative time in URL param with the time picker
if (
!searchStartTime &&
!searchEndTime &&
relativeTimeFromUrl &&
isValidTimeFormat(relativeTimeFromUrl)
) {
handleRelativeTimeSync(relativeTimeFromUrl);
}
// set the default relative time for alert history and overview pages if relative time is not specified
if (
(!urlQuery.has(QueryParams.startTime) ||
@ -535,7 +622,7 @@ function DateTimeSelection({
updateTimeInterval(defaultRelativeTime);
urlQuery.set(QueryParams.relativeTime, defaultRelativeTime);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
return;
}
@ -573,7 +660,7 @@ function DateTimeSelection({
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, updateTimeInterval, globalTimeLoading]);

View File

@ -12,6 +12,7 @@ import {
transformDataWithDate,
} from 'container/TracesExplorer/ListView/utils';
import { Pagination } from 'hooks/queryPagination';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import history from 'lib/history';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
@ -39,6 +40,7 @@ function TracesTableComponent({
offset: 0,
limit: 10,
});
const { safeNavigate } = useSafeNavigate();
useEffect(() => {
setRequestData((prev) => ({
@ -87,6 +89,25 @@ function TracesTableComponent({
[],
);
const handlePaginationChange = useCallback(
(newPagination: Pagination) => {
const urlQuery = new URLSearchParams(window.location.search);
// Update URL with new pagination values
urlQuery.set('offset', newPagination.offset.toString());
urlQuery.set('limit', newPagination.limit.toString());
// Update URL without page reload
safeNavigate({
search: urlQuery.toString(),
});
// Update component state
setPagination(newPagination);
},
[safeNavigate],
);
if (queryResponse.isError) {
return <div>{SOMETHING_WENT_WRONG}</div>;
}
@ -116,19 +137,19 @@ function TracesTableComponent({
offset={pagination.offset}
countPerPage={pagination.limit}
handleNavigatePrevious={(): void => {
setPagination({
handlePaginationChange({
...pagination,
offset: pagination.offset - pagination.limit,
});
}}
handleNavigateNext={(): void => {
setPagination({
handlePaginationChange({
...pagination,
offset: pagination.offset + pagination.limit,
});
}}
handleCountItemsPerPageChange={(value): void => {
setPagination({
handlePaginationChange({
...pagination,
limit: value,
offset: 0,

View File

@ -1,10 +1,10 @@
import { useMachine } from '@xstate/react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { encode } from 'js-base64';
import history from 'lib/history';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { whilelistedKeys } from './config';
@ -32,6 +32,7 @@ function ResourceProvider({ children }: Props): JSX.Element {
const [queries, setQueries] = useState<IResourceAttribute[]>(
getResourceAttributeQueriesFromURL(),
);
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const [optionsData, setOptionsData] = useState<OptionsData>({
@ -39,6 +40,12 @@ function ResourceProvider({ children }: Props): JSX.Element {
options: [],
});
// Watch for URL query changes
useEffect(() => {
const queriesFromUrl = getResourceAttributeQueriesFromURL();
setQueries(queriesFromUrl);
}, [urlQuery]);
const handleLoading = (isLoading: boolean): void => {
setLoading(isLoading);
if (isLoading) {
@ -53,10 +60,10 @@ function ResourceProvider({ children }: Props): JSX.Element {
encode(JSON.stringify(queries)),
);
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
setQueries(queries);
},
[pathname, urlQuery],
[pathname, safeNavigate, urlQuery],
);
const [state, send] = useMachine(ResourceAttributesFilterMachine, {

View File

@ -5,6 +5,12 @@ import { Router } from 'react-router-dom';
import ResourceProvider from '../ResourceProvider';
import useResourceAttribute from '../useResourceAttribute';
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
describe('useResourceAttribute component hook', () => {
it('should not change other query params except for resourceAttribute', async () => {
const history = createMemoryHistory({

View File

@ -0,0 +1,136 @@
import { cloneDeep, isEqual } from 'lodash-es';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
interface NavigateOptions {
replace?: boolean;
state?: any;
}
interface SafeNavigateParams {
pathname?: string;
search?: string;
}
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) return false;
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
const allParams = new Set([
...Array.from(params1.keys()),
...Array.from(params2.keys()),
]);
return Array.from(allParams).every((param) => {
if (param === 'compositeQuery') {
try {
const query1 = params1.get('compositeQuery');
const query2 = params2.get('compositeQuery');
if (!query1 || !query2) return false;
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
delete filtered1.id;
delete filtered2.id;
return isEqual(filtered1, filtered2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;
}
}
return params1.get(param) === params2.get(param);
});
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) return false;
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) return true;
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(Array.from(currentParams.keys()));
const targetKeys = new Set(Array.from(targetParams.keys()));
// Find keys that exist in target but not in current
const newKeys = Array.from(targetKeys).filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};
export const useSafeNavigate = (): {
safeNavigate: (
to: string | SafeNavigateParams,
options?: NavigateOptions,
) => void;
} => {
const navigate = useNavigate();
const location = useLocation();
const safeNavigate = useCallback(
(to: string | SafeNavigateParams, options?: NavigateOptions) => {
const currentUrl = new URL(
`${location.pathname}${location.search}`,
window.location.origin,
);
let targetUrl: URL;
if (typeof to === 'string') {
targetUrl = new URL(to, window.location.origin);
} else {
targetUrl = new URL(
`${to.pathname || location.pathname}${to.search || ''}`,
window.location.origin,
);
}
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
if (urlsAreSame) {
return;
}
const navigationOptions = {
...options,
replace: isDefaultParamsNavigation || options?.replace,
};
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
},
[navigate, location.pathname, location.search],
);
return { safeNavigate };
};

View File

@ -1,15 +1,16 @@
import { useCallback, useMemo } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useSafeNavigate } from './useSafeNavigate';
import useUrlQuery from './useUrlQuery';
const useUrlQueryData = <T>(
queryKey: string,
defaultData?: T,
): UseUrlQueryData<T> => {
const history = useHistory();
const location = useLocation();
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const query = useMemo(() => urlQuery.get(queryKey), [urlQuery, queryKey]);
@ -32,9 +33,9 @@ const useUrlQueryData = <T>(
// Construct the new URL by combining the current pathname with the updated query string
const generatedUrl = `${location.pathname}?${currentUrlQuery.toString()}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
},
[history, location.pathname, queryKey],
[location.pathname, queryKey, safeNavigate],
);
return {

View File

@ -20,6 +20,7 @@ import { urlKey } from 'container/AllError/utils';
import { RelativeTimeMap } from 'container/TopNav/DateTimeSelection/config';
import useAxiosError from 'hooks/useAxiosError';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import GetMinMax from 'lib/getMinMax';
@ -321,6 +322,8 @@ export const useTimelineTable = ({
extra: any,
) => void;
} => {
const { safeNavigate } = useSafeNavigate();
const { pathname } = useLocation();
const { search } = useLocation();
@ -343,7 +346,7 @@ export const useTimelineTable = ({
const updatedOrder = order === 'ascend' ? 'asc' : 'desc';
const params = new URLSearchParams(window.location.search);
history.replace(
safeNavigate(
`${pathname}?${createQueryParams({
...Object.fromEntries(params),
order: updatedOrder,
@ -353,7 +356,7 @@ export const useTimelineTable = ({
);
}
},
[pathname],
[pathname, safeNavigate],
);
const offsetInt = parseInt(offset, 10);

View File

@ -5,8 +5,8 @@ import ROUTES from 'constants/routes';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { GalleryVerticalEnd, Pyramid } from 'lucide-react';
import AlertDetails from 'pages/AlertDetails';
import { useLocation } from 'react-router-dom';
@ -14,6 +14,7 @@ import { useLocation } from 'react-router-dom';
function AllAlertList(): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const tab = urlQuery.get('tab');
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
@ -67,7 +68,7 @@ function AllAlertList(): JSX.Element {
if (search) {
params += `&search=${search}`;
}
history.replace(`/alerts?${params}`);
safeNavigate(`/alerts?${params}`);
}}
className={`${
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''

View File

@ -4,8 +4,8 @@ import { SOMETHING_WENT_WRONG } from 'constants/api';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import NewWidget from 'container/NewWidget';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useEffect, useState } from 'react';
import { generatePath, useLocation, useParams } from 'react-router-dom';
@ -14,6 +14,7 @@ import { Widgets } from 'types/api/dashboard/getAll';
function DashboardWidget(): JSX.Element | null {
const { search } = useLocation();
const { dashboardId } = useParams<DashboardWidgetPageParams>();
const { safeNavigate } = useSafeNavigate();
const [selectedGraph, setSelectedGraph] = useState<PANEL_TYPES>();
@ -32,11 +33,11 @@ function DashboardWidget(): JSX.Element | null {
const graphType = params.get('graphType') as PANEL_TYPES | null;
if (graphType === null) {
history.push(generatePath(ROUTES.DASHBOARD, { dashboardId }));
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
} else {
setSelectedGraph(graphType);
}
}, [dashboardId, search]);
}, [dashboardId, safeNavigate, search]);
if (selectedGraph === undefined || dashboardResponse.isLoading) {
return <Spinner tip="Loading.." />;

View File

@ -29,6 +29,12 @@ jest.mock('react-router-dom', () => ({
const mockWindowOpen = jest.fn();
window.open = mockWindowOpen;
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
describe('dashboard list page', () => {
// should render on updatedAt and descend when the column key and order is messed up
it('should render the list even when the columnKey or the order is mismatched', async () => {

View File

@ -8,6 +8,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import EditRulesContainer from 'container/EditRules';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useEffect } from 'react';
@ -21,6 +22,7 @@ import {
} from './constants';
function EditRules(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const params = useUrlQuery();
const ruleId = params.get(QueryParams.ruleId);
const { t } = useTranslation('common');
@ -55,9 +57,9 @@ function EditRules(): JSX.Element {
notifications.error({
message: 'Rule Id is required',
});
history.replace(ROUTES.LIST_ALL_ALERT);
safeNavigate(ROUTES.LIST_ALL_ALERT);
}
}, [isValidRuleId, ruleId, notifications]);
}, [isValidRuleId, ruleId, notifications, safeNavigate]);
if (
(isError && !isValidRuleId) ||

View File

@ -67,6 +67,12 @@ jest.mock('d3-interpolate', () => ({
interpolate: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
const logsQueryServerRequest = (): void =>
server.use(
rest.post(queryRangeURL, (req, res, ctx) =>

View File

@ -11,7 +11,7 @@ import logEvent from 'api/common/logEvent';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isArray, isEmpty, isEqual } from 'lodash-es';
import { cloneDeep, isArray, isEmpty, isEqual } from 'lodash-es';
import {
Dispatch,
SetStateAction,
@ -177,6 +177,21 @@ export function Filter(props: FilterProps): JSX.Element {
return items as TagFilterItem[];
};
const removeFilterItemIds = (query: Query): Query => {
const clonedQuery = cloneDeep(query);
clonedQuery.builder.queryData = clonedQuery.builder.queryData.map((data) => ({
...data,
filters: {
...data.filters,
items: data.filters?.items?.map((item) => ({
...item,
id: '',
})),
},
}));
return clonedQuery;
};
const handleRun = useCallback(
(props?: HandleRunProps): void => {
const preparedQuery: Query = {
@ -204,9 +219,16 @@ export function Filter(props: FilterProps): JSX.Element {
});
}
if (isEqual(currentQuery, preparedQuery) && !props?.resetAll) {
const currentQueryWithoutIds = removeFilterItemIds(currentQuery);
const preparedQueryWithoutIds = removeFilterItemIds(preparedQuery);
if (
isEqual(currentQueryWithoutIds, preparedQueryWithoutIds) &&
!props?.resetAll
) {
return;
}
redirectWithQueryBuilderData(preparedQuery);
},
[currentQuery, redirectWithQueryBuilderData, selectedFilters],

View File

@ -116,6 +116,12 @@ jest.mock('react-redux', () => ({
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
describe('TracesExplorer - Filters', () => {
// Initial filter panel rendering
// Test the initial state like which filters section are opened, default state of duration slider, etc.

View File

@ -24,7 +24,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { cloneDeep, isEmpty, set } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@ -61,6 +61,7 @@ function TracesExplorer(): JSX.Element {
const currentPanelType = useGetPanelTypesQueryParam();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const currentTab = panelType || PANEL_TYPES.LIST;
@ -197,7 +198,7 @@ function TracesExplorer(): JSX.Element {
widgetId,
});
history.push(dashboardEditView);
safeNavigate(dashboardEditView);
},
onError: (error) => {
if (axios.isAxiosError(error)) {

View File

@ -9,10 +9,10 @@ import { getMinMax } from 'container/TopNav/AutoRefresh/config';
import dayjs, { Dayjs } from 'dayjs';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
import useAxiosError from 'hooks/useAxiosError';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useTabVisibility from 'hooks/useTabFocus';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import history from 'lib/history';
import { defaultTo } from 'lodash-es';
import isEqual from 'lodash-es/isEqual';
import isUndefined from 'lodash-es/isUndefined';
@ -84,6 +84,7 @@ interface Props {
export function DashboardProvider({
children,
}: PropsWithChildren): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [isDashboardSliderOpen, setIsDashboardSlider] = useState<boolean>(false);
const [toScrollWidgetId, setToScrollWidgetId] = useState<string>('');
@ -145,7 +146,7 @@ export function DashboardProvider({
params.set('order', sortOrder.order as string);
params.set('page', sortOrder.pagination || '1');
params.set('search', sortOrder.search || '');
history.replace({ search: params.toString() });
safeNavigate({ search: params.toString() });
}
const dispatch = useDispatch<Dispatch<AppActions>>();

View File

@ -23,6 +23,7 @@ import {
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
@ -39,7 +40,7 @@ import {
useState,
} from 'react';
import { useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { AppState } from 'store/reducers';
// ** Types
import {
@ -96,7 +97,6 @@ export function QueryBuilderProvider({
children,
}: PropsWithChildren): JSX.Element {
const urlQuery = useUrlQuery();
const history = useHistory();
const location = useLocation();
const currentPathnameRef = useRef<string | null>(location.pathname);
@ -763,6 +763,8 @@ export function QueryBuilderProvider({
[panelType, stagedQuery],
);
const { safeNavigate } = useSafeNavigate();
const redirectWithQueryBuilderData = useCallback(
(
query: Partial<Query>,
@ -833,9 +835,9 @@ export function QueryBuilderProvider({
? `${redirectingUrl}?${urlQuery}`
: `${location.pathname}?${urlQuery}`;
history.replace(generatedUrl);
safeNavigate(generatedUrl);
},
[history, location.pathname, urlQuery],
[location.pathname, safeNavigate, urlQuery],
);
const handleSetConfig = useCallback(

View File

@ -88,6 +88,17 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: (): any => 'PUSH',
}));
export function getAppContextMock(
role: string,
appContextOverrides?: Partial<IAppContext>,

View File

@ -134,3 +134,21 @@ export const epochToTimeString = (epochMs: number): string => {
};
return date.toLocaleTimeString('en-US', options);
};
/**
* Converts nanoseconds to milliseconds
* @param timestamp - The timestamp to convert
* @returns The timestamp in milliseconds
*/
export const normalizeTimeToMs = (timestamp: number | string): number => {
let ts = timestamp;
if (typeof timestamp === 'string') {
ts = Math.trunc(parseInt(timestamp, 10));
}
ts = Number(ts);
// Check if timestamp is in nanoseconds (19+ digits)
const isNanoSeconds = ts.toString().length >= 19;
return isNanoSeconds ? Math.floor(ts / 1_000_000) : ts;
};