Compare commits
26 Commits
dependabot
...
enhancemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf4e814a4 | ||
|
|
84ae5b4ca9 | ||
|
|
a564fa9d28 | ||
|
|
7f4390f370 | ||
|
|
c41ae00433 | ||
|
|
9aacf7f2f5 | ||
|
|
792d0f3db6 | ||
|
|
47e8a89dbe | ||
|
|
bced4774bb | ||
|
|
0c25de9560 | ||
|
|
24307b48ff | ||
|
|
bba2385c8b | ||
|
|
6491e31242 | ||
|
|
86b572b1af | ||
|
|
1779d66a9e | ||
|
|
1c7e80cf38 | ||
|
|
b091746051 | ||
|
|
eaaf244ab1 | ||
|
|
1f2db8bf4b | ||
|
|
b4c2427c24 | ||
|
|
24ace81568 | ||
|
|
b5a784b50f | ||
|
|
c43482a572 | ||
|
|
9f480b6fbe | ||
|
|
b48ac31f0d | ||
|
|
4668c7f15b |
44
ee/authz/openfgaschema/base.fga
Normal file
44
ee/authz/openfgaschema/base.fga
Normal file
@@ -0,0 +1,44 @@
|
||||
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]
|
||||
29
ee/authz/openfgaschema/schema.go
Normal file
29
ee/authz/openfgaschema/schema.go
Normal file
@@ -0,0 +1,29 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
132
ee/http/middleware/authz.go
Normal file
132
ee/http/middleware/authz.go
Normal file
@@ -0,0 +1,132 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,8 @@ 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"
|
||||
@@ -334,6 +336,8 @@ 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,
|
||||
@@ -348,8 +352,10 @@ func makeRulesManager(
|
||||
PrepareTaskFunc: rules.PrepareTaskFunc,
|
||||
PrepareTestRuleFunc: rules.TestNotification,
|
||||
Alertmanager: alertmanager,
|
||||
SQLStore: sqlstore,
|
||||
OrgGetter: orgGetter,
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SqlStore: sqlstore,
|
||||
}
|
||||
|
||||
// create Manager
|
||||
|
||||
484
frontend/.cursorrules
Normal file
484
frontend/.cursorrules
Normal file
@@ -0,0 +1,484 @@
|
||||
# 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**: 3–5 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)
|
||||
}));
|
||||
```
|
||||
51
frontend/__mocks__/uplotMock.ts
Normal file
51
frontend/__mocks__/uplotMock.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/* 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;
|
||||
29
frontend/__mocks__/useSafeNavigate.ts
Normal file
29
frontend/__mocks__/useSafeNavigate.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// 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
|
||||
>,
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
clearMocks: true,
|
||||
coverageDirectory: 'coverage',
|
||||
@@ -10,6 +12,10 @@ 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'],
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
"style-loader": "1.3.0",
|
||||
"styled-components": "^5.3.11",
|
||||
|
||||
@@ -119,7 +119,9 @@ const filterAndSortTimezones = (
|
||||
return createTimezoneEntry(normalizedTz, offset);
|
||||
});
|
||||
|
||||
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
|
||||
export const generateTimezoneData = (
|
||||
includeEtcTimezones = false,
|
||||
): Timezone[] => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
|
||||
const timezones: Timezone[] = [];
|
||||
|
||||
@@ -19,20 +19,6 @@ 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()]),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import ErrorModal from './ErrorModal';
|
||||
@@ -56,9 +56,8 @@ describe('ErrorModal Component', () => {
|
||||
|
||||
// Click the close button
|
||||
const closeButton = screen.getByTestId('close-button');
|
||||
act(() => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(closeButton);
|
||||
|
||||
// Check if onClose was called
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
@@ -149,9 +148,8 @@ it('should open the modal when the trigger component is clicked', async () => {
|
||||
|
||||
// Click the trigger component
|
||||
const triggerButton = screen.getByText('Open Error Modal');
|
||||
act(() => {
|
||||
fireEvent.click(triggerButton);
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(triggerButton);
|
||||
|
||||
// Check if the modal is displayed
|
||||
expect(screen.getByText('An error occurred')).toBeInTheDocument();
|
||||
@@ -170,18 +168,15 @@ it('should close the modal when the onCancel event is triggered', async () => {
|
||||
|
||||
// Click the trigger component
|
||||
const triggerButton = screen.getByText('error');
|
||||
act(() => {
|
||||
fireEvent.click(triggerButton);
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(triggerButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('An error occurred')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Trigger the onCancel event
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId('close-button'));
|
||||
});
|
||||
await user.click(screen.getByTestId('close-button'));
|
||||
|
||||
// Check if the modal is closed
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
filterConfig,
|
||||
isDynamicFilters,
|
||||
customFilters,
|
||||
setIsStale,
|
||||
refetchCustomFilters,
|
||||
isCustomFiltersLoading,
|
||||
} = useFilterConfig({ signal, config });
|
||||
|
||||
@@ -263,7 +263,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
signal={signal}
|
||||
setIsSettingsOpen={setIsSettingsOpen}
|
||||
customFilters={customFilters}
|
||||
setIsStale={setIsStale}
|
||||
refetchCustomFilters={refetchCustomFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,12 +14,12 @@ function QuickFiltersSettings({
|
||||
signal,
|
||||
setIsSettingsOpen,
|
||||
customFilters,
|
||||
setIsStale,
|
||||
refetchCustomFilters,
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
setIsStale: (isStale: boolean) => void;
|
||||
refetchCustomFilters: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
handleSettingsClose,
|
||||
@@ -34,7 +34,7 @@ function QuickFiltersSettings({
|
||||
} = useQuickFilterSettings({
|
||||
setIsSettingsOpen,
|
||||
customFilters,
|
||||
setIsStale,
|
||||
refetchCustomFilters,
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
setIsStale: (isStale: boolean) => void;
|
||||
refetchCustomFilters: () => void;
|
||||
signal?: SignalType;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ interface UseQuickFilterSettingsReturn {
|
||||
const useQuickFilterSettings = ({
|
||||
customFilters,
|
||||
setIsSettingsOpen,
|
||||
setIsStale,
|
||||
refetchCustomFilters,
|
||||
signal,
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
@@ -46,7 +46,7 @@ const useQuickFilterSettings = ({
|
||||
} = useMutation(updateCustomFiltersAPI, {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
setIsStale(true);
|
||||
refetchCustomFilters();
|
||||
logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
Filter as FilterType,
|
||||
PayloadProps,
|
||||
} from 'types/api/quickFilters/getCustomFilters';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
@@ -18,37 +14,34 @@ interface UseFilterConfigProps {
|
||||
interface UseFilterConfigReturn {
|
||||
filterConfig: IQuickFiltersConfig[];
|
||||
customFilters: FilterType[];
|
||||
setCustomFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
isCustomFiltersLoading: boolean;
|
||||
isDynamicFilters: boolean;
|
||||
setIsStale: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refetchCustomFilters: () => void;
|
||||
}
|
||||
|
||||
const useFilterConfig = ({
|
||||
signal,
|
||||
config,
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const [customFilters, setCustomFilters] = useState<FilterType[]>([]);
|
||||
const [isStale, setIsStale] = useState(true);
|
||||
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 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],
|
||||
@@ -57,10 +50,9 @@ const useFilterConfig = ({
|
||||
return {
|
||||
filterConfig,
|
||||
customFilters,
|
||||
setCustomFilters,
|
||||
isCustomFiltersLoading,
|
||||
isDynamicFilters,
|
||||
setIsStale,
|
||||
refetchCustomFilters: refetch,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
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,
|
||||
@@ -18,8 +9,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
|
||||
@@ -29,21 +19,6 @@ 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();
|
||||
@@ -78,11 +53,10 @@ 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)),
|
||||
),
|
||||
);
|
||||
@@ -96,14 +70,12 @@ function TestQuickFilters({
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<MockQueryClientProvider>
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
signal={signal}
|
||||
/>
|
||||
</MockQueryClientProvider>
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,11 +90,11 @@ beforeAll(() => {
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -151,9 +123,13 @@ describe('Quick Filters', () => {
|
||||
});
|
||||
|
||||
it('should add filter data to query when checkbox is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters />);
|
||||
const checkbox = screen.getByText('mq-kafka');
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Prefer role if possible; if label text isn’t wired to input, clicking the label text is OK
|
||||
const target = await screen.findByText('mq-kafka');
|
||||
await user.click(target);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
@@ -182,16 +158,20 @@ 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);
|
||||
fireEvent.click(icon);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
|
||||
|
||||
@@ -207,16 +187,19 @@ 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);
|
||||
fireEvent.click(icon);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME);
|
||||
const addButton = otherFilterItem.parentElement?.querySelector('button');
|
||||
expect(addButton).not.toBeNull();
|
||||
fireEvent.click(addButton as HTMLButtonElement);
|
||||
await user.click(addButton as HTMLButtonElement);
|
||||
|
||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||
await waitFor(() => {
|
||||
@@ -225,17 +208,21 @@ 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);
|
||||
fireEvent.click(icon);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
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();
|
||||
fireEvent.click(removeBtn as HTMLButtonElement);
|
||||
|
||||
await user.click(removeBtn as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addedSection).not.toContainElement(
|
||||
@@ -250,17 +237,20 @@ 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);
|
||||
fireEvent.click(icon);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
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();
|
||||
fireEvent.click(removeBtn as HTMLButtonElement);
|
||||
await user.click(removeBtn as HTMLButtonElement);
|
||||
|
||||
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
||||
await waitFor(() => {
|
||||
@@ -272,7 +262,11 @@ describe('Quick Filters with custom filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(DISCARD_TEXT));
|
||||
const discardBtn = screen
|
||||
.getByText(DISCARD_TEXT)
|
||||
.closest('button') as HTMLButtonElement;
|
||||
expect(discardBtn).not.toBeNull();
|
||||
await user.click(discardBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addedSection).toContainElement(
|
||||
@@ -285,18 +279,25 @@ 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);
|
||||
fireEvent.click(icon);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
||||
const removeBtn = target.parentElement?.querySelector('button');
|
||||
expect(removeBtn).not.toBeNull();
|
||||
fireEvent.click(removeBtn as HTMLButtonElement);
|
||||
await user.click(removeBtn as HTMLButtonElement);
|
||||
|
||||
fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT));
|
||||
const saveBtn = screen
|
||||
.getByText(SAVE_CHANGES_TEXT)
|
||||
.closest('button') as HTMLButtonElement;
|
||||
expect(saveBtn).not.toBeNull();
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(putHandler).toHaveBeenCalled();
|
||||
@@ -311,31 +312,36 @@ describe('Quick Filters with custom filters', () => {
|
||||
expect(requestBody.signal).toBe(SIGNAL);
|
||||
});
|
||||
|
||||
// render duration filter
|
||||
it('should render duration slider for duration_nono filter', async () => {
|
||||
// Set up fake timers **before rendering**
|
||||
// Use fake timers only in this test (for debounce), and wire them to userEvent
|
||||
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();
|
||||
|
||||
// click to open the duration filter
|
||||
fireEvent.click(screen.getByText('Duration'));
|
||||
// Open the duration section (use role if it’s a button/collapse)
|
||||
await user.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');
|
||||
|
||||
await act(async () => {
|
||||
// set values
|
||||
fireEvent.change(minDuration, { target: { value: '10000' } });
|
||||
fireEvent.change(maxDuration, { target: { value: '20000' } });
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
// 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 waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -363,6 +369,144 @@ describe('Quick Filters with custom filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
jest.useRealTimers(); // Clean up
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ function TopContributorsCard({
|
||||
|
||||
return newState;
|
||||
});
|
||||
history.push({ search: searchParams.toString() });
|
||||
history.push(`${history.location.pathname}?${searchParams.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -102,9 +102,7 @@ function HorizontalTimelineGraph({
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
|
||||
history.push({
|
||||
search: urlQuery.toString(),
|
||||
});
|
||||
history.push(`${history.location.pathname}?${urlQuery.toString()}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ function TimelineFilters(): JSX.Element {
|
||||
|
||||
const handleFilter = (value: TimelineFilter): void => {
|
||||
searchParams.set('timelineFilter', value);
|
||||
history.push({ search: searchParams.toString() });
|
||||
history.push(`${history.location.pathname}?${searchParams.toString()}`);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
|
||||
@@ -20,11 +20,12 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [action] = useComponentPermission(['new_alert_action'], user.role);
|
||||
|
||||
const onClickEditHandler = useCallback((id: string) => {
|
||||
const onClickEditHandler = useCallback((id: string, e: React.MouseEvent) => {
|
||||
history.push(
|
||||
generatePath(ROUTES.CHANNELS_EDIT, {
|
||||
channelId: id,
|
||||
}),
|
||||
e,
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -52,7 +53,10 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
|
||||
width: 80,
|
||||
render: (id: string): JSX.Element => (
|
||||
<>
|
||||
<Button onClick={(): void => onClickEditHandler(id)} type="link">
|
||||
<Button
|
||||
onClick={(e: React.MouseEvent): void => onClickEditHandler(id, e)}
|
||||
type="link"
|
||||
>
|
||||
{t('column_channel_edit')}
|
||||
</Button>
|
||||
<Delete id={id} notifications={notifications} />
|
||||
|
||||
@@ -22,6 +22,8 @@ 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(),
|
||||
@@ -29,6 +31,7 @@ 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 ', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ 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(),
|
||||
@@ -35,6 +36,7 @@ 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 () => {
|
||||
|
||||
@@ -30,8 +30,8 @@ function AlertChannels(): JSX.Element {
|
||||
['add_new_channel'],
|
||||
user.role,
|
||||
);
|
||||
const onToggleHandler = useCallback(() => {
|
||||
history.push(ROUTES.CHANNELS_NEW);
|
||||
const onToggleHandler = useCallback((e: React.MouseEvent) => {
|
||||
history.push(ROUTES.CHANNELS_NEW, e);
|
||||
}, []);
|
||||
|
||||
const { isLoading, data, error } = useQuery<
|
||||
@@ -78,7 +78,7 @@ function AlertChannels(): JSX.Element {
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={onToggleHandler}
|
||||
onClick={(e: React.MouseEvent): void => onToggleHandler(e)}
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!addNewChannelPermission}
|
||||
>
|
||||
|
||||
@@ -9,22 +9,6 @@ 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(() => ({
|
||||
@@ -67,78 +51,103 @@ describe('BillingContainer', () => {
|
||||
expect(currentBill).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('OnTrail', async () => {
|
||||
await act(async () => {
|
||||
render(<BillingContainer />, undefined, undefined, {
|
||||
trialInfo: licensesSuccessResponse.data,
|
||||
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,
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
const freeTrailText = await screen.findByText('Free Trial');
|
||||
expect(freeTrailText).toBeInTheDocument();
|
||||
|
||||
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 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,
|
||||
test('OnTrail but trialConvertedToSubscription', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{
|
||||
appContextOverrides: {
|
||||
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();
|
||||
});
|
||||
|
||||
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 />, undefined, undefined, {
|
||||
trialInfo: notOfTrailResponse.data,
|
||||
});
|
||||
const { findByText } = render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{
|
||||
appContextOverrides: {
|
||||
trialInfo: notOfTrailResponse.data,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const billingPeriodText = `Your current billing period is from ${getFormattedDate(
|
||||
billingSuccessResponse.data.billingPeriodStart,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -14,20 +13,6 @@ 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(),
|
||||
@@ -84,11 +69,11 @@ describe('Alert rule documentation redirection', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
renderResult = render(
|
||||
<MemoryRouter initialEntries={['/alerts/new']}>
|
||||
<Route path={ROUTES.ALERTS_NEW}>
|
||||
<CreateAlertPage />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
<CreateAlertPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: ROUTES.ALERTS_NEW,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,20 +15,6 @@ 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(() => ({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
.time-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
|
||||
.time-input-field {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
&: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);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import './TimeInput.scss';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface TimeInputProps {
|
||||
value?: string; // Format: "HH:MM:SS"
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function TimeInput({
|
||||
value = '00:00:00',
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: TimeInputProps): JSX.Element {
|
||||
const [hours, setHours] = useState('00');
|
||||
const [minutes, setMinutes] = useState('00');
|
||||
const [seconds, setSeconds] = useState('00');
|
||||
|
||||
// Parse initial value
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const timeParts = value.split(':');
|
||||
if (timeParts.length === 3) {
|
||||
setHours(timeParts[0]);
|
||||
setMinutes(timeParts[1]);
|
||||
setSeconds(timeParts[2]);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const notifyChange = (h: string, m: string, s: string): void => {
|
||||
const rawValue = `${h}:${m}:${s}`;
|
||||
onChange?.(rawValue);
|
||||
};
|
||||
|
||||
const notifyFormattedChange = (h: string, m: string, s: string): void => {
|
||||
const formattedValue = `${h.padStart(2, '0')}:${m.padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:${s.padStart(2, '0')}`;
|
||||
onChange?.(formattedValue);
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
setHours(newHours);
|
||||
notifyChange(newHours, minutes, seconds);
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
setMinutes(newMinutes);
|
||||
notifyChange(hours, newMinutes, seconds);
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
// Helper functions for field navigation
|
||||
const getNextField = (current: string): string => {
|
||||
switch (current) {
|
||||
case 'hours':
|
||||
return 'minutes';
|
||||
case 'minutes':
|
||||
return 'seconds';
|
||||
default:
|
||||
return 'hours';
|
||||
}
|
||||
};
|
||||
|
||||
const getPrevField = (current: string): string => {
|
||||
switch (current) {
|
||||
case 'seconds':
|
||||
return 'minutes';
|
||||
case 'minutes':
|
||||
return 'hours';
|
||||
default:
|
||||
return 'seconds';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle key navigation
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
currentField: 'hours' | 'minutes' | 'seconds',
|
||||
): void => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const nextField = document.querySelector(
|
||||
`[data-field="${getNextField(currentField)}"]`,
|
||||
) as HTMLInputElement;
|
||||
nextField?.focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prevField = document.querySelector(
|
||||
`[data-field="${getPrevField(currentField)}"]`,
|
||||
) as HTMLInputElement;
|
||||
prevField?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`time-input-container ${className}`}>
|
||||
<Input
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
TimeInput.defaultProps = {
|
||||
value: '00:00:00',
|
||||
onChange: undefined,
|
||||
disabled: false,
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default TimeInput;
|
||||
@@ -0,0 +1,3 @@
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
export default TimeInput;
|
||||
@@ -0,0 +1,241 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import TimeInput from '../TimeInput/TimeInput';
|
||||
|
||||
describe('TimeInput', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with default value', () => {
|
||||
render(<TimeInput />);
|
||||
|
||||
expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds
|
||||
});
|
||||
|
||||
it('should render with provided value', () => {
|
||||
render(<TimeInput value="12:34:56" />);
|
||||
|
||||
expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours
|
||||
expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes
|
||||
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
|
||||
});
|
||||
|
||||
it('should handle hours changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '12' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should handle minutes changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
fireEvent.change(minutesInput, { target: { value: '30' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:30:00');
|
||||
});
|
||||
|
||||
it('should handle seconds changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
fireEvent.change(secondsInput, { target: { value: '45' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
|
||||
});
|
||||
|
||||
it('should pad single digits with zeros on blur', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('should filter non-numeric characters', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '1a2b3c' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should limit input to 2 characters', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '123456' } });
|
||||
|
||||
expect(hoursInput).toHaveValue('12');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with ArrowRight', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with ArrowLeft', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(minutesInput);
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(hoursInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle Tab navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{Tab}');
|
||||
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should disable inputs when disabled prop is true', () => {
|
||||
render(<TimeInput disabled />);
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update internal state when value prop changes', () => {
|
||||
const { rerender } = render(<TimeInput value="01:02:03" />);
|
||||
|
||||
expect(screen.getByDisplayValue('01')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('02')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('03')).toBeInTheDocument();
|
||||
|
||||
rerender(<TimeInput value="04:05:06" />);
|
||||
|
||||
expect(screen.getByDisplayValue('04')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('05')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('06')).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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,656 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable import/first */
|
||||
|
||||
// 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';
|
||||
const FREQ_DAILY = 'FREQ=DAILY';
|
||||
const TEN_THIRTY_TIME = '10:30:00';
|
||||
const NINE_AM_TIME = '09:00:00';
|
||||
jest.mock('dayjs', () => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = jest.fn((date?: string | Date) => {
|
||||
if (date) {
|
||||
return originalDayjs(date);
|
||||
}
|
||||
return originalDayjs(MOCK_DATE_STRING);
|
||||
});
|
||||
Object.keys(originalDayjs).forEach((key) => {
|
||||
((mockDayjs as unknown) as Record<string, unknown>)[key] = originalDayjs[key];
|
||||
});
|
||||
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' },
|
||||
]),
|
||||
}));
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
describe('getEvaluationWindowTypeText', () => {
|
||||
it('should return correct text for rolling window', () => {
|
||||
expect(getEvaluationWindowTypeText('rolling')).toBe('Rolling');
|
||||
});
|
||||
|
||||
it('should return correct text for cumulative window', () => {
|
||||
expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative');
|
||||
});
|
||||
|
||||
it('should default to empty string for unknown type', () => {
|
||||
expect(
|
||||
getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
|
||||
it('should return correct text for current day', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText({
|
||||
...mockEvaluationWindowState,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
}),
|
||||
).toBe('Current day, starting from 00:00:00 (UTC)');
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
|
||||
it('should default to empty string for unknown timeframe', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText({
|
||||
...mockEvaluationWindowState,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'unknown',
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRollingWindowTimeframeText', () => {
|
||||
it('should return correct text for last 5 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_5_MINUTES),
|
||||
).toBe('Last 5 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 10 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_10_MINUTES),
|
||||
).toBe('Last 10 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 15 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_15_MINUTES),
|
||||
).toBe('Last 15 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 30 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_30_MINUTES),
|
||||
).toBe('Last 30 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 1 hour', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_1_HOUR),
|
||||
).toBe('Last 1 hour');
|
||||
});
|
||||
|
||||
it('should return correct text for last 2 hours', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_2_HOURS),
|
||||
).toBe('Last 2 hours');
|
||||
});
|
||||
|
||||
it('should return correct text for last 4 hours', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_4_HOURS),
|
||||
).toBe('Last 4 hours');
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeframeText', () => {
|
||||
it('should call getCustomRollingWindowTimeframeText for custom rolling window', () => {
|
||||
expect(
|
||||
getTimeframeText({
|
||||
...mockEvaluationWindowState,
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '4',
|
||||
},
|
||||
}),
|
||||
).toBe('Last 4 Minutes');
|
||||
});
|
||||
|
||||
it('should call getRollingWindowTimeframeText for rolling window', () => {
|
||||
expect(getTimeframeText(mockEvaluationWindowState)).toBe('Last 5 minutes');
|
||||
});
|
||||
|
||||
it('should call getCumulativeWindowTimeframeText for cumulative window', () => {
|
||||
expect(
|
||||
getTimeframeText({
|
||||
...mockEvaluationWindowState,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
}),
|
||||
).toBe('Current day, starting from 00:00:00 (UTC)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertScheduleFromRRule', () => {
|
||||
const mockRRule = {
|
||||
all: jest.fn((callback) => {
|
||||
const dates = [
|
||||
new Date(MOCK_DATE_STRING),
|
||||
new Date('2024-01-16T10:30:00Z'),
|
||||
new Date('2024-01-17T10:30:00Z'),
|
||||
];
|
||||
dates.forEach((date, index) => callback(date, index));
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(rrulestr as jest.Mock).mockReturnValue(mockRRule);
|
||||
});
|
||||
|
||||
it('should return null for empty rrule string', () => {
|
||||
const result = buildAlertScheduleFromRRule('', null, '10:30:00');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should build schedule from valid rrule string', () => {
|
||||
const result = buildAlertScheduleFromRRule(
|
||||
FREQ_DAILY,
|
||||
null,
|
||||
TEN_THIRTY_TIME,
|
||||
);
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
expect(result).toEqual([
|
||||
new Date(MOCK_DATE_STRING),
|
||||
new Date('2024-01-16T10:30:00Z'),
|
||||
new Date('2024-01-17T10:30:00Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle rrule with DTSTART', () => {
|
||||
const date = dayjs('2024-01-20');
|
||||
buildAlertScheduleFromRRule(FREQ_DAILY, date, NINE_AM_TIME);
|
||||
|
||||
// When date is provided, DTSTART is automatically added to the rrule string
|
||||
expect(rrulestr).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/DTSTART:20240120T\d{6}Z/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rrule without DTSTART', () => {
|
||||
// Test with no date provided - should use original rrule string
|
||||
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, NINE_AM_TIME);
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle escaped newlines', () => {
|
||||
buildAlertScheduleFromRRule('FREQ=DAILY\\nINTERVAL=1', null, '10:30:00');
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
|
||||
});
|
||||
|
||||
it('should limit occurrences to maxOccurrences', () => {
|
||||
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, '10:30:00', 2);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return null on error', () => {
|
||||
(rrulestr as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Invalid rrule');
|
||||
});
|
||||
|
||||
const result = buildAlertScheduleFromRRule('INVALID', null, '10:30:00');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertScheduleFromCustomSchedule', () => {
|
||||
it('should generate monthly occurrences', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'month',
|
||||
['1', '15'],
|
||||
'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',
|
||||
'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',
|
||||
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', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'week',
|
||||
['monday', 'friday'],
|
||||
'10:30:00',
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate weekly occurrences excluding today if alert time is in the past', () => {
|
||||
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'],
|
||||
'10:30:00',
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidRRule', () => {
|
||||
beforeEach(() => {
|
||||
(rrulestr as jest.Mock).mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should return true for valid rrule', () => {
|
||||
expect(isValidRRule(FREQ_DAILY)).toBe(true);
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
});
|
||||
|
||||
it('should handle escaped newlines', () => {
|
||||
expect(isValidRRule('FREQ=DAILY\\nINTERVAL=1')).toBe(true);
|
||||
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
|
||||
});
|
||||
|
||||
it('should return false for invalid rrule', () => {
|
||||
(rrulestr as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Invalid rrule');
|
||||
});
|
||||
|
||||
expect(isValidRRule('INVALID')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
|
||||
|
||||
export const EVALUATION_WINDOW_TYPE = [
|
||||
{ label: 'Rolling', value: 'rolling' },
|
||||
{ label: 'Cumulative', value: 'cumulative' },
|
||||
];
|
||||
|
||||
export const EVALUATION_WINDOW_TIMEFRAME = {
|
||||
rolling: [
|
||||
{ label: 'Last 5 minutes', value: '5m0s' },
|
||||
{ label: 'Last 10 minutes', value: '10m0s' },
|
||||
{ label: 'Last 15 minutes', value: '15m0s' },
|
||||
{ label: 'Last 30 minutes', value: '30m0s' },
|
||||
{ label: 'Last 1 hour', value: '1h0m0s' },
|
||||
{ label: 'Last 2 hours', value: '2h0m0s' },
|
||||
{ label: 'Last 4 hours', value: '4h0m0s' },
|
||||
],
|
||||
cumulative: [
|
||||
{ label: 'Current hour', value: 'currentHour' },
|
||||
{ label: 'Current day', value: 'currentDay' },
|
||||
{ label: 'Current month', value: 'currentMonth' },
|
||||
],
|
||||
};
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
|
||||
{ label: 'WEEK', value: 'week' },
|
||||
{ label: 'MONTH', value: 'month' },
|
||||
];
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [
|
||||
{ label: 'SUNDAY', value: 'sunday' },
|
||||
{ label: 'MONDAY', value: 'monday' },
|
||||
{ label: 'TUESDAY', value: 'tuesday' },
|
||||
{ label: 'WEDNESDAY', value: 'wednesday' },
|
||||
{ label: 'THURSDAY', value: 'thursday' },
|
||||
{ label: 'FRIDAY', value: 'friday' },
|
||||
{ label: 'SATURDAY', value: 'saturday' },
|
||||
];
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from(
|
||||
{ length: 31 },
|
||||
(_, i) => {
|
||||
const value = String(i + 1);
|
||||
return { label: value, value };
|
||||
},
|
||||
);
|
||||
|
||||
export const WEEKDAY_MAP: { [key: string]: number } = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
};
|
||||
|
||||
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
label: `${timezone.name} (${timezone.offset})`,
|
||||
value: timezone.value,
|
||||
}));
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
} from '../context/types';
|
||||
|
||||
export interface IAdvancedOptionItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
input: JSX.Element;
|
||||
}
|
||||
|
||||
export enum RollingWindowTimeframes {
|
||||
'LAST_5_MINUTES' = '5m0s',
|
||||
'LAST_10_MINUTES' = '10m0s',
|
||||
'LAST_15_MINUTES' = '15m0s',
|
||||
'LAST_30_MINUTES' = '30m0s',
|
||||
'LAST_1_HOUR' = '1h0m0s',
|
||||
'LAST_2_HOURS' = '2h0m0s',
|
||||
'LAST_4_HOURS' = '4h0m0s',
|
||||
}
|
||||
|
||||
export enum CumulativeWindowTimeframes {
|
||||
'CURRENT_HOUR' = 'currentHour',
|
||||
'CURRENT_DAY' = 'currentDay',
|
||||
'CURRENT_MONTH' = 'currentMonth',
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowPopoverProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowDetailsProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
}
|
||||
|
||||
export interface IEvaluationCadenceDetailsProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface TimeInputProps {
|
||||
value?: string; // Format: "HH:MM:SS"
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
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';
|
||||
|
||||
// Extend dayjs with timezone plugins
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export const getEvaluationWindowTypeText = (
|
||||
windowType: 'rolling' | 'cumulative',
|
||||
): string => {
|
||||
switch (windowType) {
|
||||
case 'rolling':
|
||||
return 'Rolling';
|
||||
case 'cumulative':
|
||||
return 'Cumulative';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getCumulativeWindowTimeframeText = (
|
||||
evaluationWindow: EvaluationWindowState,
|
||||
): string => {
|
||||
switch (evaluationWindow.timeframe) {
|
||||
case CumulativeWindowTimeframes.CURRENT_HOUR:
|
||||
return `Current hour, starting at minute ${evaluationWindow.startingAt.number} (${evaluationWindow.startingAt.timezone})`;
|
||||
case CumulativeWindowTimeframes.CURRENT_DAY:
|
||||
return `Current day, starting from ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
|
||||
case CumulativeWindowTimeframes.CURRENT_MONTH:
|
||||
return `Current month, starting from day ${evaluationWindow.startingAt.number} at ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getRollingWindowTimeframeText = (
|
||||
timeframe: RollingWindowTimeframes,
|
||||
): string => {
|
||||
switch (timeframe) {
|
||||
case RollingWindowTimeframes.LAST_5_MINUTES:
|
||||
return 'Last 5 minutes';
|
||||
case RollingWindowTimeframes.LAST_10_MINUTES:
|
||||
return 'Last 10 minutes';
|
||||
case RollingWindowTimeframes.LAST_15_MINUTES:
|
||||
return 'Last 15 minutes';
|
||||
case RollingWindowTimeframes.LAST_30_MINUTES:
|
||||
return 'Last 30 minutes';
|
||||
case RollingWindowTimeframes.LAST_1_HOUR:
|
||||
return 'Last 1 hour';
|
||||
case RollingWindowTimeframes.LAST_2_HOURS:
|
||||
return 'Last 2 hours';
|
||||
case RollingWindowTimeframes.LAST_4_HOURS:
|
||||
return 'Last 4 hours';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
): string => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
if (evaluationWindow.timeframe === 'custom') {
|
||||
return getCustomRollingWindowTimeframeText(evaluationWindow);
|
||||
}
|
||||
return getRollingWindowTimeframeText(
|
||||
evaluationWindow.timeframe as RollingWindowTimeframes,
|
||||
);
|
||||
}
|
||||
return getCumulativeWindowTimeframeText(evaluationWindow);
|
||||
};
|
||||
|
||||
export function buildAlertScheduleFromRRule(
|
||||
rruleString: string,
|
||||
date: Dayjs | null,
|
||||
startAt: string,
|
||||
maxOccurrences = 10,
|
||||
): Date[] | null {
|
||||
try {
|
||||
if (!rruleString) return null;
|
||||
|
||||
// Handle literal \n in string
|
||||
let finalRRuleString = rruleString.replace(/\\n/g, '\n');
|
||||
|
||||
if (date) {
|
||||
const dt = dayjs(date);
|
||||
if (!dt.isValid()) throw new Error('Invalid date provided');
|
||||
|
||||
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
|
||||
|
||||
const dtWithTime = dt
|
||||
.set('hour', hours)
|
||||
.set('minute', minutes)
|
||||
.set('second', seconds)
|
||||
.set('millisecond', 0);
|
||||
|
||||
const dtStartStr = dtWithTime
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
if (!/DTSTART/i.test(finalRRuleString)) {
|
||||
finalRRuleString = `DTSTART:${dtStartStr}\n${finalRRuleString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const rruleObj = rrulestr(finalRRuleString);
|
||||
const occurrences: Date[] = [];
|
||||
rruleObj.all((date, index) => {
|
||||
if (index >= maxOccurrences) return false;
|
||||
occurrences.push(date);
|
||||
return true;
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateMonthlyOccurrences(
|
||||
targetDays: number[],
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
maxOccurrences: number,
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const currentMonth = dayjs().startOf('month');
|
||||
|
||||
const currentDate = dayjs();
|
||||
|
||||
const scanMonths = maxOccurrences + 12;
|
||||
for (let monthOffset = 0; monthOffset < scanMonths; monthOffset++) {
|
||||
const monthDate = currentMonth.add(monthOffset, 'month');
|
||||
targetDays.forEach((day) => {
|
||||
if (occurrences.length >= maxOccurrences) return;
|
||||
|
||||
const daysInMonth = monthDate.daysInMonth();
|
||||
if (day <= daysInMonth) {
|
||||
const targetDate = monthDate
|
||||
.date(day)
|
||||
.hour(hours)
|
||||
.minute(minutes)
|
||||
.second(seconds);
|
||||
if (targetDate.isAfter(currentDate)) {
|
||||
occurrences.push(targetDate.toDate());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
function generateWeeklyOccurrences(
|
||||
targetWeekdays: number[],
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
maxOccurrences: number,
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const currentWeek = dayjs().startOf('week');
|
||||
|
||||
const currentDate = dayjs();
|
||||
|
||||
for (let weekOffset = 0; weekOffset < maxOccurrences; weekOffset++) {
|
||||
const weekDate = currentWeek.add(weekOffset, 'week');
|
||||
targetWeekdays.forEach((weekday) => {
|
||||
if (occurrences.length >= maxOccurrences) return;
|
||||
|
||||
const targetDate = weekDate
|
||||
.day(weekday)
|
||||
.hour(hours)
|
||||
.minute(minutes)
|
||||
.second(seconds);
|
||||
if (targetDate.isAfter(currentDate)) {
|
||||
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;
|
||||
}
|
||||
|
||||
export function buildAlertScheduleFromCustomSchedule(
|
||||
repeatEvery: string,
|
||||
occurence: string[],
|
||||
startAt: string,
|
||||
maxOccurrences = 10,
|
||||
): Date[] | null {
|
||||
try {
|
||||
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
|
||||
let occurrences: Date[] = [];
|
||||
|
||||
if (repeatEvery === 'month') {
|
||||
const targetDays = occurence
|
||||
.map((day) => parseInt(day, 10))
|
||||
.filter((day) => !Number.isNaN(day));
|
||||
occurrences = generateMonthlyOccurrences(
|
||||
targetDays,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
maxOccurrences,
|
||||
);
|
||||
} else if (repeatEvery === 'week') {
|
||||
const targetWeekdays = occurence
|
||||
.map((day) => WEEKDAY_MAP[day.toLowerCase()])
|
||||
.filter((day) => day !== undefined);
|
||||
occurrences = generateWeeklyOccurrences(
|
||||
targetWeekdays,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
maxOccurrences,
|
||||
);
|
||||
} else if (repeatEvery === 'day') {
|
||||
occurrences = generateDailyOccurrences(
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
maxOccurrences,
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidRRule(rruleString: string): boolean {
|
||||
try {
|
||||
// normalize escaped \n
|
||||
const finalRRuleString = rruleString.replace(/\\n/g, '\n');
|
||||
rrulestr(finalRRuleString); // will throw if invalid
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import getRandomColor from 'lib/getRandomColor';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
AlertThresholdState,
|
||||
Algorithm,
|
||||
EvaluationWindowState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
@@ -70,6 +75,49 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
|
||||
thresholds: [INITIAL_CRITICAL_THRESHOLD],
|
||||
};
|
||||
|
||||
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: 15,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: 0,
|
||||
},
|
||||
delayEvaluation: {
|
||||
delay: 5,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
evaluationCadence: {
|
||||
mode: 'default',
|
||||
default: {
|
||||
value: 1,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
custom: {
|
||||
repeatEvery: 'week',
|
||||
startAt: '00:00:00',
|
||||
occurence: [],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
rrule: {
|
||||
date: dayjs(),
|
||||
startAt: '00:00:00',
|
||||
rrule: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
|
||||
windowType: 'rolling',
|
||||
timeframe: '5m0s',
|
||||
startingAt: {
|
||||
time: '00:00:00',
|
||||
number: '1',
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
};
|
||||
|
||||
export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||
@@ -115,3 +163,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
|
||||
{ value: Seasonality.DAILY, label: 'Daily' },
|
||||
{ value: Seasonality.WEEKLY, label: 'Weekly' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
@@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
alertThresholdReducer,
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
getInitialAlertTypeFromURL,
|
||||
} from './utils';
|
||||
|
||||
@@ -80,6 +84,16 @@ export function CreateAlertProvider(
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
);
|
||||
|
||||
const [evaluationWindow, setEvaluationWindow] = useReducer(
|
||||
evaluationWindowReducer,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
);
|
||||
|
||||
const [advancedOptions, setAdvancedOptions] = useReducer(
|
||||
advancedOptionsReducer,
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
@@ -94,8 +108,19 @@ export function CreateAlertProvider(
|
||||
setAlertType: handleAlertTypeChange,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
}),
|
||||
[alertState, alertType, handleAlertTypeChange, thresholdState],
|
||||
[
|
||||
alertState,
|
||||
alertType,
|
||||
handleAlertTypeChange,
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { Dispatch } from 'react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
@@ -9,6 +10,10 @@ export interface ICreateAlertContextProps {
|
||||
setAlertType: Dispatch<AlertTypes>;
|
||||
thresholdState: AlertThresholdState;
|
||||
setThresholdState: Dispatch<AlertThresholdAction>;
|
||||
advancedOptions: AdvancedOptionsState;
|
||||
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
@@ -101,3 +106,87 @@ export type AlertThresholdAction =
|
||||
| { type: 'SET_SEASONALITY'; payload: string }
|
||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface AdvancedOptionsState {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: number;
|
||||
};
|
||||
delayEvaluation: {
|
||||
delay: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
evaluationCadence: {
|
||||
mode: EvaluationCadenceMode;
|
||||
default: {
|
||||
value: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
custom: {
|
||||
repeatEvery: string;
|
||||
startAt: string;
|
||||
occurence: string[];
|
||||
timezone: string;
|
||||
};
|
||||
rrule: {
|
||||
date: Dayjs | null;
|
||||
startAt: string;
|
||||
rrule: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type AdvancedOptionsAction =
|
||||
| {
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||
payload: { toleranceLimit: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
|
||||
payload: { minimumDatapoints: number };
|
||||
}
|
||||
| {
|
||||
type: 'SET_DELAY_EVALUATION';
|
||||
payload: { delay: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'SET_EVALUATION_CADENCE';
|
||||
payload: {
|
||||
default: { value: number; timeUnit: string };
|
||||
custom: {
|
||||
repeatEvery: string;
|
||||
startAt: string;
|
||||
timezone: string;
|
||||
occurence: string[];
|
||||
};
|
||||
rrule: { date: Dayjs | null; startAt: string; rrule: string };
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface EvaluationWindowState {
|
||||
windowType: 'rolling' | 'cumulative';
|
||||
timeframe: string;
|
||||
startingAt: {
|
||||
time: string;
|
||||
number: string;
|
||||
timezone: string;
|
||||
unit: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type EvaluationWindowAction =
|
||||
| { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' }
|
||||
| { type: 'SET_TIMEFRAME'; payload: string }
|
||||
| {
|
||||
type: 'SET_STARTING_AT';
|
||||
payload: { time: string; number: string; timezone: string; unit: string };
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
|
||||
@@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdAction,
|
||||
AlertThresholdState,
|
||||
CreateAlertAction,
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
} from './types';
|
||||
|
||||
export const alertCreationReducer = (
|
||||
@@ -110,3 +118,57 @@ export const alertThresholdReducer = (
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const advancedOptionsReducer = (
|
||||
state: AdvancedOptionsState,
|
||||
action: AdvancedOptionsAction,
|
||||
): AdvancedOptionsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
|
||||
return { ...state, sendNotificationIfDataIsMissing: action.payload };
|
||||
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
|
||||
return { ...state, enforceMinimumDatapoints: action.payload };
|
||||
case 'SET_DELAY_EVALUATION':
|
||||
return { ...state, delayEvaluation: action.payload };
|
||||
case 'SET_EVALUATION_CADENCE':
|
||||
return {
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, ...action.payload },
|
||||
};
|
||||
case 'SET_EVALUATION_CADENCE_MODE':
|
||||
return {
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_ADVANCED_OPTIONS_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluationWindowReducer = (
|
||||
state: EvaluationWindowState,
|
||||
action: EvaluationWindowAction,
|
||||
): EvaluationWindowState => {
|
||||
switch (action.type) {
|
||||
case 'SET_WINDOW_TYPE':
|
||||
return {
|
||||
...state,
|
||||
windowType: action.payload,
|
||||
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
timeframe:
|
||||
action.payload === 'rolling'
|
||||
? INITIAL_EVALUATION_WINDOW_STATE.timeframe
|
||||
: 'currentHour',
|
||||
};
|
||||
case 'SET_TIMEFRAME':
|
||||
return { ...state, timeframe: action.payload };
|
||||
case 'SET_STARTING_AT':
|
||||
return { ...state, startingAt: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,14 +111,16 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
value: errorDetail[key as keyof GetByErrorTypeAndServicePayload],
|
||||
}));
|
||||
|
||||
const onClickTraceHandler = (): void => {
|
||||
const onClickTraceHandler = (event?: React.MouseEvent): void => {
|
||||
logEvent('Exception: Navigate to trace detail page', {
|
||||
groupId: errorDetail?.groupID,
|
||||
spanId: errorDetail.spanID,
|
||||
traceId: errorDetail.traceID,
|
||||
exceptionId: errorDetail?.errorId,
|
||||
});
|
||||
history.push(`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`);
|
||||
const path = `/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`;
|
||||
|
||||
history.push(path, event);
|
||||
};
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -185,7 +187,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
|
||||
<DashedContainer>
|
||||
<Typography>{t('see_trace_graph')}</Typography>
|
||||
<Button onClick={onClickTraceHandler} type="primary">
|
||||
<Button onClick={(e): void => onClickTraceHandler(e)} type="primary">
|
||||
{t('see_error_in_trace_graph')}
|
||||
</Button>
|
||||
</DashedContainer>
|
||||
|
||||
@@ -190,7 +190,7 @@ function ExplorerOptions({
|
||||
);
|
||||
|
||||
const onCreateAlertsHandler = useCallback(
|
||||
(defaultQuery: Query | null) => {
|
||||
(defaultQuery: Query | null, event?: React.MouseEvent) => {
|
||||
if (sourcepage === DataSource.TRACES) {
|
||||
logEvent('Traces Explorer: Create alert', {
|
||||
panelType,
|
||||
@@ -209,11 +209,20 @@ function ExplorerOptions({
|
||||
|
||||
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
|
||||
|
||||
history.push(
|
||||
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
stringifiedQuery,
|
||||
)}`,
|
||||
);
|
||||
if (event) {
|
||||
history.push(
|
||||
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
stringifiedQuery,
|
||||
)}`,
|
||||
event,
|
||||
);
|
||||
} else {
|
||||
history.push(
|
||||
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
stringifiedQuery,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[handleConditionalQueryModification, history],
|
||||
@@ -726,7 +735,7 @@ function ExplorerOptions({
|
||||
<Button
|
||||
disabled={disabled}
|
||||
shape="round"
|
||||
onClick={(): void => onCreateAlertsHandler(query)}
|
||||
onClick={(e: React.MouseEvent): void => onCreateAlertsHandler(query, e)}
|
||||
icon={<ConciergeBell size={16} />}
|
||||
>
|
||||
Create an Alert
|
||||
|
||||
@@ -9,8 +9,8 @@ export default function FullScreenHeader({
|
||||
}: {
|
||||
overrideRoute?: string;
|
||||
}): React.ReactElement {
|
||||
const handleLogoClick = (): void => {
|
||||
history.push(overrideRoute || '/');
|
||||
const handleLogoClick = (event?: React.MouseEvent): void => {
|
||||
history.push(overrideRoute || '/', event);
|
||||
};
|
||||
return (
|
||||
<div className="full-screen-header-container">
|
||||
|
||||
@@ -30,20 +30,6 @@ 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: {
|
||||
|
||||
@@ -114,7 +114,10 @@ export default function AlertRules({
|
||||
</div>
|
||||
);
|
||||
|
||||
const onEditHandler = (record: GettableAlert) => (): void => {
|
||||
const onEditHandler = (
|
||||
record: GettableAlert,
|
||||
event?: React.MouseEvent | React.KeyboardEvent,
|
||||
) => (): void => {
|
||||
logEvent('Homepage: Alert clicked', {
|
||||
ruleId: record.id,
|
||||
ruleName: record.alert,
|
||||
@@ -131,7 +134,9 @@ export default function AlertRules({
|
||||
|
||||
params.set(QueryParams.ruleId, record.id.toString());
|
||||
|
||||
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
const path = `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;
|
||||
|
||||
history.push(path, event);
|
||||
};
|
||||
|
||||
const renderAlertRules = (): JSX.Element => (
|
||||
@@ -143,10 +148,10 @@ export default function AlertRules({
|
||||
tabIndex={0}
|
||||
className="alert-rule-item home-data-item"
|
||||
key={rule.id}
|
||||
onClick={onEditHandler(rule)}
|
||||
onClick={(e): void => onEditHandler(rule, e)()}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
onEditHandler(rule);
|
||||
onEditHandler(rule, e)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -84,14 +84,14 @@ function DataSourceInfo({
|
||||
icon={<img src="/Icons/container-plus.svg" alt="plus" />}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Homepage: Connect dataSource clicked', {});
|
||||
|
||||
if (
|
||||
activeLicense &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD, e);
|
||||
} else {
|
||||
window?.open(
|
||||
DOCS_LINKS.ADD_DATA_SOURCE,
|
||||
|
||||
@@ -413,12 +413,12 @@ export default function Home(): JSX.Element {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="active-ingestion-card-actions"
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Logs',
|
||||
});
|
||||
history.push(ROUTES.LOGS_EXPLORER);
|
||||
history.push(ROUTES.LOGS_EXPLORER, e);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -455,11 +455,11 @@ export default function Home(): JSX.Element {
|
||||
className="active-ingestion-card-actions"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Traces',
|
||||
});
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
history.push(ROUTES.TRACES_EXPLORER, e);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -496,11 +496,11 @@ export default function Home(): JSX.Element {
|
||||
className="active-ingestion-card-actions"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
history.push(ROUTES.METRICS_EXPLORER);
|
||||
history.push(ROUTES.METRICS_EXPLORER, e);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -550,11 +550,11 @@ export default function Home(): JSX.Element {
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<Wrench size={14} />}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Logs',
|
||||
});
|
||||
history.push(ROUTES.LOGS_EXPLORER);
|
||||
history.push(ROUTES.LOGS_EXPLORER, e);
|
||||
}}
|
||||
>
|
||||
Open Logs Explorer
|
||||
@@ -564,11 +564,11 @@ export default function Home(): JSX.Element {
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<Wrench size={14} />}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Traces',
|
||||
});
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
history.push(ROUTES.TRACES_EXPLORER, e);
|
||||
}}
|
||||
>
|
||||
Open Traces Explorer
|
||||
@@ -578,11 +578,11 @@ export default function Home(): JSX.Element {
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<Wrench size={14} />}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
history.push(ROUTES.METRICS_EXPLORER_EXPLORER);
|
||||
history.push(ROUTES.METRICS_EXPLORER_EXPLORER, e);
|
||||
}}
|
||||
>
|
||||
Open Metrics Explorer
|
||||
@@ -619,11 +619,11 @@ export default function Home(): JSX.Element {
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Dashboards',
|
||||
});
|
||||
history.push(ROUTES.ALL_DASHBOARD);
|
||||
history.push(ROUTES.ALL_DASHBOARD, e);
|
||||
}}
|
||||
>
|
||||
Create dashboard
|
||||
@@ -661,11 +661,11 @@ export default function Home(): JSX.Element {
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Alerts',
|
||||
});
|
||||
history.push(ROUTES.ALERTS_NEW);
|
||||
history.push(ROUTES.ALERTS_NEW, e);
|
||||
}}
|
||||
>
|
||||
Create an alert
|
||||
|
||||
@@ -86,18 +86,18 @@ function HomeChecklist({
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Welcome Checklist: Get started clicked', {
|
||||
step: item.id,
|
||||
});
|
||||
|
||||
if (item.toRoute !== ROUTES.GET_STARTED_WITH_CLOUD) {
|
||||
history.push(item.toRoute || '');
|
||||
history.push(item.toRoute || '', e);
|
||||
} else if (
|
||||
activeLicense &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
history.push(item.toRoute || '');
|
||||
history.push(item.toRoute || '', e);
|
||||
} else {
|
||||
window?.open(
|
||||
item.docsLink || '',
|
||||
|
||||
@@ -116,7 +116,7 @@ const ServicesListTable = memo(
|
||||
onRowClick,
|
||||
}: {
|
||||
services: ServicesList[];
|
||||
onRowClick: (record: ServicesList) => void;
|
||||
onRowClick: (record: ServicesList, event: React.MouseEvent) => void;
|
||||
}): JSX.Element => (
|
||||
<div className="services-list-container home-data-item-container metrics-services-list">
|
||||
<div className="services-list">
|
||||
@@ -125,8 +125,8 @@ const ServicesListTable = memo(
|
||||
dataSource={services}
|
||||
pagination={false}
|
||||
className="services-table"
|
||||
onRow={(record): { onClick: () => void } => ({
|
||||
onClick: (): void => onRowClick(record),
|
||||
onRow={(record): { onClick: (event: React.MouseEvent) => void } => ({
|
||||
onClick: (event: React.MouseEvent): void => onRowClick(record, event),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@@ -284,11 +284,12 @@ function ServiceMetrics({
|
||||
}, [onUpdateChecklistDoneItem, loadingUserPreferences, servicesExist]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(record: ServicesList) => {
|
||||
(record: ServicesList, event: React.MouseEvent) => {
|
||||
logEvent('Homepage: Service clicked', {
|
||||
serviceName: record.serviceName,
|
||||
});
|
||||
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
|
||||
|
||||
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`, event);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
@@ -172,13 +172,13 @@ export default function ServiceTraces({
|
||||
dataSource={top5Services}
|
||||
pagination={false}
|
||||
className="services-table"
|
||||
onRow={(record): { onClick: () => void } => ({
|
||||
onClick: (): void => {
|
||||
onRow={(record): { onClick: (e: React.MouseEvent) => void } => ({
|
||||
onClick: (e: React.MouseEvent): void => {
|
||||
logEvent('Homepage: Service clicked', {
|
||||
serviceName: record.serviceName,
|
||||
});
|
||||
|
||||
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
|
||||
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`, e);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -33,19 +33,6 @@ 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 => ({
|
||||
|
||||
@@ -3,20 +3,6 @@ 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', () => {
|
||||
|
||||
@@ -4,20 +4,6 @@ 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', () => {
|
||||
|
||||
@@ -44,20 +44,6 @@ 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',
|
||||
() =>
|
||||
|
||||
@@ -4,14 +4,8 @@ import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
describe('JobDetails', () => {
|
||||
const mockJob = {
|
||||
@@ -24,13 +18,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const jobNameElements = screen.getAllByText('test-job');
|
||||
@@ -44,13 +32,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
@@ -68,13 +50,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
@@ -83,13 +59,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
@@ -100,13 +70,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
|
||||
@@ -56,19 +56,6 @@ const setupCommonMocks = (): void => {
|
||||
useNavigationType: (): any => 'PUSH',
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
get: jest.fn(),
|
||||
has: jest.fn(),
|
||||
entries: jest.fn(() => []),
|
||||
append: jest.fn(),
|
||||
toString: jest.fn(() => ''),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
|
||||
@@ -36,9 +36,9 @@ export function AlertsEmptyState(): JSX.Element {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onClickNewAlertHandler = useCallback(() => {
|
||||
const onClickNewAlertHandler = useCallback((e: React.MouseEvent) => {
|
||||
setLoading(false);
|
||||
history.push(ROUTES.ALERTS_NEW);
|
||||
history.push(ROUTES.ALERTS_NEW, e);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -70,7 +70,7 @@ export function AlertsEmptyState(): JSX.Element {
|
||||
<div className="action-container">
|
||||
<Button
|
||||
className="add-alert-btn"
|
||||
onClick={onClickNewAlertHandler}
|
||||
onClick={(e: React.MouseEvent): void => onClickNewAlertHandler(e)}
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!addNewAlert}
|
||||
loading={loading}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
// Had to add event to the props because we need to pass the event to the onCreateNewDashboard function
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
import { Button, Input, Modal, Typography } from 'antd';
|
||||
@@ -118,7 +120,7 @@ const templatesList: DashboardTemplate[] = [
|
||||
|
||||
interface DashboardTemplatesModalProps {
|
||||
showNewDashboardTemplatesModal: boolean;
|
||||
onCreateNewDashboard: () => void;
|
||||
onCreateNewDashboard: (event: React.MouseEvent) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -204,7 +206,9 @@ export default function DashboardTemplatesModal({
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
onClick={(event: React.MouseEvent): void =>
|
||||
onCreateNewDashboard(event)
|
||||
}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
|
||||
@@ -282,35 +282,39 @@ function DashboardsList(): JSX.Element {
|
||||
refetchDashboardList,
|
||||
})) || [];
|
||||
|
||||
const onNewDashboardHandler = useCallback(async () => {
|
||||
try {
|
||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||
setNewDashboardState({
|
||||
...newDashboardState,
|
||||
loading: true,
|
||||
});
|
||||
const response = await createDashboard({
|
||||
title: t('new_dashboard_title', {
|
||||
ns: 'dashboard',
|
||||
}),
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
const onNewDashboardHandler = useCallback(
|
||||
async (event: React.MouseEvent) => {
|
||||
try {
|
||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||
setNewDashboardState({
|
||||
...newDashboardState,
|
||||
loading: true,
|
||||
});
|
||||
const response = await createDashboard({
|
||||
title: t('new_dashboard_title', {
|
||||
ns: 'dashboard',
|
||||
}),
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, {
|
||||
dashboardId: response.data.id,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setNewDashboardState({
|
||||
...newDashboardState,
|
||||
error: true,
|
||||
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
|
||||
});
|
||||
}
|
||||
}, [newDashboardState, safeNavigate, showErrorModal, t]);
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, {
|
||||
dashboardId: response.data.id,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setNewDashboardState({
|
||||
...newDashboardState,
|
||||
error: true,
|
||||
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
|
||||
});
|
||||
}
|
||||
},
|
||||
[newDashboardState, safeNavigate, showErrorModal, t],
|
||||
);
|
||||
|
||||
const onModalHandler = (uploadedGrafana: boolean): void => {
|
||||
logEvent('Dashboard List: Import JSON clicked', {});
|
||||
@@ -639,8 +643,8 @@ function DashboardsList(): JSX.Element {
|
||||
label: (
|
||||
<div
|
||||
className="create-dashboard-menu-item"
|
||||
onClick={(): void => {
|
||||
onNewDashboardHandler();
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
onNewDashboardHandler(e);
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={14} /> Create dashboard
|
||||
@@ -927,7 +931,9 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
<DashboardTemplatesModal
|
||||
showNewDashboardTemplatesModal={showNewDashboardTemplatesModal}
|
||||
onCreateNewDashboard={onNewDashboardHandler}
|
||||
onCreateNewDashboard={(e: React.MouseEvent): Promise<void> =>
|
||||
onNewDashboardHandler(e)
|
||||
}
|
||||
onCancel={(): void => {
|
||||
setShowNewDashboardTemplatesModal(false);
|
||||
}}
|
||||
|
||||
@@ -56,13 +56,11 @@ function SearchFilter({
|
||||
|
||||
const updateURLWithQuery = useCallback(
|
||||
(inputQueries?: IQueryStructure[]): void => {
|
||||
history.push({
|
||||
pathname: history.location.pathname,
|
||||
search:
|
||||
inputQueries || queries
|
||||
? `?search=${convertQueriesToURLQuery(inputQueries || queries)}`
|
||||
: '',
|
||||
});
|
||||
const searchQuery =
|
||||
inputQueries || queries
|
||||
? `?search=${convertQueriesToURLQuery(inputQueries || queries)}`
|
||||
: '';
|
||||
history.push(`${history.location.pathname}${searchQuery}`);
|
||||
},
|
||||
[queries],
|
||||
);
|
||||
@@ -149,10 +147,7 @@ function SearchFilter({
|
||||
|
||||
const clearQueries = (): void => {
|
||||
setQueries([]);
|
||||
history.push({
|
||||
pathname: history.location.pathname,
|
||||
search: ``,
|
||||
});
|
||||
history.push(history.location.pathname);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,20 +32,6 @@ import {
|
||||
// Mock the useContextLogData hook
|
||||
const mockHandleRunQuery = jest.fn();
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('container/OptionsMenu', () => ({
|
||||
useOptionsMenu: (): any => ({
|
||||
options: {
|
||||
|
||||
@@ -302,8 +302,8 @@ function Login({
|
||||
<Typography.Paragraph italic style={{ color: '#ACACAC' }}>
|
||||
If you are admin,{' '}
|
||||
<Typography.Link
|
||||
onClick={(): void => {
|
||||
history.push(ROUTES.SIGN_UP);
|
||||
onClick={(e): void => {
|
||||
history.push(ROUTES.SIGN_UP, e);
|
||||
}}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
|
||||
@@ -73,20 +73,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
|
||||
__esModule: true,
|
||||
useGetExplorerQueryRange: jest.fn(),
|
||||
|
||||
@@ -7,18 +7,16 @@ import { logsresponse } from 'mocks-server/__mockdata__/query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import LogsExplorer from 'pages/LogsExplorer';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import React from 'react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import i18n from 'ReactI18';
|
||||
import {
|
||||
act,
|
||||
AllTheProviders,
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -91,20 +89,6 @@ getStateSpy.mockImplementation(() => {
|
||||
return originalState;
|
||||
});
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { search: string; pathname: string } => ({
|
||||
@@ -277,9 +261,7 @@ describe.skip('LogsExplorerViews Pagination', () => {
|
||||
act(() => {
|
||||
renderResult = render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight }}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<LogsExplorer />
|
||||
</I18nextProvider>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>,
|
||||
);
|
||||
});
|
||||
@@ -453,13 +435,14 @@ function LogsExplorerWithMockContext({
|
||||
);
|
||||
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<QueryBuilderContext.Provider value={contextValue as any}>
|
||||
<VirtuosoMockContext.Provider value={virtuosoContextValue}>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</QueryBuilderContext.Provider>
|
||||
</MemoryRouter>
|
||||
<AllTheProviders
|
||||
queryBuilderOverrides={contextValue as any}
|
||||
initialRoute="/logs"
|
||||
>
|
||||
<VirtuosoMockContext.Provider value={virtuosoContextValue}>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</AllTheProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -536,13 +519,12 @@ describe('Logs Explorer -> stage and run query', () => {
|
||||
const initialEnd = initialPayload.end;
|
||||
|
||||
// Click the Stage & Run Query button
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /stage & run query/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /stage & run query/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for additional API calls to be made after clicking Stage & Run Query
|
||||
await waitFor(
|
||||
|
||||
@@ -33,20 +33,6 @@ const lodsQueryServerRequest = (): void =>
|
||||
),
|
||||
);
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// mocking the graph components in this test as this should be handled separately
|
||||
jest.mock(
|
||||
'container/TimeSeriesView/TimeSeriesView',
|
||||
|
||||
@@ -18,10 +18,6 @@ const MOCK_SEARCH_PARAMS =
|
||||
'?graphType=list&widgetId=36a7b342-c642-4b92-abe4-cb833a244786&compositeQuery=%7B%22id%22%3A%22b325ac88-5e75-4117-a38c-1a2a7caf8115%22%2C%22builder%22%3A%7B%22queryData%22%3A%5B%7B%22dataSource%22%3A%22logs%22%2C%22queryName%22%3A%22A%22%2C%22aggregateOperator%22%3A%22noop%22%2C%22aggregateAttribute%22%3A%7B%22id%22%3A%22------%22%2C%22dataType%22%3A%22%22%2C%22key%22%3A%22%22%2C%22isColumn%22%3Afalse%2C%22type%22%3A%22%22%2C%22isJSON%22%3Afalse%7D%2C%22timeAggregation%22%3A%22rate%22%2C%22spaceAggregation%22%3A%22sum%22%2C%22functions%22%3A%5B%5D%2C%22filters%22%3A%7B%22items%22%3A%5B%5D%2C%22op%22%3A%22AND%22%7D%2C%22expression%22%3A%22A%22%2C%22disabled%22%3Afalse%2C%22stepInterval%22%3A60%2C%22having%22%3A%5B%5D%2C%22limit%22%3Anull%2C%22orderBy%22%3A%5B%7B%22columnName%22%3A%22timestamp%22%2C%22order%22%3A%22desc%22%7D%5D%2C%22groupBy%22%3A%5B%5D%2C%22legend%22%3A%22%22%2C%22reduceTo%22%3A%22avg%22%2C%22offset%22%3A0%2C%22pageSize%22%3A100%7D%5D%2C%22queryFormulas%22%3A%5B%5D%7D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22%22%7D%5D%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22queryType%22%3A%22builder%22%7D&relativeTime=30m&options=%7B%22selectColumns%22%3A%5B%5D%2C%22maxLines%22%3A2%2C%22format%22%3A%22list%22%2C%22fontSize%22%3A%22small%22%7D';
|
||||
|
||||
// Mocks
|
||||
jest.mock('uplot', () => ({
|
||||
paths: { spline: jest.fn(), bars: jest.fn() },
|
||||
default: jest.fn(() => ({ paths: { spline: jest.fn(), bars: jest.fn() } })),
|
||||
}));
|
||||
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
|
||||
@@ -235,7 +235,9 @@ function Application(): JSX.Element {
|
||||
timestamp: number,
|
||||
apmToTraceQuery: Query,
|
||||
isViewLogsClicked?: boolean,
|
||||
e?: React.MouseEvent,
|
||||
): (() => void) => (): void => {
|
||||
// eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const endTime = secondsToMilliseconds(timestamp);
|
||||
const startTime = secondsToMilliseconds(timestamp - stepInterval);
|
||||
|
||||
@@ -259,7 +261,7 @@ function Application(): JSX.Element {
|
||||
queryString,
|
||||
);
|
||||
|
||||
history.push(newPath);
|
||||
history.push(newPath, e);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stepInterval],
|
||||
@@ -299,6 +301,28 @@ function Application(): JSX.Element {
|
||||
});
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleViewTracesClick = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
event: e,
|
||||
})();
|
||||
},
|
||||
[
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={24}>
|
||||
@@ -319,14 +343,7 @@ function Application(): JSX.Element {
|
||||
type="default"
|
||||
size="small"
|
||||
id="Rate_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onClick={handleViewTracesClick}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
@@ -349,14 +366,7 @@ function Application(): JSX.Element {
|
||||
type="default"
|
||||
size="small"
|
||||
id="ApDex_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onClick={handleViewTracesClick}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
@@ -370,15 +380,12 @@ function Application(): JSX.Element {
|
||||
<ColErrorContainer>
|
||||
<GraphControlsPanel
|
||||
id="Error_button"
|
||||
onViewLogsClick={onErrorTrackHandler(
|
||||
selectedTimeStamp,
|
||||
logErrorQuery,
|
||||
true,
|
||||
)}
|
||||
onViewTracesClick={onErrorTrackHandler(
|
||||
selectedTimeStamp,
|
||||
errorTrackQuery,
|
||||
)}
|
||||
onViewLogsClick={(e: React.MouseEvent): void =>
|
||||
onErrorTrackHandler(selectedTimeStamp, logErrorQuery, undefined, e)()
|
||||
}
|
||||
onViewTracesClick={(e: React.MouseEvent): void =>
|
||||
onErrorTrackHandler(selectedTimeStamp, errorTrackQuery, undefined, e)()
|
||||
}
|
||||
/>
|
||||
|
||||
<TopLevelOperation
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Binoculars, DraftingCompass, ScrollText } from 'lucide-react';
|
||||
|
||||
interface GraphControlsPanelProps {
|
||||
id: string;
|
||||
onViewLogsClick?: () => void;
|
||||
onViewTracesClick: () => void;
|
||||
onViewAPIMonitoringClick?: () => void;
|
||||
onViewLogsClick?: (e: React.MouseEvent) => void;
|
||||
onViewTracesClick: (e: React.MouseEvent) => void;
|
||||
onViewAPIMonitoringClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function GraphControlsPanel({
|
||||
@@ -23,7 +23,7 @@ function GraphControlsPanel({
|
||||
type="link"
|
||||
icon={<DraftingCompass size={14} />}
|
||||
size="small"
|
||||
onClick={onViewTracesClick}
|
||||
onClick={(e: React.MouseEvent): void => onViewTracesClick(e)}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View traces
|
||||
@@ -33,7 +33,7 @@ function GraphControlsPanel({
|
||||
type="link"
|
||||
icon={<ScrollText size={14} />}
|
||||
size="small"
|
||||
onClick={onViewLogsClick}
|
||||
onClick={(e: React.MouseEvent): void => onViewLogsClick(e)}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View logs
|
||||
@@ -44,7 +44,7 @@ function GraphControlsPanel({
|
||||
type="link"
|
||||
icon={<Binoculars size={14} />}
|
||||
size="small"
|
||||
onClick={onViewAPIMonitoringClick}
|
||||
onClick={(e: React.MouseEvent): void => onViewAPIMonitoringClick(e)}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View External APIs
|
||||
|
||||
@@ -103,23 +103,29 @@ function ServiceOverview({
|
||||
<>
|
||||
<GraphControlsPanel
|
||||
id="Service_button"
|
||||
onViewLogsClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery: apmToLogQuery,
|
||||
isViewLogsClicked: true,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewLogsClick={(e: React.MouseEvent): void => {
|
||||
onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery: apmToLogQuery,
|
||||
isViewLogsClicked: true,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
event: e,
|
||||
})();
|
||||
}}
|
||||
onViewTracesClick={(e: React.MouseEvent): void => {
|
||||
onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
event: e,
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<Card data-testid="service_latency">
|
||||
<GraphContainer>
|
||||
|
||||
@@ -42,7 +42,8 @@ interface OnViewTracePopupClickProps {
|
||||
apmToTraceQuery: Query;
|
||||
isViewLogsClicked?: boolean;
|
||||
stepInterval?: number;
|
||||
safeNavigate: (url: string) => void;
|
||||
safeNavigate: (url: string, event?: React.MouseEvent) => void;
|
||||
event?: React.MouseEvent;
|
||||
}
|
||||
|
||||
interface OnViewAPIMonitoringPopupClickProps {
|
||||
@@ -51,8 +52,8 @@ interface OnViewAPIMonitoringPopupClickProps {
|
||||
stepInterval?: number;
|
||||
domainName: string;
|
||||
isError: boolean;
|
||||
|
||||
safeNavigate: (url: string) => void;
|
||||
event?: React.MouseEvent;
|
||||
safeNavigate: (url: string, event?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function generateExplorerPath(
|
||||
@@ -83,7 +84,7 @@ export function generateExplorerPath(
|
||||
* @param isViewLogsClicked - Whether this is for viewing logs vs traces
|
||||
* @param stepInterval - Time interval in seconds
|
||||
* @param safeNavigate - Navigation function
|
||||
|
||||
* @param event - Event object
|
||||
*/
|
||||
export function onViewTracePopupClick({
|
||||
selectedTraceTags,
|
||||
@@ -93,6 +94,7 @@ export function onViewTracePopupClick({
|
||||
isViewLogsClicked,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
event,
|
||||
}: OnViewTracePopupClickProps): VoidFunction {
|
||||
return (): void => {
|
||||
const endTime = secondsToMilliseconds(timestamp);
|
||||
@@ -118,7 +120,11 @@ export function onViewTracePopupClick({
|
||||
queryString,
|
||||
);
|
||||
|
||||
safeNavigate(newPath);
|
||||
if (event) {
|
||||
safeNavigate(newPath, event);
|
||||
} else {
|
||||
safeNavigate(newPath);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +155,7 @@ export function onViewAPIMonitoringPopupClick({
|
||||
isError,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
event,
|
||||
}: OnViewAPIMonitoringPopupClickProps): VoidFunction {
|
||||
return (): void => {
|
||||
const endTime = timestamp + (stepInterval || 60);
|
||||
@@ -190,7 +197,11 @@ export function onViewAPIMonitoringPopupClick({
|
||||
filters,
|
||||
);
|
||||
|
||||
safeNavigate(newPath);
|
||||
if (event) {
|
||||
safeNavigate(newPath, event);
|
||||
} else {
|
||||
safeNavigate(newPath);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,19 +68,6 @@ jest.mock('hooks/useNotifications', () => ({
|
||||
},
|
||||
}),
|
||||
}));
|
||||
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 => ({
|
||||
|
||||
@@ -15,12 +15,6 @@ import {
|
||||
TimeAggregationOptions,
|
||||
} from '../types';
|
||||
|
||||
jest.mock('uplot', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
destroy: jest.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
const mockResizeObserver = jest.fn();
|
||||
mockResizeObserver.mockImplementation(() => ({
|
||||
observe: (): void => undefined,
|
||||
|
||||
@@ -76,12 +76,6 @@ jest
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
jest.mock('uplot', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
destroy: jest.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import * as useSafeNavigate from 'hooks/useSafeNavigate';
|
||||
|
||||
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
|
||||
|
||||
@@ -24,9 +23,11 @@ const mockAlerts = [mockAlert1, mockAlert2];
|
||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.spyOn(useSafeNavigate, 'useSafeNavigate').mockReturnValue({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
});
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
|
||||
@@ -11,19 +11,6 @@ import store from 'store';
|
||||
import Summary from '../Summary';
|
||||
import { TreemapViewType } from '../types';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
jest.mock('d3-hierarchy', () => ({
|
||||
stratify: jest.fn().mockReturnValue({
|
||||
id: jest.fn().mockReturnValue({
|
||||
|
||||
@@ -21,17 +21,25 @@ function NewExplorerCTA(): JSX.Element | null {
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
const onClickHandler = useCallback((): void => {
|
||||
if (location.pathname === ROUTES.LOGS_EXPLORER) {
|
||||
history.push(ROUTES.OLD_LOGS_EXPLORER);
|
||||
} else if (location.pathname === ROUTES.TRACE) {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
} else if (location.pathname === ROUTES.OLD_LOGS_EXPLORER) {
|
||||
history.push(ROUTES.LOGS_EXPLORER);
|
||||
} else if (location.pathname === ROUTES.TRACES_EXPLORER) {
|
||||
history.push(ROUTES.TRACE);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
const onClickHandler = useCallback(
|
||||
(event?: React.MouseEvent): void => {
|
||||
let targetRoute = '';
|
||||
if (location.pathname === ROUTES.LOGS_EXPLORER) {
|
||||
targetRoute = ROUTES.OLD_LOGS_EXPLORER;
|
||||
} else if (location.pathname === ROUTES.TRACE) {
|
||||
targetRoute = ROUTES.TRACES_EXPLORER;
|
||||
} else if (location.pathname === ROUTES.OLD_LOGS_EXPLORER) {
|
||||
targetRoute = ROUTES.LOGS_EXPLORER;
|
||||
} else if (location.pathname === ROUTES.TRACES_EXPLORER) {
|
||||
targetRoute = ROUTES.TRACE;
|
||||
}
|
||||
|
||||
if (targetRoute) {
|
||||
history.push(targetRoute, event);
|
||||
}
|
||||
},
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function NoLogs({
|
||||
} else {
|
||||
link = ROUTES.GET_STARTED_LOGS_MANAGEMENT;
|
||||
}
|
||||
history.push(link);
|
||||
history.push(link, e);
|
||||
} else if (dataSource === 'traces') {
|
||||
window.open(DOCLINKS.TRACES_EXPLORER_EMPTY_STATE, '_blank');
|
||||
} else if (dataSource === DataSource.METRICS) {
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface ModuleProps {
|
||||
export interface SelectedModuleStepProps {
|
||||
id: string;
|
||||
title: string;
|
||||
component: any;
|
||||
component: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
export const useCases = {
|
||||
@@ -317,10 +317,18 @@ export default function Onboarding(): JSX.Element {
|
||||
{activeStep === 1 && (
|
||||
<div className="onboarding-page">
|
||||
<div
|
||||
onClick={(): void => {
|
||||
onClick={(e): void => {
|
||||
logEvent('Onboarding V2: Skip Button Clicked', {});
|
||||
history.push(ROUTES.APPLICATION);
|
||||
history.push(ROUTES.APPLICATION, e);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
logEvent('Onboarding V2: Skip Button Clicked', {});
|
||||
history.push(ROUTES.APPLICATION);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="skip-to-console"
|
||||
>
|
||||
{t('skip')}
|
||||
@@ -332,7 +340,7 @@ export default function Onboarding(): JSX.Element {
|
||||
<div className="modulesContainer">
|
||||
<div className="moduleContainerRowStyles">
|
||||
{Object.keys(ModulesMap).map((module) => {
|
||||
const selectedUseCase = (useCases as any)[module];
|
||||
const selectedUseCase = (useCases as any)[module]; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -10,20 +10,6 @@ import store from 'store';
|
||||
import ChangeHistory from '../index';
|
||||
import { pipelineData, pipelineDataHistory } from './testUtils';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -5,20 +5,6 @@ import { PipelineData } from 'types/api/pipeline/def';
|
||||
import { pipelineMockData } from '../mocks/pipeline';
|
||||
import AddNewPipeline from '../PipelineListsView/AddNewPipeline';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
export function matchMedia(): void {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
|
||||
@@ -11,20 +11,6 @@ jest.mock('../PipelineListsView/AddNewProcessor/config', () => ({
|
||||
DEFAULT_PROCESSOR_TYPE: 'json_parser',
|
||||
}));
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedProcessorData = {
|
||||
id: '1',
|
||||
orderId: 1,
|
||||
|
||||
@@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import i18n from 'ReactI18';
|
||||
import store from 'store';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render DeleteAction section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import i18n from 'ReactI18';
|
||||
import store from 'store';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render DragAction section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import i18n from 'ReactI18';
|
||||
import store from 'store';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render EditAction section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@@ -8,20 +8,6 @@ import store from 'store';
|
||||
import { pipelineMockData } from '../mocks/pipeline';
|
||||
import PipelineActions from '../PipelineListsView/TableComponents/PipelineActions';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render PipelineActions section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@@ -3,20 +3,6 @@ import { render } from 'tests/test-utils';
|
||||
import { pipelineMockData } from '../mocks/pipeline';
|
||||
import PipelineExpandView from '../PipelineListsView/PipelineExpandView';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
|
||||
@@ -6,20 +6,6 @@ import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
||||
import PipelineListsView from '../PipelineListsView';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useUrlQuery hook
|
||||
const mockUrlQuery = {
|
||||
get: jest.fn(),
|
||||
|
||||
@@ -4,20 +4,6 @@ import { v4 } from 'uuid';
|
||||
|
||||
import PipelinePageLayout from '../Layouts/Pipeline';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
|
||||
@@ -7,20 +7,6 @@ import store from 'store';
|
||||
|
||||
import TagInput from '../components/TagInput';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Pipeline Page', () => {
|
||||
it('should render TagInput section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import Tags from 'container/PipelinePage/PipelineListsView/TableComponents/Tags';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import i18n from 'ReactI18';
|
||||
import store from 'store';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
const tags = ['server', 'app'];
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render Tags section', () => {
|
||||
const { asFragment } = render(
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Tags tags={tags} />
|
||||
</I18nextProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const { asFragment } = render(<Tags tags={tags} />);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,20 +11,6 @@ import {
|
||||
getTableColumn,
|
||||
} from '../PipelineListsView/utils';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Utils testing of Pipeline Page', () => {
|
||||
test('it should be check form field of add pipeline', () => {
|
||||
expect(pipelineFields.length).toBe(3);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PlannedDowntime } from '../PlannedDowntime';
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
it('renders the PlannedDowntime component properly', () => {
|
||||
render(<PlannedDowntime />, {}, 'ADMIN');
|
||||
render(<PlannedDowntime />, {}, { role: 'ADMIN' });
|
||||
|
||||
// Check if title is rendered
|
||||
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
|
||||
@@ -30,7 +30,7 @@ describe('PlannedDowntime Component', () => {
|
||||
});
|
||||
|
||||
it('disables the "New downtime" button for users with VIEWER role', () => {
|
||||
render(<PlannedDowntime />, {}, USER_ROLES.VIEWER);
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.VIEWER });
|
||||
|
||||
// Check if "New downtime" button is disabled for VIEWER
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
|
||||
@@ -58,12 +58,16 @@
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 120px); /* Reserve space for action buttons */
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
.copy-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
|
||||
@@ -97,11 +97,13 @@ function Attributes(props: IAttributesProps): JSX.Element {
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<Tooltip title={item.value}>
|
||||
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
||||
<Typography.Text className="item-value" ellipsis>
|
||||
{item.value}
|
||||
</Typography.Text>{' '}
|
||||
</CopyClipboardHOC>
|
||||
<div className="copy-wrapper">
|
||||
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
||||
<Typography.Text className="item-value" ellipsis>
|
||||
{item.value}
|
||||
</Typography.Text>
|
||||
</CopyClipboardHOC>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<AttributeActions
|
||||
record={item}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 44px); //44px -> trace details top bar
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
overflow-y: auto;
|
||||
overflow-y: auto !important;
|
||||
&:not(&-docked) {
|
||||
min-width: 450px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { SpanDuration } from '../Success';
|
||||
@@ -15,7 +13,6 @@ const DIMMED_SPAN_CLASS = 'dimmed-span';
|
||||
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/useSafeNavigate');
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
jest.mock('@signozhq/badge', () => ({
|
||||
Badge: jest.fn(),
|
||||
@@ -52,24 +49,17 @@ const mockTraceMetadata = {
|
||||
hasMissingSpans: false,
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SpanDuration', () => {
|
||||
const mockSetSelectedSpan = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryGet = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -81,11 +71,6 @@ describe('SpanDuration', () => {
|
||||
get: mockUrlQueryGet,
|
||||
toString: () => 'spanId=test-span-id',
|
||||
});
|
||||
|
||||
// Mock safe navigate hook
|
||||
(useSafeNavigate as jest.Mock).mockReturnValue({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates URL and selected span when clicked', () => {
|
||||
|
||||
@@ -83,6 +83,84 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
|
||||
|
||||
return newKeys.length > 0;
|
||||
};
|
||||
// Helper function to determine if an argument is an event
|
||||
const isEventObject = (arg: any): boolean =>
|
||||
arg &&
|
||||
(arg instanceof MouseEvent ||
|
||||
arg instanceof KeyboardEvent ||
|
||||
arg.nativeEvent instanceof MouseEvent ||
|
||||
arg.nativeEvent instanceof KeyboardEvent ||
|
||||
arg.metaKey !== undefined ||
|
||||
arg.ctrlKey !== undefined);
|
||||
|
||||
// Helper function to extract options from arguments
|
||||
const extractOptions = (
|
||||
optionsOrEvent?:
|
||||
| NavigateOptions
|
||||
| React.MouseEvent
|
||||
| MouseEvent
|
||||
| KeyboardEvent,
|
||||
options?: NavigateOptions,
|
||||
): NavigateOptions => {
|
||||
const isEvent = isEventObject(optionsOrEvent);
|
||||
const actualOptions = isEvent ? options : (optionsOrEvent as NavigateOptions);
|
||||
|
||||
const shouldOpenInNewTab =
|
||||
isEvent &&
|
||||
((optionsOrEvent as any).metaKey || (optionsOrEvent as any).ctrlKey);
|
||||
|
||||
return {
|
||||
...actualOptions,
|
||||
newTab: shouldOpenInNewTab || actualOptions?.newTab,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to create target URL
|
||||
const createTargetUrl = (
|
||||
to: string | SafeNavigateParams,
|
||||
location: { pathname: string; search: string },
|
||||
): URL => {
|
||||
if (typeof to === 'string') {
|
||||
return new URL(to, window.location.origin);
|
||||
}
|
||||
return new URL(
|
||||
`${to.pathname || location.pathname}${to.search || ''}`,
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to handle new tab navigation
|
||||
const handleNewTabNavigation = (
|
||||
to: string | SafeNavigateParams,
|
||||
location: { pathname: string; search: string },
|
||||
): void => {
|
||||
const targetPath =
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||
window.open(targetPath, '_blank');
|
||||
};
|
||||
|
||||
// Helper function to perform navigation
|
||||
const performNavigation = (
|
||||
to: string | SafeNavigateParams,
|
||||
navigationOptions: NavigateOptions,
|
||||
navigate: any,
|
||||
location: { pathname: string; search: string },
|
||||
): void => {
|
||||
if (typeof to === 'string') {
|
||||
navigate(to, navigationOptions);
|
||||
} else {
|
||||
navigate(
|
||||
{
|
||||
pathname: to.pathname || location.pathname,
|
||||
search: to.search,
|
||||
},
|
||||
navigationOptions,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const useSafeNavigate = (
|
||||
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
|
||||
preventSameUrlNavigation: true,
|
||||
@@ -90,6 +168,11 @@ export const useSafeNavigate = (
|
||||
): {
|
||||
safeNavigate: (
|
||||
to: string | SafeNavigateParams,
|
||||
optionsOrEvent?:
|
||||
| NavigateOptions
|
||||
| React.MouseEvent
|
||||
| MouseEvent
|
||||
| KeyboardEvent,
|
||||
options?: NavigateOptions,
|
||||
) => void;
|
||||
} => {
|
||||
@@ -97,30 +180,25 @@ export const useSafeNavigate = (
|
||||
const location = useLocation();
|
||||
|
||||
const safeNavigate = useCallback(
|
||||
(to: string | SafeNavigateParams, options?: NavigateOptions) => {
|
||||
(
|
||||
to: string | SafeNavigateParams,
|
||||
optionsOrEvent?:
|
||||
| NavigateOptions
|
||||
| React.MouseEvent
|
||||
| MouseEvent
|
||||
| KeyboardEvent,
|
||||
options?: NavigateOptions,
|
||||
) => {
|
||||
const finalOptions = extractOptions(optionsOrEvent, options);
|
||||
const currentUrl = new URL(
|
||||
`${location.pathname}${location.search}`,
|
||||
window.location.origin,
|
||||
);
|
||||
const targetUrl = createTargetUrl(to, location);
|
||||
|
||||
let targetUrl: URL;
|
||||
|
||||
if (typeof to === 'string') {
|
||||
targetUrl = new URL(to, window.location.origin);
|
||||
} else {
|
||||
targetUrl = new URL(
|
||||
`${to.pathname || location.pathname}${to.search || ''}`,
|
||||
window.location.origin,
|
||||
);
|
||||
}
|
||||
|
||||
// If newTab is true, open in new tab and return early
|
||||
if (options?.newTab) {
|
||||
const targetPath =
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||
window.open(targetPath, '_blank');
|
||||
// Handle new tab navigation
|
||||
if (finalOptions?.newTab) {
|
||||
handleNewTabNavigation(to, location);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,23 +210,13 @@ export const useSafeNavigate = (
|
||||
}
|
||||
|
||||
const navigationOptions = {
|
||||
...options,
|
||||
replace: isDefaultParamsNavigation || options?.replace,
|
||||
...finalOptions,
|
||||
replace: isDefaultParamsNavigation || finalOptions?.replace,
|
||||
};
|
||||
|
||||
if (typeof to === 'string') {
|
||||
navigate(to, navigationOptions);
|
||||
} else {
|
||||
navigate(
|
||||
{
|
||||
pathname: to.pathname || location.pathname,
|
||||
search: to.search,
|
||||
},
|
||||
navigationOptions,
|
||||
);
|
||||
}
|
||||
performNavigation(to, navigationOptions, navigate, location);
|
||||
},
|
||||
[navigate, location.pathname, location.search, preventSameUrlNavigation],
|
||||
[navigate, location, preventSameUrlNavigation],
|
||||
);
|
||||
|
||||
return { safeNavigate };
|
||||
|
||||
@@ -16,8 +16,6 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes - data becomes stale after 2 minutes
|
||||
cacheTime: 5 * 60 * 1000, // 5 minutes - cache entries are garbage collected after 5 minutes
|
||||
retry(failureCount, error): boolean {
|
||||
if (
|
||||
// in case of manually throwing errors please make sure to send error.response.status
|
||||
|
||||
@@ -1,3 +1,53 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { createBrowserHistory, History } from 'history';
|
||||
|
||||
export default createBrowserHistory();
|
||||
// Create the base history instance
|
||||
const baseHistory = createBrowserHistory();
|
||||
|
||||
// Extend the History interface to include enhanced push method
|
||||
interface EnhancedHistory extends History {
|
||||
push: {
|
||||
(path: string, state?: any): void;
|
||||
(
|
||||
path: string,
|
||||
event?: React.MouseEvent | MouseEvent | KeyboardEvent,
|
||||
state?: any,
|
||||
): void;
|
||||
};
|
||||
originalPush: History['push'];
|
||||
}
|
||||
|
||||
// Create enhanced history with overridden push method
|
||||
const history = baseHistory as EnhancedHistory;
|
||||
|
||||
// Store the original push method
|
||||
history.originalPush = baseHistory.push;
|
||||
|
||||
// Override push to handle meta/ctrl key events
|
||||
history.push = function (
|
||||
path: string,
|
||||
eventOrState?: React.MouseEvent | MouseEvent | KeyboardEvent | any,
|
||||
state?: any,
|
||||
): void {
|
||||
// Check if second argument is an event object
|
||||
const isEvent =
|
||||
eventOrState &&
|
||||
(eventOrState instanceof MouseEvent ||
|
||||
eventOrState instanceof KeyboardEvent ||
|
||||
eventOrState.nativeEvent instanceof MouseEvent ||
|
||||
eventOrState.nativeEvent instanceof KeyboardEvent ||
|
||||
eventOrState.metaKey !== undefined ||
|
||||
eventOrState.ctrlKey !== undefined);
|
||||
|
||||
// If it's an event and meta/ctrl key is pressed, open in new tab
|
||||
if (isEvent && (eventOrState.metaKey || eventOrState.ctrlKey)) {
|
||||
window.open(path, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, use normal navigation
|
||||
// If eventOrState is not an event, treat it as state
|
||||
const actualState = isEvent ? state : eventOrState;
|
||||
history.originalPush(path, actualState);
|
||||
};
|
||||
|
||||
export default history;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// src/mocks/server.js
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { handlers } from './handlers';
|
||||
|
||||
// This configures a request mocking server with the given request handlers.
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
export { rest };
|
||||
|
||||
@@ -79,7 +79,7 @@ function OldLogsExplorer(): JSX.Element {
|
||||
});
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set(QueryParams.order, value);
|
||||
history.push({ search: params.toString() });
|
||||
history.push(`${history.location.pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user