Compare commits

..

16 Commits

Author SHA1 Message Date
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
205 changed files with 5052 additions and 10508 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

@@ -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

@@ -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,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

@@ -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,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

@@ -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

@@ -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

@@ -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

@@ -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';
@@ -6,13 +7,16 @@ import { Activity, ChartLine } from 'lucide-react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AlertThreshold from './AlertThreshold';
import AnomalyThreshold from './AnomalyThreshold';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const showCondensedLayoutFlag = showCondensedLayout();
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
@@ -75,6 +79,11 @@ function AlertCondition(): JSX.Element {
</div>
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
{showCondensedLayoutFlag ? (
<div className="condensed-advanced-options-container">
<AdvancedOptions />
</div>
) : null}
</div>
);
}

View File

@@ -1,7 +1,9 @@
import './styles.scss';
import '../EvaluationSettings/styles.scss';
import { Button, Select, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Plus } from 'lucide-react';
import { useQuery } from 'react-query';
@@ -17,6 +19,8 @@ import {
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { showCondensedLayout } from '../utils';
import ThresholdItem from './ThresholdItem';
import { UpdateThreshold } from './types';
import {
@@ -37,6 +41,7 @@ function AlertThreshold(): JSX.Element {
>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const showCondensedLayoutFlag = showCondensedLayout();
const channels = data?.data || [];
const { currentQuery } = useQueryBuilder();
@@ -81,8 +86,18 @@ function AlertThreshold(): JSX.Element {
});
};
const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings />
) : (
<strong>Evaluation Window.</strong>
);
return (
<div className="alert-threshold-container">
<div
className={classNames('alert-threshold-container', {
'condensed-alert-threshold-container': showCondensedLayoutFlag,
})}
>
{/* Main condition sentence */}
<div className="alert-condition-sentences">
<div className="alert-condition-sentence">
@@ -128,7 +143,7 @@ function AlertThreshold(): JSX.Element {
options={THRESHOLD_MATCH_TYPE_OPTIONS}
/>
<Typography.Text className="sentence-text">
during the <strong>Evaluation Window.</strong>
during the {evaluationWindowContext}
</Typography.Text>
</div>
</div>

View File

@@ -7,11 +7,6 @@ import { Channels } from 'types/api/channels/getAll';
import ThresholdItem from '../ThresholdItem';
import { ThresholdItemProps } from '../types';
// Mock the enableRecoveryThreshold utility
jest.mock('../../utils', () => ({
enableRecoveryThreshold: jest.fn(() => true),
}));
const TEST_CONSTANTS = {
THRESHOLD_ID: 'test-threshold-1',
CRITICAL_LABEL: 'CRITICAL',

View File

@@ -84,6 +84,9 @@
color: var(--text-vanilla-400);
font-size: 14px;
line-height: 1.5;
display: flex;
align-items: center;
gap: 8px;
}
.ant-select {
@@ -275,3 +278,43 @@
}
}
}
.condensed-alert-threshold-container,
.condensed-anomaly-threshold-container {
width: 100%;
}
.condensed-advanced-options-container {
margin-top: 16px;
width: fit-parent;
}
.condensed-evaluation-settings-container {
.ant-btn {
display: flex;
align-items: center;
width: 240px;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
}
.evaluate-alert-conditions-button-right {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
font-weight: 500;
background-color: var(--bg-slate-400);
padding: 1px 4px;
}
}
}
}

View File

@@ -3,6 +3,7 @@ $top-nav-background-2: #101010;
.create-alert-v2-container {
background-color: var(--bg-ink-500);
padding-bottom: 50px;
}
.top-nav-container {

View File

@@ -7,7 +7,10 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import AlertCondition from './AlertCondition';
import { CreateAlertProvider } from './context';
import CreateAlertHeader from './CreateAlertHeader';
import EvaluationSettings from './EvaluationSettings';
import NotificationSettings from './NotificationSettings';
import QuerySection from './QuerySection';
import { showCondensedLayout } from './utils';
function CreateAlertV2({
initialQuery = initialQueriesMap.metrics,
@@ -16,14 +19,18 @@ function CreateAlertV2({
}): JSX.Element {
useShareBuilderUrl({ defaultValue: initialQuery });
const showCondensedLayoutFlag = showCondensedLayout();
return (
<div className="create-alert-v2-container">
<CreateAlertProvider>
<CreateAlertProvider>
<div className="create-alert-v2-container">
<CreateAlertHeader />
<QuerySection />
<AlertCondition />
</CreateAlertProvider>
</div>
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
<NotificationSettings />
</div>
</CreateAlertProvider>
);
}

View File

@@ -1,22 +1,16 @@
import './styles.scss';
import { Switch, Tooltip, Typography } from 'antd';
import { Info } from 'lucide-react';
import { Switch, Typography } from 'antd';
import { useState } from 'react';
import { IAdvancedOptionItemProps } from '../types';
import { IAdvancedOptionItemProps } from './types';
function AdvancedOptionItem({
title,
description,
input,
tooltipText,
onToggle,
}: IAdvancedOptionItemProps): JSX.Element {
const [showInput, setShowInput] = useState<boolean>(false);
const handleOnToggle = (): void => {
onToggle?.();
const onToggle = (): void => {
setShowInput((currentShowInput) => !currentShowInput);
};
@@ -25,24 +19,14 @@ function AdvancedOptionItem({
<div className="advanced-option-item-left-content">
<Typography.Text className="advanced-option-item-title">
{title}
{tooltipText && (
<Tooltip title={tooltipText}>
<Info data-testid="tooltip-icon" size={16} />
</Tooltip>
)}
</Typography.Text>
<Typography.Text className="advanced-option-item-description">
{description}
</Typography.Text>
{showInput && <div className="advanced-option-item-input">{input}</div>}
</div>
<div className="advanced-option-item-right-content">
<div
className="advanced-option-item-input"
style={{ display: showInput ? 'block' : 'none' }}
>
{input}
</div>
<Switch onChange={handleOnToggle} />
<Switch onChange={onToggle} />
</div>
</div>
);

View File

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

View File

@@ -1,250 +0,0 @@
.advanced-option-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid var(--bg-slate-500);
.advanced-option-item-left-content {
display: flex;
flex-direction: column;
gap: 6px;
.advanced-option-item-title {
color: var(--bg-vanilla-300);
font-family: Inter;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.advanced-option-item-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.advanced-option-item-input {
margin-top: 16px;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&::placeholder {
font-family: 'Space Mono';
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
}
.advanced-option-item-right-content {
display: flex;
align-items: flex-start;
gap: 16px;
.advanced-option-item-input-group {
display: flex;
align-items: center;
gap: 8px;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
color: var(--bg-vanilla-100);
height: 32px;
border: 1px solid var(--bg-slate-400);
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
.advanced-option-item-button {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-ink-200);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
border-radius: 4px;
}
}
}
.lightMode {
.advanced-option-item {
border-bottom: 1px solid var(--bg-vanilla-300);
.advanced-option-item-left-content {
.advanced-option-item-title {
color: var(--bg-ink-300);
}
.advanced-option-item-description {
color: var(--bg-ink-400);
}
.advanced-option-item-input {
.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);
}
}
}
}
.advanced-option-item-right-content {
.advanced-option-item-input-group {
.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);
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
&: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);
}
}
}
.advanced-option-item-button {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}

View File

@@ -0,0 +1,123 @@
import { Collapse, Input, Select } from 'antd';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { useCreateAlertState } from '../context';
import AdvancedOptionItem from './AdvancedOptionItem';
import EvaluationCadence from './EvaluationCadence';
function AdvancedOptions(): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const timeOptions = Y_AXIS_CATEGORIES.find(
(category) => category.name === 'Time',
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
return (
<div className="advanced-options-container">
<Collapse bordered={false}>
<Collapse.Panel header="ADVANCED OPTIONS" key="1">
<EvaluationCadence />
<AdvancedOptionItem
title="Send a notification if data is missing"
description="If data is missing for this alert rule for a certain time period, notify in the default notification channel."
input={
<Input.Group>
<Input
placeholder="Enter tolerance limit..."
type="number"
style={{ width: 240 }}
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit: Number(e.target.value),
timeUnit: advancedOptions.sendNotificationIfDataIsMissing.timeUnit,
},
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
/>
<Select
style={{ width: 120 }}
options={timeOptions}
placeholder="Select time unit"
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit:
advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
timeUnit: value as string,
},
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
/>
</Input.Group>
}
/>
<AdvancedOptionItem
title="Enforce minimum datapoints"
description="Run alert evaluation only when there are minimum of pre-defined number of data points in each result group"
input={
<Input
placeholder="Enter minimum datapoints..."
style={{ width: 360 }}
type="number"
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
payload: {
minimumDatapoints: Number(e.target.value),
},
})
}
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
/>
}
/>
<AdvancedOptionItem
title="Delay evaluation"
description="Delay the evaluation of newer groups to prevent noisy alerts."
input={
<Input.Group>
<Input
placeholder="Enter delay..."
style={{ width: 240 }}
type="number"
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: Number(e.target.value),
timeUnit: advancedOptions.delayEvaluation.timeUnit,
},
})
}
value={advancedOptions.delayEvaluation.delay}
/>
<Select
style={{ width: 120 }}
options={timeOptions}
placeholder="Select time unit"
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: advancedOptions.delayEvaluation.delay,
timeUnit: value as string,
},
})
}
value={advancedOptions.delayEvaluation.timeUnit}
/>
</Input.Group>
}
/>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default AdvancedOptions;

View File

@@ -0,0 +1,543 @@
import { Button, DatePicker, Input, Select, 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 } 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 EditCustomSchedule({
setIsEvaluationCadenceDetailsVisible,
}: {
setIsEvaluationCadenceDetailsVisible: (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 handlePreviewAndEdit = (): void => {
setIsEvaluationCadenceDetailsVisible(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={handlePreviewAndEdit}>
<Edit size={12} />
<Typography.Text>Edit custom schedule</Typography.Text>
</Button>
<Button type="default" onClick={handlePreviewAndEdit}>
<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 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">
Evaluation cadence
</Typography.Text>
<Typography.Text className="advanced-option-item-description">
Customize when this Alert Rule will run. By default, it runs every 60
seconds (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
}
/>
)}
{isEvaluationCadenceDetailsVisible && (
<EvaluationCadenceDetails
isOpen={isEvaluationCadenceDetailsVisible}
setIsOpen={setIsEvaluationCadenceDetailsVisible}
/>
)}
</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

@@ -0,0 +1,85 @@
import './styles.scss';
import { Button, Popover, Typography } from 'antd';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AdvancedOptions from './AdvancedOptions';
import EvaluationWindowPopover from './EvaluationWindowPopover';
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
function EvaluationSettings(): JSX.Element {
const {
alertType,
evaluationWindow,
setEvaluationWindow,
} = useCreateAlertState();
const [
isEvaluationWindowPopoverOpen,
setIsEvaluationWindowPopoverOpen,
] = useState(false);
const showCondensedLayoutFlag = showCondensedLayout();
const popoverContent = (
<Popover
open={isEvaluationWindowPopoverOpen}
onOpenChange={(visibility: boolean): void => {
setIsEvaluationWindowPopoverOpen(visibility);
}}
content={
<EvaluationWindowPopover
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
isOpen={isEvaluationWindowPopoverOpen}
setIsOpen={setIsEvaluationWindowPopoverOpen}
/>
}
trigger="click"
showArrow={false}
>
<Button>
<div className="evaluate-alert-conditions-button-left">
{getTimeframeText(evaluationWindow.windowType, evaluationWindow.timeframe)}
</div>
<div className="evaluate-alert-conditions-button-right">
<div className="evaluate-alert-conditions-button-right-text">
{getEvaluationWindowTypeText(evaluationWindow.windowType)}
</div>
{isEvaluationWindowPopoverOpen ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</div>
</Button>
</Popover>
);
if (showCondensedLayoutFlag) {
return (
<div className="condensed-evaluation-settings-container">
{popoverContent}
</div>
);
}
return (
<div className="evaluation-settings-container">
<Stepper stepNumber={3} label="Evaluation settings" />
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
<div className="evaluate-alert-conditions-container">
<Typography.Text>Evaluate Alert Conditions over</Typography.Text>
<div className="evaluate-alert-conditions-separator" />
{popoverContent}
</div>
)}
<AdvancedOptions />
</div>
);
}
export default EvaluationSettings;

View File

@@ -0,0 +1,258 @@
/* eslint-disable jsx-a11y/interactive-supports-focus */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Button, Select, Typography } from 'antd';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import { useMemo } from 'react';
import {
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
} from './constants';
import TimeInput from './TimeInput';
import {
CumulativeWindowTimeframes,
IEvaluationWindowDetailsProps,
IEvaluationWindowPopoverProps,
RollingWindowTimeframes,
} from './types';
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;
}, []);
if (evaluationWindow.windowType === 'rolling') {
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,
},
});
};
const handleTimeChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: value,
timezone: evaluationWindow.startingAt.timezone,
},
});
};
const handleTimezoneChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: value,
},
});
};
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<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">
<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">
<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 />;
}
function EvaluationWindowPopover({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowPopoverProps): JSX.Element {
const renderEvaluationWindowContent = (
label: string,
contentOptions: Array<{ label: string; value: string }>,
currentValue: string,
onChange: (value: string) => void,
): JSX.Element => (
<div className="evaluation-window-content-item">
<Typography.Text className="evaluation-window-content-item-label">
{label}
</Typography.Text>
<div className="evaluation-window-content-list">
{contentOptions.map((option) => (
<div
className={classNames('evaluation-window-content-list-item', {
active: currentValue === option.value,
})}
key={option.value}
role="button"
onClick={(): void => onChange(option.value)}
>
<Typography.Text>{option.label}</Typography.Text>
{currentValue === option.value && <Check size={12} />}
</div>
))}
</div>
</div>
);
const renderSelectionContent = (): JSX.Element => {
if (evaluationWindow.windowType === 'rolling') {
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">
<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',
}),
)}
{renderEvaluationWindowContent(
'TIMEFRAME',
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
evaluationWindow.timeframe,
(value: string): void =>
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
}),
)}
{renderSelectionContent()}
</div>
</div>
);
}
export default EvaluationWindowPopover;

View File

@@ -25,80 +25,46 @@ function TimeInput({
if (value) {
const timeParts = value.split(':');
if (timeParts.length === 3) {
setHours(timeParts[0]);
setMinutes(timeParts[1]);
setSeconds(timeParts[2]);
setHours(timeParts[0].padStart(2, '0'));
setMinutes(timeParts[1].padStart(2, '0'));
setSeconds(timeParts[2].padStart(2, '0'));
}
}
}, [value]);
const notifyChange = (h: string, m: string, s: string): void => {
const rawValue = `${h}:${m}:${s}`;
onChange?.(rawValue);
};
// Format time value
const formatTimeValue = (h: string, m: string, s: string): string =>
`${h.padStart(2, '0')}:${m.padStart(2, '0')}:${s.padStart(2, '0')}`;
const notifyFormattedChange = (h: string, m: string, s: string): void => {
const formattedValue = `${h.padStart(2, '0')}:${m.padStart(
2,
'0',
)}:${s.padStart(2, '0')}`;
// Handle input change
const handleTimeChange = (
newHours: string,
newMinutes: string,
newSeconds: string,
): void => {
const formattedValue = formatTimeValue(newHours, newMinutes, newSeconds);
onChange?.(formattedValue);
};
// Handle hours change
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newHours = e.target.value.replace(/\D/g, '');
if (newHours.length > 2) {
newHours = newHours.slice(0, 2);
}
if (newHours && parseInt(newHours, 10) > 23) {
newHours = '23';
}
const newHours = e.target.value.replace(/\D/g, '').slice(0, 2);
setHours(newHours);
notifyChange(newHours, minutes, seconds);
handleTimeChange(newHours, minutes, seconds);
};
// Handle minutes change
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newMinutes = e.target.value.replace(/\D/g, '');
if (newMinutes.length > 2) {
newMinutes = newMinutes.slice(0, 2);
}
if (newMinutes && parseInt(newMinutes, 10) > 59) {
newMinutes = '59';
}
const newMinutes = e.target.value.replace(/\D/g, '').slice(0, 2);
setMinutes(newMinutes);
notifyChange(hours, newMinutes, seconds);
handleTimeChange(hours, newMinutes, seconds);
};
// Handle seconds change
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newSeconds = e.target.value.replace(/\D/g, '');
if (newSeconds.length > 2) {
newSeconds = newSeconds.slice(0, 2);
}
if (newSeconds && parseInt(newSeconds, 10) > 59) {
newSeconds = '59';
}
const newSeconds = e.target.value.replace(/\D/g, '').slice(0, 2);
setSeconds(newSeconds);
notifyChange(hours, minutes, newSeconds);
};
const handleHoursBlur = (): void => {
const formattedHours = hours.padStart(2, '0');
setHours(formattedHours);
notifyFormattedChange(formattedHours, minutes, seconds);
};
const handleMinutesBlur = (): void => {
const formattedMinutes = minutes.padStart(2, '0');
setMinutes(formattedMinutes);
notifyFormattedChange(hours, formattedMinutes, seconds);
};
const handleSecondsBlur = (): void => {
const formattedSeconds = seconds.padStart(2, '0');
setSeconds(formattedSeconds);
notifyFormattedChange(hours, minutes, formattedSeconds);
handleTimeChange(hours, minutes, newSeconds);
};
// Helper functions for field navigation
@@ -150,36 +116,30 @@ function TimeInput({
data-field="hours"
value={hours}
onChange={handleHoursChange}
onBlur={handleHoursBlur}
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
<span className="time-input-separator">:</span>
<Input
data-field="minutes"
value={minutes}
onChange={handleMinutesChange}
onBlur={handleMinutesBlur}
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
<span className="time-input-separator">:</span>
<Input
data-field="seconds"
value={seconds}
onChange={handleSecondsChange}
onBlur={handleSecondsBlur}
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
</div>
);

View File

@@ -1,12 +1,13 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AdvancedOptionItem from '../AdvancedOptionItem/AdvancedOptionItem';
import AdvancedOptionItem from '../AdvancedOptionItem';
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

@@ -0,0 +1,193 @@
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';
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.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(),
}),
}));
// 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', () => {
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();
await expandAdvancedOptions(user);
expect(
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 enable advanced option inputs when switches are toggled', async () => {
const user = userEvent.setup();
renderAdvancedOptions();
await expandAdvancedOptions(user);
const switches = screen.getAllByRole('switch');
// 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('should update advanced options state when user interacts with inputs', async () => {
const user = userEvent.setup();
renderAdvancedOptions();
await expandAdvancedOptions(user);
// Enable send notification option
const switches = screen.getAllByRole('switch');
await user.click(switches[0]);
// Wait for tolerance input to appear and test interaction
const toleranceInput = await screen.findByPlaceholderText(
TOLERANCE_LIMIT_PLACEHOLDER,
);
await user.clear(toleranceInput);
await user.type(toleranceInput, '10');
const timeUnitSelect = screen.getByRole('combobox');
await user.click(timeUnitSelect);
await waitFor(() => {
expect(screen.getByText('Minute')).toBeInTheDocument();
});
await user.click(screen.getByText('Minute'));
// Verify that the state update function was called (testing behavior, not exact values)
expect(mockSetAdvancedOptions).toHaveBeenCalled();
// 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();
});
});

View File

@@ -1,316 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
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 { AdvancedOptionsState } from 'container/CreateAlertV2/context/types';
import EvaluationCadenceDetails from '../EvaluationCadence/EvaluationCadenceDetails';
import { createMockAlertContextState } from './testUtils';
const ENTER_RRULE_PLACEHOLDER = 'Enter RRule';
jest.mock('dayjs', () => {
const actualDayjs = jest.requireActual('dayjs');
const mockDayjs = (date?: any): any => {
if (date) {
return actualDayjs(date);
}
// 21 Jan 2025
return actualDayjs('2025-01-21T16:31:36.982Z');
};
Object.keys(actualDayjs).forEach((key) => {
if (typeof (actualDayjs as any)[key] === 'function') {
(mockDayjs as any)[key] = (actualDayjs as any)[key];
}
});
(mockDayjs as any).tz = {
guess: (): string => 'Asia/Saigon',
};
return mockDayjs;
});
const INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
};
const mockSetAdvancedOptions = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
setAdvancedOptions: mockSetAdvancedOptions,
}),
);
const mockSetIsOpen = jest.fn();
const mockSetIsCustomScheduleButtonVisible = jest.fn();
const SCHEDULE_PREVIEW_TEST_ID = 'schedule-preview';
const NO_SCHEDULE_TEST_ID = 'no-schedule';
const EDITOR_VIEW_TEST_ID = 'editor-view';
const RULE_VIEW_TEST_ID = 'rrule-view';
const SAVE_CUSTOM_SCHEDULE_TEXT = 'Save Custom Schedule';
describe('EvaluationCadenceDetails', () => {
it('should render the evaluation cadence details component with editor mode in daily occurence by default', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
expect(screen.getByText('Add Custom Schedule')).toBeInTheDocument();
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId('rrule-view')).not.toBeInTheDocument();
expect(screen.getByText('REPEAT EVERY')).toBeInTheDocument();
expect(screen.getByText('AT')).toBeInTheDocument();
expect(screen.getByText('TIMEZONE')).toBeInTheDocument();
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText('Discard')).toBeInTheDocument();
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
});
it('when switching to rrule mode, the rrule view should be rendered with no schedule preview', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
fireEvent.click(screen.getByText('RRule'));
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(SCHEDULE_PREVIEW_TEST_ID),
).not.toBeInTheDocument();
expect(screen.getByTestId(NO_SCHEDULE_TEST_ID)).toBeInTheDocument();
expect(screen.getByText('STARTING ON')).toBeInTheDocument();
expect(screen.getByText('AT')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER),
).toBeInTheDocument();
expect(screen.getByText('Discard')).toBeInTheDocument();
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
});
it('when showing weekly occurence, the occurence options should be rendered', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
repeatEvery: 'week',
},
},
},
}),
);
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Verify that the "ON DAY(S)" section is rendered for weekly occurrence
expect(screen.getByText('ON DAY(S)')).toBeInTheDocument();
// Verify that the schedule preview is shown as today is selected by default
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
});
it('render schedule preview in weekly occurence when days are selected', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
repeatEvery: 'week',
occurence: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
},
},
},
}),
);
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Verify that the schedule preview is shown because days are selected
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
});
it('when showing monthly occurence, the occurence options should be rendered', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
repeatEvery: 'month',
},
},
},
}),
);
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Verify that the "ON DAY(S)" section is rendered for monthly occurrence
expect(screen.getByText('ON DAY(S)')).toBeInTheDocument();
// Verify that the schedule preview is shown as today is selected by default
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
});
it('render schedule preview in monthly occurence when days are selected', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
repeatEvery: 'month',
occurence: ['1'],
},
},
},
}),
);
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Verify that the schedule preview is shown because days are selected
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
});
it('discard action works correctly', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
fireEvent.click(screen.getByText('Discard'));
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
expect(mockSetIsCustomScheduleButtonVisible).toHaveBeenCalledWith(true);
});
it('save custom schedule action works correctly', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
fireEvent.click(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT));
expect(mockSetAdvancedOptions).toHaveBeenCalledTimes(2);
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_EVALUATION_CADENCE',
payload: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
// today selected by default
occurence: [new Date().getDate().toString()],
},
},
});
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'custom',
});
});
describe('alert context mock state verification', () => {
it('should set the evaluation cadence tab to rrule from custom', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Switch to RRule tab
fireEvent.click(screen.getByText('RRule'));
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
// Type in the text box
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue('');
fireEvent.change(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER), {
target: { value: 'RRULE:FREQ=DAILY' },
});
// Ensure text box content is updated
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue(
'RRULE:FREQ=DAILY',
);
});
it('ensure rrule content is not modified by previous test', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Switch to RRule tab
fireEvent.click(screen.getByText('RRule'));
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
// Verify text box content
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue('');
});
});
});

View File

@@ -1,88 +0,0 @@
import { 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 EvaluationCadencePreview, {
ScheduleList,
} from '../EvaluationCadence/EvaluationCadencePreview';
import { createMockAlertContextState } from './testUtils';
jest
.spyOn(alertState, 'useCreateAlertState')
.mockReturnValue(createMockAlertContextState());
const mockSetIsOpen = jest.fn();
describe('EvaluationCadencePreview', () => {
it('should render list of dates when schedule is generated', () => {
render(<EvaluationCadencePreview isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByTestId('schedule-preview')).toBeInTheDocument();
});
it('should render empty state when no schedule is generated', () => {
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: [],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(<EvaluationCadencePreview isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByTestId('no-schedule')).toBeInTheDocument();
});
});
describe('ScheduleList', () => {
const schedule = [
new Date('2024-01-15T00:00:00Z'),
new Date('2024-01-16T00:00:00Z'),
new Date('2024-01-17T00:00:00Z'),
new Date('2024-01-18T00:00:00Z'),
new Date('2024-01-19T00:00:00Z'),
];
it('should render list of dates when schedule is generated', () => {
render(
<ScheduleList
schedule={schedule}
currentTimezone={TIMEZONE_DATA[0].value}
/>,
);
expect(
screen.queryByText(
'Please fill the relevant information to generate a schedule',
),
).not.toBeInTheDocument();
// Verify all dates are rendered correctly
schedule.forEach((date) => {
const dateString = date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const timeString = date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const combinedString = `${dateString}, ${timeString}`;
expect(screen.getByText(combinedString)).toBeInTheDocument();
});
// Verify timezone is rendered correctly with each date
const timezoneElements = screen.getAllByText(TIMEZONE_DATA[0].label);
expect(timezoneElements).toHaveLength(schedule.length);
});
});

View File

@@ -0,0 +1,77 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import * as context from '../../context';
import { INITIAL_EVALUATION_WINDOW_STATE } from '../../context/constants';
import EvaluationSettings from '../EvaluationSettings';
const mockSetEvaluationWindow = jest.fn();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
setEvaluationWindow: mockSetEvaluationWindow,
} as any);
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock(
'../AdvancedOptions',
() =>
function MockAdvancedOptions(): JSX.Element {
return <div data-testid="advanced-options">Advanced Options</div>;
},
);
describe('EvaluationSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render evaluation settings container', () => {
render(<EvaluationSettings />);
expect(screen.getByText('Evaluation settings')).toBeInTheDocument();
});
it('should render evaluation alert conditions text', () => {
render(<EvaluationSettings />);
expect(
screen.getByText('Evaluate Alert Conditions over'),
).toBeInTheDocument();
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should display correct timeframe text for rolling window', () => {
render(<EvaluationSettings />);
expect(screen.getByText('Last 5 minutes')).toBeInTheDocument();
expect(screen.getByText('Rolling')).toBeInTheDocument();
});
it('should display correct timeframe text for cumulative window', () => {
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
evaluationWindow: {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentDay',
},
} as any);
render(<EvaluationSettings />);
expect(screen.getByText('Current day')).toBeInTheDocument();
expect(screen.getByText('Cumulative')).toBeInTheDocument();
});
});

View File

@@ -24,7 +24,7 @@ describe('TimeInput', () => {
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
});
it('should handle hours changes', () => {
it('should handle value changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
@@ -51,12 +51,11 @@ describe('TimeInput', () => {
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
});
it('should pad single digits with zeros on blur', () => {
it('should pad single digits with zeros', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '5' } });
fireEvent.blur(hoursInput);
expect(mockOnChange).toHaveBeenCalledWith('05:00:00');
});
@@ -119,6 +118,41 @@ describe('TimeInput', () => {
expect(minutesInput).toHaveFocus();
});
it('should wrap around navigation from seconds to hours', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const secondsInput = screen.getAllByDisplayValue('00')[2];
await user.click(secondsInput);
await user.keyboard('{ArrowRight}');
expect(hoursInput).toHaveFocus();
});
it('should wrap around navigation from hours to seconds', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const secondsInput = screen.getAllByDisplayValue('00')[2];
await user.click(hoursInput);
await user.keyboard('{ArrowLeft}');
expect(secondsInput).toHaveFocus();
});
it('should apply custom className', () => {
const { container } = render(<TimeInput className="custom-class" />);
expect(container.firstChild).toHaveClass(
'time-input-container',
'custom-class',
);
});
it('should disable inputs when disabled prop is true', () => {
render(<TimeInput disabled />);
@@ -142,100 +176,19 @@ describe('TimeInput', () => {
expect(screen.getByDisplayValue('06')).toBeInTheDocument();
});
it('should handle malformed time values gracefully', () => {
render(<TimeInput value="invalid:time:format" />);
// Should show the invalid values as they are
expect(screen.getByDisplayValue('invalid')).toBeInTheDocument();
expect(screen.getByDisplayValue('time')).toBeInTheDocument();
expect(screen.getByDisplayValue('format')).toBeInTheDocument();
});
it('should handle partial time values', () => {
render(<TimeInput value="12:34" />);
// Should fall back to default values for incomplete format
expect(screen.getAllByDisplayValue('00')).toHaveLength(3);
});
it('should cap hours at 23 when user enters value > 23', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '25' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should cap hours at 23 when user enters value = 24', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '24' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should allow hours value of 23', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '23' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should cap minutes at 59 when user enters value > 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '65' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should cap minutes at 59 when user enters value = 60', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '60' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should allow minutes value of 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '59' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should cap seconds at 59 when user enters value > 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '75' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
it('should cap seconds at 59 when user enters value = 60', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '60' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
it('should allow seconds value of 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '59' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
});

View File

@@ -1,24 +0,0 @@
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from 'container/CreateAlertV2/context/constants';
import { ICreateAlertContextProps } from 'container/CreateAlertV2/context/types';
import { AlertTypes } from 'types/api/alerts/alertTypes';
export const createMockAlertContextState = (
overrides?: Partial<ICreateAlertContextProps>,
): ICreateAlertContextProps => ({
alertState: INITIAL_ALERT_STATE,
setAlertState: jest.fn(),
alertType: AlertTypes.METRICS_BASED_ALERT,
setAlertType: jest.fn(),
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
setThresholdState: jest.fn(),
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
setAdvancedOptions: jest.fn(),
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
setEvaluationWindow: jest.fn(),
...overrides,
});

View File

@@ -1,13 +1,23 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable import/first */
import dayjs from 'dayjs';
import { rrulestr } from 'rrule';
// Mock dayjs before importing any other modules
const MOCK_DATE_STRING = '2024-01-15T00:30:00Z';
const MOCK_DATE_STRING_NON_LEAP_YEAR = '2023-01-15T00:30:00Z';
const MOCK_DATE_STRING_SPANS_MONTHS = '2024-01-31T00:30:00Z';
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
getCumulativeWindowTimeframeText,
getEvaluationWindowTypeText,
getRollingWindowTimeframeText,
getTimeframeText,
isValidRRule,
} from '../utils';
const MOCK_DATE_STRING = '2024-01-15T10:30:00Z';
const FREQ_DAILY = 'FREQ=DAILY';
const TEN_THIRTY_TIME = '10:30:00';
const NINE_AM_TIME = '09:00:00';
// Mock dayjs
jest.mock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = jest.fn((date?: string | Date) => {
@@ -22,49 +32,14 @@ jest.mock('dayjs', () => {
return mockDayjs;
});
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { EvaluationWindowState } from 'container/CreateAlertV2/context/types';
import dayjs, { Dayjs } from 'dayjs';
import { rrulestr } from 'rrule';
import { RollingWindowTimeframes } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
getCumulativeWindowTimeframeText,
getCustomRollingWindowTimeframeText,
getEvaluationWindowTypeText,
getRollingWindowTimeframeText,
getTimeframeText,
isValidRRule,
} from '../utils';
jest.mock('rrule', () => ({
rrulestr: jest.fn(),
}));
jest.mock('components/CustomTimePicker/timezoneUtils', () => ({
generateTimezoneData: jest.fn().mockReturnValue([
{ name: 'UTC', value: 'UTC', offset: '+00:00' },
{ name: 'America/New_York', value: 'America/New_York', offset: '-05:00' },
{ name: 'Europe/London', value: 'Europe/London', offset: '+00:00' },
]),
generateTimezoneData: jest.fn().mockReturnValue([]),
}));
const mockEvaluationWindowState: EvaluationWindowState = {
windowType: 'rolling',
timeframe: '5m0s',
startingAt: {
number: '0',
timezone: 'UTC',
time: '00:00:00',
unit: UniversalYAxisUnit.MINUTES,
},
};
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
describe('utils', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -79,52 +54,34 @@ describe('utils', () => {
expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative');
});
it('should default to empty string for unknown type', () => {
it('should default to Rolling for unknown type', () => {
expect(
getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'),
).toBe('');
).toBe('Rolling');
});
});
describe('getCumulativeWindowTimeframeText', () => {
it('should return correct text for current hour', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentHour',
}),
).toBe('Current hour, starting at minute 0 (UTC)');
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_HOUR),
).toBe('Current hour');
});
it('should return correct text for current day', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentDay',
}),
).toBe('Current day, starting from 00:00:00 (UTC)');
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_DAY),
).toBe('Current day');
});
it('should return correct text for current month', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentMonth',
}),
).toBe('Current month, starting from day 0 at 00:00:00 (UTC)');
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_MONTH),
).toBe('Current month');
});
it('should default to empty string for unknown timeframe', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'unknown',
}),
).toBe('');
it('should default to Current hour for unknown timeframe', () => {
expect(getCumulativeWindowTimeframeText('unknown')).toBe('Current hour');
});
});
@@ -174,45 +131,21 @@ describe('utils', () => {
it('should default to Last 5 minutes for unknown timeframe', () => {
expect(
getRollingWindowTimeframeText('unknown' as RollingWindowTimeframes),
).toBe('');
});
});
describe('getCustomRollingWindowTimeframeText', () => {
it('should return correct text for custom rolling window', () => {
expect(getCustomRollingWindowTimeframeText(mockEvaluationWindowState)).toBe(
'Last 0 Minutes',
);
).toBe('Last 5 minutes');
});
});
describe('getTimeframeText', () => {
it('should call getCustomRollingWindowTimeframeText for custom rolling window', () => {
it('should return rolling window text for rolling type', () => {
expect(
getTimeframeText({
...mockEvaluationWindowState,
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '4',
},
}),
).toBe('Last 4 Minutes');
getTimeframeText('rolling', RollingWindowTimeframes.LAST_1_HOUR),
).toBe('Last 1 hour');
});
it('should call getRollingWindowTimeframeText for rolling window', () => {
expect(getTimeframeText(mockEvaluationWindowState)).toBe('Last 5 minutes');
});
it('should call getCumulativeWindowTimeframeText for cumulative window', () => {
it('should return cumulative window text for cumulative type', () => {
expect(
getTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentDay',
}),
).toBe('Current day, starting from 00:00:00 (UTC)');
getTimeframeText('cumulative', CumulativeWindowTimeframes.CURRENT_DAY),
).toBe('Current day');
});
});
@@ -258,7 +191,7 @@ describe('utils', () => {
// When date is provided, DTSTART is automatically added to the rrule string
expect(rrulestr).toHaveBeenCalledWith(
expect.stringMatching(/DTSTART:20240120T\d{6}Z/),
expect.stringContaining('DTSTART:20240120T020000Z'),
);
});
@@ -293,340 +226,105 @@ describe('utils', () => {
});
describe('buildAlertScheduleFromCustomSchedule', () => {
beforeEach(() => {
// Mock dayjs timezone methods
((dayjs as unknown) as { tz: jest.Mock }).tz = jest.fn(
(date?: string | Date) => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = originalDayjs(date || MOCK_DATE_STRING);
mockDayjs.startOf = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.add = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.date = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.hour = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.minute = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.second = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.daysInMonth = jest.fn().mockReturnValue(31);
mockDayjs.day = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.isAfter = jest.fn().mockReturnValue(true);
mockDayjs.toDate = jest.fn().mockReturnValue(new Date(MOCK_DATE_STRING));
return mockDayjs;
},
);
});
it('should return null for missing required parameters', () => {
expect(
buildAlertScheduleFromCustomSchedule('', [], '10:30:00', 'UTC'),
).toBeNull();
expect(
buildAlertScheduleFromCustomSchedule('week', [], '10:30:00', 'UTC'),
).toBeNull();
expect(
buildAlertScheduleFromCustomSchedule('week', ['monday'], '', 'UTC'),
).toBeNull();
expect(
buildAlertScheduleFromCustomSchedule('week', ['monday'], '10:30:00', ''),
).toBeNull();
});
it('should generate monthly occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['1', '15'],
'10:30:00',
'UTC',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'01-02-2024 10:30:00',
'15-02-2024 10:30:00',
'01-03-2024 10:30:00',
'15-03-2024 10:30:00',
]);
});
it('should generate weekly occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'12:30:00',
'10:30:00',
'UTC',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 12:30:00',
'19-01-2024 12:30:00',
'22-01-2024 12:30:00',
'26-01-2024 12:30:00',
'29-01-2024 12:30:00',
]);
});
it('should generate weekly occurrences including today if alert time is in the future', () => {
it('should filter invalid days for monthly schedule', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['1', 'invalid', '15'],
'10:30:00',
'UTC',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it('should filter invalid weekdays for weekly schedule', () => {
buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
['monday', 'invalid', 'friday'],
'10:30:00',
'UTC',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today included (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'19-01-2024 10:30:00',
'22-01-2024 10:30:00',
'26-01-2024 10:30:00',
'29-01-2024 10:30:00',
]);
// Function should handle invalid weekdays gracefully
expect(true).toBe(true);
});
it('should generate weekly occurrences excluding today if alert time is in the past', () => {
it('should return null on error', () => {
// Test with invalid parameters that should cause an error
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'19-01-2024 00:00:00',
'22-01-2024 00:00:00',
'26-01-2024 00:00:00',
'29-01-2024 00:00:00',
'02-02-2024 00:00:00',
]);
});
it('should generate weekly occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'19-01-2024 00:30:00',
'22-01-2024 00:30:00',
'26-01-2024 00:30:00',
'29-01-2024 00:30:00',
'02-02-2024 00:30:00',
]);
});
it('should generate monthly occurrences including today if alert time is in the future', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'invalid_repeat_type',
['monday'],
'10:30:00',
'UTC',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today included (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'15-02-2024 10:30:00',
'15-03-2024 10:30:00',
'15-04-2024 10:30:00',
'15-05-2024 10:30:00',
]);
});
it('should generate monthly occurrences excluding today if alert time is in the past', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-02-2024 00:00:00',
'15-03-2024 00:00:00',
'15-04-2024 00:00:00',
'15-05-2024 00:00:00',
'15-06-2024 00:00:00',
]);
});
it('should generate monthly occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-02-2024 00:30:00',
'15-03-2024 00:30:00',
'15-04-2024 00:30:00',
'15-05-2024 00:30:00',
'15-06-2024 00:30:00',
]);
});
it('should account for february 29th in a leap year', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['29'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'29-01-2024 10:30:00',
'29-02-2024 10:30:00',
'29-03-2024 10:30:00',
'29-04-2024 10:30:00',
'29-05-2024 10:30:00',
]);
});
it('should skip 31st on 30-day months', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['31'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'31-01-2024 10:30:00',
'31-03-2024 10:30:00',
'31-05-2024 10:30:00',
'31-07-2024 10:30:00',
'31-08-2024 10:30:00',
]);
});
it('should skip february 29th in a non-leap year', async () => {
jest.resetModules(); // clear previous mocks
jest.doMock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = (date?: string | Date): Dayjs => {
if (date) return originalDayjs(date);
return originalDayjs(MOCK_DATE_STRING_NON_LEAP_YEAR);
};
Object.assign(mockDayjs, originalDayjs);
return mockDayjs;
});
const { buildAlertScheduleFromCustomSchedule } = await import('../utils');
const { default: dayjs } = await import('dayjs');
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
const result = buildAlertScheduleFromCustomSchedule(
'month',
['29'],
'10:30:00',
5,
);
expect(result?.map((res) => formatDate(res))).toEqual([
'29-01-2023 10:30:00',
'29-03-2023 10:30:00',
'29-04-2023 10:30:00',
'29-05-2023 10:30:00',
'29-06-2023 10:30:00',
]);
});
it('should generate daily occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:40:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:40:00',
'16-01-2024 10:40:00',
'17-01-2024 10:40:00',
'18-01-2024 10:40:00',
'19-01-2024 10:40:00',
]);
});
it('should generate daily occurrences excluding today if alert time is in the past', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'16-01-2024 00:00:00',
'17-01-2024 00:00:00',
'18-01-2024 00:00:00',
'19-01-2024 00:00:00',
'20-01-2024 00:00:00',
]);
});
it('should generate daily occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'16-01-2024 00:30:00',
'17-01-2024 00:30:00',
'18-01-2024 00:30:00',
'19-01-2024 00:30:00',
'20-01-2024 00:30:00',
]);
});
it('should generate daily occurrences including today if alert time is in the future', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'16-01-2024 10:30:00',
'17-01-2024 10:30:00',
'18-01-2024 10:30:00',
'19-01-2024 10:30:00',
]);
});
it('daily occurrences should span across months correctly', async () => {
jest.resetModules(); // clear previous mocks
jest.doMock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = (date?: string | Date): Dayjs => {
if (date) return originalDayjs(date);
return originalDayjs(MOCK_DATE_STRING_SPANS_MONTHS);
};
Object.assign(mockDayjs, originalDayjs);
return mockDayjs;
});
const { buildAlertScheduleFromCustomSchedule } = await import('../utils');
const { default: dayjs } = await import('dayjs');
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'31-01-2024 10:30:00',
'01-02-2024 10:30:00',
'02-02-2024 10:30:00',
'03-02-2024 10:30:00',
'04-02-2024 10:30:00',
]);
// Should return empty array, not null, for invalid repeat type
expect(result).toEqual([]);
});
});

View File

@@ -1,5 +1,3 @@
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
export const EVALUATION_WINDOW_TYPE = [
{ label: 'Rolling', value: 'rolling' },
{ label: 'Cumulative', value: 'cumulative' },
@@ -23,7 +21,6 @@ export const EVALUATION_WINDOW_TIMEFRAME = {
};
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
{ label: 'DAY', value: 'day' },
{ label: 'WEEK', value: 'week' },
{ label: 'MONTH', value: 'month' },
];
@@ -39,7 +36,7 @@ export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [
];
export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from(
{ length: 31 },
{ length: 30 },
(_, i) => {
const value = String(i + 1);
return { label: value, value };
@@ -55,8 +52,3 @@ export const WEEKDAY_MAP: { [key: string]: number } = {
friday: 5,
saturday: 6,
};
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
label: `${timezone.name} (${timezone.offset})`,
value: timezone.value,
}));

View File

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

View File

@@ -25,6 +25,7 @@
.ant-btn {
display: flex;
align-items: center;
width: 240px;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
@@ -32,7 +33,6 @@
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
padding-right: 16px;
}
.evaluate-alert-conditions-button-right {
@@ -72,6 +72,154 @@
}
}
}
.advanced-option-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid var(--bg-slate-500);
.advanced-option-item-left-content {
display: flex;
flex-direction: column;
gap: 6px;
.advanced-option-item-title {
color: var(--bg-vanilla-300);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
.advanced-option-item-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.advanced-option-item-input {
margin-top: 16px;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&::placeholder {
font-family: 'Space Mono';
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
}
.advanced-option-item-right-content {
display: flex;
align-items: center;
gap: 8px;
.advanced-option-item-input-group {
display: flex;
align-items: center;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
color: var(--bg-vanilla-100);
height: 32px;
border: 1px solid var(--bg-slate-400);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
.advanced-option-item-button {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-ink-200);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
border-radius: 4px;
}
}
}
}
.ant-popover-arrow {
@@ -185,7 +333,7 @@
display: flex;
flex-direction: column;
gap: 16px;
width: 400px;
min-width: 400px;
min-height: 300px;
padding: 16px;
@@ -215,7 +363,7 @@
.ant-typography {
color: var(--bg-vanilla-400);
font-size: 13px;
font-size: 14px;
font-weight: 500;
}
@@ -237,153 +385,293 @@
border-color: var(--bg-ink-400);
}
}
}
.ant-input {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
width: 60%;
.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;
}
}
}
}
.lightMode {
.evaluation-settings-container {
.evaluate-alert-conditions-container {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.evaluation-cadence-details {
margin: 16px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid var(--bg-slate-500);
.ant-typography {
color: var(--bg-ink-400);
}
.evaluation-cadence-details-title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-weight: 500;
padding-left: 16px;
padding-top: 16px;
}
.evaluate-alert-conditions-separator {
border-top: 1px dashed var(--bg-vanilla-300);
}
.query-section-tabs {
display: flex;
align-items: center;
.ant-btn {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
.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;
.evaluate-alert-conditions-button-left {
color: var(--bg-ink-400);
.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;
}
}
.evaluate-alert-conditions-button-right {
color: var(--bg-ink-400);
&:disabled {
background-color: var(--bg-ink-300);
opacity: 0.6;
}
.evaluate-alert-conditions-button-right-text {
background-color: var(--bg-vanilla-300);
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--bg-vanilla-100);
}
}
}
}
.advanced-options-container {
.ant-collapse {
.ant-collapse-item {
.ant-collapse-header {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.evaluation-cadence-details-content {
display: flex;
gap: 16px;
border-top: 1px solid var(--bg-slate-500);
padding: 16px;
.ant-collapse-header-text {
color: var(--bg-ink-400);
.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;
}
}
.ant-collapse-content {
.ant-collapse-content-box {
background-color: var(--bg-vanilla-200);
.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);
}
}
}
}
}
}
.ant-popover-content {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.buttons-row {
display: flex;
align-items: center;
gap: 16px;
margin-top: 16px;
}
.ant-popover-inner {
background-color: var(--bg-vanilla-200);
.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;
}
.evaluation-window-popover {
.evaluation-window-content {
.evaluation-window-content-item {
border-right: 1px solid var(--bg-vanilla-300);
.schedule-preview {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
.evaluation-window-content-item-label {
color: var(--bg-ink-300);
.schedule-preview-header {
display: flex;
align-items: center;
gap: 8px;
.schedule-preview-title {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
}
}
.schedule-preview-list {
display: flex;
flex-direction: column;
gap: 0;
.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);
}
}
.evaluation-window-content-list {
.evaluation-window-content-list-item {
.ant-typography {
color: var(--bg-ink-400);
}
.schedule-preview-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
&.active {
background-color: var(--bg-vanilla-300);
border-left: 2px solid var(--bg-robin-500);
.ant-typography {
color: var(--bg-ink-400);
}
}
.schedule-preview-date {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 400;
white-space: nowrap;
}
&:hover {
background-color: var(--bg-vanilla-300);
}
.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;
}
}
}
.selection-content {
.ant-typography {
color: var(--bg-ink-400);
}
}
}
.evaluation-window-footer {
background-color: var(--bg-vanilla-300);
border-top: 1px solid var(--bg-vanilla-300);
}
.ant-btn {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}
.evaluation-window-details {
.select-group {
.ant-typography {
color: var(--bg-ink-300);
}
}
.ant-typography {
color: var(--bg-ink-400);
}
.ant-select {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
.ant-select-selector {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
&:hover {
border-color: var(--bg-vanilla-200);
}
}
}
}
.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);
}

View File

@@ -9,8 +9,6 @@ export interface IAdvancedOptionItemProps {
title: string;
description: string;
input: JSX.Element;
tooltipText?: string;
onToggle?: () => void;
}
export enum RollingWindowTimeframes {
@@ -44,12 +42,6 @@ export interface IEvaluationWindowDetailsProps {
export interface IEvaluationCadenceDetailsProps {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsCustomScheduleButtonVisible: Dispatch<SetStateAction<boolean>>;
}
export interface IEvaluationCadencePreviewProps {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface TimeInputProps {
@@ -59,13 +51,3 @@ export interface TimeInputProps {
disabled?: boolean;
className?: string;
}
export interface IEditCustomScheduleProps {
setIsEvaluationCadenceDetailsVisible: (isOpen: boolean) => void;
setIsPreviewVisible: (isOpen: boolean) => void;
}
export interface IScheduleListProps {
schedule: Date[] | null;
currentTimezone: string;
}

View File

@@ -1,11 +1,9 @@
import * as Sentry from '@sentry/react';
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
import dayjs, { Dayjs } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { rrulestr } from 'rrule';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../context/constants';
import { EvaluationWindowState } from '../context/types';
import { WEEKDAY_MAP } from './constants';
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from './types';
@@ -22,22 +20,20 @@ export const getEvaluationWindowTypeText = (
case 'cumulative':
return 'Cumulative';
default:
return '';
return 'Rolling';
}
};
export const getCumulativeWindowTimeframeText = (
evaluationWindow: EvaluationWindowState,
): string => {
switch (evaluationWindow.timeframe) {
export const getCumulativeWindowTimeframeText = (timeframe: string): string => {
switch (timeframe) {
case CumulativeWindowTimeframes.CURRENT_HOUR:
return `Current hour, starting at minute ${evaluationWindow.startingAt.number} (${evaluationWindow.startingAt.timezone})`;
return 'Current hour';
case CumulativeWindowTimeframes.CURRENT_DAY:
return `Current day, starting from ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
return 'Current day';
case CumulativeWindowTimeframes.CURRENT_MONTH:
return `Current month, starting from day ${evaluationWindow.startingAt.number} at ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
return 'Current month';
default:
return '';
return 'Current hour';
}
};
@@ -60,31 +56,18 @@ export const getRollingWindowTimeframeText = (
case RollingWindowTimeframes.LAST_4_HOURS:
return 'Last 4 hours';
default:
return '';
return 'Last 5 minutes';
}
};
export const getCustomRollingWindowTimeframeText = (
evaluationWindow: EvaluationWindowState,
): string =>
`Last ${evaluationWindow.startingAt.number} ${
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
(option) => option.value === evaluationWindow.startingAt.unit,
)?.label
}`;
export const getTimeframeText = (
evaluationWindow: EvaluationWindowState,
windowType: 'rolling' | 'cumulative',
timeframe: string,
): string => {
if (evaluationWindow.windowType === 'rolling') {
if (evaluationWindow.timeframe === 'custom') {
return getCustomRollingWindowTimeframeText(evaluationWindow);
}
return getRollingWindowTimeframeText(
evaluationWindow.timeframe as RollingWindowTimeframes,
);
if (windowType === 'rolling') {
return getRollingWindowTimeframeText(timeframe as RollingWindowTimeframes);
}
return getCumulativeWindowTimeframeText(evaluationWindow);
return getCumulativeWindowTimeframeText(timeframe);
};
export function buildAlertScheduleFromRRule(
@@ -131,6 +114,7 @@ export function buildAlertScheduleFromRRule(
return occurrences;
} catch (error) {
console.error('Error building RRULE:', error);
return null;
}
}
@@ -140,15 +124,13 @@ function generateMonthlyOccurrences(
hours: number,
minutes: number,
seconds: number,
timezone: string,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentMonth = dayjs().startOf('month');
const currentMonth = dayjs().tz(timezone).startOf('month');
const currentDate = dayjs();
const scanMonths = maxOccurrences + 12;
for (let monthOffset = 0; monthOffset < scanMonths; monthOffset++) {
Array.from({ length: maxOccurrences }).forEach((_, monthOffset) => {
const monthDate = currentMonth.add(monthOffset, 'month');
targetDays.forEach((day) => {
if (occurrences.length >= maxOccurrences) return;
@@ -160,12 +142,12 @@ function generateMonthlyOccurrences(
.hour(hours)
.minute(minutes)
.second(seconds);
if (targetDate.isAfter(currentDate)) {
if (targetDate.isAfter(dayjs().tz(timezone))) {
occurrences.push(targetDate.toDate());
}
}
});
}
});
return occurrences;
}
@@ -175,14 +157,13 @@ function generateWeeklyOccurrences(
hours: number,
minutes: number,
seconds: number,
timezone: string,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentWeek = dayjs().startOf('week');
const currentWeek = dayjs().tz(timezone).startOf('week');
const currentDate = dayjs();
for (let weekOffset = 0; weekOffset < maxOccurrences; weekOffset++) {
Array.from({ length: maxOccurrences }).forEach((_, weekOffset) => {
const weekDate = currentWeek.add(weekOffset, 'week');
targetWeekdays.forEach((weekday) => {
if (occurrences.length >= maxOccurrences) return;
@@ -192,39 +173,11 @@ function generateWeeklyOccurrences(
.hour(hours)
.minute(minutes)
.second(seconds);
if (targetDate.isAfter(currentDate)) {
if (targetDate.isAfter(dayjs().tz(timezone))) {
occurrences.push(targetDate.toDate());
}
});
}
return occurrences;
}
export function generateDailyOccurrences(
hours: number,
minutes: number,
seconds: number,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentDate = dayjs();
const currentTime =
currentDate.hour() * 3600 + currentDate.minute() * 60 + currentDate.second();
const targetTime = hours * 3600 + minutes * 60 + seconds;
// Start from today if target time is after current time, otherwise start from tomorrow
const startDayOffset = targetTime > currentTime ? 0 : 1;
for (
let dayOffset = startDayOffset;
dayOffset < startDayOffset + maxOccurrences;
dayOffset++
) {
const dayDate = currentDate.add(dayOffset, 'day');
const targetDate = dayDate.hour(hours).minute(minutes).second(seconds);
occurrences.push(targetDate.toDate());
}
});
return occurrences;
}
@@ -233,9 +186,14 @@ export function buildAlertScheduleFromCustomSchedule(
repeatEvery: string,
occurence: string[],
startAt: string,
timezone: string,
maxOccurrences = 10,
): Date[] | null {
try {
if (!repeatEvery || !occurence.length || !startAt || !timezone) {
return null;
}
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
let occurrences: Date[] = [];
@@ -248,6 +206,7 @@ export function buildAlertScheduleFromCustomSchedule(
hours,
minutes,
seconds,
timezone,
maxOccurrences,
);
} else if (repeatEvery === 'week') {
@@ -259,13 +218,7 @@ export function buildAlertScheduleFromCustomSchedule(
hours,
minutes,
seconds,
maxOccurrences,
);
} else if (repeatEvery === 'day') {
occurrences = generateDailyOccurrences(
hours,
minutes,
seconds,
timezone,
maxOccurrences,
);
}
@@ -273,16 +226,16 @@ export function buildAlertScheduleFromCustomSchedule(
occurrences.sort((a, b) => a.getTime() - b.getTime());
return occurrences.slice(0, maxOccurrences);
} catch (error) {
Sentry.captureEvent({
message: `Error building alert schedule from custom schedule: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
level: 'error',
});
console.error('Error building custom schedule:', error);
return null;
}
}
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
label: `${timezone.name} (${timezone.offset})`,
value: timezone.value,
}));
export function isValidRRule(rruleString: string): boolean {
try {
// normalize escaped \n

View File

@@ -0,0 +1,92 @@
import { Radio, Select } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useMemo } from 'react';
import { useCreateAlertState } from '../context';
function MultipleNotifications(): JSX.Element {
const {
notificationSettings,
setNotificationSettings,
thresholdState,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const selectedQuery = useMemo(
() =>
currentQuery.builder.queryData.find(
(query) => query.queryName === thresholdState.selectedQuery,
),
[currentQuery, thresholdState.selectedQuery],
);
const spaceAggregationOptions = useMemo(
() =>
selectedQuery?.groupBy?.map((groupBy) => ({
label: groupBy.key,
value: groupBy.key,
})) || [],
[selectedQuery],
);
return (
<div className="multiple-notifications-container">
<Radio.Group
value={
notificationSettings.multipleNotifications.enabled ? 'multiple' : 'single'
}
onChange={(e): void => {
const isMultiple = e.target.value === 'multiple';
setNotificationSettings({
type: 'SET_MULTIPLE_NOTIFICATIONS',
payload: {
enabled: isMultiple,
value: isMultiple ? spaceAggregationOptions[0]?.value || '' : '',
},
});
}}
>
<Radio value="single" disabled={spaceAggregationOptions.length === 0}>
<div className="multiple-notifications-container-item">
<div className="multiple-notifications-container-item-title">
Single Alert Notification
</div>
<div className="multiple-notifications-container-item-description">
Send a single alert notification when the query meets the conditions
defined.
</div>
</div>
</Radio>
<div className="border-bottom" />
<Radio value="multiple" disabled={spaceAggregationOptions.length === 0}>
<div className="multiple-notifications-container-item">
<div className="multiple-notifications-container-item-title">
Multiple Alert Notifications
</div>
<div className="multiple-notifications-container-item-description">
Send a notification for each
<Select
options={spaceAggregationOptions}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_MULTIPLE_NOTIFICATIONS',
payload: {
enabled: true,
value,
},
});
}}
value={notificationSettings.multipleNotifications.value || null}
placeholder="SELECT VALUE"
disabled={!notificationSettings.multipleNotifications.enabled}
/>
meeting the conditions defined.
</div>
</div>
</Radio>
</Radio.Group>
</div>
);
}
export default MultipleNotifications;

View File

@@ -0,0 +1,40 @@
import { Tabs } from 'antd/lib';
import TextArea from 'antd/lib/input/TextArea';
import { useCreateAlertState } from '../context';
function NotificationMessage(): JSX.Element {
const {
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const NotificationMessageEditor = (
<TextArea
value={notificationSettings.description}
onChange={(e): void =>
setNotificationSettings({
type: 'SET_DESCRIPTION',
payload: e.target.value,
})
}
placeholder="Enter notification message..."
/>
);
const items = [
{
key: '1',
label: 'Notification Message',
children: NotificationMessageEditor,
},
];
return (
<div className="notification-message-container">
<Tabs items={items} />
</div>
);
}
export default NotificationMessage;

View File

@@ -0,0 +1,25 @@
import './styles.scss';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import MultipleNotifications from './MultipleNotifications';
import NotificationMessage from './NotificationMessage';
import ReNotification from './ReNotification';
function NotificationSettings(): JSX.Element {
const showCondensedLayoutFlag = showCondensedLayout();
return (
<div className="notification-settings-container">
<Stepper
stepNumber={showCondensedLayoutFlag ? 3 : 4}
label="Notification settings"
/>
<NotificationMessage />
<MultipleNotifications />
<ReNotification />
</div>
);
}
export default NotificationSettings;

View File

@@ -0,0 +1,108 @@
import { Input, Select, Switch, Typography } from 'antd';
import { useCreateAlertState } from '../context';
import {
RE_NOTIFICATION_CONDITION_OPTIONS,
RE_NOTIFICATION_UNIT_OPTIONS,
} from '../context/constants';
function ReNotification(): JSX.Element {
const {
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
return (
<div className="re-notification-container">
<div className="advanced-option-item">
<div className="advanced-option-item-left-content">
<Typography.Text className="advanced-option-item-title">
Re-notification
</Typography.Text>
<Typography.Text className="advanced-option-item-description">
Send notifications for the alert status periodically as long as the
resources have not recovered.
</Typography.Text>
</div>
<div className="advanced-option-item-right-content">
<Switch
checked={notificationSettings.reNotification.enabled}
onChange={(checked): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: checked,
value: notificationSettings.reNotification.value,
unit: notificationSettings.reNotification.unit,
conditions: notificationSettings.reNotification.conditions,
},
});
}}
/>
</div>
</div>
<div className="border-bottom" />
<div className="re-notification-condition">
<Typography.Text>If this alert rule stays in</Typography.Text>
<Select
mode="multiple"
value={notificationSettings.reNotification.conditions || null}
placeholder="Select conditions"
disabled={!notificationSettings.reNotification.enabled}
options={RE_NOTIFICATION_CONDITION_OPTIONS}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: notificationSettings.reNotification.enabled,
value: notificationSettings.reNotification.value,
unit: notificationSettings.reNotification.unit,
conditions: value,
},
});
}}
/>
<Typography.Text>re-notify every</Typography.Text>
<Input.Group>
<Input
value={notificationSettings.reNotification.value}
placeholder="Enter time interval..."
disabled={!notificationSettings.reNotification.enabled}
type="number"
onChange={(e): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: notificationSettings.reNotification.enabled,
value: parseInt(e.target.value, 10),
unit: notificationSettings.reNotification.unit,
conditions: notificationSettings.reNotification.conditions,
},
});
}}
/>
<Select
value={notificationSettings.reNotification.unit || null}
placeholder="Select unit"
disabled={!notificationSettings.reNotification.enabled}
options={RE_NOTIFICATION_UNIT_OPTIONS}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: notificationSettings.reNotification.enabled,
value: notificationSettings.reNotification.value,
unit: value,
conditions: notificationSettings.reNotification.conditions,
},
});
}}
style={{ width: 200 }}
/>
</Input.Group>
</div>
</div>
);
}
export default ReNotification;

View File

@@ -0,0 +1,162 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as createAlertContext from 'container/CreateAlertV2/context';
import MultipleNotifications from '../MultipleNotifications';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
const TEST_QUERY = 'test-query';
const mockSetNotificationSettings = jest.fn();
const mockUseQueryBuilder = {
currentQuery: {
builder: {
queryData: [
{
queryName: TEST_QUERY,
groupBy: [{ key: 'service' }, { key: 'environment' }],
},
],
},
},
};
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
multipleNotifications: {
enabled: false,
value: '',
},
},
thresholdState: {
selectedQuery: TEST_QUERY,
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
describe('MultipleNotifications', () => {
const { useQueryBuilder } = jest.requireMock(
'hooks/queryBuilder/useQueryBuilder',
);
beforeEach(() => {
jest.clearAllMocks();
useQueryBuilder.mockReturnValue(mockUseQueryBuilder);
});
it('renders single and multiple notification options', () => {
render(<MultipleNotifications />);
expect(screen.getByText('Single Alert Notification')).toBeInTheDocument();
expect(screen.getByText('Multiple Alert Notifications')).toBeInTheDocument();
});
it('renders descriptions for both options', () => {
render(<MultipleNotifications />);
expect(
screen.getByText(/Send a single alert notification when the query meets/),
).toBeInTheDocument();
expect(screen.getByText(/Send a notification for each/)).toBeInTheDocument();
});
it('renders select dropdown for multiple notifications', () => {
render(<MultipleNotifications />);
const selectElement = screen.getByText('SELECT VALUE');
expect(selectElement).toBeInTheDocument();
});
it('switches to multiple notifications when radio is clicked', async () => {
const user = userEvent.setup();
render(<MultipleNotifications />);
const multipleRadio = screen.getByDisplayValue('multiple');
await user.click(multipleRadio);
expect(mockSetNotificationSettings).toHaveBeenCalledWith({
type: 'SET_MULTIPLE_NOTIFICATIONS',
payload: {
enabled: true,
value: 'service', // First option from groupBy
},
});
});
it('switches to single notification when radio is clicked', async () => {
// First enable multiple notifications, then switch back to single
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
multipleNotifications: {
enabled: true,
value: 'service',
},
},
thresholdState: {
selectedQuery: TEST_QUERY,
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
const user = userEvent.setup();
render(<MultipleNotifications />);
const singleRadio = screen.getByDisplayValue('single');
await user.click(singleRadio);
expect(mockSetNotificationSettings).toHaveBeenCalledWith({
type: 'SET_MULTIPLE_NOTIFICATIONS',
payload: {
enabled: false,
value: '',
},
});
});
it('disables radio options when no groupBy options are available', () => {
useQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: 'test-query',
groupBy: [],
},
],
},
},
});
render(<MultipleNotifications />);
const singleRadio = screen.getByDisplayValue('single');
const multipleRadio = screen.getByDisplayValue('multiple');
expect(singleRadio).toBeDisabled();
expect(multipleRadio).toBeDisabled();
});
});

View File

@@ -0,0 +1,77 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as createAlertContext from 'container/CreateAlertV2/context';
import NotificationMessage from '../NotificationMessage';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const mockSetNotificationSettings = jest.fn();
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
description: '',
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
describe('NotificationMessage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the notification message tab', () => {
render(<NotificationMessage />);
expect(screen.getByText('Notification Message')).toBeInTheDocument();
});
it('renders textarea with placeholder', () => {
render(<NotificationMessage />);
const textarea = screen.getByPlaceholderText('Enter notification message...');
expect(textarea).toBeInTheDocument();
});
it('updates notification settings when textarea value changes', async () => {
const user = userEvent.setup();
render(<NotificationMessage />);
const textarea = screen.getByPlaceholderText('Enter notification message...');
await user.type(textarea, 'Test');
expect(mockSetNotificationSettings).toHaveBeenCalledTimes(4);
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
type: 'SET_DESCRIPTION',
payload: 't',
});
});
it('displays existing description value', () => {
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
description: 'Existing message',
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
render(<NotificationMessage />);
const textarea = screen.getByDisplayValue('Existing message');
expect(textarea).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import * as createAlertContext from 'container/CreateAlertV2/context';
import NotificationSettings from '../NotificationSettings';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const mockSetNotificationSettings = jest.fn();
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
multipleNotifications: {
enabled: false,
value: '',
},
reNotification: {
enabled: false,
value: 0,
unit: 'seconds',
conditions: [],
},
description: '',
},
setNotificationSettings: mockSetNotificationSettings,
thresholdState: {
selectedQuery: '',
evaluationWindow: '',
algorithm: '',
seasonality: '',
},
} as any),
);
jest.mock('../NotificationMessage', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="notification-message">NotificationMessage</div>
),
}));
jest.mock('../MultipleNotifications', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="multiple-notifications">MultipleNotifications</div>
),
}));
jest.mock('../ReNotification', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="re-notification">ReNotification</div>
),
}));
describe('NotificationSettings', () => {
it('should render the sub components', () => {
render(<NotificationSettings />);
expect(screen.getByText('Notification settings')).toBeInTheDocument();
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
expect(screen.getByTestId('re-notification')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,148 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as createAlertContext from 'container/CreateAlertV2/context';
import ReNotification from '../ReNotification';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const mockSetNotificationSettings = jest.fn();
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
reNotification: {
enabled: false,
value: 0,
unit: 'seconds',
conditions: [],
},
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
describe('ReNotification', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the re-notification title and description', () => {
render(<ReNotification />);
expect(screen.getByText('Re-notification')).toBeInTheDocument();
expect(
screen.getByText(/Send notifications for the alert status periodically/),
).toBeInTheDocument();
});
it('renders switch to enable/disable re-notification', () => {
render(<ReNotification />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toBeInTheDocument();
expect(switchElement).not.toBeChecked();
});
it('toggles re-notification when switch is clicked', async () => {
const user = userEvent.setup();
render(<ReNotification />);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
expect(mockSetNotificationSettings).toHaveBeenCalledWith({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: true,
value: 0,
unit: 'seconds',
conditions: [],
},
});
});
it('renders disabled inputs when re-notification is disabled', () => {
render(<ReNotification />);
const timeInput = screen.getByPlaceholderText('Enter time interval...');
const unitSelect = screen.getByText('seconds');
expect(timeInput).toBeDisabled();
expect(unitSelect.closest('.ant-select')).toHaveClass('ant-select-disabled');
});
it('renders enabled inputs when re-notification is enabled', () => {
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
reNotification: {
enabled: true,
value: 5,
unit: 'minutes',
conditions: ['firing'],
},
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
render(<ReNotification />);
const timeInput = screen.getByDisplayValue('5');
const unitSelect = screen.getByText('minutes');
expect(timeInput).not.toBeDisabled();
expect(unitSelect.closest('.ant-select')).not.toHaveClass(
'ant-select-disabled',
);
});
it('updates time value when input changes', async () => {
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
reNotification: {
enabled: true,
value: 0,
unit: 'seconds',
conditions: [],
},
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
const user = userEvent.setup();
render(<ReNotification />);
const timeInput = screen.getByPlaceholderText('Enter time interval...');
await user.clear(timeInput);
await user.type(timeInput, '10');
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: true,
value: 1, // parseInt of '1' from the last character typed
unit: 'seconds',
conditions: [],
},
});
});
});

View File

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

View File

@@ -0,0 +1,182 @@
.notification-settings-container {
display: flex;
flex-direction: column;
margin: 0 16px;
.notification-message-container {
display: flex;
flex-direction: column;
margin-top: -8px;
.ant-tabs {
.ant-tabs-nav {
margin-bottom: 4px;
.ant-tabs-nav-wrap {
border-bottom: 1px solid var(--bg-slate-400);
.ant-tabs-nav-list {
.ant-tabs-tab {
.ant-tabs-tab-btn {
font-family: Inter !important;
font-size: 14px !important;
}
}
.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: var(--bg-vanilla-100);
}
}
}
}
}
.ant-tabs-content-holder {
.ant-tabs-content {
.ant-tabs-tabpane {
textarea {
height: 350px;
background: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
color: var(--bg-vanilla-400) !important;
font-family: Inter;
font-size: 14px;
}
}
}
}
}
}
.multiple-notifications-container {
display: flex;
flex-direction: column;
gap: 16px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
padding: 16px;
margin-top: 16px;
.border-bottom {
border-bottom: 1px solid var(--bg-slate-400);
width: 100%;
margin-left: -16px;
margin-right: -32px;
}
.ant-radio-group {
.ant-radio-wrapper {
display: flex;
align-items: flex-start;
gap: 8px;
&:first-child {
margin-bottom: 20px;
}
&:last-child {
margin-top: 16px;
}
.ant-radio {
align-self: flex-start;
padding-top: 8px;
}
.ant-select {
height: 32px;
width: 200px;
margin: 0 12px;
.ant-select-selector {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
color: var(--bg-vanilla-400);
}
}
}
}
.multiple-notifications-container-item {
.multiple-notifications-container-item-title {
font-size: 14px;
}
}
}
.re-notification-container {
display: flex;
flex-direction: column;
gap: 16px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
padding: 16px;
margin-top: 16px;
.advanced-option-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
.advanced-option-item-left-content {
display: flex;
flex-direction: column;
gap: 6px;
.advanced-option-item-title {
color: var(--bg-vanilla-300);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
.advanced-option-item-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
}
}
.border-bottom {
border-bottom: 1px solid var(--bg-slate-400);
width: 100%;
margin-left: -16px;
margin-right: -32px;
}
.re-notification-condition {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
.ant-typography {
font-size: 14px;
font-weight: 400;
color: var(--bg-vanilla-400);
white-space: nowrap;
}
.ant-select {
width: 200px;
height: 32px;
flex-shrink: 0;
.ant-select-selector {
border: 1px solid var(--bg-slate-400);
}
}
.ant-input {
width: 200px;
flex-shrink: 0;
border: 1px solid var(--bg-slate-400);
}
}
}
}

View File

@@ -1,6 +1,5 @@
import { Color } from '@signozhq/design-tokens';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/constants';
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/utils';
import dayjs from 'dayjs';
import getRandomColor from 'lib/getRandomColor';
import { v4 } from 'uuid';
@@ -13,6 +12,7 @@ import {
AlertThresholdState,
Algorithm,
EvaluationWindowState,
NotificationSettingsState,
Seasonality,
Threshold,
TimeDuration,
@@ -77,31 +77,31 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
sendNotificationIfDataIsMissing: {
toleranceLimit: 15,
timeUnit: UniversalYAxisUnit.MINUTES,
toleranceLimit: 0,
timeUnit: '',
},
enforceMinimumDatapoints: {
minimumDatapoints: 0,
},
delayEvaluation: {
delay: 5,
timeUnit: UniversalYAxisUnit.MINUTES,
delay: 0,
timeUnit: '',
},
evaluationCadence: {
mode: 'default',
default: {
value: 1,
timeUnit: UniversalYAxisUnit.MINUTES,
timeUnit: 'm',
},
custom: {
repeatEvery: 'day',
startAt: dayjs().format('HH:mm:ss'),
repeatEvery: 'week',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
rrule: {
date: dayjs(),
startAt: dayjs().format('HH:mm:ss'),
startAt: '00:00:00',
rrule: '',
},
},
@@ -111,10 +111,9 @@ export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
windowType: 'rolling',
timeframe: '5m0s',
startingAt: {
time: dayjs().format('HH:mm:ss'),
time: '00:00:00',
number: '1',
timezone: TIMEZONE_DATA[0].value,
unit: UniversalYAxisUnit.MINUTES,
},
};
@@ -165,8 +164,37 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
];
export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.SECONDS, label: 'Seconds' },
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
{ value: 's', label: 'Second' },
{ value: 'm', label: 'Minute' },
{ value: 'h', label: 'Hours' },
{ value: 'd', label: 'Day' },
];
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
{ value: 'firing', label: 'Firing' },
{ value: 'no-data', label: 'No Data' },
];
export const RE_NOTIFICATION_UNIT_OPTIONS = [
{ value: 'm', label: 'Minute' },
{ value: 'h', label: 'Hour' },
{ value: 'd', label: 'Day' },
{ value: 'w', label: 'Week' },
];
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
multipleNotifications: {
enabled: false,
value: '',
},
reNotification: {
enabled: false,
value: 0,
unit: RE_NOTIFICATION_UNIT_OPTIONS[0].value,
conditions: [],
},
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
};

View File

@@ -18,6 +18,7 @@ import {
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './constants';
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
import {
@@ -27,6 +28,7 @@ import {
buildInitialAlertDef,
evaluationWindowReducer,
getInitialAlertTypeFromURL,
notificationSettingsReducer,
} from './utils';
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
@@ -94,6 +96,11 @@ export function CreateAlertProvider(
INITIAL_ADVANCED_OPTIONS_STATE,
);
const [notificationSettings, setNotificationSettings] = useReducer(
notificationSettingsReducer,
INITIAL_NOTIFICATION_SETTINGS_STATE,
);
useEffect(() => {
setThresholdState({
type: 'RESET',
@@ -112,6 +119,8 @@ export function CreateAlertProvider(
setEvaluationWindow,
advancedOptions,
setAdvancedOptions,
notificationSettings,
setNotificationSettings,
}),
[
alertState,
@@ -120,6 +129,7 @@ export function CreateAlertProvider(
thresholdState,
evaluationWindow,
advancedOptions,
notificationSettings,
],
);

View File

@@ -14,6 +14,8 @@ export interface ICreateAlertContextProps {
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
notificationSettings: NotificationSettingsState;
setNotificationSettings: Dispatch<NotificationSettingsAction>;
}
export interface ICreateAlertProviderProps {
@@ -175,7 +177,6 @@ export interface EvaluationWindowState {
time: string;
number: string;
timezone: string;
unit: string;
};
}
@@ -184,9 +185,39 @@ export type EvaluationWindowAction =
| { type: 'SET_TIMEFRAME'; payload: string }
| {
type: 'SET_STARTING_AT';
payload: { time: string; number: string; timezone: string; unit: string };
payload: { time: string; number: string; timezone: string };
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
export interface NotificationSettingsState {
multipleNotifications: {
enabled: boolean;
value: string;
};
reNotification: {
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'no-data')[];
};
description: string;
}
export type NotificationSettingsAction =
| {
type: 'SET_MULTIPLE_NOTIFICATIONS';
payload: { enabled: boolean; value: string };
}
| {
type: 'SET_RE_NOTIFICATION';
payload: {
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'no-data')[];
};
}
| { type: 'SET_DESCRIPTION'; payload: string }
| { type: 'RESET' };

View File

@@ -15,6 +15,7 @@ import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './constants';
import {
AdvancedOptionsAction,
@@ -25,6 +26,8 @@ import {
CreateAlertAction,
EvaluationWindowAction,
EvaluationWindowState,
NotificationSettingsAction,
NotificationSettingsState,
} from './types';
export const alertCreationReducer = (
@@ -172,3 +175,21 @@ export const evaluationWindowReducer = (
return state;
}
};
export const notificationSettingsReducer = (
state: NotificationSettingsState,
action: NotificationSettingsAction,
): NotificationSettingsState => {
switch (action.type) {
case 'SET_MULTIPLE_NOTIFICATIONS':
return { ...state, multipleNotifications: action.payload };
case 'SET_RE_NOTIFICATION':
return { ...state, reNotification: action.payload };
case 'SET_DESCRIPTION':
return { ...state, description: action.payload };
case 'RESET':
return INITIAL_NOTIFICATION_SETTINGS_STATE;
default:
return state;
}
};

View File

@@ -1,3 +1,9 @@
// UI side feature flag
export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true';
// UI side FF to switch between the 2 layouts of the create alert page
// Layout 1 - Default layout
// Layout 2 - Condensed layout
export const showCondensedLayout = (): boolean =>
localStorage.getItem('showCondensedLayout') === 'true';

View File

@@ -30,6 +30,20 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock data
const mockProps: WidgetGraphComponentProps = {
widget: {

View File

@@ -33,6 +33,19 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
const queryClient = new QueryClient();
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): any => ({

View File

@@ -3,6 +3,20 @@ import { render, screen } from '@testing-library/react';
import HostsListTable from '../HostsListTable';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container';
describe('HostsListTable', () => {

View File

@@ -4,6 +4,20 @@ import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils';
const PROGRESS_BAR_CLASS = '.progress-bar';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('InfraMonitoringHosts utils', () => {
describe('formatDataForTable', () => {
it('should format host data correctly', () => {

View File

@@ -31,7 +31,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage (WSS)',
label: 'Mem Usage',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -105,7 +105,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Utilization (WSS)</div>,
title: <div className="column-header-left">Memory Utilization (bytes)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
@@ -113,7 +113,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Allocatable</div>,
title: <div className="column-header-left">Memory Allocatable (bytes)</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,

View File

@@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage (WSS)',
label: 'Mem Usage',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -211,10 +211,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
title: <div className="column-header small-col">Mem Usage</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
width: 80,
ellipsis: true,
sorter: true,
align: 'left',

View File

@@ -203,10 +203,10 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left small-col">Mem Usage (WSS)</div>,
title: <div className="column-header-left small-col">Mem Usage</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
width: 80,
sorter: true,
align: 'left',
},

View File

@@ -44,6 +44,20 @@ const verifyEntityLogsPayload = ({
return queryData;
};
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>

View File

@@ -84,7 +84,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage (WSS)',
label: 'Mem Usage',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -238,10 +238,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">Mem Usage (WSS)</div>,
title: <div className="column-header small-col">Mem Usage</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
width: 80,
ellipsis: true,
sorter: true,
align: 'left',

View File

@@ -99,10 +99,10 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Mem Usage (WSS)</div>,
title: <div className="column-header-left">Mem Usage</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
width: 80,
sorter: true,
align: 'left',
},

View File

@@ -37,7 +37,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Memory Usage (WSS)',
label: 'Memory Usage (bytes)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -121,7 +121,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Usage (WSS)</div>,
title: <div className="column-header-left">Memory Usage (bytes)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
@@ -129,7 +129,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Allocatable</div>,
title: <div className="column-header-left">Memory Alloc (bytes)</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,

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