Compare commits

..

26 Commits

Author SHA1 Message Date
amlannandy
ed9e90aff1 chore: add keyboard navigation to evaluation window 2025-09-18 00:35:19 +07:00
amlannandy
57ec4e9a7a chore: layout and light mode changes 2025-09-18 00:35:19 +07:00
amlannandy
fdad0663e9 chore: step 4 changes 2025-09-18 00:35:19 +07:00
amlannandy
b54ecedac3 chore: step 3 changes 2025-09-18 00:35:19 +07:00
amlannandy
834ff461ce chore: fix time input 2025-09-18 00:35:19 +07:00
amlannandy
39245b39cc chore: evaluation window changes 2025-09-18 00:35:19 +07:00
amlannandy
78ceea4f43 chore: step 2 changes 2025-09-18 00:35:19 +07:00
amlannandy
191f72818a chore: first step changes 2025-09-18 00:35:19 +07:00
amlannandy
de1b7deb8d chore: light mode fixes 2025-09-18 00:35:17 +07:00
amlannandy
53aa06bff0 chore: init footer 2025-09-18 00:34:43 +07:00
amlannandy
9c5e4c86c0 chore: delete duplicate evaluate settings folder 2025-09-16 19:44:40 +07:00
amlannandy
58e802af93 chore: correct step number in twin layouts 2025-09-16 19:42:26 +07:00
amlannandy
7c1b694679 chore: add unit tests 2025-09-16 19:42:26 +07:00
amlannandy
a63d490dc5 feat: add notification settings component 2025-09-16 19:42:26 +07:00
amlannandy
9daa4009bd chore: add evaluation window option 2025-09-16 19:42:26 +07:00
amlannandy
954d1f2641 feat: add advanced options 2025-09-16 19:42:26 +07:00
amlannandy
dcac7ec8c7 chore: add twin layouts 2025-09-16 13:01:44 +07:00
amlannandy
233e1d79d7 chore: add tests 2025-09-16 13:01:44 +07:00
amlannandy
429ecba355 chore: renaming 2025-09-16 13:01:44 +07:00
amlannandy
4a84d2a666 chore: renaming 2025-09-16 13:01:44 +07:00
amlannandy
f58e2dcc5c chore: resolve inconsistencies 2025-09-16 13:01:44 +07:00
amlannandy
f412b908c3 chore: changes to evaluation window popover 2025-09-16 13:01:44 +07:00
amlannandy
1d1fd6ae26 chore: add evaluation cadence component 2025-09-16 13:01:44 +07:00
amlannandy
07ecb4b967 chore: add evaluation window option 2025-09-16 13:01:44 +07:00
amlannandy
87ce18ca1d feat: add advanced options 2025-09-16 13:01:44 +07:00
amlannandy
a09aea2b8e feat: add alert condition component 2025-09-16 13:01:44 +07:00
301 changed files with 7020 additions and 15670 deletions

View File

@@ -42,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.129.5
image: signoz/signoz-schema-migrator:v0.129.4
container_name: schema-migrator-sync
command:
- sync
@@ -55,7 +55,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.129.5
image: signoz/signoz-schema-migrator:v0.129.4
container_name: schema-migrator-async
command:
- async

View File

@@ -1,6 +1,6 @@
services:
signoz-otel-collector:
image: signoz/signoz-otel-collector:v0.129.6
image: signoz/signoz-otel-collector:v0.128.2
container_name: signoz-otel-collector-dev
command:
- --config=/etc/otel-collector-config.yaml

View File

@@ -21,9 +21,10 @@ jobs:
- postgres
- sqlite
clickhouse-version:
- 24.1.2-alpine
- 25.5.6
schema-migrator-version:
- v0.129.6
- v0.128.1
postgres-version:
- 15
if: |

4
.gitignore vendored
View File

@@ -230,6 +230,4 @@ poetry.toml
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
frontend/.cursor/rules/
# End of https://www.toptal.com/developers/gitignore/api/python

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.95.0
image: signoz/signoz:v0.94.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,7 +209,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.5
image: signoz/signoz-otel-collector:v0.129.4
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -233,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.5
image: signoz/signoz-schema-migrator:v0.129.4
deploy:
restart_policy:
condition: on-failure

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.95.0
image: signoz/signoz:v0.94.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,7 +150,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.5
image: signoz/signoz-otel-collector:v0.129.4
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -176,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.5
image: signoz/signoz-schema-migrator:v0.129.4
deploy:
restart_policy:
condition: on-failure

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.95.0}
image: signoz/signoz:${VERSION:-v0.94.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,7 +213,7 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -239,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
container_name: schema-migrator-sync
command:
- sync
@@ -250,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
container_name: schema-migrator-async
command:
- async

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.95.0}
image: signoz/signoz:${VERSION:-v0.94.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,7 +144,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -166,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
container_name: schema-migrator-async
command:
- async

View File

@@ -192,7 +192,7 @@ Tests can be configured using pytest options:
- `--sqlstore-provider` - Choose database provider (default: postgres)
- `--postgres-version` - PostgreSQL version (default: 15)
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
- `--clickhouse-version` - ClickHouse version (default: 24.1.2-alpine)
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
Example:

View File

@@ -1,44 +0,0 @@
module base
type organisation
relations
define read: [user, role#assignee]
define update: [user, role#assignee]
type user
relations
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type anonymous
type role
relations
define assignee: [user]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type resources
relations
define create: [user, role#assignee]
define list: [user, role#assignee]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type resource
relations
define read: [user, anonymous, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
define block: [user, role#assignee]
type telemetry
relations
define read: [user, anonymous, role#assignee]

View File

@@ -1,29 +0,0 @@
package openfgaschema
import (
"context"
_ "embed"
"github.com/SigNoz/signoz/pkg/authz"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
)
var (
//go:embed base.fga
baseDSL string
)
type schema struct{}
func NewSchema() authz.Schema {
return &schema{}
}
func (schema *schema) Get(ctx context.Context) []openfgapkgtransformer.ModuleFile {
return []openfgapkgtransformer.ModuleFile{
{
Name: "base.fga",
Contents: baseDSL,
},
}
}

View File

@@ -1,132 +0,0 @@
package middleware
import (
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/gorilla/mux"
)
const (
authzDeniedMessage string = "::AUTHZ-DENIED::"
)
type AuthZ struct {
logger *slog.Logger
authzService authz.AuthZ
}
func NewAuthZ(logger *slog.Logger) *AuthZ {
if logger == nil {
panic("cannot build authz middleware, logger is empty")
}
return &AuthZ{logger: logger}
}
func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
next(rw, req)
})
}
// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis.
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
selector, parentSelectors, err := cb(req)
if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...)
if err != nil {
render.Error(rw, err)
return
}
next(rw, req)
})
}

View File

@@ -8,8 +8,6 @@ import (
"net/http"
_ "net/http/pprof" // http profiler
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/gorilla/handlers"
"github.com/SigNoz/signoz/ee/query-service/app/api"
@@ -336,8 +334,6 @@ func makeRulesManager(
querier querier.Querier,
logger *slog.Logger,
) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
managerOpts := &baserules.ManagerOptions{
TelemetryStore: telemetryStore,
@@ -352,10 +348,8 @@ func makeRulesManager(
PrepareTaskFunc: rules.PrepareTaskFunc,
PrepareTestRuleFunc: rules.TestNotification,
Alertmanager: alertmanager,
SQLStore: sqlstore,
OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SqlStore: sqlstore,
}
// create Manager

View File

@@ -1,484 +0,0 @@
# Persona
You are an expert developer with deep knowledge of Jest, React Testing Library, MSW, and TypeScript, tasked with creating unit tests for this repository.
# Auto-detect TypeScript Usage
Check for TypeScript in the project through tsconfig.json or package.json dependencies.
Adjust syntax based on this detection.
# TypeScript Type Safety for Jest Tests
**CRITICAL**: All Jest tests MUST be fully type-safe with proper TypeScript types.
**Type Safety Requirements:**
- Use proper TypeScript interfaces for all mock data
- Type all Jest mock functions with `jest.MockedFunction<T>`
- Use generic types for React components and hooks
- Define proper return types for mock functions
- Use `as const` for literal types when needed
- Avoid `any` type use proper typing instead
# Unit Testing Focus
Focus on critical functionality (business logic, utility functions, component behavior)
Mock dependencies (API calls, external modules) before imports
Test multiple data scenarios (valid inputs, invalid inputs, edge cases)
Write maintainable tests with descriptive names grouped in describe blocks
# Global vs Local Mocks
**Use Global Mocks for:**
- High-frequency dependencies (20+ test files)
- Core infrastructure (react-router-dom, react-query, antd)
- Standard implementations across the app
- Browser APIs (ResizeObserver, matchMedia, localStorage)
- Utility libraries (date-fns, lodash)
**Use Local Mocks for:**
- Business logic dependencies (5-15 test files)
- Test-specific behavior (different data per test)
- API endpoints with specific responses
- Domain-specific components
- Error scenarios and edge cases
**Global Mock Files Available (from jest.config.ts):**
- `uplot` → `__mocks__/uplotMock.ts`
# Repo-specific Testing Conventions
## Imports
Always import from our harness:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
```
For API mocks:
```ts
import { server, rest } from 'mocks-server/server';
```
Do not import directly from `@testing-library/react`.
## Router
Use the router built into render:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on them.
## Hook Mocks
Pattern:
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */ as any);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## MSW
Global MSW server runs automatically.
Override per-test:
```ts
server.use(
rest.get('*/api/v1/foo', (_req, res, ctx) => res(ctx.status(200), ctx.json({ ok: true })))
);
```
Keep large responses in `mocks-server/__mockdata_`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level/programmatic events not covered by `userEvent` (e.g., scroll, resize, setting `element.scrollTop` for virtualization). Wrap in `act(...)` if needed.
- Always await interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
```ts
// Example: virtualized list scroll (no userEvent helper)
const scroller = container.querySelector('[data-test-id="virtuoso-scroller"]') as HTMLElement;
scroller.scrollTop = targetScrollTop;
act(() => { fireEvent.scroll(scroller); });
```
## Timers
❌ No global fake timers.
✅ Per-test only, for debounce/throttle:
```ts
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
await user.type(screen.getByRole('textbox'), 'query');
jest.advanceTimersByTime(400);
jest.useRealTimers();
```
## Queries
Prefer accessible queries (`getByRole`, `findByRole`, `getByLabelText`).
Fallback: visible text.
Last resort: `data-testid`.
# Example Test (using only configured global mocks)
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
# Anti-patterns
❌ Importing RTL directly
❌ Using global fake timers
❌ Wrapping render in `act(...)`
❌ Mocking infra dependencies locally (router, react-query)
✅ Use our harness (`tests/test-utils`)
✅ Use MSW for API overrides
✅ Use userEvent + await
✅ Pin time only in tests that assert relative dates
# Best Practices
- **Critical Functionality**: Prioritize testing business logic and utilities
- **Dependency Mocking**: Global mocks for infra, local mocks for business logic
- **Data Scenarios**: Always test valid, invalid, and edge cases
- **Descriptive Names**: Make test intent clear
- **Organization**: Group related tests in describe
- **Consistency**: Match repo conventions
- **Edge Cases**: Test null, undefined, unexpected values
- **Limit Scope**: 35 focused tests per file
- **Use Helpers**: `rqSuccess`, `makeUser`, etc.
- **No Any**: Enforce type safety
# Example Test
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
# Anti-patterns
❌ Importing RTL directly
❌ Using global fake timers
❌ Wrapping render in `act(...)`
❌ Mocking infra dependencies locally (router, react-query)
✅ Use our harness (`tests/test-utils`)
✅ Use MSW for API overrides
✅ Use userEvent + await
✅ Pin time only in tests that assert relative dates
# TypeScript Type Safety Examples
## Proper Mock Typing
```ts
// ✅ GOOD - Properly typed mocks
interface User {
id: number;
name: string;
email: string;
}
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// Type the mock functions
const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise<ApiResponse<User>>>;
const mockUpdateUser = jest.fn() as jest.MockedFunction<(user: User) => Promise<ApiResponse<User>>>;
// Mock implementation with proper typing
mockFetchUser.mockResolvedValue({
data: { id: 1, name: 'John Doe', email: 'john@example.com' },
status: 200,
message: 'Success'
});
// ❌ BAD - Using any type
const mockFetchUser = jest.fn() as any; // Don't do this
```
## React Component Testing with Types
```ts
// ✅ GOOD - Properly typed component testing
interface ComponentProps {
title: string;
data: User[];
onUserSelect: (user: User) => void;
isLoading?: boolean;
}
const TestComponent: React.FC<ComponentProps> = ({ title, data, onUserSelect, isLoading = false }) => {
// Component implementation
};
describe('TestComponent', () => {
it('should render with proper props', () => {
// Arrange - Type the props properly
const mockProps: ComponentProps = {
title: 'Test Title',
data: [{ id: 1, name: 'John', email: 'john@example.com' }],
onUserSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
isLoading: false
};
// Act
render(<TestComponent {...mockProps} />);
// Assert
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
});
```
## Hook Testing with Types
```ts
// ✅ GOOD - Properly typed hook testing
interface UseUserDataReturn {
user: User | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
const useUserData = (id: number): UseUserDataReturn => {
// Hook implementation
};
describe('useUserData', () => {
it('should return user data with proper typing', () => {
// Arrange
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
mockFetchUser.mockResolvedValue({
data: mockUser,
status: 200,
message: 'Success'
});
// Act
const { result } = renderHook(() => useUserData(1));
// Assert
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
```
## Global Mock Type Safety
```ts
// ✅ GOOD - Type-safe global mocks
// In __mocks__/routerMock.ts
export const mockUseLocation = (overrides: Partial<Location> = {}): Location => ({
pathname: '/traces',
search: '',
hash: '',
state: null,
key: 'test-key',
...overrides,
});
// In test files
const location = useLocation(); // Properly typed from global mock
expect(location.pathname).toBe('/traces');
```
# TypeScript Configuration for Jest
## Required Jest Configuration
```json
// jest.config.ts
{
"preset": "ts-jest/presets/js-with-ts-esm",
"globals": {
"ts-jest": {
"useESM": true,
"isolatedModules": true,
"tsconfig": "<rootDir>/tsconfig.jest.json"
}
},
"extensionsToTreatAsEsm": [".ts", ".tsx"],
"moduleFileExtensions": ["ts", "tsx", "js", "json"]
}
```
## TypeScript Jest Configuration
```json
// tsconfig.jest.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node"
},
"include": [
"src/**/*",
"**/*.test.ts",
"**/*.test.tsx",
"__mocks__/**/*"
]
}
```
## Common Type Safety Patterns
### Mock Function Typing
```ts
// ✅ GOOD - Proper mock function typing
const mockApiCall = jest.fn() as jest.MockedFunction<typeof apiCall>;
const mockEventHandler = jest.fn() as jest.MockedFunction<(event: Event) => void>;
// ❌ BAD - Using any
const mockApiCall = jest.fn() as any;
```
### Generic Mock Typing
```ts
// ✅ GOOD - Generic mock typing
interface MockApiResponse<T> {
data: T;
status: number;
}
const mockFetchData = jest.fn() as jest.MockedFunction<
<T>(endpoint: string) => Promise<MockApiResponse<T>>
>;
// Usage
mockFetchData<User>('/users').mockResolvedValue({
data: { id: 1, name: 'John' },
status: 200
});
```
### React Testing Library with Types
```ts
// ✅ GOOD - Typed testing utilities
import { render, screen, RenderResult } from '@testing-library/react';
import { ComponentProps } from 'react';
type TestComponentProps = ComponentProps<typeof TestComponent>;
const renderTestComponent = (props: Partial<TestComponentProps> = {}): RenderResult => {
const defaultProps: TestComponentProps = {
title: 'Test',
data: [],
onSelect: jest.fn(),
...props
};
return render(<TestComponent {...defaultProps} />);
};
```
### Error Handling with Types
```ts
// ✅ GOOD - Typed error handling
interface ApiError {
message: string;
code: number;
details?: Record<string, unknown>;
}
const mockApiError: ApiError = {
message: 'API Error',
code: 500,
details: { endpoint: '/users' }
};
mockFetchUser.mockRejectedValue(new Error(JSON.stringify(mockApiError)));
```
## Type Safety Checklist
- [ ] All mock functions use `jest.MockedFunction<T>`
- [ ] All mock data has proper interfaces
- [ ] No `any` types in test files
- [ ] Generic types are used where appropriate
- [ ] Error types are properly defined
- [ ] Component props are typed
- [ ] Hook return types are defined
- [ ] API response types are defined
- [ ] Global mocks are type-safe
- [ ] Test utilities are properly typed
# Mock Decision Tree
```
Is it used in 20+ test files?
├─ YES → Use Global Mock
│ ├─ react-router-dom
│ ├─ react-query
│ ├─ antd components
│ └─ browser APIs
└─ NO → Is it business logic?
├─ YES → Use Local Mock
│ ├─ API endpoints
│ ├─ Custom hooks
│ └─ Domain components
└─ NO → Is it test-specific?
├─ YES → Use Local Mock
│ ├─ Error scenarios
│ ├─ Loading states
│ └─ Specific data
└─ NO → Consider Global Mock
└─ If it becomes frequently used
```
# Common Anti-Patterns to Avoid
❌ **Don't mock global dependencies locally:**
```js
// BAD - This is already globally mocked
jest.mock('react-router-dom', () => ({ ... }));
```
❌ **Don't create global mocks for test-specific data:**
```js
// BAD - This should be local
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => specificTestData)
}));
```
✅ **Do use global mocks for infrastructure:**
```js
// GOOD - Use global mock
import { useLocation } from 'react-router-dom';
```
✅ **Do create local mocks for business logic:**
```js
// GOOD - Local mock for specific test needs
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData)
}));
```

View File

@@ -1,51 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// Mock for uplot library used in tests
export interface MockUPlotInstance {
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
redraw: jest.Mock;
setSeries: jest.Mock;
}
export interface MockUPlotPaths {
spline: jest.Mock;
bars: jest.Mock;
}
// Create mock instance methods
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
// Create mock paths
const mockPaths: MockUPlotPaths = {
spline: jest.fn(),
bars: jest.fn(),
};
// Mock static methods
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
_options: unknown,
_data: unknown,
_target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance();
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Export the constructor as default
export default MockUPlot;

View File

@@ -1,29 +0,0 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
}
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) => {
console.log(`Mock safeNavigate called with:`, to, options);
},
) as jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>,
});

View File

@@ -1,7 +1,5 @@
import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
clearMocks: true,
coverageDirectory: 'coverage',
@@ -12,10 +10,6 @@ const config: Config.InitialOptions = {
moduleNameMapper: {
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
},
globals: {
extensionsToTreatAsEsm: ['.ts'],

View File

@@ -44,13 +44,11 @@
"@sentry/react": "8.41.0",
"@sentry/webpack-plugin": "2.22.6",
"@signozhq/badge": "0.0.2",
"@signozhq/button": "0.0.2",
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/design-tokens": "1.1.4",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",

View File

@@ -1,31 +0,0 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/thirdPartyApis/listOverview';
const listOverview = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
const { start, end, show_ip: showIp, filter } = props;
try {
const response = await ApiBaseInstance.post(
`/third-party-apis/overview/list`,
{
start,
end,
show_ip: showIp,
filter,
},
);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default listOverview;

View File

@@ -1,64 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
export const downloadExportData = async (
props: ExportRawDataProps,
): Promise<void> => {
try {
const queryParams = new URLSearchParams();
queryParams.append('start', String(props.start));
queryParams.append('end', String(props.end));
queryParams.append('filter', props.filter);
props.columns.forEach((col) => {
queryParams.append('columns', col);
});
queryParams.append('order_by', props.orderBy);
queryParams.append('limit', String(props.limit));
queryParams.append('format', props.format);
const response = await axios.get<Blob>(`export_raw_data?${queryParams}`, {
responseType: 'blob', // Important: tell axios to handle response as blob
decompress: true, // Enable automatic decompression
headers: {
Accept: 'application/octet-stream', // Tell server we expect binary data
},
timeout: 0,
});
// Only proceed if the response status is 200
if (response.status !== 200) {
throw new Error(
`Failed to download data: server returned status ${response.status}`,
);
}
// Create blob URL from response data
const blob = new Blob([response.data], { type: 'application/octet-stream' });
const url = window.URL.createObjectURL(blob);
// Create and configure download link
const link = document.createElement('a');
link.href = url;
// Get filename from Content-Disposition header or generate timestamped default
const filename =
response.headers['content-disposition']
?.split('filename=')[1]
?.replace(/["']/g, '') || `exported_data.${props.format || 'txt'}`;
link.setAttribute('download', filename);
// Trigger download
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default downloadExportData;

View File

@@ -124,7 +124,7 @@ export const FUNCTION_NAMES: Record<string, FunctionName> = {
RUNNING_DIFF: 'runningDiff',
LOG2: 'log2',
LOG10: 'log10',
CUM_SUM: 'cumulativeSum',
CUM_SUM: 'cumSum',
EWMA3: 'ewma3',
EWMA5: 'ewma5',
EWMA7: 'ewma7',

View File

@@ -19,6 +19,20 @@ beforeAll(() => {
});
});
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-dnd', () => ({
useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),

View File

@@ -1,4 +1,4 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error';
import ErrorModal from './ErrorModal';
@@ -56,8 +56,9 @@ describe('ErrorModal Component', () => {
// Click the close button
const closeButton = screen.getByTestId('close-button');
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(closeButton);
act(() => {
fireEvent.click(closeButton);
});
// Check if onClose was called
expect(onCloseMock).toHaveBeenCalledTimes(1);
@@ -148,8 +149,9 @@ it('should open the modal when the trigger component is clicked', async () => {
// Click the trigger component
const triggerButton = screen.getByText('Open Error Modal');
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(triggerButton);
act(() => {
fireEvent.click(triggerButton);
});
// Check if the modal is displayed
expect(screen.getByText('An error occurred')).toBeInTheDocument();
@@ -168,15 +170,18 @@ it('should close the modal when the onCancel event is triggered', async () => {
// Click the trigger component
const triggerButton = screen.getByText('error');
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(triggerButton);
act(() => {
fireEvent.click(triggerButton);
});
await waitFor(() => {
expect(screen.getByText('An error occurred')).toBeInTheDocument();
});
// Trigger the onCancel event
await user.click(screen.getByTestId('close-button'));
act(() => {
fireEvent.click(screen.getByTestId('close-button'));
});
// Check if the modal is closed
expect(onCloseMock).toHaveBeenCalledTimes(1);

View File

@@ -1,15 +0,0 @@
import { Typography } from 'antd';
function AnnouncementsModal(): JSX.Element {
return (
<div className="announcements-modal-container">
<div className="announcements-modal-container-header">
<Typography.Text className="announcements-modal-title">
Announcements
</Typography.Text>
</div>
</div>
);
}
export default AnnouncementsModal;

View File

@@ -1,160 +0,0 @@
import { toast } from '@signozhq/sonner';
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
const [activeTab, setActiveTab] = useState('feedback');
const [feedback, setFeedback] = useState('');
const location = useLocation();
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (): Promise<void> => {
setIsLoading(true);
let entityName = 'Feedback';
if (activeTab === 'reportBug') {
entityName = 'Bug report';
} else if (activeTab === 'featureRequest') {
entityName = 'Feature request';
}
logEvent('Feedback: Submitted', {
data: feedback,
type: activeTab,
page: location.pathname,
})
.then(() => {
onClose();
toast.success(`${entityName} submitted successfully`, {
position: 'top-right',
});
})
.catch(() => {
console.error(`Failed to submit ${entityName}`);
toast.error(`Failed to submit ${entityName}`, {
position: 'top-right',
});
})
.finally(() => {
setIsLoading(false);
});
};
useEffect(
() => (): void => {
setFeedback('');
setActiveTab('feedback');
},
[],
);
const items = [
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot feedback-tab" />
Feedback
</div>
),
key: 'feedback',
value: 'feedback',
},
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot bug-tab" />
Report a bug
</div>
),
key: 'reportBug',
value: 'reportBug',
},
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot feature-tab" />
Feature request
</div>
),
key: 'featureRequest',
value: 'featureRequest',
},
];
const handleFeedbackChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
): void => {
setFeedback(e.target.value);
};
const handleContactSupportClick = useCallback((): void => {
handleContactSupport(isCloudUserVal);
}, [isCloudUserVal]);
return (
<div className="feedback-modal-container">
<div className="feedback-modal-header">
<Radio.Group
value={activeTab}
defaultValue={activeTab}
optionType="button"
className="feedback-modal-tabs"
options={items}
onChange={(e: RadioChangeEvent): void => setActiveTab(e.target.value)}
/>
</div>
<div className="feedback-modal-content">
<div className="feedback-modal-content-header">
<Input.TextArea
placeholder="Write your feedback here..."
rows={6}
required
className="feedback-input"
value={feedback}
onChange={handleFeedbackChange}
/>
</div>
</div>
<div className="feedback-modal-content-footer">
<Button
className="periscope-btn primary"
type="primary"
onClick={handleSubmit}
loading={isLoading}
disabled={feedback.length === 0}
>
Submit
</Button>
<div className="feedback-modal-content-footer-info-text">
<Typography.Text>
Have a specific issue?{' '}
<Typography.Link
className="contact-support-link"
onClick={handleContactSupportClick}
>
Contact Support{' '}
</Typography.Link>
or{' '}
<a
href="https://signoz.io/docs/introduction/"
target="_blank"
rel="noreferrer"
className="read-docs-link"
>
Read our docs
</a>
</Typography.Text>
</div>
</div>
</div>
);
}
export default FeedbackModal;

View File

@@ -1,253 +0,0 @@
.header-right-section-container {
display: flex;
align-items: center;
gap: 8px;
}
.share-modal-content,
.feedback-modal-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
width: 460px;
border-radius: 4px;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.absolute-relative-time-toggler-container {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
.absolute-relative-time-toggler-label {
color: var(--bg-vanilla-100);
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.absolute-relative-time-toggler {
display: flex;
gap: 4px;
align-items: center;
}
.absolute-relative-time-error {
font-size: 12px;
color: var(--bg-amber-600);
}
.share-link {
.url-share-container {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
.url-share-container-header {
display: flex;
flex-direction: column;
gap: 4px;
.url-share-title,
.url-share-sub-title {
color: var(--bg-vanilla-100);
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.url-share-sub-title {
font-size: 12px;
color: var(--bg-vanilla-300);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
}
}
}
.feedback-modal-container {
.feedback-modal-tabs {
width: 100%;
display: flex;
.ant-radio-button-wrapper {
flex: 1;
margin: 0px !important;
border: 1px solid var(--bg-slate-400);
&:before {
display: none;
}
.ant-radio-button-checked {
background-color: var(--bg-slate-400);
}
}
.feedback-modal-tab-label {
display: flex;
align-items: center;
gap: 8px;
.tab-icon {
width: 6px;
height: 6px;
}
.feedback-tab {
background-color: var(--bg-sakura-500);
}
.bug-tab {
background-color: var(--bg-amber-500);
}
.feature-tab {
background-color: var(--bg-robin-500);
}
}
.ant-tabs-nav-list {
.ant-tabs-tab {
padding: 6px 16px;
border-radius: 2px;
background: var(--bg-ink-400);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--bg-slate-400);
margin: 0 !important;
.ant-tabs-tab-btn {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
}
&-active {
background: var(--bg-slate-400);
color: var(--bg-vanilla-100);
border-bottom: none !important;
.ant-tabs-tab-btn {
color: var(--bg-vanilla-100);
}
}
}
}
}
.feedback-modal-content {
display: flex;
flex-direction: column;
gap: 16px;
.feedback-input {
resize: none;
text-area {
resize: none;
}
}
.feedback-content-include-console-logs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
}
.feedback-modal-content-footer {
display: flex;
flex-direction: column;
gap: 16px;
.feedback-modal-content-footer-info-text {
font-size: 12px;
color: var(--bg-vanilla-400, #c0c1c3);
text-align: center;
/* button/ small */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 200% */
.contact-support-link,
.read-docs-link {
color: var(--bg-robin-400);
font-weight: 500;
font-size: 12px;
}
}
}
}
.lightMode {
.share-modal-content,
.feedback-modal-container {
.absolute-relative-time-toggler-container {
.absolute-relative-time-toggler-label {
color: var(--bg-ink-400);
}
}
.share-link {
.url-share-container {
.url-share-container-header {
.url-share-title,
.url-share-sub-title {
color: var(--bg-ink-400);
}
.url-share-sub-title {
color: var(--bg-ink-300);
}
}
}
}
}
.feedback-modal-container {
.feedback-modal-tabs {
.ant-radio-button-wrapper {
flex: 1;
margin: 0px !important;
border: 1px solid var(--bg-vanilla-300);
&:before {
display: none;
}
.ant-radio-button-checked {
background-color: var(--bg-vanilla-300);
}
}
}
.feedback-modal-content-footer {
.feedback-modal-content-footer-info-text {
color: var(--bg-slate-400);
}
}
}
}

View File

@@ -1,137 +0,0 @@
import './HeaderRightSection.styles.scss';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { Globe, Inbox, SquarePen } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';
import ShareURLModal from './ShareURLModal';
interface HeaderRightSectionProps {
enableAnnouncements: boolean;
enableShare: boolean;
enableFeedback: boolean;
}
function HeaderRightSection({
enableAnnouncements,
enableShare,
enableFeedback,
}: HeaderRightSectionProps): JSX.Element {
const location = useLocation();
const [openFeedbackModal, setOpenFeedbackModal] = useState(false);
const [openShareURLModal, setOpenShareURLModal] = useState(false);
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
const handleOpenFeedbackModal = useCallback((): void => {
logEvent('Feedback: Clicked', {
page: location.pathname,
});
setOpenFeedbackModal(true);
setOpenShareURLModal(false);
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleOpenShareURLModal = useCallback((): void => {
logEvent('Share: Clicked', {
page: location.pathname,
});
setOpenShareURLModal(true);
setOpenFeedbackModal(false);
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleCloseFeedbackModal = (): void => {
setOpenFeedbackModal(false);
};
const handleOpenFeedbackModalChange = (open: boolean): void => {
setOpenFeedbackModal(open);
};
const handleOpenAnnouncementsModalChange = (open: boolean): void => {
setOpenAnnouncementsModal(open);
};
const handleOpenShareURLModalChange = (open: boolean): void => {
setOpenShareURLModal(open);
};
return (
<div className="header-right-section-container">
{enableFeedback && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<FeedbackModal onClose={handleCloseFeedbackModal} />}
destroyTooltipOnHide
arrow={false}
trigger="click"
open={openFeedbackModal}
onOpenChange={handleOpenFeedbackModalChange}
>
<Button
className="share-feedback-btn periscope-btn ghost"
icon={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
/>
</Popover>
)}
{enableAnnouncements && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<AnnouncementsModal />}
arrow={false}
destroyTooltipOnHide
trigger="click"
open={openAnnouncementsModal}
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
icon={<Inbox size={14} />}
className="periscope-btn ghost announcements-btn"
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
});
}}
/>
</Popover>
)}
{enableShare && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<ShareURLModal />}
open={openShareURLModal}
destroyTooltipOnHide
arrow={false}
trigger="click"
onOpenChange={handleOpenShareURLModalChange}
>
<Button
className="share-link-btn periscope-btn ghost"
icon={<Globe size={14} />}
onClick={handleOpenShareURLModal}
>
Share
</Button>
</Popover>
)}
</div>
);
}
export default HeaderRightSection;

View File

@@ -1,171 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Switch, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
ROUTES.TRACES_EXPLORER,
ROUTES.METRICS_EXPLORER_EXPLORER,
ROUTES.METER_EXPLORER,
];
function ShareURLModal(): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
selectedTime !== 'custom',
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
const relativeTime = urlQuery.get(QueryParams.relativeTime);
const [isURLCopied, setIsURLCopied] = useState(false);
const [, handleCopyToClipboard] = useCopyToClipboard();
const isValidateRelativeTime = useMemo(
() =>
selectedTime !== 'custom' ||
(startTime && endTime && selectedTime === 'custom'),
[startTime, endTime, selectedTime],
);
const shareURLWithTime = useMemo(
() => relativeTime || (startTime && endTime),
[relativeTime, startTime, endTime],
);
const isRouteToBeSharedWithTime = useMemo(
() =>
routesToBeSharedWithTime.some((route) =>
matchPath(location.pathname, { path: route, exact: true }),
),
[location.pathname],
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const processURL = (): string => {
let currentUrl = window.location.href;
const isCustomTime = !!(startTime && endTime && selectedTime === 'custom');
if (shareURLWithTime || isRouteToBeSharedWithTime) {
if (enableAbsoluteTime || isCustomTime) {
if (selectedTime === 'custom') {
if (startTime && endTime) {
urlQuery.set(QueryParams.startTime, startTime.toString());
urlQuery.set(QueryParams.endTime, endTime.toString());
}
} else {
const { minTime, maxTime } = GetMinMax(selectedTime);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
}
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
}
}
return currentUrl;
};
const handleCopyURL = (): void => {
const URL = processURL();
handleCopyToClipboard(URL);
setIsURLCopied(true);
logEvent('Share: Copy link clicked', {
page: location.pathname,
URL,
});
setTimeout(() => {
setIsURLCopied(false);
}, 1000);
};
return (
<div className="share-modal-content">
{(shareURLWithTime || isRouteToBeSharedWithTime) && (
<>
<div className="absolute-relative-time-toggler-container">
<Typography.Text className="absolute-relative-time-toggler-label">
Enable absolute time
</Typography.Text>
<div className="absolute-relative-time-toggler">
{!isValidateRelativeTime && (
<Info size={14} color={Color.BG_AMBER_600} />
)}
<Switch
checked={enableAbsoluteTime}
disabled={!isValidateRelativeTime}
size="small"
onChange={(): void => {
setEnableAbsoluteTime((prev) => !prev);
}}
/>
</div>
</div>
{!isValidateRelativeTime && (
<div className="absolute-relative-time-error">
Please select / enter valid relative time to toggle.
</div>
)}
</>
)}
<div className="share-link">
<div className="url-share-container">
<div className="url-share-container-header">
<Typography.Text className="url-share-title">
Share page link
</Typography.Text>
<Typography.Text className="url-share-sub-title">
Share the current page link with your team member
</Typography.Text>
</div>
<Button
className="periscope-btn secondary"
onClick={handleCopyURL}
icon={isURLCopied ? <Check size={14} /> : <Link2 size={14} />}
>
Copy page link
</Button>
</div>
</div>
</div>
);
}
export default ShareURLModal;

View File

@@ -1,29 +0,0 @@
import { render, screen } from '@testing-library/react';
import AnnouncementsModal from '../AnnouncementsModal';
describe('AnnouncementsModal', () => {
it('should render announcements modal with title', () => {
render(<AnnouncementsModal />);
expect(screen.getByText('Announcements')).toBeInTheDocument();
});
it('should have proper structure and classes', () => {
render(<AnnouncementsModal />);
const container = screen
.getByText('Announcements')
.closest('.announcements-modal-container');
expect(container).toBeInTheDocument();
const headerContainer = screen
.getByText('Announcements')
.closest('.announcements-modal-container-header');
expect(headerContainer).toBeInTheDocument();
});
it('should render without any errors', () => {
expect(() => render(<AnnouncementsModal />)).not.toThrow();
});
});

View File

@@ -1,274 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
// Mock dependencies before imports
import { toast } from '@signozhq/sonner';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useLocation } from 'react-router-dom';
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(),
}));
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('pages/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;
const mockToast = toast as jest.Mocked<typeof toast>;
const mockOnClose = jest.fn();
const mockLocation = {
pathname: '/test-path',
};
describe('FeedbackModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue(mockLocation);
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
});
mockToast.success.mockClear();
mockToast.error.mockClear();
});
it('should render feedback modal with all tabs', () => {
render(<FeedbackModal onClose={mockOnClose} />);
expect(screen.getByText('Feedback')).toBeInTheDocument();
expect(screen.getByText('Report a bug')).toBeInTheDocument();
expect(screen.getByText('Feature request')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Write your feedback here...'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
it('should switch between tabs when clicked', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Initially, feedback radio should be active
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
expect(feedbackRadio).toBeChecked();
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Bug radio should now be active
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
expect(bugRadio).toBeChecked();
const featureTab = screen.getByText('Feature request');
await user.click(featureTab);
// Feature radio should now be active
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
expect(featureRadio).toBeChecked();
});
it('should update feedback text when typing in textarea', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const testFeedback = 'This is my feedback';
await user.type(textarea, testFeedback);
expect(textarea).toHaveValue(testFeedback);
});
it('should submit feedback and log event when submit button is clicked', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'Test feedback content';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'feedback',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Feedback submitted successfully',
{
position: 'top-right',
},
);
});
it('should submit bug report with correct type', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Switch to bug report tab
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Verify bug report radio is now active
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
expect(bugRadio).toBeChecked();
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'This is a bug report';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'reportBug',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Bug report submitted successfully',
{
position: 'top-right',
},
);
});
it('should submit feature request with correct type', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Switch to feature request tab
const featureTab = screen.getByText('Feature request');
await user.click(featureTab);
// Verify feature request radio is now active
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
expect(featureRadio).toBeChecked();
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'This is a feature request';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'featureRequest',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Feature request submitted successfully',
{
position: 'top-right',
},
);
});
it('should call handleContactSupport when contact support link is clicked', async () => {
const user = userEvent.setup();
const isCloudUser = true;
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
});
render(<FeedbackModal onClose={mockOnClose} />);
const contactSupportLink = screen.getByText('Contact Support');
await user.click(contactSupportLink);
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
});
it('should handle non-cloud user for contact support', async () => {
const user = userEvent.setup();
const isCloudUser = false;
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
});
render(<FeedbackModal onClose={mockOnClose} />);
const contactSupportLink = screen.getByText('Contact Support');
await user.click(contactSupportLink);
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
});
it('should render docs link with correct attributes', () => {
render(<FeedbackModal onClose={mockOnClose} />);
const docsLink = screen.getByText('Read our docs');
expect(docsLink).toHaveAttribute(
'href',
'https://signoz.io/docs/introduction/',
);
expect(docsLink).toHaveAttribute('target', '_blank');
expect(docsLink).toHaveAttribute('rel', 'noreferrer');
});
it('should reset form state when component unmounts', async () => {
const user = userEvent.setup();
// Render component
const { unmount } = render(<FeedbackModal onClose={mockOnClose} />);
// Change the form state first
const textArea = screen.getByPlaceholderText('Write your feedback here...');
await user.type(textArea, 'Some feedback text');
// Change the active tab
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Verify state has changed
expect(textArea).toHaveValue('Some feedback text');
// Unmount the component - this should trigger cleanup
unmount();
// Re-render the component to verify state was reset
render(<FeedbackModal onClose={mockOnClose} />);
// Verify form state is reset
const newTextArea = screen.getByPlaceholderText(
'Write your feedback here...',
);
expect(newTextArea).toHaveValue(''); // Should be empty
// Verify active radio is reset to default (Feedback radio)
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
expect(feedbackRadio).toBeChecked();
});
});

View File

@@ -1,192 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
// Mock dependencies before imports
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { useLocation } from 'react-router-dom';
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(),
}));
jest.mock('../FeedbackModal', () => ({
__esModule: true,
default: ({ onClose }: { onClose: () => void }): JSX.Element => (
<div data-testid="feedback-modal">
<button onClick={onClose} type="button">
Close Feedback
</button>
</div>
),
}));
jest.mock('../ShareURLModal', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="share-modal">Share URL Modal</div>
),
}));
jest.mock('../AnnouncementsModal', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="announcements-modal">Announcements Modal</div>
),
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const defaultProps = {
enableAnnouncements: true,
enableShare: true,
enableFeedback: true,
};
const mockLocation = {
pathname: '/test-path',
};
describe('HeaderRightSection', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue(mockLocation);
});
it('should render all buttons when all features are enabled', () => {
render(<HeaderRightSection {...defaultProps} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(3);
expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument();
// Check for feedback button by class
const feedbackButton = document.querySelector(
'.share-feedback-btn[class*="share-feedback-btn"]',
);
expect(feedbackButton).toBeInTheDocument();
// Check for announcements button by finding the inbox icon
const inboxIcon = document.querySelector('.lucide-inbox');
expect(inboxIcon).toBeInTheDocument();
});
it('should render only enabled features', () => {
render(
<HeaderRightSection
enableAnnouncements={false}
enableShare={false}
enableFeedback
/>,
);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1);
expect(
screen.queryByRole('button', { name: /share/i }),
).not.toBeInTheDocument();
// Check that inbox icon is not present
const inboxIcon = document.querySelector('.lucide-inbox');
expect(inboxIcon).not.toBeInTheDocument();
// Check that feedback button is present
const squarePenIcon = document.querySelector('.lucide-square-pen');
expect(squarePenIcon).toBeInTheDocument();
});
it('should open feedback modal and log event when feedback button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
});
it('should open share modal and log event when share button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
});
it('should log event when announcements button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const announcementsButton = document
.querySelector('.lucide-inbox')
?.closest('button');
expect(announcementsButton).toBeInTheDocument();
await user.click(announcementsButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
page: mockLocation.pathname,
});
});
it('should close feedback modal when onClose is called', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
// Open feedback modal
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
// Close feedback modal
const closeFeedbackButton = screen.getByText('Close Feedback');
await user.click(closeFeedbackButton);
expect(screen.queryByTestId('feedback-modal')).not.toBeInTheDocument();
});
it('should close other modals when opening feedback modal', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
// Open share modal first
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
// Open feedback modal - should close share modal
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
expect(screen.queryByTestId('share-modal')).not.toBeInTheDocument();
});
});

View File

@@ -1,289 +0,0 @@
// Mock dependencies before imports
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import { useSelector } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
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(),
matchPath: jest.fn(),
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: jest.fn(),
}));
// Mock window.location
const mockLocation = {
href: 'https://example.com/test-path?param=value',
origin: 'https://example.com',
};
Object.defineProperty(window, 'location', {
value: mockLocation,
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;
const mockGetMinMax = GetMinMax as jest.Mock;
const mockUseCopyToClipboard = useCopyToClipboard as jest.Mock;
const mockMatchPath = matchPath as jest.Mock;
const mockUrlQuery = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
toString: jest.fn(() => 'param=value'),
};
const mockHandleCopyToClipboard = jest.fn();
const TEST_PATH = '/test-path';
const ENABLE_ABSOLUTE_TIME_TEXT = 'Enable absolute time';
describe('ShareURLModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue({
pathname: TEST_PATH,
});
mockUseUrlQuery.mockReturnValue(mockUrlQuery);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
mockGetMinMax.mockReturnValue({
minTime: 1000000,
maxTime: 2000000,
});
mockUseCopyToClipboard.mockReturnValue([null, mockHandleCopyToClipboard]);
mockMatchPath.mockReturnValue(false);
// Reset URL query mocks - all return null by default
mockUrlQuery.get.mockReturnValue(null);
// Reset mock functions
mockUrlQuery.set.mockClear();
mockUrlQuery.delete.mockClear();
mockUrlQuery.toString.mockReturnValue('param=value');
});
it('should render share modal with copy button', () => {
render(<ShareURLModal />);
expect(screen.getByText('Share page link')).toBeInTheDocument();
expect(
screen.getByText('Share the current page link with your team member'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /copy page link/i }),
).toBeInTheDocument();
});
it('should copy URL and log event when copy button is clicked', async () => {
const user = userEvent.setup();
render(<ShareURLModal />);
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
page: TEST_PATH,
URL: expect.any(String),
});
});
it('should show absolute time toggle when on time-enabled route', () => {
mockMatchPath.mockReturnValue(true); // Simulate being on a route that supports time
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeInTheDocument();
});
it('should show absolute time toggle when URL has time parameters', () => {
mockUrlQuery.get.mockImplementation((key: string) =>
key === 'relativeTime' ? '5min' : null,
);
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
});
it('should toggle absolute time switch', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min', // Non-custom time should enable absolute time by default
});
render(<ShareURLModal />);
const toggleSwitch = screen.getByRole('switch');
// Should be checked by default for non-custom time
expect(toggleSwitch).toBeChecked();
await user.click(toggleSwitch);
expect(toggleSwitch).not.toBeChecked();
});
it('should disable toggle when relative time is invalid', () => {
mockUseSelector.mockReturnValue({
selectedTime: 'custom',
});
// Invalid - missing start and end time for custom
mockUrlQuery.get.mockReturnValue(null);
mockMatchPath.mockReturnValue(true);
render(<ShareURLModal />);
expect(
screen.getByText('Please select / enter valid relative time to toggle.'),
).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeDisabled();
});
it('should process URL with absolute time for non-custom time', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
render(<ShareURLModal />);
// Absolute time should be enabled by default for non-custom time
// Click copy button directly
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
});
it('should process URL with custom time parameters', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: 'custom',
});
mockUrlQuery.get.mockImplementation((key: string) => {
switch (key) {
case 'startTime':
return '1500000';
case 'endTime':
return '1600000';
default:
return null;
}
});
render(<ShareURLModal />);
// Should be enabled by default for custom time
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1500000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '1600000');
});
it('should process URL with relative time when absolute time is disabled', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
render(<ShareURLModal />);
// Disable absolute time first (it's enabled by default for non-custom time)
const toggleSwitch = screen.getByRole('switch');
await user.click(toggleSwitch);
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
});
it('should handle routes that should be shared with time', async () => {
const user = userEvent.setup();
mockUseLocation.mockReturnValue({
pathname: ROUTES.LOGS_EXPLORER,
});
mockMatchPath.mockImplementation(
(pathname: string, options: any) => options.path === ROUTES.LOGS_EXPLORER,
);
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeChecked();
// on clicking copy page link, the copied url should have startTime and endTime
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
// toggle the switch to share url with relative time
const toggleSwitch = screen.getByRole('switch');
await user.click(toggleSwitch);
await user.click(copyButton);
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
});
});

View File

@@ -1,5 +1,6 @@
import { Color } from '@signozhq/design-tokens';
import { DrawerProps, Tooltip } from 'antd';
import './RawLogView.styles.scss';
import { DrawerProps } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -25,7 +26,7 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from '../LogStateIndicator/utils';
// styles
import { InfoIconWrapper, RawLogContent, RawLogViewContainer } from './styles';
import { RawLogContent, RawLogViewContainer } from './styles';
import { RawLogViewProps } from './types';
function RawLogView({
@@ -34,17 +35,12 @@ function RawLogView({
data,
linesPerRow,
isTextOverflowEllipsisDisabled,
isHighlighted,
helpTooltip,
selectedFields = [],
fontSize,
onLogClick,
}: RawLogViewProps): JSX.Element {
const {
isHighlighted: isUrlHighlighted,
isLogsExplorerPage,
onLogCopy,
} = useCopyLogLink(data.id);
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
data.id,
);
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const {
@@ -130,20 +126,12 @@ function RawLogView({
formatTimezoneAdjustedTimestamp,
]);
const handleClickExpand = useCallback(
(event: MouseEvent) => {
if (activeContextLog || isReadOnly) return;
const handleClickExpand = useCallback(() => {
if (activeContextLog || isReadOnly) return;
// Use custom click handler if provided, otherwise use default behavior
if (onLogClick) {
onLogClick(data, event);
} else {
onSetActiveLog(data);
setSelectedTab(VIEW_TYPES.OVERVIEW);
}
},
[activeContextLog, isReadOnly, data, onSetActiveLog, onLogClick],
);
onSetActiveLog(data);
setSelectedTab(VIEW_TYPES.OVERVIEW);
}, [activeContextLog, isReadOnly, data, onSetActiveLog]);
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
(
@@ -195,11 +183,10 @@ function RawLogView({
align="middle"
$isDarkMode={isDarkMode}
$isReadOnly={isReadOnly}
$isHightlightedLog={isUrlHighlighted}
$isHightlightedLog={isHighlighted}
$isActiveLog={
activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog
}
$isCustomHighlighted={isHighlighted}
$logType={logType}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -210,15 +197,6 @@ function RawLogView({
severityText={data.severity_text}
severityNumber={data.severity_number}
/>
{helpTooltip && (
<Tooltip title={helpTooltip} placement="top" mouseEnterDelay={0.5}>
<InfoIconWrapper
size={14}
className="help-tooltip-icon"
color={Color.BG_VANILLA_400}
/>
</Tooltip>
)}
<RawLogContent
className="raw-log-content"
@@ -262,7 +240,6 @@ RawLogView.defaultProps = {
isActiveLog: false,
isReadOnly: false,
isTextOverflowEllipsisDisabled: false,
isHighlighted: false,
};
export default RawLogView;

View File

@@ -3,13 +3,8 @@ import { blue } from '@ant-design/colors';
import { Color } from '@signozhq/design-tokens';
import { Col, Row, Space } from 'antd';
import { FontSize } from 'container/OptionsMenu/types';
import { Info } from 'lucide-react';
import styled from 'styled-components';
import {
getActiveLogBackground,
getCustomHighlightBackground,
getDefaultLogBackground,
} from 'utils/logs';
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
import { RawLogContentProps } from './types';
@@ -18,7 +13,6 @@ export const RawLogViewContainer = styled(Row)<{
$isReadOnly?: boolean;
$isActiveLog?: boolean;
$isHightlightedLog: boolean;
$isCustomHighlighted?: boolean;
$logType: string;
fontSize: FontSize;
}>`
@@ -56,18 +50,6 @@ export const RawLogViewContainer = styled(Row)<{
};
transition: background-color 2s ease-in;`
: ''}
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
`;
export const InfoIconWrapper = styled(Info)`
display: flex;
align-items: center;
margin-right: 4px;
cursor: help;
flex-shrink: 0;
height: auto;
`;
export const ExpandIconWrapper = styled(Col)`

View File

@@ -1,5 +1,4 @@
import { FontSize } from 'container/OptionsMenu/types';
import { MouseEvent } from 'react';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -7,13 +6,10 @@ export interface RawLogViewProps {
isActiveLog?: boolean;
isReadOnly?: boolean;
isTextOverflowEllipsisDisabled?: boolean;
isHighlighted?: boolean;
helpTooltip?: string;
data: ILog;
linesPerRow: number;
fontSize: FontSize;
selectedFields?: IField[];
onLogClick?: (log: ILog, event: MouseEvent) => void;
}
export interface RawLogContentProps {

View File

@@ -1,86 +0,0 @@
.logs-download-popover {
.ant-popover-inner {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
var(--bg-ink-400) 0%,
var(--bg-ink-500) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0 8px 12px 8px;
margin: 6px 0;
}
.export-options-container {
width: 240px;
border-radius: 4px;
.title {
display: flex;
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format,
.row-limit,
.columns-scope {
padding: 12px 4px;
display: flex;
flex-direction: column;
:global(.ant-radio-wrapper) {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 13px;
}
}
.horizontal-line {
height: 1px;
background: var(--bg-slate-400);
}
.export-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.lightMode {
.logs-download-popover {
.ant-popover-inner {
border: 1px solid var(--bg-vanilla-300);
background: linear-gradient(
139deg,
var(--bg-vanilla-100) 0%,
var(--bg-vanilla-300) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
}
.export-options-container {
.title {
color: var(--bg-ink-200);
}
:global(.ant-radio-wrapper) {
color: var(--bg-ink-400);
}
.horizontal-line {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -1,341 +0,0 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { message } from 'antd';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DownloadFormats, DownloadRowCounts } from './constants';
import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu';
// Mock antd message
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: {
success: jest.fn(),
error: jest.fn(),
},
};
});
const TEST_IDS = {
DOWNLOAD_BUTTON: 'periscope-btn-download-options',
} as const;
interface TestProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
const createTestProps = (): TestProps => ({
startTime: 1631234567890,
endTime: 1631234567999,
filter: 'status = 200',
columns: [
{
name: 'http.status',
fieldContext: 'attribute',
fieldDataType: 'int64',
} as TelemetryFieldKey,
],
orderBy: 'timestamp:desc',
});
const testRenderContent = (props: TestProps): void => {
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
};
const testSuccessResponse = (res: any, ctx: any): any =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="export.csv"'),
ctx.body('id,value\n1,2\n'),
);
describe('LogsDownloadOptionsMenu', () => {
const BASE_URL = ENVIRONMENT.baseURL;
const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`;
let requestSpy: jest.Mock<any, any>;
const setupDefaultServer = (): void => {
server.use(
rest.get(EXPORT_URL, (req, res, ctx) => {
const params = req.url.searchParams;
const payload = {
start: Number(params.get('start')),
end: Number(params.get('end')),
filter: params.get('filter'),
columns: params.getAll('columns'),
order_by: params.get('order_by'),
limit: Number(params.get('limit')),
format: params.get('format'),
};
requestSpy(payload);
return testSuccessResponse(res, ctx);
}),
);
};
// Mock URL.createObjectURL used by download logic
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
beforeEach(() => {
requestSpy = jest.fn();
setupDefaultServer();
(message.success as jest.Mock).mockReset();
(message.error as jest.Mock).mockReset();
// jsdom doesn't implement it by default
((URL as unknown) as {
createObjectURL: (b: Blob) => string;
}).createObjectURL = jest.fn(() => 'blob:mock');
((URL as unknown) as {
revokeObjectURL: (u: string) => void;
}).revokeObjectURL = jest.fn();
});
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
// restore
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('renders download button', () => {
const props = createTestProps();
testRenderContent(props);
const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
});
it('shows popover with export options when download button is clicked', () => {
const props = createTestProps();
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('allows changing export format', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const csvRadio = screen.getByRole('radio', { name: 'csv' });
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
expect(csvRadio).toBeChecked();
fireEvent.click(jsonlRadio);
expect(jsonlRadio).toBeChecked();
expect(csvRadio).not.toBeChecked();
});
it('allows changing row limit', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const tenKRadio = screen.getByRole('radio', { name: '10k' });
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
expect(tenKRadio).toBeChecked();
fireEvent.click(fiftyKRadio);
expect(fiftyKRadio).toBeChecked();
expect(tenKRadio).not.toBeChecked();
});
it('allows changing columns scope', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
expect(allColumnsRadio).toBeChecked();
fireEvent.click(selectedColumnsRadio);
expect(selectedColumnsRadio).toBeChecked();
expect(allColumnsRadio).not.toBeChecked();
});
it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: ['attribute.http.status:int64'],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('calls downloadExportData with correct parameters when export button is clicked', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: [],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('handles successful export with success message', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.success).toHaveBeenCalledWith(
'Export completed successfully',
);
});
});
it('handles export failure with error message', async () => {
// Override handler to return 500 for this test
server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500))));
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.error).toHaveBeenCalledWith(
'Failed to export logs. Please try again.',
);
});
});
it('handles UI state correctly during export process', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)),
);
const props = createTestProps();
testRenderContent(props);
// Open popover
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Start export
fireEvent.click(screen.getByText('Export'));
// Check button is disabled during export
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled();
// Check popover is closed immediately after export starts
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Wait for export to complete and verify button is enabled again
await waitFor(() => {
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled();
});
});
it('uses filename from Content-Disposition and triggers download click', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'),
ctx.body('row\n'),
),
),
);
const originalCreateElement = document.createElement.bind(document);
const anchorEl = originalCreateElement('a') as HTMLAnchorElement;
const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute');
const clickSpy = jest.spyOn(anchorEl, 'click');
const removeSpy = jest.spyOn(anchorEl, 'remove');
const createElSpy = jest
.spyOn(document, 'createElement')
.mockImplementation((tagName: any): any =>
tagName === 'a' ? anchorEl : originalCreateElement(tagName),
);
const appendSpy = jest.spyOn(document.body, 'appendChild');
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(appendSpy).toHaveBeenCalledWith(anchorEl);
expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl');
expect(clickSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalled();
});
expect(anchorEl.getAttribute('download')).toBe('report.jsonl');
createElSpy.mockRestore();
appendSpy.mockRestore();
});
});

View File

@@ -1,170 +0,0 @@
import './LogsDownloadOptionsMenu.styles.scss';
import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd';
import { downloadExportData } from 'api/v1/download/downloadExportData';
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
DownloadColumnsScopes,
DownloadFormats,
DownloadRowCounts,
} from './constants';
function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string {
const prefix = key.fieldContext ? `${key.fieldContext}.` : '';
const suffix = key.fieldDataType ? `:${key.fieldDataType}` : '';
return `${prefix}${key.name}${suffix}`;
}
interface LogsDownloadOptionsMenuProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
export default function LogsDownloadOptionsMenu({
startTime,
endTime,
filter,
columns,
orderBy,
}: LogsDownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
const [columnsScope, setColumnsScope] = useState<string>(
DownloadColumnsScopes.ALL,
);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const handleExportRawData = useCallback(async (): Promise<void> => {
setIsPopoverOpen(false);
try {
setIsDownloading(true);
const downloadOptions = {
source: 'logs',
start: startTime,
end: endTime,
columns:
columnsScope === DownloadColumnsScopes.SELECTED
? columns.map((col) => convertTelemetryFieldKeyToText(col))
: [],
filter,
orderBy,
format: exportFormat,
limit: rowLimit,
};
await downloadExportData(downloadOptions);
message.success('Export completed successfully');
} catch (error) {
console.error('Error exporting logs:', error);
message.error('Failed to export logs. Please try again.');
} finally {
setIsDownloading(false);
}
}, [
startTime,
endTime,
columnsScope,
columns,
filter,
orderBy,
exportFormat,
rowLimit,
setIsDownloading,
setIsPopoverOpen,
]);
const popoverContent = useMemo(
() => (
<div
className="export-options-container"
role="dialog"
aria-label="Export options"
aria-modal="true"
>
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<Radio.Group
value={exportFormat}
onChange={(e): void => setExportFormat(e.target.value)}
>
<Radio value={DownloadFormats.CSV}>csv</Radio>
<Radio value={DownloadFormats.JSONL}>jsonl</Radio>
</Radio.Group>
</div>
<div className="horizontal-line" />
<div className="row-limit">
<Typography.Text className="title">Number of Rows</Typography.Text>
<Radio.Group
value={rowLimit}
onChange={(e): void => setRowLimit(e.target.value)}
>
<Radio value={DownloadRowCounts.TEN_K}>10k</Radio>
<Radio value={DownloadRowCounts.THIRTY_K}>30k</Radio>
<Radio value={DownloadRowCounts.FIFTY_K}>50k</Radio>
</Radio.Group>
</div>
<div className="horizontal-line" />
<div className="columns-scope">
<Typography.Text className="title">Columns</Typography.Text>
<Radio.Group
value={columnsScope}
onChange={(e): void => setColumnsScope(e.target.value)}
>
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
</Radio.Group>
</div>
<Button
type="primary"
icon={<Download size={16} />}
onClick={handleExportRawData}
className="export-button"
disabled={isDownloading}
loading={isDownloading}
>
Export
</Button>
</div>
),
[exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData],
);
return (
<Popover
content={popoverContent}
trigger="click"
placement="bottomRight"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="logs-download-popover"
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isDownloading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<DownloadIcon size={15} />
)
}
data-testid="periscope-btn-download-options"
disabled={isDownloading}
/>
</Tooltip>
</Popover>
);
}

View File

@@ -1,15 +0,0 @@
export const DownloadFormats = {
CSV: 'csv',
JSONL: 'jsonl',
};
export const DownloadColumnsScopes = {
ALL: 'all',
SELECTED: 'selected',
};
export const DownloadRowCounts = {
TEN_K: 10_000,
THIRTY_K: 30_000,
FIFTY_K: 50_000,
};

View File

@@ -3,30 +3,24 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import './LogsFormatOptionsMenu.styles.scss';
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
import { Button, Input, InputNumber, Tooltip, Typography } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import { LogViewMode } from 'container/LogsTable';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import {
Check,
ChevronLeft,
ChevronRight,
Minus,
Plus,
Sliders,
X,
} from 'lucide-react';
import { Check, ChevronLeft, ChevronRight, Minus, Plus, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface LogsFormatOptionsMenuProps {
title: string;
items: any;
selectedOptionFormat: any;
config: OptionsMenuConfig;
}
export default function LogsFormatOptionsMenu({
title,
items,
selectedOptionFormat,
config,
@@ -49,7 +43,6 @@ export default function LogsFormatOptionsMenu({
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const initialMouseEnterRef = useRef<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const onChange = useCallback(
(key: LogViewMode) => {
@@ -209,7 +202,7 @@ export default function LogsFormatOptionsMenu({
};
}, [selectedValue]);
const popoverContent = (
return (
<div
className={cx(
'nested-menu-container',
@@ -351,7 +344,7 @@ export default function LogsFormatOptionsMenu({
</div>
<div className="horizontal-line" />
<div className="menu-container">
<div className="title">FORMAT</div>
<div className="title"> {title} </div>
<div className="menu-items">
{items.map(
@@ -447,21 +440,4 @@ export default function LogsFormatOptionsMenu({
)}
</div>
);
return (
<Popover
content={popoverContent}
trigger="click"
placement="bottomRight"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="format-options-popover"
>
<Button
className="periscope-btn ghost"
icon={<Sliders size={14} />}
data-testid="periscope-btn-format-options"
/>
</Popover>
);
}

View File

@@ -80,24 +80,20 @@ const stopEventsExtension = EditorView.domEventHandlers({
});
function QuerySearch({
placeholder,
onChange,
queryData,
dataSource,
onRun,
signalSource,
hardcodedAttributeKeys,
}: {
placeholder?: string;
onChange: (value: string) => void;
queryData: IBuilderQuery;
dataSource: DataSource;
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const [query, setQuery] = useState<string>('');
const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
const [activeKey, setActiveKey] = useState<string>('');
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
@@ -108,10 +104,6 @@ function QuerySearch({
errors: [],
});
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [hasInteractedWithQB, setHasInteractedWithQB] = useState(false);
const handleQueryValidation = (newQuery: string): void => {
try {
const validationResponse = validateQuery(newQuery);
@@ -131,28 +123,13 @@ function QuerySearch({
useEffect(() => {
const newQuery = queryData.filter?.expression || '';
// Only update query from external source when editor is not focused
// When focused, just update the lastExternalQuery to track changes
// Only mark as external change if the query actually changed from external source
if (newQuery !== lastExternalQuery) {
setQuery(newQuery);
setIsExternalQueryChange(true);
setLastExternalQuery(newQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryData.filter?.expression]);
useEffect(() => {
// Update the query when the editor is blurred and the query has changed
// Only call onChange if the editor has been focused before (not on initial mount)
if (
!isFocused &&
hasInteractedWithQB &&
query !== queryData.filter?.expression
) {
onChange(query);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused]);
}, [queryData.filter?.expression, lastExternalQuery]);
// Validate query when it changes externally (from queryData)
useEffect(() => {
@@ -168,6 +145,9 @@ function QuerySearch({
const [showExamples] = useState(false);
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [
isFetchingCompleteValuesList,
setIsFetchingCompleteValuesList,
@@ -181,9 +161,6 @@ function QuerySearch({
const lastFetchedKeyRef = useRef<string>('');
const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const [shouldRunQueryPostUpdate, setShouldRunQueryPostUpdate] = useState(
false,
);
const { handleRunQuery } = useQueryBuilder();
@@ -229,7 +206,6 @@ function QuerySearch({
return (): void => clearTimeout(timeoutId);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isFocused],
);
@@ -243,11 +219,6 @@ function QuerySearch({
return;
}
if (hardcodedAttributeKeys) {
setKeySuggestions(hardcodedAttributeKeys);
return;
}
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
@@ -283,7 +254,6 @@ function QuerySearch({
toggleSuggestions,
queryData.aggregateAttribute?.key,
signalSource,
hardcodedAttributeKeys,
],
);
@@ -575,6 +545,7 @@ function QuerySearch({
const handleChange = (value: string): void => {
setQuery(value);
onChange(value);
// Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger
@@ -1238,25 +1209,6 @@ function QuerySearch({
</div>
);
// Effect to handle query run after update
useEffect(
() => {
// Only run the query post updating the filter expression.
// This runs the query in the next update cycle of react, when it's guaranteed that the query is updated.
// Because both the things are sequential and react batches the updates so it was still taking the old query.
if (shouldRunQueryPostUpdate) {
if (onRun && typeof onRun === 'function') {
onRun(query);
} else {
handleRunQuery();
}
setShouldRunQueryPostUpdate(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[shouldRunQueryPostUpdate, handleRunQuery, onRun],
);
return (
<div className="code-mirror-where-clause">
{editingMode && (
@@ -1331,7 +1283,6 @@ function QuerySearch({
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
@@ -1368,14 +1319,11 @@ function QuerySearch({
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (
onChange &&
typeof onChange === 'function' &&
query !== queryData.filter?.expression
) {
onChange(query);
if (onRun && typeof onRun === 'function') {
onRun(query);
} else {
handleRunQuery();
}
setShouldRunQueryPostUpdate(true);
return true;
},
},
@@ -1388,19 +1336,14 @@ function QuerySearch({
]),
),
]}
placeholder={placeholder}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
setHasInteractedWithQB(true);
}}
onBlur={handleBlur}
onCreateEditor={(view: EditorView): EditorView => {
editorRef.current = view;
return view;
}}
/>
{query && validation.isValid === false && !isFocused && (
@@ -1540,9 +1483,6 @@ function QuerySearch({
QuerySearch.defaultProps = {
onRun: undefined,
signalSource: '',
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
};
export default QuerySearch;

View File

@@ -1,380 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable import/named */
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as UseQBModule from 'hooks/queryBuilder/useQueryBuilder';
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import QuerySearch from '../QuerySearch/QuerySearch';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
const handleRunQuery = jest.fn();
return {
__esModule: true,
useQueryBuilder: (): { handleRunQuery: () => void } => ({ handleRunQuery }),
handleRunQuery,
};
});
jest.mock('@codemirror/autocomplete', () => ({
autocompletion: (): Record<string, unknown> => ({}),
closeCompletion: (): boolean => true,
completionKeymap: [] as unknown[],
startCompletion: (): boolean => true,
}));
jest.mock('@codemirror/lang-javascript', () => ({
javascript: (): Record<string, unknown> => ({}),
}));
jest.mock('@uiw/codemirror-theme-copilot', () => ({
copilot: {},
}));
jest.mock('@uiw/codemirror-theme-github', () => ({
githubLight: {},
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: {
data: { keys: {} as Record<string, QueryKeyDataSuggestionsProps[]> },
},
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
}),
}));
// Mock CodeMirror to a simple textarea to make it testable and call onUpdate
jest.mock(
'@uiw/react-codemirror',
(): Record<string, unknown> => {
// Minimal EditorView shape used by the component
class EditorViewMock {}
(EditorViewMock as any).domEventHandlers = (): unknown => ({} as unknown);
(EditorViewMock as any).lineWrapping = {} as unknown;
(EditorViewMock as any).editable = { of: () => ({}) } as unknown;
const keymap = { of: (arr: unknown) => arr } as unknown;
const Prec = { highest: (ext: unknown) => ext } as unknown;
type CodeMirrorProps = {
value?: string;
onChange?: (v: string) => void;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
onCreateEditor?: (view: unknown) => unknown;
onUpdate?: (arg: {
view: {
state: {
selection: { main: { head: number } };
doc: {
toString: () => string;
lineAt: (
_pos: number,
) => { number: number; from: number; to: number; text: string };
};
};
};
}) => void;
'data-testid'?: string;
extensions?: unknown[];
};
function CodeMirrorMock({
value,
onChange,
onFocus,
onBlur,
placeholder,
onCreateEditor,
onUpdate,
'data-testid': dataTestId,
extensions,
}: CodeMirrorProps): JSX.Element {
const [localValue, setLocalValue] = React.useState<string>(value ?? '');
// Provide a fake editor instance
React.useEffect(() => {
if (onCreateEditor) {
onCreateEditor(new EditorViewMock() as any);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Call onUpdate whenever localValue changes to simulate cursor and doc
React.useEffect(() => {
if (onUpdate) {
const text = String(localValue ?? '');
const head = text.length;
onUpdate({
view: {
state: {
selection: { main: { head } },
doc: {
toString: (): string => text,
lineAt: () => ({
number: 1,
from: 0,
to: text.length,
text,
}),
},
},
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localValue]);
const handleKeyDown = (
e: React.KeyboardEvent<HTMLTextAreaElement>,
): void => {
const isModEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
if (!isModEnter) return;
const exts: unknown[] = Array.isArray(extensions) ? extensions : [];
const flat: unknown[] = exts.flatMap((x: unknown) =>
Array.isArray(x) ? x : [x],
);
const keyBindings = flat.filter(
(x) =>
Boolean(x) &&
typeof x === 'object' &&
'key' in (x as Record<string, unknown>),
) as Array<{ key?: string; run?: () => boolean | void }>;
keyBindings
.filter((b) => b.key === 'Mod-Enter' && typeof b.run === 'function')
.forEach((b) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
b.run!();
});
};
return (
<textarea
data-testid={dataTestId || 'query-where-clause-editor'}
placeholder={placeholder}
value={localValue}
onChange={(e): void => {
setLocalValue(e.target.value);
if (onChange) {
onChange(e.target.value);
}
}}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={handleKeyDown}
style={{ width: '100%', minHeight: 80 }}
/>
);
}
return {
__esModule: true,
default: CodeMirrorMock,
EditorView: EditorViewMock,
keymap,
Prec,
};
},
);
const handleRunQueryMock = ((UseQBModule as unknown) as {
handleRunQuery: jest.MockedFunction<() => void>;
}).handleRunQuery;
const PLACEHOLDER_TEXT =
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')";
const TESTID_EDITOR = 'query-where-clause-editor';
const SAMPLE_KEY_TYPING = 'http.';
const SAMPLE_VALUE_TYPING_INCOMPLETE = " service.name = '";
const SAMPLE_VALUE_TYPING_COMPLETE = " service.name = 'frontend'";
const SAMPLE_STATUS_QUERY = " status_code = '200'";
describe('QuerySearch', () => {
it('renders with placeholder', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
});
it('calls onChange on blur after user edits', async () => {
const handleChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={handleChange}
queryData={initialQueriesMap.metrics.builder.queryData[0]}
dataSource={DataSource.METRICS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Blur triggers validation + onChange (only if focused at least once and value changed)
editor.blur();
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
expect(handleChange.mock.calls[0][0]).toContain("service.name = 'frontend'");
});
it('fetches key suggestions when typing a key (debounced)', async () => {
jest.useFakeTimers();
const advance = (ms: number): void => {
jest.advanceTimersByTime(ms);
};
const user = userEvent.setup({
advanceTimers: advance,
pointerEventsCheck: 0,
});
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.type(editor, SAMPLE_KEY_TYPING);
advance(1000);
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
timeout: 3000,
});
jest.useRealTimers();
});
it('fetches value suggestions when editing value context', async () => {
jest.useFakeTimers();
const advance = (ms: number): void => {
jest.advanceTimersByTime(ms);
};
const user = userEvent.setup({
advanceTimers: advance,
pointerEventsCheck: 0,
});
const mockedGetValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
advance(1000);
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
timeout: 3000,
});
jest.useRealTimers();
});
it('fetches key suggestions on mount for LOGS', async () => {
jest.useFakeTimers();
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
jest.advanceTimersByTime(1000);
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
timeout: 3000,
});
const lastArgs = mockedGetKeysOnMount.mock.calls[
mockedGetKeysOnMount.mock.calls.length - 1
]?.[0] as { signal: unknown; searchText: string };
expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' });
jest.useRealTimers();
});
it('calls provided onRun on Mod-Enter', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={onRun}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.click(editor);
await user.type(editor, SAMPLE_STATUS_QUERY);
await user.keyboard('{Meta>}{Enter}{/Meta}');
await waitFor(() => expect(onRun).toHaveBeenCalled());
});
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
const mockedHandleRunQuery = handleRunQueryMock as jest.MockedFunction<
() => void
>;
mockedHandleRunQuery.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
await user.keyboard('{Meta>}{Enter}{/Meta}');
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled());
});
});

View File

@@ -50,7 +50,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
filterConfig,
isDynamicFilters,
customFilters,
refetchCustomFilters,
setIsStale,
isCustomFiltersLoading,
} = useFilterConfig({ signal, config });
@@ -263,7 +263,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
signal={signal}
setIsSettingsOpen={setIsSettingsOpen}
customFilters={customFilters}
refetchCustomFilters={refetchCustomFilters}
setIsStale={setIsStale}
/>
)}
</div>

View File

@@ -14,12 +14,12 @@ function QuickFiltersSettings({
signal,
setIsSettingsOpen,
customFilters,
refetchCustomFilters,
setIsStale,
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
refetchCustomFilters: () => void;
setIsStale: (isStale: boolean) => void;
}): JSX.Element {
const {
handleSettingsClose,
@@ -34,7 +34,7 @@ function QuickFiltersSettings({
} = useQuickFilterSettings({
setIsSettingsOpen,
customFilters,
refetchCustomFilters,
setIsStale,
signal,
});

View File

@@ -12,7 +12,7 @@ import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
refetchCustomFilters: () => void;
setIsStale: (isStale: boolean) => void;
signal?: SignalType;
}
@@ -32,7 +32,7 @@ interface UseQuickFilterSettingsReturn {
const useQuickFilterSettings = ({
customFilters,
setIsSettingsOpen,
refetchCustomFilters,
setIsStale,
signal,
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
@@ -46,7 +46,7 @@ const useQuickFilterSettings = ({
} = useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
setIsStale(true);
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});

View File

@@ -1,8 +1,12 @@
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
Filter as FilterType,
PayloadProps,
} from 'types/api/quickFilters/getCustomFilters';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
@@ -14,34 +18,37 @@ interface UseFilterConfigProps {
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[];
setCustomFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
refetchCustomFilters: () => void;
setIsStale: React.Dispatch<React.SetStateAction<boolean>>;
}
const useFilterConfig = ({
signal,
config,
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
isFetching: isCustomFiltersLoading,
data: customFilters = [],
refetch,
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
);
const [customFilters, setCustomFilters] = useState<FilterType[]>([]);
const [isStale, setIsStale] = useState(true);
const isDynamicFilters = useMemo(() => customFilters.length > 0, [
customFilters,
]);
const { isFetching: isCustomFiltersLoading } = useQuery<
SuccessResponse<PayloadProps> | ErrorResponse,
Error
>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
() => getCustomFilters({ signal: signal || '' }),
{
onSuccess: (data) => {
if ('payload' in data && data.payload?.filters) {
setCustomFilters(data.payload.filters || ([] as FilterType[]));
}
setIsStale(false);
},
enabled: !!signal && isStale,
},
);
const filterConfig = useMemo(
() => getFilterConfig(signal, customFilters, config),
[config, customFilters, signal],
@@ -50,9 +57,10 @@ const useFilterConfig = ({
return {
filterConfig,
customFilters,
setCustomFilters,
isCustomFiltersLoading,
isDynamicFilters,
refetchCustomFilters: refetch,
setIsStale,
};
};

View File

@@ -1,6 +1,15 @@
import '@testing-library/jest-dom';
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
@@ -9,7 +18,8 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { USER_ROLES } from 'types/roles';
import QuickFilters from '../QuickFilters';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
@@ -19,6 +29,21 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
// eslint-disable-next-line sonarjs/no-duplicate-string
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
}),
}));
const userRole = USER_ROLES.ADMIN;
// mock useAppContext
jest.mock('providers/App/App', () => ({
useAppContext: jest.fn(() => ({ user: { role: userRole } })),
}));
const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn();
const putHandler = jest.fn();
@@ -53,10 +78,11 @@ const setupServer = (): void => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) =>
rest.get(quickFiltersAttributeValuesURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
rest.get(fieldsValuesURL, (_req, res, ctx) =>
rest.get(fieldsValuesURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
);
@@ -70,12 +96,14 @@ function TestQuickFilters({
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
<MockQueryClientProvider>
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
</MockQueryClientProvider>
);
}
@@ -90,11 +118,11 @@ beforeAll(() => {
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
afterAll(() => {
server.close();
cleanup();
});
beforeEach(() => {
@@ -123,13 +151,9 @@ describe('Quick Filters', () => {
});
it('should add filter data to query when checkbox is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters />);
// Prefer role if possible; if label text isnt wired to input, clicking the label text is OK
const target = await screen.findByText('mq-kafka');
await user.click(target);
const checkbox = screen.getByText('mq-kafka');
fireEvent.click(checkbox);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
@@ -158,20 +182,16 @@ describe('Quick Filters', () => {
describe('Quick Filters with custom filters', () => {
it('loads the custom filters correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
await screen.findByText(FILTER_SERVICE_NAME);
const allByText = await screen.findAllByText('otel-demo');
// since 2 filter collapse are open, there are 2 filter items visible
expect(allByText).toHaveLength(2);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
fireEvent.click(icon);
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
@@ -187,19 +207,16 @@ describe('Quick Filters with custom filters', () => {
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
fireEvent.click(icon);
const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME);
const addButton = otherFilterItem.parentElement?.querySelector('button');
expect(addButton).not.toBeNull();
await user.click(addButton as HTMLButtonElement);
fireEvent.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
@@ -208,21 +225,17 @@ describe('Quick Filters with custom filters', () => {
});
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
fireEvent.click(icon);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
await user.click(removeBtn as HTMLButtonElement);
fireEvent.click(removeBtn as HTMLButtonElement);
await waitFor(() => {
expect(addedSection).not.toContainElement(
@@ -237,20 +250,17 @@ describe('Quick Filters with custom filters', () => {
});
it('restores original filter state on Discard', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
fireEvent.click(icon);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
await user.click(removeBtn as HTMLButtonElement);
fireEvent.click(removeBtn as HTMLButtonElement);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
await waitFor(() => {
@@ -262,11 +272,7 @@ describe('Quick Filters with custom filters', () => {
);
});
const discardBtn = screen
.getByText(DISCARD_TEXT)
.closest('button') as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
await user.click(discardBtn);
fireEvent.click(screen.getByText(DISCARD_TEXT));
await waitFor(() => {
expect(addedSection).toContainElement(
@@ -279,25 +285,18 @@ describe('Quick Filters with custom filters', () => {
});
it('saves the updated filters by calling PUT with correct payload', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
fireEvent.click(icon);
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
await user.click(removeBtn as HTMLButtonElement);
fireEvent.click(removeBtn as HTMLButtonElement);
const saveBtn = screen
.getByText(SAVE_CHANGES_TEXT)
.closest('button') as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
await user.click(saveBtn);
fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => {
expect(putHandler).toHaveBeenCalled();
@@ -312,36 +311,31 @@ describe('Quick Filters with custom filters', () => {
expect(requestBody.signal).toBe(SIGNAL);
});
// render duration filter
it('should render duration slider for duration_nono filter', async () => {
// Use fake timers only in this test (for debounce), and wire them to userEvent
// Set up fake timers **before rendering**
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
pointerEventsCheck: 0,
});
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
expect(screen.getByText('Duration')).toBeInTheDocument();
// Open the duration section (use role if its a button/collapse)
await user.click(screen.getByText('Duration'));
// click to open the duration filter
fireEvent.click(screen.getByText('Duration'));
const minDuration = getByTestId('min-input') as HTMLInputElement;
const maxDuration = getByTestId('max-input') as HTMLInputElement;
expect(minDuration).toHaveValue(null);
expect(minDuration).toHaveProperty('placeholder', '0');
expect(maxDuration).toHaveValue(null);
expect(maxDuration).toHaveProperty('placeholder', '100000000');
// Type values and advance debounce
await user.clear(minDuration);
await user.type(minDuration, '10000');
await user.clear(maxDuration);
await user.type(maxDuration, '20000');
jest.advanceTimersByTime(2000);
await act(async () => {
// set values
fireEvent.change(minDuration, { target: { value: '10000' } });
fireEvent.change(maxDuration, { target: { value: '20000' } });
jest.advanceTimersByTime(2000);
});
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
@@ -369,144 +363,6 @@ describe('Quick Filters with custom filters', () => {
);
});
jest.useRealTimers();
});
});
describe('Quick Filters refetch behavior', () => {
it('fetches custom filters on every mount when signal is provided', async () => {
let getCalls = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
);
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
unmount();
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
expect(getCalls).toBe(2);
});
it('does not fetch custom filters when signal is undefined', async () => {
let getCalls = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
);
render(<TestQuickFilters signal={undefined} />);
await waitFor(() => expect(getCalls).toBe(0));
});
it('refetches custom filters after saving settings', async () => {
let getCalls = 0;
putHandler.mockClear();
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
rest.put(saveQuickFiltersURL, async (req, res, ctx) => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector(
'button',
) as HTMLButtonElement;
await user.click(removeBtn);
await user.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => expect(putHandler).toHaveBeenCalled());
await waitFor(() => expect(getCalls).toBeGreaterThanOrEqual(2));
});
it('renders updated filters after refetch post-save', async () => {
const updatedResponse = {
...quickFiltersListResponse,
data: {
...quickFiltersListResponse.data,
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
} as const,
],
},
};
let getCount = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCount += 1;
return getCount >= 2
? res(ctx.status(200), ctx.json(updatedResponse))
: res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
rest.put(saveQuickFiltersURL, async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({})),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
// Make a minimal change so Save button appears
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector(
'button',
) as HTMLButtonElement;
await user.click(removeBtn);
await user.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => {
expect(screen.getByText('New Custom Filter')).toBeInTheDocument();
});
});
it('shows empty state when GET fails', async () => {
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({})),
),
);
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
expect(await screen.findByText('No filters found')).toBeInTheDocument();
jest.useRealTimers(); // Clean up
});
});

View File

@@ -1,5 +1,4 @@
import { Tabs, TabsProps } from 'antd';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import {
generatePath,
matchPath,
@@ -18,7 +17,6 @@ function RouteTab({
activeKey,
onChangeHandler,
history,
showRightSection,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
const params = useParams<Params>();
@@ -63,22 +61,12 @@ function RouteTab({
items={items}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
tabBarExtraContent={
showRightSection && (
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
)
}
/>
);
}
RouteTab.defaultProps = {
onChangeHandler: undefined,
showRightSection: true,
};
export default RouteTab;

View File

@@ -13,5 +13,4 @@ export interface RouteTabProps {
activeKey: TabsProps['activeKey'];
onChangeHandler?: (key: string) => void;
history: History<unknown>;
showRightSection: boolean;
}

View File

@@ -5,7 +5,7 @@ import { RadioChangeEvent } from 'antd/es/radio';
interface Option {
value: string;
label: string | React.ReactNode;
label: string;
icon?: React.ReactNode;
}

View File

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

View File

@@ -125,7 +125,7 @@ export const queryFunctionsTypesConfig: QueryFunctionConfigType = {
log10: {
showInput: false,
},
cumulativeSum: {
cumSum: {
showInput: false,
},
ewma3: {

View File

@@ -83,7 +83,4 @@ export const REACT_QUERY_KEY = {
// Quick Filters Query Keys
GET_CUSTOM_FILTERS: 'GET_CUSTOM_FILTERS',
GET_OTHER_FILTERS: 'GET_OTHER_FILTERS',
SPAN_LOGS: 'SPAN_LOGS',
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
} as const;

View File

@@ -22,8 +22,6 @@ jest.mock('react-router-dom', () => ({
describe('Alert Channels Settings List page', () => {
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
@@ -31,7 +29,6 @@ describe('Alert Channels Settings List page', () => {
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => {

View File

@@ -28,7 +28,6 @@ jest.mock('react-router-dom', () => ({
describe('Alert Channels Settings List page (Normal User)', () => {
beforeEach(async () => {
jest.useFakeTimers();
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
@@ -36,7 +35,6 @@ describe('Alert Channels Settings List page (Normal User)', () => {
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => {

View File

@@ -3,11 +3,10 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
import {
endPointStatusCodeColumns,
extractPortAndEndpoint,
formatDataForTable,
formatTopErrorsDataForTable,
getAllEndpointsWidgetData,
getCustomFiltersForBarChart,
@@ -25,8 +24,7 @@ import {
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
TopErrorsResponseRow,
} from '../utils';
import { APIMonitoringColumnsMock } from './mock';
} from './utils';
// Mock or define DataTypes since it seems to be missing from imports
const DataTypes = {
@@ -36,9 +34,9 @@ const DataTypes = {
};
// Mock the external utils dependencies that are used within our tested functions
jest.mock('../utils', () => {
jest.mock('./utils', () => {
// Import the actual module to partial mock
const originalModule = jest.requireActual('../utils');
const originalModule = jest.requireActual('./utils');
// Return a mocked version
return {
@@ -159,54 +157,6 @@ describe('API Monitoring Utils', () => {
});
});
// New tests for formatDataForTable
describe('formatDataForTable', () => {
it('should format rows correctly with valid data', () => {
const columns = APIMonitoringColumnsMock;
const data = [
[
'test-domain', // domainName
'10', // endpoints
'25', // rps
'2.5', // error_rate
'15000000', // p99 (ns) -> 15 ms
'2025-09-17T12:54:17.040Z', // lastseen
],
];
const result = formatDataForTable(data as any, columns as any);
expect(result).toHaveLength(1);
expect(result[0].domainName).toBe('test-domain');
expect(result[0].endpointCount).toBe('10');
expect(result[0].rate).toBe('25');
expect(result[0].errorRate).toBe('2.5');
expect(result[0].latency).toBe(15);
expect(result[0].lastUsed).toBe('2025-09-17T12:54:17.040Z');
});
it('should handle n/a and undefined values', () => {
const columns = APIMonitoringColumnsMock;
const data = [
[
'test-domain',
'n/a', // endpoints -> 0
'n/a', // rps -> '-'
'n/a', // error_rate -> 0
'n/a', // p99 -> '-'
'n/a', // lastseen -> '-'
],
];
const result = formatDataForTable(data as any, columns as any);
expect(result[0].endpointCount).toBe(0);
expect(result[0].rate).toBe('-');
expect(result[0].errorRate).toBe(0);
expect(result[0].latency).toBe('-');
expect(result[0].lastUsed).toBe('-');
});
});
describe('getGroupByFiltersFromGroupByValues', () => {
it('should convert row data to filters correctly', () => {
// Arrange
@@ -1338,7 +1288,7 @@ describe('API Monitoring Utils', () => {
// Setup a mock
jest
.spyOn(
jest.requireActual('../utils'),
jest.requireActual('./utils'),
'getFormattedEndPointStatusCodeChartData',
)
.mockReturnValue({

View File

@@ -157,12 +157,9 @@ function DomainDetails({
<div className="domain-details-drawer-header">
<div className="domain-details-drawer-header-title">
<Divider type="vertical" />
{domainData?.domainName && (
<Typography.Text className="title">
{domainData.domainName}
</Typography.Text>
)}
<Typography.Text className="title">
{domainData.domainName}
</Typography.Text>
</div>
<div className="domain-details-drawer-header-right-container">
<DateTimeSelectionV2

View File

@@ -2,29 +2,36 @@ import '../Explorer.styles.scss';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table, Typography } from 'antd';
import axios from 'api';
import logEvent from 'api/common/logEvent';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import Toolbar from 'container/Toolbar/Toolbar';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
import { columnsConfig, formatDataForTable } from '../../utils';
import {
columnsConfig,
formatDataForTable,
hardcodedAttributeKeys,
} from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
function DomainList(): JSX.Element {
@@ -46,21 +53,6 @@ function DomainList(): JSX.Element {
entityVersion: '',
});
const compositeData = useGetCompositeQueryParam();
const { data, isLoading, isFetching } = useListOverview({
start: minTime,
end: maxTime,
show_ip: Boolean(showIP),
filter: {
expression: `kind_string = 'Client' ${get(
compositeData,
'builder.queryData[0].filter.expression',
'',
)}`,
},
});
// initialise tab with default query.
useShareBuilderUrl({
defaultValue: {
@@ -82,21 +74,63 @@ function DomainList(): JSX.Element {
},
});
const handleSearchChange = useCallback(
(value: string) => {
(handleChangeQueryData as HandleChangeQueryDataV5)('filter', {
expression: value,
});
const compositeData = useGetCompositeQueryParam();
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
handleChangeQueryData('filters', value);
},
[handleChangeQueryData],
);
const fetchApiOverview = async (): Promise<
SuccessResponse<any> | ErrorResponse
> => {
const requestBody = {
start: minTime,
end: maxTime,
show_ip: showIP,
filters: {
op: 'AND',
items: [
{
id: '212678b9',
key: {
key: 'kind_string',
dataType: 'string',
type: '',
},
op: '=',
value: 'Client',
},
...(compositeData?.builder?.queryData[0]?.filters?.items || []),
],
},
};
try {
const response = await axios.post(
'/third-party-apis/overview/list',
requestBody,
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
const { data, isLoading, isFetching } = useQuery(
[REACT_QUERY_KEY.GET_DOMAINS_LIST, minTime, maxTime, compositeData, showIP],
fetchApiOverview,
);
const formattedDataForTable = useMemo(
() =>
formatDataForTable(
data?.data?.data?.data.results[0]?.data || [],
data?.data?.data?.data.results[0]?.columns || [],
),
() => formatDataForTable(data?.payload?.data?.result[0]?.table?.rows),
[data],
);
@@ -116,13 +150,13 @@ function DomainList(): JSX.Element {
showAutoRefresh={false}
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
/>
{/* add bottom border here */}
<div className={cx('api-monitoring-list-header')}>
<QuerySearch
dataSource={DataSource.TRACES}
queryData={query}
onChange={handleSearchChange}
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
<QueryBuilderSearchV2
query={query}
onChange={handleChangeTagFilters}
placeholder="Search filters..."
hardcodedAttributeKeys={hardcodedAttributeKeys}
/>
</div>
<Table

View File

@@ -1,65 +0,0 @@
import { domainNameKey } from '../constants';
import { APIMonitoringResponseColumn } from '../types';
export const APIMonitoringColumnsMock: APIMonitoringResponseColumn[] = [
{
name: domainNameKey,
signal: 'traces',
fieldContext: '',
fieldDataType: 'string',
queryName: '',
aggregationIndex: 0,
meta: {},
columnType: 'attribute',
},
{
name: 'endpoints',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'endpoints',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
{
name: 'rps',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'rps',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
{
name: 'error_rate',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'error_rate',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
{
name: 'p99',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'p99',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
{
name: 'lastseen',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'lastseen',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
];

View File

@@ -1,30 +0,0 @@
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
export const ApiMonitoringHardcodedAttributeKeys: QueryKeyDataSuggestionsProps[] = [
{
label: 'deployment.environment',
type: 'resource',
name: 'deployment.environment',
signal: 'traces',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
},
{
label: 'service.name',
type: 'resource',
name: 'service.name',
signal: 'traces',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
},
{
label: 'rpc.method',
type: 'tag',
name: 'rpc.method',
signal: 'traces',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
},
];
export const domainNameKey = SPAN_ATTRIBUTES.SERVER_NAME;

View File

@@ -1,39 +0,0 @@
import { domainNameKey } from './constants';
export interface APIMonitoringResponseRow {
data: {
endpoints: number | string;
error_rate: number | string;
lastseen: number | string;
[domainNameKey]: string;
p99: number | string;
rps: number | string;
};
}
export interface APIMonitoringResponseColumn {
name: string;
signal: string;
fieldContext: string;
fieldDataType: string;
queryName: string;
aggregationIndex: number;
meta: Record<string, any>;
columnType: string;
}
export interface EndPointsResponseRow {
data: {
[key: string]: string | number | undefined;
};
}
export interface APIDomainsRowData {
key: string;
domainName: string;
endpointCount: number | string;
rate: number | string;
errorRate: number | string;
latency: number | string;
lastUsed: string;
}

View File

@@ -32,13 +32,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { domainNameKey } from './constants';
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
import {
APIDomainsRowData,
APIMonitoringResponseColumn,
EndPointsResponseRow,
} from './types';
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
{
@@ -249,47 +243,84 @@ export const columnsConfig: ColumnType<APIDomainsRowData>[] = [
},
];
export const formatDataForTable = (
data: string[][],
columns: APIMonitoringResponseColumn[],
): APIDomainsRowData[] => {
const indexMap = columns.reduce((acc, column, index) => {
if (column.name === domainNameKey) {
acc[column.name] = index;
} else {
acc[column.queryName] = index;
}
return acc;
}, {} as Record<string, number>);
// Rename this to a proper name
export const hardcodedAttributeKeys: BaseAutocompleteData[] = [
{
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
},
{
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
{
key: 'rpc.method',
dataType: DataTypes.String,
type: 'tag',
},
];
return data.map((row) => {
const rowData: APIDomainsRowData = {
key: v4(),
domainName: row[indexMap[domainNameKey]],
endpointCount:
row[indexMap.endpoints] === 'n/a' || row[indexMap.endpoints] === undefined
? 0
: row[indexMap.endpoints],
rate:
row[indexMap.rps] === 'n/a' || row[indexMap.rps] === undefined
? '-'
: row[indexMap.rps],
errorRate:
row[indexMap.error_rate] === 'n/a' || row[indexMap.error_rate] === undefined
? 0
: row[indexMap.error_rate],
latency:
row[indexMap.p99] === 'n/a' || row[indexMap.p99] === undefined
? '-'
: Math.round(Number(row[indexMap.p99]) / 1000000),
lastUsed:
row[indexMap.lastseen] === 'n/a' || row[indexMap.lastseen] === undefined
? '-'
: new Date(row[indexMap.lastseen]).toISOString(),
};
return rowData;
});
};
const domainNameKey = SPAN_ATTRIBUTES.SERVER_NAME;
interface APIMonitoringResponseRow {
data: {
endpoints: number | string;
error_rate: number | string;
lastseen: number | string;
[domainNameKey]: string;
p99: number | string;
rps: number | string;
};
}
interface EndPointsResponseRow {
data: {
[key: string]: string | number | undefined;
};
}
export interface APIDomainsRowData {
key: string;
domainName: string;
endpointCount: number | string;
rate: number | string;
errorRate: number | string;
latency: number | string;
lastUsed: string;
}
// Rename this to a proper name
export const formatDataForTable = (
data: APIMonitoringResponseRow[],
): APIDomainsRowData[] =>
data?.map((domain) => ({
key: v4(),
domainName: domain?.data[domainNameKey] || '-',
endpointCount:
domain?.data?.endpoints === 'n/a' || domain?.data?.endpoints === undefined
? 0
: domain?.data?.endpoints,
rate:
domain?.data?.rps === 'n/a' || domain?.data?.rps === undefined
? '-'
: domain?.data?.rps,
errorRate:
domain?.data?.error_rate === 'n/a' || domain?.data?.error_rate === undefined
? 0
: domain?.data?.error_rate,
latency:
domain?.data?.p99 === 'n/a' || domain?.data?.p99 === undefined
? '-'
: Math.round(Number(domain?.data?.p99) / 1000000), // Convert from nanoseconds to milliseconds
lastUsed:
domain?.data?.lastseen === 'n/a' || domain?.data?.lastseen === undefined
? '-'
: new Date(
Math.floor(Number(domain?.data?.lastseen) / 1000000),
).toISOString(), // Convert from nanoseconds to milliseconds
}));
export const getDomainMetricsQueryPayload = (
domainName: string,

View File

@@ -9,6 +9,22 @@ import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
@@ -51,103 +67,78 @@ describe('BillingContainer', () => {
expect(currentBill).toBeInTheDocument();
});
describe('Trial scenarios', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
});
afterEach(() => {
jest.useRealTimers();
});
test('OnTrail', async () => {
// Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining"
render(
<BillingContainer />,
{},
{ appContextOverrides: { trialInfo: licensesSuccessResponse.data } },
);
// If the component schedules any setTimeout on mount, flush them:
jest.runOnlyPendingTimers();
expect(await screen.findByText('Free Trial')).toBeInTheDocument();
expect(await screen.findByText('billing')).toBeInTheDocument();
expect(await screen.findByText(/\$0/i)).toBeInTheDocument();
expect(
await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
),
).toBeInTheDocument();
expect(await screen.findByText(/1 days_remaining/i)).toBeInTheDocument();
const upgradeButtons = await screen.findAllByRole('button', {
name: /upgrade_plan/i,
test('OnTrail', async () => {
await act(async () => {
render(<BillingContainer />, undefined, undefined, {
trialInfo: licensesSuccessResponse.data,
});
expect(upgradeButtons).toHaveLength(2);
expect(upgradeButtons[1]).toBeInTheDocument();
expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument();
expect(
await screen.findByRole('link', { name: /here/i }),
).toBeInTheDocument();
});
test('OnTrail but trialConvertedToSubscription', async () => {
await act(async () => {
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
});
const freeTrailText = await screen.findByText('Free Trial');
expect(freeTrailText).toBeInTheDocument();
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
expect(dollar0).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
);
expect(onTrail).toBeInTheDocument();
const onTrail = await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
);
expect(onTrail).toBeInTheDocument();
const receivedCardDetails = await screen.findByText(
/card_details_recieved_and_billing_info/i,
);
expect(receivedCardDetails).toBeInTheDocument();
const manageBillingButton = await screen.findByRole('button', {
name: /manage_billing/i,
});
expect(manageBillingButton).toBeInTheDocument();
const dayRemainingInBillingPeriod = await screen.findByText(
/1 days_remaining/i,
);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
const numberOfDayRemaining = await screen.findByText(/1 days_remaining/i);
expect(numberOfDayRemaining).toBeInTheDocument();
const upgradeButton = await screen.findAllByRole('button', {
name: /upgrade_plan/i,
});
expect(upgradeButton[1]).toBeInTheDocument();
expect(upgradeButton.length).toBe(2);
const checkPaidPlan = await screen.findByText(/checkout_plans/i);
expect(checkPaidPlan).toBeInTheDocument();
const link = await screen.findByRole('link', { name: /here/i });
expect(link).toBeInTheDocument();
});
test('OnTrail but trialConvertedToSubscription', async () => {
await act(async () => {
render(<BillingContainer />, undefined, undefined, {
trialInfo: trialConvertedToSubscriptionResponse.data,
});
});
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
);
expect(onTrail).toBeInTheDocument();
const receivedCardDetails = await screen.findByText(
/card_details_recieved_and_billing_info/i,
);
expect(receivedCardDetails).toBeInTheDocument();
const manageBillingButton = await screen.findByRole('button', {
name: /manage_billing/i,
});
expect(manageBillingButton).toBeInTheDocument();
const dayRemainingInBillingPeriod = await screen.findByText(
/1 days_remaining/i,
);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
});
test('Not on ontrail', async () => {
const { findByText } = render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: notOfTrailResponse.data,
},
},
);
const { findByText } = render(<BillingContainer />, undefined, undefined, {
trialInfo: notOfTrailResponse.data,
});
const billingPeriodText = `Your current billing period is from ${getFormattedDate(
billingSuccessResponse.data.billingPeriodStart,

View File

@@ -1,6 +1,7 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
import { MemoryRouter, Route } from 'react-router-dom';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -13,6 +14,20 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
@@ -69,11 +84,11 @@ describe('Alert rule documentation redirection', () => {
beforeEach(() => {
act(() => {
renderResult = render(
<CreateAlertPage />,
{},
{
initialRoute: ROUTES.ALERTS_NEW,
},
<MemoryRouter initialEntries={['/alerts/new']}>
<Route path={ROUTES.ALERTS_NEW}>
<CreateAlertPage />
</Route>
</MemoryRouter>,
);
});
});

View File

@@ -15,6 +15,20 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({

View File

@@ -1,4 +1,5 @@
import './styles.scss';
import '../EvaluationSettings/styles.scss';
import { Button, Tooltip } from 'antd';
import classNames from 'classnames';

View File

@@ -1,6 +1,7 @@
import './styles.scss';
import '../EvaluationSettings/styles.scss';
import { Button, Select, Typography } from 'antd';
import { Button, Select, Tooltip, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -25,6 +26,7 @@ import { UpdateThreshold } from './types';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
getMatchTypeTooltip,
getQueryNames,
} from './utils';
@@ -85,6 +87,35 @@ function AlertThreshold(): JSX.Element {
});
};
const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map(
(option) => ({
...option,
label: (
<Tooltip
title={getMatchTypeTooltip(option.value, thresholdState.operator)}
placement="left"
overlayClassName="copyable-tooltip"
overlayStyle={{
maxWidth: '450px',
minWidth: '400px',
}}
overlayInnerStyle={{
padding: '12px 16px',
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text',
}}
mouseEnterDelay={0.2}
trigger={['hover', 'click']}
destroyTooltipOnHide={false}
>
<span style={{ display: 'block', width: '100%' }}>{option.label}</span>
</Tooltip>
),
}),
);
const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings />
) : (
@@ -114,8 +145,7 @@ function AlertThreshold(): JSX.Element {
style={{ width: 80 }}
options={queryNames}
/>
</div>
<div className="alert-condition-sentence">
<Typography.Text className="sentence-text">is</Typography.Text>
<Select
value={thresholdState.operator}
onChange={(value): void => {
@@ -124,7 +154,7 @@ function AlertThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 120 }}
style={{ width: 180 }}
options={THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text className="sentence-text">
@@ -138,8 +168,8 @@ function AlertThreshold(): JSX.Element {
payload: value,
});
}}
style={{ width: 140 }}
options={THRESHOLD_MATCH_TYPE_OPTIONS}
style={{ width: 180 }}
options={matchTypeOptionsWithTooltips}
/>
<Typography.Text className="sentence-text">
during the {evaluationWindowContext}

View File

@@ -1,7 +1,9 @@
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
import { ChartLine, CircleX } from 'lucide-react';
import { Button, Input, Select, Tooltip, Typography } from 'antd';
import { ChartLine, CircleX, Trash } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useCreateAlertState } from '../context';
import { AlertThresholdOperator } from '../context/types';
import { ThresholdItemProps } from './types';
function ThresholdItem({
@@ -12,6 +14,7 @@ function ThresholdItem({
channels,
units,
}: ThresholdItemProps): JSX.Element {
const { thresholdState } = useCreateAlertState();
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
const yAxisUnitSelect = useMemo(() => {
@@ -45,6 +48,31 @@ function ThresholdItem({
return component;
}, [units, threshold.unit, updateThreshold, threshold.id]);
const getOperatorSymbol = (): string => {
switch (thresholdState.operator) {
case AlertThresholdOperator.IS_ABOVE:
return '>';
case AlertThresholdOperator.IS_BELOW:
return '<';
case AlertThresholdOperator.IS_EQUAL_TO:
return '=';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return '!=';
default:
return '';
}
};
const addRecoveryThreshold = (): void => {
setShowRecoveryThreshold(true);
updateThreshold(threshold.id, 'recoveryThresholdValue', 0);
};
const removeRecoveryThreshold = (): void => {
setShowRecoveryThreshold(false);
updateThreshold(threshold.id, 'recoveryThresholdValue', null);
};
return (
<div key={threshold.id} className="threshold-item">
<div className="threshold-row">
@@ -54,80 +82,98 @@ function ThresholdItem({
style={{ backgroundColor: threshold.color }}
/>
</div>
<Space className="threshold-controls">
<div className="threshold-inputs">
<Input.Group>
<Input
placeholder="Enter threshold name"
value={threshold.label}
onChange={(e): void =>
updateThreshold(threshold.id, 'label', e.target.value)
}
style={{ width: 260 }}
/>
<Input
placeholder="Enter threshold value"
value={threshold.thresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
}
style={{ width: 210 }}
/>
{yAxisUnitSelect}
</Input.Group>
</div>
<Typography.Text className="sentence-text">to</Typography.Text>
<div className="threshold-controls">
<Input
placeholder="Enter threshold name"
value={threshold.label}
onChange={(e): void =>
updateThreshold(threshold.id, 'label', e.target.value)
}
style={{ width: 200 }}
/>
<Typography.Text className="sentence-text">on value</Typography.Text>
<Typography.Text className="sentence-text highlighted-text">
{getOperatorSymbol()}
</Typography.Text>
<Input
placeholder="Enter threshold value"
value={threshold.thresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
}
style={{ width: 100 }}
type="number"
/>
{yAxisUnitSelect}
<Typography.Text className="sentence-text">send to</Typography.Text>
<Select
value={threshold.channels}
onChange={(value): void =>
updateThreshold(threshold.id, 'channels', value)
}
style={{ width: 260 }}
style={{ width: 350 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
showSearch
maxTagCount={2}
maxTagPlaceholder={(omittedValues): string =>
`+${omittedValues.length} more`
}
maxTagTextLength={10}
filterOption={(input, option): boolean =>
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
}
/>
{showRecoveryThreshold && (
<>
<Typography.Text className="sentence-text">recover on</Typography.Text>
<Input
placeholder="Enter recovery threshold value"
value={threshold.recoveryThresholdValue ?? ''}
onChange={(e): void =>
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
}
style={{ width: 100 }}
type="number"
/>
<Tooltip title="Remove recovery threshold">
<Button
type="default"
icon={<Trash size={16} />}
onClick={removeRecoveryThreshold}
className="icon-btn"
/>
</Tooltip>
</>
)}
<Button.Group>
{!showRecoveryThreshold && (
<Button
type="default"
icon={<ChartLine size={16} />}
className="icon-btn"
onClick={(): void => setShowRecoveryThreshold(true)}
/>
<Tooltip title="Add recovery threshold">
<Button
type="default"
icon={<ChartLine size={16} />}
className="icon-btn"
onClick={addRecoveryThreshold}
/>
</Tooltip>
)}
{showRemoveButton && (
<Button
type="default"
icon={<CircleX size={16} />}
onClick={(): void => removeThreshold(threshold.id)}
className="icon-btn"
/>
<Tooltip title="Remove threshold">
<Button
type="default"
icon={<CircleX size={16} />}
onClick={(): void => removeThreshold(threshold.id)}
className="icon-btn"
/>
</Tooltip>
)}
</Button.Group>
</Space>
</div>
</div>
{showRecoveryThreshold && (
<Input.Group className="recovery-threshold-input-group">
<Input
placeholder="Recovery threshold"
disabled
style={{ width: 260 }}
className="recovery-threshold-label"
/>
<Input
placeholder="Enter recovery threshold value"
value={threshold.recoveryThresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
}
style={{ width: 210 }}
/>
</Input.Group>
)}
</div>
);
}

View File

@@ -99,7 +99,7 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
const TEST_STRINGS = {
ADD_THRESHOLD: 'Add Threshold',
AT_LEAST_ONCE: 'AT LEAST ONCE',
IS_ABOVE: 'IS ABOVE',
IS_ABOVE: 'ABOVE',
} as const;
const createTestQueryClient = (): QueryClient =>
@@ -125,7 +125,12 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
};
const verifySelectRenders = (title: string): void => {
const select = screen.getByTitle(title);
// Try to find by title first, if not found, try to find by text content
let select = screen.queryByTitle(title);
if (!select) {
// For match type select, look for the text content instead
select = screen.getByText(title);
}
expect(select).toBeInTheDocument();
};

View File

@@ -2,15 +2,36 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { DefaultOptionType } from 'antd/es/select';
import {
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
} from 'container/CreateAlertV2/context/constants';
import { Channels } from 'types/api/channels/getAll';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
import { ThresholdItemProps } from '../types';
// Mock the enableRecoveryThreshold utility
jest.mock('../../utils', () => ({
enableRecoveryThreshold: jest.fn(() => true),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock: any = jest.fn(() => ({
paths,
}));
uplotMock.paths = paths;
return uplotMock;
});
const mockSetAlertState = jest.fn();
const mockSetThresholdState = jest.fn();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
alertState: INITIAL_ALERT_STATE,
setAlertState: mockSetAlertState,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
setThresholdState: mockSetThresholdState,
} as any);
const TEST_CONSTANTS = {
THRESHOLD_ID: 'test-threshold-1',
@@ -21,6 +42,7 @@ const TEST_CONSTANTS = {
CHANNEL_2: 'channel-2',
CHANNEL_3: 'channel-3',
EMAIL_CHANNEL_NAME: 'Email Channel',
EMAIL_CHANNEL_TRUNCATED: 'Email Chan...',
ENTER_THRESHOLD_NAME: 'Enter threshold name',
ENTER_THRESHOLD_VALUE: 'Enter threshold value',
ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value',
@@ -122,7 +144,7 @@ describe('ThresholdItem', () => {
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(valueInput).toHaveValue('100');
expect(valueInput).toHaveValue(100);
});
it('renders unit selector with correct value', () => {
@@ -135,9 +157,8 @@ describe('ThresholdItem', () => {
it('renders channels selector with correct value', () => {
renderThresholdItem();
// Check for the channels selector by looking for the displayed text
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_TRUNCATED),
).toBeInTheDocument();
});
@@ -251,7 +272,9 @@ describe('ThresholdItem', () => {
const recoveryButton = buttons[0]; // First button is the recovery button
fireEvent.click(recoveryButton);
expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Enter recovery threshold value'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE),
).toBeInTheDocument();
@@ -295,7 +318,7 @@ describe('ThresholdItem', () => {
// Check that channels are rendered as multiple select
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_TRUNCATED),
).toBeInTheDocument();
// Should be able to select multiple channels
@@ -318,7 +341,7 @@ describe('ThresholdItem', () => {
renderThresholdItem({ threshold: emptyThreshold });
expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue('');
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0');
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue(0);
});
it('renders with correct input widths', () => {
@@ -331,13 +354,13 @@ describe('ThresholdItem', () => {
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(labelInput).toHaveStyle('width: 260px');
expect(valueInput).toHaveStyle('width: 210px');
expect(labelInput).toHaveStyle('width: 200px');
expect(valueInput).toHaveStyle('width: 100px');
});
it('renders channels selector with correct width', () => {
renderThresholdItem();
verifySelectorWidth(1, '260px');
verifySelectorWidth(1, '350px');
});
it('renders unit selector with correct width', () => {
@@ -357,30 +380,7 @@ describe('ThresholdItem', () => {
const recoveryValueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
);
expect(recoveryValueInput).toHaveValue('80');
});
it('renders recovery threshold label as disabled', () => {
renderThresholdItem();
showRecoveryThreshold();
const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold');
expect(recoveryLabelInput).toBeDisabled();
});
it('renders correct channel options', () => {
renderThresholdItem();
// Check that channels are rendered
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
).toBeInTheDocument();
// Should be able to select different channels
const channelSelectors = screen.getAllByRole('combobox');
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
fireEvent.change(channelSelector, { target: { value: 'channel-2' } });
expect(screen.getByText('Slack Channel')).toBeInTheDocument();
expect(recoveryValueInput).toHaveValue(80);
});
it('handles threshold without channels', () => {

View File

@@ -67,7 +67,7 @@
padding-right: 72px;
background-color: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
width: fit-content;
width: 100%;
.alert-condition-sentences {
display: flex;
@@ -90,7 +90,7 @@
}
.ant-select {
width: 240px !important;
width: 240px;
.ant-select-selector {
background-color: var(--bg-ink-300);
@@ -148,6 +148,7 @@
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.ant-input {
background-color: var(--bg-ink-400);
@@ -293,7 +294,8 @@
.ant-btn {
display: flex;
align-items: center;
width: 240px;
min-width: 240px;
width: auto;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
@@ -301,6 +303,7 @@
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
flex-shrink: 0;
}
.evaluate-alert-conditions-button-right {
@@ -308,6 +311,7 @@
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
flex-shrink: 0;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
@@ -318,3 +322,229 @@
}
}
}
.lightMode {
.alert-condition-container {
.alert-condition {
.alert-condition-tabs {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.explorer-view-option {
border-left: 0.5px solid var(--bg-vanilla-300);
border-bottom: 0.5px solid var(--bg-vanilla-300);
&.active-tab {
background-color: var(--bg-vanilla-100);
&:hover {
background-color: var(--bg-vanilla-100) !important;
}
}
&:disabled {
background-color: var(--bg-vanilla-300);
}
&:hover {
color: var(--bg-ink-400);
}
}
}
}
}
.alert-threshold-container,
.anomaly-threshold-container {
background-color: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
.alert-condition-sentences {
.alert-condition-sentence {
.sentence-text {
color: var(--text-ink-400);
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--text-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select-selection-item {
color: var(--bg-ink-400);
}
.ant-select-arrow {
color: var(--bg-ink-400);
}
}
}
}
.thresholds-section {
.threshold-item {
.threshold-row {
.threshold-controls {
.threshold-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.ant-input {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select-selection-item {
color: var(--bg-ink-400);
}
.ant-select-arrow {
color: var(--bg-ink-400);
}
}
.icon-btn {
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
}
}
}
.recovery-threshold-input-group {
.recovery-threshold-btn {
color: var(--bg-ink-400);
background-color: var(--bg-vanilla-200) !important;
border: 1px solid var(--bg-vanilla-300);
}
.ant-input {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}
.add-threshold-btn {
border: 1px dashed var(--bg-vanilla-300);
color: var(--bg-ink-300);
&:hover {
border-color: var(--bg-ink-300);
color: var(--bg-ink-400);
}
}
}
}
}
.condensed-evaluation-settings-container {
.ant-btn {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
min-width: 240px;
width: auto;
.evaluate-alert-conditions-button-left {
color: var(--bg-ink-400);
flex-shrink: 0;
}
.evaluate-alert-conditions-button-right {
color: var(--bg-ink-400);
flex-shrink: 0;
.evaluate-alert-conditions-button-right-text {
background-color: var(--bg-vanilla-300);
}
}
}
}
}
.highlighted-text {
font-weight: bold;
color: var(--bg-robin-400);
margin: 0 4px;
}
// Tooltip styles
.tooltip-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.tooltip-description {
margin-bottom: 8px;
span {
font-weight: bold;
color: var(--bg-robin-400);
}
}
.tooltip-example {
margin-bottom: 8px;
color: #8b92a0;
}
.tooltip-link {
.tooltip-link-text {
color: #1890ff;
font-size: 11px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}

View File

@@ -8,7 +8,7 @@ export type UpdateThreshold = {
(
thresholdId: string,
field: Exclude<keyof Threshold, 'channels'>,
value: string,
value: string | number | null,
): void;
};

View File

@@ -1,6 +1,10 @@
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -44,3 +48,303 @@ export function getCategorySelectOptionByName(
) || []
);
}
const getOperatorWord = (op: AlertThresholdOperator): string => {
switch (op) {
case AlertThresholdOperator.IS_ABOVE:
return 'exceed';
case AlertThresholdOperator.IS_BELOW:
return 'fall below';
case AlertThresholdOperator.IS_EQUAL_TO:
return 'equal';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 'not equal';
default:
return 'exceed';
}
};
const getThresholdValue = (op: AlertThresholdOperator): number => {
switch (op) {
case AlertThresholdOperator.IS_ABOVE:
return 80;
case AlertThresholdOperator.IS_BELOW:
return 50;
case AlertThresholdOperator.IS_EQUAL_TO:
return 100;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 0;
default:
return 80;
}
};
const getDataPoints = (
matchType: AlertThresholdMatchType,
op: AlertThresholdOperator,
): number[] => {
const dataPointMap: Record<
AlertThresholdMatchType,
Record<AlertThresholdOperator, number[]>
> = {
[AlertThresholdMatchType.AT_LEAST_ONCE]: {
[AlertThresholdOperator.IS_BELOW]: [60, 45, 40, 55, 35],
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.ALL_THE_TIME]: {
[AlertThresholdOperator.IS_BELOW]: [45, 40, 35, 42, 38],
[AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95],
},
[AlertThresholdMatchType.ON_AVERAGE]: {
[AlertThresholdOperator.IS_BELOW]: [60, 40, 45, 35, 45],
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.IN_TOTAL]: {
[AlertThresholdOperator.IS_BELOW]: [8, 5, 10, 12, 8],
[AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30],
},
[AlertThresholdMatchType.LAST]: {
[AlertThresholdOperator.IS_BELOW]: [75, 85, 90, 78, 45],
[AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
};
return dataPointMap[matchType]?.[op] || [75, 85, 90, 78, 95];
};
const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => {
const symbolMap: Record<AlertThresholdOperator, string> = {
[AlertThresholdOperator.IS_ABOVE]: '>',
[AlertThresholdOperator.IS_BELOW]: '<',
[AlertThresholdOperator.IS_EQUAL_TO]: '=',
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=',
[AlertThresholdOperator.ABOVE_BELOW]: '>',
};
return symbolMap[op] || '>';
};
const handleTooltipClick = (
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
): void => {
e.stopPropagation();
};
function TooltipContent({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<div
role="button"
tabIndex={0}
onClick={handleTooltipClick}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
handleTooltipClick(e);
}
}}
className="tooltip-content"
>
{children}
</div>
);
}
function TooltipExample({
children,
dataPoints,
operatorSymbol,
thresholdValue,
matchType,
}: {
children: React.ReactNode;
dataPoints: number[];
operatorSymbol: string;
thresholdValue: number;
matchType: AlertThresholdMatchType;
}): JSX.Element {
return (
<div className="tooltip-example">
<strong>Example:</strong>
<br />
Say, For a 5-minute window (configured in Evaluation settings), 1 min
aggregation interval (set up in query) 5{' '}
{matchType === AlertThresholdMatchType.IN_TOTAL
? 'error counts'
: 'data points'}
: [{dataPoints.join(', ')}]<br />
With threshold {operatorSymbol} {thresholdValue}: {children}
</div>
);
}
function TooltipLink(): JSX.Element {
return (
<div className="tooltip-link">
<a
href="https://signoz.io/docs"
target="_blank"
rel="noopener noreferrer"
className="tooltip-link-text"
>
Learn more
</a>
</div>
);
}
export const getMatchTypeTooltip = (
matchType: AlertThresholdMatchType,
operator: AlertThresholdOperator,
): React.ReactNode => {
const operatorSymbol = getTooltipOperatorSymbol(operator);
const operatorWord = getOperatorWord(operator);
const thresholdValue = getThresholdValue(operator);
const dataPoints = getDataPoints(matchType, operator);
const getMatchingPointsCount = (): number =>
dataPoints.filter((p) => {
switch (operator) {
case AlertThresholdOperator.IS_ABOVE:
return p > thresholdValue;
case AlertThresholdOperator.IS_BELOW:
return p < thresholdValue;
case AlertThresholdOperator.IS_EQUAL_TO:
return p === thresholdValue;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return p !== thresholdValue;
default:
return p > thresholdValue;
}
}).length;
switch (matchType) {
case AlertThresholdMatchType.AT_LEAST_ONCE:
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if <span>ANY</span> of
those aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers ({getMatchingPointsCount()} points {operatorWord}{' '}
{thresholdValue})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
case AlertThresholdMatchType.ALL_THE_TIME:
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if <span>ALL</span>{' '}
aggregated data points cross the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (all points {operatorWord} {thresholdValue})<br />
If any point was {thresholdValue}, no alert would fire
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
case AlertThresholdMatchType.ON_AVERAGE: {
const average = (
dataPoints.reduce((a, b) => a + b, 0) / dataPoints.length
).toFixed(1);
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if the{' '}
<span>AVERAGE</span> of all aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (average = {average})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
case AlertThresholdMatchType.IN_TOTAL: {
const total = dataPoints.reduce((a, b) => a + b, 0);
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if the{' '}
<span>SUM</span> of all aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (total = {total})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
case AlertThresholdMatchType.LAST: {
const lastPoint = dataPoints[dataPoints.length - 1];
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers based on the{' '}
<span>MOST RECENT</span> aggregated data point only.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (last point = {lastPoint})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
default:
return '';
}
};

View File

@@ -49,15 +49,6 @@ function CreateAlertHeader(): JSX.Element {
className="alert-header__input title"
placeholder="Enter alert rule name"
/>
<input
type="text"
value={alertState.description}
onChange={(e): void =>
setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value })
}
className="alert-header__input description"
placeholder="Click to add description..."
/>
<LabelsInput
labels={alertState.labels}
onLabelsChange={(labels: Labels): void =>

View File

@@ -44,14 +44,6 @@ describe('CreateAlertHeader', () => {
expect(nameInput).toBeInTheDocument();
});
it('renders description input with placeholder', () => {
renderCreateAlertHeader();
const descriptionInput = screen.getByPlaceholderText(
'Click to add description...',
);
expect(descriptionInput).toBeInTheDocument();
});
it('renders LabelsInput component', () => {
renderCreateAlertHeader();
expect(screen.getByText('+ Add labels')).toBeInTheDocument();
@@ -65,13 +57,4 @@ describe('CreateAlertHeader', () => {
expect(nameInput).toHaveValue('Test Alert');
});
it('updates description when typing in description input', () => {
renderCreateAlertHeader();
const descriptionInput = screen.getByPlaceholderText(
'Click to add description...',
);
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
expect(descriptionInput).toHaveValue('Test Description');
});
});

View File

@@ -149,3 +149,75 @@
}
}
}
.lightMode {
.alert-header {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
&__tab-bar {
background: repeating-linear-gradient(
-45deg,
#f5f5f5,
#f5f5f5 10px,
#e5e5e5 10px,
#e5e5e5 20px
);
}
&__tab {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
}
&__tab::before {
color: var(--bg-ink-100);
}
&__content {
background: var(--bg-vanilla-100);
}
&__input.title {
color: var(--text-ink-100);
}
&__input.description {
color: var(--text-ink-300);
}
}
.labels-input {
&__add-button {
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
&:hover {
border-color: var(--bg-ink-300);
color: var(--bg-ink-500);
}
}
&__label-pill {
background-color: #ad7f581a;
color: var(--bg-sienna-400);
border: 1px solid var(--bg-sienna-500);
}
&__remove-button {
color: var(--bg-sienna-400);
&:hover {
color: var(--text-ink-100);
}
}
&__input {
color: var(--bg-ink-500);
&::placeholder {
color: var(--bg-ink-300);
}
}
}
}

View File

@@ -1,8 +1,12 @@
$top-nav-background-1: #0f0f0f;
$top-nav-background-2: #101010;
$top-nav-background-1-light: #f5f5f5;
$top-nav-background-2-light: #e5e5e5;
.create-alert-v2-container {
background-color: var(--bg-ink-500);
padding-bottom: 100px;
}
.top-nav-container {
@@ -15,3 +19,19 @@ $top-nav-background-2: #101010;
);
margin-bottom: 0;
}
.lightMode {
.create-alert-v2-container {
background-color: var(--bg-vanilla-100);
}
.top-nav-container {
background: repeating-linear-gradient(
-45deg,
$top-nav-background-1-light,
$top-nav-background-1-light 10px,
$top-nav-background-2-light 10px,
$top-nav-background-2-light 20px
);
}
}

View File

@@ -8,6 +8,7 @@ import AlertCondition from './AlertCondition';
import { CreateAlertProvider } from './context';
import CreateAlertHeader from './CreateAlertHeader';
import EvaluationSettings from './EvaluationSettings';
import Footer from './Footer';
import NotificationSettings from './NotificationSettings';
import QuerySection from './QuerySection';
import { showCondensedLayout } from './utils';
@@ -30,6 +31,7 @@ function CreateAlertV2({
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
<NotificationSettings />
</div>
<Footer />
</CreateAlertProvider>
);
}

View File

@@ -27,7 +27,7 @@ function AdvancedOptionItem({
{title}
{tooltipText && (
<Tooltip title={tooltipText}>
<Info data-testid="tooltip-icon" size={16} />
<Info size={16} />
</Tooltip>
)}
</Typography.Text>
@@ -36,12 +36,7 @@ function AdvancedOptionItem({
</Typography.Text>
</div>
<div className="advanced-option-item-right-content">
<div
className="advanced-option-item-input"
style={{ display: showInput ? 'block' : 'none' }}
>
{input}
</div>
{showInput && <div className="advanced-option-item-input">{input}</div>}
<Switch onChange={handleOnToggle} />
</div>
</div>

View File

@@ -2,7 +2,7 @@ import { Collapse, Input, Select, Typography } from 'antd';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { useCreateAlertState } from '../context';
import AdvancedOptionItem from './AdvancedOptionItem';
import AdvancedOptionItem from './AdvancedOptionItem/AdvancedOptionItem';
import EvaluationCadence from './EvaluationCadence';
function AdvancedOptions(): JSX.Element {

View File

@@ -0,0 +1,654 @@
import {
Button,
DatePicker,
Input,
Modal,
Select,
Tooltip,
Typography,
} from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import classNames from 'classnames';
import {
Calendar,
Calendar1,
Code,
Edit,
Edit3Icon,
Info,
Plus,
X,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useCreateAlertState } from '../context';
import {
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS,
INITIAL_ADVANCED_OPTIONS_STATE,
} from '../context/constants';
import { AdvancedOptionsState } from '../context/types';
import {
EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS,
EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS,
EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS,
} from './constants';
import TimeInput from './TimeInput';
import {
IEvaluationCadenceDetailsProps,
IEvaluationCadencePreviewProps,
} from './types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
isValidRRule,
TIMEZONE_DATA,
} from './utils';
export function EvaluationCadenceDetails({
setIsOpen,
}: IEvaluationCadenceDetailsProps): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const [evaluationCadence, setEvaluationCadence] = useState<
AdvancedOptionsState['evaluationCadence']
>({
...advancedOptions.evaluationCadence,
});
const tabs = [
{
label: 'Editor',
icon: <Edit3Icon size={14} />,
value: 'editor',
},
{
label: 'RRule',
icon: <Code size={14} />,
value: 'rrule',
},
];
const [activeTab, setActiveTab] = useState<'editor' | 'rrule'>(() =>
evaluationCadence.mode === 'custom' ? 'editor' : 'rrule',
);
const occurenceOptions =
evaluationCadence.custom.repeatEvery === 'week'
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS
: EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS;
const EditorView = (
<div className="editor-view" data-testid="editor-view">
<div className="select-group">
<Typography.Text>REPEAT EVERY</Typography.Text>
<Select
options={EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS}
value={evaluationCadence.custom.repeatEvery || null}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
repeatEvery: value,
occurence: [],
},
})
}
placeholder="Select repeat every"
/>
</div>
<div className="select-group">
<Typography.Text>ON DAY(S)</Typography.Text>
<Select
options={occurenceOptions}
value={evaluationCadence.custom.occurence || null}
mode="multiple"
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
occurence: value,
},
})
}
placeholder="Select day(s)"
/>
</div>
<div className="select-group">
<Typography.Text>AT</Typography.Text>
<TimeInput
value={evaluationCadence.custom.startAt}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
startAt: value,
},
})
}
/>
</div>
<div className="select-group">
<Typography.Text>TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationCadence.custom.timezone || null}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
timezone: value,
},
})
}
placeholder="Select timezone"
/>
</div>
</div>
);
const RRuleView = (
<div className="rrule-view" data-testid="rrule-view">
<div className="select-group">
<Typography.Text>STARTING ON</Typography.Text>
<DatePicker
value={evaluationCadence.rrule.date}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
date: value,
},
})
}
placeholder="Select date"
/>
</div>
<div className="select-group">
<Typography.Text>AT</Typography.Text>
<TimeInput
value={evaluationCadence.rrule.startAt}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
startAt: value,
},
})
}
/>
</div>
<TextArea
value={evaluationCadence.rrule.rrule}
placeholder="Enter RRule"
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
rrule: value.target.value,
},
})
}
/>
</div>
);
const handleDiscard = (): void => {
setIsOpen(false);
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'default',
});
};
const handleSaveCustomSchedule = (): void => {
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
custom: evaluationCadence.custom,
rrule: evaluationCadence.rrule,
},
});
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: evaluationCadence.mode,
});
setIsOpen(false);
};
const disableSaveButton = useMemo(() => {
if (activeTab === 'editor') {
return (
!evaluationCadence.custom.repeatEvery ||
!evaluationCadence.custom.occurence.length ||
!evaluationCadence.custom.startAt ||
!evaluationCadence.custom.timezone
);
}
return (
!evaluationCadence.rrule.rrule ||
!evaluationCadence.rrule.date ||
!evaluationCadence.rrule.startAt ||
!isValidRRule(evaluationCadence.rrule.rrule)
);
}, [evaluationCadence, activeTab]);
const schedule = useMemo(() => {
if (activeTab === 'rrule') {
return buildAlertScheduleFromRRule(
evaluationCadence.rrule.rrule,
evaluationCadence.rrule.date,
evaluationCadence.rrule.startAt,
15,
);
}
return buildAlertScheduleFromCustomSchedule(
evaluationCadence.custom.repeatEvery,
evaluationCadence.custom.occurence,
evaluationCadence.custom.startAt,
evaluationCadence.custom.timezone,
15,
);
}, [evaluationCadence, activeTab]);
const handleChangeTab = (tab: 'editor' | 'rrule'): void => {
setActiveTab(tab);
const mode = tab === 'editor' ? 'custom' : 'rrule';
setEvaluationCadence({
...evaluationCadence,
mode,
});
};
return (
<div className="evaluation-cadence-details">
<Typography.Text className="evaluation-cadence-details-title">
Add Custom Schedule
</Typography.Text>
<div className="evaluation-cadence-details-content">
<div className="evaluation-cadence-details-content-row">
<div className="query-section-tabs">
<div className="query-section-query-actions">
{tabs.map((tab) => (
<Button
key={tab.value}
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': activeTab === tab.value,
})}
onClick={(): void => {
handleChangeTab(tab.value as 'editor' | 'rrule');
}}
>
{tab.icon}
{tab.label}
</Button>
))}
</div>
</div>
{activeTab === 'editor' && EditorView}
{activeTab === 'rrule' && RRuleView}
<div className="buttons-row">
<Button type="default" onClick={handleDiscard}>
Discard
</Button>
<Button
type="primary"
onClick={handleSaveCustomSchedule}
disabled={disableSaveButton}
>
Save Custom Schedule
</Button>
</div>
</div>
<div className="evaluation-cadence-details-content-row">
{schedule ? (
<div className="schedule-preview">
<div className="schedule-preview-header">
<Calendar size={16} />
<Typography.Text className="schedule-preview-title">
Schedule Preview
</Typography.Text>
</div>
<div className="schedule-preview-list">
{schedule.map((date) => (
<div key={date.toISOString()} className="schedule-preview-item">
<div className="schedule-preview-timeline">
<div className="schedule-preview-timeline-line" />
</div>
<div className="schedule-preview-content">
<div className="schedule-preview-date">
{date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
,{' '}
{date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
<div className="schedule-preview-separator" />
<div className="schedule-preview-timezone">
UTC {date.getTimezoneOffset() <= 0 ? '+' : '-'}{' '}
{Math.abs(Math.floor(date.getTimezoneOffset() / 60))}:
{String(Math.abs(date.getTimezoneOffset() % 60)).padStart(2, '0')}
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="no-schedule">
<Info size={32} />
<Typography.Text>
Please fill the relevant information to generate a schedule
</Typography.Text>
</div>
)}
</div>
</div>
</div>
);
}
function EvaluationCadencePreview({
isOpen,
setIsOpen,
}: IEvaluationCadencePreviewProps): JSX.Element {
const { advancedOptions } = useCreateAlertState();
const schedule = useMemo(() => {
if (advancedOptions.evaluationCadence.mode === 'rrule') {
return buildAlertScheduleFromRRule(
advancedOptions.evaluationCadence.rrule.rrule,
advancedOptions.evaluationCadence.rrule.date,
advancedOptions.evaluationCadence.rrule.startAt,
15,
);
}
return buildAlertScheduleFromCustomSchedule(
advancedOptions.evaluationCadence.custom.repeatEvery,
advancedOptions.evaluationCadence.custom.occurence,
advancedOptions.evaluationCadence.custom.startAt,
advancedOptions.evaluationCadence.custom.timezone,
15,
);
}, [advancedOptions.evaluationCadence]);
return (
<Modal open={isOpen} onCancel={(): void => setIsOpen(false)} footer={null}>
<div className="evaluation-cadence-details evaluation-cadence-preview">
<div className="evaluation-cadence-details-content">
<div className="evaluation-cadence-details-content-row">
{schedule ? (
<div className="schedule-preview">
<div className="schedule-preview-header">
<Calendar size={16} />
<Typography.Text className="schedule-preview-title">
Schedule Preview
</Typography.Text>
</div>
<div className="schedule-preview-list">
{schedule.map((date) => (
<div key={date.toISOString()} className="schedule-preview-item">
<div className="schedule-preview-timeline">
<div className="schedule-preview-timeline-line" />
</div>
<div className="schedule-preview-content">
<div className="schedule-preview-date">
{date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
,{' '}
{date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
<div className="schedule-preview-separator" />
<div className="schedule-preview-timezone">
UTC {date.getTimezoneOffset() <= 0 ? '+' : '-'}{' '}
{Math.abs(Math.floor(date.getTimezoneOffset() / 60))}:
{String(Math.abs(date.getTimezoneOffset() % 60)).padStart(2, '0')}
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="no-schedule">
<Info size={32} />
<Typography.Text>
Please fill the relevant information to generate a schedule
</Typography.Text>
</div>
)}
</div>
</div>
</div>
</Modal>
);
}
function EditCustomSchedule({
setIsEvaluationCadenceDetailsVisible,
setIsPreviewVisible,
}: {
setIsEvaluationCadenceDetailsVisible: (isOpen: boolean) => void;
setIsPreviewVisible: (isOpen: boolean) => void;
}): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const displayText = useMemo(() => {
if (advancedOptions.evaluationCadence.mode === 'custom') {
return (
<Typography.Text>
<Typography.Text>Every</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.repeatEvery
.charAt(0)
.toUpperCase() +
advancedOptions.evaluationCadence.custom.repeatEvery.slice(1)}
</Typography.Text>
<Typography.Text>on</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.occurence
.map(
(occurence) => occurence.charAt(0).toUpperCase() + occurence.slice(1),
)
.join(', ')}
</Typography.Text>
<Typography.Text>at</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.startAt}
</Typography.Text>
</Typography.Text>
);
}
return (
<Typography.Text>
<Typography.Text>Starting on</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.rrule.date?.format('DD/MM/YYYY')}
</Typography.Text>
<Typography.Text>at</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.rrule.startAt}
</Typography.Text>
</Typography.Text>
);
}, [advancedOptions.evaluationCadence]);
const handleEdit = (): void => {
setIsEvaluationCadenceDetailsVisible(true);
};
const handlePreview = (): void => {
setIsPreviewVisible(true);
};
const handleDiscard = (): void => {
setIsEvaluationCadenceDetailsVisible(false);
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
});
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'default',
});
};
return (
<div className="edit-custom-schedule">
{displayText}
<div className="button-row">
<Button.Group>
<Button type="default" onClick={handleEdit}>
<Edit size={12} />
<Typography.Text>Edit custom schedule</Typography.Text>
</Button>
<Button type="default" onClick={handlePreview}>
<Calendar1 size={12} />
<Typography.Text>Preview</Typography.Text>
</Button>
<Button
data-testid="discard-button"
type="default"
onClick={handleDiscard}
>
<X size={12} />
</Button>
</Button.Group>
</div>
</div>
);
}
function EvaluationCadence(): JSX.Element {
const [
isEvaluationCadenceDetailsVisible,
setIsEvaluationCadenceDetailsVisible,
] = useState(false);
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
const showCustomScheduleButton = useMemo(
() =>
!isEvaluationCadenceDetailsVisible &&
advancedOptions.evaluationCadence.mode === 'default',
[isEvaluationCadenceDetailsVisible, advancedOptions.evaluationCadence.mode],
);
const showCustomSchedule = (): void => {
setIsEvaluationCadenceDetailsVisible(true);
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'custom',
});
};
return (
<div className="evaluation-cadence-container">
<div className="advanced-option-item evaluation-cadence-item">
<div className="advanced-option-item-left-content">
<Typography.Text className="advanced-option-item-title">
How often to check
<Tooltip title="Controls how frequently the alert evaluates your conditions. For most alerts, 1-5 minutes is sufficient.">
<Info size={16} />
</Tooltip>
</Typography.Text>
<Typography.Text className="advanced-option-item-description">
How frequently this alert checks your data. Default: Every 1 minute
</Typography.Text>
</div>
{showCustomScheduleButton && (
<div className="advanced-option-item-right-content">
<Input.Group className="advanced-option-item-input-group">
<Input
type="number"
placeholder="Enter time"
style={{ width: 180 }}
value={advancedOptions.evaluationCadence.default.value}
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
default: {
...advancedOptions.evaluationCadence.default,
value: Number(value.target.value),
},
},
})
}
/>
<Select
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
placeholder="Select time unit"
style={{ width: 120 }}
value={advancedOptions.evaluationCadence.default.timeUnit}
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
default: {
...advancedOptions.evaluationCadence.default,
timeUnit: value,
},
},
})
}
/>
</Input.Group>
<Button
className="advanced-option-item-button"
onClick={showCustomSchedule}
>
<Plus size={12} />
<Typography.Text>Add custom schedule</Typography.Text>
</Button>
</div>
)}
</div>
{!isEvaluationCadenceDetailsVisible &&
advancedOptions.evaluationCadence.mode !== 'default' && (
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
setIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={setIsPreviewVisible}
/>
)}
{isEvaluationCadenceDetailsVisible && (
<EvaluationCadenceDetails
isOpen={isEvaluationCadenceDetailsVisible}
setIsOpen={setIsEvaluationCadenceDetailsVisible}
/>
)}
{isPreviewVisible && (
<EvaluationCadencePreview
isOpen={isPreviewVisible}
setIsOpen={setIsPreviewVisible}
/>
)}
</div>
);
}
export default EvaluationCadence;

View File

@@ -1,104 +0,0 @@
import { Button, Typography } from 'antd';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import { IEditCustomScheduleProps } from 'container/CreateAlertV2/EvaluationSettings/types';
import { Calendar1, Edit, Trash } from 'lucide-react';
import { useMemo } from 'react';
function EditCustomSchedule({
setIsEvaluationCadenceDetailsVisible,
setIsPreviewVisible,
}: IEditCustomScheduleProps): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const displayText = useMemo(() => {
if (advancedOptions.evaluationCadence.mode === 'custom') {
return (
<Typography.Text>
<Typography.Text>Every</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.repeatEvery
.charAt(0)
.toUpperCase() +
advancedOptions.evaluationCadence.custom.repeatEvery.slice(1)}
</Typography.Text>
{advancedOptions.evaluationCadence.custom.repeatEvery !== 'day' && (
<>
<Typography.Text>on</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.occurence
.map(
(occurence) => occurence.charAt(0).toUpperCase() + occurence.slice(1),
)
.join(', ')}
</Typography.Text>
</>
)}
<Typography.Text>at</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.startAt}
</Typography.Text>
</Typography.Text>
);
}
return (
<Typography.Text>
<Typography.Text>Starting on</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.rrule.date?.format('DD/MM/YYYY')}
</Typography.Text>
<Typography.Text>at</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.rrule.startAt}
</Typography.Text>
</Typography.Text>
);
}, [advancedOptions.evaluationCadence]);
const handleEdit = (): void => {
setIsEvaluationCadenceDetailsVisible(true);
};
const handlePreview = (): void => {
setIsPreviewVisible(true);
};
const handleDiscard = (): void => {
setIsEvaluationCadenceDetailsVisible(false);
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
});
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'default',
});
};
return (
<div className="edit-custom-schedule">
{displayText}
<div className="button-row">
<Button.Group>
<Button type="default" onClick={handleEdit}>
<Edit size={12} />
<Typography.Text>Edit custom schedule</Typography.Text>
</Button>
<Button type="default" onClick={handlePreview}>
<Calendar1 size={12} />
<Typography.Text>Preview</Typography.Text>
</Button>
<Button
data-testid="discard-button"
type="default"
onClick={handleDiscard}
>
<Trash size={12} />
</Button>
</Button.Group>
</div>
</div>
);
}
export default EditCustomSchedule;

View File

@@ -1,134 +0,0 @@
import './styles.scss';
import '../AdvancedOptionItem/styles.scss';
import { Button, Input, Select, Tooltip, Typography } from 'antd';
import { Info, Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCreateAlertState } from '../../context';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
import EditCustomSchedule from './EditCustomSchedule';
import EvaluationCadenceDetails from './EvaluationCadenceDetails';
import EvaluationCadencePreview from './EvaluationCadencePreview';
function EvaluationCadence(): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const [
isEvaluationCadenceDetailsVisible,
setIsEvaluationCadenceDetailsVisible,
] = useState(false);
const [
isCustomScheduleButtonVisible,
setIsCustomScheduleButtonVisible,
] = useState(true);
const [
isEvaluationCadencePreviewVisible,
setIsEvaluationCadencePreviewVisible,
] = useState(false);
const [isEditCustomScheduleVisible, setIsEditCustomScheduleVisible] = useState(
() => advancedOptions.evaluationCadence.mode !== 'default',
);
useEffect(() => {
setIsEditCustomScheduleVisible(
advancedOptions.evaluationCadence.mode !== 'default',
);
}, [advancedOptions.evaluationCadence.mode]);
const showCustomSchedule = (): void => {
setIsEvaluationCadenceDetailsVisible(true);
setIsCustomScheduleButtonVisible(false);
};
return (
<div className="evaluation-cadence-container">
<div className="advanced-option-item evaluation-cadence-item">
<div className="advanced-option-item-left-content">
<Typography.Text className="advanced-option-item-title">
How often to check
<Tooltip title="Controls how frequently the alert evaluates your conditions. For most alerts, 1-5 minutes is sufficient.">
<Info data-testid="evaluation-cadence-tooltip-icon" size={16} />
</Tooltip>
</Typography.Text>
<Typography.Text className="advanced-option-item-description">
How frequently this alert checks your data. Default: Every 1 minute
</Typography.Text>
</div>
{isCustomScheduleButtonVisible && (
<div
className="advanced-option-item-right-content"
data-testid="evaluation-cadence-input-group"
>
<Input.Group className="advanced-option-item-input-group">
<Input
type="number"
placeholder="Enter time"
style={{ width: 180 }}
value={advancedOptions.evaluationCadence.default.value}
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
default: {
...advancedOptions.evaluationCadence.default,
value: Number(value.target.value),
},
},
})
}
/>
<Select
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
placeholder="Select time unit"
style={{ width: 120 }}
value={advancedOptions.evaluationCadence.default.timeUnit}
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
default: {
...advancedOptions.evaluationCadence.default,
timeUnit: value,
},
},
})
}
/>
</Input.Group>
<Button
className="advanced-option-item-button"
onClick={showCustomSchedule}
>
<Plus size={12} />
<Typography.Text>Add custom schedule</Typography.Text>
</Button>
</div>
)}
</div>
{isEditCustomScheduleVisible && (
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={setIsEvaluationCadenceDetailsVisible}
setIsPreviewVisible={setIsEvaluationCadencePreviewVisible}
/>
)}
{isEvaluationCadenceDetailsVisible && (
<EvaluationCadenceDetails
isOpen={isEvaluationCadenceDetailsVisible}
setIsOpen={setIsEvaluationCadenceDetailsVisible}
setIsCustomScheduleButtonVisible={setIsCustomScheduleButtonVisible}
/>
)}
{isEvaluationCadencePreviewVisible && (
<EvaluationCadencePreview
isOpen={isEvaluationCadencePreviewVisible}
setIsOpen={setIsEvaluationCadencePreviewVisible}
/>
)}
</div>
);
}
export default EvaluationCadence;

View File

@@ -1,347 +0,0 @@
import { Button, DatePicker, Select, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import classNames from 'classnames';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import { AdvancedOptionsState } from 'container/CreateAlertV2/context/types';
import dayjs from 'dayjs';
import { Code, Edit3Icon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS,
EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS,
EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS,
TIMEZONE_DATA,
} from '../constants';
import TimeInput from '../TimeInput';
import { IEvaluationCadenceDetailsProps } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
isValidRRule,
} from '../utils';
import { ScheduleList } from './EvaluationCadencePreview';
function EvaluationCadenceDetails({
setIsOpen,
setIsCustomScheduleButtonVisible,
}: IEvaluationCadenceDetailsProps): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const [evaluationCadence, setEvaluationCadence] = useState<
AdvancedOptionsState['evaluationCadence']
>({
...advancedOptions.evaluationCadence,
mode: 'custom',
custom: {
...advancedOptions.evaluationCadence.custom,
startAt: dayjs().format('HH:mm:ss'),
},
rrule: {
...advancedOptions.evaluationCadence.rrule,
startAt: dayjs().format('HH:mm:ss'),
},
});
const [searchTimezoneString, setSearchTimezoneString] = useState('');
const [occurenceSearchString, setOccurenceSearchString] = useState('');
const [repeatEverySearchString, setRepeatEverySearchString] = useState('');
const tabs = [
{
label: 'Editor',
icon: <Edit3Icon size={14} />,
value: 'editor',
},
{
label: 'RRule',
icon: <Code size={14} />,
value: 'rrule',
},
];
const [activeTab, setActiveTab] = useState<'editor' | 'rrule'>(() =>
evaluationCadence.mode === 'custom' ? 'editor' : 'rrule',
);
const occurenceOptions =
evaluationCadence.custom.repeatEvery === 'week'
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS
: EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS;
useEffect(() => {
if (!evaluationCadence.custom.occurence.length) {
const today = new Date();
const dayOfWeek = today.getDay();
const dayOfMonth = today.getDate();
const occurence =
evaluationCadence.custom.repeatEvery === 'week'
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS[dayOfWeek].value
: dayOfMonth.toString();
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
occurence: [occurence],
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [evaluationCadence.custom.repeatEvery]);
const EditorView = (
<div className="editor-view" data-testid="editor-view">
<div className="select-group">
<Typography.Text>REPEAT EVERY</Typography.Text>
<Select
options={EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS}
value={evaluationCadence.custom.repeatEvery || null}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
repeatEvery: value,
occurence: [],
},
})
}
placeholder="Select repeat every"
showSearch
searchValue={repeatEverySearchString}
onSearch={setRepeatEverySearchString}
/>
</div>
{evaluationCadence.custom.repeatEvery !== 'day' && (
<div className="select-group">
<Typography.Text>ON DAY(S)</Typography.Text>
<Select
options={occurenceOptions}
value={evaluationCadence.custom.occurence || null}
mode="multiple"
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
occurence: value,
},
})
}
placeholder="Select day(s)"
showSearch
searchValue={occurenceSearchString}
onSearch={setOccurenceSearchString}
/>
</div>
)}
<div className="select-group">
<Typography.Text>AT</Typography.Text>
<TimeInput
value={evaluationCadence.custom.startAt}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
startAt: value,
},
})
}
/>
</div>
<div className="select-group">
<Typography.Text>TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationCadence.custom.timezone || null}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
timezone: value,
},
})
}
placeholder="Select timezone"
onSearch={setSearchTimezoneString}
searchValue={searchTimezoneString}
showSearch
/>
</div>
</div>
);
const RRuleView = (
<div className="rrule-view" data-testid="rrule-view">
<div className="select-group">
<Typography.Text>STARTING ON</Typography.Text>
<DatePicker
value={evaluationCadence.rrule.date}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
date: value,
},
})
}
placeholder="Select date"
/>
</div>
<div className="select-group">
<Typography.Text>AT</Typography.Text>
<TimeInput
value={evaluationCadence.rrule.startAt}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
startAt: value,
},
})
}
/>
</div>
<TextArea
value={evaluationCadence.rrule.rrule}
placeholder="Enter RRule"
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
rrule: value.target.value,
},
})
}
/>
</div>
);
const handleDiscard = (): void => {
setIsOpen(false);
setIsCustomScheduleButtonVisible(true);
};
const handleSaveCustomSchedule = (): void => {
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
custom: evaluationCadence.custom,
rrule: evaluationCadence.rrule,
},
});
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: evaluationCadence.mode,
});
setIsOpen(false);
};
const disableSaveButton = useMemo(() => {
if (activeTab === 'editor') {
if (evaluationCadence.custom.repeatEvery === 'day') {
return (
!evaluationCadence.custom.repeatEvery ||
!evaluationCadence.custom.startAt ||
!evaluationCadence.custom.timezone
);
}
return (
!evaluationCadence.custom.repeatEvery ||
!evaluationCadence.custom.occurence.length ||
!evaluationCadence.custom.startAt ||
!evaluationCadence.custom.timezone
);
}
return (
!evaluationCadence.rrule.rrule ||
!evaluationCadence.rrule.date ||
!evaluationCadence.rrule.startAt ||
!isValidRRule(evaluationCadence.rrule.rrule)
);
}, [evaluationCadence, activeTab]);
const schedule = useMemo(() => {
if (activeTab === 'rrule') {
return buildAlertScheduleFromRRule(
evaluationCadence.rrule.rrule,
evaluationCadence.rrule.date,
evaluationCadence.rrule.startAt,
15,
);
}
return buildAlertScheduleFromCustomSchedule(
evaluationCadence.custom.repeatEvery,
evaluationCadence.custom.occurence,
evaluationCadence.custom.startAt,
15,
);
}, [evaluationCadence, activeTab]);
const handleChangeTab = (tab: 'editor' | 'rrule'): void => {
setActiveTab(tab);
const mode = tab === 'editor' ? 'custom' : 'rrule';
setEvaluationCadence({
...evaluationCadence,
mode,
});
};
return (
<div className="evaluation-cadence-details">
<Typography.Text className="evaluation-cadence-details-title">
Add Custom Schedule
</Typography.Text>
<div className="evaluation-cadence-details-content">
<div className="evaluation-cadence-details-content-row">
<div className="query-section-tabs">
<div className="query-section-query-actions">
{tabs.map((tab) => (
<Button
key={tab.value}
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': activeTab === tab.value,
})}
onClick={(): void => {
handleChangeTab(tab.value as 'editor' | 'rrule');
}}
>
{tab.icon}
{tab.label}
</Button>
))}
</div>
</div>
{activeTab === 'editor' && EditorView}
{activeTab === 'rrule' && RRuleView}
<div className="buttons-row">
<Button type="default" onClick={handleDiscard}>
Discard
</Button>
<Button
type="primary"
onClick={handleSaveCustomSchedule}
disabled={disableSaveButton}
>
Save Custom Schedule
</Button>
</div>
</div>
<div className="evaluation-cadence-details-content-row">
<ScheduleList
schedule={schedule}
currentTimezone={evaluationCadence.custom.timezone}
/>
</div>
</div>
</div>
);
}
export default EvaluationCadenceDetails;

View File

@@ -1,118 +0,0 @@
import { Modal, Typography } from 'antd';
import { Calendar, Info } from 'lucide-react';
import { useMemo } from 'react';
import { useCreateAlertState } from '../../context';
import { TIMEZONE_DATA } from '../constants';
import { IEvaluationCadencePreviewProps, IScheduleListProps } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
} from '../utils';
export function ScheduleList({
schedule,
currentTimezone,
}: IScheduleListProps): JSX.Element {
if (schedule && schedule.length > 0) {
return (
<div className="schedule-preview" data-testid="schedule-preview">
<div className="schedule-preview-header">
<Calendar size={16} />
<Typography.Text className="schedule-preview-title">
Schedule Preview
</Typography.Text>
</div>
<div className="schedule-preview-list">
{schedule.map((date) => (
<div key={date.toISOString()} className="schedule-preview-item">
<div className="schedule-preview-timeline">
<div className="schedule-preview-timeline-line" />
</div>
<div className="schedule-preview-content">
<div className="schedule-preview-date">
{date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
,{' '}
{date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
<div className="schedule-preview-separator" />
<div className="schedule-preview-timezone">
{
TIMEZONE_DATA.find((timezone) => timezone.value === currentTimezone)
?.label
}
</div>
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="no-schedule" data-testid="no-schedule">
<Info size={32} />
<Typography.Text>
Please fill the relevant information to generate a schedule
</Typography.Text>
</div>
);
}
function EvaluationCadencePreview({
isOpen,
setIsOpen,
}: IEvaluationCadencePreviewProps): JSX.Element {
const { advancedOptions } = useCreateAlertState();
const schedule = useMemo(() => {
if (advancedOptions.evaluationCadence.mode === 'rrule') {
return buildAlertScheduleFromRRule(
advancedOptions.evaluationCadence.rrule.rrule,
advancedOptions.evaluationCadence.rrule.date,
advancedOptions.evaluationCadence.rrule.startAt,
15,
);
}
return buildAlertScheduleFromCustomSchedule(
advancedOptions.evaluationCadence.custom.repeatEvery,
advancedOptions.evaluationCadence.custom.occurence,
advancedOptions.evaluationCadence.custom.startAt,
15,
);
}, [advancedOptions.evaluationCadence]);
return (
<Modal
open={isOpen}
onCancel={(): void => setIsOpen(false)}
footer={null}
className="evaluation-cadence-preview-modal"
width={800}
centered
>
<div className="evaluation-cadence-details evaluation-cadence-preview">
<div className="evaluation-cadence-details-content">
<div className="evaluation-cadence-details-content-row">
<ScheduleList
schedule={schedule}
currentTimezone={advancedOptions.evaluationCadence.custom.timezone}
/>
</div>
</div>
</div>
</Modal>
);
}
export default EvaluationCadencePreview;

View File

@@ -1,5 +0,0 @@
import './styles.scss';
import EvaluationCadence from './EvaluationCadence';
export default EvaluationCadence;

View File

@@ -1,700 +0,0 @@
.evaluation-cadence-container {
border-bottom: 1px solid var(--bg-slate-500);
.evaluation-cadence-item {
border-bottom: none !important;
}
.edit-custom-schedule {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
.ant-typography {
color: var(--bg-vanilla-100);
font-size: 13px;
.highlight {
background-color: var(--bg-slate-500);
padding: 4px 8px;
border-radius: 4px;
color: var(--bg-vanilla-400);
font-weight: 500;
margin: 0 4px;
font-size: 14px;
}
}
.ant-btn-group {
.ant-btn {
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
.evaluation-cadence-details {
margin: 16px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid var(--bg-slate-500);
.evaluation-cadence-details-title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-weight: 500;
padding-left: 16px;
padding-top: 16px;
}
.query-section-tabs {
display: flex;
align-items: center;
.query-section-query-actions {
display: flex;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 0.5px solid var(--bg-slate-400);
border-bottom: 0.5px solid var(--bg-slate-400);
width: 120px;
height: 36px;
gap: 8px;
&.active-tab {
background-color: var(--bg-ink-500);
border-bottom: none;
&:hover {
background-color: var(--bg-ink-500) !important;
}
}
&:disabled {
background-color: var(--bg-ink-300);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--bg-vanilla-100);
}
}
}
}
.evaluation-cadence-details-content {
display: flex;
gap: 16px;
border-top: 1px solid var(--bg-slate-500);
padding: 16px;
.evaluation-cadence-details-content-row {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
height: 500px;
overflow-y: scroll;
padding-right: 16px;
.editor-view,
.rrule-view {
display: flex;
flex-direction: column;
gap: 16px;
textarea {
height: 200px;
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
color: var(--bg-vanilla-400) !important;
font-family: 'Space Mono';
font-size: 14px;
&::placeholder {
font-family: 'Space Mono';
color: var(--bg-vanilla-400) !important;
}
}
.select-group {
display: flex;
flex-direction: column;
gap: 4px;
.ant-typography {
color: var(--bg-vanilla-100);
font-size: 13px;
font-weight: 500;
}
.ant-select {
border: 1px solid var(--bg-slate-400);
.ant-select-selector {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
}
}
.ant-picker {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.ant-picker-input {
background-color: var(--bg-ink-300);
color: var(--bg-vanilla-100);
}
}
}
}
.buttons-row {
display: flex;
align-items: center;
gap: 16px;
margin-top: 16px;
}
.no-schedule {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
height: 100%;
color: var(--bg-vanilla-100);
font-size: 14px;
}
.schedule-preview {
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
min-height: 0;
.schedule-preview-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
background-color: var(--bg-ink-400);
position: sticky;
top: 0;
z-index: 1;
border-bottom: 1px solid var(--bg-slate-500);
.schedule-preview-title {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
}
}
.schedule-preview-list {
display: flex;
flex-direction: column;
gap: 0;
flex: 1;
overflow-y: auto;
padding-top: 8px;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
.schedule-preview-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
.schedule-preview-timeline {
display: flex;
flex-direction: column;
align-items: center;
min-width: 20px;
.schedule-preview-timeline-line {
width: 1px;
height: 20px;
background-color: var(--bg-slate-400);
}
}
.schedule-preview-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.schedule-preview-date {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 400;
white-space: nowrap;
}
.schedule-preview-separator {
flex: 1;
height: 1px;
border-top: 1px dashed var(--bg-slate-400);
}
.schedule-preview-timezone {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 400;
white-space: nowrap;
}
}
}
}
}
}
}
}
.ant-picker-date-panel {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}
.ant-picker-date-panel-layout {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}
.ant-picker-date-panel-header {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}
// Custom modal styles for preview
.evaluation-cadence-preview-modal {
.ant-modal-content {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-500);
border-radius: 8px;
}
.ant-modal-header {
background-color: var(--bg-ink-400);
border-bottom: 1px solid var(--bg-slate-500);
padding: 16px 20px;
.ant-modal-title {
color: var(--bg-vanilla-100);
font-size: 16px;
font-weight: 600;
}
}
.ant-modal-close {
color: var(--bg-vanilla-400);
top: 16px;
right: 20px;
&:hover {
color: var(--bg-vanilla-100);
}
}
.ant-modal-body {
padding: 0;
background-color: var(--bg-ink-400);
}
.evaluation-cadence-details {
border: none;
margin: 0;
.evaluation-cadence-details-content {
border-top: none;
padding: 0;
.evaluation-cadence-details-content-row {
height: auto;
max-height: 60vh;
overflow-y: auto;
padding: 12px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-400);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-300);
}
.schedule-preview {
.schedule-preview-header {
background-color: var(--bg-ink-400);
border-bottom: 1px solid var(--bg-slate-500);
padding: 12px 16px;
margin: -12px -12px 16px -12px;
.schedule-preview-title {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
}
}
.schedule-preview-list {
.schedule-preview-item {
padding: 12px 0;
border-bottom: 1px solid var(--bg-slate-500);
&:last-child {
border-bottom: none;
}
.schedule-preview-timeline {
.schedule-preview-timeline-line {
width: 2px;
height: 24px;
background-color: var(--bg-robin-500);
border-radius: 1px;
}
}
.schedule-preview-content {
.schedule-preview-date {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
}
.schedule-preview-timezone {
background-color: var(--bg-slate-500);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
}
}
}
}
.no-schedule {
min-height: 300px;
padding: 40px 12px;
svg {
color: var(--bg-slate-400);
}
}
}
}
}
}
// Light mode styles
.lightMode {
.evaluation-cadence-container {
border-bottom: 1px solid var(--bg-vanilla-300);
.edit-custom-schedule {
.ant-typography {
color: var(--bg-ink-400);
.highlight {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
.ant-btn-group {
.ant-btn {
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}
.evaluation-cadence-details {
border: 1px solid var(--bg-vanilla-300);
.evaluation-cadence-details-title {
color: var(--bg-ink-400);
}
.query-section-tabs {
.query-section-query-actions {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.explorer-view-option {
border-left: 0.5px solid var(--bg-vanilla-300);
border-bottom: 0.5px solid var(--bg-vanilla-300);
&.active-tab {
background-color: var(--bg-vanilla-100);
&:hover {
background-color: var(--bg-vanilla-100) !important;
}
}
&:disabled {
background-color: var(--bg-vanilla-300);
}
&:hover {
color: var(--bg-ink-400);
}
}
}
}
.evaluation-cadence-details-content {
border-top: 1px solid var(--bg-vanilla-300);
.evaluation-cadence-details-content-row {
.editor-view,
.rrule-view {
textarea {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400) !important;
&::placeholder {
color: var(--bg-ink-400) !important;
}
}
.select-group {
.ant-typography {
color: var(--bg-ink-400);
}
.ant-select {
border: 1px solid var(--bg-vanilla-300);
.ant-select-selector {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
.ant-picker {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
.ant-picker-input {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}
.no-schedule {
color: var(--bg-ink-400);
}
.schedule-preview {
.schedule-preview-header {
background-color: var(--bg-vanilla-200);
border-bottom: 1px solid var(--bg-vanilla-300);
.schedule-preview-title {
color: var(--bg-ink-300);
}
}
.schedule-preview-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-400);
}
.schedule-preview-item {
.schedule-preview-timeline {
.schedule-preview-timeline-line {
background-color: var(--bg-vanilla-300);
}
}
.schedule-preview-content {
.schedule-preview-date {
color: var(--bg-ink-300);
}
.schedule-preview-separator {
border-top: 1px dashed var(--bg-vanilla-300);
}
.schedule-preview-timezone {
color: var(--bg-ink-400);
}
}
}
}
}
}
}
}
.ant-picker-date-panel {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
}
.ant-picker-date-panel-layout {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
}
.ant-picker-date-panel-header {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
}
// Light mode styles for preview modal
.evaluation-cadence-preview-modal {
.ant-modal-content {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
}
.ant-modal-header {
background-color: var(--bg-vanilla-200);
border-bottom: 1px solid var(--bg-vanilla-300);
.ant-modal-title {
color: var(--bg-ink-400);
}
}
.ant-modal-close {
color: var(--bg-ink-400);
&:hover {
color: var(--bg-ink-300);
}
}
.ant-modal-body {
background-color: var(--bg-vanilla-200);
}
.evaluation-cadence-details {
.evaluation-cadence-details-content {
.evaluation-cadence-details-content-row {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-400);
}
.schedule-preview {
.schedule-preview-header {
background-color: var(--bg-vanilla-200);
border-bottom: 1px solid var(--bg-vanilla-300);
.schedule-preview-title {
color: var(--bg-ink-300);
}
}
.schedule-preview-list {
.schedule-preview-item {
border-bottom: 1px solid var(--bg-vanilla-300);
.schedule-preview-timeline {
.schedule-preview-timeline-line {
background-color: var(--bg-robin-500);
}
}
.schedule-preview-content {
.schedule-preview-date {
color: var(--bg-ink-300);
}
.schedule-preview-separator {
border-top: 1px dashed var(--bg-vanilla-300);
}
.schedule-preview-timezone {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}
}
.no-schedule {
color: var(--bg-ink-400);
svg {
color: var(--bg-vanilla-300);
}
}
}
}
}
}
}

View File

@@ -34,6 +34,8 @@ function EvaluationSettings(): JSX.Element {
<EvaluationWindowPopover
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
isOpen={isEvaluationWindowPopoverOpen}
setIsOpen={setIsEvaluationWindowPopoverOpen}
/>
}
trigger="click"
@@ -57,22 +59,14 @@ function EvaluationSettings(): JSX.Element {
</Popover>
);
// Layout consists of only the evaluation window popover
if (showCondensedLayoutFlag) {
return (
<div
className="condensed-evaluation-settings-container"
data-testid="condensed-evaluation-settings-container"
>
<div className="condensed-evaluation-settings-container">
{popoverContent}
</div>
);
}
// Layout consists of
// - Stepper header
// - Evaluation window popover
// - Advanced options
return (
<div className="evaluation-settings-container">
<Stepper stepNumber={3} label="Evaluation settings" />

View File

@@ -0,0 +1,389 @@
import { Button, Input, Select, Typography } from 'antd';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import { useMemo } from 'react';
import { RE_NOTIFICATION_UNIT_OPTIONS } from '../context/constants';
import {
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
} from './constants';
import TimeInput from './TimeInput';
import {
CumulativeWindowTimeframes,
IEvaluationWindowDetailsProps,
IEvaluationWindowPopoverProps,
RollingWindowTimeframes,
} from './types';
import { useKeyboardNavigation } from './useKeyboardNavigation';
import { TIMEZONE_DATA } from './utils';
function EvaluationWindowDetails({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowDetailsProps): JSX.Element {
const currentHourOptions = useMemo(() => {
const options = [];
for (let i = 0; i < 60; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const currentMonthOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 31; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const displayText = useMemo(() => {
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe === 'custom'
) {
return `Last ${evaluationWindow.startingAt.number} ${
RE_NOTIFICATION_UNIT_OPTIONS.find(
(option) => option.value === evaluationWindow.startingAt.unit,
)?.label
}${parseInt(evaluationWindow.startingAt.number, 10) > 1 ? 's' : ''}`;
}
if (evaluationWindow.windowType === 'cumulative') {
if (evaluationWindow.timeframe === 'currentHour') {
return `Current hour, starting at minute ${evaluationWindow.startingAt.number} (${evaluationWindow.startingAt.timezone})`;
}
if (evaluationWindow.timeframe === 'currentDay') {
return `Current day, starting from ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
}
if (evaluationWindow.timeframe === 'currentMonth') {
return `Current month, starting from day ${evaluationWindow.startingAt.number} at ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
}
}
return '';
}, [evaluationWindow]);
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe !== 'custom'
) {
return <div />;
}
const isCurrentHour =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentHour';
const isCurrentDay =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentDay';
const isCurrentMonth =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentMonth';
const handleNumberChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: value,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
unit: evaluationWindow.startingAt.unit,
},
});
};
const handleTimeChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: value,
timezone: evaluationWindow.startingAt.timezone,
unit: evaluationWindow.startingAt.unit,
},
});
};
const handleUnitChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
unit: value,
},
});
};
const handleTimezoneChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: value,
unit: evaluationWindow.startingAt.unit,
},
});
};
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<Typography.Text>
A Cumulative Window has a fixed starting point and expands over time.
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text>
<Select
options={currentHourOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
</div>
);
}
if (isCurrentDay) {
return (
<div className="evaluation-window-details">
<Typography.Text>
A Cumulative Window has a fixed starting point and expands over time.
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
if (isCurrentMonth) {
return (
<div className="evaluation-window-details">
<Typography.Text>
A Cumulative Window has a fixed starting point and expands over time.
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text>
<Select
options={currentMonthOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
return (
<div className="evaluation-window-details">
<Typography.Text>
A Rolling Window has a fixed size and shifts its starting point over time
based on when the rules are evaluated.
</Typography.Text>
<Typography.Text>Specify custom duration</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>VALUE</Typography.Text>
<Input
name="value"
type="number"
value={evaluationWindow.startingAt.number}
onChange={(e): void => handleNumberChange(e.target.value)}
placeholder="Enter value"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>UNIT</Typography.Text>
<Select
options={RE_NOTIFICATION_UNIT_OPTIONS}
value={evaluationWindow.startingAt.unit || null}
onChange={handleUnitChange}
placeholder="Select unit"
/>
</div>
</div>
);
}
function EvaluationWindowPopover({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowPopoverProps): JSX.Element {
const { containerRef, firstItemRef } = useKeyboardNavigation({
onSelect: (value: string, sectionId: string): void => {
if (sectionId === 'window-type') {
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
});
} else if (sectionId === 'timeframe') {
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
});
}
},
onEscape: (): void => {
const triggerElement = document.querySelector(
'[aria-haspopup="true"]',
) as HTMLElement;
triggerElement?.focus();
},
});
const renderEvaluationWindowContent = (
label: string,
contentOptions: Array<{ label: string; value: string }>,
currentValue: string,
onChange: (value: string) => void,
sectionId: string,
): JSX.Element => (
<div className="evaluation-window-content-item" data-section-id={sectionId}>
<Typography.Text className="evaluation-window-content-item-label">
{label}
</Typography.Text>
<div className="evaluation-window-content-list">
{contentOptions.map((option, index) => (
<div
className={classNames('evaluation-window-content-list-item', {
active: currentValue === option.value,
})}
key={option.value}
role="button"
tabIndex={0}
data-value={option.value}
data-section-id={sectionId}
onClick={(): void => onChange(option.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(option.value);
}
}}
ref={index === 0 ? firstItemRef : undefined}
>
<Typography.Text>{option.label}</Typography.Text>
{currentValue === option.value && <Check size={12} />}
</div>
))}
</div>
</div>
);
const renderSelectionContent = (): JSX.Element => {
if (evaluationWindow.windowType === 'rolling') {
if (evaluationWindow.timeframe === 'custom') {
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
}
return (
<div className="selection-content">
<Typography.Text>
A Rolling Window has a fixed size and shifts its starting point over time
based on when the rules are evaluated.
</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
if (
evaluationWindow.windowType === 'cumulative' &&
!evaluationWindow.timeframe
) {
return (
<div className="selection-content">
<Typography.Text>
A Cumulative Window has a fixed starting point and expands over time.
</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
};
return (
<div
className="evaluation-window-popover"
ref={containerRef}
role="menu"
aria-label="Evaluation window options"
>
<div className="evaluation-window-content">
{renderEvaluationWindowContent(
'EVALUATION WINDOW',
EVALUATION_WINDOW_TYPE,
evaluationWindow.windowType,
(value: string): void =>
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
}),
'window-type',
)}
{renderEvaluationWindowContent(
'TIMEFRAME',
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
evaluationWindow.timeframe,
(value: string): void =>
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
}),
'timeframe',
)}
{renderSelectionContent()}
</div>
</div>
);
}
export default EvaluationWindowPopover;

View File

@@ -1,221 +0,0 @@
import { Input, Select, Typography } from 'antd';
import { useMemo } from 'react';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
ROLLING_WINDOW_DESCRIPTION,
TIMEZONE_DATA,
} from '../constants';
import TimeInput from '../TimeInput';
import { IEvaluationWindowDetailsProps } from '../types';
import { getCumulativeWindowTimeframeText } from '../utils';
function EvaluationWindowDetails({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowDetailsProps): JSX.Element {
const currentHourOptions = useMemo(() => {
const options = [];
for (let i = 0; i < 60; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const currentMonthOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 31; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const displayText = useMemo(() => {
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe === 'custom'
) {
return `Last ${evaluationWindow.startingAt.number} ${
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
(option) => option.value === evaluationWindow.startingAt.unit,
)?.label
}`;
}
if (evaluationWindow.windowType === 'cumulative') {
return getCumulativeWindowTimeframeText(evaluationWindow);
}
return '';
}, [evaluationWindow]);
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe !== 'custom'
) {
return <div />;
}
const isCurrentHour =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentHour';
const isCurrentDay =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentDay';
const isCurrentMonth =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentMonth';
const handleNumberChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: value,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
unit: evaluationWindow.startingAt.unit,
},
});
};
const handleTimeChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: value,
timezone: evaluationWindow.startingAt.timezone,
unit: evaluationWindow.startingAt.unit,
},
});
};
const handleUnitChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
unit: value,
},
});
};
const handleTimezoneChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: value,
unit: evaluationWindow.startingAt.unit,
},
});
};
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text>
<Select
options={currentHourOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
</div>
);
}
if (isCurrentDay) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
if (isCurrentMonth) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text>
<Select
options={currentMonthOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
return (
<div className="evaluation-window-details">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>Specify custom duration</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>VALUE</Typography.Text>
<Input
name="value"
type="number"
value={evaluationWindow.startingAt.number}
onChange={(e): void => handleNumberChange(e.target.value)}
placeholder="Enter value"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>UNIT</Typography.Text>
<Select
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
value={evaluationWindow.startingAt.unit || null}
onChange={handleUnitChange}
placeholder="Select unit"
/>
</div>
</div>
);
}
export default EvaluationWindowDetails;

View File

@@ -1,161 +0,0 @@
import { Button, Typography } from 'antd';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
ROLLING_WINDOW_DESCRIPTION,
} from '../constants';
import {
CumulativeWindowTimeframes,
IEvaluationWindowPopoverProps,
RollingWindowTimeframes,
} from '../types';
import EvaluationWindowDetails from './EvaluationWindowDetails';
import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNavigation';
function EvaluationWindowPopover({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowPopoverProps): JSX.Element {
const {
containerRef,
firstItemRef,
} = useKeyboardNavigationForEvaluationWindowPopover({
onSelect: (value: string, sectionId: string): void => {
if (sectionId === 'window-type') {
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
});
} else if (sectionId === 'timeframe') {
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
});
}
},
onEscape: (): void => {
const triggerElement = document.querySelector(
'[aria-haspopup="true"]',
) as HTMLElement;
triggerElement?.focus();
},
});
const renderEvaluationWindowContent = (
label: string,
contentOptions: Array<{ label: string; value: string }>,
currentValue: string,
onChange: (value: string) => void,
sectionId: string,
): JSX.Element => (
<div className="evaluation-window-content-item" data-section-id={sectionId}>
<Typography.Text className="evaluation-window-content-item-label">
{label}
</Typography.Text>
<div className="evaluation-window-content-list">
{contentOptions.map((option, index) => (
<div
className={classNames('evaluation-window-content-list-item', {
active: currentValue === option.value,
})}
key={option.value}
role="button"
tabIndex={0}
data-value={option.value}
data-section-id={sectionId}
onClick={(): void => onChange(option.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(option.value);
}
}}
ref={index === 0 ? firstItemRef : undefined}
>
<Typography.Text>{option.label}</Typography.Text>
{currentValue === option.value && <Check size={12} />}
</div>
))}
</div>
</div>
);
const renderSelectionContent = (): JSX.Element => {
if (evaluationWindow.windowType === 'rolling') {
if (evaluationWindow.timeframe === 'custom') {
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
}
return (
<div className="selection-content">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
if (
evaluationWindow.windowType === 'cumulative' &&
!evaluationWindow.timeframe
) {
return (
<div className="selection-content">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
};
return (
<div
className="evaluation-window-popover"
ref={containerRef}
role="menu"
aria-label="Evaluation window options"
>
<div className="evaluation-window-content">
{renderEvaluationWindowContent(
'EVALUATION WINDOW',
EVALUATION_WINDOW_TYPE,
evaluationWindow.windowType,
(value: string): void =>
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
}),
'window-type',
)}
{renderEvaluationWindowContent(
'TIMEFRAME',
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
evaluationWindow.timeframe,
(value: string): void =>
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
}),
'timeframe',
)}
{renderSelectionContent()}
</div>
</div>
);
}
export default EvaluationWindowPopover;

View File

@@ -1,3 +0,0 @@
import EvaluationWindowPopover from './EvaluationWindowPopover';
export default EvaluationWindowPopover;

View File

@@ -53,12 +53,12 @@
.lightMode {
.time-input-container {
.time-input-field {
background-color: var(--bg-vanilla-300);
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-300);
color: var(--bg-ink-400);
}
&:hover {
@@ -72,8 +72,7 @@
&:disabled {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-300);
cursor: not-allowed;
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-vanilla-300);
@@ -82,7 +81,7 @@
}
.time-input-separator {
color: var(--bg-ink-300);
color: var(--bg-ink-400);
}
}
}

View File

@@ -32,11 +32,13 @@ function TimeInput({
}
}, [value]);
// Notify parent of changes (raw values during typing)
const notifyChange = (h: string, m: string, s: string): void => {
const rawValue = `${h}:${m}:${s}`;
onChange?.(rawValue);
};
// Notify parent of formatted changes (with padding)
const notifyFormattedChange = (h: string, m: string, s: string): void => {
const formattedValue = `${h.padStart(2, '0')}:${m.padStart(
2,
@@ -45,44 +47,61 @@ function TimeInput({
onChange?.(formattedValue);
};
// Handle hours change
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newHours = e.target.value.replace(/\D/g, '');
// Limit to 2 digits
if (newHours.length > 2) {
newHours = newHours.slice(0, 2);
}
// Validate hours (0-23)
if (newHours && parseInt(newHours, 10) > 23) {
newHours = '23';
}
setHours(newHours);
notifyChange(newHours, minutes, seconds);
};
// Handle minutes change
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newMinutes = e.target.value.replace(/\D/g, '');
// Limit to 2 digits
if (newMinutes.length > 2) {
newMinutes = newMinutes.slice(0, 2);
}
// Validate minutes (0-59)
if (newMinutes && parseInt(newMinutes, 10) > 59) {
newMinutes = '59';
}
setMinutes(newMinutes);
notifyChange(hours, newMinutes, seconds);
};
// Handle seconds change
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newSeconds = e.target.value.replace(/\D/g, '');
// Limit to 2 digits
if (newSeconds.length > 2) {
newSeconds = newSeconds.slice(0, 2);
}
// Validate seconds (0-59)
if (newSeconds && parseInt(newSeconds, 10) > 59) {
newSeconds = '59';
}
setSeconds(newSeconds);
notifyChange(hours, minutes, newSeconds);
};
// Handle blur events to format values
const handleHoursBlur = (): void => {
const formattedHours = hours.padStart(2, '0');
setHours(formattedHours);

View File

@@ -7,6 +7,7 @@ const TEST_INPUT_PLACEHOLDER = 'Test input';
const TEST_TITLE = 'Test Title';
const TEST_DESCRIPTION = 'Test Description';
const TEST_VALUE = 'test value';
const FIRST_INPUT_PLACEHOLDER = 'First input';
const TEST_INPUT_TEST_ID = 'test-input';
describe('AdvancedOptionItem', () => {
@@ -27,7 +28,7 @@ describe('AdvancedOptionItem', () => {
jest.clearAllMocks();
});
it('should render title, description and switch', () => {
it('should render title and description', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
@@ -38,6 +39,16 @@ describe('AdvancedOptionItem', () => {
expect(screen.getByText(TEST_TITLE)).toBeInTheDocument();
expect(screen.getByText(TEST_DESCRIPTION)).toBeInTheDocument();
});
it('should render switch component', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
expect(switchElement).toBeInTheDocument();
@@ -53,9 +64,7 @@ describe('AdvancedOptionItem', () => {
/>,
);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElement).toBeInTheDocument();
expect(inputElement).not.toBeVisible();
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
});
it('should show input when switch is toggled on', async () => {
@@ -68,17 +77,11 @@ describe('AdvancedOptionItem', () => {
/>,
);
const initialInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(initialInputElement).toBeInTheDocument();
expect(initialInputElement).not.toBeVisible();
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
expect(switchElement).toBeChecked();
const visibleInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(visibleInputElement).toBeInTheDocument();
expect(visibleInputElement).toBeVisible();
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
});
it('should hide input when switch is toggled off', async () => {
@@ -93,21 +96,80 @@ describe('AdvancedOptionItem', () => {
const switchElement = screen.getByRole('switch');
const initialInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(initialInputElement).toBeInTheDocument();
expect(initialInputElement).not.toBeVisible();
// First toggle on
await user.click(switchElement);
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
// Then toggle off
await user.click(switchElement);
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
});
it('should toggle switch state correctly', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
// Initial state
expect(switchElement).not.toBeChecked();
// After first click
await user.click(switchElement);
expect(switchElement).toBeChecked();
// After second click
await user.click(switchElement);
expect(switchElement).not.toBeChecked();
});
it('should render input with correct props when visible', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElement).toBeInTheDocument();
expect(inputElement).toBeVisible();
expect(inputElement).toHaveAttribute('placeholder', TEST_INPUT_PLACEHOLDER);
});
// Then toggle off - input should be hidden but still in DOM
it('should handle multiple toggle operations', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
// Toggle on
await user.click(switchElement);
const hiddenInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(hiddenInputElement).toBeInTheDocument();
expect(hiddenInputElement).not.toBeVisible();
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
// Toggle off
await user.click(switchElement);
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
// Toggle on again
await user.click(switchElement);
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
});
it('should maintain input state when toggling', async () => {
@@ -128,41 +190,59 @@ describe('AdvancedOptionItem', () => {
await user.type(inputElement, TEST_VALUE);
expect(inputElement).toHaveValue(TEST_VALUE);
// Toggle off - input should still be in DOM but hidden
// Toggle off
await user.click(switchElement);
const hiddenInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(hiddenInputElement).toBeInTheDocument();
expect(hiddenInputElement).not.toBeVisible();
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
// Toggle back on - input should maintain its previous state
// Toggle back on - input should be recreated (fresh state)
await user.click(switchElement);
const inputElementAgain = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElementAgain).toHaveValue(TEST_VALUE); // State preserved!
expect(inputElementAgain).toHaveValue(''); // Fresh input, no previous state
});
it('should not render tooltip icon if tooltipText is not provided', () => {
it('should render with different title and description', () => {
const customTitle = 'Custom Title';
const customDescription = 'Custom Description';
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
title={customTitle}
description={customDescription}
input={defaultProps.input}
/>,
);
const tooltipIcon = screen.queryByTestId('tooltip-icon');
expect(tooltipIcon).not.toBeInTheDocument();
expect(screen.getByText(customTitle)).toBeInTheDocument();
expect(screen.getByText(customDescription)).toBeInTheDocument();
});
it('should render tooltip icon if tooltipText is provided', () => {
it('should render with complex input component', async () => {
const user = userEvent.setup();
const complexInput = (
<div data-testid="complex-input">
<input placeholder={FIRST_INPUT_PLACEHOLDER} />
<select>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
</select>
</div>
);
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
tooltipText="mock tooltip text"
input={complexInput}
/>,
);
const tooltipIcon = screen.getByTestId('tooltip-icon');
expect(tooltipIcon).toBeInTheDocument();
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
expect(screen.getByTestId('complex-input')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(FIRST_INPUT_PLACEHOLDER),
).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});

View File

@@ -1,141 +1,193 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { CreateAlertProvider } from '../../context';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from '../../context/constants';
import AdvancedOptions from '../AdvancedOptions';
import { createMockAlertContextState } from './testUtils';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock dayjs timezone
jest.mock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = jest.fn((date) => originalDayjs(date));
Object.assign(mockDayjs, originalDayjs);
((mockDayjs as unknown) as { tz: { guess: jest.Mock } }).tz = {
guess: jest.fn(() => 'UTC'),
};
return mockDayjs;
});
// Mock Y_AXIS_CATEGORIES
jest.mock('components/YAxisUnitSelector/constants', () => ({
Y_AXIS_CATEGORIES: [
{
name: 'Time',
units: [
{ name: 'Second', id: 's' },
{ name: 'Minute', id: 'm' },
{ name: 'Hour', id: 'h' },
{ name: 'Day', id: 'd' },
],
},
],
}));
// Mock the context
const mockSetAdvancedOptions = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
jest.mock('../../context', () => ({
...jest.requireActual('../../context'),
useCreateAlertState: (): {
advancedOptions: typeof INITIAL_ADVANCED_OPTIONS_STATE;
setAdvancedOptions: jest.Mock;
evaluationWindow: typeof INITIAL_EVALUATION_WINDOW_STATE;
setEvaluationWindow: jest.Mock;
} => ({
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
setAdvancedOptions: mockSetAdvancedOptions,
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
setEvaluationWindow: jest.fn(),
}),
);
}));
const ALERT_WHEN_DATA_STOPS_COMING_TEXT = 'Alert when data stops coming';
const MINIMUM_DATA_REQUIRED_TEXT = 'Minimum data required';
const ACCOUNT_FOR_DATA_DELAY_TEXT = 'Account for data delay';
const ADVANCED_OPTION_ITEM_CLASS = '.advanced-option-item';
const SWITCH_ROLE_SELECTOR = '[role="switch"]';
// Mock EvaluationCadence component
jest.mock('../EvaluationCadence', () => ({
__esModule: true,
default: function MockEvaluationCadence(): JSX.Element {
return (
<div data-testid="evaluation-cadence">Evaluation Cadence Component</div>
);
},
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const TOLERANCE_LIMIT_PLACEHOLDER = 'Enter tolerance limit...';
const renderAdvancedOptions = (): void => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<CreateAlertProvider>
<AdvancedOptions />
</CreateAlertProvider>
</MemoryRouter>
</Provider>
</QueryClientProvider>,
);
};
describe('AdvancedOptions', () => {
it('should render evaluation cadence and the advanced options minimized by default', () => {
render(<AdvancedOptions />);
beforeEach(() => {
jest.clearAllMocks();
});
const expandAdvancedOptions = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
const collapseHeader = screen.getByRole('button');
await user.click(collapseHeader);
await waitFor(() => {
expect(screen.getByTestId('evaluation-cadence')).toBeInTheDocument();
});
};
it('should render and allow expansion of advanced options', async () => {
const user = userEvent.setup();
renderAdvancedOptions();
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
expect(screen.queryByText('How often to check')).not.toBeInTheDocument();
await expandAdvancedOptions(user);
expect(
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
screen.getByText('Send a notification if data is missing'),
).toBeInTheDocument();
expect(screen.getByText('Enforce minimum datapoints')).toBeInTheDocument();
expect(screen.getByText('Delay evaluation')).toBeInTheDocument();
});
it('should be able to expand the advanced options', () => {
render(<AdvancedOptions />);
it('should enable advanced option inputs when switches are toggled', async () => {
const user = userEvent.setup();
renderAdvancedOptions();
expect(
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
await expandAdvancedOptions(user);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const switches = screen.getAllByRole('switch');
expect(screen.getByText('How often to check')).toBeInTheDocument();
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
expect(screen.getByText('Account for data delay')).toBeInTheDocument();
// Toggle the first switch (send notification)
await user.click(switches[0]);
await waitFor(() => {
expect(
screen.getByPlaceholderText(TOLERANCE_LIMIT_PLACEHOLDER),
).toBeInTheDocument();
});
// Toggle the second switch (minimum datapoints)
await user.click(switches[1]);
await waitFor(() => {
expect(
screen.getByPlaceholderText('Enter minimum datapoints...'),
).toBeInTheDocument();
});
});
it('"Alert when data stops coming" works as expected', () => {
render(<AdvancedOptions />);
it('should update advanced options state when user interacts with inputs', async () => {
const user = userEvent.setup();
renderAdvancedOptions();
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
await expandAdvancedOptions(user);
const alertWhenDataStopsComingContainer = screen
.getByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const alertWhenDataStopsComingSwitch = alertWhenDataStopsComingContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
// Enable send notification option
const switches = screen.getAllByRole('switch');
await user.click(switches[0]);
fireEvent.click(alertWhenDataStopsComingSwitch);
const toleranceInput = screen.getByPlaceholderText(
'Enter tolerance limit...',
// Wait for tolerance input to appear and test interaction
const toleranceInput = await screen.findByPlaceholderText(
TOLERANCE_LIMIT_PLACEHOLDER,
);
fireEvent.change(toleranceInput, { target: { value: '10' } });
await user.clear(toleranceInput);
await user.type(toleranceInput, '10');
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit: 10,
timeUnit: 'min',
},
const timeUnitSelect = screen.getByRole('combobox');
await user.click(timeUnitSelect);
await waitFor(() => {
expect(screen.getByText('Minute')).toBeInTheDocument();
});
});
await user.click(screen.getByText('Minute'));
it('"Minimum data required" works as expected', () => {
render(<AdvancedOptions />);
// Verify that the state update function was called (testing behavior, not exact values)
expect(mockSetAdvancedOptions).toHaveBeenCalled();
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const minimumDataRequiredContainer = screen
.getByText(MINIMUM_DATA_REQUIRED_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const minimumDataRequiredSwitch = minimumDataRequiredContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
fireEvent.click(minimumDataRequiredSwitch);
const minimumDataRequiredInput = screen.getByPlaceholderText(
'Enter minimum datapoints...',
);
fireEvent.change(minimumDataRequiredInput, { target: { value: '10' } });
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
payload: {
minimumDatapoints: 10,
},
});
});
it('"Account for data delay" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const accountForDataDelayContainer = screen
.getByText(ACCOUNT_FOR_DATA_DELAY_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const accountForDataDelaySwitch = accountForDataDelayContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
fireEvent.click(accountForDataDelaySwitch);
const delayInput = screen.getByPlaceholderText('Enter delay...');
fireEvent.change(delayInput, { target: { value: '10' } });
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: 10,
timeUnit: 'min',
},
});
// Verify the function was called with the expected action types
const { calls } = mockSetAdvancedOptions.mock;
const actionTypes = calls.map((call) => call[0].type);
expect(actionTypes).toContain('SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING');
});
});

View File

@@ -1,155 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import { TIMEZONE_DATA } from '../constants';
import EditCustomSchedule from '../EvaluationCadence/EditCustomSchedule';
import { createMockAlertContextState } from './testUtils';
const mockSetAdvancedOptions = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setAdvancedOptions: mockSetAdvancedOptions,
}),
);
const mockSetIsEvaluationCadenceDetailsVisible = jest.fn();
const mockSetIsPreviewVisible = jest.fn();
const EDIT_CUSTOM_SCHEDULE_TEST_ID = '.edit-custom-schedule';
describe('EditCustomSchedule', () => {
it('should render the correct display text for custom mode with daily occurrence', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'day',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
// Use textContent to verify the complete text across multiple Typography components
const container = screen
.getByText('Every')
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
expect(container).toHaveTextContent('EveryDayat00:00:00');
});
it('should render the correct display text for custom mode with weekly occurrence', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'week',
startAt: '00:00:00',
occurence: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
const container = screen
.getByText('Every')
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
expect(container).toHaveTextContent(
'EveryWeekonMonday, Tuesday, Wednesday, Thursday, Fridayat00:00:00',
);
});
it('should render the correct display text for custom mode with monthly occurrence', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'month',
startAt: '00:00:00',
occurence: ['1'],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
const container = screen
.getByText('Every')
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
expect(container).toHaveTextContent('EveryMonthon1at00:00:00');
});
it('edit custom schedule action works correctly', () => {
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
fireEvent.click(screen.getByText('Edit custom schedule'));
expect(mockSetIsEvaluationCadenceDetailsVisible).toHaveBeenCalledWith(true);
expect(mockSetIsPreviewVisible).not.toHaveBeenCalled();
});
it('preview custom schedule action works correctly', () => {
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
fireEvent.click(screen.getByText('Preview'));
expect(mockSetIsPreviewVisible).toHaveBeenCalledWith(true);
expect(mockSetIsEvaluationCadenceDetailsVisible).not.toHaveBeenCalled();
});
});

View File

@@ -1,162 +1,210 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import { TIMEZONE_DATA } from '../constants';
import EvaluationCadence from '../EvaluationCadence';
import { createMockAlertContextState } from './testUtils';
import * as context from '../../context';
import EvaluationCadence, {
EvaluationCadenceDetails,
} from '../EvaluationCadence';
jest.mock('../EvaluationCadence/EditCustomSchedule', () => ({
__esModule: true,
default: ({
setIsPreviewVisible,
}: {
setIsPreviewVisible: (isPreviewVisible: boolean) => void;
}): JSX.Element => (
<div data-testid="edit-custom-schedule">
<div>EditCustomSchedule</div>
<button type="button" onClick={(): void => setIsPreviewVisible(true)}>
Preview
</button>
</div>
),
}));
jest.mock('../EvaluationCadence/EvaluationCadenceDetails', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="evaluation-cadence-details">EvaluationCadenceDetails</div>
),
}));
jest.mock('../EvaluationCadence/EvaluationCadencePreview', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="evaluation-cadence-preview">EvaluationCadencePreview</div>
),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const mockSetAdvancedOptions = jest.fn();
const EVALUATION_CADENCE_DETAILS_TEST_ID = 'evaluation-cadence-details';
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
setAdvancedOptions: mockSetAdvancedOptions,
} as any);
const EDIT_CUSTOM_SCHEDULE_TEXT = 'Edit custom schedule';
const PREVIEW_TEXT = 'Preview';
const EVALUATION_CADENCE_TEXT = 'Evaluation cadence';
const EVALUATION_CADENCE_DESCRIPTION_TEXT =
'Customize when this Alert Rule will run. By default, it runs every 60 seconds (1 minute).';
const ADD_CUSTOM_SCHEDULE_TEXT = 'Add custom schedule';
const EVALUATION_CADENCE_PREVIEW_TEST_ID = 'evaluation-cadence-preview';
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setAdvancedOptions: mockSetAdvancedOptions,
}),
);
const EVALUATION_CADENCE_INPUT_GROUP = 'evaluation-cadence-input-group';
const SAVE_CUSTOM_SCHEDULE_TEXT = 'Save Custom Schedule';
const DISCARD_TEXT = 'Discard';
describe('EvaluationCadence', () => {
it('should render the title, description, tooltip and input group with default values', () => {
it('should render evaluation cadence component in default mode', () => {
render(<EvaluationCadence />);
expect(screen.getByText('How often to check')).toBeInTheDocument();
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
expect(
screen.getByText(
'How frequently this alert checks your data. Default: Every 1 minute',
),
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
).toBeInTheDocument();
expect(
screen.getByTestId('evaluation-cadence-tooltip-icon'),
).toBeInTheDocument();
expect(
screen.getByTestId(EVALUATION_CADENCE_INPUT_GROUP),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter time')).toHaveValue(1);
expect(screen.getByText('Minutes')).toBeInTheDocument();
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
});
it('should hide the input group when add custom schedule button is clicked', () => {
render(<EvaluationCadence />);
expect(
screen.getByTestId(EVALUATION_CADENCE_INPUT_GROUP),
).toBeInTheDocument();
fireEvent.click(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT));
expect(
screen.queryByTestId(EVALUATION_CADENCE_INPUT_GROUP),
).not.toBeInTheDocument();
expect(
screen.getByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
).toBeInTheDocument();
});
it('should not show the edit custom schedule component in default mode', () => {
render(<EvaluationCadence />);
expect(screen.queryByTestId('edit-custom-schedule')).not.toBeInTheDocument();
});
it('should show the custom schedule text when the mode is custom with selected values', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'day',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
},
it('should render evaluation cadence component in custom mode', () => {
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
}),
);
render(<EvaluationCadence />);
expect(screen.getByTestId('edit-custom-schedule')).toBeInTheDocument();
});
it('should not show evaluation cadence details component in default mode', () => {
render(<EvaluationCadence />);
expect(
screen.queryByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
).not.toBeInTheDocument();
});
it('should show evaluation cadence details component when clicked on add custom schedule button', () => {
},
} as any);
render(<EvaluationCadence />);
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
expect(
screen.queryByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
).not.toBeInTheDocument();
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
fireEvent.click(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT));
expect(
screen.getByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
).toBeInTheDocument();
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
});
it('should not show evaluation cadence preview component in default mode', () => {
render(<EvaluationCadence />);
expect(
screen.queryByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
).not.toBeInTheDocument();
});
it('should show evaluation cadence preview component when clicked on preview button in custom mode', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
it('should render evaluation cadence component in rrule mode', () => {
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'rrule',
},
}),
);
},
} as any);
render(<EvaluationCadence />);
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
expect(
screen.queryByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Preview'));
expect(
screen.getByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
).toBeInTheDocument();
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
});
it('clicking on discard button should reset the evaluation cadence mode to default', async () => {
const user = userEvent.setup();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
},
setAdvancedOptions: mockSetAdvancedOptions,
} as any);
render(<EvaluationCadence />);
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
expect(
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
).toBeInTheDocument();
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
const discardButton = screen.getByTestId('discard-button');
await user.click(discardButton);
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'default',
});
});
it('clicking on preview button should open the preview modal', async () => {
const user = userEvent.setup();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
},
setAdvancedOptions: mockSetAdvancedOptions,
} as any);
render(<EvaluationCadence />);
expect(screen.queryByText(SAVE_CUSTOM_SCHEDULE_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(DISCARD_TEXT)).not.toBeInTheDocument();
const previewButton = screen.getByText(PREVIEW_TEXT);
await user.click(previewButton);
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
});
it('clicking on edit custom schedule button should open the edit custom schedule modal', async () => {
const user = userEvent.setup();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
},
setAdvancedOptions: mockSetAdvancedOptions,
} as any);
render(<EvaluationCadence />);
expect(screen.queryByText(SAVE_CUSTOM_SCHEDULE_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(DISCARD_TEXT)).not.toBeInTheDocument();
const editCustomScheduleButton = screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT);
await user.click(editCustomScheduleButton);
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
const mockSetIsOpen = jest.fn();
const RULE_VIEW_TEXT = 'RRule';
const EDITOR_VIEW_TEST_ID = 'editor-view';
const RULE_VIEW_TEST_ID = 'rrule-view';
describe('EvaluationCadenceDetails', () => {
it('should render evaluation cadence details component', () => {
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByText('Add Custom Schedule')).toBeInTheDocument();
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
it('should open the editor tab by default', () => {
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(RULE_VIEW_TEST_ID)).not.toBeInTheDocument();
});
it('should open the rrule tab when rrule tab is clicked', async () => {
const user = userEvent.setup();
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(RULE_VIEW_TEST_ID)).not.toBeInTheDocument();
const rruleTab = screen.getByText(RULE_VIEW_TEXT);
await user.click(rruleTab);
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
});
});

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