Compare commits

...

38 Commits

Author SHA1 Message Date
Manika Malhotra
15da283d0d Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-10-22 11:32:41 +05:30
Manika Malhotra
455ba0549f Merge branch 'main' into enhancement/cmd-click-stack 2025-10-22 11:32:29 +05:30
manika-signoz
5f2c302551 chore: extract isEventObject utility to separate file 2025-10-13 23:56:49 +05:30
manika-signoz
15c2dc700a chore: use requireActual 2025-10-13 23:41:21 +05:30
manika-signoz
63a4beb737 Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-10-13 23:26:05 +05:30
manika-signoz
02fa0dbc32 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-13 23:25:35 +05:30
manika-signoz
e0948033c8 chore: re-export isEventObject utility from mocks 2025-10-13 23:24:28 +05:30
manika-signoz
8e02c73f0a Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-10-13 10:30:10 +05:30
manika-signoz
a1115ac65b Merge branch 'main' into enhancement/cmd-click-stack 2025-10-13 10:30:04 +05:30
manika-signoz
640482292d Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-10-09 17:03:19 +05:30
manika-signoz
9bcb88c747 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-09 17:03:12 +05:30
manika-signoz
e4711ee79e Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-10-09 00:57:38 +05:30
manika-signoz
367bf7b4b5 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-09 00:57:31 +05:30
manika-signoz
1d67461773 revert: top contributors and new explorer cta changes 2025-10-09 00:56:28 +05:30
manika-signoz
3510411464 Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-10-08 15:38:22 +05:30
manika-signoz
59b68057b8 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-08 15:38:11 +05:30
manika-signoz
1cd2bdd0fc Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-10-07 23:07:30 +05:30
manika-signoz
fa1b2ddf7c Merge branch 'main' into enhancement/cmd-click-stack 2025-10-07 23:07:15 +05:30
manika-signoz
642a0e5656 fix: dashboardandalertspopover.test to use usesafenav mock 2025-09-30 19:31:34 +05:30
manika-signoz
cb99ee1ac1 fix: failing test cases due to isEventObject, add to mock 2025-09-30 19:16:54 +05:30
manika-signoz
018cab5d7e Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-09-30 17:43:06 +05:30
manika-signoz
7616cb89e4 Merge branch 'enhancement/cmd-click-stack' of github.com:SigNoz/signoz into enhancement/cmd-click-stack 2025-09-30 17:41:07 +05:30
manika-signoz
bf780c7445 chore: resolve comments, improve type safety in usesafenav 2025-09-30 17:40:17 +05:30
manika-signoz
61062dfd8d Merge branch 'main' into enhancement/cmd-click-stack 2025-09-30 17:08:38 +05:30
manika-signoz
f3f995420e Merge branch 'feat/cmd-click-impl-updated-history' of github.com:SigNoz/signoz into feat/cmd-click-impl-updated-history 2025-09-30 17:07:49 +05:30
manika-signoz
87971e50f8 chore: log details open in explorer 2025-09-30 17:07:14 +05:30
manika-signoz
24d7a72777 Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-09-29 10:40:38 +05:30
manika-signoz
5b7af9651c Merge branch 'main' into enhancement/cmd-click-stack 2025-09-29 10:40:22 +05:30
manika-signoz
50ff558195 Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-09-24 13:12:44 +05:30
manika-signoz
b9012f6150 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-24 13:12:36 +05:30
manika-signoz
abb3ec3688 Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history 2025-09-24 10:13:09 +05:30
manika-signoz
7ab81780b3 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-24 10:06:51 +05:30
manika-signoz
8bf6c23740 feat: cmd click implementation using updated methods 2025-09-23 17:20:21 +05:30
manika-signoz
a16f51457f Merge branch 'main' into enhancement/cmd-click-stack 2025-09-23 16:32:51 +05:30
manika-signoz
38a38b5645 test: add tests for history.push 2025-09-23 16:30:16 +05:30
manika-signoz
bb04bc5044 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-23 16:16:16 +05:30
manika-signoz
58736f40dc feat: add support for location object in history.push override 2025-09-22 19:05:42 +05:30
manika-signoz
91154249d6 feat: add history.push and safeNavigate method overrides 2025-09-22 18:55:52 +05:30
26 changed files with 1022 additions and 184 deletions

1
.gitignore vendored
View File

@@ -106,6 +106,7 @@ downloads/
eggs/
.eggs/
lib/
!frontend/src/lib/
lib64/
parts/
sdist/

View File

@@ -1,4 +1,6 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
export { isEventObject } from '../src/utils/isEventObject';
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;

View File

@@ -133,7 +133,7 @@ function LogDetailInner({
};
// Go to logs explorer page with the log data
const handleOpenInExplorer = (): void => {
const handleOpenInExplorer = (event: React.MouseEvent): void => {
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
@@ -146,7 +146,10 @@ function LogDetailInner({
),
),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`);
safeNavigate(
`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`,
event,
);
};
const handleQueryExpressionChange = useCallback(
@@ -239,7 +242,7 @@ function LogDetailInner({
<Button
className="open-in-explorer-btn"
icon={<Compass size={16} />}
onClick={handleOpenInExplorer}
onClick={(event: React.MouseEvent): void => handleOpenInExplorer(event)}
>
Open in Explorer
</Button>

View File

@@ -20,13 +20,17 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
const { user } = useAppContext();
const [action] = useComponentPermission(['new_alert_action'], user.role);
const onClickEditHandler = useCallback((id: string) => {
history.push(
generatePath(ROUTES.CHANNELS_EDIT, {
channelId: id,
}),
);
}, []);
const onClickEditHandler = useCallback(
(id: string, event?: React.MouseEvent) => {
history.push(
generatePath(ROUTES.CHANNELS_EDIT, {
channelId: id,
}),
event,
);
},
[],
);
const columns: ColumnsType<Channels> = [
{
@@ -52,7 +56,10 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
width: 80,
render: (id: string): JSX.Element => (
<>
<Button onClick={(): void => onClickEditHandler(id)} type="link">
<Button
onClick={(event: React.MouseEvent): void => onClickEditHandler(id, event)}
type="link"
>
{t('column_channel_edit')}
</Button>
<Delete id={id} notifications={notifications} />

View File

@@ -30,8 +30,8 @@ function AlertChannels(): JSX.Element {
['add_new_channel'],
user.role,
);
const onToggleHandler = useCallback(() => {
history.push(ROUTES.CHANNELS_NEW);
const onToggleHandler = useCallback((event?: React.MouseEvent) => {
history.push(ROUTES.CHANNELS_NEW, event);
}, []);
const { isLoading, data, error } = useQuery<
@@ -78,7 +78,7 @@ function AlertChannels(): JSX.Element {
}
>
<Button
onClick={onToggleHandler}
onClick={(event: React.MouseEvent): void => onToggleHandler(event)}
icon={<PlusOutlined />}
disabled={!addNewChannelPermission}
>

View File

@@ -111,14 +111,17 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
value: errorDetail[key as keyof GetByErrorTypeAndServicePayload],
}));
const onClickTraceHandler = (): void => {
const onClickTraceHandler = (event?: React.MouseEvent): void => {
logEvent('Exception: Navigate to trace detail page', {
groupId: errorDetail?.groupID,
spanId: errorDetail.spanID,
traceId: errorDetail.traceID,
exceptionId: errorDetail?.errorId,
});
history.push(`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`);
history.push(
`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`,
event,
);
};
const logEventCalledRef = useRef(false);
@@ -185,7 +188,10 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
<DashedContainer>
<Typography>{t('see_trace_graph')}</Typography>
<Button onClick={onClickTraceHandler} type="primary">
<Button
onClick={(event: React.MouseEvent): void => onClickTraceHandler(event)}
type="primary"
>
{t('see_error_in_trace_graph')}
</Button>
</DashedContainer>

View File

@@ -190,7 +190,7 @@ function ExplorerOptions({
);
const onCreateAlertsHandler = useCallback(
(defaultQuery: Query | null) => {
(defaultQuery: Query | null, event?: React.MouseEvent) => {
if (sourcepage === DataSource.TRACES) {
logEvent('Traces Explorer: Create alert', {
panelType,
@@ -213,6 +213,7 @@ function ExplorerOptions({
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
event,
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -726,7 +727,9 @@ function ExplorerOptions({
<Button
disabled={disabled}
shape="round"
onClick={(): void => onCreateAlertsHandler(query)}
onClick={(event: React.MouseEvent): void =>
onCreateAlertsHandler(query, event)
}
icon={<ConciergeBell size={16} />}
>
Create an Alert

View File

@@ -114,7 +114,10 @@ export default function AlertRules({
</div>
);
const onEditHandler = (record: GettableAlert) => (): void => {
const onEditHandler = (
record: GettableAlert,
event?: React.MouseEvent,
) => (): void => {
logEvent('Homepage: Alert clicked', {
ruleId: record.id,
ruleName: record.alert,
@@ -131,7 +134,7 @@ export default function AlertRules({
params.set(QueryParams.ruleId, record.id.toString());
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, event);
};
const renderAlertRules = (): JSX.Element => (
@@ -143,7 +146,7 @@ export default function AlertRules({
tabIndex={0}
className="alert-rule-item home-data-item"
key={rule.id}
onClick={onEditHandler(rule)}
onClick={(event: React.MouseEvent): void => onEditHandler(rule, event)()}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
onEditHandler(rule);

View File

@@ -84,14 +84,14 @@ function DataSourceInfo({
icon={<img src="/Icons/container-plus.svg" alt="plus" />}
role="button"
tabIndex={0}
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Connect dataSource clicked', {});
if (
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
history.push(ROUTES.GET_STARTED_WITH_CLOUD, event);
} else {
window?.open(
DOCS_LINKS.ADD_DATA_SOURCE,

View File

@@ -413,12 +413,12 @@ export default function Home(): JSX.Element {
role="button"
tabIndex={0}
className="active-ingestion-card-actions"
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
// eslint-disable-next-line sonarjs/no-duplicate-string
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Logs',
});
history.push(ROUTES.LOGS_EXPLORER);
history.push(ROUTES.LOGS_EXPLORER, event);
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -455,11 +455,11 @@ export default function Home(): JSX.Element {
className="active-ingestion-card-actions"
role="button"
tabIndex={0}
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Traces',
});
history.push(ROUTES.TRACES_EXPLORER);
history.push(ROUTES.TRACES_EXPLORER, event);
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -496,11 +496,11 @@ export default function Home(): JSX.Element {
className="active-ingestion-card-actions"
role="button"
tabIndex={0}
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Metrics',
});
history.push(ROUTES.METRICS_EXPLORER);
history.push(ROUTES.METRICS_EXPLORER, event);
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -550,11 +550,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Wrench size={14} />}
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
source: 'Logs',
});
history.push(ROUTES.LOGS_EXPLORER);
history.push(ROUTES.LOGS_EXPLORER, event);
}}
>
Open Logs Explorer
@@ -564,11 +564,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Wrench size={14} />}
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
source: 'Traces',
});
history.push(ROUTES.TRACES_EXPLORER);
history.push(ROUTES.TRACES_EXPLORER, event);
}}
>
Open Traces Explorer
@@ -578,11 +578,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Wrench size={14} />}
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
source: 'Metrics',
});
history.push(ROUTES.METRICS_EXPLORER_EXPLORER);
history.push(ROUTES.METRICS_EXPLORER_EXPLORER, event);
}}
>
Open Metrics Explorer
@@ -619,11 +619,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Plus size={14} />}
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
source: 'Dashboards',
});
history.push(ROUTES.ALL_DASHBOARD);
history.push(ROUTES.ALL_DASHBOARD, event);
}}
>
Create dashboard
@@ -661,11 +661,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Plus size={14} />}
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
source: 'Alerts',
});
history.push(ROUTES.ALERTS_NEW);
history.push(ROUTES.ALERTS_NEW, event);
}}
>
Create an alert

View File

@@ -86,18 +86,18 @@ function HomeChecklist({
<Button
type="default"
className="periscope-btn secondary"
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Welcome Checklist: Get started clicked', {
step: item.id,
});
if (item.toRoute !== ROUTES.GET_STARTED_WITH_CLOUD) {
history.push(item.toRoute || '');
history.push(item.toRoute || '', event);
} else if (
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(item.toRoute || '');
history.push(item.toRoute || '', event);
} else {
window?.open(
item.docsLink || '',

View File

@@ -64,7 +64,7 @@ const EmptyState = memo(
<Button
type="default"
className="periscope-btn secondary"
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Get Started clicked', {
source: 'Service Metrics',
});
@@ -73,7 +73,7 @@ const EmptyState = memo(
activeLicenseV3 &&
activeLicenseV3.platform === LicensePlatform.CLOUD
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
history.push(ROUTES.GET_STARTED_WITH_CLOUD, event);
} else {
window?.open(
DOCS_LINKS.ADD_DATA_SOURCE,
@@ -116,7 +116,7 @@ const ServicesListTable = memo(
onRowClick,
}: {
services: ServicesList[];
onRowClick: (record: ServicesList) => void;
onRowClick: (record: ServicesList, event: React.MouseEvent) => void;
}): JSX.Element => (
<div className="services-list-container home-data-item-container metrics-services-list">
<div className="services-list">
@@ -125,8 +125,8 @@ const ServicesListTable = memo(
dataSource={services}
pagination={false}
className="services-table"
onRow={(record): { onClick: () => void } => ({
onClick: (): void => onRowClick(record),
onRow={(record): { onClick: (event: React.MouseEvent) => void } => ({
onClick: (event: React.MouseEvent): void => onRowClick(record, event),
})}
/>
</div>
@@ -284,11 +284,11 @@ function ServiceMetrics({
}, [onUpdateChecklistDoneItem, loadingUserPreferences, servicesExist]);
const handleRowClick = useCallback(
(record: ServicesList) => {
(record: ServicesList, event: React.MouseEvent) => {
logEvent('Homepage: Service clicked', {
serviceName: record.serviceName,
});
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`, event);
},
[safeNavigate],
);
@@ -333,7 +333,12 @@ function ServiceMetrics({
)}
<Card.Content>
{servicesExist ? (
<ServicesListTable services={top5Services} onRowClick={handleRowClick} />
<ServicesListTable
services={top5Services}
onRowClick={(record: ServicesList, event: React.MouseEvent): void =>
handleRowClick(record, event)
}
/>
) : (
<EmptyState user={user} activeLicenseV3={activeLicense} />
)}

View File

@@ -118,7 +118,7 @@ export default function ServiceTraces({
<Button
type="default"
className="periscope-btn secondary"
onClick={(): void => {
onClick={(event: React.MouseEvent): void => {
logEvent('Homepage: Get Started clicked', {
source: 'Service Traces',
});
@@ -127,7 +127,7 @@ export default function ServiceTraces({
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
history.push(ROUTES.GET_STARTED_WITH_CLOUD, event);
} else {
window?.open(
DOCS_LINKS.ADD_DATA_SOURCE,
@@ -172,13 +172,13 @@ export default function ServiceTraces({
dataSource={top5Services}
pagination={false}
className="services-table"
onRow={(record): { onClick: () => void } => ({
onClick: (): void => {
onRow={(record): { onClick: (event: React.MouseEvent) => void } => ({
onClick: (event: React.MouseEvent): void => {
logEvent('Homepage: Service clicked', {
serviceName: record.serviceName,
});
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`, event);
},
})}
/>

View File

@@ -36,9 +36,9 @@ export function AlertsEmptyState(): JSX.Element {
const [loading, setLoading] = useState(false);
const onClickNewAlertHandler = useCallback(() => {
const onClickNewAlertHandler = useCallback((event: React.MouseEvent) => {
setLoading(false);
history.push(ROUTES.ALERTS_NEW);
history.push(ROUTES.ALERTS_NEW, event);
}, []);
return (
@@ -70,7 +70,9 @@ export function AlertsEmptyState(): JSX.Element {
<div className="action-container">
<Button
className="add-alert-btn"
onClick={onClickNewAlertHandler}
onClick={(event: React.MouseEvent): void =>
onClickNewAlertHandler(event)
}
icon={<PlusOutlined />}
disabled={!addNewAlert}
loading={loading}

View File

@@ -118,7 +118,7 @@ const templatesList: DashboardTemplate[] = [
interface DashboardTemplatesModalProps {
showNewDashboardTemplatesModal: boolean;
onCreateNewDashboard: () => void;
onCreateNewDashboard: (event: React.MouseEvent) => void;
onCancel: () => void;
}
@@ -204,7 +204,9 @@ export default function DashboardTemplatesModal({
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
onClick={(event: React.MouseEvent): void =>
onCreateNewDashboard(event)
}
>
New dashboard
</Button>

View File

@@ -282,35 +282,39 @@ function DashboardsList(): JSX.Element {
refetchDashboardList,
})) || [];
const onNewDashboardHandler = useCallback(async () => {
try {
logEvent('Dashboard List: Create dashboard clicked', {});
setNewDashboardState({
...newDashboardState,
loading: true,
});
const response = await createDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
const onNewDashboardHandler = useCallback(
async (event: React.MouseEvent) => {
try {
logEvent('Dashboard List: Create dashboard clicked', {});
setNewDashboardState({
...newDashboardState,
loading: true,
});
const response = await createDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: response.data.id,
}),
);
} catch (error) {
showErrorModal(error as APIError);
setNewDashboardState({
...newDashboardState,
error: true,
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
});
}
}, [newDashboardState, safeNavigate, showErrorModal, t]);
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: response.data.id,
}),
event,
);
} catch (error) {
showErrorModal(error as APIError);
setNewDashboardState({
...newDashboardState,
error: true,
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
});
}
},
[newDashboardState, safeNavigate, showErrorModal, t],
);
const onModalHandler = (uploadedGrafana: boolean): void => {
logEvent('Dashboard List: Import JSON clicked', {});
@@ -639,8 +643,8 @@ function DashboardsList(): JSX.Element {
label: (
<div
className="create-dashboard-menu-item"
onClick={(): void => {
onNewDashboardHandler();
onClick={(event: React.MouseEvent): void => {
onNewDashboardHandler(event);
}}
>
<LayoutGrid size={14} /> Create dashboard
@@ -927,7 +931,9 @@ function DashboardsList(): JSX.Element {
<DashboardTemplatesModal
showNewDashboardTemplatesModal={showNewDashboardTemplatesModal}
onCreateNewDashboard={onNewDashboardHandler}
onCreateNewDashboard={(event: React.MouseEvent): Promise<void> =>
onNewDashboardHandler(event)
}
onCancel={(): void => {
setShowNewDashboardTemplatesModal(false);
}}

View File

@@ -235,6 +235,7 @@ function Application(): JSX.Element {
timestamp: number,
apmToTraceQuery: Query,
isViewLogsClicked?: boolean,
event?: React.MouseEvent,
): (() => void) => (): void => {
const endTime = secondsToMilliseconds(timestamp);
const startTime = secondsToMilliseconds(timestamp - stepInterval);
@@ -259,7 +260,7 @@ function Application(): JSX.Element {
queryString,
);
history.push(newPath);
history.push(newPath, event);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[stepInterval],
@@ -319,14 +320,16 @@ function Application(): JSX.Element {
type="default"
size="small"
id="Rate_button"
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
onClick={(event: React.MouseEvent): void =>
onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})(event)
}
>
View Traces
</Button>
@@ -370,15 +373,12 @@ function Application(): JSX.Element {
<ColErrorContainer>
<GraphControlsPanel
id="Error_button"
onViewLogsClick={onErrorTrackHandler(
selectedTimeStamp,
logErrorQuery,
true,
)}
onViewTracesClick={onErrorTrackHandler(
selectedTimeStamp,
errorTrackQuery,
)}
onViewLogsClick={(event: React.MouseEvent): void =>
onErrorTrackHandler(selectedTimeStamp, logErrorQuery, true, event)()
}
onViewTracesClick={(event: React.MouseEvent): void =>
onErrorTrackHandler(selectedTimeStamp, errorTrackQuery, false, event)()
}
/>
<TopLevelOperation

View File

@@ -6,9 +6,9 @@ import { Binoculars, DraftingCompass, ScrollText } from 'lucide-react';
interface GraphControlsPanelProps {
id: string;
onViewLogsClick?: () => void;
onViewTracesClick: () => void;
onViewAPIMonitoringClick?: () => void;
onViewLogsClick?: (event: React.MouseEvent) => void;
onViewTracesClick: (event: React.MouseEvent) => void;
onViewAPIMonitoringClick?: (event: React.MouseEvent) => void;
}
function GraphControlsPanel({
@@ -23,7 +23,7 @@ function GraphControlsPanel({
type="link"
icon={<DraftingCompass size={14} />}
size="small"
onClick={onViewTracesClick}
onClick={(event: React.MouseEvent): void => onViewTracesClick(event)}
style={{ color: Color.BG_VANILLA_100 }}
>
View traces
@@ -33,7 +33,7 @@ function GraphControlsPanel({
type="link"
icon={<ScrollText size={14} />}
size="small"
onClick={onViewLogsClick}
onClick={(event: React.MouseEvent): void => onViewLogsClick(event)}
style={{ color: Color.BG_VANILLA_100 }}
>
View logs
@@ -44,7 +44,9 @@ function GraphControlsPanel({
type="link"
icon={<Binoculars size={14} />}
size="small"
onClick={onViewAPIMonitoringClick}
onClick={(event: React.MouseEvent): void =>
onViewAPIMonitoringClick(event)
}
style={{ color: Color.BG_VANILLA_100 }}
>
View External APIs

View File

@@ -103,23 +103,27 @@ function ServiceOverview({
<>
<GraphControlsPanel
id="Service_button"
onViewLogsClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery: apmToLogQuery,
isViewLogsClicked: true,
stepInterval,
safeNavigate,
})}
onViewTracesClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
onViewLogsClick={(event: React.MouseEvent): void =>
onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery: apmToLogQuery,
isViewLogsClicked: true,
stepInterval,
safeNavigate,
})(event)
}
onViewTracesClick={(event: React.MouseEvent): void =>
onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})(event)
}
/>
<Card data-testid="service_latency">
<GraphContainer>

View File

@@ -42,7 +42,7 @@ interface OnViewTracePopupClickProps {
apmToTraceQuery: Query;
isViewLogsClicked?: boolean;
stepInterval?: number;
safeNavigate: (url: string) => void;
safeNavigate: (url: string, event?: React.MouseEvent) => void;
}
interface OnViewAPIMonitoringPopupClickProps {
@@ -52,7 +52,7 @@ interface OnViewAPIMonitoringPopupClickProps {
domainName: string;
isError: boolean;
safeNavigate: (url: string) => void;
safeNavigate: (url: string, event?: React.MouseEvent) => void;
}
export function generateExplorerPath(
@@ -93,8 +93,8 @@ export function onViewTracePopupClick({
isViewLogsClicked,
stepInterval,
safeNavigate,
}: OnViewTracePopupClickProps): VoidFunction {
return (): void => {
}: OnViewTracePopupClickProps): (event: React.MouseEvent) => void {
return (event: React.MouseEvent): void => {
const endTime = secondsToMilliseconds(timestamp);
const startTime = secondsToMilliseconds(timestamp - (stepInterval || 60));
@@ -118,7 +118,11 @@ export function onViewTracePopupClick({
queryString,
);
safeNavigate(newPath);
if (event) {
safeNavigate(newPath, event);
} else {
safeNavigate(newPath);
}
};
}
@@ -149,8 +153,8 @@ export function onViewAPIMonitoringPopupClick({
isError,
stepInterval,
safeNavigate,
}: OnViewAPIMonitoringPopupClickProps): VoidFunction {
return (): void => {
}: OnViewAPIMonitoringPopupClickProps): (event: React.MouseEvent) => void {
return (event: React.MouseEvent): void => {
const endTime = timestamp + (stepInterval || 60);
const startTime = timestamp - (stepInterval || 60);
const filters = {
@@ -190,7 +194,11 @@ export function onViewAPIMonitoringPopupClick({
filters,
);
safeNavigate(newPath);
if (event) {
safeNavigate(newPath, event);
} else {
safeNavigate(newPath);
}
};
}

View File

@@ -23,11 +23,15 @@ const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/useSafeNavigate', () => {
const actual = jest.requireActual('hooks/useSafeNavigate');
return {
...actual,
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
};
});
const mockSetQuery = jest.fn();
const mockUrlQuery = {

View File

@@ -38,7 +38,7 @@ export default function NoLogs({
} else {
link = ROUTES.GET_STARTED_LOGS_MANAGEMENT;
}
history.push(link);
history.push(link, e);
} else if (dataSource === 'traces') {
window.open(DOCLINKS.TRACES_EXPLORER_EMPTY_STATE, '_blank');
} else if (dataSource === DataSource.METRICS) {
@@ -59,7 +59,12 @@ export default function NoLogs({
</span>
</Typography>
<Typography.Link className="send-logs-link" onClick={handleLinkClick}>
<Typography.Link
className="send-logs-link"
onClick={(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void =>
handleLinkClick(e)
}
>
Sending {dataSource} to SigNoz <ArrowUpRight size={16} />
</Typography.Link>
</div>

View File

@@ -1,7 +1,14 @@
import { cloneDeep, isEqual } from 'lodash-es';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import {
Location,
NavigateFunction,
useLocation,
useNavigate,
} from 'react-router-dom-v5-compat';
import { isEventObject } from 'utils/isEventObject';
// state uses 'any' because react-router's NavigateOptions interface uses it
interface NavigateOptions {
replace?: boolean;
state?: any;
@@ -83,6 +90,74 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
return newKeys.length > 0;
};
// Helper function to extract options from arguments
const extractOptions = (
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
): NavigateOptions => {
const isEvent = isEventObject(optionsOrEvent);
const actualOptions = isEvent ? options : (optionsOrEvent as NavigateOptions);
const shouldOpenInNewTab =
isEvent && (optionsOrEvent.metaKey || optionsOrEvent.ctrlKey);
return {
...actualOptions,
newTab: shouldOpenInNewTab || actualOptions?.newTab,
};
};
// Helper function to create target URL
const createTargetUrl = (
to: string | SafeNavigateParams,
location: Location,
): URL => {
if (typeof to === 'string') {
return new URL(to, window.location.origin);
}
return new URL(
`${to.pathname || location.pathname}${to.search || ''}`,
window.location.origin,
);
};
// Helper function to handle new tab navigation
const handleNewTabNavigation = (
to: string | SafeNavigateParams,
location: Location,
): void => {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
};
// Helper function to perform navigation
const performNavigation = (
to: string | SafeNavigateParams,
navigationOptions: NavigateOptions,
navigate: NavigateFunction,
location: Location,
): void => {
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,
@@ -90,6 +165,11 @@ export const useSafeNavigate = (
): {
safeNavigate: (
to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
) => void;
} => {
@@ -97,30 +177,25 @@ export const useSafeNavigate = (
const location = useLocation();
const safeNavigate = useCallback(
(to: string | SafeNavigateParams, options?: NavigateOptions) => {
(
to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
) => {
const finalOptions = extractOptions(optionsOrEvent, options);
const currentUrl = new URL(
`${location.pathname}${location.search}`,
window.location.origin,
);
const targetUrl = createTargetUrl(to, location);
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,
);
}
// If newTab is true, open in new tab and return early
if (options?.newTab) {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
// Handle new tab navigation
if (finalOptions?.newTab) {
handleNewTabNavigation(to, location);
return;
}
@@ -132,23 +207,13 @@ export const useSafeNavigate = (
}
const navigationOptions = {
...options,
replace: isDefaultParamsNavigation || options?.replace,
...finalOptions,
replace: isDefaultParamsNavigation || finalOptions?.replace,
};
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
performNavigation(to, navigationOptions, navigate, location);
},
[navigate, location.pathname, location.search, preventSameUrlNavigation],
[navigate, location, preventSameUrlNavigation],
);
return { safeNavigate };

View File

@@ -0,0 +1,634 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { LocationDescriptorObject } from 'history';
import history from '../history';
jest.mock('history', () => {
const actualHistory = jest.requireActual('history');
const mockPush = jest.fn();
const mockReplace = jest.fn();
const mockGo = jest.fn();
const mockGoBack = jest.fn();
const mockGoForward = jest.fn();
const mockBlock = jest.fn(() => jest.fn());
const mockListen = jest.fn(() => jest.fn());
const mockCreateHref = jest.fn((location) => {
if (typeof location === 'string') return location;
return actualHistory.createPath(location);
});
const baseHistory = {
length: 2,
action: 'PUSH' as const,
location: {
pathname: '/current-path',
search: '?existing=param',
hash: '#section',
state: { existing: 'state' },
key: 'test-key',
},
push: mockPush,
replace: mockReplace,
go: mockGo,
goBack: mockGoBack,
goForward: mockGoForward,
block: mockBlock,
listen: mockListen,
createHref: mockCreateHref,
};
return {
...actualHistory,
createBrowserHistory: jest.fn(() => baseHistory),
};
});
interface TestUser {
id: number;
name: string;
email: string;
}
interface TestState {
from?: string;
user?: TestUser;
timestamp?: number;
}
describe('Enhanced History Methods', () => {
let mockWindowOpen: jest.SpyInstance;
let originalPush: jest.MockedFunction<typeof history.push>;
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen = jest.spyOn(window, 'open').mockImplementation(() => null);
originalPush = history.originalPush as jest.MockedFunction<
typeof history.push
>;
});
afterEach(() => {
mockWindowOpen.mockRestore();
});
describe('history.push() - String Path Navigation', () => {
it('should handle simple string path navigation', () => {
history.push('/dashboard');
expect(originalPush).toHaveBeenCalledTimes(1);
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle string path with state', () => {
const testState: TestState = { from: 'home', timestamp: Date.now() };
history.push('/dashboard', testState);
expect(originalPush).toHaveBeenCalledWith('/dashboard', testState);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle string path with query parameters', () => {
history.push('/logs?filter=error&timeRange=24h');
expect(originalPush).toHaveBeenCalledWith(
'/logs?filter=error&timeRange=24h',
undefined,
);
});
it('should handle string path with hash', () => {
history.push('/docs#installation');
expect(originalPush).toHaveBeenCalledWith('/docs#installation', undefined);
});
it('should handle complex URL with all components', () => {
const complexUrl = '/api/traces?service=backend&status=error#span-details';
const state: TestState = {
user: { id: 1, name: 'John', email: 'john@test.com' },
};
history.push(complexUrl, state);
expect(originalPush).toHaveBeenCalledWith(complexUrl, state);
});
});
describe('history.push() - Location Object Navigation', () => {
it('should handle location object with only pathname', () => {
const location: LocationDescriptorObject = {
pathname: '/metrics',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle location object with pathname and search', () => {
const location: LocationDescriptorObject = {
pathname: '/logs',
search: '?filter=error&severity=high',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle location object with all properties', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/traces',
search: '?service=api-server&duration=slow',
hash: '#span-123',
state: { from: 'dashboard', timestamp: Date.now() },
key: 'unique-key',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle location object with state passed separately', () => {
const location: LocationDescriptorObject = {
pathname: '/alerts',
search: '?type=critical',
};
const separateState: TestState = { from: 'monitoring' };
history.push(location, separateState);
expect(originalPush).toHaveBeenCalledWith(location, separateState);
});
it('should handle empty location object', () => {
const location: LocationDescriptorObject = {};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should preserve current pathname when updating search', () => {
const location: LocationDescriptorObject = {
pathname: history.location.pathname,
search: '?newParam=value',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
expect(originalPush.mock.calls[0][0]).toHaveProperty(
'pathname',
'/current-path',
);
});
});
describe('history.push() - Event Handling (Cmd/Ctrl+Click)', () => {
describe('MouseEvent handling', () => {
it('should open in new tab when metaKey is pressed with string path', () => {
const event = new MouseEvent('click', { metaKey: true });
history.push('/dashboard', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when ctrlKey is pressed with string path', () => {
const event = new MouseEvent('click', { ctrlKey: true });
history.push('/metrics', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/metrics', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when both metaKey and ctrlKey are pressed', () => {
const event = new MouseEvent('click', { metaKey: true, ctrlKey: true });
history.push('/logs', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle normal click without meta/ctrl keys', () => {
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
const state: TestState = { from: 'nav' };
history.push('/alerts', event, state);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/alerts', state);
});
});
describe('KeyboardEvent handling', () => {
it('should open in new tab when metaKey is pressed with keyboard event', () => {
const event = new KeyboardEvent('keydown', { metaKey: true });
history.push('/traces', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/traces', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when ctrlKey is pressed with keyboard event', () => {
const event = new KeyboardEvent('keydown', { ctrlKey: true });
history.push('/pipelines', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/pipelines', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
});
describe('React SyntheticEvent handling', () => {
it('should handle React MouseEvent with metaKey', () => {
const nativeEvent = new MouseEvent('click', { metaKey: true });
const reactEvent = {
nativeEvent,
metaKey: true,
ctrlKey: false,
} as React.MouseEvent;
history.push('/dashboard', reactEvent);
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle React MouseEvent with ctrlKey', () => {
const nativeEvent = new MouseEvent('click', { ctrlKey: true });
const reactEvent = {
nativeEvent,
metaKey: false,
ctrlKey: true,
} as React.MouseEvent;
history.push('/logs', reactEvent);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle React MouseEvent without modifier keys', () => {
const nativeEvent = new MouseEvent('click');
const reactEvent = {
nativeEvent,
metaKey: false,
ctrlKey: false,
} as React.MouseEvent;
history.push('/metrics', reactEvent);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/metrics', undefined);
});
});
describe('Location Object with Event handling', () => {
it('should open location object URL in new tab with metaKey', () => {
const location: LocationDescriptorObject = {
pathname: '/traces',
search: '?service=backend',
hash: '#span-details',
};
const event = new MouseEvent('click', { metaKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/traces?service=backend#span-details',
'_blank',
);
expect(originalPush).not.toHaveBeenCalled();
});
it('should open location object URL in new tab with ctrlKey', () => {
const location: LocationDescriptorObject = {
pathname: '/alerts',
search: '?status=firing',
};
const event = new MouseEvent('click', { ctrlKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/alerts?status=firing',
'_blank',
);
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle location object with normal navigation', () => {
const location: LocationDescriptorObject = {
pathname: '/dashboard',
search: '?tab=overview',
};
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
const state: TestState = { from: 'home' };
history.push(location, event, state);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith(location, state);
});
it('should handle complex location object with all properties in new tab', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/api/v1/traces',
search: '?limit=100&offset=0&service=auth',
hash: '#result-section',
state: { from: 'explorer' }, // State is ignored in new tab
};
const event = new MouseEvent('click', { metaKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/api/v1/traces?limit=100&offset=0&service=auth#result-section',
'_blank',
);
});
});
});
describe('history.push() - Edge Cases and Error Scenarios', () => {
it('should handle undefined as second parameter', () => {
history.push('/dashboard', undefined);
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
});
it('should handle null as second parameter', () => {
history.push('/logs', null);
expect(originalPush).toHaveBeenCalledWith('/logs', null);
});
it('should handle empty string path', () => {
history.push('');
expect(originalPush).toHaveBeenCalledWith('', undefined);
});
it('should handle root path', () => {
history.push('/');
expect(originalPush).toHaveBeenCalledWith('/', undefined);
});
it('should handle relative paths', () => {
history.push('../parent');
expect(originalPush).toHaveBeenCalledWith('../parent', undefined);
});
it('should handle special characters in path', () => {
const specialPath = '/path/with spaces/and#special?chars=@$%';
history.push(specialPath);
expect(originalPush).toHaveBeenCalledWith(specialPath, undefined);
});
it('should handle location object with undefined values', () => {
const location: LocationDescriptorObject = {
pathname: undefined,
search: undefined,
hash: undefined,
state: undefined,
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle very long URLs', () => {
const longParam = 'x'.repeat(1000);
const longUrl = `/path?param=${longParam}`;
history.push(longUrl);
expect(originalPush).toHaveBeenCalledWith(longUrl, undefined);
});
it('should handle object that looks like an event but isnt', () => {
const fakeEvent = {
metaKey: 'not-a-boolean', // Invalid type but still truthy values
ctrlKey: 'not-a-boolean',
};
history.push('/dashboard', fakeEvent as any);
// The implementation checks if metaKey/ctrlKey exist and are truthy values
// Since these are truthy strings, it will be treated as an event
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle event-like object with falsy values', () => {
const fakeEventFalsy = {
metaKey: false,
ctrlKey: false,
};
history.push('/dashboard', fakeEventFalsy as any);
// The object is detected as an event (has metaKey/ctrlKey properties)
// but since both are false, it doesn't open in new tab
// When treated as event, third param (state) is undefined
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
});
it('should handle partial event-like objects', () => {
const partialEvent = { metaKey: true }; // Has metaKey but not instanceof MouseEvent
history.push('/logs', partialEvent as any);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle object without event properties as state', () => {
const regularObject = {
someData: 'value',
anotherProp: 123,
// No metaKey or ctrlKey properties
};
history.push('/page', regularObject);
// Object without metaKey/ctrlKey is treated as state, not event
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/page', regularObject);
});
});
describe('history.push() - State Handling', () => {
it('should pass state with string path', () => {
const complexState: TestState = {
from: 'dashboard',
user: { id: 123, name: 'Test User', email: 'test@example.com' },
timestamp: Date.now(),
};
history.push('/profile', complexState);
expect(originalPush).toHaveBeenCalledWith('/profile', complexState);
});
it('should handle state with location object', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/settings',
state: { from: 'profile' },
};
const additionalState: TestState = { timestamp: Date.now() };
history.push(location, additionalState);
expect(originalPush).toHaveBeenCalledWith(location, additionalState);
});
it('should handle state with event and string path', () => {
const event = new MouseEvent('click', { metaKey: false });
const state: TestState = { from: 'nav' };
history.push('/dashboard', event, state);
expect(originalPush).toHaveBeenCalledWith('/dashboard', state);
});
it('should handle state with event and location object', () => {
const location: LocationDescriptorObject = {
pathname: '/logs',
};
const event = new MouseEvent('click', { metaKey: false });
const state: TestState = { from: 'sidebar' };
history.push(location, event, state);
expect(originalPush).toHaveBeenCalledWith(location, state);
});
});
describe('Other History Methods', () => {
it('should have working replace method', () => {
// replace should exist and be callable
expect(history.replace).toBeDefined();
expect(typeof history.replace).toBe('function');
history.replace('/new-path');
const mockReplace = (history as any).replace as jest.MockedFunction<
typeof history.replace
>;
expect(mockReplace).toHaveBeenCalledWith('/new-path');
});
it('should have working go method', () => {
expect(history.go).toBeDefined();
expect(typeof history.go).toBe('function');
history.go(-2);
const mockGo = (history as any).go as jest.MockedFunction<typeof history.go>;
expect(mockGo).toHaveBeenCalledWith(-2);
});
it('should have working goBack method', () => {
expect(history.goBack).toBeDefined();
expect(typeof history.goBack).toBe('function');
history.goBack();
const mockGoBack = (history as any).goBack as jest.MockedFunction<
typeof history.goBack
>;
expect(mockGoBack).toHaveBeenCalled();
});
it('should have working goForward method', () => {
expect(history.goForward).toBeDefined();
expect(typeof history.goForward).toBe('function');
history.goForward();
const mockGoForward = (history as any).goForward as jest.MockedFunction<
typeof history.goForward
>;
expect(mockGoForward).toHaveBeenCalled();
});
it('should have working block method', () => {
expect(history.block).toBeDefined();
expect(typeof history.block).toBe('function');
const unblock = history.block('Are you sure?');
expect(typeof unblock).toBe('function');
const mockBlock = (history as any).block as jest.MockedFunction<
typeof history.block
>;
expect(mockBlock).toHaveBeenCalledWith('Are you sure?');
});
it('should have working listen method', () => {
expect(history.listen).toBeDefined();
expect(typeof history.listen).toBe('function');
const listener = jest.fn();
const unlisten = history.listen(listener);
expect(typeof unlisten).toBe('function');
const mockListen = (history as any).listen as jest.MockedFunction<
typeof history.listen
>;
expect(mockListen).toHaveBeenCalledWith(listener);
});
it('should have working createHref method', () => {
expect(history.createHref).toBeDefined();
expect(typeof history.createHref).toBe('function');
const location: LocationDescriptorObject = {
pathname: '/test',
search: '?query=value',
};
const href = history.createHref(location);
expect(href).toBe('/test?query=value');
});
it('should have accessible location property', () => {
expect(history.location).toBeDefined();
expect(history.location.pathname).toBe('/current-path');
expect(history.location.search).toBe('?existing=param');
expect(history.location.hash).toBe('#section');
expect(history.location.state).toEqual({ existing: 'state' });
});
it('should have accessible length property', () => {
expect(history.length).toBeDefined();
expect(history.length).toBe(2);
});
it('should have accessible action property', () => {
expect(history.action).toBeDefined();
expect(history.action).toBe('PUSH');
});
});
});

View File

@@ -1,3 +1,58 @@
import { createBrowserHistory } from 'history';
import {
createBrowserHistory,
createPath,
History,
LocationDescriptorObject,
LocationState,
} from 'history';
import { isEventObject } from 'utils/isEventObject';
export default createBrowserHistory();
// Create the base history instance
const baseHistory = createBrowserHistory();
type PathOrLocation = string | LocationDescriptorObject<LocationState>;
// Extend the History interface to include enhanced push method
interface EnhancedHistory extends History {
push: {
(path: PathOrLocation, state?: any): void;
(
path: PathOrLocation,
event?: React.MouseEvent | MouseEvent | KeyboardEvent,
state?: any,
): void;
};
originalPush: History['push'];
}
// Create enhanced history with overridden push method
const history = baseHistory as EnhancedHistory;
// Store the original push method
history.originalPush = baseHistory.push;
// Override push to handle meta/ctrl key events and location objects
history.push = function (
path: PathOrLocation,
eventOrState?: React.MouseEvent | MouseEvent | KeyboardEvent | any,
state?: any,
): void {
// Check if second argument is an event object
const isEvent = isEventObject(eventOrState);
// If it's an event and meta/ctrl key is pressed, open in new tab
if (isEvent && (eventOrState.metaKey || eventOrState.ctrlKey)) {
// Convert location object to URL string using createPath from history
const url = typeof path === 'string' ? path : createPath(path);
window.open(url, '_blank');
return;
}
// Otherwise, use normal navigation
// The original push method already handles both strings and location objects
// If eventOrState is not an event, treat it as state
const actualState = isEvent ? state : eventOrState;
history.originalPush(path, actualState);
};
export default history;

View File

@@ -0,0 +1,21 @@
// Event types that have metaKey/ctrlKey properties
type EventWithModifiers =
| MouseEvent
| KeyboardEvent
| React.MouseEvent<any, MouseEvent>
| React.KeyboardEvent<any>;
// Helper function to determine if an argument is an event - Also used in utils/history.ts
export const isEventObject = (arg: unknown): arg is EventWithModifiers => {
if (!arg || typeof arg !== 'object') return false;
return (
arg instanceof MouseEvent ||
arg instanceof KeyboardEvent ||
('nativeEvent' in arg &&
(arg.nativeEvent instanceof MouseEvent ||
arg.nativeEvent instanceof KeyboardEvent)) ||
'metaKey' in arg ||
'ctrlKey' in arg
);
};