Compare commits

..

7 Commits

Author SHA1 Message Date
Abhi Kumar
71036f4e12 test(charts): add Pie chart + usePieInteractions tests
Cover the shared Pie chart and its helpers/hook:
- utils: scaled font size, arc geometry, colour lighten/fill dimming
- usePieInteractions: marker toggle, label isolate/reset, hover focus,
  hidden-slice guard, localStorage persistence + rehydration
- PieArc: leader-label threshold, label truncation, enter/leave/click
- PieCenterLabel: numeric/unit split
- Pie: no-data state, arc-per-slice rendering, legend, layout per position,
  hide-on-marker-click

Adds a global ResizeObserver polyfill to jest.setup.ts (alongside the existing
IntersectionObserver one) since visx's useTooltipInPortal requires it.
2026-06-04 16:13:19 +05:30
Abhi Kumar
5d9f1502f2 feat(charts): add shared Pie chart component
Add a reusable @visx donut Pie chart (charts/Pie) that consumes the shared
presentational Legend: chart/legend split via calculateChartDimensions,
search/copy, hover-sync, slice hide/unhide (mirrors the uPlot legend) with
localStorage persistence, leader labels and a centre total. Split into PieArc /
PieCenterLabel / usePieInteractions for readability.

Also renames the tooltip click payload TooltipClickData -> ChartClickData across
the TooltipPlugin and chart prop types (the type describes any chart click, not
just tooltips).
2026-06-04 15:45:29 +05:30
Abhi Kumar
331daef14b refactor(uPlotV2): split Legend into presentational + uPlot controller
Separate the legend into a presentational `Legend` (renders supplied items +
delegated handlers, with search/copy/virtualization) and a `UPlotLegend`
controller that wires it to the uPlot config via useLegendsSync/useLegendActions.
This lets non-uPlot charts reuse the legend. ChartWrapper now renders UPlotLegend
(behaviour unchanged for TimeSeries/Bar/Histogram). Also fixes the copy-button
hover layout shift (reserve space, fade via opacity) and uses @signozhq/ui
tooltips for legend items.
2026-06-04 15:44:11 +05:30
Gaurav Tewari
f4ede36d3e fix: ui issues in Trace and logs (#11564)
* fix: ui migration issues

* fix: minor issue

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-04 07:31:39 +00:00
Gaurav Tewari
fa7d941266 fix: time not being updated issue in domainlist and traces (#11460)
* fix: time not being updated issue in domainlist and traces

* refactor: make a hook

* chore: more refactor

* fix: test cases

* fix: test cases

* refactor: code

* fix: add test cases

* fix: test cases

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-04 07:31:14 +00:00
Vinicius Lourenço
fdb22e6669 test(alerts): add tests for list alerts & triggered alerts (#11554)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* test(alerts): add tests for triggered and list alerts page

* test(alerts): add tests for list alerts and triggered alerts

* test(fireEvent): replace by userEvent
2026-06-03 19:53:35 +00:00
Ashwin Bhatkal
95adbc31cc chore(dashboard): remove obsolete Sentry query-range timeout warning (#11576)
Removes the useEffect that captured a Sentry warning when a widget's
query range was not called within 120s, along with the now-unused
queryRangeCalledRef and the @sentry/react import in GridCard.

Closes SigNoz/engineering-pod#5217
2026-06-03 19:50:16 +00:00
83 changed files with 4216 additions and 539 deletions

View File

@@ -1,28 +0,0 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
newTab?: boolean;
}
interface SafeNavigateTo {
pathname?: string;
search?: string;
hash?: string;
}
type SafeNavigateToType = string | SafeNavigateTo;
interface UseSafeNavigateReturn {
safeNavigate: jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>;
}
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
safeNavigate: jest.fn(
(_to: SafeNavigateToType, _options?: SafeNavigateOptions) => {},
) as jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>,
});

View File

@@ -1,6 +1,8 @@
import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const USE_SAFE_NAVIGATE_MOCK_PATH =
'<rootDir>/src/__tests__/safeNavigateMock.ts';
const LOG_EVENT_MOCK_PATH = '<rootDir>/src/__tests__/logEventMock.ts';
const config: Config.InitialOptions = {
silent: true,
@@ -22,6 +24,8 @@ const config: Config.InitialOptions = {
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^api/common/logEvent$': LOG_EVENT_MOCK_PATH,
'^src/api/common/logEvent$': LOG_EVENT_MOCK_PATH,
'^constants/env$': '<rootDir>/__mocks__/env.ts',
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',

View File

@@ -41,6 +41,15 @@ if (typeof window.IntersectionObserver === 'undefined') {
(window as any).IntersectionObserver = IntersectionObserverMock;
}
if (typeof window.ResizeObserver === 'undefined') {
class ResizeObserverMock {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
(window as any).ResizeObserver = ResizeObserverMock;
}
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),

View File

@@ -0,0 +1,11 @@
// Shared mock for `api/common/logEvent`.
// Wired into jest.config.ts moduleNameMapper, so any import of
// `api/common/logEvent` in test code resolves to this file.
// Tests can import `logEventMock` to assert analytics calls — Jest's
// `clearMocks: true` resets call history between tests.
export const logEventMock: jest.MockedFunction<
(eventName: string, attributes?: Record<string, unknown>) => void
> = jest.fn();
export default logEventMock;

View File

@@ -0,0 +1,29 @@
// Shared mock for `hooks/useSafeNavigate`.
// Wired into jest.config.ts moduleNameMapper, so any import of
// `hooks/useSafeNavigate` in test code resolves to this file.
// Tests can import `safeNavigateMock` to assert navigation calls — Jest's
// `clearMocks: true` resets call history between tests.
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
newTab?: boolean;
}
interface SafeNavigateTo {
pathname?: string;
search?: string;
hash?: string;
}
type SafeNavigateToType = string | SafeNavigateTo;
export const safeNavigateMock: jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
> = jest.fn();
export const useSafeNavigate = (): {
safeNavigate: typeof safeNavigateMock;
} => ({
safeNavigate: safeNavigateMock,
});

View File

@@ -3,17 +3,12 @@ import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import FeedbackModal from '../FeedbackModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -35,7 +30,6 @@ jest.mock('container/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
const mockHandleContactSupport = handleContactSupport as jest.Mock;
@@ -50,6 +44,7 @@ const mockLocation = {
describe('FeedbackModal', () => {
beforeEach(() => {
jest.clearAllMocks();
logEventMock.mockReturnValue(Promise.resolve() as never);
mockUseLocation.mockReturnValue(mockLocation);
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
@@ -116,7 +111,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'feedback',
page: mockLocation.pathname,
@@ -149,7 +144,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'reportBug',
page: mockLocation.pathname,
@@ -182,7 +177,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'featureRequest',
page: mockLocation.pathname,

View File

@@ -2,16 +2,11 @@
import { useLocation } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import HeaderRightSection from '../HeaderRightSection';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -50,7 +45,6 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: (): boolean => false,
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
@@ -120,7 +114,7 @@ describe('HeaderRightSection', () => {
await user.click(feedbackButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
@@ -133,7 +127,7 @@ describe('HeaderRightSection', () => {
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
@@ -150,7 +144,7 @@ describe('HeaderRightSection', () => {
await user.click(announcementsButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', {
page: mockLocation.pathname,
});
});

View File

@@ -5,18 +5,13 @@ import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import ShareURLModal from '../ShareURLModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -53,7 +48,6 @@ Object.defineProperty(window, 'location', {
writable: true,
});
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseUrlQuery = useUrlQuery as jest.Mock;
const mockUseSelector = useSelector as jest.Mock;
@@ -125,7 +119,7 @@ describe('ShareURLModal', () => {
await user.click(copyButton);
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', {
page: TEST_PATH,
URL: expect.any(String),
});

View File

@@ -139,7 +139,6 @@ jest.mock('react-query', (): unknown => {
});
// mock other side-effecty modules
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('api/browser/localstorage/set', () => jest.fn());
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
import { USER_PREFERENCES } from 'constants/userPreferences';
import {
@@ -24,8 +24,6 @@ jest.mock('providers/cmdKProvider', () => ({
}),
}));
jest.mock('api/common/logEvent', () => jest.fn());
// Mock the AppContext
const mockUpdateUserPreferenceInContext = jest.fn();
@@ -139,7 +137,7 @@ describe('Sidebar Toggle Shortcut', () => {
it('should log the toggle event with correct parameters', async () => {
const user = userEvent.setup();
const mockHandleShortcut = jest.fn(() => {
logEvent('Global Shortcut: Sidebar Toggle', {
logEventMock('Global Shortcut: Sidebar Toggle', {
previousState: false,
newState: true,
});
@@ -155,10 +153,13 @@ describe('Sidebar Toggle Shortcut', () => {
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
previousState: false,
newState: true,
});
expect(logEventMock).toHaveBeenCalledWith(
'Global Shortcut: Sidebar Toggle',
{
previousState: false,
newState: true,
},
);
});
it('should update user preference in context', async () => {

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
import {
LegendPosition,
TooltipRenderArgs,
@@ -47,7 +47,7 @@ export default function ChartWrapper({
return null;
}
return (
<Legend
<UPlotLegend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}

View File

@@ -0,0 +1,67 @@
.pieChartWrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.pieChartNoData {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
font-size: 14px;
}
// Size is set inline from the computed chart dimensions (mirrors the uPlot
// chart/legend split); this just centres the donut within that box.
.pieChartContainer {
flex: 0 0 auto;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.pieChartTooltip {
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
background-color: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.pieTooltipContent {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.pieChartIndicator {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
display: inline-block;
}
.tooltipValue {
font-weight: bold;
margin-top: 4px;
}
// Wraps the shared chart Legend. Its width/height are set inline from the
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
// box (right column / bottom rows) the uPlot charts use.
.pieLegend {
flex: 0 0 auto;
min-height: 0;
min-width: 0;
padding: 8px;
}

View File

@@ -0,0 +1,241 @@
import { useCallback, useMemo, useRef } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Group } from '@visx/group';
import { Pie as VisxPie } from '@visx/shape';
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useResizeObserver } from 'hooks/useDimensions';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { PieChartProps, PieSlice } from '../types';
import { calculateChartDimensions } from '../utils';
import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { usePieInteractions } from './usePieInteractions';
import { getFillColor } from './utils';
interface PieTooltipData {
label: string;
value: string;
color: string;
}
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
* same `calculateChartDimensions` logic as the uPlot charts (right column /
* up-to-two bottom rows), renders the shared chart Legend, and delegates the
* arcs, centre total and interaction state to PieArc / PieCenterLabel /
* usePieInteractions. Pure presentation — slices are pre-resolved by the caller.
*/
export default function Pie({
data,
yAxisUnit,
decimalPrecision,
isDarkMode,
position = LegendPosition.BOTTOM,
id,
onSliceClick,
'data-testid': testId,
}: PieChartProps): JSX.Element {
const {
active,
setActive,
visibleData,
legendItems,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = usePieInteractions(data, id);
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
} = useTooltip<PieTooltipData>();
const { containerRef, TooltipInPortal } = useTooltipInPortal({
scroll: true,
detectBounds: true,
});
const wrapperRef = useRef<HTMLDivElement>(null);
const { width: containerWidth, height: containerHeight } =
useResizeObserver(wrapperRef);
// Reuse the uPlot chart/legend split so the donut + legend get the same area
// allocation (right column, or up-to-two bottom rows) as every other panel.
const dimensions = useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
const size = Math.min(dimensions.width, dimensions.height);
const radius = size * 0.35;
const innerRadius = radius * 0.6;
const totalValue = visibleData.reduce((sum, slice) => sum + slice.value, 0);
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const activeColor = active?.color ?? null;
const handleSliceEnter = useCallback(
(slice: PieSlice, centroidX: number, centroidY: number): void => {
showTooltip({
tooltipData: {
label: slice.label,
value: getYAxisFormattedValue(
slice.value.toString(),
yAxisUnit || 'none',
decimalPrecision,
),
color: slice.color,
},
tooltipTop: centroidY + dimensions.height / 2,
tooltipLeft: centroidX + dimensions.width / 2,
});
setActive(slice);
},
[
showTooltip,
setActive,
yAxisUnit,
decimalPrecision,
dimensions.height,
dimensions.width,
],
);
const handleSliceLeave = useCallback((): void => {
hideTooltip();
setActive(null);
}, [hideTooltip, setActive]);
if (!data.length) {
return (
<div
ref={wrapperRef}
className={styles.pieChartWrapper}
data-testid={testId}
>
<div className={styles.pieChartNoData}>No data</div>
</div>
);
}
const isRightLegend = position === LegendPosition.RIGHT;
return (
<div
ref={wrapperRef}
className={styles.pieChartWrapper}
style={{ flexDirection: isRightLegend ? 'row' : 'column' }}
data-testid={testId}
>
<div
className={styles.pieChartContainer}
style={{ width: dimensions.width, height: dimensions.height }}
>
{size > 0 && (
<svg
width={dimensions.width}
height={dimensions.height}
ref={containerRef}
>
<Group top={dimensions.height / 2} left={dimensions.width / 2}>
<VisxPie
data={visibleData}
pieValue={(slice: PieSlice): number => slice.value}
outerRadius={radius}
innerRadius={innerRadius}
padAngle={0.01}
cornerRadius={3}
width={size}
height={size}
>
{(pie): JSX.Element[] =>
pie.arcs.map((arc) => (
<PieArc
key={`arc-${arc.data.label}-${arc.data.value}-${arc.startAngle.toFixed(
6,
)}`}
slice={arc.data}
arcPath={pie.path(arc) || ''}
centroid={pie.path.centroid(arc)}
startAngle={arc.startAngle}
endAngle={arc.endAngle}
radius={radius}
totalValue={totalValue}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
labelColor={labelColor}
fill={getFillColor(arc.data.color, activeColor)}
onEnter={handleSliceEnter}
onLeave={handleSliceLeave}
onClick={onSliceClick}
/>
))
}
</VisxPie>
<PieCenterLabel
total={totalValue}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
radius={radius}
innerRadius={innerRadius}
color={labelColor}
/>
</Group>
</svg>
)}
{tooltipOpen && tooltipData && (
<TooltipInPortal
top={tooltipTop}
left={tooltipLeft}
className={styles.pieChartTooltip}
style={{
...defaultStyles,
color: labelColor,
}}
>
<div
className={styles.pieChartIndicator}
style={{ background: tooltipData.color }}
/>
<div className={styles.pieTooltipContent}>
<span>{tooltipData.label}</span>
<span className={styles.tooltipValue}>{tooltipData.value}</span>
</div>
</TooltipInPortal>
)}
</div>
<div
className={styles.pieLegend}
style={{
width: dimensions.legendWidth,
height: dimensions.legendHeight,
}}
>
<Legend
items={legendItems}
position={position}
averageLegendWidth={dimensions.averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { PieSlice } from '../types';
import { getArcGeometry } from './utils';
// Slices below this share of the total don't get a leader label (too cramped).
const MIN_LABEL_SHARE = 0.03;
const MAX_LABEL_LENGTH = 15;
interface PieArcProps {
slice: PieSlice;
/** SVG path `d` for the arc, from the visx pie generator. */
arcPath: string;
/** Arc centroid `[x, y]`, used to anchor the leader line and tooltip. */
centroid: [number, number];
startAngle: number;
endAngle: number;
radius: number;
/** Sum of visible slice values — drives the show-label threshold. */
totalValue: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
labelColor: string;
/** Resolved fill (already dimmed if another slice is active). */
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
onLeave: () => void;
onClick?: (slice: PieSlice) => void;
}
/**
* A single donut slice: the arc path plus, for non-tiny slices, a leader line
* out to an external label + value. Pure presentation — interaction is
* delegated to the `onEnter`/`onLeave`/`onClick` callbacks.
*/
export default function PieArc({
slice,
arcPath,
centroid,
startAngle,
endAngle,
radius,
totalValue,
yAxisUnit,
decimalPrecision,
labelColor,
fill,
onEnter,
onLeave,
onClick,
}: PieArcProps): JSX.Element {
const { label, value } = slice;
const [centroidX, centroidY] = centroid;
const { labelX, labelY, lineEndX, lineEndY, textAnchor } = getArcGeometry(
startAngle,
endAngle,
radius,
);
const displayValue = getYAxisFormattedValue(
value.toString(),
yAxisUnit || 'none',
decimalPrecision,
);
const shortenedLabel =
label.length > MAX_LABEL_LENGTH ? `${label.substring(0, 12)}...` : label;
const shouldShowLabel = value / totalValue > MIN_LABEL_SHARE;
return (
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
onMouseLeave={onLeave}
onClick={(): void => onClick?.(slice)}
>
<path d={arcPath} fill={fill} />
{shouldShowLabel && (
<>
<line
x1={centroidX}
y1={centroidY}
x2={lineEndX}
y2={lineEndY}
stroke={labelColor}
strokeWidth={1}
/>
<line
x1={lineEndX}
y1={lineEndY}
x2={labelX}
y2={labelY}
stroke={labelColor}
strokeWidth={1}
/>
<text
x={labelX}
y={labelY - 8}
dy=".33em"
fill={labelColor}
fontSize={10}
textAnchor={textAnchor}
pointerEvents="none"
>
{shortenedLabel}
</text>
<text
x={labelX}
y={labelY + 8}
dy=".33em"
fill={labelColor}
fontSize={10}
fontWeight="bold"
textAnchor={textAnchor}
pointerEvents="none"
>
{displayValue}
</text>
</>
)}
</g>
);
}

View File

@@ -0,0 +1,57 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { getScaledFontSize } from './utils';
interface PieCenterLabelProps {
/** Sum of the visible slice values, shown in the donut hole. */
total: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
radius: number;
innerRadius: number;
color: string;
}
/**
* The total shown in the centre of the donut. Splits the formatted value into
* its numeric part and unit so each can be sized independently, and scales the
* numeric font down for long values so it never overflows the hole.
*/
export default function PieCenterLabel({
total,
yAxisUnit,
decimalPrecision,
radius,
innerRadius,
color,
}: PieCenterLabelProps): JSX.Element {
const formattedTotal = getYAxisFormattedValue(
total.toString(),
yAxisUnit || 'none',
decimalPrecision,
);
const matches = formattedTotal.match(/([\d.]+[KMB]?)(.*)$/);
const numericTotal = matches?.[1] || formattedTotal;
const unitTotal = matches?.[2]?.trim() || '';
const numericFontSize = getScaledFontSize({
text: numericTotal,
baseSize: radius * 0.3,
innerRadius,
});
const unitFontSize = numericFontSize * 0.5;
return (
<text textAnchor="middle" dominantBaseline="central" fill={color}>
<tspan fontSize={numericFontSize} fontWeight="bold">
{numericTotal}
</tspan>
{unitTotal && (
<tspan fontSize={unitFontSize} opacity={0.9} dx={2}>
{unitTotal}
</tspan>
)}
</text>
);
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { PieSlice } from '../../types';
import Pie from '../Pie';
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),
}));
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn((value: string) => value),
}));
// VirtuosoGrid only renders a window in jsdom; render every item so we can
// assert on legend entries.
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div data-testid="virtuoso-grid">
{data.map((item, index) => (
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
))}
</div>
),
}));
const DATA: PieSlice[] = [
{ label: 'frontend', value: 100, color: '#aa0000' },
{ label: 'cart', value: 60, color: '#00aa00' },
{ label: 'checkout', value: 40, color: '#0000aa' },
];
function renderPie(
props: Partial<React.ComponentProps<typeof Pie>> = {},
): void {
render(
<TooltipProvider>
<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />
</TooltipProvider>,
);
}
describe('Pie', () => {
it('renders the "No data" state for empty data', () => {
render(
<TooltipProvider>
<Pie data={[]} isDarkMode={false} data-testid="pie" />
</TooltipProvider>,
);
expect(screen.getByText('No data')).toBeInTheDocument();
});
it('renders one arc per slice plus the legend entries and centre total', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(DATA.length);
const legend = screen.getByTestId('virtuoso-grid');
expect(within(legend).getByText('frontend')).toBeInTheDocument();
expect(within(legend).getByText('cart')).toBeInTheDocument();
expect(within(legend).getByText('checkout')).toBeInTheDocument();
// Centre total = 100 + 60 + 40 (formatter mocked to echo the value).
expect(screen.getByText('200')).toBeInTheDocument();
});
it('lays the legend out in a row for the right position and a column for bottom', () => {
const { rerender } = render(
<TooltipProvider>
<Pie
data={DATA}
isDarkMode={false}
position={LegendPosition.RIGHT}
data-testid="pie"
/>
</TooltipProvider>,
);
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'row' });
rerender(
<TooltipProvider>
<Pie
data={DATA}
isDarkMode={false}
position={LegendPosition.BOTTOM}
data-testid="pie"
/>
</TooltipProvider>,
);
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
});
it('hides a slice when its legend marker is clicked', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(3);
const marker = document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
) as HTMLElement;
fireEvent.click(marker);
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { PieSlice } from '../../types';
import PieArc from '../PieArc';
jest.mock('components/Graph/yAxisConfig', () => ({
// Echo the raw value so assertions are deterministic.
getYAxisFormattedValue: jest.fn((value: string) => value),
}));
const SLICE: PieSlice = { label: 'frontend', value: 50, color: '#f00' };
function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
onEnter: jest.Mock;
onLeave: jest.Mock;
onClick: jest.Mock;
container: HTMLElement;
} {
const onEnter = jest.fn();
const onLeave = jest.fn();
const onClick = jest.fn();
const { container } = render(
<svg>
<PieArc
slice={SLICE}
arcPath="M0,0L1,1"
centroid={[10, 20]}
startAngle={0}
endAngle={Math.PI}
radius={100}
totalValue={100}
labelColor="#fff"
fill="#f00"
onEnter={onEnter}
onLeave={onLeave}
onClick={onClick}
{...props}
/>
</svg>,
);
return { onEnter, onLeave, onClick, container };
}
describe('PieArc', () => {
it('renders the arc path with the resolved fill', () => {
const { container } = renderArc();
const path = container.querySelector('path');
expect(path).toHaveAttribute('d', 'M0,0L1,1');
expect(path).toHaveAttribute('fill', '#f00');
});
it('shows the leader label + value for a slice above the threshold', () => {
renderArc(); // 50 / 100 = 0.5
expect(screen.getByText('frontend')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
it('hides the leader label for a slice below the 3% threshold', () => {
renderArc({ totalValue: 10000 }); // 50 / 10000 = 0.005
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
// the arc path itself still renders
expect(screen.queryByText('50')).not.toBeInTheDocument();
});
it('truncates labels longer than 15 chars', () => {
renderArc({
slice: { label: 'a-really-long-service-name', value: 50, color: '#f00' },
});
expect(screen.getByText('a-really-lon...')).toBeInTheDocument();
});
it('fires onEnter with the slice + centroid, and onLeave / onClick', () => {
const { onEnter, onLeave, onClick, container } = renderArc();
const g = container.querySelector('g') as SVGGElement;
fireEvent.mouseEnter(g);
expect(onEnter).toHaveBeenCalledWith(SLICE, 10, 20);
fireEvent.mouseLeave(g);
expect(onLeave).toHaveBeenCalledTimes(1);
fireEvent.click(g);
expect(onClick).toHaveBeenCalledWith(SLICE);
});
});

View File

@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import PieCenterLabel from '../PieCenterLabel';
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn(),
}));
const mockFormat = getYAxisFormattedValue as jest.MockedFunction<
typeof getYAxisFormattedValue
>;
function renderInSvg(node: JSX.Element): ReturnType<typeof render> {
// PieCenterLabel returns an SVG <text>, so it needs an <svg> host.
return render(<svg>{node}</svg>);
}
describe('PieCenterLabel', () => {
const baseProps = {
total: 3700,
radius: 100,
innerRadius: 60,
color: '#fff',
};
it('renders the formatted total (numeric + unit suffix) as one numeric tspan when there is no separate unit', () => {
mockFormat.mockReturnValue('3.7K');
renderInSvg(<PieCenterLabel {...baseProps} />);
expect(screen.getByText('3.7K')).toBeInTheDocument();
});
it('splits the numeric part and the trailing unit into separate tspans', () => {
mockFormat.mockReturnValue('1.2 MB');
renderInSvg(<PieCenterLabel {...baseProps} />);
expect(screen.getByText('1.2')).toBeInTheDocument();
expect(screen.getByText('MB')).toBeInTheDocument();
});
it('passes the unit + precision through to the formatter', () => {
mockFormat.mockReturnValue('100');
renderInSvg(<PieCenterLabel {...baseProps} total={100} yAxisUnit="bytes" />);
expect(mockFormat).toHaveBeenCalledWith('100', 'bytes', undefined);
});
});

View File

@@ -0,0 +1,147 @@
import { act, renderHook } from '@testing-library/react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import type { MouseEvent } from 'react';
import { PieSlice } from '../../types';
import { usePieInteractions } from '../usePieInteractions';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
);
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
typeof getStoredSeriesVisibility
>;
const mockUpdateStored =
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
const DATA: PieSlice[] = [
{ label: 'frontend', value: 100, color: '#a' },
{ label: 'cart', value: 60, color: '#b' },
{ label: 'checkout', value: 40, color: '#c' },
];
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
function legendEvent(
index: number | null,
isMarker = false,
): MouseEvent<HTMLDivElement> {
const itemEl =
index == null ? null : { dataset: { legendItemId: String(index) } };
return {
target: {
closest: (): unknown => itemEl,
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
},
} as unknown as MouseEvent<HTMLDivElement>;
}
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
mockUpdateStored.mockReset();
});
it('starts with everything visible and nothing focused', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
true,
true,
]);
expect(result.current.focusedSeriesIndex).toBeNull();
expect(result.current.active).toBeNull();
});
describe('marker click (toggle one)', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('label click (isolate / reset)', () => {
it('isolates the clicked slice, then resets on a second click', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
false,
false,
]);
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual(DATA);
});
});
describe('hover', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendMouseMove(legendEvent(2)));
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() => result.current.onLegendMouseLeave());
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
act(() => result.current.onLegendMouseMove(legendEvent(1)));
expect(result.current.active).toBeNull();
});
});
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, true)));
expect(mockUpdateStored).not.toHaveBeenCalled();
});
it('rehydrates hidden slices from storage on mount (matched by label)', () => {
mockGetStored.mockReturnValue([
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
});
});
});

View File

@@ -0,0 +1,101 @@
import {
getArcGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from '../utils';
describe('Pie utils', () => {
describe('getScaledFontSize', () => {
it('returns the base size for empty text', () => {
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(
30,
);
});
it('does not scale short text (length <= 3)', () => {
// scaleFactor = max(0.3, 1) = 1 → baseSize, capped by innerRadius * 0.9.
expect(
getScaledFontSize({ text: '3.7', baseSize: 30, innerRadius: 100 }),
).toBe(30);
});
it('scales longer text down', () => {
// length 8 → scaleFactor = max(0.3, 1 - 5 * 0.09) = 0.55 → 30 * 0.55.
expect(
getScaledFontSize({ text: '12345678', baseSize: 30, innerRadius: 100 }),
).toBeCloseTo(16.5);
});
it('floors the scale factor at 0.3 for very long text', () => {
// length 20 → 1 - 17 * 0.09 < 0.3 → floored to 0.3 → 100 * 0.3.
expect(
getScaledFontSize({
text: '12345678901234567890',
baseSize: 100,
innerRadius: 1000,
}),
).toBeCloseTo(30);
});
it('caps the size at 90% of the inner radius', () => {
expect(
getScaledFontSize({ text: '3.7', baseSize: 200, innerRadius: 10 }),
).toBeCloseTo(9);
});
});
describe('getArcGeometry', () => {
it('places the label below for a slice centred at the top (angle 0)', () => {
const g = getArcGeometry(0, 0, 100);
expect(g.labelX).toBeCloseTo(0);
expect(g.labelY).toBeCloseTo(-130);
expect(g.lineEndX).toBeCloseTo(0);
expect(g.lineEndY).toBeCloseTo(-110);
// sin(0) is not > 0 → anchor end.
expect(g.textAnchor).toBe('end');
});
it('anchors to the start on the right half (angle pi/2)', () => {
const g = getArcGeometry(0, Math.PI, 100);
expect(g.labelX).toBeCloseTo(130);
expect(g.labelY).toBeCloseTo(0);
expect(g.textAnchor).toBe('start');
});
it('anchors to the end on the left half (angle 3pi/2)', () => {
const g = getArcGeometry(Math.PI, 2 * Math.PI, 100);
expect(g.labelX).toBeCloseTo(-130);
expect(g.textAnchor).toBe('end');
});
});
describe('lightenColor', () => {
it('converts a #rrggbb hex to rgba at the given opacity', () => {
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
});
it('accepts hex without a leading #', () => {
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
});
it('returns the original colour when it is not parseable hex', () => {
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
expect(lightenColor('red', 0.4)).toBe('red');
});
});
describe('getFillColor', () => {
it('returns the colour unchanged when nothing is active', () => {
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
});
it('returns the colour unchanged for the active slice', () => {
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
});
it('dims non-active slices to 40% opacity', () => {
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
});
});
});

View File

@@ -0,0 +1,168 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from '../../panels/utils/legendVisibilityUtils';
import { PieSlice } from '../types';
export interface UsePieInteractionsResult {
/** The hovered/focused slice (drives donut dimming + tooltip). */
active: PieSlice | null;
setActive: Dispatch<SetStateAction<PieSlice | null>>;
/** Slices currently shown (hidden ones removed). */
visibleData: PieSlice[];
/** Legend item per slice (`show` reflects hide state). */
legendItems: LegendItem[];
/** Index of the active slice for the legend's focus highlight, or null. */
focusedSeriesIndex: number | null;
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
}
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
// event target (the shared Legend tags each item with its seriesIndex).
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
'[data-legend-item-id]',
);
const id = el?.dataset.legendItemId;
return id != null ? Number(id) : null;
}
/**
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
* uPlot legend — marker toggles one, label isolates), and persistence of the
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
* reloads. Returns the visible slices, legend items, focus index, and the
* legend container handlers.
*/
export function usePieInteractions(
data: PieSlice[],
id?: string,
): UsePieInteractionsResult {
const [active, setActive] = useState<PieSlice | null>(null);
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const isolatedIndexRef = useRef<number | null>(null);
const legendItems = useMemo<LegendItem[]>(
() =>
data.map((slice, index) => ({
seriesIndex: index,
label: slice.label,
color: slice.color,
show: !hiddenIndices.has(index),
})),
[data, hiddenIndices],
);
// Hidden slices drop out so the remaining arcs + centre total recompute.
const visibleData = useMemo(
() => data.filter((_, index) => !hiddenIndices.has(index)),
[data, hiddenIndices],
);
// Rehydrate hide/unhide from localStorage (matched by label) whenever the
// data set changes — including first load and every refetch, since the store
// is the source of truth and toggles write back to it.
useEffect(() => {
if (!id || !data.length) {
return;
}
const stored = getStoredSeriesVisibility(id);
if (!stored) {
return;
}
const hidden = new Set<number>();
data.forEach((slice, index) => {
if (stored.find((s) => s.label === slice.label)?.show === false) {
hidden.add(index);
}
});
setHiddenIndices(hidden);
}, [id, data]);
// Apply a new hidden set and persist it (label + show) to localStorage.
const applyHidden = useCallback(
(hidden: Set<number>): void => {
setHiddenIndices(hidden);
if (id) {
updateSeriesVisibilityToLocalStorage(
id,
data.map((slice, index) => ({
label: slice.label,
show: !hidden.has(index),
})),
);
}
},
[id, data],
);
const onLegendMouseMove = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
// Don't focus/dim for hidden slices — they aren't on the donut.
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
},
[data, hiddenIndices],
);
// Marker click toggles just that slice on/off; label click isolates it
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
const onLegendClick = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
if (index == null) {
return;
}
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
if (isMarker) {
const next = new Set(hiddenIndices);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
applyHidden(next);
return;
}
const isReset = isolatedIndexRef.current === index;
isolatedIndexRef.current = isReset ? null : index;
if (isReset) {
applyHidden(new Set());
return;
}
const next = new Set<number>();
data.forEach((_, i) => {
if (i !== index) {
next.add(i);
}
});
applyHidden(next);
},
[data, hiddenIndices, applyHidden],
);
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
const focusedIndex = active ? data.indexOf(active) : -1;
return {
active,
setActive,
visibleData,
legendItems,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -0,0 +1,110 @@
/**
* Pure presentation helpers for the Pie chart. Kept out of the component file
* so the renderer stays declarative (per the one-component-per-file rule).
*/
interface ScaledFontSizeArgs {
text: string;
baseSize: number;
innerRadius: number;
}
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
* the donut hole. Ported from the V1 PiePanelWrapper.
*/
export function getScaledFontSize({
text,
baseSize,
innerRadius,
}: ScaledFontSizeArgs): number {
if (!text) {
return baseSize;
}
const { length } = text;
// More aggressive scaling for very long numbers.
const scaleFactor = Math.max(0.3, 1 - (length - 3) * 0.09);
// Don't use more than 90% of the inner radius.
const maxSize = innerRadius * 0.9;
return Math.min(baseSize * scaleFactor, maxSize);
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;
labelY: number;
/** Elbow point where the leader line bends toward the label. */
lineEndX: number;
lineEndY: number;
/** Anchor the label left/right depending on which half of the circle it's in. */
textAnchor: 'start' | 'end';
}
/**
* Computes the leader-line / label geometry for one arc from its angular span.
* Pulled out of the render prop so the SVG markup stays declarative.
*/
export function getArcGeometry(
startAngle: number,
endAngle: number,
radius: number,
): ArcGeometry {
const angle = (startAngle + endAngle) / 2;
const labelRadius = radius * 1.3;
const lineEndRadius = radius * 1.1;
return {
labelX: Math.sin(angle) * labelRadius,
labelY: -Math.cos(angle) * labelRadius,
lineEndX: Math.sin(angle) * lineEndRadius,
lineEndY: -Math.cos(angle) * lineEndRadius,
textAnchor: Math.sin(angle) > 0 ? 'start' : 'end',
};
}
interface ParsedRgb {
r: number;
g: number;
b: number;
}
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
// already-rgba string), letting callers fall back to the original colour.
function hexToRgb(color: string): ParsedRgb | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Returns an rgba() string for `color` at the given opacity. Used to dim the
* non-hovered slices. Falls back to the original colour if it can't be parsed.
*/
export function lightenColor(color: string, opacity: number): string {
const rgb = hexToRgb(color);
if (!rgb) {
return color;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
}
/**
* Resolves the fill for a slice given the currently-hovered slice colour:
* everything but the active slice dims to 40% opacity. With nothing hovered
* (`activeColor === null`) every slice keeps its full colour.
*/
export function getFillColor(
color: string,
activeColor: string | null,
): string {
if (activeColor === null) {
return color;
}
return activeColor === color ? color : lightenColor(color, 0.4);
}

View File

@@ -3,13 +3,14 @@ import { PrecisionOption } from 'components/Graph/types';
import {
IRenderTooltipFooterArgs,
LegendConfig,
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
TooltipClickData,
ChartClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -22,10 +23,10 @@ interface BaseChartProps {
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
onClick?: (clickData: ChartClickData) => void;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
pinnedTooltipElement?: (clickData: ChartClickData) => React.ReactNode;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
@@ -69,3 +70,36 @@ export type ChartProps =
| TimeSeriesChartProps
| BarChartProps
| HistogramChartProps;
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.
*/
export interface PieSlice {
label: string;
value: number;
color: string;
}
/**
* Props for the Pie chart. Unlike the others above, Pie is NOT uPlot-based
* (it renders with @visx), so it deliberately does not extend BaseChartProps /
* UPlotBasedChartProps — it takes pre-resolved slices and self-measures its
* draw area rather than receiving a uPlot config + aligned data.
*/
export interface PieChartProps {
data: PieSlice[];
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
isDarkMode: boolean;
/** Legend placement. Drives the chart-vs-legend layout. Default BOTTOM. */
position?: LegendPosition;
/**
* Widget id used to persist per-slice hide/unhide state to localStorage
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
*/
id?: string;
/** Fired when a slice (or its legend entry) is clicked. */
onSliceClick?: (slice: PieSlice) => void;
'data-testid'?: string;
}

View File

@@ -1,16 +1,10 @@
import { logEventMock } from '__tests__/logEventMock';
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { render, screen, userEvent } from 'tests/test-utils';
import TooltipFooter from '../TooltipFooter';
const mockLogEvent = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
describe('TooltipFooter', () => {
const defaultProps = {
id: 'panel-123',
@@ -84,7 +78,7 @@ describe('TooltipFooter', () => {
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);

View File

@@ -1,7 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
@@ -67,20 +66,6 @@ function GridCardGraph({
const [errorMessage, setErrorMessage] = useState<string>();
const [isInternalServerError, setIsInternalServerError] =
useState<boolean>(false);
const queryRangeCalledRef = useRef(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (!queryRangeCalledRef.current) {
Sentry.captureEvent({
message: `Dashboard query range not called within expected timeframe for widget ${widget?.id}`,
level: 'warning',
});
}
}, 120000);
return (): void => clearTimeout(timeoutId);
}, [widget?.id]);
const {
minTime,
maxTime,
@@ -271,14 +256,12 @@ function GridCardGraph({
});
}
}
queryRangeCalledRef.current = true;
},
onSettled: (data) => {
dataAvailable?.(
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
);
getGraphData?.(data?.payload?.data);
queryRangeCalledRef.current = true;
},
},
);

View File

@@ -89,7 +89,7 @@ export function AlertsEmptyState({
onClick={onClickNewAlertHandler}
disabled={!addNewAlert}
loading={loading}
data-testid="add-alert"
testId="add-alert"
>
<span className={styles.buttonContent}>
<Plus size="md" />
@@ -97,7 +97,12 @@ export function AlertsEmptyState({
</span>
</Button>
{onRefresh && (
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
<Button
onClick={onRefresh}
prefix={<RefreshCw />}
color="secondary"
testId="list-alerts-empty-refresh-button"
>
Refresh
</Button>
)}

View File

@@ -0,0 +1,215 @@
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { findAlertRow, renderListAlertRules } from './_helpers';
async function openActionsMenu(row: HTMLElement): Promise<void> {
const trigger = row.querySelector(
'[data-testid="alert-actions"]',
) as HTMLElement | null;
expect(trigger).not.toBeNull();
const user = userEvent.setup({ delay: null });
await user.click(trigger as HTMLElement);
// Radix renders the menu items in a portal once the trigger is activated.
await screen.findByRole('menu');
}
async function clickMenuItem(label: string): Promise<void> {
const user = userEvent.setup({ delay: null });
const item = await screen.findByRole('menuitem', { name: label });
await user.click(item);
}
describe('ListAlertRules — actions menu', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders Enable/Disable/Edit/Edit in New Tab/Clone/Delete items after opening the menu', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
await openActionsMenu(row);
const items = screen.getAllByRole('menuitem');
const labels = items.map((it) => it.textContent);
expect(labels).toStrictEqual(
expect.arrayContaining([
'Edit',
'Edit in New Tab',
'Clone',
'Delete',
'Disable',
]),
);
});
it('disabled rule (rule-4) shows "Enable" instead of "Disable"', async () => {
renderListAlertRules();
const row = await findAlertRow('Disabled Alert');
await openActionsMenu(row);
const items = screen.getAllByRole('menuitem');
const labels = items.map((it) => it.textContent);
expect(labels).toContain('Enable');
expect(labels).not.toContain('Disable');
});
it('toggle action: clicking Disable sends PATCH with disabled:true', async () => {
let capturedBody: unknown = null;
let capturedPath: string | null = null;
server.use(
rest.patch('http://localhost/api/v2/rules/:id', async (req, res, ctx) => {
capturedBody = await req.json();
capturedPath = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Disable');
await waitFor(() => {
expect(capturedBody).toStrictEqual(
expect.objectContaining({ disabled: true }),
);
});
expect(capturedPath).toBe('rule-1');
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
);
});
it('edit action: clicking Edit navigates via safeNavigate and logs event', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Edit');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock.mock.calls[0][0]).toContain('ruleId=rule-1');
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Edit', ruleId: 'rule-1' }),
);
});
it('edit in new tab action: clicking opens with newTab:true', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Edit in New Tab');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url, options] = safeNavigateMock.mock.calls[0];
expect(url).toContain('ruleId=rule-1');
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
server.use(
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
});
// The id from the server response round-trips into the navigate URL — this
// protects against a regression where the code hardcodes the id.
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock.mock.calls[0][0]).toContain(
'ruleId=cloned-from-server',
);
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Clone', ruleId: 'rule-1' }),
);
});
it('delete action: sends DELETE for the rule id', async () => {
let deletedId: string | null = null;
server.use(
rest.delete('http://localhost/api/v2/rules/:id', (req, res, ctx) => {
deletedId = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Delete');
await waitFor(() => {
expect(deletedId).toBe('rule-1');
});
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Delete', ruleId: 'rule-1' }),
);
});
it('error path: PATCH is still attempted when server returns 500', async () => {
let patchAttempted = false;
server.use(
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) => {
patchAttempted = true;
return res(ctx.status(500), ctx.json({ status: 'error' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Disable');
await waitFor(() => {
expect(patchAttempted).toBe(true);
});
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
);
});
});

View File

@@ -0,0 +1,79 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
const COLUMN_STORAGE_KEY = '@signoz/table-columns/alert-rules-columns';
describe('ListAlertRules — columns selector', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('opens columns popover and lists toggleable columns', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByTestId('alert-columns-button'));
// Popover should reveal "Toggle Columns" heading + per-column labels.
await screen.findByText('Toggle Columns');
expect(screen.getByText('Created At')).toBeInTheDocument();
expect(screen.getByText('Created By')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
expect(screen.getByText('Updated By')).toBeInTheDocument();
});
it('default-hidden columns (Created At/By, Updated At/By) are not in the table header', async () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
const headers = document.querySelectorAll('th');
const headerTexts = Array.from(headers).map((h) => h.textContent || '');
expect(headerTexts.some((t) => t.includes('Created At'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Created By'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Updated At'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Updated By'))).toBe(false);
});
it('toggling Created At on writes to localStorage and adds the header', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
const headersBefore = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headersBefore.some((t) => t.includes('Created At'))).toBe(false);
await user.click(screen.getByTestId('alert-columns-button'));
await screen.findByText('Toggle Columns');
const checkbox = document.getElementById('col-createdAt');
expect(checkbox).not.toBeNull();
await user.click(checkbox as HTMLElement);
await waitFor(() => {
const stored = window.localStorage.getItem(COLUMN_STORAGE_KEY);
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored as string);
expect(parsed.hiddenColumnIds).not.toContain('createdAt');
});
await waitFor(() => {
const headersAfter = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headersAfter.some((t) => t.includes('Created At'))).toBe(true);
});
});
});

View File

@@ -0,0 +1,91 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — empty states', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders AlertsEmptyState when API returns no rules', async () => {
const user = userEvent.setup({ delay: null });
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
),
);
renderListAlertRules();
await screen.findByText('No Alert rules yet.');
expect(
screen.getByText('Create an Alert Rule to get started'),
).toBeInTheDocument();
// New Alert Rule button is visible and triggers safeNavigate to ALERTS_NEW.
await user.click(screen.getByTestId('add-alert'));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: false }),
);
});
it('renders ErrorEmptyState when API returns 500; refresh triggers a refetch', async () => {
const user = userEvent.setup({ delay: null });
let callCount = 0;
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
callCount += 1;
if (callCount === 1) {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({ data: alertRulesFixture, status: 'success' }),
);
}),
);
renderListAlertRules();
await screen.findByTestId('error-empty-state');
await user.click(screen.getByTestId('error-refresh-button'));
const rule = await screen.findByText('High CPU Alert');
expect(rule).toBeInTheDocument();
});
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
const searchInput = screen.getByTestId('list-alerts-search-input');
await user.clear(searchInput);
await user.type(searchInput, 'totally-not-found');
await screen.findByTestId('no-results-empty-state');
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
'No alert rules match your search. Try adjusting your search criteria.',
);
await user.click(screen.getByTestId('no-results-clear-button'));
const rule = await screen.findByText('High CPU Alert');
expect(rule).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,123 @@
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — list rendering', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders alert rules from API', async () => {
renderListAlertRules();
await expect(
screen.findByTestId('alert-row-rule-1-name'),
).resolves.toHaveTextContent('High CPU Alert');
expect(screen.getByTestId('alert-row-rule-2-name')).toHaveTextContent(
'Memory Pending Alert',
);
expect(screen.getByTestId('alert-row-rule-3-name')).toHaveTextContent(
'Healthy Alert',
);
expect(screen.getByTestId('alert-row-rule-4-name')).toHaveTextContent(
'Disabled Alert',
);
});
it('renders state badges via STATE_CONFIG mapping', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveTextContent(
'Firing',
);
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveTextContent(
'Pending',
);
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveTextContent('OK');
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveTextContent(
'Disabled',
);
expect(screen.getByTestId('alert-row-rule-5-state')).toHaveTextContent('OK');
});
it('renders state badges with semantic colors', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveAttribute(
'data-color',
'cherry',
);
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveAttribute(
'data-color',
'amber',
);
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveAttribute(
'data-color',
'forest',
);
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveAttribute(
'data-color',
'vanilla',
);
});
it('renders severity badges for rules with severity', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-severity')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveTextContent(
'critical',
);
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveTextContent(
'warning',
);
expect(screen.getByTestId('alert-row-rule-3-severity')).toHaveTextContent(
'info',
);
expect(screen.getByTestId('alert-row-rule-4-severity')).toHaveTextContent(
'critical',
);
expect(screen.getByTestId('alert-row-rule-5-severity')).toHaveTextContent(
'-',
);
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveAttribute(
'data-color',
'cherry',
);
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveAttribute(
'data-color',
'amber',
);
});
it('renders header controls (search, columns, new alert)', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
);
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
).toBeInTheDocument();
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,65 @@
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — new alert button', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('plain click navigates to ALERTS_NEW with newTab:false', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: false }),
);
});
it('logs Alert: New alert button clicked', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await waitFor(() => {
expect(logEventMock).toHaveBeenCalledWith(
'Alert: New alert button clicked',
expect.objectContaining({ layout: 'new' }),
);
});
});
it('ctrl+click on New Alert opens in a new tab (newTab:true)', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.keyboard('{Control>}');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await user.keyboard('{/Control}');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: true }),
);
});
});

View File

@@ -0,0 +1,64 @@
import userEvent from '@testing-library/user-event';
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — pagination', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
),
),
);
});
it('shows first 10 rows on page 1 (default limit)', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
for (let i = 0; i < 10; i += 1) {
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
}
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
});
it('shows total count when showTotalCount is enabled', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const totalCount = await screen.findByTestId('pagination-total-count');
expect(totalCount.textContent).toContain('Showing');
expect(totalCount.textContent).toContain('of 15');
});
it('navigates to page 2 and shows remaining rows', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const nextBtn = screen.getByLabelText('Go to next page');
await user.click(nextBtn);
await waitFor(() => {
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
expect(screen.getByText('Pag Rule 14')).toBeInTheDocument();
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(getCurrentNuqsQueryString()).toContain('page=2');
});
});
});

View File

@@ -0,0 +1,71 @@
import { screen, waitFor } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — permissions', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('VIEWER role hides "New Alert" button and "Actions" column', async () => {
renderListAlertRules({ role: USER_ROLES.VIEWER });
await screen.findByText('High CPU Alert');
expect(
screen.queryByTestId('list-alerts-new-alert-button'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /new alert/i }),
).not.toBeInTheDocument();
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(false);
expect(screen.queryByTestId('alert-actions')).not.toBeInTheDocument();
});
it('ADMIN role shows "New Alert" button and "Actions" column', async () => {
renderListAlertRules({ role: USER_ROLES.ADMIN });
await screen.findByText('High CPU Alert');
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
await waitFor(() => {
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
});
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
});
it('EDITOR role behaves like ADMIN (New Alert + Actions visible)', async () => {
renderListAlertRules({ role: USER_ROLES.EDITOR });
await screen.findByText('High CPU Alert');
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
await waitFor(() => {
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
});
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,52 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — row click navigation', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
const ruleCell = await screen.findByText('High CPU Alert');
const td = ruleCell.closest('td');
expect(td).not.toBeNull();
await user.click(td as HTMLElement);
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url] = safeNavigateMock.mock.calls[0];
expect(url).toContain('/alerts/overview?');
expect(url).toContain('ruleId=rule-1');
expect(url).toContain('panelTypes=graph');
expect(url).toContain('compositeQuery=');
});
it('ctrl+click on a row navigates with newTab option', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
const ruleCell = await screen.findByText('High CPU Alert');
const td = ruleCell.closest('td');
await user.keyboard('{Control>}');
await user.click(td as HTMLElement);
await user.keyboard('{/Control}');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url, options] = safeNavigateMock.mock.calls[0];
expect(url).toContain('ruleId=rule-1');
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
});

View File

@@ -0,0 +1,99 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
function getSearchInput(): HTMLInputElement {
return screen.getByTestId('list-alerts-search-input') as HTMLInputElement;
}
describe('ListAlertRules — search', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('filters rows by alert name with debounce', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'CPU');
await waitFor(() => {
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
});
});
it('filters rows by label values (severity)', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'warning');
await waitFor(() => {
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
});
});
it('restores all rows when search is cleared', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'CPU');
await waitFor(() => {
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
});
await user.clear(getSearchInput());
await waitFor(() => {
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
expect(screen.getByText('Healthy Alert')).toBeInTheDocument();
});
});
it('shows no-results state when no match', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'zzzzzz-no-match');
await waitFor(() => {
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
});
});
it('resets page to 1 when search debounce fires', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules({ initialRoute: '/?page=2' });
// Page 2 of the 4-rule fixture has no rows; we only need the search input
// to be mounted, which happens before data is fetched.
const input = await screen.findByTestId('list-alerts-search-input');
await user.clear(input);
await user.type(input, 'CPU');
await waitFor(() => {
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
});
});
});

View File

@@ -0,0 +1,232 @@
import { logEventMock } from '__tests__/logEventMock';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView/types';
import type { AlertRule } from '../types';
import {
ALERT_ACTIONS,
alertActionLogEvent,
filterRulesByFilters,
getAlertSortValue,
sortRules,
} from '../utils';
const baseRule = {
id: 'r1',
alert: 'Rule 1',
alertType: 'METRIC_BASED_ALERT',
state: 'inactive',
labels: { severity: 'info' },
condition: {},
createdAt: '2023-10-15T10:00:00Z',
updatedAt: '2023-10-19T10:00:00Z',
} as unknown as AlertRule;
const makeRule = (overrides: Partial<AlertRule>): AlertRule => ({
...baseRule,
...overrides,
});
describe('getAlertSortValue', () => {
it('returns state for "state"', () => {
expect(
getAlertSortValue(
makeRule({ state: RuletypesAlertStateDTO.firing }),
'state',
),
).toBe('firing');
});
it('returns alert name for "name"', () => {
expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe(
'My Rule',
);
});
it('returns severity label for "severity"', () => {
expect(
getAlertSortValue(
makeRule({ labels: { severity: 'critical' } }),
'severity',
),
).toBe('critical');
});
it('returns createdAt as ms', () => {
const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' });
const result = getAlertSortValue(rule, 'createdAt');
expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime());
});
it('returns updatedAt as ms', () => {
const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' });
const result = getAlertSortValue(rule, 'updatedAt');
expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime());
});
it('returns 0 when createdAt missing', () => {
expect(
getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'),
).toBe(0);
});
it('returns empty for unknown column', () => {
expect(getAlertSortValue(baseRule, 'xxx')).toBe('');
});
it('returns empty for missing fields', () => {
expect(
getAlertSortValue(
makeRule({ state: undefined, labels: undefined }),
'state',
),
).toBe('');
expect(
getAlertSortValue(
makeRule({ state: undefined, labels: undefined }),
'severity',
),
).toBe('');
});
});
describe('sortRules', () => {
const r1 = makeRule({ id: '1', alert: 'A' });
const r2 = makeRule({ id: '2', alert: 'B' });
const r3 = makeRule({ id: '3', alert: 'C' });
it('sorts ascending by name', () => {
const order: SortState = { columnName: 'name', order: 'asc' };
const result = sortRules([r3, r1, r2], order);
expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']);
});
it('sorts descending by name', () => {
const order: SortState = { columnName: 'name', order: 'desc' };
const result = sortRules([r1, r2, r3], order);
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']);
});
it('returns unsorted when orderBy is null', () => {
const result = sortRules([r3, r1, r2], null);
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']);
});
});
describe('filterRulesByFilters', () => {
const r1 = makeRule({
id: '1',
alert: 'R1',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'critical' },
});
const r2 = makeRule({
id: '2',
alert: 'R2',
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'warning' },
});
const r3 = makeRule({
id: '3',
alert: 'R3',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'warning' },
});
const rules = [r1, r2, r3];
it('returns input when filters empty', () => {
expect(filterRulesByFilters(rules, [])).toStrictEqual(rules);
});
it('filters by state', () => {
const result = filterRulesByFilters(rules, ['state:firing']);
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
});
it('filters by severity', () => {
const result = filterRulesByFilters(rules, ['severity:warning']);
expect(result.map((r) => r.id)).toStrictEqual(['2', '3']);
});
it('combines state AND severity', () => {
const result = filterRulesByFilters(rules, [
'state:firing',
'severity:warning',
]);
expect(result.map((r) => r.id)).toStrictEqual(['3']);
});
it('OR within same key (state)', () => {
const result = filterRulesByFilters(rules, [
'state:firing',
'state:inactive',
]);
expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']);
});
it('matches values case-insensitively', () => {
const result = filterRulesByFilters(rules, ['state:FIRING']);
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
});
it('ignores prefixes with wrong case (state: is required lowercase)', () => {
const result = filterRulesByFilters(rules, ['STATE:FIRING']);
expect(result).toStrictEqual(rules);
});
it('returns empty when no rule matches', () => {
expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]);
});
it('ignores unknown prefix', () => {
expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules);
});
});
describe('alertActionLogEvent', () => {
it('logs with mapped action label', () => {
const rule = makeRule({
id: 'rule-1',
alert: 'My Rule',
alertType: 'METRIC_BASED_ALERT' as AlertRule['alertType'],
});
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
expect(logEventMock).toHaveBeenCalledWith('Alert: Action', {
ruleId: 'rule-1',
dataSource: expect.any(String),
name: 'My Rule',
action: 'Edit',
});
});
it('falls back to raw action when unmapped', () => {
alertActionLogEvent('custom', baseRule);
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'custom' }),
);
});
it('maps TOGGLE action', () => {
alertActionLogEvent(ALERT_ACTIONS.TOGGLE, baseRule);
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Enable/Disable' }),
);
});
it('maps DELETE and CLONE', () => {
alertActionLogEvent(ALERT_ACTIONS.DELETE, baseRule);
alertActionLogEvent(ALERT_ACTIONS.CLONE, baseRule);
expect(logEventMock).toHaveBeenNthCalledWith(
1,
'Alert: Action',
expect.objectContaining({ action: 'Delete' }),
);
expect(logEventMock).toHaveBeenNthCalledWith(
2,
'Alert: Action',
expect.objectContaining({ action: 'Clone' }),
);
});
});

View File

@@ -0,0 +1,65 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, RenderResult, screen } from '@testing-library/react';
import ListAlertRules from 'container/ListAlertRules';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { AppContext } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
import { getAppContextMock } from 'tests/test-utils';
interface RenderOptions {
role?: string;
initialRoute?: string;
}
export function renderListAlertRules(
options: RenderOptions = {},
): RenderResult {
const { role = 'ADMIN', initialRoute = '/' } = options;
const initialSearch = initialRoute.includes('?')
? initialRoute.slice(initialRoute.indexOf('?'))
: '';
resetNuqsState(initialSearch);
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
mutations: { retry: false },
},
});
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<NuqsTestingAdapter
searchParams={initialSearch}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
hasMemory
>
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={getAppContextMock(role)}>
<TimezoneProvider>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 800, itemHeight: 46 }}
>
<ListAlertRules />
</VirtuosoMockContext.Provider>
</TimezoneProvider>
</AppContext.Provider>
</QueryClientProvider>
</NuqsTestingAdapter>
</MemoryRouter>,
);
}
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
const row = cell.closest('tr');
if (!row) {
throw new Error(`Row not found for alert "${alertName}"`);
}
return row as HTMLElement;
}

View File

@@ -47,6 +47,7 @@ function ColumnSelector<TData>({
size="sm"
color="secondary"
prefix={<Columns3 size={14} />}
data-testid="alert-columns-button"
>
Columns
</Button>

View File

@@ -136,6 +136,7 @@ function ListAlertRules(): JSX.Element {
prefix={<Plus size={14} />}
onClick={handleNewAlert}
color="primary"
testId="list-alerts-new-alert-button"
>
New Alert
</Button>
@@ -157,6 +158,7 @@ function ListAlertRules(): JSX.Element {
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="list-alerts-search-input"
/>
</div>
)}

View File

@@ -26,14 +26,18 @@ export function getAlertRuleColumns(
enableSort: true,
enableRemove: false,
enableMove: false,
cell: ({ value }): JSX.Element => {
cell: ({ row, value }): JSX.Element => {
const state = String(value ?? '').toLowerCase();
const config = STATE_CONFIG[state] ?? {
color: 'secondary' as BadgeColor,
label: 'Unknown',
};
return (
<Badge color={config.color} variant="outline">
<Badge
color={config.color}
variant="outline"
testId={`alert-row-${row.id ?? ''}-state`}
>
{config.label}
</Badge>
);
@@ -47,8 +51,11 @@ export function getAlertRuleColumns(
enableSort: true,
enableRemove: false,
enableMove: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text title={value}>
cell: ({ row, value }): JSX.Element => (
<TanStackTable.Text
title={value}
data-testid={`alert-row-${row.id ?? ''}-name`}
>
{String(value ?? '-')}
</TanStackTable.Text>
),
@@ -60,15 +67,20 @@ export function getAlertRuleColumns(
width: { fixed: '120px' },
enableSort: true,
enableMove: false,
cell: ({ value }): JSX.Element => {
cell: ({ row, value }): JSX.Element => {
const severity = String(value ?? '').toLowerCase();
if (!severity) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return (
<TanStackTable.Text data-testid={`alert-row-${row.id ?? ''}-severity`}>
-
</TanStackTable.Text>
);
}
return (
<Badge
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
variant="outline"
testId={`alert-row-${row.id ?? ''}-severity`}
>
{severity}
</Badge>

View File

@@ -10,7 +10,7 @@ import {
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
@@ -42,7 +42,6 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { isEmpty, isUndefined } from 'lodash-es';
import LiveLogs from 'pages/LiveLogs';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
@@ -77,7 +76,6 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const dispatch = useDispatch();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -90,10 +88,9 @@ function LogsExplorerViewsContainer({
DEFAULT_PER_PAGE_VALUE,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const currentMinTimeRef = useRef<number>(minTime);
@@ -329,16 +326,6 @@ function LogsExplorerViewsContainer({
currentMinTimeRef.current !== minTime ||
orderByChanged
) {
// Recalculate global time when query changes i.e. stage and run query clicked
if (
!!requestData?.id &&
stagedQuery?.id &&
requestData?.id !== stagedQuery?.id &&
selectedTime !== 'custom'
) {
dispatch(UpdateTimeInterval(selectedTime));
}
const newRequestData = getRequestData(stagedQuery, {
filters: listQuery?.filters || initialFilters,
filter: listQuery?.filter || { expression: '' },
@@ -360,8 +347,6 @@ function LogsExplorerViewsContainer({
minTime,
activeLogId,
selectedPanelType,
dispatch,
selectedTime,
maxTime,
orderBy,
]);

View File

@@ -108,13 +108,21 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock(
'container/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => {
const { useQueryBuilder } = jest.requireActual(
'hooks/queryBuilder/useQueryBuilder',
);
const { useSyncTimeOnStagedQueryChange } = jest.requireActual(
'hooks/queryBuilder/useSyncTimeOnStagedQueryChange',
);
return function MockDateTimeSelection(): JSX.Element {
const { stagedQuery } = useQueryBuilder();
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
return <div>MockDateTimeSelection</div>;
};
});
jest.mock(
'container/LogsExplorerChart',
() =>

View File

@@ -1,8 +1,8 @@
import { logEventMock } from '__tests__/logEventMock';
import { render, screen, userEvent } from 'tests/test-utils';
import MCPServerSettings from './MCPServerSettings';
const mockLogEvent = jest.fn();
const mockCopyToClipboard = jest.fn();
const mockHistoryPush = jest.fn();
const mockUseGetGlobalConfig = jest.fn();
@@ -11,11 +11,6 @@ const mockUseGetTenantLicense = jest.fn();
const mockToastSuccess = jest.fn();
const mockToastWarning = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
jest.mock('api/generated/services/global', () => ({
useGetGlobalConfig: (...args: unknown[]): unknown =>
mockUseGetGlobalConfig(...args),
@@ -148,7 +143,7 @@ describe('MCPServerSettings', () => {
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
expect(mockLogEvent).toHaveBeenCalledWith('MCP Settings: Page viewed', {
expect(logEventMock).toHaveBeenCalledWith('MCP Settings: Page viewed', {
role: 'ADMIN',
});
});

View File

@@ -1,5 +1,6 @@
import userEvent from '@testing-library/user-event';
import MySettingsContainer from 'container/MySettings';
import { logEventMock } from '__tests__/logEventMock';
import {
act,
fireEvent,
@@ -12,7 +13,6 @@ import APIError from 'types/api/error';
import { toast } from '@signozhq/ui/sonner';
const toggleThemeFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
const updateMyPasswordFn = jest.fn();
@@ -62,11 +62,6 @@ jest.mock('hooks/useDarkMode', () => ({
})),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn((eventName, data) => logEventFunction(eventName, data)),
}));
const errorNotification = jest.fn();
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
@@ -135,7 +130,7 @@ describe('MySettings Flows', () => {
await waitFor(() => {
expect(toggleThemeFunction).toHaveBeenCalled();
expect(logEventFunction).toHaveBeenCalledWith(
expect(logEventMock).toHaveBeenCalledWith(
'Account Settings: Theme Changed',
{
theme: 'light',

View File

@@ -9,11 +9,6 @@ import {
import InviteTeamMembers from '../InviteTeamMembers';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
(args: { message: string }) => void
>;

View File

@@ -4,11 +4,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import OnboardingQuestionaire from '../index';
// Mock dependencies
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('lib/history', () => ({
__esModule: true,
default: {

View File

@@ -4,15 +4,13 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { logEventMock } from '__tests__/logEventMock';
import i18n from 'ReactI18';
import store from 'store';
import CreatePipelineButton from '../Layouts/Pipeline/CreatePipelineButton';
import { pipelineApiResponseMockData } from '../mocks/pipeline';
jest.mock('api/common/logEvent');
describe('PipelinePage container test', () => {
it('should render CreatePipelineButton section', async () => {
const { asFragment } = render(
@@ -53,9 +51,12 @@ describe('PipelinePage container test', () => {
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
expect(logEvent).toHaveBeenCalledWith('Logs: Pipelines: Entered Edit Mode', {
source: 'signoz-ui',
});
expect(logEventMock).toHaveBeenCalledWith(
'Logs: Pipelines: Entered Edit Mode',
{
source: 'signoz-ui',
},
);
});
it('CreatePipelineButton - add new mode & tracking', async () => {
@@ -78,7 +79,7 @@ describe('PipelinePage container test', () => {
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
expect(logEvent).toHaveBeenCalledWith(
expect(logEventMock).toHaveBeenCalledWith(
'Logs: Pipelines: Clicked Add New Pipeline',
{
source: 'signoz-ui',

View File

@@ -233,8 +233,10 @@
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
padding: 0px;
gap: 0;
margin: 4px;
.ant-typography {
.qb-tag-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px !important;
@@ -244,7 +246,7 @@
padding: 2px 6px;
}
.close-icon {
> button {
display: flex;
align-items: center;
justify-content: center;
@@ -259,26 +261,26 @@
&.resource {
border: 1px solid color-mix(in srgb, var(--bg-aqua-400) 13%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-aqua-400);
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
}
}
&.tag {
border: 1px solid color-mix(in srgb, var(--bg-sienna-400) 20%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-sienna-400);
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
}
}
@@ -286,13 +288,13 @@
&.scope {
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-robin-400);
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
}
}

View File

@@ -966,6 +966,7 @@ function QueryBuilderSearchV2(
>
<Tooltip title={chipValue}>
<TypographyText
className="qb-tag-text"
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {

View File

@@ -19,6 +19,7 @@ import {
useIsGlobalTimeQueryRefreshing,
} from 'store/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSyncTimeOnStagedQueryChange } from 'hooks/queryBuilder/useSyncTimeOnStagedQueryChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
@@ -187,6 +188,8 @@ function DateTimeSelection({
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
const getInputLabel = (
startTime?: Dayjs,
endTime?: Dayjs,

View File

@@ -0,0 +1,118 @@
import userEvent from '@testing-library/user-event';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import ROUTES from 'constants/routes';
import { triggeredAlertsFixture } from 'mocks-server/__mockdata__/triggered_alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { renderTriggeredAlerts } from './_helpers';
describe('TriggeredAlerts — empty / error states', () => {
it('shows the "No alerts firing" empty state when the API returns []', async () => {
server.use(
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
),
);
renderTriggeredAlerts();
await screen.findByText('No alerts firing');
expect(
screen.getByTestId('triggered-alerts-empty-create-button'),
).toBeInTheDocument();
expect(
screen.getByTestId('triggered-alerts-empty-refresh-button'),
).toBeInTheDocument();
});
it('navigates to ROUTES.ALERTS_NEW when "Create Alert Rule" is clicked', async () => {
const user = userEvent.setup({ delay: null });
server.use(
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
),
);
renderTriggeredAlerts();
await screen.findByText('No alerts firing');
await user.click(screen.getByTestId('triggered-alerts-empty-create-button'));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: false }),
);
});
it('shows ErrorEmptyState when the API returns 500', async () => {
server.use(
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(ctx.status(500)),
),
);
renderTriggeredAlerts();
await screen.findByTestId('error-empty-state');
expect(screen.getByTestId('error-refresh-button')).toBeInTheDocument();
});
it('refetches on refresh button click after an initial error', async () => {
let callCount = 0;
server.use(
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => {
callCount += 1;
if (callCount === 1) {
return res(ctx.status(500));
}
return res(
ctx.status(200),
ctx.json({ data: triggeredAlertsFixture, status: 'success' }),
);
}),
);
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await screen.findByTestId('error-refresh-button');
await user.click(screen.getByTestId('error-refresh-button'));
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
});
it('shows NoResultsEmptyState when filters yield zero matches', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
const input = screen.getByTestId('triggered-alerts-search-input');
await user.type(input, 'this-matches-nothing-xyz');
await screen.findByTestId('no-results-empty-state');
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alerts',
);
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
'No alerts match your current filters. Try adjusting your search criteria.',
);
await user.click(screen.getByTestId('no-results-clear-button'));
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
expect(
(screen.getByTestId('triggered-alerts-search-input') as HTMLInputElement)
.value,
).toBe('');
});
});

View File

@@ -0,0 +1,87 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderTriggeredAlerts } from './_helpers';
describe('TriggeredAlerts — severity filter', () => {
it('filters to only critical-severity rows when "Critical" is selected', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
const criticalOption = await screen.findByText(
'Critical (severity:critical)',
);
await user.click(criticalOption);
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument();
});
});
it('shows union when multiple severities are selected', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
const critical = await screen.findByText('Critical (severity:critical)');
await user.click(critical);
const warning = await screen.findByText('Warning (severity:warning)');
await user.click(warning);
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument();
});
});
it('clearing the filter shows all rows again', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
const critical = await screen.findByText('Critical (severity:critical)');
await user.click(critical);
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(),
);
// Reopen the filter combobox and deselect Critical (clicking again toggles).
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
const criticalAgain = await screen.findByText('Critical (severity:critical)');
await user.click(criticalAgain);
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
expect(screen.getByText('Network Hiccup')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,85 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderTriggeredAlerts } from './_helpers';
describe('TriggeredAlerts — group by', () => {
it('renders a flat table when no group-by is selected', async () => {
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
// No "Group" column header in flat mode.
expect(screen.queryByText('Group')).not.toBeInTheDocument();
});
it('groups by service when "service" is selected', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
await user.click(screen.getByTestId('triggered-alerts-groupby-combobox'));
const serviceOption = await screen.findByText('service');
await user.click(serviceOption);
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.getByText('Group')).toBeInTheDocument());
await waitFor(() => {
expect(screen.getByText('service:frontend')).toBeInTheDocument();
expect(screen.getByText('service:backend')).toBeInTheDocument();
expect(screen.getByText('service:misc')).toBeInTheDocument();
});
});
it('expands and collapses a group row to reveal nested alerts', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
await user.click(screen.getByTestId('triggered-alerts-groupby-combobox'));
const serviceOption = await screen.findByText('service');
await user.click(serviceOption);
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.getByText('service:frontend')).toBeInTheDocument(),
);
// Nested rows aren't shown yet.
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
// The "frontend" group sits first in the table; its expand toggle is the
// first `group-expand-toggle` in DOM order. Targeting by testid is safe
// against design changes that add other buttons to the row.
const expandToggles = screen.getAllByTestId('group-expand-toggle');
expect(expandToggles.length).toBeGreaterThan(0);
const frontendGroupBadge = screen.getByText('service:frontend');
const frontendRow = frontendGroupBadge.closest('tr');
expect(frontendRow).not.toBeNull();
const frontendToggle = (frontendRow as HTMLElement).querySelector(
'[data-testid="group-expand-toggle"]',
) as HTMLElement | null;
expect(frontendToggle).not.toBeNull();
await user.click(frontendToggle as HTMLElement);
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
await user.click(frontendToggle as HTMLElement);
await waitFor(() =>
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,115 @@
import { screen, waitFor } from 'tests/test-utils';
import { renderTriggeredAlerts } from './_helpers';
describe('TriggeredAlerts — list rendering', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders alerts from the API', async () => {
renderTriggeredAlerts();
await expect(
screen.findByTestId('alert-row-fp-critical-1-name'),
).resolves.toHaveTextContent('High CPU Usage');
expect(screen.getByTestId('alert-row-fp-warning-1-name')).toHaveTextContent(
'Memory Warning',
);
expect(screen.getByTestId('alert-row-fp-info-1-name')).toHaveTextContent(
'Disk Slow',
);
});
it('renders severity badges for alerts that have severity', async () => {
renderTriggeredAlerts();
await waitFor(() =>
expect(
screen.getByTestId('alert-row-fp-critical-1-name'),
).toBeInTheDocument(),
);
expect(
screen.getByTestId('alert-row-fp-critical-1-severity'),
).toHaveTextContent('critical');
expect(
screen.getByTestId('alert-row-fp-warning-1-severity'),
).toHaveTextContent('warning');
expect(screen.getByTestId('alert-row-fp-info-1-severity')).toHaveTextContent(
'info',
);
expect(
screen.getByTestId('alert-row-fp-noseverity-severity'),
).toHaveTextContent('-');
});
it('renders status tags reflecting the alert state', async () => {
renderTriggeredAlerts();
await waitFor(() =>
expect(
screen.getByTestId('alert-row-fp-critical-1-name'),
).toBeInTheDocument(),
);
expect(
screen.getByTestId('alert-row-fp-critical-1-status'),
).toHaveTextContent('Firing');
expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveTextContent(
'Unprocessed',
);
expect(
screen.getByTestId('alert-row-fp-suppressed-1-status'),
).toHaveTextContent('Suppressed');
});
it('renders status badges with semantic colors', async () => {
renderTriggeredAlerts();
await waitFor(() =>
expect(
screen.getByTestId('alert-row-fp-critical-1-status'),
).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-fp-critical-1-status')).toHaveAttribute(
'data-color',
'cherry',
);
expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveAttribute(
'data-color',
'forest',
);
expect(
screen.getByTestId('alert-row-fp-critical-1-severity'),
).toHaveAttribute('data-color', 'cherry');
expect(screen.getByTestId('alert-row-fp-warning-1-severity')).toHaveAttribute(
'data-color',
'amber',
);
});
it('renders the search input and filter comboboxes', async () => {
renderTriggeredAlerts();
await waitFor(() =>
expect(
screen.getByTestId('alert-row-fp-critical-1-name'),
).toBeInTheDocument(),
);
expect(
screen.getByTestId('triggered-alerts-search-input'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search alerts by name'),
).toBeInTheDocument();
expect(
screen.getByTestId('triggered-alerts-filter-combobox'),
).toBeInTheDocument();
expect(
screen.getByTestId('triggered-alerts-groupby-combobox'),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,76 @@
import userEvent from '@testing-library/user-event';
import { triggeredAlertsPaginationFixture } from 'mocks-server/__mockdata__/triggered_alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import { renderTriggeredAlerts } from './_helpers';
function usePaginationHandler(): void {
server.use(
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: triggeredAlertsPaginationFixture,
status: 'success',
}),
),
),
);
}
describe('TriggeredAlerts — pagination', () => {
// Default sort is duration ascending = newest startsAt first. Fixture indices
// 0..14 use startsAt 2023-10-01..15, so index 14 (newest) appears first and
// index 0 (oldest) appears last. Page 1 (limit 10) = items 14..5. Page 2 = 4..0.
it('shows the first 10 rows on page 1 by default', async () => {
usePaginationHandler();
renderTriggeredAlerts();
await screen.findByText('Pag Alert 14');
expect(screen.getByText('Pag Alert 5')).toBeInTheDocument();
expect(screen.queryByText('Pag Alert 4')).not.toBeInTheDocument();
expect(screen.queryByText('Pag Alert 0')).not.toBeInTheDocument();
});
it('renders a "page 2" pagination button reflecting total=15 with pageSize=10', async () => {
usePaginationHandler();
renderTriggeredAlerts();
await screen.findByText('Pag Alert 14');
const nav = screen.getByRole('navigation');
const page2 = Array.from(nav.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === '2',
);
expect(page2).toBeDefined();
});
it('navigates to page 2 and shows the next batch of alerts', async () => {
usePaginationHandler();
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await screen.findByText('Pag Alert 14');
const nav = screen.getByRole('navigation');
const page2Button = Array.from(nav.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === '2',
);
if (!page2Button) {
throw new Error('Page 2 button not found');
}
await user.click(page2Button);
await waitFor(() =>
expect(screen.getByText('Pag Alert 0')).toBeInTheDocument(),
);
expect(screen.getByText('Pag Alert 4')).toBeInTheDocument();
expect(screen.queryByText('Pag Alert 14')).not.toBeInTheDocument();
await waitFor(() => expect(getCurrentNuqsQueryString()).toContain('page=2'));
});
});

View File

@@ -0,0 +1,143 @@
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { getTriggeredAlertRowTestId, renderTriggeredAlerts } from './_helpers';
describe('TriggeredAlerts — row click', () => {
it('navigates to the alert overview with the rule id from labels on row click', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(
screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')),
).toBeInTheDocument(),
);
await user.click(
screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')),
);
expect(safeNavigateMock).toHaveBeenCalledWith(
'/alerts/overview?ruleId=rule-1',
);
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Triggered alert clicked',
expect.objectContaining({
ruleId: 'rule-1',
alertName: 'High CPU Usage',
}),
);
});
it('opens in a new tab when ctrl+clicked', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(
screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')),
).toBeInTheDocument(),
);
await user.keyboard('{Control>}');
await user.click(
screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')),
);
await user.keyboard('{/Control}');
expect(safeNavigateMock).toHaveBeenCalledWith(
'/alerts/overview?ruleId=rule-2',
{ newTab: true },
);
});
it('navigates correctly when ruleId is parsed from generatorURL', async () => {
// Override fixture so the alert has no labels.ruleId but a valid
// generatorURL → getRuleId() falls back to parsing the URL.
server.use(
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: [
{
fingerprint: 'fp-no-rule-label',
startsAt: '2023-10-19T10:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
updatedAt: '2023-10-20T00:00:00Z',
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-from-url',
labels: { alertname: 'URL Rule Alert' },
annotations: {},
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: [],
},
],
status: 'success',
}),
),
),
);
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')),
).toBeInTheDocument(),
);
await user.click(
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')),
);
expect(safeNavigateMock).toHaveBeenCalledWith(
'/alerts/overview?ruleId=rule-from-url',
);
});
it('does not navigate when the row has no ruleId anywhere', async () => {
server.use(
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: [
{
fingerprint: 'fp-no-rule',
startsAt: '2023-10-19T10:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
updatedAt: '2023-10-20T00:00:00Z',
labels: { alertname: 'No Rule Alert' },
annotations: {},
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: [],
},
],
status: 'success',
}),
),
),
);
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')),
).toBeInTheDocument(),
);
await user.click(
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')),
);
expect(safeNavigateMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,118 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderTriggeredAlerts } from './_helpers';
describe('TriggeredAlerts — search', () => {
it('filters rows by alertname when typing in the search input', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
const input = screen.getByTestId('triggered-alerts-search-input');
await user.type(input, 'CPU');
await waitFor(() => {
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
});
});
it('shows all rows again when search is cleared', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
const input = screen.getByTestId('triggered-alerts-search-input');
await user.type(input, 'CPU');
await waitFor(() =>
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(),
);
await user.clear(input);
await waitFor(() => {
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
});
});
it('matches rows by label value (case-insensitive)', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
const input = screen.getByTestId('triggered-alerts-search-input');
// "backend" matches `service: backend` labels on Memory Warning and Network Hiccup.
await user.type(input, 'backend');
await waitFor(() => {
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
expect(screen.getByText('Network Hiccup')).toBeInTheDocument();
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
});
});
it('matches rows by label key', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
const input = screen.getByTestId('triggered-alerts-search-input');
await user.type(input, 'staging');
await waitFor(() => {
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
});
});
it('renders the no-results empty state when the search matches nothing', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts();
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
const input = screen.getByTestId('triggered-alerts-search-input');
await user.type(input, 'zzzzznever-matches-anything');
await waitFor(() =>
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument(),
);
});
it('resets pagination to page 1 when the search changes', async () => {
const user = userEvent.setup({ delay: null });
renderTriggeredAlerts({ initialRoute: '/?page=2&limit=2' });
await waitFor(() =>
expect(screen.getByText('Disk Slow')).toBeInTheDocument(),
);
const input = screen.getByTestId('triggered-alerts-search-input');
await user.type(input, 'CPU');
await waitFor(() =>
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,181 @@
import type { SortState } from 'components/TanStackTableView/types';
import type { Alert } from '../types';
import {
getAlertSortValue,
getRuleId,
normalizeAlerts,
sortAlerts,
} from '../utils';
const alertWithFingerprint: Alert = {
fingerprint: 'fp-existing',
labels: { alertname: 'Test', severity: 'critical' },
annotations: {},
startsAt: '2023-10-19T10:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: [],
};
const alertWithoutFingerprint: Alert = {
...alertWithFingerprint,
fingerprint: undefined,
};
describe('normalizeAlerts', () => {
it('returns empty array when given undefined', () => {
expect(normalizeAlerts(undefined)).toStrictEqual([]);
});
it('preserves existing fingerprints', () => {
const result = normalizeAlerts([alertWithFingerprint]);
expect(result).toHaveLength(1);
expect(result[0].fingerprint).toBe('fp-existing');
});
it('mints fingerprint when missing', () => {
const result = normalizeAlerts([alertWithoutFingerprint]);
expect(result[0].fingerprint).toBeDefined();
expect(typeof result[0].fingerprint).toBe('string');
expect(result[0].fingerprint?.length).toBeGreaterThan(0);
});
it('does not mutate the input array', () => {
const input = [alertWithFingerprint];
const copy = JSON.parse(JSON.stringify(input));
normalizeAlerts(input);
expect(input).toStrictEqual(copy);
});
});
describe('getAlertSortValue', () => {
const alert: Alert = {
fingerprint: 'fp',
labels: { alertname: 'CPU', severity: 'critical' },
annotations: {},
startsAt: '2023-10-19T10:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: [],
};
it('returns status.state for "status"', () => {
expect(getAlertSortValue(alert, 'status')).toBe('active');
});
it('returns alertname for "alertName"', () => {
expect(getAlertSortValue(alert, 'alertName')).toBe('CPU');
});
it('returns severity for "severity"', () => {
expect(getAlertSortValue(alert, 'severity')).toBe('critical');
});
it('returns elapsed ms for "firingSince"', () => {
const result = getAlertSortValue(alert, 'firingSince');
expect(typeof result).toBe('number');
expect(result).toBeGreaterThan(0);
});
it('returns elapsed ms for "duration"', () => {
const result = getAlertSortValue(alert, 'duration');
expect(typeof result).toBe('number');
});
it('returns empty string for unknown columns', () => {
expect(getAlertSortValue(alert, 'unknown')).toBe('');
});
it('returns empty string for missing fields', () => {
const empty = { ...alert, status: undefined, labels: undefined };
expect(getAlertSortValue(empty, 'status')).toBe('');
expect(getAlertSortValue(empty, 'alertName')).toBe('');
expect(getAlertSortValue(empty, 'severity')).toBe('');
});
it('returns empty for firingSince with no startsAt', () => {
const empty = { ...alert, startsAt: undefined };
expect(getAlertSortValue(empty, 'firingSince')).toBe('');
});
});
describe('sortAlerts', () => {
const a: Alert = {
fingerprint: 'a',
labels: { alertname: 'A' },
annotations: {},
startsAt: '2023-10-19T10:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: [],
};
const b: Alert = { ...a, fingerprint: 'b', labels: { alertname: 'B' } };
const c: Alert = { ...a, fingerprint: 'c', labels: { alertname: 'C' } };
it('sorts ascending when given orderBy', () => {
const order: SortState = { columnName: 'alertName', order: 'asc' };
const result = sortAlerts([c, a, b], order);
expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['A', 'B', 'C']);
});
it('sorts descending', () => {
const order: SortState = { columnName: 'alertName', order: 'desc' };
const result = sortAlerts([a, b, c], order);
expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['C', 'B', 'A']);
});
it('falls back to default duration asc when orderBy is null', () => {
const result = sortAlerts([a, b, c], null);
expect(result).toHaveLength(3);
});
});
describe('getRuleId', () => {
const base: Alert = {
labels: {},
annotations: {},
startsAt: '2023-10-19T10:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: [],
fingerprint: 'fp',
};
it('returns labels.ruleId when present', () => {
expect(getRuleId({ ...base, labels: { ruleId: 'rule-1' } })).toBe('rule-1');
});
it('falls back to generatorURL when ruleId label missing', () => {
expect(
getRuleId({
...base,
generatorURL: 'http://localhost/foo?ruleId=rule-42',
}),
).toBe('rule-42');
});
it('prefers labels.ruleId over generatorURL', () => {
expect(
getRuleId({
...base,
labels: { ruleId: 'from-label' },
generatorURL: 'http://localhost/foo?ruleId=from-url',
}),
).toBe('from-label');
});
it('returns null when generatorURL has no ruleId param', () => {
expect(
getRuleId({ ...base, generatorURL: 'http://localhost/foo' }),
).toBeNull();
});
it('returns null when generatorURL is invalid', () => {
expect(getRuleId({ ...base, generatorURL: 'not-a-url' })).toBeNull();
});
it('returns null when no source available', () => {
expect(getRuleId(base)).toBeNull();
});
});

View File

@@ -0,0 +1,71 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, RenderResult, screen } from '@testing-library/react';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { AppContext } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
import { getAppContextMock } from 'tests/test-utils';
interface RenderOptions {
initialRoute?: string;
}
export function renderTriggeredAlerts(
options: RenderOptions = {},
): RenderResult {
const { initialRoute = '/' } = options;
const initialSearch = initialRoute.includes('?')
? initialRoute.slice(initialRoute.indexOf('?'))
: '';
resetNuqsState(initialSearch);
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
mutations: { retry: false },
},
});
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<NuqsTestingAdapter
searchParams={initialSearch}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
hasMemory
>
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={getAppContextMock('ADMIN')}>
<TimezoneProvider>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 800, itemHeight: 46 }}
>
<TriggeredAlerts />
</VirtuosoMockContext.Provider>
</TimezoneProvider>
</AppContext.Provider>
</QueryClientProvider>
</NuqsTestingAdapter>
</MemoryRouter>,
);
}
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
const row = cell.closest('tr');
if (!row) {
throw new Error(`Row not found for alert "${alertName}"`);
}
return row as HTMLElement;
}
export function getTriggeredAlertRowTestId(
fingerprint: string,
column: 'name' | 'severity' | 'status',
): string {
return `alert-row-${fingerprint}-${column}`;
}

View File

@@ -2,31 +2,32 @@ import { Badge } from '@signozhq/ui/badge';
interface AlertStatusTagProps {
state: string;
testId?: string;
}
function AlertStatusTag({ state }: AlertStatusTagProps): JSX.Element {
function AlertStatusTag({ state, testId }: AlertStatusTagProps): JSX.Element {
switch (state) {
case 'unprocessed':
return (
<Badge color="success" variant="outline">
<Badge color="success" variant="outline" testId={testId}>
Unprocessed
</Badge>
);
case 'active':
return (
<Badge color="error" variant="outline">
<Badge color="error" variant="outline" testId={testId}>
Firing
</Badge>
);
case 'suppressed':
return (
<Badge color="error" variant="outline">
<Badge color="error" variant="outline" testId={testId}>
Suppressed
</Badge>
);
default:
return (
<Badge color="secondary" variant="outline">
<Badge color="secondary" variant="outline" testId={testId}>
Unknown
</Badge>
);

View File

@@ -3,6 +3,7 @@ import { CircleCheck, Plus, RefreshCw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import styles from './EmptyStates.module.scss';
@@ -13,9 +14,12 @@ interface EmptyStateProps {
export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const handleCreateAlert = useCallback((): void => {
safeNavigate(ROUTES.ALERTS_NEW);
}, [safeNavigate]);
const handleCreateAlert = useCallback(
(e: React.MouseEvent): void => {
safeNavigate(ROUTES.ALERTS_NEW, { newTab: isModifierKeyPressed(e) });
},
[safeNavigate],
);
return (
<div className={styles.emptyState}>
@@ -30,6 +34,7 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
color="primary"
prefix={<Plus size={14} />}
onClick={handleCreateAlert}
testId="triggered-alerts-empty-create-button"
>
Create Alert Rule
</Button>
@@ -39,6 +44,7 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
testId="triggered-alerts-empty-refresh-button"
>
Refresh
</Button>

View File

@@ -44,6 +44,7 @@ export function GroupTagsCell({
prefix={
localIsExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
testId="group-expand-toggle"
/>
<div className={styles.tagsContainer}>
{tags.map((tag) => (

View File

@@ -175,6 +175,7 @@ function TriggeredAlerts(): JSX.Element {
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="triggered-alerts-search-input"
/>
<ComboboxSimple
className={styles.filterSelect}
@@ -186,6 +187,7 @@ function TriggeredAlerts(): JSX.Element {
allowCreate
items={severyFilters}
maxDisplayedPills={2}
testId="triggered-alerts-filter-combobox"
/>
<ComboboxSimple
className={styles.filterSelect}
@@ -196,6 +198,7 @@ function TriggeredAlerts(): JSX.Element {
items={labelOptions}
multiple
maxDisplayedPills={2}
testId="triggered-alerts-groupby-combobox"
/>
</div>

View File

@@ -21,8 +21,11 @@ export function getAlertColumns(
width: { fixed: '100px' },
enableSort: false,
enableMove: false,
cell: ({ value }): JSX.Element => (
<AlertStatusTag state={String(value ?? '')} />
cell: ({ row, value }): JSX.Element => (
<AlertStatusTag
state={String(value ?? '')}
testId={`alert-row-${row.fingerprint ?? ''}-status`}
/>
),
},
{
@@ -32,8 +35,11 @@ export function getAlertColumns(
width: { default: '100%' },
enableSort: true,
enableMove: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text title={value}>
cell: ({ row, value }): JSX.Element => (
<TanStackTable.Text
title={value}
data-testid={`alert-row-${row.fingerprint ?? ''}-name`}
>
{String(value ?? '-')}
</TanStackTable.Text>
),
@@ -45,15 +51,17 @@ export function getAlertColumns(
width: { fixed: '120px' },
enableSort: true,
enableMove: false,
cell: ({ value }): JSX.Element => {
cell: ({ row, value }): JSX.Element => {
const severity = String(value ?? '').toLowerCase();
const testId = `alert-row-${row.fingerprint ?? ''}-severity`;
if (!severity) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TanStackTable.Text data-testid={testId}>-</TanStackTable.Text>;
}
return (
<Badge
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
variant="outline"
testId={testId}
>
{severity}
</Badge>

View File

@@ -17,11 +17,6 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/dashboard/substitute_vars', () => ({
getSubstituteVars: jest.fn(),
}));

View File

@@ -0,0 +1,139 @@
import { renderHook } from '@testing-library/react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { useSyncTimeOnStagedQueryChange } from '../useSyncTimeOnStagedQueryChange';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
useDispatch: (): jest.Mock => mockDispatch,
useSelector: jest.fn(),
}));
jest.mock('store/actions', () => ({
UpdateTimeInterval: jest.fn((time: string) => ({
type: 'UPDATE_TIME_INTERVAL_THUNK',
payload: time,
})),
}));
const mockedUseSelector = useSelector as jest.Mock;
const mockedUpdateTimeInterval = UpdateTimeInterval as unknown as jest.Mock;
const setSelectedTime = (value: string): void => {
mockedUseSelector.mockImplementation(
(selector: (state: { globalTime: { selectedTime: string } }) => unknown) =>
selector({ globalTime: { selectedTime: value } }),
);
};
describe('useSyncTimeOnStagedQueryChange', () => {
beforeEach(() => {
jest.clearAllMocks();
setSelectedTime('1h');
});
it('does not dispatch on initial mount when stagedQueryId is undefined', () => {
renderHook(() => useSyncTimeOnStagedQueryChange(undefined));
expect(mockDispatch).not.toHaveBeenCalled();
});
it('does not dispatch on initial mount when stagedQueryId is already defined', () => {
renderHook(() => useSyncTimeOnStagedQueryChange('initial-id'));
expect(mockDispatch).not.toHaveBeenCalled();
});
it('does not dispatch when stagedQueryId transitions from undefined to defined (first staged query arriving)', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: undefined as string | undefined } },
);
rerender({ id: 'first-id' });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('dispatches UpdateTimeInterval with current selectedTime when stagedQueryId changes', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
expect(mockDispatch).not.toHaveBeenCalled();
rerender({ id: 'second-id' });
expect(mockedUpdateTimeInterval).toHaveBeenCalledTimes(1);
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('1h');
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'UPDATE_TIME_INTERVAL_THUNK',
payload: '1h',
});
});
it('does not dispatch when selectedTime is "custom" even if stagedQueryId changes', () => {
setSelectedTime('custom');
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
rerender({ id: 'second-id' });
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockedUpdateTimeInterval).not.toHaveBeenCalled();
});
it('does not dispatch when only selectedTime changes (stagedQueryId stable)', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'stable-id' as string | undefined } },
);
setSelectedTime('5m');
rerender({ id: 'stable-id' });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('dispatches once per distinct stagedQueryId change', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'a' as string | undefined } },
);
rerender({ id: 'b' });
rerender({ id: 'c' });
rerender({ id: 'c' }); // no change — should not dispatch again
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
it('does not dispatch when stagedQueryId transitions from defined to undefined', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
rerender({ id: undefined });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('uses the latest selectedTime at the moment of stagedQueryId change', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'a' as string | undefined } },
);
setSelectedTime('15m');
rerender({ id: 'b' });
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('15m');
});
});

View File

@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
// Push fresh min/max back into Redux whenever the staged query changes for a
// relative time interval. The data hooks that read minTime/maxTime from Redux
// otherwise keep refetching with the originally frozen window and the time
// picker displays a stale absolute range.
// ref - SigNoz/signoz#8277
export function useSyncTimeOnStagedQueryChange(
stagedQueryId: string | undefined,
): void {
const dispatch = useDispatch();
const selectedTime = useSelector<AppState, GlobalReducer['selectedTime']>(
(state) => state.globalTime.selectedTime,
);
const prevStagedQueryIdRef = useRef<string | undefined>();
useEffect(() => {
const prevId = prevStagedQueryIdRef.current;
const currentId = stagedQueryId;
prevStagedQueryIdRef.current = currentId;
if (
prevId !== undefined &&
currentId !== undefined &&
prevId !== currentId &&
selectedTime !== 'custom'
) {
dispatch(UpdateTimeInterval(selectedTime));
}
}, [stagedQueryId, selectedTime, dispatch]);
}

View File

@@ -171,17 +171,20 @@
}
.legend-copy-button {
display: none;
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
display: flex;
opacity: 0;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 2px;
margin: 0;
border: none;
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
border-radius: 4px;
opacity: 1;
transition:
opacity 0.15s ease,
color 0.15s ease;
@@ -192,9 +195,8 @@
}
&:hover {
background: color-mix(in srgb, var(--l1-foreground) 5%, transparent);
background: var(--l3-background);
.legend-copy-button {
display: flex;
opacity: 1;
}
}

View File

@@ -1,39 +1,40 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input, Tooltip as AntdTooltip } from 'antd';
import { Input } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { Check, Copy } from '@signozhq/icons';
import { useLegendActions } from '../../hooks/useLegendActions';
import { LegendPosition, LegendProps } from '../types';
import './Legend.styles.scss';
export const MAX_LEGEND_WIDTH = 240;
/**
* Presentational legend. Renders the supplied `items` (markers + labels, an
* optional copy button, and a search box for the RIGHT position) and delegates
* all interaction to the container handlers. Source-agnostic — the uPlot
* charts feed it via UPlotLegend; Pie feeds it directly.
*/
export default function Legend({
items,
position = LegendPosition.BOTTOM,
config,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex = null,
onClick,
onMouseMove,
onMouseLeave,
showCopy = true,
showSearch,
}: LegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
const legendItems = useMemo(
() => Object.values(legendItemsMap),
[legendItemsMap],
);
const searchEnabled = showSearch ?? position === LegendPosition.RIGHT;
const isSingleRow = useMemo(() => {
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
@@ -41,21 +42,19 @@ export default function Legend({
}
const containerWidth = legendContainerRef.current.clientWidth;
const totalLegendWidth = legendItems.length * (averageLegendWidth + 16);
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, legendContainerRef, legendItems.length, position]);
}, [averageLegendWidth, items.length, position]);
const visibleLegendItems = useMemo(() => {
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
return legendItems;
if (!searchEnabled || !legendSearchQuery.trim()) {
return items;
}
const query = legendSearchQuery.trim().toLowerCase();
return legendItems.filter((item) =>
item.label?.toLowerCase().includes(query),
);
}, [position, legendSearchQuery, legendItems]);
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const handleCopyLegendItem = useCallback(
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
@@ -68,6 +67,9 @@ export default function Legend({
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => {
const isCopied = copiedId === item.seriesIndex;
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
return (
<div
key={item.seriesIndex}
@@ -77,54 +79,56 @@ export default function Legend({
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<AntdTooltip title={item.label}>
<TooltipSimple title={item.label} arrow side="top">
<div className="legend-item-label-trigger">
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
style={{ borderColor: markerColor }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
<AntdTooltip title={isCopied ? 'Copied' : 'Copy'}>
<button
type="button"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
</AntdTooltip>
</TooltipSimple>
{showCopy && (
<TooltipSimple title={isCopied ? 'Copied' : 'Copy'} arrow side="top">
<button
type="button"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
</TooltipSimple>
)}
</div>
);
},
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position],
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
);
const isEmptyState = useMemo(() => {
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
if (!searchEnabled || !legendSearchQuery.trim()) {
return false;
}
return visibleLegendItems.length === 0;
}, [position, legendSearchQuery, visibleLegendItems]);
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
return (
<div
ref={legendContainerRef}
className="legend-container"
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
onClick={onClick}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
>
{position === LegendPosition.RIGHT && (
{searchEnabled && (
<div className="legend-search-container">
<Input
allowClear

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import { LegendPosition, UPlotLegendProps } from '../types';
import Legend from './Legend';
/**
* uPlot legend controller. Derives the legend items + focus/visibility state
* from the chart config (useLegendsSync) and the toggle/focus interactions from
* the plot context (useLegendActions), then renders the presentational Legend.
* Must be rendered inside a PlotContextProvider.
*/
export default function UPlotLegend({
position = LegendPosition.BOTTOM,
config,
averageLegendWidth,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
return (
<Legend
items={items}
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
);
}

View File

@@ -7,11 +7,12 @@ import {
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import Legend from '../Legend/Legend';
import UPlotLegend from '../Legend/UPlotLegend';
import { LegendPosition } from '../types';
const mockWriteText = jest.fn().mockResolvedValue(undefined);
@@ -47,7 +48,7 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
describe('Legend', () => {
describe('UPlotLegend', () => {
beforeAll(() => {
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
Object.defineProperty(navigator, 'clipboard', {
@@ -115,11 +116,13 @@ describe('Legend', () => {
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<Legend
position={position}
// config is not used directly in the component, it's consumed by the mocked hook
config={{} as any}
/>,
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {

View File

@@ -1,9 +1,10 @@
import { ReactNode } from 'react';
import { MouseEventHandler, ReactNode } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -109,7 +110,33 @@ export enum LegendPosition {
export interface LegendConfig {
position: LegendPosition;
}
/**
* Presentational legend props. Source-agnostic: it renders whatever
* `items` it's given and delegates interaction to the container handlers, so
* it serves both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
*/
export interface LegendProps {
items: LegendItem[];
position?: LegendPosition;
averageLegendWidth?: number;
/** Series index to highlight (hovered/focused). */
focusedSeriesIndex?: number | null;
/**
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
* handler reads the target's id rather than binding per item.
*/
onClick?: MouseEventHandler<HTMLDivElement>;
onMouseMove?: MouseEventHandler<HTMLDivElement>;
onMouseLeave?: () => void;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
/** Show the filter search box. Default: only for the RIGHT position. */
showSearch?: boolean;
}
/** Props for the uPlot legend controller, which derives items + interaction
* from the chart config and renders the presentational Legend. */
export interface UPlotLegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
averageLegendWidth?: number;

View File

@@ -37,7 +37,7 @@ export interface TooltipViewState {
isHovering: boolean;
isPinned: boolean;
dismiss: () => void;
clickData: TooltipClickData | null;
clickData: ChartClickData | null;
contents?: ReactNode;
}
@@ -59,17 +59,17 @@ export interface TooltipPluginProps {
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
onClick?: (clickData: ChartClickData) => void;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
pinnedTooltipElement?: (clickData: ChartClickData) => ReactNode;
maxWidth?: number;
maxHeight?: number;
}
export interface TooltipClickData {
export interface ChartClickData {
xValue: number;
yValue: number;
focusedSeries: {
@@ -101,7 +101,7 @@ export interface TooltipControllerState {
hoverActive: boolean;
isAnySeriesActive: boolean;
pinned: boolean;
clickData: TooltipClickData | null;
clickData: ChartClickData | null;
style: TooltipViewState['style'];
horizontalOffset: number;
verticalOffset: number;

View File

@@ -2,7 +2,7 @@ import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
TOOLTIP_OFFSET,
TooltipClickData,
ChartClickData,
TooltipLayoutInfo,
TooltipViewState,
} from './types';
@@ -167,14 +167,11 @@ export function createLayoutObserver(
}
/**
* Resolves a TooltipClickData snapshot from a MouseEvent (real or synthetic)
* Resolves a ChartClickData snapshot from a MouseEvent (real or synthetic)
* and the current uPlot instance. Shared by the overlay click handler and the
* keyboard-pin handler (which synthesises an event from the cursor position).
*/
export function buildClickData(
event: MouseEvent,
plot: uPlot,
): TooltipClickData {
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);

View File

@@ -0,0 +1,85 @@
import {
RuletypesAlertStateDTO,
RuletypesAlertTypeDTO,
RuletypesPanelTypeDTO,
RuletypesQueryTypeDTO,
RuletypesRuleTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
RuletypesRuleConditionDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
const baseCondition: RuletypesRuleConditionDTO = {
compositeQuery: {
queryType: RuletypesQueryTypeDTO.builder,
panelType: RuletypesPanelTypeDTO.graph,
queries: null,
},
} as unknown as RuletypesRuleConditionDTO;
const make = (
id: string,
overrides: Partial<RuletypesRuleDTO>,
): RuletypesRuleDTO => ({
id,
alert: `Alert ${id}`,
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
condition: baseCondition,
createdAt: '2023-10-15T10:00:00Z',
updatedAt: '2023-10-19T10:00:00Z',
createdBy: 'alice@signoz.io',
updatedBy: 'alice@signoz.io',
disabled: false,
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'info' },
annotations: {},
source: '',
evalWindow: '5m0s',
frequency: '1m0s',
ruleType: RuletypesRuleTypeDTO.threshold_rule,
...overrides,
});
export const alertRulesFixture: RuletypesRuleDTO[] = [
make('rule-1', {
alert: 'High CPU Alert',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'critical', team: 'infra' },
}),
make('rule-2', {
alert: 'Memory Pending Alert',
state: RuletypesAlertStateDTO.pending,
labels: { severity: 'warning', team: 'backend' },
}),
make('rule-3', {
alert: 'Healthy Alert',
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'info', team: 'infra' },
}),
make('rule-4', {
alert: 'Disabled Alert',
state: RuletypesAlertStateDTO.disabled,
disabled: true,
labels: { severity: 'critical', team: 'frontend' },
}),
make('rule-5', {
alert: 'No Labels Alert',
state: RuletypesAlertStateDTO.inactive,
labels: {},
}),
];
export const alertRulesPaginationFixture: RuletypesRuleDTO[] = Array.from(
{ length: 15 },
(_, i) =>
make(`rule-pag-${i}`, {
alert: `Pag Rule ${i}`,
state:
i % 2 === 0
? RuletypesAlertStateDTO.firing
: RuletypesAlertStateDTO.inactive,
labels: { severity: i % 2 === 0 ? 'critical' : 'warning' },
createdAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
}),
);

View File

@@ -0,0 +1,102 @@
import type { AlertmanagertypesDeprecatedGettableAlertDTO } from 'api/generated/services/sigNoz.schemas';
export const triggeredAlertsFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] =
[
{
fingerprint: 'fp-critical-1',
startsAt: '2023-10-19T10:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-1&panelTypes=graph',
labels: {
alertname: 'High CPU Usage',
severity: 'critical',
ruleId: 'rule-1',
service: 'frontend',
env: 'prod',
},
annotations: {
summary: 'CPU above 90%',
description: 'Frontend CPU usage critical',
},
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: ['slack'],
},
{
fingerprint: 'fp-warning-1',
startsAt: '2023-10-19T09:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-2',
labels: {
alertname: 'Memory Warning',
severity: 'warning',
ruleId: 'rule-2',
service: 'backend',
env: 'prod',
},
annotations: { summary: 'Memory high' },
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: ['slack'],
},
{
fingerprint: 'fp-info-1',
startsAt: '2023-10-19T08:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-3',
labels: {
alertname: 'Disk Slow',
severity: 'info',
ruleId: 'rule-3',
service: 'frontend',
env: 'staging',
},
annotations: { summary: 'Disk slow' },
status: { state: 'unprocessed', silencedBy: [], inhibitedBy: [] },
receivers: ['email'],
},
{
fingerprint: 'fp-suppressed-1',
startsAt: '2023-10-19T07:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-4',
labels: {
alertname: 'Network Hiccup',
severity: 'error',
ruleId: 'rule-4',
service: 'backend',
env: 'dev',
},
annotations: { summary: 'Network errors' },
status: { state: 'suppressed', silencedBy: ['s-1'], inhibitedBy: [] },
receivers: ['pagerduty'],
},
{
fingerprint: 'fp-noseverity',
startsAt: '2023-10-19T06:00:00Z',
endsAt: '0001-01-01T00:00:00Z',
labels: {
alertname: 'Unknown Alert',
service: 'misc',
},
annotations: {},
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: [],
},
];
// Bigger fixture for pagination tests (15 entries → 2 pages at limit=10).
export const triggeredAlertsPaginationFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] =
Array.from({ length: 15 }, (_, i) => ({
fingerprint: `fp-pag-${i}`,
startsAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
endsAt: '0001-01-01T00:00:00Z',
generatorURL: `http://localhost/alerts/edit?ruleId=rule-pag-${i}`,
labels: {
alertname: `Pag Alert ${i}`,
severity: i % 2 === 0 ? 'critical' : 'warning',
ruleId: `rule-pag-${i}`,
service: 'frontend',
},
annotations: {},
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
receivers: [],
}));

View File

@@ -3,6 +3,8 @@ import { rest } from 'msw';
import commonEnTranslation from '../../public/locales/en/common.json';
import enTranslation from '../../public/locales/en/translation.json';
import { allAlertChannels } from './__mockdata__/alerts';
import { alertRulesFixture } from './__mockdata__/alert_rules';
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
import { billingSuccessResponse } from './__mockdata__/billing';
import {
dashboardSuccessResponse,
@@ -236,6 +238,38 @@ export const handlers = [
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
),
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: triggeredAlertsFixture, status: 'success' }),
),
),
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: alertRulesFixture, status: 'success' }),
),
),
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
const body = (await req.json()) as { alert?: string };
return res(
ctx.status(201),
ctx.json({
data: {
...alertRulesFixture[0],
id: 'new-rule-id',
alert: body?.alert ?? 'New Rule',
},
status: 'success',
}),
);
}),
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
rest.delete('http://localhost/api/v2/rules/:id', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
rest.delete('http://localhost/api/v1/channels/:id', (_, res, ctx) =>
res(
ctx.status(200),

View File

@@ -10,11 +10,6 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
children,
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('lib/history', () => ({
push: jest.fn(),
listen: jest.fn(() => jest.fn()),

View File

@@ -14,11 +14,6 @@ jest.mock('AppRoutes/utils', () => ({
const mockAfterLogin = jest.mocked(afterLogin);
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('lib/history', () => ({
__esModule: true,
default: {

View File

@@ -119,6 +119,7 @@
gap: 12px;
margin-bottom: 14px;
align-items: center;
background: var(--l3-background);
}
.searchInput {
@@ -126,16 +127,13 @@
padding: 6px 8px;
background: var(--l3-background);
:global(.ant-input-prefix) {
height: 18px;
margin-inline-end: 6px;
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
svg {
opacity: 0.4;
}
&,
input {
font-size: 14px;
line-height: 18px;

View File

@@ -0,0 +1,41 @@
// Helpers for tests that drive components built with `nuqs`.
//
// We replace `NuqsAdapter` (which throttles URL writes to `window.history`)
// with `NuqsTestingAdapter`. The testing adapter applies URL updates
// synchronously (`rateLimitFactor: 0`) and stores state in memory
// (`hasMemory: true`) so subsequent reads see the latest value without any
// `flushNuqsUrl` sleep. Each test gets a fresh queue because
// `resetUrlUpdateQueueOnMount` defaults to `true`.
//
// Reads on `window.location.search` are no longer authoritative since the
// adapter does not push to the browser history. Use
// `getCurrentNuqsQueryString()` (or assert on `lastNuqsUrlUpdate`) instead.
import type { OnUrlUpdateFunction } from 'nuqs/adapters/testing';
let lastUrlUpdate: { searchParams: URLSearchParams; queryString: string } = {
searchParams: new URLSearchParams(),
queryString: '',
};
export function resetNuqsState(initialQuery = ''): void {
lastUrlUpdate = {
searchParams: new URLSearchParams(initialQuery),
queryString: initialQuery,
};
}
export const onNuqsUrlUpdate: OnUrlUpdateFunction = (event) => {
lastUrlUpdate = {
searchParams: event.searchParams,
queryString: event.queryString,
};
};
export function getCurrentNuqsQueryString(): string {
return lastUrlUpdate.queryString;
}
export function getCurrentNuqsSearchParams(): URLSearchParams {
return lastUrlUpdate.searchParams;
}

View File

@@ -648,176 +648,3 @@ describe('getQueryContextAtCursor - trailing dot in key/value', () => {
expect(ctx.keyToken).toBe('k8s.namespace');
});
});
describe('getQueryContextAtCursor - partial operator', () => {
it('treats text after an incomplete key as an operator prefix', () => {
const q = 'service.name c';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.isInKey).toBe(false);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('c');
expect(ctx.currentPair).toStrictEqual(
expect.objectContaining({
key: 'service.name',
operator: 'c',
position: expect.objectContaining({
operatorStart: 13,
operatorEnd: 13,
}),
}),
);
});
it('keeps the operator context while completing contains', () => {
const q = 'service.name cont';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('cont');
});
it('treats cursor mid-token as operator context', () => {
const q = 'service.name cont';
// cursor sits between "con" and "t" — user still typing the operator
const ctx = getQueryContextAtCursor(q, 15);
expect(ctx.isInOperator).toBe(true);
expect(ctx.isInKey).toBe(false);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('cont');
});
it('keeps operator context when an AND conjunction precedes the pair', () => {
const q = 'a = 1 AND service.name c';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.isInKey).toBe(false);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('c');
expect(ctx.currentPair).toStrictEqual(
expect.objectContaining({
key: 'service.name',
operator: 'c',
position: expect.objectContaining({
operatorStart: 23,
operatorEnd: 23,
}),
}),
);
});
it('keeps operator context when an open parenthesis precedes the pair', () => {
const q = '(service.name c';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.isInKey).toBe(false);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('c');
});
it('re-glues a partial operator that follows a NOT negation', () => {
const q = 'service.name NOT c';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.isInKey).toBe(false);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('NOT c');
// operatorStart points at the partial operator (post-NOT), not at the
// negation — so suggestion selection only replaces the partial, never
// the user's typed NOT.
expect(ctx.currentPair).toStrictEqual(
expect.objectContaining({
key: 'service.name',
operator: 'NOT c',
hasNegation: true,
position: expect.objectContaining({
negationStart: 13,
negationEnd: 15,
operatorStart: 17,
operatorEnd: 17,
}),
}),
);
});
it('re-glues a multi-character partial operator after NOT', () => {
const q = 'service.name NOT lik';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('NOT lik');
expect(ctx.currentPair?.hasNegation).toBe(true);
});
it('re-glues an uppercase partial operator after NOT', () => {
const q = 'service.name NOT EXI';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('NOT EXI');
expect(ctx.currentPair?.hasNegation).toBe(true);
});
it('preserves original NOT casing in the operator text', () => {
const q = 'service.name not c';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('not c');
expect(ctx.currentPair?.hasNegation).toBe(true);
});
it('tolerates extra whitespace between NOT and the partial operator', () => {
const q = 'service.name NOT c';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.keyToken).toBe('service.name');
// Display text uses a canonical single space between NOT and the
// partial, regardless of how many spaces the user typed.
expect(ctx.operatorToken).toBe('NOT c');
expect(ctx.currentPair?.hasNegation).toBe(true);
});
it('keeps operator context for NOT-prefixed partial inside parentheses', () => {
const q = '(service.name NOT c';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('NOT c');
expect(ctx.currentPair?.hasNegation).toBe(true);
});
it('keeps operator context for NOT-prefixed partial after an AND conjunction', () => {
const q = 'a = 1 AND service.name NOT c';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.keyToken).toBe('service.name');
expect(ctx.operatorToken).toBe('NOT c');
expect(ctx.currentPair?.hasNegation).toBe(true);
});
it('re-glues the most recent incomplete pair when three partial tokens are typed', () => {
// Pins documented behavior: with two trailing partial pairs (`c` and
// `k`), the heuristic pairs the most recent two — `c` becomes the
// key, `k` becomes the partial operator. The earlier `service.name`
// is dropped from the current pair view.
const q = 'service.name c k';
const ctx = getQueryContextAtCursor(q, q.length);
expect(ctx.isInOperator).toBe(true);
expect(ctx.keyToken).toBe('c');
expect(ctx.operatorToken).toBe('k');
});
});

View File

@@ -605,98 +605,6 @@ export function getQueryContextAtCursor(
queryPairs,
);
// Re-glue a partial operator that ANTLR has lexed as a second key.
//
// When the user types `service.name c` (or `service.name NOT c`), the
// lexer sees two KEY tokens (`service.name`, `c`) instead of a key +
// partial operator, so `extractQueryPairs` emits two consecutive
// key-only incomplete pairs. Downstream, that makes the dropdown
// suggest keys when it should be suggesting operators.
//
// Detect that pattern — a previous incomplete key-only pair followed
// by another incomplete key-only `currentPair`, separated only by
// whitespace (or by a negation token attached to the previous pair) —
// and rebuild a single synthetic pair where the previous pair's key
// is the key and the current pair's key is treated as the partial
// operator. The synthetic pair inherits the previous pair's negation
// flag and positions via the spread, so `NOT <partial>` propagates
// correctly to consumers.
const previousIncompletePair = queryPairs
.filter(
(pair) =>
!pair.isComplete &&
!!pair.key &&
!pair.operator &&
pair.position.keyEnd < (currentPair?.position.keyStart ?? cursorIndex),
)
.sort((a, b) => b.position.keyEnd - a.position.keyEnd)[0];
if (
previousIncompletePair &&
currentPair &&
currentPair !== previousIncompletePair &&
!currentPair.operator &&
currentPair.position.keyStart > previousIncompletePair.position.keyEnd
) {
const negationStart = previousIncompletePair.position.negationStart ?? 0;
const negationEnd = previousIncompletePair.position.negationEnd ?? 0;
const negationAfterKey =
previousIncompletePair.hasNegation &&
negationStart > previousIncompletePair.position.keyEnd;
const gapStart = negationAfterKey
? negationEnd + 1
: previousIncompletePair.position.keyEnd + 1;
const textBetweenPairs = query.slice(
gapStart,
currentPair.position.keyStart,
);
if (textBetweenPairs.trim() === '') {
// The replacement range (operatorStart/operatorEnd) must point
// at the partial operator only, NOT the leading negation.
// Consumers like QuerySearch use it to splice the chosen
// suggestion in-place, so including the negation would let a
// `NOT lik` -> `LIKE` selection erase the user's typed `NOT`.
// Matches the convention used for complete pairs in
// extractQueryPairs, where operatorStart starts after the
// negation token.
const operatorStart = currentPair.position.keyStart;
const operatorEnd = currentPair.position.keyEnd;
const partialOperator = query.slice(operatorStart, operatorEnd + 1);
const operatorText = negationAfterKey
? `${query.slice(negationStart, negationEnd + 1)} ${partialOperator}`
: partialOperator;
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: operatorText,
isInKey: false,
isInNegation: false,
isInOperator: true,
isInValue: false,
isInConjunction: false,
isInFunction: false,
isInParenthesis: false,
isInBracketList: false,
keyToken: previousIncompletePair.key,
operatorToken: operatorText,
queryPairs,
currentPair: {
...previousIncompletePair,
operator: operatorText,
position: {
...previousIncompletePair.position,
operatorStart,
operatorEnd,
},
},
};
}
}
// Check if cursor is within any of the specific context boundaries
// FIXED: Include the case where the cursor is exactly at the end of a boundary
const isInKeyBoundary =