Compare commits

...

13 Commits

Author SHA1 Message Date
Vinícius Lourenço
4510889dd9 test(alerts): add tests for list alerts and triggered alerts 2026-06-02 17:19:30 -03:00
Vinícius Lourenço
a75233ee25 test(alerts): add tests for triggered and list alerts page 2026-06-01 11:12:26 -03:00
Vikrant Gupta
407d969cd3 feat(user): add support for token verification (#11496)
* feat(user): add support for token validation

* feat(user): update openapi specs

* feat(user): update verify endpoint

* feat(user): use binding package

* feat(user): update openapi specs
2026-05-29 13:35:30 +00:00
Vinicius Lourenço
fa3ab0c197 chore(oxlint): disable max-params, jsdoc, and more rules for tests (#11467) 2026-05-29 13:02:36 +00:00
Vinicius Lourenço
457ceb8fe7 feat(alerts): rewrite list alerts & triggered alerts to use new table (#11277)
* feat(tanstack-table): add showPageSize flag and callbacks to pagination

* chore(signozhq/ui): bump to v0.0.19

* feat(components): added shared components for alerts

* feat(hooks): add shared hooks for alerts

* feat(time-utils): add little helper to get elapsed time in ms

* feat(alerts-components): add badge map colors

* feat(list-alerts): rewrite page to use new table component (#11276)

* feat(triggered-alerts): rewrite page to use new table component (#11260)

* feat(triggered-alerts): rewrite page

* chore(triggered-alerts): move reason tooltip content to own component

* chore(pr-comments): address PR comments

* chore(lint): fix signozhq/ui imports

* refactor(alerts): removed stats card

* refactor(alerts): move table to use pagination instead of infinity load

* refactor(alerts): remove extra columns & add back info tooltip

* refactor(alerts): disable move columns

* refactor(alerts): cleanup dead components

* refactor(alerts): standardize errors and empty states

* refactor(alerts): missing pagination class on group

* fix(tanstack): preserve page from URL on refresh page

* refactor(alerts): ensure no empty has correct colors

* refactor(alerts): remove barrel imports

* fix(alerts): missing including error empty state

* fix(alerts): ensure the params are removed on page change

* fix(alerts): address comments related to UI/UX

* feat(tanstack): add auto page size

* fix(alert): use calculated page size

* fix(alerts): address tiny issues in ui

* fix(tests): label column tests

* fix(actions-menu): ensure callbacks are memoized

* fix(alerts): ensure tabs reset the url state (#11380)

* fix(pr): address issue with expand button

* fix(list-alerts): reduce size of state/severity, add line clamp for table

* fix(triggered-alerts): reduce size of state/severity, add line clamp for table

* fix(expanded-alerts): remove min-height when no page

* fix(triggered-alerts): increase row height for triggered alerts

* fix(list-alerts-triggered-alerts): reset page to 1 when change search

* fix(use-url-search): add missing test case for clear search
2026-05-29 12:53:55 +00:00
SagarRajput-7
1928de7452 feat(web): gate sentry and pylon init behind boot settings flags (#11498) 2026-05-29 12:31:44 +00:00
Vikrant Gupta
c6b2fe47d5 feat(web): add support for sentry and pylon (#11495)
Some checks failed
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
build-staging / prepare (push) Has been cancelled
* feat(web): add support for sentry and pylon

* feat(web): add support for sentry and pylon

* feat(web): add support for sentry and pylon

* feat(web): removed bootdata.ts and maintaining websettings as single source of truth

* feat(web): update the example.yaml

---------

Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2026-05-29 12:08:00 +00:00
Tushar Vats
1d6fa6e507 feat: extend error fields (#11228)
* fix: extended error fields

* fix: remove stale comment

* fix: removed retry policy enum and added example

* fix: generate openapi

* fix: stale comments
2026-05-29 11:43:35 +00:00
swapnil-signoz
910645516d chore: remove cloud integration service cascade delete constraint (#11480)
* chore: remove cloud integration service cascade delete constraint

* refactor: manually recreate table
2026-05-29 11:21:05 +00:00
Tushar Vats
edc1278769 fix(querybuilder): return PreparedWhereClause by value so warnings propagate when clause is empty (#11395)
* fix: propage dropped warnings from where clause visitor

* fix: added integration test

* fix: make py-fmt

* fix: remove stale comment
2026-05-29 10:09:09 +00:00
Yunus M
da1b09c479 refactor: replace antd Tabs with @signozhq/ui Tabs (#11392)
* refactor: replace antd Tabs with @signozhq/ui Tabs

Migrates Tabs usage from antd to the @signozhq/ui Tabs component across
dashboard settings, integration details, metrics application, pipelines,
trace detail, and workspace-locked pages. API differences (activeKey →
value, defaultActiveKey → defaultValue, TabsProps['items'] → TabItemProps[])
are updated to match the new component.

* refactor: enhance RouteTab component with new styling and functionality
2026-05-29 09:53:51 +00:00
Gaurav Tewari
1f406823d8 refactor(frontend): migrate plain antd Input to @signozhq/ui/input (#11401)
* chore: refactor input

* chore: remove markdown

* chore: update markdown

* fix: minior comments for input

* chore: remove comments

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-05-29 08:37:54 +00:00
Nikhil Mantri
f626380b1a fix(infra-monitoring): align v2 custom queries' bounds with QBv5 querier step adjustment (#11397)
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: fix for surfacing meta for pods custom group by

* chore: added todo and note

* chore: added changes based on comments

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-29 07:14:43 +00:00
265 changed files with 9479 additions and 3167 deletions

View File

@@ -68,6 +68,12 @@ web:
appcues:
# Whether to enable Appcues in web.
enabled: true
sentry:
# Whether to enable Sentry in web.
enabled: true
pylon:
# Whether to enable Pylon in web.
enabled: true
##################### Cache #####################
cache:

View File

@@ -3237,8 +3237,20 @@ components:
items:
$ref: '#/components/schemas/ErrorsResponseerroradditional'
type: array
invalidReferences:
items:
type: string
type: array
message:
type: string
retry:
$ref: '#/components/schemas/ErrorsResponseretryjson'
suggestions:
items:
type: string
type: array
type:
type: string
url:
type: string
required:
@@ -3250,6 +3262,11 @@ components:
message:
type: string
type: object
ErrorsResponseretryjson:
properties:
delay:
$ref: '#/components/schemas/TimeDuration'
type: object
FactoryResponse:
properties:
healthy:
@@ -7068,6 +7085,13 @@ components:
required:
- name
type: object
TypesPostableVerifyResetPasswordToken:
properties:
token:
type: string
required:
- token
type: object
TypesResetPasswordToken:
properties:
expiresAt:
@@ -14849,6 +14873,41 @@ paths:
summary: Readiness check
tags:
- health
/api/v2/reset_password_tokens/verify:
post:
deprecated: false
description: This endpoint verifies whether a reset password token exists and
is not expired
operationId: VerifyResetPasswordToken
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesPostableVerifyResetPasswordToken'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
summary: Verify a reset password token
tags:
- users
/api/v2/roles/{id}/users:
get:
deprecated: false

View File

@@ -1,7 +1,9 @@
{
"required": [
"posthog",
"appcues"
"appcues",
"sentry",
"pylon"
],
"additionalProperties": false,
"definitions": {
@@ -28,6 +30,30 @@
}
},
"type": "object"
},
"Pylon": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"Sentry": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
@@ -36,6 +62,12 @@
},
"posthog": {
"$ref": "#/definitions/Posthog"
},
"pylon": {
"$ref": "#/definitions/Pylon"
},
"sentry": {
"$ref": "#/definitions/Sentry"
}
},
"type": "object"

View File

@@ -123,6 +123,7 @@
"react/require-render-return": "error",
"react/no-unsafe": "off",
"no-array-constructor": "error",
"jsdoc/check-tag-names": "off",
"@typescript-eslint/no-duplicate-enum-values": "warn",
// TODO: Change to error after migration to oxlint
"@typescript-eslint/no-empty-object-type": "error",
@@ -197,10 +198,7 @@
]
}
],
"max-params": [
"warn",
3
],
"max-params": "off",
// Warns when functions have more than 3 parameters
"@typescript-eslint/explicit-function-return-type": "error",
// Requires explicit return types on functions
@@ -538,7 +536,10 @@
"signoz/no-raw-absolute-path":"off",
"no-restricted-globals": "off",
// Tests need raw localStorage/sessionStorage to seed DOM state for isolation
"signoz/no-zustand-getstate-in-hooks": "off"
"signoz/no-zustand-getstate-in-hooks": "off",
"@typescript-eslint/no-explicit-any": "off", // Tests often need any for mocks/stubs
"@typescript-eslint/explicit-module-boundary-types": "off", // Return types not required in tests
"@typescript-eslint/explicit-function-return-type": "off" // Same as above rule, don't need to care about return type
}
},
{

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

@@ -112,7 +112,10 @@
<script>
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
if (PYLON_APP_ID) {
var pylonSettings =
((window.signozBootData || {}).settings || {}).pylon || {};
var pylonEnabled = pylonSettings.enabled !== false;
if (PYLON_APP_ID && pylonEnabled) {
(function () {
var e = window;
var t = document;

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

@@ -35,7 +35,6 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { bootSettings } from 'utils/bootData';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
@@ -292,7 +291,8 @@ function App(): JSX.Element {
isLoggedInState &&
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser)
(isCloudUser || isEnterpriseSelfHostedUser) &&
(window.signozBootData?.settings?.pylon.enabled ?? true)
) {
const email = user.email || '';
const secret = process.env.PYLON_IDENTITY_SECRET || '';
@@ -334,14 +334,20 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
if (
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
process.env.POSTHOG_KEY
) {
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
});
}
if (!isSentryInitialized) {
if (
!isSentryInitialized &&
(window.signozBootData?.settings?.sentry.enabled ?? true)
) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,

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

@@ -2051,6 +2051,10 @@ export interface ErrorsResponseerroradditionalDTO {
message?: string;
}
export interface ErrorsResponseretryjsonDTO {
delay?: TimeDurationDTO;
}
export interface ErrorsJSONDTO {
/**
* @type string
@@ -2060,10 +2064,23 @@ export interface ErrorsJSONDTO {
* @type array
*/
errors?: ErrorsResponseerroradditionalDTO[];
/**
* @type array
*/
invalidReferences?: string[];
/**
* @type string
*/
message: string;
retry?: ErrorsResponseretryjsonDTO;
/**
* @type array
*/
suggestions?: string[];
/**
* @type string
*/
type?: string;
/**
* @type string
*/
@@ -8308,6 +8325,13 @@ export interface TypesPostableRoleDTO {
name: string;
}
export interface TypesPostableVerifyResetPasswordTokenDTO {
/**
* @type string
*/
token: string;
}
export interface TypesResetPasswordTokenDTO {
/**
* @type string

View File

@@ -48,6 +48,7 @@ import type {
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesPostableRoleDTO,
TypesPostableVerifyResetPasswordTokenDTO,
TypesUpdatableUserDTO,
UpdateUserDeprecated200,
UpdateUserDeprecatedPathParameters,
@@ -947,6 +948,90 @@ export const useForgotPassword = <
> => {
return useMutation(getForgotPasswordMutationOptions(options));
};
/**
* This endpoint verifies whether a reset password token exists and is not expired
* @summary Verify a reset password token
*/
export const verifyResetPasswordToken = (
typesPostableVerifyResetPasswordTokenDTO?: BodyType<TypesPostableVerifyResetPasswordTokenDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/reset_password_tokens/verify`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableVerifyResetPasswordTokenDTO,
signal,
});
};
export const getVerifyResetPasswordTokenMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
> => {
const mutationKey = ['verifyResetPasswordToken'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> }
> = (props) => {
const { data } = props ?? {};
return verifyResetPasswordToken(data);
};
return { mutationFn, ...mutationOptions };
};
export type VerifyResetPasswordTokenMutationResult = NonNullable<
Awaited<ReturnType<typeof verifyResetPasswordToken>>
>;
export type VerifyResetPasswordTokenMutationBody =
| BodyType<TypesPostableVerifyResetPasswordTokenDTO>
| undefined;
export type VerifyResetPasswordTokenMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Verify a reset password token
*/
export const useVerifyResetPasswordToken = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
> => {
return useMutation(getVerifyResetPasswordTokenMutationOptions(options));
};
/**
* This endpoint returns the users having the role by role id
* @summary Get users by role id

View File

@@ -0,0 +1,31 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
text-align: center;
}
.icon {
color: var(--danger-background);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--text-vanilla-400);
max-width: 400px;
}
.actions {
display: flex;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { LifeBuoy, RefreshCw, TriangleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import styles from './ErrorEmptyState.module.scss';
interface ErrorEmptyStateProps {
title?: string;
subtitle?: string;
onRefresh?: () => void;
}
function ErrorEmptyState({
title = 'Something went wrong',
subtitle = 'Our team is getting on top to resolve this. Please reach out to support if the issue persists.',
onRefresh,
}: ErrorEmptyStateProps): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
const onContactSupport = useCallback((): void => {
handleContactSupport(isCloudUser);
}, [isCloudUser]);
return (
<div className={styles.emptyState} data-testid="error-empty-state">
<TriangleAlert className={styles.icon} size={32} />
<div className={styles.title} data-testid="error-title">
{title}
</div>
<div className={styles.subtitle} data-testid="error-subtitle">
{subtitle}
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="secondary"
prefix={<LifeBuoy size={14} />}
onClick={onContactSupport}
data-testid="error-contact-support-button"
>
Contact Support
</Button>
{onRefresh && (
<Button
variant="outlined"
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
data-testid="error-refresh-button"
>
Refresh
</Button>
)}
</div>
</div>
);
}
export default ErrorEmptyState;

View File

@@ -0,0 +1 @@
export { default } from './ErrorEmptyState';

View File

@@ -0,0 +1,68 @@
.labelColumn {
display: flex;
gap: 4px;
align-items: center;
overflow: hidden;
max-width: 100%;
width: 100%;
}
.labelBadge {
cursor: default;
font-size: 12px;
--badge-display: inline;
max-width: 180px;
text-overflow: ellipsis;
}
.overflowTrigger {
all: unset;
cursor: pointer;
}
.overflowBadge {
cursor: pointer;
font-size: 12px;
}
.labelPopover {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.labelTooltip {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;
}
.tooltipContent {
display: flex;
align-items: center;
gap: 8px;
}
.copyButton {
all: unset;
cursor: pointer;
display: flex;
align-items: center;
opacity: 0.7;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,142 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { act, render, screen } from '@testing-library/react';
import LabelColumn from './LabelColumn';
let resizeCallback: ResizeObserverCallback | null = null;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
}
function triggerResize(width: number): void {
if (resizeCallback) {
act(() => {
resizeCallback?.(
[{ contentRect: { width } } as ResizeObserverEntry],
{} as ResizeObserver,
);
});
}
}
beforeAll(() => {
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
});
afterEach(() => {
resizeCallback = null;
});
function renderWithProviders(
ui: React.ReactElement,
): ReturnType<typeof render> {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
describe('LabelColumn', () => {
it('should render all labels when 5 or fewer', () => {
const labels = ['env', 'service', 'region'];
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
});
it('should truncate labels and show +N badge when container is narrow', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate narrow container that fits ~3 badges
// Badge widths: env=37, service=65, region=58, team=44, owner=51, version=65
// 220px available = 3 badges (160px) + gaps (8px) + overflow (44px)
triggerResize(220);
// First 3 visible
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
// Remaining in overflow badge
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
});
it('should render label with value when value prop provided', () => {
const labels = ['env'];
const value = { env: 'production' };
renderWithProviders(<LabelColumn labels={labels} value={value} />);
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
'env: production',
);
});
it('should render labels without value when value is not provided for that label', () => {
const labels = ['env', 'service'];
const value = { env: 'production' };
renderWithProviders(<LabelColumn labels={labels} value={value} />);
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
'env: production',
);
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
});
it('should show overflow badge with remaining count when container is narrow', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate narrow container to trigger overflow (shows 3 labels)
// 220px fits first 3 badges before overflow
triggerResize(220);
// Overflow badge shows +3 (remaining labels)
const overflowBadge = screen.getByTestId('label-overflow-badge');
expect(overflowBadge).toBeInTheDocument();
expect(overflowBadge).toHaveTextContent('+3');
});
it('should render empty when no labels provided', () => {
renderWithProviders(<LabelColumn labels={[]} />);
const column = screen.getByTestId('label-column');
expect(column.children).toHaveLength(0);
});
it('should use primary color by default', () => {
const labels = ['env'];
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
});
it('should show all labels when container is wide enough', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate wide container
triggerResize(1000);
// All labels visible
labels.forEach((label) => {
expect(screen.getByTestId(`label-tag-${label}`)).toBeInTheDocument();
});
// No overflow badge
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,150 @@
import { Copy } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/sonner';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import LabelTag from './LabelTag';
import styles from './LabelColumn.module.scss';
import { BADGE_GAP, estimateBadgeWidth, OVERFLOW_BADGE_WIDTH } from './utils';
export interface LabelColumnProps {
labels: string[];
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
value?: { [key: string]: string };
}
function LabelColumn({
labels,
value,
color = 'primary',
}: LabelColumnProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [maxVisibleCount, setMaxVisibleCount] = useState(labels.length);
const [, copyToClipboard] = useCopyToClipboard();
const calculateMaxVisible = useCallback(
(width: number): number => {
if (width <= 0) {
return 1;
}
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
let usedWidth = 0;
let count = 0;
for (const label of labels) {
const badgeWidth = estimateBadgeWidth(label, value?.[label]) + BADGE_GAP;
if (usedWidth + badgeWidth > availableWidth && count > 0) {
break;
}
usedWidth += badgeWidth;
count++;
}
return Math.max(1, count);
},
[labels, value],
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0) {
setMaxVisibleCount(calculateMaxVisible(entry.contentRect.width));
}
});
observer.observe(container);
if (container.clientWidth > 0) {
setMaxVisibleCount(calculateMaxVisible(container.clientWidth));
}
return (): void => observer.disconnect();
}, [calculateMaxVisible]);
const needsOverflow = labels.length > maxVisibleCount;
const visibleLabels = needsOverflow
? labels.slice(0, maxVisibleCount)
: labels;
const remainingLabels = needsOverflow ? labels.slice(maxVisibleCount) : [];
return (
<div
ref={containerRef}
className={styles.labelColumn}
data-testid="label-column"
>
{visibleLabels.map((label) => (
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
))}
{remainingLabels.length > 0 && (
<TooltipRoot>
<TooltipTrigger asChild>
<span>
<Badge
color={color}
className={styles.overflowBadge}
variant="outline"
data-testid="label-overflow-badge"
>
+{remainingLabels.length}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="end">
<div className={styles.tooltipContent}>
<span>
{remainingLabels
.map((label) => (value?.[label] ? `${label}: ${value[label]}` : label))
.join(', ')}
</span>
<button
type="button"
className={styles.copyButton}
onClick={(e): void => {
e.stopPropagation();
const searchFormat = remainingLabels
.map((label) => (value?.[label] ? `${label} ${value[label]}` : label))
.join(' ');
copyToClipboard(searchFormat);
toast.success('Copied! Use in search to filter alerts.');
}}
aria-label="Copy to clipboard"
>
<Copy size={12} />
</button>
</div>
</TooltipContent>
</TooltipRoot>
)}
</div>
);
}
export default LabelColumn;

View File

@@ -0,0 +1,30 @@
.labelBadge {
cursor: default;
font-size: 12px;
max-width: 180px;
text-overflow: ellipsis;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;
}
.tooltipContent {
display: flex;
align-items: center;
gap: 8px;
}
.copyButton {
all: unset;
cursor: pointer;
display: flex;
align-items: center;
opacity: 0.7;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,74 @@
import { Copy } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/sonner';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { useCopyToClipboard } from 'react-use';
import styles from './LabelTag.module.scss';
export interface LabelTagProps {
label: string;
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
value?: string;
}
function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const displayText = value ? `${label}: ${value}` : label;
const searchFormat = value ? `${label} ${value}` : label;
const handleCopy = (e: React.MouseEvent): void => {
e.stopPropagation();
copyToClipboard(searchFormat);
toast.success('Copied! Use in search to filter alerts.');
};
return (
<TooltipRoot>
<TooltipTrigger asChild>
<span>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid={`label-tag-${label}`}
>
<span className={styles.labelValue}>{displayText}</span>
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
<div className={styles.tooltipContent}>
<span>{displayText}</span>
<button
type="button"
className={styles.copyButton}
onClick={handleCopy}
aria-label="Copy to clipboard"
>
<Copy size={12} />
</button>
</div>
</TooltipContent>
</TooltipRoot>
);
}
export default LabelTag;

View File

@@ -0,0 +1,2 @@
export { default } from './LabelColumn';
export type { LabelColumnProps } from './LabelColumn';

View File

@@ -0,0 +1,14 @@
export const BADGE_GAP = 4;
export const OVERFLOW_BADGE_WIDTH = 40;
export const BADGE_MAX_WIDTH = 180;
export const BADGE_PADDING = 16;
export const CHAR_WIDTH = 7;
export function estimateBadgeWidth(label: string, value?: string): number {
const displayText = value ? `${label}: ${value}` : label;
return Math.min(
displayText.length * CHAR_WIDTH + BADGE_PADDING,
BADGE_MAX_WIDTH,
);
}

View File

@@ -0,0 +1,30 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
text-align: center;
}
.icon {
color: var(--text-vanilla-400);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--text-vanilla-400);
max-width: 400px;
}
.actions {
display: flex;
gap: 8px;
}

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import NoResultsEmptyState from './NoResultsEmptyState';
describe('NoResultsEmptyState', () => {
it('should render with default props', () => {
render(<NoResultsEmptyState />);
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching results',
);
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
'No items match your current filters. Try adjusting your search criteria.',
);
});
it('should render with custom title and subtitle', () => {
render(
<NoResultsEmptyState title="Custom Title" subtitle="Custom Subtitle" />,
);
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'Custom Title',
);
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
'Custom Subtitle',
);
});
it('should not render clear button when onClear is not provided', () => {
render(<NoResultsEmptyState />);
expect(
screen.queryByTestId('no-results-clear-button'),
).not.toBeInTheDocument();
});
it('should render clear button when onClear is provided', () => {
const onClear = jest.fn();
render(<NoResultsEmptyState onClear={onClear} />);
expect(screen.getByTestId('no-results-clear-button')).toBeInTheDocument();
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
'Clear Filters',
);
});
it('should render custom clear button text', () => {
render(
<NoResultsEmptyState onClear={jest.fn()} clearButtonText="Reset All" />,
);
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
'Reset All',
);
});
it('should call onClear when clear button is clicked', async () => {
const user = userEvent.setup();
const onClear = jest.fn();
render(<NoResultsEmptyState onClear={onClear} />);
await user.click(screen.getByTestId('no-results-clear-button'));
expect(onClear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,57 @@
import { RefreshCw, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './NoResultsEmptyState.module.scss';
interface NoResultsEmptyStateProps {
title?: string;
subtitle?: string;
onClear?: () => void;
clearButtonText?: string;
onRefresh?: () => void;
}
function NoResultsEmptyState({
title = 'No matching results',
subtitle = 'No items match your current filters. Try adjusting your search criteria.',
onClear,
clearButtonText = 'Clear Filters',
onRefresh,
}: NoResultsEmptyStateProps): JSX.Element {
return (
<div className={styles.emptyState} data-testid="no-results-empty-state">
<Search className={styles.icon} size={16} />
<div className={styles.title} data-testid="no-results-title">
{title}
</div>
<div className={styles.subtitle} data-testid="no-results-subtitle">
{subtitle}
</div>
<div className={styles.actions}>
{onClear && (
<Button
variant="outlined"
color="secondary"
onClick={onClear}
data-testid="no-results-clear-button"
>
{clearButtonText}
</Button>
)}
{onRefresh && (
<Button
variant="outlined"
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
data-testid="no-results-refresh-button"
>
Refresh
</Button>
)}
</div>
</div>
);
}
export default NoResultsEmptyState;

View File

@@ -0,0 +1 @@
export { default } from './NoResultsEmptyState';

View File

@@ -0,0 +1,32 @@
import type { BadgeColor } from '@signozhq/ui/badge';
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
export const STATE_LABELS: Record<string, string> = {
firing: 'Firing',
pending: 'Pending',
inactive: 'OK',
disabled: 'Disabled',
};
export const STATE_COLORS: Record<string, string> = {
firing: 'var(--bg-cherry-500)',
pending: 'var(--bg-amber-500)',
inactive: 'var(--bg-forest-500)',
disabled: 'var(--l2-foreground)',
};
export const SEVERITY_COLORS: Record<string, string> = {
critical: 'var(--bg-cherry-500)',
error: 'var(--bg-cherry-400)',
warning: 'var(--bg-amber-500)',
info: 'var(--bg-robin-500)',
};
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColor> = {
critical: 'error',
error: 'error',
warning: 'warning',
info: 'primary',
};

View File

@@ -0,0 +1,7 @@
export interface FilterValue {
value: string;
}
export interface AlertWithLabels {
labels?: Record<string, string>;
}

View File

@@ -0,0 +1,287 @@
import type { SortState } from 'components/TanStackTableView/types';
import type { AlertWithLabels, FilterValue } from './types';
import { filterByLabels, searchByLabels, sortByColumn } from './utils';
interface TestAlert extends AlertWithLabels {
name: string;
value: number;
}
const createAlert = (
name: string,
value: number,
labels?: Record<string, string>,
): TestAlert => ({
name,
value,
labels,
});
describe('sortByColumn', () => {
const alerts: TestAlert[] = [
createAlert('Alert C', 3),
createAlert('Alert A', 1),
createAlert('Alert B', 2),
];
const getSortValue = (
item: TestAlert,
columnName: string,
): string | number => {
if (columnName === 'name') {
return item.name;
}
if (columnName === 'value') {
return item.value;
}
return '';
};
it('should return items unchanged when no orderBy provided', () => {
const result = sortByColumn(alerts, null, getSortValue);
expect(result).toStrictEqual(alerts);
});
it('should sort by string column ascending', () => {
const orderBy: SortState = { columnName: 'name', order: 'asc' };
const result = sortByColumn(alerts, orderBy, getSortValue);
expect(result.map((a) => a.name)).toStrictEqual([
'Alert A',
'Alert B',
'Alert C',
]);
});
it('should sort by string column descending', () => {
const orderBy: SortState = { columnName: 'name', order: 'desc' };
const result = sortByColumn(alerts, orderBy, getSortValue);
expect(result.map((a) => a.name)).toStrictEqual([
'Alert C',
'Alert B',
'Alert A',
]);
});
it('should sort by number column ascending', () => {
const orderBy: SortState = { columnName: 'value', order: 'asc' };
const result = sortByColumn(alerts, orderBy, getSortValue);
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
});
it('should sort by number column descending', () => {
const orderBy: SortState = { columnName: 'value', order: 'desc' };
const result = sortByColumn(alerts, orderBy, getSortValue);
expect(result.map((a) => a.value)).toStrictEqual([3, 2, 1]);
});
it('should use defaultSort when orderBy is null', () => {
const defaultSort: SortState = { columnName: 'value', order: 'asc' };
const result = sortByColumn(alerts, null, getSortValue, defaultSort);
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
});
it('should not mutate original array', () => {
const original = [...alerts];
const orderBy: SortState = { columnName: 'name', order: 'asc' };
sortByColumn(alerts, orderBy, getSortValue);
expect(alerts).toStrictEqual(original);
});
it('should handle empty array', () => {
const result = sortByColumn(
[],
{ columnName: 'name', order: 'asc' },
getSortValue,
);
expect(result).toStrictEqual([]);
});
it('should handle equal values', () => {
const duplicates = [
createAlert('Same', 1),
createAlert('Same', 1),
createAlert('Same', 1),
];
const orderBy: SortState = { columnName: 'name', order: 'asc' };
const result = sortByColumn(duplicates, orderBy, getSortValue);
expect(result).toHaveLength(3);
});
});
describe('searchByLabels', () => {
const alerts: TestAlert[] = [
createAlert('CPU High', 1, { severity: 'critical', team: 'infra' }),
createAlert('Memory Warning', 2, { severity: 'warning', team: 'backend' }),
createAlert('Disk Full', 3, { severity: 'error', region: 'us-east' }),
createAlert('Network Slow', 4, {}),
createAlert('No Labels', 5),
];
const getAlertName = (alert: TestAlert): string => alert.name;
it('should return all items when search is empty', () => {
const result = searchByLabels(alerts, '', getAlertName);
expect(result).toStrictEqual(alerts);
});
it('should return all items when search is whitespace', () => {
const result = searchByLabels(alerts, ' ', getAlertName);
expect(result).toStrictEqual(alerts);
});
it('should search by alert name', () => {
const result = searchByLabels(alerts, 'CPU', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
it('should search by alert name case-insensitive', () => {
const result = searchByLabels(alerts, 'cpu', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
it('should search by severity label', () => {
const result = searchByLabels(alerts, 'critical', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
it('should search by any label key', () => {
const result = searchByLabels(alerts, 'team', getAlertName);
expect(result).toHaveLength(2);
});
it('should search by any label value', () => {
const result = searchByLabels(alerts, 'infra', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
it('should handle alerts with no labels', () => {
const result = searchByLabels(alerts, 'No Labels', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('No Labels');
});
it('should handle partial matches', () => {
const result = searchByLabels(alerts, 'warn', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Memory Warning');
});
it('should return empty for no matches', () => {
const result = searchByLabels(alerts, 'nonexistent', getAlertName);
expect(result).toStrictEqual([]);
});
it('should trim search text', () => {
const result = searchByLabels(alerts, ' CPU ', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
});
describe('filterByLabels', () => {
const alerts: TestAlert[] = [
createAlert('A1', 1, { severity: 'critical', team: 'infra', env: 'prod' }),
createAlert('A2', 2, { severity: 'critical', team: 'backend', env: 'prod' }),
createAlert('A3', 3, { severity: 'warning', team: 'infra', env: 'staging' }),
createAlert('A4', 4, { severity: 'info', team: 'frontend', env: 'dev' }),
createAlert('A5', 5, {}),
createAlert('A6', 6),
];
const createFilter = (value: string): FilterValue => ({ value });
it('should return all items when filters are empty', () => {
const result = filterByLabels(alerts, []);
expect(result).toStrictEqual(alerts);
});
it('should return all items when filters is null-ish', () => {
const result = filterByLabels(alerts, null as unknown as FilterValue[]);
expect(result).toStrictEqual(alerts);
});
it('should filter by single label', () => {
const filters = [createFilter('severity:critical')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2']);
});
it('should use OR logic for same key', () => {
const filters = [
createFilter('severity:critical'),
createFilter('severity:warning'),
];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(3);
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2', 'A3']);
});
it('should use AND logic for different keys', () => {
const filters = [
createFilter('severity:critical'),
createFilter('team:infra'),
];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('A1');
});
it('should handle case-insensitive keys', () => {
const filters = [createFilter('SEVERITY:critical')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
});
it('should handle case-insensitive values', () => {
const filters = [createFilter('severity:CRITICAL')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
});
it('should trim whitespace', () => {
const filters = [createFilter(' severity : critical ')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
});
it('should return empty for invalid filter format', () => {
const filters = [createFilter('invalid')];
const result = filterByLabels(alerts, filters);
expect(result).toStrictEqual([]);
});
it('should ignore invalid filters mixed with valid', () => {
const filters = [createFilter('invalid'), createFilter('severity:critical')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
});
it('should exclude alerts without matching label key', () => {
const filters = [createFilter('nonexistent:value')];
const result = filterByLabels(alerts, filters);
expect(result).toStrictEqual([]);
});
it('should exclude alerts with no labels', () => {
const filters = [createFilter('severity:critical')];
const result = filterByLabels(alerts, filters);
expect(result.every((a) => a.labels !== undefined)).toBe(true);
});
it('should handle complex AND/OR combinations', () => {
const filters = [
createFilter('env:prod'),
createFilter('env:staging'),
createFilter('team:infra'),
];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A3']);
});
});

View File

@@ -0,0 +1,116 @@
import type { SortState } from 'components/TanStackTableView/types';
import type { AlertWithLabels, FilterValue } from './types';
/**
* Generic sort function for alert-like data
*/
export function sortByColumn<T>(
items: T[],
orderBy: SortState | null,
getSortValue: (item: T, columnName: string) => string | number,
defaultSort?: SortState,
): T[] {
const sortState = orderBy ?? defaultSort;
if (!sortState) {
return items;
}
const { columnName, order } = sortState;
const multiplier = order === 'asc' ? 1 : -1;
return [...items].sort((a, b) => {
const aVal = getSortValue(a, columnName);
const bVal = getSortValue(b, columnName);
if (aVal < bVal) {
return -1 * multiplier;
}
if (aVal > bVal) {
return 1 * multiplier;
}
return 0;
});
}
/**
* Search alerts/rules by name, severity, and all labels
*/
export function searchByLabels<T extends AlertWithLabels>(
items: T[],
searchText: string,
getAlertName: (item: T) => string,
): T[] {
if (!searchText.trim()) {
return items;
}
const value = searchText.toLowerCase().trim();
return items.filter((item) => {
const alertName = getAlertName(item).toLowerCase();
const severity = item.labels?.severity?.toLowerCase() ?? '';
const labelSearchString = Object.entries(item.labels ?? {})
.map(([key, val]) => `${key} ${val}`)
.join(' ')
.toLowerCase();
return (
alertName.includes(value) ||
severity.includes(value) ||
labelSearchString.includes(value)
);
});
}
/**
* Filter alerts by label key:value pairs
* Same key uses OR logic, different keys use AND logic
*/
export function filterByLabels<T extends AlertWithLabels>(
items: T[],
selectedFilters: FilterValue[],
): T[] {
if (!selectedFilters?.length) {
return items;
}
const validFilters = selectedFilters
.map((e) => e.value)
.filter((v) => v.split(':').length === 2);
if (!validFilters.length) {
return [];
}
// Group values by key - same key uses OR, different keys use AND
const filtersByKey = new Map<string, string[]>();
validFilters.forEach((f) => {
const [key, value] = f.split(':');
const trimmedKey = key.trim().toLowerCase();
const trimmedValue = value.trim().toLowerCase();
const existing = filtersByKey.get(trimmedKey) ?? [];
existing.push(trimmedValue);
filtersByKey.set(trimmedKey, existing);
});
return items.filter((item) => {
if (!item.labels) {
return false;
}
// All keys must match (AND), any value per key can match (OR)
return Array.from(filtersByKey.entries()).every(([filterKey, values]) => {
// Case-insensitive key lookup
const matchingKey = Object.keys(item.labels ?? {}).find(
(k) => k.toLowerCase() === filterKey,
);
if (!matchingKey) {
return false;
}
const labelValue = item.labels?.[matchingKey]?.toLowerCase();
return values.some((v) => labelValue === v);
});
});
}

View File

@@ -41,14 +41,22 @@ $item-spacing: 8px;
width: 100%;
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
outline: none;
height: auto;
color: var(--l1-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
padding: 0;
&.ant-input:focus {
&:focus,
&:focus-visible,
&:hover {
border: none;
box-shadow: none;
outline: none;
}
&::placeholder {

View File

@@ -6,7 +6,7 @@ import {
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { TimezonePickerShortcuts } from 'constants/shortcuts/TimezonePickerShortcuts';

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Card, Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Card, Form } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';

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

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Button, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { X } from '@signozhq/icons';

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Input, InputNumber, Popover, Tooltip } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';

View File

@@ -266,6 +266,14 @@
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {
border-color: var(--l2-border);
border-left-color: transparent;
outline: none;
box-shadow: none;
}
}
}
}
@@ -291,5 +299,21 @@
.cm-placeholder {
font-size: 12px !important;
}
$add-on-row-height: 38px;
.periscope-input-with-label {
.input {
.ant-select {
height: $add-on-row-height;
}
}
}
.input-with-label {
.input {
height: $add-on-row-height;
}
}
}
}

View File

@@ -4,6 +4,23 @@
padding: 12px;
gap: 12px;
border-bottom: 1px solid var(--l1-border);
.search {
input {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
&::placeholder {
color: var(--l3-foreground);
}
--input-font-size: 14px;
--input-border-color: var(--l1-border);
--input-focus-border-color: var(--primary-background);
--input-focus-outline-width: 0;
--input-focus-outline-offset: 0;
}
}
.filter-header-checkbox {
display: flex;
align-items: center;

View File

@@ -1,6 +1,7 @@
/* eslint-disable sonarjs/no-identical-functions */
import { Fragment, useMemo, useState } from 'react';
import { Button, Input, Skeleton } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Skeleton } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { Button, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';

View File

@@ -0,0 +1,12 @@
.route-tab-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px;
}
.route-tab-extra {
display: flex;
align-items: center;
}

View File

@@ -70,7 +70,7 @@ describe('RouteTab component', () => {
</Router>,
);
expect(history.location.pathname).toBe('/');
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Tab2' }));
expect(history.location.pathname).toBe('/tab2');
});
@@ -87,7 +87,7 @@ describe('RouteTab component', () => {
/>
</Router>,
);
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Tab2' }));
expect(onChangeHandler).toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,17 @@
import './RouteTab.styles.scss';
import {
generatePath,
matchPath,
useLocation,
useParams,
} from 'react-router-dom';
import { Tabs, TabsProps } from 'antd';
import {
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { RouteTabProps } from './types';
@@ -16,11 +23,13 @@ interface Params {
function RouteTab({
routes,
activeKey,
defaultActiveKey,
onChangeHandler,
history,
showRightSection,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
showRightSection = true,
tabBarExtraContent,
hideTabBar = false,
}: RouteTabProps): JSX.Element {
const params = useParams<Params>();
const location = useLocation();
@@ -46,38 +55,38 @@ function RouteTab({
}
};
const items = routes.map(({ Component, name, route, key }) => ({
label: name,
key,
tabKey: route,
children: <Component />,
}));
const resolvedActiveKey = currentRoute?.key || activeKey;
const extraContent =
tabBarExtraContent ??
(showRightSection && (
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
));
return (
<Tabs
onChange={onChange}
destroyInactiveTabPane
activeKey={currentRoute?.key || activeKey}
defaultActiveKey={currentRoute?.key || activeKey}
animated
items={items}
tabBarExtraContent={
showRightSection && (
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
)
}
{...rest}
/>
<TabsRoot
value={resolvedActiveKey}
defaultValue={defaultActiveKey ?? resolvedActiveKey}
onValueChange={onChange}
>
{!hideTabBar && (
<div className="route-tab-header">
<TabsList>
{routes.map(({ name, key }) => (
<TabsTrigger key={key} value={key}>
{name}
</TabsTrigger>
))}
</TabsList>
{extraContent && <div className="route-tab-extra">{extraContent}</div>}
</div>
)}
{routes.map(({ key, Component }) => (
<TabsContent key={key} value={key}>
<Component />
</TabsContent>
))}
</TabsRoot>
);
}
RouteTab.defaultProps = {
onChangeHandler: undefined,
showRightSection: true,
};
export default RouteTab;

View File

@@ -1,5 +1,5 @@
import { TabsProps } from 'antd';
import { History } from 'history';
import { ReactNode } from 'react';
export type TabRoutes = {
name: React.ReactNode;
@@ -10,8 +10,11 @@ export type TabRoutes = {
export interface RouteTabProps {
routes: TabRoutes[];
activeKey: TabsProps['activeKey'];
activeKey: string | undefined;
defaultActiveKey?: string;
onChangeHandler?: (key: string) => void;
history: History<unknown>;
showRightSection: boolean;
showRightSection?: boolean;
tabBarExtraContent?: ReactNode;
hideTabBar?: boolean;
}

View File

@@ -16,7 +16,7 @@ import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Pagination } from '@signozhq/ui/pagination';
import type { Row } from '@tanstack/react-table';
@@ -51,7 +51,7 @@ import { useEffectiveData } from './useEffectiveData';
import { useFlatItems } from './useFlatItems';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import { buildTanstackColumnDef } from './utils';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
@@ -66,14 +66,6 @@ const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
const noopColumnVisibility = (): void => {};
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
(value) => ({
value: value.toString(),
label: value.toString(),
displayValue: value.toString(),
}),
);
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackTableInner<TData>(
{
@@ -89,7 +81,6 @@ function TanStackTableInner<TData>(
enableQueryParams,
pagination,
paginationClassname,
onSort,
onEndReached,
getRowKey,
getItemKey,
@@ -102,6 +93,7 @@ function TanStackTableInner<TData>(
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
@@ -129,17 +121,22 @@ function TanStackTableInner<TData>(
const {
page,
limit,
setPage,
setLimit,
setPage: internalSetPage,
setLimit: internalSetLimit,
orderBy,
setOrderBy: internalSetOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
page: pagination?.defaultPage,
limit: pagination?.defaultLimit,
limit: pagination?.defaultLimit ?? pagination?.calculatedPageSize ?? 10,
});
const pageSizeItems = useMemo(
() => buildPageSizeItems(pagination?.calculatedPageSize),
[pagination?.calculatedPageSize],
);
const setOrderBy = useCallback(
(sort: SortState | null) => {
internalSetOrderBy(sort);
@@ -148,6 +145,23 @@ function TanStackTableInner<TData>(
[internalSetOrderBy, onSort],
);
const setPage = useCallback(
(p: number) => {
internalSetPage(p);
pagination?.onPageChange?.(p);
},
[internalSetPage, pagination],
);
const setLimit = useCallback(
(l: number) => {
internalSetLimit(l);
internalSetPage(1);
pagination?.onLimitChange?.(l);
},
[internalSetLimit, internalSetPage, pagination],
);
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
@@ -621,6 +635,7 @@ function TanStackTableInner<TData>(
{pagination.showPageSize !== false && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
testId="pagination-page-size"
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => {
@@ -631,7 +646,7 @@ function TanStackTableInner<TData>(
pagination.onPageChange?.(1);
}
}}
items={paginationPageSizeItems}
items={pageSizeItems}
/>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
@@ -23,12 +23,13 @@ jest.mock('../TanStackTable.module.scss', () => ({
},
}));
// Mock ResizeObserver for combobox tests
global.ResizeObserver = class ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
beforeAll(() => {
window.ResizeObserver = jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
});
describe('TanStackTableView Integration', () => {
describe('rendering', () => {
@@ -402,6 +403,22 @@ describe('TanStackTableView Integration', () => {
});
});
it('preserves page from URL on initial mount', async () => {
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
enableQueryParams: true,
},
queryParams: { page: '3' },
});
const nav = await screen.findByRole('navigation');
const page3Button = within(nav).getByRole('button', { name: '3' });
// Page 3 should be active (from URL), not reset to defaultPage 1
expect(page3Button).toHaveAttribute('aria-current', 'page');
});
it('resets page to 1 when limit changes', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();

View File

@@ -0,0 +1,25 @@
import { renderHook } from '@testing-library/react';
import { useCalculatedPageSize } from '../useCalculatedPageSize';
describe('useCalculatedPageSize', () => {
it('returns containerRef and null calculatedPageSize initially', () => {
const { result } = renderHook(() => useCalculatedPageSize());
expect(result.current.containerRef).toBeDefined();
expect(result.current.containerRef.current).toBeNull();
expect(result.current.calculatedPageSize).toBeNull();
});
it('accepts custom config', () => {
const { result } = renderHook(() =>
useCalculatedPageSize({
rowHeight: 50,
headerHeight: 40,
paginationHeight: 50,
minPageSize: 3,
maxPageSize: 20,
}),
);
expect(result.current.containerRef).toBeDefined();
});
});

View File

@@ -0,0 +1,89 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import {
getPreferredPageSize,
usePreferredPageSize,
usePreferredPageSizeStore,
} from '../usePreferredPageSize.store';
const STORAGE_KEY = 'test-table';
const FULL_STORAGE_KEY = '@signoz/table-columns/test-table-preferred-page-size';
describe('usePreferredPageSize', () => {
beforeEach(() => {
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
it('returns null when no stored value exists', () => {
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
expect(result.current[0]).toBeNull();
});
it('returns null when storageKey is undefined', () => {
const { result } = renderHook(() => usePreferredPageSize(undefined));
expect(result.current[0]).toBeNull();
});
it('loads stored page size from localStorage', () => {
localStorage.setItem(FULL_STORAGE_KEY, '25');
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
expect(result.current[0]).toBe(25);
});
it('ignores invalid stored values', () => {
localStorage.setItem(FULL_STORAGE_KEY, 'invalid');
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
expect(result.current[0]).toBeNull();
});
it('persists page size to localStorage when set', () => {
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
act(() => {
result.current[1](30);
});
expect(result.current[0]).toBe(30);
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBe('30');
});
it('removes from localStorage when set to null', () => {
localStorage.setItem(FULL_STORAGE_KEY, '25');
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
act(() => {
result.current[1](null);
});
expect(result.current[0]).toBeNull();
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBeNull();
});
it('does nothing when storageKey is undefined and set is called', () => {
const { result } = renderHook(() => usePreferredPageSize(undefined));
act(() => {
result.current[1](30);
});
expect(result.current[0]).toBeNull();
});
});
describe('getPreferredPageSize', () => {
beforeEach(() => {
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
it('returns null when no stored value exists', () => {
expect(getPreferredPageSize(STORAGE_KEY)).toBeNull();
});
it('returns stored value from localStorage', () => {
localStorage.setItem(FULL_STORAGE_KEY, '42');
expect(getPreferredPageSize(STORAGE_KEY)).toBe(42);
});
});

View File

@@ -7,6 +7,7 @@ import {
} from 'nuqs/adapters/testing';
import { useTableParams } from '../useTableParams';
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
function createNuqsWrapper(
queryParams?: Record<string, string>,
@@ -543,3 +544,406 @@ describe('useTableParams (selective URL mode — partial config object)', () =>
});
});
});
describe('useTableParams (cleanupOnUnmount option)', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
afterEach(() => {
jest.useRealTimers();
});
it('clears URL params on unmount when cleanupOnUnmount is true', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
{
page: 1,
limit: 10,
cleanupOnUnmount: true,
},
),
{ wrapper },
);
// Set some values
await act(async () => {
result.current.setLimit(50);
result.current.setPage(3);
jest.runAllTimers();
await Promise.resolve();
});
// Verify values set
expect(result.current.limit).toBe(50);
expect(result.current.page).toBe(3);
// Unmount triggers cleanup
unmount();
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// Last URL update should have cleared params
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
expect(lastUpdate[0].searchParams.get('limit')).toBeNull();
expect(lastUpdate[0].searchParams.get('page')).toBeNull();
});
it('does not clear URL params on unmount when cleanupOnUnmount is false', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
cleanupOnUnmount: false,
},
),
{ wrapper },
);
await act(async () => {
result.current.setLimit(50);
jest.runAllTimers();
await Promise.resolve();
});
expect(result.current.limit).toBe(50);
unmount();
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// No new URL updates after unmount (or same count)
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
});
it('defaults cleanupOnUnmount to false', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams({ page: 'page', limit: 'limit' }, { page: 1, limit: 10 }),
{ wrapper },
);
await act(async () => {
result.current.setLimit(50);
jest.runAllTimers();
await Promise.resolve();
});
unmount();
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// URL should still have limit=50 (cleanup not triggered)
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
});
});
describe('useTableParams (auto page size with storageKey)', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
afterEach(() => {
jest.useRealTimers();
});
it('uses explicit default when no URL, no calculated, no preferred', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: null,
},
),
{ wrapper },
);
// Should use explicit default (10), NOT the internal DEFAULT_LIMIT (50)
expect(result.current.limit).toBe(10);
});
it('uses calculatedPageSize when available and no preferred', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
expect(result.current.limit).toBe(42);
});
it('prefers stored value over calculatedPageSize', () => {
// Pre-populate the store
localStorage.setItem(
'@signoz/table-columns/test-table-preferred-page-size',
'25',
);
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Should use preferred (25), not calculated (42)
expect(result.current.limit).toBe(25);
});
it('preserves URL limit over calculated and preferred', () => {
localStorage.setItem(
'@signoz/table-columns/test-table-preferred-page-size',
'25',
);
const wrapper = createNuqsWrapper({ limit: '30' });
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Should use URL (30), not preferred (25) or calculated (42)
expect(result.current.limit).toBe(30);
});
it('persists user selection when different from calculated', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// User selects 30 (different from calculated 42)
act(() => {
result.current.setLimit(30);
jest.runAllTimers();
});
expect(result.current.limit).toBe(30);
expect(
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
).toBe('30');
});
it('clears preference when user selects calculated value', () => {
// Pre-set a preference
localStorage.setItem(
'@signoz/table-columns/test-table-preferred-page-size',
'30',
);
usePreferredPageSizeStore.setState({ tables: { 'test-table': 30 } });
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// User selects 42 (same as calculated)
act(() => {
result.current.setLimit(42);
jest.runAllTimers();
});
expect(result.current.limit).toBe(42);
// Preference should be cleared (null removes from storage)
expect(
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
).toBeNull();
});
it('returns calculated value even before URL is synced', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Limit should be 42 (calculated) even if URL sync is async
expect(result.current.limit).toBe(42);
});
it('does not override URL when it already has a value', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
// Limit should stay at 30 (from URL), not change to 42
expect(result.current.limit).toBe(30);
});
it('handles calculatedPageSize changing from null to number', () => {
const wrapper = createNuqsWrapper();
const { result, rerender } = renderHook(
({ calculated }) =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table-2',
calculatedPageSize: calculated,
},
),
{ wrapper, initialProps: { calculated: null as number | null } },
);
// Initially should use explicit default (10)
expect(result.current.limit).toBe(10);
// When calculated becomes available, should update
rerender({ calculated: 42 });
act(() => {
jest.runAllTimers();
});
// Limit should now be 42
expect(result.current.limit).toBe(42);
});
it('keeps user selection when calculatedPageSize changes', () => {
const wrapper = createNuqsWrapper();
const { result, rerender } = renderHook(
({ calculated }) =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table-3',
calculatedPageSize: calculated,
},
),
{ wrapper, initialProps: { calculated: 42 as number | null } },
);
expect(result.current.limit).toBe(42);
// User selects 30
act(() => {
result.current.setLimit(30);
jest.runAllTimers();
});
expect(result.current.limit).toBe(30);
// calculatedPageSize changes (e.g., window resize)
rerender({ calculated: 50 });
act(() => {
jest.runAllTimers();
});
// Should keep user's selection (30), not change to new calculated (50)
expect(result.current.limit).toBe(30);
});
});

View File

@@ -0,0 +1,199 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useQueryStates, parseAsInteger } from 'nuqs';
import {
NuqsTestingAdapter,
OnUrlUpdateFunction,
UrlUpdateEvent,
} from 'nuqs/adapters/testing';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { useTableParams } from '../useTableParams';
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
function createNuqsWrapper(
queryParams?: Record<string, string>,
onUrlUpdate?: OnUrlUpdateFunction,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function NuqsWrapper({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
};
}
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
page: 'page',
limit: 'limit',
} as const;
type TableParamsWithCleanup = ReturnType<typeof useTableParams> & {
clearParams: ReturnType<typeof useQueryStates>[1];
};
/**
* Simulates the cleanup pattern used in ListAlertRules:
* - Uses useQueryStates to clear URL params on unmount
*/
function useTableParamsWithCleanup(
storageKey: string,
calculatedPageSize: number | null,
): TableParamsWithCleanup {
const result = useTableParams(QUERY_PARAMS_CONFIG, {
page: 1,
limit: 10,
storageKey,
calculatedPageSize,
});
// This mirrors the cleanup effect in ListAlertRules
const [, setTableQueryParams] = useQueryStates({
[QUERY_PARAMS_CONFIG.orderBy]: parseAsJsonNoValidate(),
[QUERY_PARAMS_CONFIG.page]: parseAsInteger,
[QUERY_PARAMS_CONFIG.limit]: parseAsInteger,
});
// Note: We can't use useEffect cleanup in tests easily, but we can verify
// that calling setTableQueryParams with nulls does clear the URL
return { ...result, clearParams: setTableQueryParams };
}
describe('URL cleanup pattern (simulating ListAlertRules behavior)', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
afterEach(() => {
jest.useRealTimers();
});
it('setTableQueryParams with null values should clear URL params', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParamsWithCleanup('alert-rules', 42),
{ wrapper },
);
// Set limit to 100
await act(async () => {
result.current.setLimit(100);
jest.runAllTimers();
await Promise.resolve();
});
expect(result.current.limit).toBe(100);
// Verify limit=100 is in URL
const limitAfterSet = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('limit'))
.filter(Boolean)
.pop();
expect(limitAfterSet).toBe('100');
// Simulate cleanup: clear all params
await act(async () => {
void result.current.clearParams({
orderBy: null,
page: null,
limit: null,
});
jest.runAllTimers();
await Promise.resolve();
});
// Verify limit was cleared (last update should have limit=null or removed)
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
const finalLimit = lastUpdate[0].searchParams.get('limit');
expect(finalLimit).toBeNull();
});
it('cleanup should work even when limit was set from localStorage preference', async () => {
// Pre-set preference
localStorage.setItem(
'@signoz/table-columns/alert-rules-preferred-page-size',
'100',
);
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParamsWithCleanup('alert-rules', 42),
{ wrapper },
);
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// Should use preferred value
expect(result.current.limit).toBe(100);
// Simulate cleanup
await act(async () => {
void result.current.clearParams({
orderBy: null,
page: null,
limit: null,
});
jest.runAllTimers();
await Promise.resolve();
});
// URL should be cleared
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
const finalLimit = lastUpdate[0].searchParams.get('limit');
expect(finalLimit).toBeNull();
});
it('demonstrates the bug: component without cleanup leaves limit in URL', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Mount TriggeredAlerts-like component (no cleanup)
const { result, unmount } = renderHook(
() =>
useTableParams(QUERY_PARAMS_CONFIG, {
page: 1,
limit: 10,
storageKey: 'triggered-alerts',
calculatedPageSize: 42,
}),
{ wrapper },
);
// Set limit to 100
await act(async () => {
result.current.setLimit(100);
jest.runAllTimers();
await Promise.resolve();
});
expect(result.current.limit).toBe(100);
// Unmount WITHOUT cleanup
unmount();
// Verify limit=100 is STILL in URL (this is the bug!)
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
const finalLimit = lastUpdate[0].searchParams.get('limit');
expect(finalLimit).toBe('100'); // BUG: limit persists after unmount
});
});

View File

@@ -0,0 +1,385 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import {
NuqsTestingAdapter,
OnUrlUpdateFunction,
UrlUpdateEvent,
} from 'nuqs/adapters/testing';
import { useTableParams } from '../useTableParams';
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
function createNuqsWrapper(
queryParams?: Record<string, string>,
onUrlUpdate?: OnUrlUpdateFunction,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function NuqsWrapper({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
};
}
describe('useTableParams navigation scenarios', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
afterEach(() => {
jest.useRealTimers();
});
describe('Tab navigation: Alert Rules -> Configuration -> Routing Policies', () => {
it('preferred value from one table should NOT leak to URL when navigating away', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Simulate Alert Rules: user sets limit=100
const alertRules = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
{
page: 1,
limit: 10,
storageKey: 'alert-rules',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// User selects limit=100
act(() => {
alertRules.result.current.setLimit(100);
jest.runAllTimers();
});
expect(alertRules.result.current.limit).toBe(100);
// Verify it's persisted in localStorage
expect(
localStorage.getItem(
'@signoz/table-columns/alert-rules-preferred-page-size',
),
).toBe('100');
// Simulate unmount (user navigates away)
alertRules.unmount();
// At this point, the URL should NOT have limit=100 from alert-rules
// when another component mounts with a different storageKey
});
it('different tables with different storageKeys maintain separate preferences', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Alert Rules sets limit=100
localStorage.setItem(
'@signoz/table-columns/alert-rules-preferred-page-size',
'100',
);
// Triggered Alerts sets limit=25
localStorage.setItem(
'@signoz/table-columns/triggered-alerts-preferred-page-size',
'25',
);
// Mount Triggered Alerts (simulating tab switch from Alert Rules)
const triggeredAlerts = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
{
page: 1,
limit: 10,
storageKey: 'triggered-alerts',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
// Should use triggered-alerts preference (25), NOT alert-rules (100)
expect(triggeredAlerts.result.current.limit).toBe(25);
});
it('table without storageKey should NOT write preference to URL from another table', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
// Pre-set alert-rules preference
localStorage.setItem(
'@signoz/table-columns/alert-rules-preferred-page-size',
'100',
);
// Start fresh with NO URL params
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Mount a table WITHOUT storageKey (simulating a simple table)
const simpleTable = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
// NO storageKey
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
// Should use calculated (42), not alert-rules preference (100)
expect(simpleTable.result.current.limit).toBe(42);
});
});
describe('URL cleanup on unmount', () => {
it('URL params should be cleanable by consumer on unmount', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
{
page: 1,
limit: 10,
storageKey: 'test-cleanup',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Set some values
act(() => {
result.current.setLimit(50);
result.current.setPage(3);
jest.runAllTimers();
});
// Verify URL was updated
const limitUpdates = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('limit'))
.filter(Boolean);
expect(limitUpdates).toContain('50');
// Unmount (note: useTableParams itself doesn't cleanup URL - consumer should)
unmount();
// Verify the component unmounted (no errors)
expect(true).toBe(true);
});
});
describe('Parallel tables sharing URL params', () => {
it('two tables using same URL params should see same values when URL pre-set', () => {
const wrapper = createNuqsWrapper({ limit: '30' });
const table1 = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'table-1',
calculatedPageSize: 42,
},
),
{ wrapper },
);
const table2 = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 20,
storageKey: 'table-2',
calculatedPageSize: 50,
},
),
{ wrapper },
);
// Both should see URL value (30), not their defaults
expect(table1.result.current.limit).toBe(30);
expect(table2.result.current.limit).toBe(30);
});
it('table mounted after setLimit should see updated URL value', () => {
const wrapper = createNuqsWrapper();
// Table1 mounts first
const table1 = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'table-1',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
expect(table1.result.current.limit).toBe(42);
// Table1 sets limit to 100
act(() => {
table1.result.current.setLimit(100);
jest.runAllTimers();
});
expect(table1.result.current.limit).toBe(100);
// Table2 mounts AFTER table1 set limit=100 in URL
// In test environment, URL state doesn't persist between renderHook calls
// This test documents current behavior - each hook instance is independent
});
});
describe('URL state initialization race conditions', () => {
it('should not write preferred value to URL if URL already has value', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
// Pre-set preference
localStorage.setItem(
'@signoz/table-columns/test-table-preferred-page-size',
'100',
);
// URL already has limit=30
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
// Should use URL (30), not preferred (100)
expect(result.current.limit).toBe(30);
// URL should NOT have been overwritten with 100
const limitUpdates = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('limit'))
.filter((v) => v === '100');
expect(limitUpdates).toHaveLength(0);
});
it('URL init effect should write calculated value when URL empty', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Mount with no URL params
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'table-1',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Effects run after render, need to flush
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// Should use calculated value
expect(result.current.limit).toBe(42);
// The URL init effect writes to URL asynchronously
// Check that limit is 42 (which it is from the limitDefault calculation)
});
it('consumer cleanup effect is responsible for clearing URL params', () => {
// This test documents that useTableParams does NOT auto-cleanup URL
// Consumer components (like ListAlertRules) must use useEffect cleanup
// to clear URL params when unmounting
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'table-1',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
result.current.setLimit(100);
jest.runAllTimers();
});
expect(result.current.limit).toBe(100);
// Unmount - useTableParams does NOT clear URL
unmount();
// Verify unmount happened without clearing URL
// The last URL update should still have limit=100, not null
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
expect(lastUpdate[0].searchParams.get('limit')).toBe('100');
});
});
});

View File

@@ -3,8 +3,10 @@ import TanStackTableText from './TanStackTableText';
export * from './TanStackTableStateContext';
export * from './types';
export * from './useCalculatedPageSize';
export * from './useColumnState';
export * from './useColumnStore';
export * from './usePreferredPageSize.store';
export * from './useTableParams';
/**
@@ -192,6 +194,67 @@ export * from './useTableParams';
* )}
* />
* ```
*
* @example useTableParams — manages pagination state with URL sync and persistence
*
* The `useTableParams` hook handles page, limit, orderBy, and expanded state. It can sync
* to URL params, persist user's page size preference, and auto-calculate page size from
* container height.
*
* **Priority chain for limit**: URL > preferred (localStorage) > calculated > explicit default > 50
*
* ```tsx
* import { useCalculatedPageSize, useTableParams } from 'components/TanStackTableView';
*
* const QUERY_PARAMS = { page: 'page', limit: 'limit', orderBy: 'orderBy' } as const;
*
* function MyTable({ data, columns }) {
* // Auto-calculate page size based on container height
* const { containerRef, calculatedPageSize } = useCalculatedPageSize({ rowHeight: 42 });
*
* // useTableParams options:
* // - storageKey: persists user's page size selection to localStorage
* // - calculatedPageSize: uses this when no URL/preferred value exists
* // - cleanupOnUnmount: clears URL params when component unmounts
* const { page, limit, setLimit, orderBy } = useTableParams(QUERY_PARAMS, {
* page: 1,
* limit: 10,
* storageKey: 'my-table',
* calculatedPageSize,
* cleanupOnUnmount: true,
* });
*
* const paginatedData = useMemo(() => {
* const start = (page - 1) * limit;
* return data.slice(start, start + limit);
* }, [data, page, limit]);
*
* return (
* <div ref={containerRef} style={{ height: '100%' }}>
* <TanStackTable
* data={paginatedData}
* columns={columns}
* enableQueryParams={QUERY_PARAMS}
* pagination={{
* total: data.length,
* calculatedPageSize,
* onLimitChange: setLimit,
* }}
* />
* </div>
* );
* }
* ```
*
* **useTableParams options:**
* - `storageKey`: Persists user's page size to localStorage. When user selects a size
* different from calculated, it's saved. Selecting calculated size clears preference.
* - `calculatedPageSize`: From `useCalculatedPageSize`. Used as default when no URL/preferred.
* - `cleanupOnUnmount`: Clears URL params (page, limit, orderBy, expanded) on unmount.
* Use when navigating away should reset table state.
*
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
* to reset to auto-calculated size.
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -74,6 +74,7 @@ export type TableColumnDef<
min?: number | string;
default?: number | string;
max?: number | string;
ignoreLastColumnFill?: boolean;
};
};
@@ -111,6 +112,14 @@ export type TableRowContext<TData> = {
enableAlternatingRowColors?: boolean;
};
export type AutoPageSizeConfig = {
rowHeight?: number;
headerHeight?: number;
paginationHeight?: number;
minPageSize?: number;
maxPageSize?: number;
};
export type PaginationProps = {
total: number;
defaultPage?: number;
@@ -123,6 +132,12 @@ export type PaginationProps = {
onLimitChange?: (limit: number) => void;
showTotalCount?: boolean;
totalCountLabel?: string;
/**
* Auto-calculated page size for the current container.
* When set, shows as "Auto (N)" option in the page size dropdown.
* Consumer is responsible for calculating this via useCalculatedPageSize.
*/
calculatedPageSize?: number | null;
};
export type TanstackTableQueryParamsConfig = {

View File

@@ -0,0 +1,76 @@
import type { RefObject } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AutoPageSizeConfig } from './types';
const DEFAULT_ROW_HEIGHT = 36;
const DEFAULT_HEADER_HEIGHT = 36;
const DEFAULT_PAGINATION_HEIGHT = 62;
const MIN_PAGE_SIZE = 5;
const MAX_PAGE_SIZE = 100;
export type UseCalculatedPageSizeResult = {
containerRef: RefObject<HTMLDivElement>;
calculatedPageSize: number | null;
};
export function useCalculatedPageSize(
config?: AutoPageSizeConfig,
): UseCalculatedPageSizeResult {
const containerRef = useRef<HTMLDivElement>(null);
const [calculatedPageSize, setCalculatedPageSize] = useState<number | null>(
null,
);
const rowHeight = config?.rowHeight ?? DEFAULT_ROW_HEIGHT;
const headerHeight = config?.headerHeight ?? DEFAULT_HEADER_HEIGHT;
const paginationHeight = config?.paginationHeight ?? DEFAULT_PAGINATION_HEIGHT;
const minPageSize = config?.minPageSize ?? MIN_PAGE_SIZE;
const maxPageSize = config?.maxPageSize ?? MAX_PAGE_SIZE;
const calculatePageSize = useCallback(
(containerHeight: number): number => {
const availableHeight = containerHeight - headerHeight - paginationHeight;
const rawPageSize = Math.floor(availableHeight / rowHeight);
return Math.min(maxPageSize, Math.max(minPageSize, rawPageSize));
},
[rowHeight, headerHeight, paginationHeight, minPageSize, maxPageSize],
);
useEffect(() => {
if (!containerRef.current) {
return;
}
const container = containerRef.current;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const { height } = entry.contentRect;
if (height > 0) {
const newPageSize = calculatePageSize(height);
setCalculatedPageSize((prev) =>
prev !== newPageSize ? newPageSize : prev,
);
}
});
observer.observe(container);
const { height } = container.getBoundingClientRect();
if (height > 0) {
setCalculatedPageSize(calculatePageSize(height));
}
return (): void => {
observer.disconnect();
};
}, [calculatePageSize]);
return { containerRef, calculatedPageSize };
}

View File

@@ -0,0 +1,91 @@
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import remove from 'api/browser/localstorage/remove';
import { create } from 'zustand';
const STORAGE_PREFIX = '@signoz/table-columns/';
const STORAGE_SUFFIX = '-preferred-page-size';
type PreferredPageSizeState = {
tables: Record<string, number | null>;
setPreferredPageSize: (storageKey: string, pageSize: number | null) => void;
};
const getStorageKey = (tableKey: string): string =>
`${STORAGE_PREFIX}${tableKey}${STORAGE_SUFFIX}`;
const loadFromStorage = (tableKey: string): number | null => {
try {
const raw = get(getStorageKey(tableKey));
if (!raw) {
return null;
}
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? null : parsed;
} catch {
return null;
}
};
const saveToStorage = (tableKey: string, pageSize: number | null): void => {
try {
const key = getStorageKey(tableKey);
if (pageSize === null) {
remove(key);
} else {
set(key, String(pageSize));
}
} catch {
// Ignore storage errors
}
};
export const usePreferredPageSizeStore = create<PreferredPageSizeState>()(
(set, get) => ({
tables: {},
setPreferredPageSize: (storageKey, pageSize): void => {
set({ tables: { ...get().tables, [storageKey]: pageSize } });
saveToStorage(storageKey, pageSize);
},
}),
);
export function usePreferredPageSize(
storageKey: string | undefined,
): [number | null, (pageSize: number | null) => void] {
const pageSize = usePreferredPageSizeStore((s) => {
if (!storageKey) {
return null;
}
const cached = s.tables[storageKey];
if (cached !== undefined) {
return cached;
}
return loadFromStorage(storageKey);
});
const setPageSize = usePreferredPageSizeStore((s) => s.setPreferredPageSize);
const setPreferred = (size: number | null): void => {
if (storageKey) {
setPageSize(storageKey, size);
}
};
return [pageSize, setPreferred];
}
export function getPreferredPageSize(storageKey: string): number | null {
// oxlint-disable-next-line signoz/no-zustand-getstate-in-hooks
const state = usePreferredPageSizeStore.getState();
const cached = state.tables[storageKey];
if (cached !== undefined) {
return cached;
}
const stored = loadFromStorage(storageKey);
if (stored !== null) {
state.setPreferredPageSize(storageKey, stored);
}
return stored;
}

View File

@@ -4,6 +4,7 @@ import { parseAsInteger, useQueryState } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { SortState, TanstackTableQueryParamsConfig } from './types';
import { usePreferredPageSize } from './usePreferredPageSize.store';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
@@ -20,9 +21,15 @@ type Defaults = {
limit?: number;
orderBy?: SortState | null;
expanded?: ExpandedState;
/** Storage key for persisting user's page size preference */
storageKey?: string;
/** Auto-calculated page size from container. URL initializes with this when available. */
calculatedPageSize?: number | null;
/** Clear URL params on unmount. Useful when navigating away from table views. */
cleanupOnUnmount?: boolean;
};
type TableParamsResult = {
export type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
@@ -99,15 +106,23 @@ export function useTableParams(
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
: URL_KEYS_DEFAULT.expanded;
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
const expandedDefault = defaults?.expanded ?? {};
const storageKey = defaults?.storageKey;
const calculatedPageSize = defaults?.calculatedPageSize;
const cleanupOnUnmount = defaults?.cleanupOnUnmount ?? false;
const expandedDefaultArray = useMemo(
() => expandedStateToArray(expandedDefault),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const [preferredPageSize, setPreferredPageSize] =
usePreferredPageSize(storageKey);
const limitDefault =
preferredPageSize ?? calculatedPageSize ?? defaults?.limit ?? DEFAULT_LIMIT;
const [localPage, setLocalPage] = useState(pageDefault);
const [localLimit, setLocalLimit] = useState(limitDefault);
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
@@ -120,9 +135,71 @@ export function useTableParams(
pageQueryParam,
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
);
const [urlLimit, setUrlLimit] = useQueryState(
const [urlLimitRaw, setUrlLimitRaw] = useQueryState(
limitQueryParam,
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
parseAsInteger.withOptions(NUQS_OPTIONS),
);
// Track if URL had limit on initial mount
const hadUrlLimitOnMountRef = useRef<boolean | null>(null);
if (hadUrlLimitOnMountRef.current === null) {
hadUrlLimitOnMountRef.current = urlLimitRaw !== null;
}
const hadUrlLimit = hadUrlLimitOnMountRef.current ?? false;
const urlLimit = urlLimitRaw ?? limitDefault;
// Initialize URL with preferred/calculated when available (only if URL was empty)
const hasInitializedUrlRef = useRef(false);
useEffect(() => {
if (!useUrlForLimit || hasInitializedUrlRef.current || hadUrlLimit) {
return;
}
if (preferredPageSize !== null) {
hasInitializedUrlRef.current = true;
void setUrlLimitRaw(preferredPageSize);
return;
}
if (calculatedPageSize != null) {
hasInitializedUrlRef.current = true;
void setUrlLimitRaw(calculatedPageSize);
}
}, [
useUrlForLimit,
calculatedPageSize,
preferredPageSize,
hadUrlLimit,
setUrlLimitRaw,
]);
// Wrapped setLimit that persists preference when different from calculated
const setUrlLimit = useCallback(
(newLimit: number): void => {
if (storageKey) {
if (newLimit !== calculatedPageSize) {
setPreferredPageSize(newLimit);
} else {
setPreferredPageSize(null);
}
}
void setUrlLimitRaw(newLimit);
},
[storageKey, calculatedPageSize, setPreferredPageSize, setUrlLimitRaw],
);
const setLocalLimitWithPersist = useCallback(
(newLimit: number): void => {
if (storageKey) {
if (newLimit !== calculatedPageSize) {
setPreferredPageSize(newLimit);
} else {
setPreferredPageSize(null);
}
}
setLocalLimit(newLimit);
},
[storageKey, calculatedPageSize, setPreferredPageSize],
);
const [urlOrderBy, setUrlOrderBy] = useQueryState(
orderByQueryParam,
@@ -155,7 +232,7 @@ export function useTableParams(
typeof updaterOrValue === 'function'
? updaterOrValue(urlExpandedRef.current)
: updaterOrValue;
setUrlExpandedArray(expandedStateToArray(newState));
void setUrlExpandedArray(expandedStateToArray(newState));
},
[setUrlExpandedArray],
);
@@ -172,21 +249,53 @@ export function useTableParams(
[],
);
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const prevOrderByRef = useRef<string | null>(null);
useEffect(() => {
if (useUrlForPage) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
// Only reset page when orderBy actually changes, not on initial mount
if (
prevOrderByRef.current !== null &&
prevOrderByRef.current !== orderByUrlMemoKey
) {
if (useUrlForPage) {
void setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}
prevOrderByRef.current = orderByUrlMemoKey;
}, [useUrlForPage, orderByUrlMemoKey, pageDefault, setUrlPage]);
useEffect(() => {
if (!cleanupOnUnmount) {
return;
}
return (): void => {
if (useUrlForPage) {
void setUrlPage(null);
}
if (useUrlForLimit) {
void setUrlLimitRaw(null);
}
if (useUrlForOrderBy) {
void setUrlOrderBy(null);
}
if (useUrlForExpanded) {
void setUrlExpandedArray(null);
}
};
}, [
cleanupOnUnmount,
useUrlForPage,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
useUrlForLimit,
useUrlForOrderBy,
useUrlForExpanded,
setUrlPage,
setUrlLimitRaw,
setUrlOrderBy,
setUrlExpandedArray,
]);
return {
@@ -195,7 +304,7 @@ export function useTableParams(
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
};

View File

@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { RowKeyData, TableColumnDef } from './types';
import { ComboboxSimpleItem } from '@signozhq/ui/combobox';
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
column.id;
@@ -34,7 +35,7 @@ export const getColumnWidthStyle = <TData>(
isLastColumn?: boolean,
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
if (isLastColumn && column?.width?.ignoreLastColumnFill !== true) {
return {
width: '100%',
minWidth: persistedWidth ?? column?.width?.min,
@@ -145,3 +146,31 @@ export function buildTanstackColumnDef<TData>(
},
};
}
const DEFAULT_PAGE_SIZES = [10, 20, 30, 50, 100];
export function buildPageSizeItems(
calculatedSize?: number | null,
): ComboboxSimpleItem[] {
const items: ComboboxSimpleItem[] = [];
if (calculatedSize) {
items.push({
value: calculatedSize.toString(),
label: `Auto (${calculatedSize})`,
displayValue: calculatedSize.toString(),
});
}
for (const size of DEFAULT_PAGE_SIZES) {
if (size !== calculatedSize) {
items.push({
value: size.toString(),
label: size.toString(),
displayValue: size.toString(),
});
}
}
return items;
}

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,5 +1,6 @@
import { useMemo, useState } from 'react';
import { Button, Input, Select, Tooltip } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Select, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { CircleX, Trash } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';

View File

@@ -1,4 +1,5 @@
import { Collapse, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Collapse } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useCreateAlertState } from '../context';

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { Input, Select } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import './TimeInput.scss';
export interface TimeInputProps {

View File

@@ -1,4 +1,5 @@
import { Input, Select } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useCreateAlertState } from '../context';

View File

@@ -16,9 +16,10 @@ import {
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Input, Modal, Popover, Tooltip } from 'antd';
import { Button, Card, Modal, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';

View File

@@ -1,70 +0,0 @@
.settings-tabs {
.ant-tabs-nav-list {
height: 32px;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
transition: opacity 0.1s !important;
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px;
}
.ant-tabs-tab:not(:last-child) {
border-right: 1px solid var(--l1-border) !important;
}
.overview-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.variables-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.public-dashboard-btn {
width: 150px;
display: flex;
align-items: center;
justify-content: center;
&.disabled-btn {
opacity: 0.5;
cursor: not-allowed;
}
}
.ant-tabs-ink-bar {
display: none;
}
.ant-tabs-tab-active {
.overview-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.variables-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.public-dashboard-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
}
}
.ant-tabs-nav::before {
border-bottom: none;
}
}

View File

@@ -1,4 +1,4 @@
import { Button, Tabs, Tooltip } from 'antd';
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Braces, Globe, Table } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -9,8 +9,6 @@ import DashboardVariableSettings from './DashboardVariableSettings';
import GeneralDashboardSettings from './General';
import PublicDashboardSetting from './PublicDashboard';
import './DashboardSettingsContent.styles.scss';
function DashboardSettings({
variablesSettingsTabHandle,
}: {
@@ -21,49 +19,26 @@ function DashboardSettings({
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const publicDashboardItem = {
label: (
<Tooltip
title={
user?.role !== USER_ROLES.ADMIN
? 'Only admins can publish / manage public dashboards'
: ''
}
placement="right"
>
<Button
type="text"
icon={<Globe size={14} />}
className={`public-dashboard-btn ${
user?.role !== USER_ROLES.ADMIN ? 'disabled-btn' : ''
}`}
>
Publish
</Button>
</Tooltip>
),
const publicDashboardItem: TabItemProps = {
key: 'public-dashboard',
label: 'Publish',
prefixIcon: <Globe size={14} />,
children: <PublicDashboardSetting />,
disabled: user?.role !== USER_ROLES.ADMIN,
disabledReason: 'Only admins can publish / manage public dashboards',
};
const items = [
const items: TabItemProps[] = [
{
label: (
<Button type="text" icon={<Table size={14} />} className="overview-btn">
Overview
</Button>
),
key: 'general',
label: 'Overview',
prefixIcon: <Table size={14} />,
children: <GeneralDashboardSettings />,
},
{
label: (
<Button type="text" icon={<Braces size={14} />} className="variables-btn">
Variables
</Button>
),
key: 'variables',
label: 'Variables',
prefixIcon: <Braces size={14} />,
children: (
<DashboardVariableSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
@@ -73,7 +48,7 @@ function DashboardSettings({
...(enablePublicDashboard ? [publicDashboardItem] : []),
];
return <Tabs items={items} animated className="settings-tabs" />;
return <Tabs items={items} />;
}
export default DashboardSettings;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { ResizeTable } from 'components/ResizeTable';
import { useNotifications } from 'hooks/useNotifications';

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

@@ -22,7 +22,6 @@ import { Color } from '@signozhq/design-tokens';
import {
Button,
ColorPicker,
Input,
Modal,
RefSelectProps,
Select,
@@ -30,6 +29,7 @@ import {
} from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { Input } from '@signozhq/ui/input';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';

View File

@@ -1,7 +1,7 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { EmailChannel } from '../../CreateAlertChannels/config';
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {

View File

@@ -1,6 +1,7 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { WebhookChannel } from '../../CreateAlertChannels/config';

View File

@@ -1,7 +1,8 @@
import { Dispatch, ReactElement, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, FormInstance, Input, Select } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, FormInstance, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { Store } from 'antd/lib/form/interface';
import ROUTES from 'constants/routes';

View File

@@ -6,7 +6,8 @@ import { useIsFetching } from 'react-query';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Button, Form, Input, Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Form, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';

View File

@@ -5,12 +5,12 @@ import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import {
Col,
Collapse,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Row,

View File

@@ -1,4 +1,5 @@
import { Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
function RenderConnectionFields({

View File

@@ -1,5 +1,4 @@
import { Button, Tabs, TabsProps } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { CableCar, Group } from '@signozhq/icons';
import { IntegrationDetailedProps } from 'types/api/integrations/types';
@@ -22,18 +21,11 @@ function IntegrationDetailContent(
): JSX.Element {
const { activeDetailTab, integrationData, integrationId, setActiveDetailTab } =
props;
const items: TabsProps['items'] = [
const items: TabItemProps[] = [
{
key: 'overview',
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<CableCar size={14} />}
>
<Typography.Text className="typography">Overview</Typography.Text>
</Button>
),
label: 'Overview',
prefixIcon: <CableCar size={14} />,
children: (
<Overview
categories={integrationData.categories}
@@ -44,15 +36,8 @@ function IntegrationDetailContent(
},
{
key: 'configuration',
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<ConfigureIcon />}
>
<Typography.Text className="typography">Configure</Typography.Text>
</Button>
),
label: 'Configure',
prefixIcon: <ConfigureIcon />,
children: (
<Configure
configuration={integrationData.configuration}
@@ -62,15 +47,8 @@ function IntegrationDetailContent(
},
{
key: 'dataCollected',
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<Group size={14} />}
>
<Typography.Text className="typography">Data Collected</Typography.Text>
</Button>
),
label: 'Data Collected',
prefixIcon: <Group size={14} />,
children: (
<DataCollected
logsData={integrationData.data_collected.logs}
@@ -81,11 +59,7 @@ function IntegrationDetailContent(
];
return (
<div className="integration-detail-container">
<Tabs
activeKey={activeDetailTab}
items={items}
onChange={setActiveDetailTab}
/>
<Tabs value={activeDetailTab} items={items} onChange={setActiveDetailTab} />
</div>
);
}

View File

@@ -168,45 +168,6 @@
padding: 10px 16px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
.integration-tab-btns {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 8px 18px 8px !important;
.typography {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.integration-tab-btns:hover {
&.ant-btn-text {
background-color: unset !important;
}
}
.ant-tabs-nav-list {
gap: 24px;
}
.ant-tabs-nav {
padding: 0px !important;
}
.ant-tabs-tab {
padding: 0 !important;
}
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px !important;
}
}
.uninstall-integration-bar {

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Form } from 'antd';
import apply from 'api/v3/licenses/post';
import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error';

View File

@@ -2,6 +2,8 @@ import { ArrowRight } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
import styles from './AlertsEmptyState.module.scss';
interface AlertInfoCardProps {
header: string;
subheader: string;
@@ -17,17 +19,17 @@ function AlertInfoCard({
}: AlertInfoCardProps): JSX.Element {
return (
<div
className="alert-info-card"
className={styles.alertInfoCard}
onClick={(): void => {
onClick();
openInNewTab(link);
}}
>
<div className="alert-card-text">
<Typography.Text className="alert-card-text-header">
<div className={styles.alertCardText}>
<Typography.Text className={styles.alertCardTextHeader}>
{header}
</Typography.Text>
<Typography.Text className="alert-card-text-subheader">
<Typography.Text className={styles.alertCardTextSubheader}>
{subheader}
</Typography.Text>
</div>

View File

@@ -0,0 +1,189 @@
.alertListContainer {
margin-top: auto;
margin-bottom: auto;
display: flex;
justify-content: center;
width: 100%;
}
.alertListViewContent {
width: calc(100% - 30px);
max-width: 836px;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 28px;
letter-spacing: -0.09px;
}
.subtitle {
color: var(--l2-foreground);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}
.emptyAlertInfoContainer {
display: flex;
padding: 71px 193.5px;
justify-content: center;
align-items: center;
border-radius: 6px;
border: 1px dashed var(--l1-border);
margin-top: 16px;
}
.alertContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.heading {
display: flex;
flex-direction: column;
gap: 4px;
}
.icons {
color: white;
}
.emptyAlertAction {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.emptyInfo {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 24px;
letter-spacing: -0.07px;
}
.actionContainer {
display: flex;
gap: 24px;
align-items: center;
padding-top: 24px;
padding-bottom: 24px;
width: 100%;
}
.buttonGroup {
display: flex;
gap: 8px;
}
.buttonContent {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.getStartedText {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
margin-bottom: 24px;
width: 100%;
:global(.ant-divider)::before,
:global(.ant-divider)::after {
border-bottom: 2px dotted var(--l1-border);
border-top: 2px dotted var(--l1-border);
height: 8px;
}
:global(.ant-typography) {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
text-transform: uppercase;
padding-top: 8px;
}
}
.alertInfoCard {
display: flex;
padding: 16px;
justify-content: space-between;
align-items: center;
border-radius: 6px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
margin-bottom: 16px;
&:hover {
cursor: pointer;
}
}
.alertCardText {
display: flex;
gap: 2px;
flex-direction: column;
}
.alertCardTextHeader {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
.alertCardTextSubheader {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
}
.infoText {
color: var(--primary);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 16px;
letter-spacing: -0.06px;
margin: 0 8px;
white-space: nowrap;
}
.infoLinkContainer {
svg {
color: var(--primary);
}
&:hover {
cursor: pointer;
}
}

View File

@@ -1,175 +0,0 @@
.alert-list-container {
margin-top: 104px;
margin-bottom: 30px;
display: flex;
justify-content: center;
width: 100%;
.alert-list-view-content {
width: calc(100% - 30px);
max-width: 836px;
.alert-list-title-container {
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 28px; /* 155.556% */
letter-spacing: -0.09px;
}
.subtitle {
color: var(--l2-foreground);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.empty-alert-info-container {
display: flex;
padding: 71px 193.5px;
justify-content: center;
align-items: center;
border-radius: 6px;
border: 1px dashed var(--l1-border);
margin-top: 16px;
.alert-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
.heading {
display: flex;
flex-direction: column;
gap: 4px;
.icons {
color: white;
}
.empty-alert-action {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
letter-spacing: -0.07px;
}
.empty-info {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 24px;
letter-spacing: -0.07px;
}
}
.action-container {
display: flex;
gap: 24px;
align-items: center;
padding-top: 24px;
padding-bottom: 24px;
width: 100%;
}
}
}
.get-started-text {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
margin-bottom: 24px;
width: 100%;
&__divider {
--divider-border-width: 1px;
}
.ant-typography {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
letter-spacing: 0.48px;
text-transform: uppercase;
padding-top: 8px;
}
}
.alert-info-card {
display: flex;
padding: 16px;
justify-content: space-between;
align-items: center;
border-radius: 6px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
margin-bottom: 16px;
&:hover {
cursor: pointer;
}
.alert-card-text {
display: flex;
gap: 2px;
flex-direction: column;
.alert-card-text-header {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.alert-card-text-subheader {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}
}
}
}
.info-text {
color: var(--bg-robin-400) !important;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 16px; /* 133.333% */
letter-spacing: -0.06px;
}
.info-link-container {
.anticon {
color: var(--bg-robin-400);
}
:hover {
cursor: pointer;
}
}

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button, Flex } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Plus, RefreshCw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
@@ -17,7 +17,7 @@ import AlertInfoCard from './AlertInfoCard';
import { ALERT_CARDS, ALERT_INFO_LINKS } from './alertLinks';
import InfoLinkText from './InfoLinkText';
import './AlertsEmptyState.styles.scss';
import styles from './AlertsEmptyState.module.scss';
const alertLogEvents = (
title: string,
@@ -29,10 +29,16 @@ const alertLogEvents = (
page: 'Alert empty state page',
};
logEvent(title, dataSource ? { ...attributes, dataSource } : attributes);
void logEvent(title, dataSource ? { ...attributes, dataSource } : attributes);
};
export function AlertsEmptyState(): JSX.Element {
interface AlertsEmptyStateProps {
onRefresh?: () => void;
}
export function AlertsEmptyState({
onRefresh,
}: AlertsEmptyStateProps): JSX.Element {
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const [addNewAlert] = useComponentPermission(
@@ -51,45 +57,56 @@ export function AlertsEmptyState(): JSX.Element {
);
return (
<div className="alert-list-container">
<div className="alert-list-view-content">
<div className="alert-list-title-container">
<Typography.Title className="title">Alert Rules</Typography.Title>
<Typography.Text className="subtitle">
<div className={styles.alertListContainer}>
<div className={styles.alertListViewContent}>
<div>
<Typography.Title className={styles.title}>Alert Rules</Typography.Title>
<Typography.Text className={styles.subtitle}>
Create and manage alert rules for your resources.
</Typography.Text>
</div>
<section className="empty-alert-info-container">
<div className="alert-content">
<section className="heading">
<section className={styles.emptyAlertInfoContainer}>
<div className={styles.alertContent}>
<section className={styles.heading}>
<img
src={alertEmojiUrl}
alt="alert-header"
style={{ height: '32px', width: '32px' }}
/>
<div>
<Typography.Text className="empty-info">
<Typography.Text className={styles.emptyInfo}>
No Alert rules yet.{' '}
</Typography.Text>
<Typography.Text className="empty-alert-action">
<br />
<Typography.Text className={styles.emptyAlertAction}>
Create an Alert Rule to get started
</Typography.Text>
</div>
</section>
<div className="action-container">
<Button
className="add-alert-btn"
onClick={onClickNewAlertHandler}
disabled={!addNewAlert}
loading={loading}
type="primary"
data-testid="add-alert"
>
<Flex align="center" justify="center" gap={4}>
<Plus size="md" />
New Alert Rule
</Flex>
</Button>
<div className={styles.actionContainer}>
<div className={styles.buttonGroup}>
<Button
onClick={onClickNewAlertHandler}
disabled={!addNewAlert}
loading={loading}
testId="add-alert"
>
<span className={styles.buttonContent}>
<Plus size="md" />
New Alert Rule
</span>
</Button>
{onRefresh && (
<Button
onClick={onRefresh}
prefix={<RefreshCw />}
color="secondary"
testId="list-alerts-empty-refresh-button"
>
Refresh
</Button>
)}
</div>
<InfoLinkText
infoText="Watch a tutorial on creating a sample alert"
link="https://youtu.be/xjxNIqiv4_M"
@@ -124,11 +141,9 @@ export function AlertsEmptyState(): JSX.Element {
})}
</div>
</section>
<div className="get-started-text">
<div className={styles.getStartedText}>
<Divider className="get-started-text__divider">
<Typography.Text className="get-started-text">
Or get started with these sample alerts
</Typography.Text>
<Typography.Text>Or get started with these sample alerts</Typography.Text>
</Divider>
</div>

View File

@@ -3,6 +3,8 @@ import { Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
import styles from './AlertsEmptyState.module.scss';
interface InfoLinkTextProps {
infoText: string;
link: string;
@@ -24,12 +26,12 @@ function InfoLinkText({
onClick();
openInNewTab(link);
}}
className="info-link-container"
className={styles.infoLinkContainer}
>
{leftIconVisible && <CirclePlay size="md" />}
<Typography.Text className="info-text">{infoText}</Typography.Text>
{leftIconVisible && <CirclePlay size={16} />}
<Typography.Text className={styles.infoText}>{infoText}</Typography.Text>
{rightIconVisible && (
<ArrowRight size="md" style={{ transform: 'rotate(315deg)' }} />
<ArrowRight size={16} style={{ transform: 'rotate(315deg)' }} />
)}
</Flex>
);

View File

@@ -1,86 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import type { NotificationInstance } from 'antd/es/notification/interface';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { deleteRuleByID } from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { State } from 'hooks/useFetch';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { PayloadProps as DeleteAlertPayloadProps } from 'types/api/alerts/delete';
import APIError from 'types/api/error';
import { ColumnButton } from './styles';
function DeleteAlert({
id,
setData,
notifications,
}: DeleteAlertProps): JSX.Element {
const [deleteAlertState, setDeleteAlertState] = useState<
State<DeleteAlertPayloadProps>
>({
error: false,
errorMessage: '',
loading: false,
success: false,
payload: undefined,
});
const { showErrorModal } = useErrorModal();
const onDeleteHandler = async (id: string): Promise<void> => {
try {
await deleteRuleByID({ id });
setData((state) => state.filter((alert) => alert.id !== id));
setDeleteAlertState((state) => ({
...state,
loading: false,
}));
notifications.success({
message: 'Success',
});
} catch (error) {
setDeleteAlertState((state) => ({
...state,
loading: false,
error: true,
}));
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
}
};
const onClickHandler = (): void => {
setDeleteAlertState((state) => ({
...state,
loading: true,
}));
onDeleteHandler(id);
};
return (
<ColumnButton
disabled={deleteAlertState.loading || false}
loading={deleteAlertState.loading || false}
onClick={onClickHandler}
type="link"
>
Delete
</ColumnButton>
);
}
interface DeleteAlertProps {
id: string;
setData: Dispatch<SetStateAction<RuletypesRuleDTO[]>>;
notifications: NotificationInstance;
}
export default DeleteAlert;

View File

@@ -1,439 +0,0 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { Button, Flex, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Ellipsis, Plus } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { ColumnsType } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { createRule } from 'api/generated/services/rules';
import type {
ListRules200,
RenderErrorResponseDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
import {
DynamicColumnsKey,
TableDataSource,
} from 'components/ResizeTable/contants';
import DynamicColumnTable from 'components/ResizeTable/DynamicColumnTable';
import DateComponent from 'components/ResizeTable/TableComponent/DateComponent';
import LabelColumn from 'components/TableRenderer/LabelColumn';
import TextToolTip from 'components/TextToolTip';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import useSortableTable from 'hooks/ResizeTable/useSortableTable';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import useInterval from 'hooks/useInterval';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import DeleteAlert from './DeleteAlert';
import { ColumnButton, SearchContainer } from './styles';
import Status from './TableComponents/Status';
import ToggleAlertState from './ToggleAlertState';
import { alertActionLogEvent, filterAlerts } from './utils';
const { Search } = Input;
function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
const { t } = useTranslation('common');
const { safeNavigate } = useSafeNavigate();
const { user } = useAppContext();
const [addNewAlert, action] = useComponentPermission(
['add_new_alert', 'action'],
user.role,
);
const [editLoader, setEditLoader] = useState<boolean>(false);
const [cloneLoader, setCloneLoader] = useState<boolean>(false);
const params = useUrlQuery();
const orderColumnParam = params.get('columnKey');
const orderQueryParam = params.get('order');
const paginationParam = params.get('page');
const searchParams = params.get('search');
const [searchString, setSearchString] = useState<string>(searchParams || '');
const [data, setData] = useState<RuletypesRuleDTO[]>(() => {
const value = searchString.toLowerCase();
const filteredData = filterAlerts(allAlertRules, value);
return filteredData || [];
});
// Type asuring
const sortingOrder: 'ascend' | 'descend' | null =
orderQueryParam === 'ascend' || orderQueryParam === 'descend'
? orderQueryParam
: null;
const { sortedInfo, handleChange } = useSortableTable<RuletypesRuleDTO>(
sortingOrder,
orderColumnParam || '',
searchString,
);
const { notifications: notificationsApi } = useNotifications();
useInterval(() => {
(async (): Promise<void> => {
const { data: refetchData, status } = await refetch();
if (status === 'success') {
const value = searchString.toLowerCase();
const filteredData = filterAlerts(refetchData?.data ?? [], value);
setData(filteredData || []);
}
if (status === 'error') {
notificationsApi.error({
message: t('something_went_wrong'),
});
}
})();
}, 30000);
const { showErrorModal } = useErrorModal();
const onClickNewAlertHandler = useCallback(
(e: React.MouseEvent): void => {
logEvent('Alert: New alert button clicked', {
number: allAlertRules?.length,
layout: 'new',
});
safeNavigate(ROUTES.ALERTS_NEW, {
newTab: isModifierKeyPressed(e),
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const onEditHandler = (
record: RuletypesRuleDTO,
options?: { newTab?: boolean },
): void => {
const compositeQuery = sanitizeDefaultAlertQuery(
mapQueryDataFromApi(toCompositeMetricQuery(record.condition.compositeQuery)),
record.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, record.id);
setEditLoader(false);
safeNavigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, {
newTab: options?.newTab,
});
};
const onCloneHandler =
(originalAlert: RuletypesRuleDTO) => async (): Promise<void> => {
const copyAlert: RuletypesRuleDTO = {
...originalAlert,
alert: `${originalAlert.alert} - Copy`,
};
try {
setCloneLoader(true);
await createRule(copyAlert);
notificationsApi.success({
message: 'Success',
description: 'Alert cloned successfully',
});
const { data: refetchData, status } = await refetch();
const rules = refetchData?.data;
if (status === 'success' && rules) {
setData(rules);
setTimeout(() => {
const clonedAlert = rules[rules.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
safeNavigate(`${ROUTES.EDIT_ALERTS}?${params.toString()}`);
}, 2000);
}
if (status === 'error') {
notificationsApi.error({
message: t('something_went_wrong'),
});
}
} catch (error) {
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
} finally {
setCloneLoader(false);
}
};
const handleSearch = useDebouncedFn((e: unknown) => {
const value = (e as React.BaseSyntheticEvent).target.value.toLowerCase();
setSearchString(value);
const filteredData = filterAlerts(allAlertRules, value);
setData(filteredData);
});
const dynamicColumns: ColumnsType<RuletypesRuleDTO> = [
{
title: 'Created At',
dataIndex: 'createdAt',
width: 80,
key: DynamicColumnsKey.CreatedAt,
align: 'center',
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
const prev = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const next = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return prev - next;
},
render: DateComponent,
sortOrder:
sortedInfo.columnKey === DynamicColumnsKey.CreatedAt
? sortedInfo.order
: null,
},
{
title: 'Created By',
dataIndex: 'createdBy',
width: 80,
key: DynamicColumnsKey.CreatedBy,
align: 'center',
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
width: 80,
key: DynamicColumnsKey.UpdatedAt,
align: 'center',
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
const prev = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const next = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return prev - next;
},
render: DateComponent,
sortOrder:
sortedInfo.columnKey === DynamicColumnsKey.UpdatedAt
? sortedInfo.order
: null,
},
{
title: 'Updated By',
dataIndex: 'updatedBy',
width: 80,
key: DynamicColumnsKey.UpdatedBy,
align: 'center',
},
];
const columns: ColumnsType<RuletypesRuleDTO> = [
{
title: 'Status',
dataIndex: 'state',
width: 80,
key: 'state',
sorter: (a, b): number =>
(b.state ? b.state.charCodeAt(0) : 1000) -
(a.state ? a.state.charCodeAt(0) : 1000),
render: (value): JSX.Element => <Status status={value} />,
sortOrder: sortedInfo.columnKey === 'state' ? sortedInfo.order : null,
},
{
title: 'Alert Name',
dataIndex: 'alert',
width: 100,
key: 'name',
sorter: (alertA, alertB): number => {
if (alertA.alert && alertB.alert) {
return alertA.alert.localeCompare(alertB.alert);
}
return 0;
},
render: (value, record): JSX.Element => {
const onClickHandler = (e: React.MouseEvent<HTMLElement>): void => {
e.stopPropagation();
e.preventDefault();
onEditHandler(record, { newTab: isModifierKeyPressed(e) });
};
return <Typography.Link onClick={onClickHandler}>{value}</Typography.Link>;
},
sortOrder: sortedInfo.columnKey === 'name' ? sortedInfo.order : null,
},
{
title: 'Severity',
dataIndex: 'labels',
width: 80,
key: 'severity',
sorter: (a, b): number =>
(a?.labels?.severity?.length || 0) - (b?.labels?.severity?.length || 0),
render: (value): JSX.Element => {
const objectKeys = value ? Object.keys(value) : [];
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
const severityValue = withSeverityKey ? value[withSeverityKey] : '-';
return <Typography>{severityValue}</Typography>;
},
sortOrder: sortedInfo.columnKey === 'severity' ? sortedInfo.order : null,
},
{
title: 'Labels',
dataIndex: 'labels',
key: 'tags',
align: 'center',
width: 100,
render: (value): JSX.Element => {
const objectKeys = value ? Object.keys(value) : [];
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
if (withOutSeverityKeys.length === 0) {
return <Typography>-</Typography>;
}
return <LabelColumn labels={withOutSeverityKeys} value={value} />;
},
},
];
if (action) {
columns.push({
title: 'Action',
dataIndex: 'id',
key: 'action',
width: 10,
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => {
const actionItems = [
<ToggleAlertState
key="1"
disabled={record.disabled ?? false}
setData={setData}
id={id ?? ''}
/>,
<ColumnButton
key="2"
onClick={(e: React.MouseEvent): void =>
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
}
type="link"
loading={editLoader}
>
Edit
</ColumnButton>,
<ColumnButton
key="3-new-tab"
onClick={(): void => onEditHandler(record, { newTab: true })}
type="link"
loading={editLoader}
>
Edit in New Tab
</ColumnButton>,
<ColumnButton
key="3-clone"
onClick={onCloneHandler(record)}
type="link"
loading={cloneLoader}
>
Clone
</ColumnButton>,
<DeleteAlert
key="4"
notifications={notificationsApi}
setData={setData}
id={id ?? ''}
/>,
];
return (
<div data-testid="alert-actions">
<DropdownMenuSimple
menu={{
items: actionItems.map((element, index) => ({
key: String(index),
label: element,
onClick: ({ key }): void => alertActionLogEvent(key, record),
})),
}}
>
<Button
type="link"
style={{ color: 'var(--l1-foreground)' }}
icon={<Ellipsis size={16} />}
/>
</DropdownMenuSimple>
</div>
);
},
});
}
const paginationConfig = {
defaultCurrent: Number(paginationParam) || 1,
};
return (
<div className="alert-rules-list-container">
<SearchContainer>
<Search
placeholder="Search by Alert Name, Severity and Labels"
onChange={handleSearch}
defaultValue={searchString}
/>
<Flex gap={12} align="center">
{addNewAlert && (
<Button type="primary" onClick={onClickNewAlertHandler}>
<Flex align="center" gap={4}>
<Plus size="md" />
New Alert
</Flex>
</Button>
)}
<TextToolTip
{...{
text: `More details on how to create alerts`,
url: 'https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts',
urlText: 'Learn More',
}}
/>
</Flex>
</SearchContainer>
<DynamicColumnTable
tablesource={TableDataSource.Alert}
columns={columns}
rowKey="id"
dataSource={data}
shouldSendAlertsLogEvent
dynamicColumns={dynamicColumns}
onChange={handleChange}
pagination={paginationConfig}
/>
</div>
);
}
interface ListAlertProps {
allAlertRules: RuletypesRuleDTO[];
refetch: UseQueryResult<
ListRules200,
ErrorType<RenderErrorResponseDTO>
>['refetch'];
}
export default ListAlert;

View File

@@ -0,0 +1,92 @@
.container {
display: flex;
flex-direction: column;
gap: 1rem;
height: calc(100vh - 62px);
min-height: 400px;
}
.header {
position: relative;
z-index: 10;
display: flex;
justify-content: flex-end;
gap: 1rem;
flex-shrink: 0;
padding: 0 var(--spacing-8);
}
.refreshRow {
display: flex;
align-items: center;
gap: 8px;
}
.filtersRow {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
padding: 0 var(--spacing-8);
--combobox-trigger-height: 2rem;
}
.searchInput {
min-width: 250px;
}
.filterSelect {
min-width: 300px;
flex: 1;
}
.tableContainer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
--badge-cursor: pointer;
}
.searchIcon {
color: var(--l2-foreground);
}
.actionsColumn {
display: flex;
justify-content: flex-end;
}
.paginationContainer {
padding-right: var(--spacing-12);
height: 62px;
}

View File

@@ -1,52 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
function Status({ status }: StatusProps): JSX.Element {
switch (status) {
case 'inactive': {
return (
<Badge color="forest" variant="outline">
OK
</Badge>
);
}
case 'pending': {
return (
<Badge color="amber" variant="outline">
Pending
</Badge>
);
}
case 'firing': {
return (
<Badge color="cherry" variant="outline">
Firing
</Badge>
);
}
case 'disabled': {
return (
<Badge color="vanilla" variant="outline">
Disabled
</Badge>
);
}
default: {
return (
<Badge color="vanilla" variant="outline">
Unknown
</Badge>
);
}
}
}
interface StatusProps {
status: RuletypesRuleDTO['state'];
}
export default Status;

View File

@@ -1,103 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useQueryClient } from 'react-query';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { invalidateGetRuleByID } from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { State } from 'hooks/useFetch';
import { useNotifications } from 'hooks/useNotifications';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ColumnButton } from './styles';
function ToggleAlertState({
id,
disabled,
setData,
}: ToggleAlertStateProps): JSX.Element {
const [apiStatus, setAPIStatus] = useState<State<RuletypesRuleDTO>>({
error: false,
errorMessage: '',
loading: false,
success: false,
payload: undefined,
});
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const queryClient = useQueryClient();
const onToggleHandler = async (
id: string,
disabled: boolean,
): Promise<void> => {
try {
setAPIStatus((state) => ({
...state,
loading: true,
}));
const response = await patchRulePartial(id, { disabled });
const { data: updatedRule } = response;
setData((state) =>
state.map((alert) => {
if (alert.id === id) {
return {
...alert,
disabled: updatedRule.disabled,
state: updatedRule.state,
};
}
return alert;
}),
);
setAPIStatus((state) => ({
...state,
loading: false,
payload: updatedRule,
}));
invalidateGetRuleByID(queryClient, { id });
notifications.success({
message: 'Success',
});
} catch (error) {
setAPIStatus((state) => ({
...state,
loading: false,
error: true,
}));
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
}
};
return (
<ColumnButton
disabled={apiStatus.loading || false}
loading={apiStatus.loading || false}
onClick={(): Promise<void> => onToggleHandler(id, !disabled)}
type="link"
>
{disabled ? 'Enable' : 'Disable'}
</ColumnButton>
);
}
interface ToggleAlertStateProps {
id: string;
disabled: boolean;
setData: Dispatch<SetStateAction<RuletypesRuleDTO[]>>;
}
export default ToggleAlertState;

View File

@@ -1,147 +0,0 @@
import type {
RuletypesAlertStateDTO,
RuletypesCompareOperatorDTO,
RuletypesMatchTypeDTO,
RuletypesPanelTypeDTO,
RuletypesQueryTypeDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { filterAlerts } from '../utils';
describe('filterAlerts', () => {
const mockAlertBase: Partial<RuletypesRuleDTO> = {
state: 'active' as RuletypesAlertStateDTO,
disabled: false,
createdAt: '2024-01-01T00:00:00Z',
createdBy: 'test-user',
updatedAt: '2024-01-01T00:00:00Z',
updatedBy: 'test-user',
version: '1',
condition: {
compositeQuery: {
queries: [],
panelType: 'graph' as RuletypesPanelTypeDTO,
queryType: 'builder' as RuletypesQueryTypeDTO,
},
matchType: 'at_least_once' as RuletypesMatchTypeDTO,
op: 'above' as RuletypesCompareOperatorDTO,
},
ruleType: 'threshold_rule' as RuletypesRuleDTO['ruleType'],
};
const mockAlerts: RuletypesRuleDTO[] = [
{
...mockAlertBase,
id: '1',
alert: 'High CPU Usage',
alertType: 'METRIC_BASED_ALERT',
labels: {
severity: 'warning',
status: 'ok',
environment: 'production',
},
} as RuletypesRuleDTO,
{
...mockAlertBase,
id: '2',
alert: 'Memory Leak Detected',
alertType: 'METRIC_BASED_ALERT',
labels: {
severity: 'critical',
status: 'firing',
environment: 'staging',
},
} as RuletypesRuleDTO,
{
...mockAlertBase,
id: '3',
alert: 'Database Connection Error',
alertType: 'METRIC_BASED_ALERT',
labels: {
severity: 'error',
status: 'pending',
environment: 'production',
},
} as RuletypesRuleDTO,
];
it('should return all alerts when filter is empty', () => {
const result = filterAlerts(mockAlerts, '');
expect(result).toStrictEqual(mockAlerts);
});
it('should return all alerts when filter is only whitespace', () => {
const result = filterAlerts(mockAlerts, ' ');
expect(result).toStrictEqual(mockAlerts);
});
it('should filter alerts by alert name', () => {
const result = filterAlerts(mockAlerts, 'CPU');
expect(result).toHaveLength(1);
expect(result[0].alert).toBe('High CPU Usage');
});
it('should filter alerts by severity', () => {
const result = filterAlerts(mockAlerts, 'warning');
expect(result).toHaveLength(1);
expect(result[0].labels?.severity).toBe('warning');
});
it('should filter alerts by label key', () => {
const result = filterAlerts(mockAlerts, 'environment');
expect(result).toHaveLength(3); // All alerts have environment label
});
it('should filter alerts by label value', () => {
const result = filterAlerts(mockAlerts, 'production');
expect(result).toHaveLength(2);
expect(
result.every((alert) => alert.labels?.environment === 'production'),
).toBe(true);
});
it('should be case insensitive', () => {
const result = filterAlerts(mockAlerts, 'cpu');
expect(result).toHaveLength(1);
expect(result[0].alert).toBe('High CPU Usage');
});
it('should handle partial matches', () => {
const result = filterAlerts(mockAlerts, 'mem');
expect(result).toHaveLength(1);
expect(result[0].alert).toBe('Memory Leak Detected');
});
it('should handle alerts with missing labels', () => {
const alertsWithMissingLabels: RuletypesRuleDTO[] = [
{
...mockAlertBase,
id: '4',
alert: 'Test Alert',
alertType: 'METRIC_BASED_ALERT',
labels: undefined,
} as RuletypesRuleDTO,
];
const result = filterAlerts(alertsWithMissingLabels, 'test');
expect(result).toHaveLength(1);
expect(result[0].alert).toBe('Test Alert');
});
it('should handle alerts with missing alert name', () => {
const alertsWithMissingName: RuletypesRuleDTO[] = [
{
...mockAlertBase,
id: '5',
alert: '',
alertType: 'METRIC_BASED_ALERT',
labels: {
severity: 'warning',
},
} as RuletypesRuleDTO,
];
const result = filterAlerts(alertsWithMissingName, 'warning');
expect(result).toHaveLength(1);
expect(result[0].labels?.severity).toBe('warning');
});
});

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,76 @@
import { fireEvent, 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 () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
fireEvent.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 () => {
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);
fireEvent.click(screen.getByTestId('alert-columns-button'));
await screen.findByText('Toggle Columns');
const checkbox = document.getElementById('col-createdAt');
expect(checkbox).not.toBeNull();
fireEvent.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,84 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import ROUTES from 'constants/routes';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { fireEvent, 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 () => {
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.
fireEvent.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 () => {
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');
fireEvent.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 () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
fireEvent.change(screen.getByTestId('list-alerts-search-input'), {
target: { value: '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.',
);
fireEvent.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,61 @@
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import ROUTES from 'constants/routes';
import { fireEvent, 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 () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
fireEvent.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 () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
fireEvent.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 () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
fireEvent.click(screen.getByRole('button', { name: /new alert/i }), {
ctrlKey: true,
});
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: true }),
);
});
});

View File

@@ -0,0 +1,62 @@
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { fireEvent, 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 () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const nextBtn = screen.getByLabelText('Go to next page');
fireEvent.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,47 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { fireEvent, 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 () => {
renderListAlertRules();
const ruleCell = await screen.findByText('High CPU Alert');
const td = ruleCell.closest('td');
expect(td).not.toBeNull();
fireEvent.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 () => {
renderListAlertRules();
const ruleCell = await screen.findByText('High CPU Alert');
const td = ruleCell.closest('td');
fireEvent.click(td as HTMLElement, { ctrlKey: true });
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 }));
});
});

Some files were not shown because too many files have changed in this diff Show More