Compare commits

..

10 Commits

Author SHA1 Message Date
ahmadshaheer
daa73b9d19 fix: show hover style only for interactive fields in the span details drawer 2025-10-27 10:30:49 +04:30
ahmadshaheer
fb671872a9 Merge branch 'main' of https://github.com/SigNoz/signoz into feat/add-actionables-to-span-fields 2025-10-23 16:03:39 +04:30
SagarRajput-7
d05d394f57 chore: update slow running test in tracesExplorer test (#9396) 2025-10-23 11:02:02 +05:30
Vikrant Gupta
b4e5085a5a fix(sqlschema): postgres sqlschema get table operation (#9395)
* fix(sqlschema): postgres sqlschema get table operation

* fix(sqlschema): postgres sqlschema get table operation
2025-10-22 19:02:15 +05:30
Abhi kumar
88f7502a15 fix: prevent memory leaks from uncleaned uPlot event listeners (#9320) 2025-10-22 07:19:11 +00:00
primus-bot[bot]
b0442761ac chore(release): bump to v0.98.0 (#9393)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-10-22 12:09:31 +05:30
ahmadshaheer
6814c236b2 feat(span-details-drawer): enhance action button styles and visibility 2025-10-21 10:04:09 +04:30
ahmadshaheer
f1371f965e feat(span-details-drawer): add action buttons for status message field 2025-10-08 20:37:18 +04:30
ahmadshaheer
9fd4d3eeb8 test(span-details-drawer): add user flow tests for span field actions 2025-10-08 19:54:10 +04:30
ahmadshaheer
e27fd996c3 feat(span-details-drawer): add action buttons for span fields 2025-10-08 19:27:05 +04:30
40 changed files with 1203 additions and 1098 deletions

1
.gitignore vendored
View File

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

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.97.0 image: signoz/signoz:v0.98.0
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml
ports: ports:

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.97.0 image: signoz/signoz:v0.98.0
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml
ports: ports:

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.97.0} image: signoz/signoz:${VERSION:-v0.98.0}
container_name: signoz container_name: signoz
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.97.0} image: signoz/signoz:${VERSION:-v0.98.0}
container_name: signoz container_name: signoz
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml

View File

@@ -2,6 +2,7 @@ package postgressqlschema
import ( import (
"context" "context"
"database/sql"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema" "github.com/SigNoz/signoz/pkg/sqlschema"
@@ -47,50 +48,45 @@ func (provider *provider) Operator() sqlschema.SQLOperator {
} }
func (provider *provider) GetTable(ctx context.Context, tableName sqlschema.TableName) (*sqlschema.Table, []*sqlschema.UniqueConstraint, error) { func (provider *provider) GetTable(ctx context.Context, tableName sqlschema.TableName) (*sqlschema.Table, []*sqlschema.UniqueConstraint, error) {
rows, err := provider. columns := []struct {
ColumnName string `bun:"column_name"`
Nullable bool `bun:"nullable"`
SQLDataType string `bun:"udt_name"`
DefaultVal *string `bun:"column_default"`
}{}
err := provider.
sqlstore. sqlstore.
BunDB(). BunDB().
QueryContext(ctx, ` NewRaw(`
SELECT SELECT
c.column_name, c.column_name,
c.is_nullable = 'YES', c.is_nullable = 'YES' as nullable,
c.udt_name, c.udt_name,
c.column_default c.column_default
FROM FROM
information_schema.columns AS c information_schema.columns AS c
WHERE WHERE
c.table_name = ?`, string(tableName)) c.table_name = ?`, string(tableName)).
Scan(ctx, &columns)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if len(columns) == 0 {
return nil, nil, sql.ErrNoRows
}
defer func() { sqlschemaColumns := make([]*sqlschema.Column, 0)
if err := rows.Close(); err != nil { for _, column := range columns {
provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err)
}
}()
columns := make([]*sqlschema.Column, 0)
for rows.Next() {
var (
name string
sqlDataType string
nullable bool
defaultVal *string
)
if err := rows.Scan(&name, &nullable, &sqlDataType, &defaultVal); err != nil {
return nil, nil, err
}
columnDefault := "" columnDefault := ""
if defaultVal != nil { if column.DefaultVal != nil {
columnDefault = *defaultVal columnDefault = *column.DefaultVal
} }
columns = append(columns, &sqlschema.Column{ sqlschemaColumns = append(sqlschemaColumns, &sqlschema.Column{
Name: sqlschema.ColumnName(name), Name: sqlschema.ColumnName(column.ColumnName),
Nullable: nullable, Nullable: column.Nullable,
DataType: provider.fmter.DataTypeOf(sqlDataType), DataType: provider.fmter.DataTypeOf(column.SQLDataType),
Default: columnDefault, Default: columnDefault,
}) })
} }
@@ -208,7 +204,7 @@ WHERE
return &sqlschema.Table{ return &sqlschema.Table{
Name: tableName, Name: tableName,
Columns: columns, Columns: sqlschemaColumns,
PrimaryKeyConstraint: primaryKeyConstraint, PrimaryKeyConstraint: primaryKeyConstraint,
ForeignKeyConstraints: foreignKeyConstraints, ForeignKeyConstraints: foreignKeyConstraints,
}, uniqueConstraints, nil }, uniqueConstraints, nil

View File

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

View File

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

View File

@@ -18,11 +18,6 @@ import UPlot from 'uplot';
import { dataMatch, optionsUpdateState } from './utils'; import { dataMatch, optionsUpdateState } from './utils';
// Extended uPlot interface with custom properties
interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void;
}
export interface UplotProps { export interface UplotProps {
options: uPlot.Options; options: uPlot.Options;
data: uPlot.AlignedData; data: uPlot.AlignedData;
@@ -71,12 +66,6 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
const destroy = useCallback((chart: uPlot | null) => { const destroy = useCallback((chart: uPlot | null) => {
if (chart) { if (chart) {
// Clean up legend scroll event listener
const extendedChart = chart as ExtendedUPlot;
if (extendedChart._legendScrollCleanup) {
extendedChart._legendScrollCleanup();
}
onDeleteRef.current?.(chart); onDeleteRef.current?.(chart);
chart.destroy(); chart.destroy();
chartRef.current = null; chartRef.current = null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Button, Popover, Spin, Tooltip } from 'antd'; import { Button, Popover, Spin, Tooltip } from 'antd';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon'; import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import cx from 'classnames';
import { OPERATORS } from 'constants/antlrQueryConstants'; import { OPERATORS } from 'constants/antlrQueryConstants';
import { useTraceActions } from 'hooks/trace/useTraceActions'; import { useTraceActions } from 'hooks/trace/useTraceActions';
import { import {
@@ -124,7 +125,7 @@ export default function AttributeActions({
); );
return ( return (
<div className="action-btn"> <div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}> <Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button <Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`} className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}

View File

@@ -17,7 +17,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: 12px; padding-block: 12px;
.item { .item {
display: flex; display: flex;
@@ -25,8 +25,10 @@
gap: 8px; gap: 8px;
justify-content: flex-start; justify-content: flex-start;
position: relative; position: relative;
padding: 2px 12px;
&:hover { &:hover {
background-color: var(--bg-slate-500);
.action-btn { .action-btn {
display: flex; display: flex;
} }
@@ -81,22 +83,23 @@
.action-btn { .action-btn {
display: none; display: none;
&--is-open {
display: flex;
}
position: absolute; position: absolute;
right: 8px; right: 8px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
gap: 4px; gap: 4px;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
padding: 2px;
.filter-btn { .filter-btn {
display: flex; display: flex;
align-items: center; align-items: center;
border: none; border-color: var(--bg-slate-400);
box-shadow: none; box-shadow: none;
border-radius: 2px; border-radius: 2px;
background: var(--bg-slate-400); background: var(--bg-slate-500);
padding: 4px; padding: 4px;
gap: 3px; gap: 3px;
height: 24px; height: 24px;
@@ -129,7 +132,7 @@
gap: 8px; gap: 8px;
&:hover { &:hover {
background-color: var(--bg-slate-400); background-color: var(--bg-slate-400) !important;
} }
} }
@@ -142,6 +145,7 @@
.ant-popover-inner { .ant-popover-inner {
padding: 8px; padding: 8px;
min-width: 160px; min-width: 160px;
background: var(--bg-slate-500);
} }
} }
@@ -149,6 +153,9 @@
.attributes-corner { .attributes-corner {
.attributes-container { .attributes-container {
.item { .item {
&:hover {
background-color: var(--bg-vanilla-300);
}
.item-key { .item-key {
color: var(--bg-ink-100); color: var(--bg-ink-100);
} }
@@ -163,8 +170,6 @@
} }
.action-btn { .action-btn {
background: rgba(255, 255, 255, 0.9);
.filter-btn { .filter-btn {
background: var(--bg-vanilla-200); background: var(--bg-vanilla-200);

View File

@@ -48,12 +48,52 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
padding: 10px 12px; padding-block: 12px;
.item { .item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
position: relative;
padding: 2px 12px;
&--interactive {
&:hover {
background-color: var(--bg-slate-500);
.action-btn {
display: flex;
}
}
}
.action-btn {
display: none;
&--is-open {
display: flex;
}
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
gap: 4px;
.filter-btn {
display: flex;
align-items: center;
border-color: var(--bg-slate-400);
box-shadow: none;
border-radius: 2px;
background: var(--bg-slate-500);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--bg-slate-300);
}
}
}
.attribute-key { .attribute-key {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -238,6 +278,18 @@
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
.action-btn {
background: rgba(255, 255, 255, 0.9);
.filter-btn {
background: var(--bg-vanilla-200);
&:hover {
background: var(--bg-vanilla-100);
}
}
}
.value-wrapper { .value-wrapper {
border: 1px solid var(--bg-vanilla-300); border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300); background: var(--bg-vanilla-300);

View File

@@ -16,6 +16,7 @@ import Attributes from './Attributes/Attributes';
import { RelatedSignalsViews } from './constants'; import { RelatedSignalsViews } from './constants';
import Events from './Events/Events'; import Events from './Events/Events';
import LinkedSpans from './LinkedSpans/LinkedSpans'; import LinkedSpans from './LinkedSpans/LinkedSpans';
import SpanFieldActions from './SpanFieldActions/SpanFieldActions';
import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals'; import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals';
interface ISpanDetailsDrawerProps { interface ISpanDetailsDrawerProps {
@@ -141,7 +142,7 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan && !isSpanDetailsDocked && ( {selectedSpan && !isSpanDetailsDocked && (
<> <>
<section className="description"> <section className="description">
<div className="item"> <div className="item item--interactive">
<Typography.Text className="attribute-key">span name</Typography.Text> <Typography.Text className="attribute-key">span name</Typography.Text>
<Tooltip title={selectedSpan.name}> <Tooltip title={selectedSpan.name}>
<div className="value-wrapper"> <div className="value-wrapper">
@@ -150,14 +151,22 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Typography.Text> </Typography.Text>
</div> </div>
</Tooltip> </Tooltip>
<SpanFieldActions
fieldDisplayName="span name"
fieldValue={selectedSpan.name}
/>
</div> </div>
<div className="item"> <div className="item item--interactive">
<Typography.Text className="attribute-key">span id</Typography.Text> <Typography.Text className="attribute-key">span id</Typography.Text>
<div className="value-wrapper"> <div className="value-wrapper">
<Typography.Text className="attribute-value"> <Typography.Text className="attribute-value">
{selectedSpan.spanId} {selectedSpan.spanId}
</Typography.Text> </Typography.Text>
</div> </div>
<SpanFieldActions
fieldDisplayName="span id"
fieldValue={selectedSpan.spanId}
/>
</div> </div>
<div className="item"> <div className="item">
<Typography.Text className="attribute-key">start time</Typography.Text> <Typography.Text className="attribute-key">start time</Typography.Text>
@@ -167,15 +176,19 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Typography.Text> </Typography.Text>
</div> </div>
</div> </div>
<div className="item"> <div className="item item--interactive">
<Typography.Text className="attribute-key">duration</Typography.Text> <Typography.Text className="attribute-key">duration</Typography.Text>
<div className="value-wrapper"> <div className="value-wrapper">
<Typography.Text className="attribute-value"> <Typography.Text className="attribute-value">
{getYAxisFormattedValue(`${selectedSpan.durationNano}`, 'ns')} {getYAxisFormattedValue(`${selectedSpan.durationNano}`, 'ns')}
</Typography.Text> </Typography.Text>
</div> </div>
<SpanFieldActions
fieldDisplayName="duration"
fieldValue={selectedSpan.durationNano.toString()}
/>
</div> </div>
<div className="item"> <div className="item item--interactive">
<Typography.Text className="attribute-key">service</Typography.Text> <Typography.Text className="attribute-key">service</Typography.Text>
<div className="service"> <div className="service">
<div className="dot" style={{ backgroundColor: color }} /> <div className="dot" style={{ backgroundColor: color }} />
@@ -187,16 +200,24 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<SpanFieldActions
fieldDisplayName="service"
fieldValue={selectedSpan.serviceName}
/>
</div> </div>
<div className="item"> <div className="item item--interactive">
<Typography.Text className="attribute-key">span kind</Typography.Text> <Typography.Text className="attribute-key">span kind</Typography.Text>
<div className="value-wrapper"> <div className="value-wrapper">
<Typography.Text className="attribute-value"> <Typography.Text className="attribute-value">
{selectedSpan.spanKind} {selectedSpan.spanKind}
</Typography.Text> </Typography.Text>
</div> </div>
<SpanFieldActions
fieldDisplayName="span kind"
fieldValue={selectedSpan.spanKind}
/>
</div> </div>
<div className="item"> <div className="item item--interactive">
<Typography.Text className="attribute-key"> <Typography.Text className="attribute-key">
status code string status code string
</Typography.Text> </Typography.Text>
@@ -205,10 +226,14 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.statusCodeString} {selectedSpan.statusCodeString}
</Typography.Text> </Typography.Text>
</div> </div>
<SpanFieldActions
fieldDisplayName="status code string"
fieldValue={selectedSpan.statusCodeString}
/>
</div> </div>
{selectedSpan.statusMessage && ( {selectedSpan.statusMessage && (
<div className="item"> <div className="item item--interactive">
<Typography.Text className="attribute-key"> <Typography.Text className="attribute-key">
status message status message
</Typography.Text> </Typography.Text>
@@ -217,6 +242,10 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.statusMessage} {selectedSpan.statusMessage}
</Typography.Text> </Typography.Text>
</div> </div>
<SpanFieldActions
fieldDisplayName="status message"
fieldValue={selectedSpan.statusMessage}
/>
</div> </div>
)} )}
<div className="item"> <div className="item">

View File

@@ -0,0 +1,157 @@
import { Button, Popover, Spin, Tooltip } from 'antd';
import cx from 'classnames';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { useTraceActions } from 'hooks/trace/useTraceActions';
import { ArrowDownToDot, ArrowUpFromDot, Copy, Ellipsis } from 'lucide-react';
import { useCallback, useState } from 'react';
// Field mapping from display names to actual span property keys
const SPAN_FIELD_MAPPING: Record<string, string> = {
'span name': 'name',
'span id': 'span_id',
duration: 'durationNano',
service: 'serviceName',
'span kind': 'spanKind',
'status code string': 'statusCodeString',
'status message': 'statusMessage',
};
interface SpanFieldActionsProps {
fieldDisplayName: string;
fieldValue: string;
}
export default function SpanFieldActions({
fieldDisplayName,
fieldValue,
}: SpanFieldActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
const { onAddToQuery, onCopyFieldName, onCopyFieldValue } = useTraceActions();
const mappedFieldKey =
SPAN_FIELD_MAPPING[fieldDisplayName] || fieldDisplayName;
const handleFilter = useCallback(
async (operator: string, isFilterIn: boolean): Promise<void> => {
const isLoading = isFilterIn ? isFilterInLoading : isFilterOutLoading;
const setLoading = isFilterIn ? setIsFilterInLoading : setIsFilterOutLoading;
if (!onAddToQuery || isLoading) return;
setLoading(true);
try {
await onAddToQuery(mappedFieldKey, fieldValue, operator);
} finally {
setLoading(false);
}
},
[
onAddToQuery,
mappedFieldKey,
fieldValue,
isFilterInLoading,
isFilterOutLoading,
],
);
const handleFilterIn = useCallback(() => handleFilter(OPERATORS['='], true), [
handleFilter,
]);
const handleFilterOut = useCallback(
() => handleFilter(OPERATORS['!='], false),
[handleFilter],
);
const handleCopy = useCallback(
(copyFn: ((value: string) => void) | undefined, value: string): void => {
if (copyFn) {
copyFn(value);
}
setIsOpen(false);
},
[],
);
const handleCopyFieldName = useCallback(
() => handleCopy(onCopyFieldName, mappedFieldKey),
[handleCopy, onCopyFieldName, mappedFieldKey],
);
const handleCopyFieldValue = useCallback(
() => handleCopy(onCopyFieldValue, fieldValue),
[fieldValue, handleCopy, onCopyFieldValue],
);
const moreActionsContent = (
<div className="attribute-actions-menu">
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</div>
);
return (
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter for value"
disabled={isFilterInLoading}
icon={
isFilterInLoading ? (
<Spin size="small" />
) : (
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterIn}
/>
</Tooltip>
<Tooltip title="Filter out value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter out value"
disabled={isFilterOutLoading}
icon={
isFilterOutLoading ? (
<Spin size="small" />
) : (
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterOut}
/>
</Tooltip>
<Popover
open={isOpen}
onOpenChange={setIsOpen}
arrow={false}
content={moreActionsContent}
rootClassName="attribute-actions-content"
trigger="hover"
placement="bottomLeft"
>
<Button
icon={<Ellipsis size={14} />}
className="filter-btn periscope-btn"
/>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,659 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { AppProvider } from 'providers/App/App';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { MemoryRouter, Route } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
// Mock external dependencies following the same pattern as AttributeActions tests
const mockRedirectWithQueryBuilderData = jest.fn();
const mockNotifications = {
success: jest.fn(),
error: jest.fn(),
};
const mockSetCopy = jest.fn();
const mockQueryClient = {
fetchQuery: jest.fn(),
};
// Mock the hooks - same as AttributeActions test setup
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
currentQuery: {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
},
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({ notifications: mockNotifications }),
}));
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
}));
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueryClient: (): any => mockQueryClient,
}));
jest.mock('@signozhq/sonner', () => ({ toast: jest.fn() }));
// Mock the API response for getAggregateKeys
const mockAggregateKeysResponse = {
payload: {
attributeKeys: [
{
key: 'name',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'serviceName',
dataType: 'string',
type: 'resource',
isColumn: true,
},
{
key: 'spanKind',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'statusCodeString',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'span_id',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'durationNano',
dataType: 'number',
type: 'tag',
isColumn: true,
},
],
},
};
beforeEach(() => {
jest.clearAllMocks();
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
});
// Create realistic mock span data for testing
const createMockSpan = (overrides: Partial<Span> = {}): Span => ({
spanId: '28a8a67365d0bd8b',
traceId: '000000000000000071dc9b0a338729b4',
name: 'HTTP GET /api/users',
timestamp: 1699872000000000,
durationNano: 150000000,
serviceName: 'frontend-service',
spanKind: 'server',
statusCodeString: 'OK',
statusMessage: '',
tagMap: {
'http.method': 'GET',
'http.url': '/api/users?page=1',
},
event: [],
references: [],
hasError: false,
rootSpanId: '',
parentSpanId: '',
kind: 0,
rootName: '',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
...overrides,
});
interface RenderResult {
user: ReturnType<typeof userEvent.setup>;
}
const renderSpanDetailsDrawer = (
span: Span = createMockSpan(),
): RenderResult => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<MockQueryClientProvider>
<AppProvider>
<MemoryRouter>
<Route>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={span}
traceStartTime={span.timestamp}
traceEndTime={span.timestamp + span.durationNano}
/>
</Route>
</MemoryRouter>
</AppProvider>
</MockQueryClientProvider>,
);
return { user };
};
describe('SpanFieldActions User Flow Tests', () => {
describe('Primary Filter Flow', () => {
it('should allow user to filter for span name value and navigate to traces explorer', async () => {
const testSpan = createMockSpan({
name: 'GET /api/orders',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the span name displayed
expect(screen.getByText('span name')).toBeInTheDocument();
expect(screen.getByText('GET /api/orders')).toBeInTheDocument();
// Find the span name field item
const spanNameItem = screen.getByText('span name').closest('.item');
expect(spanNameItem).toBeInTheDocument();
// User hovers over the span name field to reveal action buttons
await user.hover(spanNameItem!);
// Action buttons should appear on hover
const actionButtons = spanNameItem!.querySelector('.action-btn');
expect(actionButtons).toBeInTheDocument();
const filterForButton = spanNameItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
expect(filterForButton).toBeInTheDocument();
// User clicks "Filter for value" button
await user.click(filterForButton);
// Verify navigation to traces explorer with correct filter
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'name' }),
op: '=',
value: 'GET /api/orders',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
it('should allow user to filter for service name with proper field mapping', async () => {
const testSpan = createMockSpan({
serviceName: 'payment-service',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the service displayed
expect(screen.getByText('service')).toBeInTheDocument();
expect(screen.getByText('payment-service')).toBeInTheDocument();
// Find the service field item
const serviceItem = screen.getByText('service').closest('.item');
expect(serviceItem).toBeInTheDocument();
// User hovers and clicks filter for
await user.hover(serviceItem!);
const filterForButton = serviceItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
await user.click(filterForButton);
// Verify correct field mapping: "service" display name → "serviceName" query key
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'serviceName' }),
op: '=',
value: 'payment-service',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
});
describe('Filter Out Flow', () => {
it('should allow user to exclude span kind value and navigate to traces explorer', async () => {
const testSpan = createMockSpan({
spanKind: 'client',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the span kind displayed
expect(screen.getByText('span kind')).toBeInTheDocument();
expect(screen.getByText('client')).toBeInTheDocument();
// Find the span kind field item
const spanKindItem = screen.getByText('span kind').closest('.item');
expect(spanKindItem).toBeInTheDocument();
// User hovers over the span kind field
await user.hover(spanKindItem!);
const filterOutButton = spanKindItem!.querySelector(
'[aria-label="Filter out value"]',
) as HTMLElement;
expect(filterOutButton).toBeInTheDocument();
// User clicks "Filter out value" button
await user.click(filterOutButton);
// Verify navigation to traces explorer with exclusion filter
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'spanKind' }),
op: '!=',
value: 'client',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
});
describe('Copy Actions Flow', () => {
it('should allow user to copy field name and field value through popover actions', async () => {
const testSpan = createMockSpan({
statusCodeString: 'ERROR',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the status code string displayed
expect(screen.getByText('status code string')).toBeInTheDocument();
expect(screen.getByText('ERROR')).toBeInTheDocument();
// Find the status code string field item
const statusCodeItem = screen
.getByText('status code string')
.closest('.item');
expect(statusCodeItem).toBeInTheDocument();
// User hovers over the field to reveal action buttons
await user.hover(statusCodeItem!);
// User clicks the more actions button (ellipsis)
const moreActionsButton = statusCodeItem!
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
expect(moreActionsButton).toBeInTheDocument();
await user.click(moreActionsButton);
// Verify popover opens with copy options
await waitFor(() => {
expect(screen.getByText('Copy Field Name')).toBeInTheDocument();
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
// User clicks "Copy Field Name"
const copyFieldNameButton = screen.getByText('Copy Field Name');
fireEvent.click(copyFieldNameButton);
// Verify field name is copied with correct mapping
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('statusCodeString');
expect(mockNotifications.success).toHaveBeenCalledWith({
message: 'Field name copied to clipboard',
});
});
// Reset mocks and test copy field value
mockSetCopy.mockClear();
mockNotifications.success.mockClear();
// Open popover again for copy field value test
await user.hover(statusCodeItem!);
await user.click(moreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
// User clicks "Copy Field Value"
const copyFieldValueButton = screen.getByText('Copy Field Value');
fireEvent.click(copyFieldValueButton);
// Verify field value is copied
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('ERROR');
expect(mockNotifications.success).toHaveBeenCalledWith({
message: 'Field value copied to clipboard',
});
});
});
});
describe('Multiple Standard Fields', () => {
it('should work consistently across different field types with proper field mappings', async () => {
const testSpan = createMockSpan({
spanId: 'abc123def456',
name: 'Database Query',
serviceName: 'db-service',
spanKind: 'internal',
statusCodeString: 'OK',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// Test span ID field with its mapping
const spanIdItem = screen.getByText('span id').closest('.item');
await user.hover(spanIdItem!);
const spanIdFilterButton = spanIdItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
await user.click(spanIdFilterButton);
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'span_id' }),
op: '=',
value: 'abc123def456',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
mockRedirectWithQueryBuilderData.mockClear();
// Test span name field
const spanNameItem = screen.getByText('span name').closest('.item');
await user.hover(spanNameItem!);
const spanNameFilterButton = spanNameItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
await user.click(spanNameFilterButton);
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'name' }),
op: '=',
value: 'Database Query',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
});
describe('Special Field Values', () => {
it('should handle duration field with numeric values properly', async () => {
const testSpan = createMockSpan({
durationNano: 250000000, // 250ms
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the duration displayed (formatted)
expect(screen.getByText('duration')).toBeInTheDocument();
// Duration should be formatted by getYAxisFormattedValue, but we test the raw value is used in filter
// Find the duration field item
const durationItem = screen.getByText('duration').closest('.item');
expect(durationItem).toBeInTheDocument();
// User hovers and clicks filter for
await user.hover(durationItem!);
const filterForButton = durationItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
await user.click(filterForButton);
// Verify the raw numeric value is used in the filter, not the formatted display value
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'durationNano' }),
op: '=',
value: '250000000', // Raw numeric value as string
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
it('should handle fields with special characters and preserve exact field values', async () => {
const testSpan = createMockSpan({
name: 'POST /api/users/create',
statusCodeString: '"INTERNAL_ERROR"', // Quoted value to test exact preservation
});
const { user } = renderSpanDetailsDrawer(testSpan);
// Test span name with special characters
const spanNameItem = screen.getByText('span name').closest('.item');
await user.hover(spanNameItem!);
const moreActionsButton = spanNameItem!
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
await user.click(moreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
const copyFieldValueButton = screen.getByText('Copy Field Value');
fireEvent.click(copyFieldValueButton);
// Verify special characters are handled correctly
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('POST /api/users/create');
});
// Reset and test quoted value handling
mockSetCopy.mockClear();
// Test status code string with quotes (should preserve exact value)
const statusItem = screen.getByText('status code string').closest('.item');
await user.hover(statusItem!);
const statusMoreActionsButton = statusItem!
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
await user.click(statusMoreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
const statusCopyButton = screen.getByText('Copy Field Value');
fireEvent.click(statusCopyButton);
// Verify exact field value is preserved (including quotes)
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('"INTERNAL_ERROR"');
});
});
it('should handle status message field with action buttons when present', async () => {
const testSpan = createMockSpan({
statusMessage: 'Connection timeout error',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the status message displayed
expect(screen.getByText('status message')).toBeInTheDocument();
expect(screen.getByText('Connection timeout error')).toBeInTheDocument();
// Find the status message field item
const statusMessageItem = screen
.getByText('status message')
.closest('.item');
expect(statusMessageItem).toBeInTheDocument();
// User hovers over the status message field to reveal action buttons
await user.hover(statusMessageItem!);
const filterForButton = statusMessageItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
expect(filterForButton).toBeInTheDocument();
// User clicks "Filter for value" button
await user.click(filterForButton);
// Verify navigation to traces explorer with correct filter mapping
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'statusMessage' }),
op: '=',
value: 'Connection timeout error',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
// Reset and test copy functionality
mockRedirectWithQueryBuilderData.mockClear();
// Test copy field name functionality
await user.hover(statusMessageItem!);
const moreActionsButton = statusMessageItem!
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
await user.click(moreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Name')).toBeInTheDocument();
});
const copyFieldNameButton = screen.getByText('Copy Field Name');
fireEvent.click(copyFieldNameButton);
// Verify field name mapping (display "status message" → query "statusMessage")
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('statusMessage');
expect(mockNotifications.success).toHaveBeenCalledWith({
message: 'Field name copied to clipboard',
});
});
});
});
});

View File

@@ -1,14 +1,7 @@
import { cloneDeep, isEqual } from 'lodash-es'; import { cloneDeep, isEqual } from 'lodash-es';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
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 { interface NavigateOptions {
replace?: boolean; replace?: boolean;
state?: any; state?: any;
@@ -90,74 +83,6 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
return newKeys.length > 0; 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 = ( export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = { { preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true, preventSameUrlNavigation: true,
@@ -165,11 +90,6 @@ export const useSafeNavigate = (
): { ): {
safeNavigate: ( safeNavigate: (
to: string | SafeNavigateParams, to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions, options?: NavigateOptions,
) => void; ) => void;
} => { } => {
@@ -177,25 +97,30 @@ export const useSafeNavigate = (
const location = useLocation(); const location = useLocation();
const safeNavigate = useCallback( 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( const currentUrl = new URL(
`${location.pathname}${location.search}`, `${location.pathname}${location.search}`,
window.location.origin, window.location.origin,
); );
const targetUrl = createTargetUrl(to, location);
// Handle new tab navigation let targetUrl: URL;
if (finalOptions?.newTab) {
handleNewTabNavigation(to, location); 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');
return; return;
} }
@@ -207,13 +132,23 @@ export const useSafeNavigate = (
} }
const navigationOptions = { const navigationOptions = {
...finalOptions, ...options,
replace: isDefaultParamsNavigation || finalOptions?.replace, replace: isDefaultParamsNavigation || options?.replace,
}; };
performNavigation(to, navigationOptions, navigate, location); if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
}, },
[navigate, location, preventSameUrlNavigation], [navigate, location.pathname, location.search, preventSameUrlNavigation],
); );
return { safeNavigate }; return { safeNavigate };

View File

@@ -1,634 +0,0 @@
/* 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,58 +1,3 @@
import { import { createBrowserHistory } from 'history';
createBrowserHistory,
createPath,
History,
LocationDescriptorObject,
LocationState,
} from 'history';
import { isEventObject } from 'utils/isEventObject';
// Create the base history instance export default createBrowserHistory();
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

@@ -36,6 +36,7 @@ import { getYAxisScale } from './utils/getYAxisScale';
interface ExtendedUPlot extends uPlot { interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void; _legendScrollCleanup?: () => void;
_tooltipCleanup?: () => void; _tooltipCleanup?: () => void;
_legendElementCleanup?: Array<() => void>;
} }
export interface GetUPlotChartOptions { export interface GetUPlotChartOptions {
@@ -473,6 +474,9 @@ export const getUPlotChartOptions = ({
if (legend) { if (legend) {
const legendElement = legend as HTMLElement; const legendElement = legend as HTMLElement;
// Initialize cleanup array for legend element listeners
(self as ExtendedUPlot)._legendElementCleanup = [];
// Apply enhanced legend styling // Apply enhanced legend styling
if (enhancedLegend) { if (enhancedLegend) {
applyEnhancedLegendStyling( applyEnhancedLegendStyling(
@@ -639,6 +643,17 @@ export const getUPlotChartOptions = ({
thElement.addEventListener('mouseenter', showTooltip); thElement.addEventListener('mouseenter', showTooltip);
thElement.addEventListener('mouseleave', hideTooltip); thElement.addEventListener('mouseleave', hideTooltip);
// Store cleanup function for tooltip listeners
(self as ExtendedUPlot)._legendElementCleanup?.push(() => {
thElement.removeEventListener('mouseenter', showTooltip);
thElement.removeEventListener('mouseleave', hideTooltip);
// Cleanup any lingering tooltip
if (tooltipElement) {
tooltipElement.remove();
tooltipElement = null;
}
});
// Add click handlers for marker and text separately // Add click handlers for marker and text separately
const currentMarker = thElement.querySelector('.u-marker'); const currentMarker = thElement.querySelector('.u-marker');
const textElement = thElement.querySelector('.legend-text'); const textElement = thElement.querySelector('.legend-text');
@@ -658,7 +673,7 @@ export const getUPlotChartOptions = ({
// Marker click handler - checkbox behavior (toggle individual series) // Marker click handler - checkbox behavior (toggle individual series)
if (currentMarker) { if (currentMarker) {
currentMarker.addEventListener('click', (e) => { const markerClickHandler = (e: Event): void => {
e.stopPropagation?.(); // Prevent event bubbling to text handler e.stopPropagation?.(); // Prevent event bubbling to text handler
if (stackChart) { if (stackChart) {
@@ -680,12 +695,19 @@ export const getUPlotChartOptions = ({
return newGraphVisibilityStates; return newGraphVisibilityStates;
}); });
} }
};
currentMarker.addEventListener('click', markerClickHandler);
// Store cleanup function for marker click listener
(self as ExtendedUPlot)._legendElementCleanup?.push(() => {
currentMarker.removeEventListener('click', markerClickHandler);
}); });
} }
// Text click handler - show only/show all behavior (existing behavior) // Text click handler - show only/show all behavior (existing behavior)
if (textElement) { if (textElement) {
textElement.addEventListener('click', (e) => { const textClickHandler = (e: Event): void => {
e.stopPropagation?.(); // Prevent event bubbling e.stopPropagation?.(); // Prevent event bubbling
if (stackChart) { if (stackChart) {
@@ -716,6 +738,13 @@ export const getUPlotChartOptions = ({
return newGraphVisibilityStates; return newGraphVisibilityStates;
}); });
} }
};
textElement.addEventListener('click', textClickHandler);
// Store cleanup function for text click listener
(self as ExtendedUPlot)._legendElementCleanup?.push(() => {
textElement.removeEventListener('click', textClickHandler);
}); });
} }
} }
@@ -723,6 +752,33 @@ export const getUPlotChartOptions = ({
} }
}, },
], ],
destroy: [
(self): void => {
// Clean up legend scroll listener
if ((self as ExtendedUPlot)._legendScrollCleanup) {
(self as ExtendedUPlot)._legendScrollCleanup?.();
(self as ExtendedUPlot)._legendScrollCleanup = undefined;
}
// Clean up tooltip global listener
if ((self as ExtendedUPlot)._tooltipCleanup) {
(self as ExtendedUPlot)._tooltipCleanup?.();
(self as ExtendedUPlot)._tooltipCleanup = undefined;
}
// Clean up all legend element listeners
if ((self as ExtendedUPlot)._legendElementCleanup) {
(self as ExtendedUPlot)._legendElementCleanup?.forEach((cleanup) => {
cleanup();
});
(self as ExtendedUPlot)._legendElementCleanup = [];
}
// Clean up any remaining tooltips in DOM
const existingTooltips = document.querySelectorAll('.legend-tooltip');
existingTooltips.forEach((tooltip) => tooltip.remove());
},
],
}, },
series: customSeries series: customSeries
? customSeries(apiResponse?.data?.result || []) ? customSeries(apiResponse?.data?.result || [])

View File

@@ -700,24 +700,23 @@ describe('TracesExplorer - ', () => {
}); });
it('select a view options - assert and save this view', async () => { it('select a view options - assert and save this view', async () => {
jest.useFakeTimers();
const { container } = renderWithTracesExplorerRouter(<TracesExplorer />, [ const { container } = renderWithTracesExplorerRouter(<TracesExplorer />, [
'/traces-explorer/?panelType=list&selectedExplorerView=list', '/traces-explorer/?panelType=list&selectedExplorerView=list',
]); ]);
await screen.findByText(FILTER_SERVICE_NAME);
await act(async () => {
fireEvent.mouseDown(
container.querySelector(
'.view-options .ant-select-selection-search-input',
) as HTMLElement,
);
});
const viewListOptions = await screen.findByRole('listbox'); const viewSearchInput = container.querySelector(
expect(viewListOptions).toBeInTheDocument(); '.view-options .ant-select-selection-search-input',
) as HTMLElement;
expect(within(viewListOptions).getByText('R-test panel')).toBeInTheDocument(); expect(viewSearchInput).toBeInTheDocument();
expect(within(viewListOptions).getByText('Table View')).toBeInTheDocument(); fireEvent.mouseDown(viewSearchInput);
expect(
await screen.findByRole('option', { name: 'R-test panel' }),
).toBeInTheDocument();
// save this view // save this view
fireEvent.click(screen.getByText('Save this view')); fireEvent.click(screen.getByText('Save this view'));

View File

@@ -1,21 +0,0 @@
// 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
);
};