Compare commits
44 Commits
SIG-2878
...
some-edits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3540fc7ae2 | ||
|
|
61efbd248c | ||
|
|
9a5bcb6b64 | ||
|
|
96cdf21a92 | ||
|
|
1aa5f5d0e1 | ||
|
|
6ac812b5af | ||
|
|
0b4831ca04 | ||
|
|
340aa9ec21 | ||
|
|
5a47a4349b | ||
|
|
80f0c6dd92 | ||
|
|
c0acc69f87 | ||
|
|
35192eecd8 | ||
|
|
9114b44c0e | ||
|
|
7b14490266 | ||
|
|
c68096152d | ||
|
|
4d8d0223e7 | ||
|
|
2f4b8f6f80 | ||
|
|
a3ee84af48 | ||
|
|
db79d3a0de | ||
|
|
0a466ee3e9 | ||
|
|
a54c3a3d7f | ||
|
|
2c59c1196d | ||
|
|
73ff89a80a | ||
|
|
b2dc2790d8 | ||
|
|
dc8e4365f5 | ||
|
|
eb38dd548a | ||
|
|
0ac5d97495 | ||
|
|
710f7740d3 | ||
|
|
a16ab114f5 | ||
|
|
84ae5b4ca9 | ||
|
|
a564fa9d28 | ||
|
|
7f4390f370 | ||
|
|
c41ae00433 | ||
|
|
9aacf7f2f5 | ||
|
|
792d0f3db6 | ||
|
|
47e8a89dbe | ||
|
|
bced4774bb | ||
|
|
0c25de9560 | ||
|
|
24307b48ff | ||
|
|
0626a89412 | ||
|
|
5cd775f2b2 | ||
|
|
c9568be5d8 | ||
|
|
1c257f3e14 | ||
|
|
ff8ac96d37 |
@@ -42,7 +42,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
signoz-otel-collector:
|
||||
image: signoz/signoz-otel-collector:v0.128.2
|
||||
image: signoz/signoz-otel-collector:v0.129.6
|
||||
container_name: signoz-otel-collector-dev
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
|
||||
3
.github/workflows/integrationci.yaml
vendored
3
.github/workflows/integrationci.yaml
vendored
@@ -21,10 +21,9 @@ jobs:
|
||||
- postgres
|
||||
- sqlite
|
||||
clickhouse-version:
|
||||
- 24.1.2-alpine
|
||||
- 25.5.6
|
||||
schema-migrator-version:
|
||||
- v0.128.1
|
||||
- v0.129.6
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -230,4 +230,6 @@ poetry.toml
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
|
||||
frontend/.cursor/rules/
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.95.0
|
||||
image: signoz/signoz:v0.96.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -209,7 +209,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.5
|
||||
image: signoz/signoz-otel-collector:v0.129.6
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -233,7 +233,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.95.0
|
||||
image: signoz/signoz:v0.96.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -150,7 +150,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.5
|
||||
image: signoz/signoz-otel-collector:v0.129.6
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.95.0}
|
||||
image: signoz/signoz:${VERSION:-v0.96.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -239,7 +239,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -250,7 +250,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.95.0}
|
||||
image: signoz/signoz:${VERSION:-v0.96.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -144,7 +144,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -166,7 +166,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -178,7 +178,7 @@ services:
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -192,7 +192,7 @@ Tests can be configured using pytest options:
|
||||
|
||||
- `--sqlstore-provider` - Choose database provider (default: postgres)
|
||||
- `--postgres-version` - PostgreSQL version (default: 15)
|
||||
- `--clickhouse-version` - ClickHouse version (default: 24.1.2-alpine)
|
||||
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
|
||||
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
|
||||
|
||||
Example:
|
||||
|
||||
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'],
|
||||
|
||||
31
frontend/src/api/thirdPartyApis/listOverview.ts
Normal file
31
frontend/src/api/thirdPartyApis/listOverview.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/thirdPartyApis/listOverview';
|
||||
|
||||
const listOverview = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
const { start, end, show_ip: showIp, filter } = props;
|
||||
try {
|
||||
const response = await ApiBaseInstance.post(
|
||||
`/third-party-apis/overview/list`,
|
||||
{
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default listOverview;
|
||||
@@ -124,7 +124,7 @@ export const FUNCTION_NAMES: Record<string, FunctionName> = {
|
||||
RUNNING_DIFF: 'runningDiff',
|
||||
LOG2: 'log2',
|
||||
LOG10: 'log10',
|
||||
CUM_SUM: 'cumSum',
|
||||
CUM_SUM: 'cumulativeSum',
|
||||
EWMA3: 'ewma3',
|
||||
EWMA5: 'ewma5',
|
||||
EWMA7: 'ewma7',
|
||||
|
||||
@@ -87,7 +87,7 @@ function ChangelogModal({ changelog, onClose }: Props): JSX.Element {
|
||||
|
||||
const onClickUpdateWorkspace = (): void => {
|
||||
window.open(
|
||||
'https://github.com/SigNoz/signoz/releases',
|
||||
'https://signoz.io/upgrade-path',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('ChangelogModal', () => {
|
||||
renderChangelog();
|
||||
fireEvent.click(screen.getByText('Update my workspace'));
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://github.com/SigNoz/signoz/releases',
|
||||
'https://signoz.io/upgrade-path',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
function AnnouncementsModal(): JSX.Element {
|
||||
return (
|
||||
<div className="announcements-modal-container">
|
||||
<div className="announcements-modal-container-header">
|
||||
<Typography.Text className="announcements-modal-title">
|
||||
Announcements
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnnouncementsModal;
|
||||
160
frontend/src/components/HeaderRightSection/FeedbackModal.tsx
Normal file
160
frontend/src/components/HeaderRightSection/FeedbackModal.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState('feedback');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const location = useLocation();
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
let entityName = 'Feedback';
|
||||
if (activeTab === 'reportBug') {
|
||||
entityName = 'Bug report';
|
||||
} else if (activeTab === 'featureRequest') {
|
||||
entityName = 'Feature request';
|
||||
}
|
||||
|
||||
logEvent('Feedback: Submitted', {
|
||||
data: feedback,
|
||||
type: activeTab,
|
||||
page: location.pathname,
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
|
||||
toast.success(`${entityName} submitted successfully`, {
|
||||
position: 'top-right',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(`Failed to submit ${entityName}`);
|
||||
toast.error(`Failed to submit ${entityName}`, {
|
||||
position: 'top-right',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
setFeedback('');
|
||||
setActiveTab('feedback');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<div className="feedback-modal-tab-label">
|
||||
<div className="tab-icon dot feedback-tab" />
|
||||
Feedback
|
||||
</div>
|
||||
),
|
||||
key: 'feedback',
|
||||
value: 'feedback',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="feedback-modal-tab-label">
|
||||
<div className="tab-icon dot bug-tab" />
|
||||
Report a bug
|
||||
</div>
|
||||
),
|
||||
key: 'reportBug',
|
||||
value: 'reportBug',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="feedback-modal-tab-label">
|
||||
<div className="tab-icon dot feature-tab" />
|
||||
Feature request
|
||||
</div>
|
||||
),
|
||||
key: 'featureRequest',
|
||||
value: 'featureRequest',
|
||||
},
|
||||
];
|
||||
|
||||
const handleFeedbackChange = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement>,
|
||||
): void => {
|
||||
setFeedback(e.target.value);
|
||||
};
|
||||
|
||||
const handleContactSupportClick = useCallback((): void => {
|
||||
handleContactSupport(isCloudUserVal);
|
||||
}, [isCloudUserVal]);
|
||||
|
||||
return (
|
||||
<div className="feedback-modal-container">
|
||||
<div className="feedback-modal-header">
|
||||
<Radio.Group
|
||||
value={activeTab}
|
||||
defaultValue={activeTab}
|
||||
optionType="button"
|
||||
className="feedback-modal-tabs"
|
||||
options={items}
|
||||
onChange={(e: RadioChangeEvent): void => setActiveTab(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="feedback-modal-content">
|
||||
<div className="feedback-modal-content-header">
|
||||
<Input.TextArea
|
||||
placeholder="Write your feedback here..."
|
||||
rows={6}
|
||||
required
|
||||
className="feedback-input"
|
||||
value={feedback}
|
||||
onChange={handleFeedbackChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feedback-modal-content-footer">
|
||||
<Button
|
||||
className="periscope-btn primary"
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
disabled={feedback.length === 0}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<div className="feedback-modal-content-footer-info-text">
|
||||
<Typography.Text>
|
||||
Have a specific issue?{' '}
|
||||
<Typography.Link
|
||||
className="contact-support-link"
|
||||
onClick={handleContactSupportClick}
|
||||
>
|
||||
Contact Support{' '}
|
||||
</Typography.Link>
|
||||
or{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/introduction/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="read-docs-link"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedbackModal;
|
||||
@@ -0,0 +1,253 @@
|
||||
.header-right-section-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.share-modal-content,
|
||||
.feedback-modal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
width: 460px;
|
||||
|
||||
border-radius: 4px;
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.absolute-relative-time-toggler-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.absolute-relative-time-toggler-label {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.absolute-relative-time-toggler {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.absolute-relative-time-error {
|
||||
font-size: 12px;
|
||||
color: var(--bg-amber-600);
|
||||
}
|
||||
|
||||
.share-link {
|
||||
.url-share-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.url-share-container-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.url-share-title,
|
||||
.url-share-sub-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.url-share-sub-title {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-300);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-container {
|
||||
.feedback-modal-tabs {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
margin: 0px !important;
|
||||
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-radio-button-checked {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tab-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.feedback-tab {
|
||||
background-color: var(--bg-sakura-500);
|
||||
}
|
||||
|
||||
.bug-tab {
|
||||
background-color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
.feature-tab {
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
.ant-tabs-tab {
|
||||
padding: 6px 16px;
|
||||
|
||||
border-radius: 2px;
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
margin: 0 !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&-active {
|
||||
background: var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
border-bottom: none !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.feedback-input {
|
||||
resize: none;
|
||||
|
||||
text-area {
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-content-include-console-logs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-content-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.feedback-modal-content-footer-info-text {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
text-align: center;
|
||||
|
||||
/* button/ small */
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 200% */
|
||||
|
||||
.contact-support-link,
|
||||
.read-docs-link {
|
||||
color: var(--bg-robin-400);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.share-modal-content,
|
||||
.feedback-modal-container {
|
||||
.absolute-relative-time-toggler-container {
|
||||
.absolute-relative-time-toggler-label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.share-link {
|
||||
.url-share-container {
|
||||
.url-share-container-header {
|
||||
.url-share-title,
|
||||
.url-share-sub-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.url-share-sub-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-container {
|
||||
.feedback-modal-tabs {
|
||||
.ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
margin: 0px !important;
|
||||
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-radio-button-checked {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal-content-footer {
|
||||
.feedback-modal-content-footer-info-text {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import './HeaderRightSection.styles.scss';
|
||||
|
||||
import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { Globe, Inbox, SquarePen } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
import ShareURLModal from './ShareURLModal';
|
||||
|
||||
interface HeaderRightSectionProps {
|
||||
enableAnnouncements: boolean;
|
||||
enableShare: boolean;
|
||||
enableFeedback: boolean;
|
||||
}
|
||||
|
||||
function HeaderRightSection({
|
||||
enableAnnouncements,
|
||||
enableShare,
|
||||
enableFeedback,
|
||||
}: HeaderRightSectionProps): JSX.Element | null {
|
||||
const location = useLocation();
|
||||
|
||||
const [openFeedbackModal, setOpenFeedbackModal] = useState(false);
|
||||
const [openShareURLModal, setOpenShareURLModal] = useState(false);
|
||||
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const handleOpenFeedbackModal = useCallback((): void => {
|
||||
logEvent('Feedback: Clicked', {
|
||||
page: location.pathname,
|
||||
});
|
||||
|
||||
setOpenFeedbackModal(true);
|
||||
setOpenShareURLModal(false);
|
||||
setOpenAnnouncementsModal(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleOpenShareURLModal = useCallback((): void => {
|
||||
logEvent('Share: Clicked', {
|
||||
page: location.pathname,
|
||||
});
|
||||
|
||||
setOpenShareURLModal(true);
|
||||
setOpenFeedbackModal(false);
|
||||
setOpenAnnouncementsModal(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleCloseFeedbackModal = (): void => {
|
||||
setOpenFeedbackModal(false);
|
||||
};
|
||||
|
||||
const handleOpenFeedbackModalChange = (open: boolean): void => {
|
||||
setOpenFeedbackModal(open);
|
||||
};
|
||||
|
||||
const handleOpenAnnouncementsModalChange = (open: boolean): void => {
|
||||
setOpenAnnouncementsModal(open);
|
||||
};
|
||||
|
||||
const handleOpenShareURLModalChange = (open: boolean): void => {
|
||||
setOpenShareURLModal(open);
|
||||
};
|
||||
|
||||
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
|
||||
|
||||
return (
|
||||
<div className="header-right-section-container">
|
||||
{enableFeedback && isLicenseEnabled && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<FeedbackModal onClose={handleCloseFeedbackModal} />}
|
||||
destroyTooltipOnHide
|
||||
arrow={false}
|
||||
trigger="click"
|
||||
open={openFeedbackModal}
|
||||
onOpenChange={handleOpenFeedbackModalChange}
|
||||
>
|
||||
<Button
|
||||
className="share-feedback-btn periscope-btn ghost"
|
||||
icon={<SquarePen size={14} />}
|
||||
onClick={handleOpenFeedbackModal}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{enableAnnouncements && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<AnnouncementsModal />}
|
||||
arrow={false}
|
||||
destroyTooltipOnHide
|
||||
trigger="click"
|
||||
open={openAnnouncementsModal}
|
||||
onOpenChange={handleOpenAnnouncementsModalChange}
|
||||
>
|
||||
<Button
|
||||
icon={<Inbox size={14} />}
|
||||
className="periscope-btn ghost announcements-btn"
|
||||
onClick={(): void => {
|
||||
logEvent('Announcements: Clicked', {
|
||||
page: location.pathname,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{enableShare && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<ShareURLModal />}
|
||||
open={openShareURLModal}
|
||||
destroyTooltipOnHide
|
||||
arrow={false}
|
||||
trigger="click"
|
||||
onOpenChange={handleOpenShareURLModalChange}
|
||||
>
|
||||
<Button
|
||||
className="share-link-btn periscope-btn ghost"
|
||||
icon={<Globe size={14} />}
|
||||
onClick={handleOpenShareURLModal}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeaderRightSection;
|
||||
171
frontend/src/components/HeaderRightSection/ShareURLModal.tsx
Normal file
171
frontend/src/components/HeaderRightSection/ShareURLModal.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Switch, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { Check, Info, Link2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
const routesToBeSharedWithTime = [
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
ROUTES.METER_EXPLORER,
|
||||
];
|
||||
|
||||
function ShareURLModal(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
|
||||
selectedTime !== 'custom',
|
||||
);
|
||||
|
||||
const startTime = urlQuery.get(QueryParams.startTime);
|
||||
const endTime = urlQuery.get(QueryParams.endTime);
|
||||
const relativeTime = urlQuery.get(QueryParams.relativeTime);
|
||||
|
||||
const [isURLCopied, setIsURLCopied] = useState(false);
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isValidateRelativeTime = useMemo(
|
||||
() =>
|
||||
selectedTime !== 'custom' ||
|
||||
(startTime && endTime && selectedTime === 'custom'),
|
||||
[startTime, endTime, selectedTime],
|
||||
);
|
||||
|
||||
const shareURLWithTime = useMemo(
|
||||
() => relativeTime || (startTime && endTime),
|
||||
[relativeTime, startTime, endTime],
|
||||
);
|
||||
|
||||
const isRouteToBeSharedWithTime = useMemo(
|
||||
() =>
|
||||
routesToBeSharedWithTime.some((route) =>
|
||||
matchPath(location.pathname, { path: route, exact: true }),
|
||||
),
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const processURL = (): string => {
|
||||
let currentUrl = window.location.href;
|
||||
const isCustomTime = !!(startTime && endTime && selectedTime === 'custom');
|
||||
|
||||
if (shareURLWithTime || isRouteToBeSharedWithTime) {
|
||||
if (enableAbsoluteTime || isCustomTime) {
|
||||
if (selectedTime === 'custom') {
|
||||
if (startTime && endTime) {
|
||||
urlQuery.set(QueryParams.startTime, startTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTime.toString());
|
||||
}
|
||||
} else {
|
||||
const { minTime, maxTime } = GetMinMax(selectedTime);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, minTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, maxTime.toString());
|
||||
}
|
||||
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
|
||||
currentUrl = `${window.location.origin}${
|
||||
location.pathname
|
||||
}?${urlQuery.toString()}`;
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.startTime);
|
||||
urlQuery.delete(QueryParams.endTime);
|
||||
|
||||
urlQuery.set(QueryParams.relativeTime, selectedTime);
|
||||
currentUrl = `${window.location.origin}${
|
||||
location.pathname
|
||||
}?${urlQuery.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
};
|
||||
|
||||
const handleCopyURL = (): void => {
|
||||
const URL = processURL();
|
||||
|
||||
handleCopyToClipboard(URL);
|
||||
setIsURLCopied(true);
|
||||
|
||||
logEvent('Share: Copy link clicked', {
|
||||
page: location.pathname,
|
||||
URL,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setIsURLCopied(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="share-modal-content">
|
||||
{(shareURLWithTime || isRouteToBeSharedWithTime) && (
|
||||
<>
|
||||
<div className="absolute-relative-time-toggler-container">
|
||||
<Typography.Text className="absolute-relative-time-toggler-label">
|
||||
Enable absolute time
|
||||
</Typography.Text>
|
||||
|
||||
<div className="absolute-relative-time-toggler">
|
||||
{!isValidateRelativeTime && (
|
||||
<Info size={14} color={Color.BG_AMBER_600} />
|
||||
)}
|
||||
<Switch
|
||||
checked={enableAbsoluteTime}
|
||||
disabled={!isValidateRelativeTime}
|
||||
size="small"
|
||||
onChange={(): void => {
|
||||
setEnableAbsoluteTime((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isValidateRelativeTime && (
|
||||
<div className="absolute-relative-time-error">
|
||||
Please select / enter valid relative time to toggle.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="share-link">
|
||||
<div className="url-share-container">
|
||||
<div className="url-share-container-header">
|
||||
<Typography.Text className="url-share-title">
|
||||
Share page link
|
||||
</Typography.Text>
|
||||
<Typography.Text className="url-share-sub-title">
|
||||
Share the current page link with your team member
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="periscope-btn secondary"
|
||||
onClick={handleCopyURL}
|
||||
icon={isURLCopied ? <Check size={14} /> : <Link2 size={14} />}
|
||||
>
|
||||
Copy page link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShareURLModal;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import AnnouncementsModal from '../AnnouncementsModal';
|
||||
|
||||
describe('AnnouncementsModal', () => {
|
||||
it('should render announcements modal with title', () => {
|
||||
render(<AnnouncementsModal />);
|
||||
|
||||
expect(screen.getByText('Announcements')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper structure and classes', () => {
|
||||
render(<AnnouncementsModal />);
|
||||
|
||||
const container = screen
|
||||
.getByText('Announcements')
|
||||
.closest('.announcements-modal-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
const headerContainer = screen
|
||||
.getByText('Announcements')
|
||||
.closest('.announcements-modal-container-header');
|
||||
expect(headerContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without any errors', () => {
|
||||
expect(() => render(<AnnouncementsModal />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
// Mock dependencies before imports
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('pages/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
const mockHandleContactSupport = handleContactSupport as jest.Mock;
|
||||
const mockToast = toast as jest.Mocked<typeof toast>;
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
const mockLocation = {
|
||||
pathname: '/test-path',
|
||||
};
|
||||
|
||||
describe('FeedbackModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
});
|
||||
mockToast.success.mockClear();
|
||||
mockToast.error.mockClear();
|
||||
});
|
||||
|
||||
it('should render feedback modal with all tabs', () => {
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Feedback')).toBeInTheDocument();
|
||||
expect(screen.getByText('Report a bug')).toBeInTheDocument();
|
||||
expect(screen.getByText('Feature request')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Write your feedback here...'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch between tabs when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Initially, feedback radio should be active
|
||||
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
|
||||
expect(feedbackRadio).toBeChecked();
|
||||
|
||||
const bugTab = screen.getByText('Report a bug');
|
||||
await user.click(bugTab);
|
||||
|
||||
// Bug radio should now be active
|
||||
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
|
||||
expect(bugRadio).toBeChecked();
|
||||
|
||||
const featureTab = screen.getByText('Feature request');
|
||||
await user.click(featureTab);
|
||||
|
||||
// Feature radio should now be active
|
||||
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
|
||||
expect(featureRadio).toBeChecked();
|
||||
});
|
||||
|
||||
it('should update feedback text when typing in textarea', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const testFeedback = 'This is my feedback';
|
||||
|
||||
await user.type(textarea, testFeedback);
|
||||
|
||||
expect(textarea).toHaveValue(testFeedback);
|
||||
});
|
||||
|
||||
it('should submit feedback and log event when submit button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
const testFeedback = 'Test feedback content';
|
||||
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'feedback',
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Feedback submitted successfully',
|
||||
{
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should submit bug report with correct type', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Switch to bug report tab
|
||||
const bugTab = screen.getByText('Report a bug');
|
||||
await user.click(bugTab);
|
||||
|
||||
// Verify bug report radio is now active
|
||||
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
|
||||
expect(bugRadio).toBeChecked();
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
const testFeedback = 'This is a bug report';
|
||||
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'reportBug',
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Bug report submitted successfully',
|
||||
{
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should submit feature request with correct type', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Switch to feature request tab
|
||||
const featureTab = screen.getByText('Feature request');
|
||||
await user.click(featureTab);
|
||||
|
||||
// Verify feature request radio is now active
|
||||
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
|
||||
expect(featureRadio).toBeChecked();
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
const testFeedback = 'This is a feature request';
|
||||
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'featureRequest',
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Feature request submitted successfully',
|
||||
{
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call handleContactSupport when contact support link is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const isCloudUser = true;
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
});
|
||||
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const contactSupportLink = screen.getByText('Contact Support');
|
||||
await user.click(contactSupportLink);
|
||||
|
||||
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
|
||||
});
|
||||
|
||||
it('should handle non-cloud user for contact support', async () => {
|
||||
const user = userEvent.setup();
|
||||
const isCloudUser = false;
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
});
|
||||
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const contactSupportLink = screen.getByText('Contact Support');
|
||||
await user.click(contactSupportLink);
|
||||
|
||||
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
|
||||
});
|
||||
|
||||
it('should render docs link with correct attributes', () => {
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
const docsLink = screen.getByText('Read our docs');
|
||||
expect(docsLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/introduction/',
|
||||
);
|
||||
expect(docsLink).toHaveAttribute('target', '_blank');
|
||||
expect(docsLink).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
|
||||
it('should reset form state when component unmounts', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Render component
|
||||
const { unmount } = render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Change the form state first
|
||||
const textArea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
await user.type(textArea, 'Some feedback text');
|
||||
|
||||
// Change the active tab
|
||||
const bugTab = screen.getByText('Report a bug');
|
||||
await user.click(bugTab);
|
||||
|
||||
// Verify state has changed
|
||||
expect(textArea).toHaveValue('Some feedback text');
|
||||
|
||||
// Unmount the component - this should trigger cleanup
|
||||
unmount();
|
||||
|
||||
// Re-render the component to verify state was reset
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Verify form state is reset
|
||||
const newTextArea = screen.getByPlaceholderText(
|
||||
'Write your feedback here...',
|
||||
);
|
||||
expect(newTextArea).toHaveValue(''); // Should be empty
|
||||
|
||||
// Verify active radio is reset to default (Feedback radio)
|
||||
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
|
||||
expect(feedbackRadio).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
// Mock dependencies before imports
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../FeedbackModal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onClose }: { onClose: () => void }): JSX.Element => (
|
||||
<div data-testid="feedback-modal">
|
||||
<button onClick={onClose} type="button">
|
||||
Close Feedback
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../ShareURLModal', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="share-modal">Share URL Modal</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../AnnouncementsModal', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="announcements-modal">Announcements Modal</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
const defaultProps = {
|
||||
enableAnnouncements: true,
|
||||
enableShare: true,
|
||||
enableFeedback: true,
|
||||
};
|
||||
|
||||
const mockLocation = {
|
||||
pathname: '/test-path',
|
||||
};
|
||||
|
||||
describe('HeaderRightSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
// Default to licensed user (Enterprise or Cloud)
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: true,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all buttons when all features are enabled', () => {
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument();
|
||||
|
||||
// Check for feedback button by class
|
||||
const feedbackButton = document.querySelector(
|
||||
'.share-feedback-btn[class*="share-feedback-btn"]',
|
||||
);
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
|
||||
// Check for announcements button by finding the inbox icon
|
||||
const inboxIcon = document.querySelector('.lucide-inbox');
|
||||
expect(inboxIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render only enabled features', () => {
|
||||
render(
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare={false}
|
||||
enableFeedback
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /share/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Check that inbox icon is not present
|
||||
const inboxIcon = document.querySelector('.lucide-inbox');
|
||||
expect(inboxIcon).not.toBeInTheDocument();
|
||||
|
||||
// Check that feedback button is present
|
||||
const squarePenIcon = document.querySelector('.lucide-square-pen');
|
||||
expect(squarePenIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open feedback modal and log event when feedback button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document
|
||||
.querySelector('.lucide-square-pen')
|
||||
?.closest('button');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open share modal and log event when share button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const shareButton = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should log event when announcements button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const announcementsButton = document
|
||||
.querySelector('.lucide-inbox')
|
||||
?.closest('button');
|
||||
expect(announcementsButton).toBeInTheDocument();
|
||||
|
||||
await user.click(announcementsButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
});
|
||||
|
||||
it('should close feedback modal when onClose is called', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
// Open feedback modal
|
||||
const feedbackButton = document
|
||||
.querySelector('.lucide-square-pen')
|
||||
?.closest('button');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
|
||||
// Close feedback modal
|
||||
const closeFeedbackButton = screen.getByText('Close Feedback');
|
||||
await user.click(closeFeedbackButton);
|
||||
expect(screen.queryByTestId('feedback-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close other modals when opening feedback modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
// Open share modal first
|
||||
const shareButton = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareButton);
|
||||
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
|
||||
|
||||
// Open feedback modal - should close share modal
|
||||
const feedbackButton = document
|
||||
.querySelector('.lucide-square-pen')
|
||||
?.closest('button');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show feedback button for Cloud users when feedback is enabled', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: true,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show feedback button for Enterprise self-hosted users when feedback is enabled', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide feedback button for Community users even when feedback is enabled', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: true,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide feedback button for Community Enterprise users even when feedback is enabled', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: true,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
const feedbackButton = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correct number of buttons when feedback is hidden due to license', () => {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityUser: true,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
|
||||
render(<HeaderRightSection {...defaultProps} />);
|
||||
|
||||
// Should have 2 buttons (announcements + share) instead of 3
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
// Verify which buttons are present
|
||||
expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument();
|
||||
const inboxIcon = document.querySelector('.lucide-inbox');
|
||||
expect(inboxIcon).toBeInTheDocument();
|
||||
|
||||
// Verify feedback button is not present
|
||||
const feedbackIcon = document.querySelector('.lucide-square-pen');
|
||||
expect(feedbackIcon).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
// Mock dependencies before imports
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import ShareURLModal from '../ShareURLModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
matchPath: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock window.location
|
||||
const mockLocation = {
|
||||
href: 'https://example.com/test-path?param=value',
|
||||
origin: 'https://example.com',
|
||||
};
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
const mockUseSelector = useSelector as jest.Mock;
|
||||
const mockGetMinMax = GetMinMax as jest.Mock;
|
||||
const mockUseCopyToClipboard = useCopyToClipboard as jest.Mock;
|
||||
const mockMatchPath = matchPath as jest.Mock;
|
||||
|
||||
const mockUrlQuery = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
toString: jest.fn(() => 'param=value'),
|
||||
};
|
||||
|
||||
const mockHandleCopyToClipboard = jest.fn();
|
||||
|
||||
const TEST_PATH = '/test-path';
|
||||
const ENABLE_ABSOLUTE_TIME_TEXT = 'Enable absolute time';
|
||||
|
||||
describe('ShareURLModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: TEST_PATH,
|
||||
});
|
||||
|
||||
mockUseUrlQuery.mockReturnValue(mockUrlQuery);
|
||||
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min',
|
||||
});
|
||||
|
||||
mockGetMinMax.mockReturnValue({
|
||||
minTime: 1000000,
|
||||
maxTime: 2000000,
|
||||
});
|
||||
|
||||
mockUseCopyToClipboard.mockReturnValue([null, mockHandleCopyToClipboard]);
|
||||
|
||||
mockMatchPath.mockReturnValue(false);
|
||||
|
||||
// Reset URL query mocks - all return null by default
|
||||
mockUrlQuery.get.mockReturnValue(null);
|
||||
|
||||
// Reset mock functions
|
||||
mockUrlQuery.set.mockClear();
|
||||
mockUrlQuery.delete.mockClear();
|
||||
mockUrlQuery.toString.mockReturnValue('param=value');
|
||||
});
|
||||
|
||||
it('should render share modal with copy button', () => {
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(screen.getByText('Share page link')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Share the current page link with your team member'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy page link/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should copy URL and log event when copy button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ShareURLModal />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
page: TEST_PATH,
|
||||
URL: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show absolute time toggle when on time-enabled route', () => {
|
||||
mockMatchPath.mockReturnValue(true); // Simulate being on a route that supports time
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show absolute time toggle when URL has time parameters', () => {
|
||||
mockUrlQuery.get.mockImplementation((key: string) =>
|
||||
key === 'relativeTime' ? '5min' : null,
|
||||
);
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle absolute time switch', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min', // Non-custom time should enable absolute time by default
|
||||
});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
// Should be checked by default for non-custom time
|
||||
expect(toggleSwitch).toBeChecked();
|
||||
|
||||
await user.click(toggleSwitch);
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should disable toggle when relative time is invalid', () => {
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: 'custom',
|
||||
});
|
||||
|
||||
// Invalid - missing start and end time for custom
|
||||
mockUrlQuery.get.mockReturnValue(null);
|
||||
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Please select / enter valid relative time to toggle.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should process URL with absolute time for non-custom time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min',
|
||||
});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
// Absolute time should be enabled by default for non-custom time
|
||||
// Click copy button directly
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
|
||||
});
|
||||
|
||||
it('should process URL with custom time parameters', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: 'custom',
|
||||
});
|
||||
|
||||
mockUrlQuery.get.mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'startTime':
|
||||
return '1500000';
|
||||
case 'endTime':
|
||||
return '1600000';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
// Should be enabled by default for custom time
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1500000');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '1600000');
|
||||
});
|
||||
|
||||
it('should process URL with relative time when absolute time is disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min',
|
||||
});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
// Disable absolute time first (it's enabled by default for non-custom time)
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
await user.click(toggleSwitch);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
|
||||
});
|
||||
|
||||
it('should handle routes that should be shared with time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: ROUTES.LOGS_EXPLORER,
|
||||
});
|
||||
|
||||
mockMatchPath.mockImplementation(
|
||||
(pathname: string, options: any) => options.path === ROUTES.LOGS_EXPLORER,
|
||||
);
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeChecked();
|
||||
|
||||
// on clicking copy page link, the copied url should have startTime and endTime
|
||||
const copyButton = screen.getByRole('button', { name: /copy page link/i });
|
||||
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
|
||||
|
||||
// toggle the switch to share url with relative time
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
await user.click(toggleSwitch);
|
||||
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
|
||||
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
|
||||
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ interface LogsFormatOptionsMenuProps {
|
||||
config: OptionsMenuConfig;
|
||||
}
|
||||
|
||||
export default function LogsFormatOptionsMenu({
|
||||
function OptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
@@ -49,7 +49,6 @@ export default function LogsFormatOptionsMenu({
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const initialMouseEnterRef = useRef<boolean>(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const onChange = useCallback(
|
||||
(key: LogViewMode) => {
|
||||
@@ -209,7 +208,7 @@ export default function LogsFormatOptionsMenu({
|
||||
};
|
||||
}, [selectedValue]);
|
||||
|
||||
const popoverContent = (
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'nested-menu-container',
|
||||
@@ -447,15 +446,30 @@ export default function LogsFormatOptionsMenu({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsFormatOptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
}: LogsFormatOptionsMenuProps): JSX.Element {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
content={
|
||||
<OptionsMenu
|
||||
items={items}
|
||||
selectedOptionFormat={selectedOptionFormat}
|
||||
config={config}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
rootClassName="format-options-popover"
|
||||
destroyTooltipOnHide
|
||||
>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
@@ -465,3 +479,5 @@ export default function LogsFormatOptionsMenu({
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogsFormatOptionsMenu;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
|
||||
|
||||
const mockUpdateFormatting = jest.fn();
|
||||
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): any => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateFormatting.mockClear();
|
||||
});
|
||||
|
||||
function setup(): {
|
||||
getByTestId: ReturnType<typeof render>['getByTestId'];
|
||||
findItemByLabel: (label: string) => Element | undefined;
|
||||
formatOnChange: jest.Mock<any, any>;
|
||||
maxLinesOnChange: jest.Mock<any, any>;
|
||||
fontSizeOnChange: jest.Mock<any, any>;
|
||||
} {
|
||||
const items = [
|
||||
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
|
||||
{ key: 'list', label: 'Default' },
|
||||
{ key: 'table', label: 'Column', data: { title: 'columns' } },
|
||||
];
|
||||
|
||||
const formatOnChange = jest.fn();
|
||||
const maxLinesOnChange = jest.fn();
|
||||
const fontSizeOnChange = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<LogsFormatOptionsMenu
|
||||
items={items}
|
||||
selectedOptionFormat="table"
|
||||
config={{
|
||||
format: { value: 'table', onChange: formatOnChange },
|
||||
maxLines: { value: 2, onChange: maxLinesOnChange },
|
||||
fontSize: { value: FontSize.SMALL, onChange: fontSizeOnChange },
|
||||
addColumn: {
|
||||
isFetching: false,
|
||||
value: [],
|
||||
options: [],
|
||||
onFocus: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
onSearch: jest.fn(),
|
||||
onSelect: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open the popover menu by default for each test
|
||||
const formatButton = getByTestId('periscope-btn-format-options');
|
||||
fireEvent.click(formatButton);
|
||||
|
||||
const getMenuItems = (): Element[] =>
|
||||
Array.from(document.querySelectorAll('.menu-items .item'));
|
||||
const findItemByLabel = (label: string): Element | undefined =>
|
||||
getMenuItems().find((el) => (el.textContent || '').includes(label));
|
||||
|
||||
return {
|
||||
getByTestId,
|
||||
findItemByLabel,
|
||||
formatOnChange,
|
||||
maxLinesOnChange,
|
||||
fontSizeOnChange,
|
||||
};
|
||||
}
|
||||
|
||||
// Covers: opens menu, changes format selection, updates max-lines, changes font size
|
||||
it('opens and toggles format selection', async () => {
|
||||
const { findItemByLabel, formatOnChange } = setup();
|
||||
|
||||
// Assert initial selection
|
||||
const columnItem = findItemByLabel('Column') as Element;
|
||||
expect(document.querySelectorAll('.menu-items .item svg')).toHaveLength(1);
|
||||
expect(columnItem.querySelector('svg')).toBeInTheDocument();
|
||||
|
||||
// Change selection to 'Raw'
|
||||
const rawItem = findItemByLabel('Raw') as Element;
|
||||
fireEvent.click(rawItem as HTMLElement);
|
||||
await waitFor(() => {
|
||||
const rawEl = findItemByLabel('Raw') as Element;
|
||||
expect(document.querySelectorAll('.menu-items .item svg')).toHaveLength(1);
|
||||
expect(rawEl.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
expect(formatOnChange).toHaveBeenCalledWith('raw');
|
||||
});
|
||||
|
||||
it('increments max-lines and calls onChange', async () => {
|
||||
const { maxLinesOnChange } = setup();
|
||||
|
||||
// Increment max lines
|
||||
const input = document.querySelector(
|
||||
'.max-lines-per-row-input input',
|
||||
) as HTMLInputElement;
|
||||
const initial = Number(input.value);
|
||||
const buttons = document.querySelectorAll(
|
||||
'.max-lines-per-row-input .periscope-btn',
|
||||
);
|
||||
const incrementBtn = buttons[1] as HTMLElement;
|
||||
fireEvent.click(incrementBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Number(input.value)).toBe(initial + 1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(maxLinesOnChange).toHaveBeenCalledWith(initial + 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('changes font size to MEDIUM and calls onChange', async () => {
|
||||
const { fontSizeOnChange } = setup();
|
||||
// Open font dropdown
|
||||
const fontButton = document.querySelector(
|
||||
'.font-size-container .value',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(fontButton);
|
||||
|
||||
// Choose MEDIUM
|
||||
const optionButtons = Array.from(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn'),
|
||||
);
|
||||
const mediumBtn = optionButtons[1] as HTMLElement;
|
||||
fireEvent.click(mediumBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
(optionButtons[1] as Element).querySelector('.icon'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -80,16 +80,20 @@ const stopEventsExtension = EditorView.domEventHandlers({
|
||||
});
|
||||
|
||||
function QuerySearch({
|
||||
placeholder,
|
||||
onChange,
|
||||
queryData,
|
||||
dataSource,
|
||||
onRun,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
}: {
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
dataSource: DataSource;
|
||||
signalSource?: string;
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -219,6 +223,11 @@ function QuerySearch({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hardcodedAttributeKeys) {
|
||||
setKeySuggestions(hardcodedAttributeKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
lastFetchedKeyRef.current = searchText || '';
|
||||
|
||||
const response = await getKeySuggestions({
|
||||
@@ -254,6 +263,7 @@ function QuerySearch({
|
||||
toggleSuggestions,
|
||||
queryData.aggregateAttribute?.key,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1336,7 +1346,7 @@ function QuerySearch({
|
||||
]),
|
||||
),
|
||||
]}
|
||||
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
|
||||
placeholder={placeholder}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
}}
|
||||
@@ -1483,6 +1493,9 @@ function QuerySearch({
|
||||
QuerySearch.defaultProps = {
|
||||
onRun: undefined,
|
||||
signalSource: '',
|
||||
hardcodedAttributeKeys: undefined,
|
||||
placeholder:
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
|
||||
};
|
||||
|
||||
export default QuerySearch;
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable import/named */
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * as UseQBModule from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import QuerySearch from '../QuerySearch/QuerySearch';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard', () => ({
|
||||
useDashboard: (): { selectedDashboard: undefined } => ({
|
||||
selectedDashboard: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
return {
|
||||
__esModule: true,
|
||||
useQueryBuilder: (): { handleRunQuery: () => void } => ({ handleRunQuery }),
|
||||
handleRunQuery,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@codemirror/autocomplete', () => ({
|
||||
autocompletion: (): Record<string, unknown> => ({}),
|
||||
closeCompletion: (): boolean => true,
|
||||
completionKeymap: [] as unknown[],
|
||||
startCompletion: (): boolean => true,
|
||||
}));
|
||||
|
||||
jest.mock('@codemirror/lang-javascript', () => ({
|
||||
javascript: (): Record<string, unknown> => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('@uiw/codemirror-theme-copilot', () => ({
|
||||
copilot: {},
|
||||
}));
|
||||
|
||||
jest.mock('@uiw/codemirror-theme-github', () => ({
|
||||
githubLight: {},
|
||||
}));
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: { keys: {} as Record<string, QueryKeyDataSuggestionsProps[]> },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock CodeMirror to a simple textarea to make it testable and call onUpdate
|
||||
jest.mock(
|
||||
'@uiw/react-codemirror',
|
||||
(): Record<string, unknown> => {
|
||||
// Minimal EditorView shape used by the component
|
||||
class EditorViewMock {}
|
||||
(EditorViewMock as any).domEventHandlers = (): unknown => ({} as unknown);
|
||||
(EditorViewMock as any).lineWrapping = {} as unknown;
|
||||
(EditorViewMock as any).editable = { of: () => ({}) } as unknown;
|
||||
|
||||
const keymap = { of: (arr: unknown) => arr } as unknown;
|
||||
const Prec = { highest: (ext: unknown) => ext } as unknown;
|
||||
|
||||
type CodeMirrorProps = {
|
||||
value?: string;
|
||||
onChange?: (v: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
onCreateEditor?: (view: unknown) => unknown;
|
||||
onUpdate?: (arg: {
|
||||
view: {
|
||||
state: {
|
||||
selection: { main: { head: number } };
|
||||
doc: {
|
||||
toString: () => string;
|
||||
lineAt: (
|
||||
_pos: number,
|
||||
) => { number: number; from: number; to: number; text: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
}) => void;
|
||||
'data-testid'?: string;
|
||||
extensions?: unknown[];
|
||||
};
|
||||
|
||||
function CodeMirrorMock({
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
placeholder,
|
||||
onCreateEditor,
|
||||
onUpdate,
|
||||
'data-testid': dataTestId,
|
||||
extensions,
|
||||
}: CodeMirrorProps): JSX.Element {
|
||||
const [localValue, setLocalValue] = React.useState<string>(value ?? '');
|
||||
|
||||
// Provide a fake editor instance
|
||||
React.useEffect(() => {
|
||||
if (onCreateEditor) {
|
||||
onCreateEditor(new EditorViewMock() as any);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Call onUpdate whenever localValue changes to simulate cursor and doc
|
||||
React.useEffect(() => {
|
||||
if (onUpdate) {
|
||||
const text = String(localValue ?? '');
|
||||
const head = text.length;
|
||||
onUpdate({
|
||||
view: {
|
||||
state: {
|
||||
selection: { main: { head } },
|
||||
doc: {
|
||||
toString: (): string => text,
|
||||
lineAt: () => ({
|
||||
number: 1,
|
||||
from: 0,
|
||||
to: text.length,
|
||||
text,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localValue]);
|
||||
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
): void => {
|
||||
const isModEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
|
||||
if (!isModEnter) return;
|
||||
const exts: unknown[] = Array.isArray(extensions) ? extensions : [];
|
||||
const flat: unknown[] = exts.flatMap((x: unknown) =>
|
||||
Array.isArray(x) ? x : [x],
|
||||
);
|
||||
const keyBindings = flat.filter(
|
||||
(x) =>
|
||||
Boolean(x) &&
|
||||
typeof x === 'object' &&
|
||||
'key' in (x as Record<string, unknown>),
|
||||
) as Array<{ key?: string; run?: () => boolean | void }>;
|
||||
keyBindings
|
||||
.filter((b) => b.key === 'Mod-Enter' && typeof b.run === 'function')
|
||||
.forEach((b) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
b.run!();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
data-testid={dataTestId || 'query-where-clause-editor'}
|
||||
placeholder={placeholder}
|
||||
value={localValue}
|
||||
onChange={(e): void => {
|
||||
setLocalValue(e.target.value);
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: '100%', minHeight: 80 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: CodeMirrorMock,
|
||||
EditorView: EditorViewMock,
|
||||
keymap,
|
||||
Prec,
|
||||
};
|
||||
},
|
||||
);
|
||||
const handleRunQueryMock = ((UseQBModule as unknown) as {
|
||||
handleRunQuery: jest.MockedFunction<() => void>;
|
||||
}).handleRunQuery;
|
||||
|
||||
const PLACEHOLDER_TEXT =
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')";
|
||||
const TESTID_EDITOR = 'query-where-clause-editor';
|
||||
const SAMPLE_KEY_TYPING = 'http.';
|
||||
const SAMPLE_VALUE_TYPING_INCOMPLETE = " service.name = '";
|
||||
const SAMPLE_VALUE_TYPING_COMPLETE = " service.name = 'frontend'";
|
||||
const SAMPLE_STATUS_QUERY = " status_code = '200'";
|
||||
|
||||
describe('QuerySearch', () => {
|
||||
it('renders with placeholder', () => {
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches key suggestions when typing a key (debounced)', async () => {
|
||||
jest.useFakeTimers();
|
||||
const advance = (ms: number): void => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
};
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: advance,
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.type(editor, SAMPLE_KEY_TYPING);
|
||||
advance(1000);
|
||||
|
||||
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('fetches value suggestions when editing value context', async () => {
|
||||
jest.useFakeTimers();
|
||||
const advance = (ms: number): void => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
};
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: advance,
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
const mockedGetValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
>;
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
|
||||
advance(1000);
|
||||
|
||||
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('fetches key suggestions on mount for LOGS', async () => {
|
||||
jest.useFakeTimers();
|
||||
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
const lastArgs = mockedGetKeysOnMount.mock.calls[
|
||||
mockedGetKeysOnMount.mock.calls.length - 1
|
||||
]?.[0] as { signal: unknown; searchText: string };
|
||||
expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' });
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls provided onRun on Mod-Enter', async () => {
|
||||
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={onRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_STATUS_QUERY);
|
||||
await user.keyboard('{Meta>}{Enter}{/Meta}');
|
||||
|
||||
await waitFor(() => expect(onRun).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
|
||||
const mockedHandleRunQuery = handleRunQueryMock as jest.MockedFunction<
|
||||
() => void
|
||||
>;
|
||||
mockedHandleRunQuery.mockClear();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
|
||||
await user.keyboard('{Meta>}{Enter}{/Meta}');
|
||||
|
||||
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import RouteTab from './index';
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import {
|
||||
generatePath,
|
||||
matchPath,
|
||||
@@ -17,6 +18,7 @@ function RouteTab({
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
@@ -59,7 +61,16 @@ function RouteTab({
|
||||
defaultActiveKey={currentRoute?.key || activeKey}
|
||||
animated
|
||||
items={items}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
tabBarExtraContent={
|
||||
showRightSection && (
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading ---- TODO: remove this once follow the linting rules
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
@@ -67,6 +78,7 @@ function RouteTab({
|
||||
|
||||
RouteTab.defaultProps = {
|
||||
onChangeHandler: undefined,
|
||||
showRightSection: true,
|
||||
};
|
||||
|
||||
export default RouteTab;
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface RouteTabProps {
|
||||
activeKey: TabsProps['activeKey'];
|
||||
onChangeHandler?: (key: string) => void;
|
||||
history: History<unknown>;
|
||||
showRightSection: boolean;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ import UPlot from 'uplot';
|
||||
|
||||
import { dataMatch, optionsUpdateState } from './utils';
|
||||
|
||||
// Extended uPlot interface with custom properties
|
||||
interface ExtendedUPlot extends uPlot {
|
||||
_legendScrollCleanup?: () => void;
|
||||
}
|
||||
|
||||
export interface UplotProps {
|
||||
options: uPlot.Options;
|
||||
data: uPlot.AlignedData;
|
||||
@@ -66,6 +71,12 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
|
||||
|
||||
const destroy = useCallback((chart: uPlot | null) => {
|
||||
if (chart) {
|
||||
// Clean up legend scroll event listener
|
||||
const extendedChart = chart as ExtendedUPlot;
|
||||
if (extendedChart._legendScrollCleanup) {
|
||||
extendedChart._legendScrollCleanup();
|
||||
}
|
||||
|
||||
onDeleteRef.current?.(chart);
|
||||
chart.destroy();
|
||||
chartRef.current = null;
|
||||
|
||||
@@ -125,7 +125,7 @@ export const queryFunctionsTypesConfig: QueryFunctionConfigType = {
|
||||
log10: {
|
||||
showInput: false,
|
||||
},
|
||||
cumSum: {
|
||||
cumulativeSum: {
|
||||
showInput: false,
|
||||
},
|
||||
ewma3: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -157,9 +157,12 @@ function DomainDetails({
|
||||
<div className="domain-details-drawer-header">
|
||||
<div className="domain-details-drawer-header-title">
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text className="title">
|
||||
{domainData.domainName}
|
||||
</Typography.Text>
|
||||
|
||||
{domainData?.domainName && (
|
||||
<Typography.Text className="title">
|
||||
{domainData.domainName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="domain-details-drawer-header-right-container">
|
||||
<DateTimeSelectionV2
|
||||
|
||||
@@ -2,36 +2,29 @@ import '../Explorer.styles.scss';
|
||||
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table, Typography } from 'antd';
|
||||
import axios from 'api';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
|
||||
import { get } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
|
||||
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
|
||||
import {
|
||||
columnsConfig,
|
||||
formatDataForTable,
|
||||
hardcodedAttributeKeys,
|
||||
} from '../../utils';
|
||||
import { columnsConfig, formatDataForTable } from '../../utils';
|
||||
import DomainDetails from './DomainDetails/DomainDetails';
|
||||
|
||||
function DomainList(): JSX.Element {
|
||||
@@ -53,6 +46,21 @@ function DomainList(): JSX.Element {
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
|
||||
const { data, isLoading, isFetching } = useListOverview({
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
show_ip: Boolean(showIP),
|
||||
filter: {
|
||||
expression: `kind_string = 'Client' ${get(
|
||||
compositeData,
|
||||
'builder.queryData[0].filter.expression',
|
||||
'',
|
||||
)}`,
|
||||
},
|
||||
});
|
||||
|
||||
// initialise tab with default query.
|
||||
useShareBuilderUrl({
|
||||
defaultValue: {
|
||||
@@ -74,63 +82,21 @@ function DomainList(): JSX.Element {
|
||||
},
|
||||
});
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
|
||||
const handleChangeTagFilters = useCallback(
|
||||
(value: IBuilderQuery['filters']) => {
|
||||
handleChangeQueryData('filters', value);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
(handleChangeQueryData as HandleChangeQueryDataV5)('filter', {
|
||||
expression: value,
|
||||
});
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const fetchApiOverview = async (): Promise<
|
||||
SuccessResponse<any> | ErrorResponse
|
||||
> => {
|
||||
const requestBody = {
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
show_ip: showIP,
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: '212678b9',
|
||||
key: {
|
||||
key: 'kind_string',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'Client',
|
||||
},
|
||||
...(compositeData?.builder?.queryData[0]?.filters?.items || []),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'/third-party-apis/overview/list',
|
||||
requestBody,
|
||||
);
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery(
|
||||
[REACT_QUERY_KEY.GET_DOMAINS_LIST, minTime, maxTime, compositeData, showIP],
|
||||
fetchApiOverview,
|
||||
);
|
||||
|
||||
const formattedDataForTable = useMemo(
|
||||
() => formatDataForTable(data?.payload?.data?.result[0]?.table?.rows),
|
||||
() =>
|
||||
formatDataForTable(
|
||||
data?.data?.data?.data.results[0]?.data || [],
|
||||
data?.data?.data?.data.results[0]?.columns || [],
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
@@ -150,13 +116,13 @@ function DomainList(): JSX.Element {
|
||||
showAutoRefresh={false}
|
||||
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
|
||||
/>
|
||||
{/* add bottom border here */}
|
||||
<div className={cx('api-monitoring-list-header')}>
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={handleChangeTagFilters}
|
||||
placeholder="Search filters..."
|
||||
hardcodedAttributeKeys={hardcodedAttributeKeys}
|
||||
<QuerySearch
|
||||
dataSource={DataSource.TRACES}
|
||||
queryData={query}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
|
||||
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
|
||||
@@ -3,10 +3,11 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import {
|
||||
endPointStatusCodeColumns,
|
||||
extractPortAndEndpoint,
|
||||
formatDataForTable,
|
||||
formatTopErrorsDataForTable,
|
||||
getAllEndpointsWidgetData,
|
||||
getCustomFiltersForBarChart,
|
||||
@@ -24,7 +25,8 @@ import {
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
TopErrorsResponseRow,
|
||||
} from './utils';
|
||||
} from '../utils';
|
||||
import { APIMonitoringColumnsMock } from './mock';
|
||||
|
||||
// Mock or define DataTypes since it seems to be missing from imports
|
||||
const DataTypes = {
|
||||
@@ -34,9 +36,9 @@ const DataTypes = {
|
||||
};
|
||||
|
||||
// Mock the external utils dependencies that are used within our tested functions
|
||||
jest.mock('./utils', () => {
|
||||
jest.mock('../utils', () => {
|
||||
// Import the actual module to partial mock
|
||||
const originalModule = jest.requireActual('./utils');
|
||||
const originalModule = jest.requireActual('../utils');
|
||||
|
||||
// Return a mocked version
|
||||
return {
|
||||
@@ -157,6 +159,54 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// New tests for formatDataForTable
|
||||
describe('formatDataForTable', () => {
|
||||
it('should format rows correctly with valid data', () => {
|
||||
const columns = APIMonitoringColumnsMock;
|
||||
const data = [
|
||||
[
|
||||
'test-domain', // domainName
|
||||
'10', // endpoints
|
||||
'25', // rps
|
||||
'2.5', // error_rate
|
||||
'15000000', // p99 (ns) -> 15 ms
|
||||
'2025-09-17T12:54:17.040Z', // lastseen
|
||||
],
|
||||
];
|
||||
|
||||
const result = formatDataForTable(data as any, columns as any);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].domainName).toBe('test-domain');
|
||||
expect(result[0].endpointCount).toBe('10');
|
||||
expect(result[0].rate).toBe('25');
|
||||
expect(result[0].errorRate).toBe('2.5');
|
||||
expect(result[0].latency).toBe(15);
|
||||
expect(result[0].lastUsed).toBe('2025-09-17T12:54:17.040Z');
|
||||
});
|
||||
|
||||
it('should handle n/a and undefined values', () => {
|
||||
const columns = APIMonitoringColumnsMock;
|
||||
const data = [
|
||||
[
|
||||
'test-domain',
|
||||
'n/a', // endpoints -> 0
|
||||
'n/a', // rps -> '-'
|
||||
'n/a', // error_rate -> 0
|
||||
'n/a', // p99 -> '-'
|
||||
'n/a', // lastseen -> '-'
|
||||
],
|
||||
];
|
||||
|
||||
const result = formatDataForTable(data as any, columns as any);
|
||||
|
||||
expect(result[0].endpointCount).toBe(0);
|
||||
expect(result[0].rate).toBe('-');
|
||||
expect(result[0].errorRate).toBe(0);
|
||||
expect(result[0].latency).toBe('-');
|
||||
expect(result[0].lastUsed).toBe('-');
|
||||
});
|
||||
});
|
||||
describe('getGroupByFiltersFromGroupByValues', () => {
|
||||
it('should convert row data to filters correctly', () => {
|
||||
// Arrange
|
||||
@@ -1288,7 +1338,7 @@ describe('API Monitoring Utils', () => {
|
||||
// Setup a mock
|
||||
jest
|
||||
.spyOn(
|
||||
jest.requireActual('./utils'),
|
||||
jest.requireActual('../utils'),
|
||||
'getFormattedEndPointStatusCodeChartData',
|
||||
)
|
||||
.mockReturnValue({
|
||||
65
frontend/src/container/ApiMonitoring/__tests__/mock.ts
Normal file
65
frontend/src/container/ApiMonitoring/__tests__/mock.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { domainNameKey } from '../constants';
|
||||
import { APIMonitoringResponseColumn } from '../types';
|
||||
|
||||
export const APIMonitoringColumnsMock: APIMonitoringResponseColumn[] = [
|
||||
{
|
||||
name: domainNameKey,
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'string',
|
||||
queryName: '',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'attribute',
|
||||
},
|
||||
{
|
||||
name: 'endpoints',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'endpoints',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
{
|
||||
name: 'rps',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'rps',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
{
|
||||
name: 'error_rate',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'error_rate',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
{
|
||||
name: 'p99',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'p99',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
{
|
||||
name: 'lastseen',
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: 'lastseen',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
},
|
||||
];
|
||||
30
frontend/src/container/ApiMonitoring/constants.ts
Normal file
30
frontend/src/container/ApiMonitoring/constants.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
export const ApiMonitoringHardcodedAttributeKeys: QueryKeyDataSuggestionsProps[] = [
|
||||
{
|
||||
label: 'deployment.environment',
|
||||
type: 'resource',
|
||||
name: 'deployment.environment',
|
||||
signal: 'traces',
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
},
|
||||
{
|
||||
label: 'service.name',
|
||||
type: 'resource',
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
},
|
||||
{
|
||||
label: 'rpc.method',
|
||||
type: 'tag',
|
||||
name: 'rpc.method',
|
||||
signal: 'traces',
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
},
|
||||
];
|
||||
|
||||
export const domainNameKey = SPAN_ATTRIBUTES.SERVER_NAME;
|
||||
39
frontend/src/container/ApiMonitoring/types.ts
Normal file
39
frontend/src/container/ApiMonitoring/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { domainNameKey } from './constants';
|
||||
|
||||
export interface APIMonitoringResponseRow {
|
||||
data: {
|
||||
endpoints: number | string;
|
||||
error_rate: number | string;
|
||||
lastseen: number | string;
|
||||
[domainNameKey]: string;
|
||||
p99: number | string;
|
||||
rps: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface APIMonitoringResponseColumn {
|
||||
name: string;
|
||||
signal: string;
|
||||
fieldContext: string;
|
||||
fieldDataType: string;
|
||||
queryName: string;
|
||||
aggregationIndex: number;
|
||||
meta: Record<string, any>;
|
||||
columnType: string;
|
||||
}
|
||||
|
||||
export interface EndPointsResponseRow {
|
||||
data: {
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface APIDomainsRowData {
|
||||
key: string;
|
||||
domainName: string;
|
||||
endpointCount: number | string;
|
||||
rate: number | string;
|
||||
errorRate: number | string;
|
||||
latency: number | string;
|
||||
lastUsed: string;
|
||||
}
|
||||
@@ -32,7 +32,13 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { domainNameKey } from './constants';
|
||||
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
|
||||
import {
|
||||
APIDomainsRowData,
|
||||
APIMonitoringResponseColumn,
|
||||
EndPointsResponseRow,
|
||||
} from './types';
|
||||
|
||||
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
@@ -243,84 +249,47 @@ export const columnsConfig: ColumnType<APIDomainsRowData>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Rename this to a proper name
|
||||
export const hardcodedAttributeKeys: BaseAutocompleteData[] = [
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'rpc.method',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
];
|
||||
|
||||
const domainNameKey = SPAN_ATTRIBUTES.SERVER_NAME;
|
||||
|
||||
interface APIMonitoringResponseRow {
|
||||
data: {
|
||||
endpoints: number | string;
|
||||
error_rate: number | string;
|
||||
lastseen: number | string;
|
||||
[domainNameKey]: string;
|
||||
p99: number | string;
|
||||
rps: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EndPointsResponseRow {
|
||||
data: {
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface APIDomainsRowData {
|
||||
key: string;
|
||||
domainName: string;
|
||||
endpointCount: number | string;
|
||||
rate: number | string;
|
||||
errorRate: number | string;
|
||||
latency: number | string;
|
||||
lastUsed: string;
|
||||
}
|
||||
|
||||
// Rename this to a proper name
|
||||
export const formatDataForTable = (
|
||||
data: APIMonitoringResponseRow[],
|
||||
): APIDomainsRowData[] =>
|
||||
data?.map((domain) => ({
|
||||
key: v4(),
|
||||
domainName: domain?.data[domainNameKey] || '-',
|
||||
endpointCount:
|
||||
domain?.data?.endpoints === 'n/a' || domain?.data?.endpoints === undefined
|
||||
? 0
|
||||
: domain?.data?.endpoints,
|
||||
rate:
|
||||
domain?.data?.rps === 'n/a' || domain?.data?.rps === undefined
|
||||
? '-'
|
||||
: domain?.data?.rps,
|
||||
errorRate:
|
||||
domain?.data?.error_rate === 'n/a' || domain?.data?.error_rate === undefined
|
||||
? 0
|
||||
: domain?.data?.error_rate,
|
||||
latency:
|
||||
domain?.data?.p99 === 'n/a' || domain?.data?.p99 === undefined
|
||||
? '-'
|
||||
: Math.round(Number(domain?.data?.p99) / 1000000), // Convert from nanoseconds to milliseconds
|
||||
lastUsed:
|
||||
domain?.data?.lastseen === 'n/a' || domain?.data?.lastseen === undefined
|
||||
? '-'
|
||||
: new Date(
|
||||
Math.floor(Number(domain?.data?.lastseen) / 1000000),
|
||||
).toISOString(), // Convert from nanoseconds to milliseconds
|
||||
}));
|
||||
data: string[][],
|
||||
columns: APIMonitoringResponseColumn[],
|
||||
): APIDomainsRowData[] => {
|
||||
const indexMap = columns.reduce((acc, column, index) => {
|
||||
if (column.name === domainNameKey) {
|
||||
acc[column.name] = index;
|
||||
} else {
|
||||
acc[column.queryName] = index;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return data.map((row) => {
|
||||
const rowData: APIDomainsRowData = {
|
||||
key: v4(),
|
||||
domainName: row[indexMap[domainNameKey]],
|
||||
endpointCount:
|
||||
row[indexMap.endpoints] === 'n/a' || row[indexMap.endpoints] === undefined
|
||||
? 0
|
||||
: row[indexMap.endpoints],
|
||||
rate:
|
||||
row[indexMap.rps] === 'n/a' || row[indexMap.rps] === undefined
|
||||
? '-'
|
||||
: row[indexMap.rps],
|
||||
errorRate:
|
||||
row[indexMap.error_rate] === 'n/a' || row[indexMap.error_rate] === undefined
|
||||
? 0
|
||||
: row[indexMap.error_rate],
|
||||
latency:
|
||||
row[indexMap.p99] === 'n/a' || row[indexMap.p99] === undefined
|
||||
? '-'
|
||||
: Math.round(Number(row[indexMap.p99]) / 1000000),
|
||||
lastUsed:
|
||||
row[indexMap.lastseen] === 'n/a' || row[indexMap.lastseen] === undefined
|
||||
? '-'
|
||||
: new Date(row[indexMap.lastseen]).toISOString(),
|
||||
};
|
||||
return rowData;
|
||||
});
|
||||
};
|
||||
|
||||
export const getDomainMetricsQueryPayload = (
|
||||
domainName: string,
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import './styles.scss';
|
||||
import '../EvaluationSettings/styles.scss';
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './styles.scss';
|
||||
import '../EvaluationSettings/styles.scss';
|
||||
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import { Button, Select, Tooltip, Typography } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -26,6 +26,7 @@ import { UpdateThreshold } from './types';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
getMatchTypeTooltip,
|
||||
getQueryNames,
|
||||
} from './utils';
|
||||
|
||||
@@ -86,6 +87,35 @@ function AlertThreshold(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map(
|
||||
(option) => ({
|
||||
...option,
|
||||
label: (
|
||||
<Tooltip
|
||||
title={getMatchTypeTooltip(option.value, thresholdState.operator)}
|
||||
placement="left"
|
||||
overlayClassName="copyable-tooltip"
|
||||
overlayStyle={{
|
||||
maxWidth: '450px',
|
||||
minWidth: '400px',
|
||||
}}
|
||||
overlayInnerStyle={{
|
||||
padding: '12px 16px',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
MozUserSelect: 'text',
|
||||
msUserSelect: 'text',
|
||||
}}
|
||||
mouseEnterDelay={0.2}
|
||||
trigger={['hover', 'click']}
|
||||
destroyTooltipOnHide={false}
|
||||
>
|
||||
<span style={{ display: 'block', width: '100%' }}>{option.label}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const evaluationWindowContext = showCondensedLayoutFlag ? (
|
||||
<EvaluationSettings />
|
||||
) : (
|
||||
@@ -115,8 +145,7 @@ function AlertThreshold(): JSX.Element {
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
/>
|
||||
</div>
|
||||
<div className="alert-condition-sentence">
|
||||
<Typography.Text className="sentence-text">is</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.operator}
|
||||
onChange={(value): void => {
|
||||
@@ -125,7 +154,7 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 180 }}
|
||||
options={THRESHOLD_OPERATOR_OPTIONS}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
@@ -139,8 +168,8 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 140 }}
|
||||
options={THRESHOLD_MATCH_TYPE_OPTIONS}
|
||||
style={{ width: 180 }}
|
||||
options={matchTypeOptionsWithTooltips}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
during the {evaluationWindowContext}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
|
||||
import { ChartLine, CircleX } from 'lucide-react';
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { ChartLine, CircleX, Trash } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { AlertThresholdOperator } from '../context/types';
|
||||
import { ThresholdItemProps } from './types';
|
||||
|
||||
function ThresholdItem({
|
||||
@@ -12,6 +14,7 @@ function ThresholdItem({
|
||||
channels,
|
||||
units,
|
||||
}: ThresholdItemProps): JSX.Element {
|
||||
const { thresholdState } = useCreateAlertState();
|
||||
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
|
||||
|
||||
const yAxisUnitSelect = useMemo(() => {
|
||||
@@ -45,6 +48,32 @@ function ThresholdItem({
|
||||
return component;
|
||||
}, [units, threshold.unit, updateThreshold, threshold.id]);
|
||||
|
||||
const getOperatorSymbol = (): string => {
|
||||
switch (thresholdState.operator) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return '>';
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return '<';
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return '=';
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return '!=';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const addRecoveryThreshold = (): void => {
|
||||
// Recovery threshold - hidden for now
|
||||
// setShowRecoveryThreshold(true);
|
||||
// updateThreshold(threshold.id, 'recoveryThresholdValue', 0);
|
||||
};
|
||||
|
||||
const removeRecoveryThreshold = (): void => {
|
||||
setShowRecoveryThreshold(false);
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={threshold.id} className="threshold-item">
|
||||
<div className="threshold-row">
|
||||
@@ -54,80 +83,99 @@ function ThresholdItem({
|
||||
style={{ backgroundColor: threshold.color }}
|
||||
/>
|
||||
</div>
|
||||
<Space className="threshold-controls">
|
||||
<div className="threshold-inputs">
|
||||
<Input.Group>
|
||||
<Input
|
||||
placeholder="Enter threshold name"
|
||||
value={threshold.label}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'label', e.target.value)
|
||||
}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Enter threshold value"
|
||||
value={threshold.thresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 210 }}
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
</Input.Group>
|
||||
</div>
|
||||
<Typography.Text className="sentence-text">to</Typography.Text>
|
||||
<div className="threshold-controls">
|
||||
<Input
|
||||
placeholder="Enter threshold name"
|
||||
value={threshold.label}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'label', e.target.value)
|
||||
}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
{getOperatorSymbol()}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder="Enter threshold value"
|
||||
value={threshold.thresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
<Typography.Text className="sentence-text">send to</Typography.Text>
|
||||
<Select
|
||||
value={threshold.channels}
|
||||
onChange={(value): void =>
|
||||
updateThreshold(threshold.id, 'channels', value)
|
||||
}
|
||||
style={{ width: 260 }}
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.id,
|
||||
label: channel.name,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
showSearch
|
||||
maxTagCount={2}
|
||||
maxTagPlaceholder={(omittedValues): string =>
|
||||
`+${omittedValues.length} more`
|
||||
}
|
||||
maxTagTextLength={10}
|
||||
filterOption={(input, option): boolean =>
|
||||
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
|
||||
}
|
||||
/>
|
||||
{/* Recovery threshold - hidden for now */}
|
||||
{/* {showRecoveryThreshold && (
|
||||
<>
|
||||
<Typography.Text className="sentence-text">recover on</Typography.Text>
|
||||
<Input
|
||||
placeholder="Enter recovery threshold value"
|
||||
value={threshold.recoveryThresholdValue ?? ''}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
/>
|
||||
<Tooltip title="Remove recovery threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Trash size={16} />}
|
||||
onClick={removeRecoveryThreshold}
|
||||
className="icon-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)} */}
|
||||
<Button.Group>
|
||||
{!showRecoveryThreshold && (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ChartLine size={16} />}
|
||||
className="icon-btn"
|
||||
onClick={(): void => setShowRecoveryThreshold(true)}
|
||||
/>
|
||||
)}
|
||||
{/* {!showRecoveryThreshold && (
|
||||
<Tooltip title="Add recovery threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ChartLine size={16} />}
|
||||
className="icon-btn"
|
||||
onClick={addRecoveryThreshold}
|
||||
/>
|
||||
</Tooltip>
|
||||
)} */}
|
||||
{showRemoveButton && (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<CircleX size={16} />}
|
||||
onClick={(): void => removeThreshold(threshold.id)}
|
||||
className="icon-btn"
|
||||
/>
|
||||
<Tooltip title="Remove threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<CircleX size={16} />}
|
||||
onClick={(): void => removeThreshold(threshold.id)}
|
||||
className="icon-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button.Group>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
{showRecoveryThreshold && (
|
||||
<Input.Group className="recovery-threshold-input-group">
|
||||
<Input
|
||||
placeholder="Recovery threshold"
|
||||
disabled
|
||||
style={{ width: 260 }}
|
||||
className="recovery-threshold-label"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Enter recovery threshold value"
|
||||
value={threshold.recoveryThresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 210 }}
|
||||
/>
|
||||
</Input.Group>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
|
||||
const TEST_STRINGS = {
|
||||
ADD_THRESHOLD: 'Add Threshold',
|
||||
AT_LEAST_ONCE: 'AT LEAST ONCE',
|
||||
IS_ABOVE: 'IS ABOVE',
|
||||
IS_ABOVE: 'ABOVE',
|
||||
} as const;
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
@@ -125,7 +125,10 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
|
||||
};
|
||||
|
||||
const verifySelectRenders = (title: string): void => {
|
||||
const select = screen.getByTitle(title);
|
||||
let select = screen.queryByTitle(title);
|
||||
if (!select) {
|
||||
select = screen.getByText(title);
|
||||
}
|
||||
expect(select).toBeInTheDocument();
|
||||
};
|
||||
|
||||
|
||||
@@ -2,11 +2,37 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import {
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import * as context from '../../context';
|
||||
import ThresholdItem from '../ThresholdItem';
|
||||
import { ThresholdItemProps } from '../types';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock: any = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
uplotMock.paths = paths;
|
||||
return uplotMock;
|
||||
});
|
||||
|
||||
const mockSetAlertState = jest.fn();
|
||||
const mockSetThresholdState = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
alertState: INITIAL_ALERT_STATE,
|
||||
setAlertState: mockSetAlertState,
|
||||
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
setThresholdState: mockSetThresholdState,
|
||||
} as any);
|
||||
|
||||
const TEST_CONSTANTS = {
|
||||
THRESHOLD_ID: 'test-threshold-1',
|
||||
CRITICAL_LABEL: 'CRITICAL',
|
||||
@@ -16,6 +42,7 @@ const TEST_CONSTANTS = {
|
||||
CHANNEL_2: 'channel-2',
|
||||
CHANNEL_3: 'channel-3',
|
||||
EMAIL_CHANNEL_NAME: 'Email Channel',
|
||||
EMAIL_CHANNEL_TRUNCATED: 'Email Chan...',
|
||||
ENTER_THRESHOLD_NAME: 'Enter threshold name',
|
||||
ENTER_THRESHOLD_VALUE: 'Enter threshold value',
|
||||
ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value',
|
||||
@@ -117,7 +144,7 @@ describe('ThresholdItem', () => {
|
||||
const valueInput = screen.getByPlaceholderText(
|
||||
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
|
||||
);
|
||||
expect(valueInput).toHaveValue('100');
|
||||
expect(valueInput).toHaveValue(100);
|
||||
});
|
||||
|
||||
it('renders unit selector with correct value', () => {
|
||||
@@ -130,9 +157,8 @@ describe('ThresholdItem', () => {
|
||||
it('renders channels selector with correct value', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// Check for the channels selector by looking for the displayed text
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_TRUNCATED),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -246,7 +272,9 @@ describe('ThresholdItem', () => {
|
||||
const recoveryButton = buttons[0]; // First button is the recovery button
|
||||
fireEvent.click(recoveryButton);
|
||||
|
||||
expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Enter recovery threshold value'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE),
|
||||
).toBeInTheDocument();
|
||||
@@ -290,7 +318,7 @@ describe('ThresholdItem', () => {
|
||||
|
||||
// Check that channels are rendered as multiple select
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_TRUNCATED),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should be able to select multiple channels
|
||||
@@ -313,7 +341,7 @@ describe('ThresholdItem', () => {
|
||||
renderThresholdItem({ threshold: emptyThreshold });
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0');
|
||||
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue(0);
|
||||
});
|
||||
|
||||
it('renders with correct input widths', () => {
|
||||
@@ -326,13 +354,13 @@ describe('ThresholdItem', () => {
|
||||
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
|
||||
);
|
||||
|
||||
expect(labelInput).toHaveStyle('width: 260px');
|
||||
expect(valueInput).toHaveStyle('width: 210px');
|
||||
expect(labelInput).toHaveStyle('width: 200px');
|
||||
expect(valueInput).toHaveStyle('width: 100px');
|
||||
});
|
||||
|
||||
it('renders channels selector with correct width', () => {
|
||||
renderThresholdItem();
|
||||
verifySelectorWidth(1, '260px');
|
||||
verifySelectorWidth(1, '350px');
|
||||
});
|
||||
|
||||
it('renders unit selector with correct width', () => {
|
||||
@@ -352,30 +380,7 @@ describe('ThresholdItem', () => {
|
||||
const recoveryValueInput = screen.getByPlaceholderText(
|
||||
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
|
||||
);
|
||||
expect(recoveryValueInput).toHaveValue('80');
|
||||
});
|
||||
|
||||
it('renders recovery threshold label as disabled', () => {
|
||||
renderThresholdItem();
|
||||
showRecoveryThreshold();
|
||||
|
||||
const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold');
|
||||
expect(recoveryLabelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders correct channel options', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// Check that channels are rendered
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should be able to select different channels
|
||||
const channelSelectors = screen.getAllByRole('combobox');
|
||||
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
|
||||
fireEvent.change(channelSelector, { target: { value: 'channel-2' } });
|
||||
expect(screen.getByText('Slack Channel')).toBeInTheDocument();
|
||||
expect(recoveryValueInput).toHaveValue(80);
|
||||
});
|
||||
|
||||
it('handles threshold without channels', () => {
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
padding-right: 72px;
|
||||
background-color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
|
||||
.alert-condition-sentences {
|
||||
display: flex;
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 240px !important;
|
||||
width: 240px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
@@ -148,6 +148,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
@@ -293,7 +294,8 @@
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
width: auto;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
@@ -301,6 +303,7 @@
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
@@ -308,6 +311,7 @@
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
font-size: 12px;
|
||||
@@ -318,3 +322,229 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-condition-container {
|
||||
.alert-condition {
|
||||
.alert-condition-tabs {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
.explorer-view-option {
|
||||
border-left: 0.5px solid var(--bg-vanilla-300);
|
||||
border-bottom: 0.5px solid var(--bg-vanilla-300);
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-threshold-container,
|
||||
.anomaly-threshold-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.alert-condition-sentences {
|
||||
.alert-condition-sentence {
|
||||
.sentence-text {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--text-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thresholds-section {
|
||||
.threshold-item {
|
||||
.threshold-row {
|
||||
.threshold-controls {
|
||||
.threshold-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recovery-threshold-input-group {
|
||||
.recovery-threshold-btn {
|
||||
color: var(--bg-ink-400);
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-threshold-btn {
|
||||
border: 1px dashed var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.condensed-evaluation-settings-container {
|
||||
.ant-btn {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
min-width: 240px;
|
||||
width: auto;
|
||||
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-ink-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
color: var(--bg-ink-400);
|
||||
flex-shrink: 0;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.highlighted-text {
|
||||
font-weight: bold;
|
||||
color: var(--bg-robin-400);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
// Tooltip styles
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
.tooltip-description {
|
||||
margin-bottom: 8px;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-example {
|
||||
margin-bottom: 8px;
|
||||
color: #8b92a0;
|
||||
}
|
||||
|
||||
.tooltip-link {
|
||||
.tooltip-link-text {
|
||||
color: #1890ff;
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type UpdateThreshold = {
|
||||
(
|
||||
thresholdId: string,
|
||||
field: Exclude<keyof Threshold, 'channels'>,
|
||||
value: string,
|
||||
value: string | number | null,
|
||||
): void;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
|
||||
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
@@ -44,3 +48,303 @@ export function getCategorySelectOptionByName(
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
||||
const getOperatorWord = (op: AlertThresholdOperator): string => {
|
||||
switch (op) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return 'exceed';
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return 'fall below';
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return 'equal';
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return 'not equal';
|
||||
default:
|
||||
return 'exceed';
|
||||
}
|
||||
};
|
||||
|
||||
const getThresholdValue = (op: AlertThresholdOperator): number => {
|
||||
switch (op) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return 80;
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return 50;
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return 100;
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return 0;
|
||||
default:
|
||||
return 80;
|
||||
}
|
||||
};
|
||||
|
||||
const getDataPoints = (
|
||||
matchType: AlertThresholdMatchType,
|
||||
op: AlertThresholdOperator,
|
||||
): number[] => {
|
||||
const dataPointMap: Record<
|
||||
AlertThresholdMatchType,
|
||||
Record<AlertThresholdOperator, number[]>
|
||||
> = {
|
||||
[AlertThresholdMatchType.AT_LEAST_ONCE]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [60, 45, 40, 55, 35],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.ALL_THE_TIME]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [45, 40, 35, 42, 38],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.ON_AVERAGE]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [60, 40, 45, 35, 45],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.IN_TOTAL]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [8, 5, 10, 12, 8],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30],
|
||||
},
|
||||
[AlertThresholdMatchType.LAST]: {
|
||||
[AlertThresholdOperator.IS_BELOW]: [75, 85, 90, 78, 45],
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
};
|
||||
|
||||
return dataPointMap[matchType]?.[op] || [75, 85, 90, 78, 95];
|
||||
};
|
||||
|
||||
const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => {
|
||||
const symbolMap: Record<AlertThresholdOperator, string> = {
|
||||
[AlertThresholdOperator.IS_ABOVE]: '>',
|
||||
[AlertThresholdOperator.IS_BELOW]: '<',
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: '=',
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=',
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: '>',
|
||||
};
|
||||
return symbolMap[op] || '>';
|
||||
};
|
||||
|
||||
const handleTooltipClick = (
|
||||
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
function TooltipContent({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleTooltipClick}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleTooltipClick(e);
|
||||
}
|
||||
}}
|
||||
className="tooltip-content"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipExample({
|
||||
children,
|
||||
dataPoints,
|
||||
operatorSymbol,
|
||||
thresholdValue,
|
||||
matchType,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
dataPoints: number[];
|
||||
operatorSymbol: string;
|
||||
thresholdValue: number;
|
||||
matchType: AlertThresholdMatchType;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="tooltip-example">
|
||||
<strong>Example:</strong>
|
||||
<br />
|
||||
Say, For a 5-minute window (configured in Evaluation settings), 1 min
|
||||
aggregation interval (set up in query) → 5{' '}
|
||||
{matchType === AlertThresholdMatchType.IN_TOTAL
|
||||
? 'error counts'
|
||||
: 'data points'}
|
||||
: [{dataPoints.join(', ')}]<br />
|
||||
With threshold {operatorSymbol} {thresholdValue}: {children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipLink(): JSX.Element {
|
||||
return (
|
||||
<div className="tooltip-link">
|
||||
<a
|
||||
href="https://signoz.io/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="tooltip-link-text"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getMatchTypeTooltip = (
|
||||
matchType: AlertThresholdMatchType,
|
||||
operator: AlertThresholdOperator,
|
||||
): React.ReactNode => {
|
||||
const operatorSymbol = getTooltipOperatorSymbol(operator);
|
||||
const operatorWord = getOperatorWord(operator);
|
||||
const thresholdValue = getThresholdValue(operator);
|
||||
const dataPoints = getDataPoints(matchType, operator);
|
||||
const getMatchingPointsCount = (): number =>
|
||||
dataPoints.filter((p) => {
|
||||
switch (operator) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return p > thresholdValue;
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return p < thresholdValue;
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return p === thresholdValue;
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return p !== thresholdValue;
|
||||
default:
|
||||
return p > thresholdValue;
|
||||
}
|
||||
}).length;
|
||||
|
||||
switch (matchType) {
|
||||
case AlertThresholdMatchType.AT_LEAST_ONCE:
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers if <span>ANY</span> of
|
||||
those aggregated data points crosses the threshold.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers ({getMatchingPointsCount()} points {operatorWord}{' '}
|
||||
{thresholdValue})
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
|
||||
case AlertThresholdMatchType.ALL_THE_TIME:
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers if <span>ALL</span>{' '}
|
||||
aggregated data points cross the threshold.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (all points {operatorWord} {thresholdValue})<br />
|
||||
If any point was {thresholdValue}, no alert would fire
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
|
||||
case AlertThresholdMatchType.ON_AVERAGE: {
|
||||
const average = (
|
||||
dataPoints.reduce((a, b) => a + b, 0) / dataPoints.length
|
||||
).toFixed(1);
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers if the{' '}
|
||||
<span>AVERAGE</span> of all aggregated data points crosses the threshold.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (average = {average})
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
case AlertThresholdMatchType.IN_TOTAL: {
|
||||
const total = dataPoints.reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers if the{' '}
|
||||
<span>SUM</span> of all aggregated data points crosses the threshold.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (total = {total})
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
case AlertThresholdMatchType.LAST: {
|
||||
const lastPoint = dataPoints[dataPoints.length - 1];
|
||||
return (
|
||||
<TooltipContent>
|
||||
<div className="tooltip-description">
|
||||
Data is aggregated at each interval within your evaluation window,
|
||||
creating multiple data points. This option triggers based on the{' '}
|
||||
<span>MOST RECENT</span> aggregated data point only.
|
||||
</div>
|
||||
<TooltipExample
|
||||
dataPoints={dataPoints}
|
||||
operatorSymbol={operatorSymbol}
|
||||
thresholdValue={thresholdValue}
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (last point = {lastPoint})
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,15 +49,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
className="alert-header__input title"
|
||||
placeholder="Enter alert rule name"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={alertState.description}
|
||||
onChange={(e): void =>
|
||||
setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value })
|
||||
}
|
||||
className="alert-header__input description"
|
||||
placeholder="Click to add description..."
|
||||
/>
|
||||
<LabelsInput
|
||||
labels={alertState.labels}
|
||||
onLabelsChange={(labels: Labels): void =>
|
||||
|
||||
@@ -44,14 +44,6 @@ describe('CreateAlertHeader', () => {
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description input with placeholder', () => {
|
||||
renderCreateAlertHeader();
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
'Click to add description...',
|
||||
);
|
||||
expect(descriptionInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders LabelsInput component', () => {
|
||||
renderCreateAlertHeader();
|
||||
expect(screen.getByText('+ Add labels')).toBeInTheDocument();
|
||||
@@ -65,13 +57,4 @@ describe('CreateAlertHeader', () => {
|
||||
|
||||
expect(nameInput).toHaveValue('Test Alert');
|
||||
});
|
||||
|
||||
it('updates description when typing in description input', () => {
|
||||
renderCreateAlertHeader();
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
'Click to add description...',
|
||||
);
|
||||
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
|
||||
expect(descriptionInput).toHaveValue('Test Description');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,3 +149,75 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-header {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
|
||||
&__tab-bar {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#f5f5f5,
|
||||
#f5f5f5 10px,
|
||||
#e5e5e5 10px,
|
||||
#e5e5e5 20px
|
||||
);
|
||||
}
|
||||
|
||||
&__tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
&__tab::before {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
&__content {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&__input.title {
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
&__input.description {
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.labels-input {
|
||||
&__add-button {
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__label-pill {
|
||||
background-color: #ad7f581a;
|
||||
color: var(--bg-sienna-400);
|
||||
border: 1px solid var(--bg-sienna-500);
|
||||
}
|
||||
|
||||
&__remove-button {
|
||||
color: var(--bg-sienna-400);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
color: var(--bg-ink-500);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
$top-nav-background-1: #0f0f0f;
|
||||
$top-nav-background-2: #101010;
|
||||
|
||||
$top-nav-background-1-light: #f5f5f5;
|
||||
$top-nav-background-2-light: #e5e5e5;
|
||||
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-ink-500);
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.top-nav-container {
|
||||
@@ -15,3 +19,19 @@ $top-nav-background-2: #101010;
|
||||
);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.top-nav-container {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
$top-nav-background-1-light,
|
||||
$top-nav-background-1-light 10px,
|
||||
$top-nav-background-2-light 10px,
|
||||
$top-nav-background-2-light 20px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import AlertCondition from './AlertCondition';
|
||||
import { CreateAlertProvider } from './context';
|
||||
import CreateAlertHeader from './CreateAlertHeader';
|
||||
import EvaluationSettings from './EvaluationSettings';
|
||||
import Footer from './Footer';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import QuerySection from './QuerySection';
|
||||
import { showCondensedLayout } from './utils';
|
||||
|
||||
@@ -27,7 +29,9 @@ function CreateAlertV2({
|
||||
<QuerySection />
|
||||
<AlertCondition />
|
||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<Footer />
|
||||
</CreateAlertProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Switch, Typography } from 'antd';
|
||||
import './styles.scss';
|
||||
|
||||
import { Switch, Tooltip, Typography } from 'antd';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { IAdvancedOptionItemProps } from './types';
|
||||
import { IAdvancedOptionItemProps } from '../types';
|
||||
|
||||
function AdvancedOptionItem({
|
||||
title,
|
||||
description,
|
||||
input,
|
||||
tooltipText,
|
||||
onToggle,
|
||||
}: IAdvancedOptionItemProps): JSX.Element {
|
||||
const [showInput, setShowInput] = useState<boolean>(false);
|
||||
|
||||
const onToggle = (): void => {
|
||||
const handleOnToggle = (): void => {
|
||||
onToggle?.();
|
||||
setShowInput((currentShowInput) => !currentShowInput);
|
||||
};
|
||||
|
||||
@@ -19,14 +25,24 @@ function AdvancedOptionItem({
|
||||
<div className="advanced-option-item-left-content">
|
||||
<Typography.Text className="advanced-option-item-title">
|
||||
{title}
|
||||
{tooltipText && (
|
||||
<Tooltip title={tooltipText}>
|
||||
<Info data-testid="tooltip-icon" size={16} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="advanced-option-item-description">
|
||||
{description}
|
||||
</Typography.Text>
|
||||
{showInput && <div className="advanced-option-item-input">{input}</div>}
|
||||
</div>
|
||||
<div className="advanced-option-item-right-content">
|
||||
<Switch onChange={onToggle} />
|
||||
<div
|
||||
className="advanced-option-item-input"
|
||||
style={{ display: showInput ? 'block' : 'none' }}
|
||||
>
|
||||
{input}
|
||||
</div>
|
||||
<Switch onChange={handleOnToggle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
import AdvancedOptionItem from './AdvancedOptionItem';
|
||||
|
||||
export default AdvancedOptionItem;
|
||||
@@ -0,0 +1,250 @@
|
||||
.advanced-option-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
.advanced-option-item-left-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.advanced-option-item-input {
|
||||
margin-top: 16px;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&::placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-right-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.advanced-option-item-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-ink-200);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.advanced-option-item {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.advanced-option-item-left-content {
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.advanced-option-item-input {
|
||||
.ant-input {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-right-content {
|
||||
.advanced-option-item-input-group {
|
||||
.ant-input {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-button {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Collapse, Input, Select } from 'antd';
|
||||
import { Collapse, Input, Select, Typography } from 'antd';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
@@ -18,14 +18,15 @@ function AdvancedOptions(): JSX.Element {
|
||||
<Collapse.Panel header="ADVANCED OPTIONS" key="1">
|
||||
<EvaluationCadence />
|
||||
<AdvancedOptionItem
|
||||
title="Send a notification if data is missing"
|
||||
description="If data is missing for this alert rule for a certain time period, notify in the default notification channel."
|
||||
title="Alert when data stops coming"
|
||||
description="Send notification if no data is received for a specified time period."
|
||||
tooltipText="Useful for monitoring data pipelines or services that should continuously send data. For example, alert if no logs are received for 10 minutes"
|
||||
input={
|
||||
<Input.Group>
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter tolerance limit..."
|
||||
type="number"
|
||||
style={{ width: 240 }}
|
||||
style={{ width: 100 }}
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
@@ -53,37 +54,42 @@ function AdvancedOptions(): JSX.Element {
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
|
||||
/>
|
||||
</Input.Group>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Enforce minimum datapoints"
|
||||
description="Run alert evaluation only when there are minimum of pre-defined number of data points in each result group"
|
||||
title="Minimum data required"
|
||||
description="Only trigger alert when there are enough data points to make a reliable decision."
|
||||
tooltipText="Prevents false alarms when there's insufficient data. For example, require at least 5 data points before checking if CPU usage is above 80%."
|
||||
input={
|
||||
<Input
|
||||
placeholder="Enter minimum datapoints..."
|
||||
style={{ width: 360 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: {
|
||||
minimumDatapoints: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
/>
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter minimum datapoints..."
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: {
|
||||
minimumDatapoints: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Delay evaluation"
|
||||
description="Delay the evaluation of newer groups to prevent noisy alerts."
|
||||
{/* <AdvancedOptionItem
|
||||
title="Account for data delay"
|
||||
description="Shift the evaluation window backwards to account for data processing delays."
|
||||
tooltipText="Use when your data takes time to arrive on the platform. For example, if logs typically arrive 5 minutes late, set a 5-minute delay so the alert checks the correct time window."
|
||||
input={
|
||||
<Input.Group>
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter delay..."
|
||||
style={{ width: 240 }}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
@@ -111,9 +117,9 @@ function AdvancedOptions(): JSX.Element {
|
||||
}
|
||||
value={advancedOptions.delayEvaluation.timeUnit}
|
||||
/>
|
||||
</Input.Group>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
import { Button, DatePicker, Input, Select, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Calendar,
|
||||
Calendar1,
|
||||
Code,
|
||||
Edit,
|
||||
Edit3Icon,
|
||||
Info,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS,
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
} from '../context/constants';
|
||||
import { AdvancedOptionsState } from '../context/types';
|
||||
import {
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS,
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS,
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS,
|
||||
} from './constants';
|
||||
import TimeInput from './TimeInput';
|
||||
import { IEvaluationCadenceDetailsProps } from './types';
|
||||
import {
|
||||
buildAlertScheduleFromCustomSchedule,
|
||||
buildAlertScheduleFromRRule,
|
||||
isValidRRule,
|
||||
TIMEZONE_DATA,
|
||||
} from './utils';
|
||||
|
||||
export function EvaluationCadenceDetails({
|
||||
setIsOpen,
|
||||
}: IEvaluationCadenceDetailsProps): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
const [evaluationCadence, setEvaluationCadence] = useState<
|
||||
AdvancedOptionsState['evaluationCadence']
|
||||
>({
|
||||
...advancedOptions.evaluationCadence,
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Editor',
|
||||
icon: <Edit3Icon size={14} />,
|
||||
value: 'editor',
|
||||
},
|
||||
{
|
||||
label: 'RRule',
|
||||
icon: <Code size={14} />,
|
||||
value: 'rrule',
|
||||
},
|
||||
];
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'rrule'>(() =>
|
||||
evaluationCadence.mode === 'custom' ? 'editor' : 'rrule',
|
||||
);
|
||||
|
||||
const occurenceOptions =
|
||||
evaluationCadence.custom.repeatEvery === 'week'
|
||||
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS
|
||||
: EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS;
|
||||
|
||||
const EditorView = (
|
||||
<div className="editor-view" data-testid="editor-view">
|
||||
<div className="select-group">
|
||||
<Typography.Text>REPEAT EVERY</Typography.Text>
|
||||
<Select
|
||||
options={EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS}
|
||||
value={evaluationCadence.custom.repeatEvery || null}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
repeatEvery: value,
|
||||
occurence: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select repeat every"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>ON DAY(S)</Typography.Text>
|
||||
<Select
|
||||
options={occurenceOptions}
|
||||
value={evaluationCadence.custom.occurence || null}
|
||||
mode="multiple"
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
occurence: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select day(s)"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationCadence.custom.startAt}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
startAt: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationCadence.custom.timezone || null}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
timezone: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const RRuleView = (
|
||||
<div className="rrule-view" data-testid="rrule-view">
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON</Typography.Text>
|
||||
<DatePicker
|
||||
value={evaluationCadence.rrule.date}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
date: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationCadence.rrule.startAt}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
startAt: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
value={evaluationCadence.rrule.rrule}
|
||||
placeholder="Enter RRule"
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
rrule: value.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setIsOpen(false);
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'default',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveCustomSchedule = (): void => {
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
custom: evaluationCadence.custom,
|
||||
rrule: evaluationCadence.rrule,
|
||||
},
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: evaluationCadence.mode,
|
||||
});
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const disableSaveButton = useMemo(() => {
|
||||
if (activeTab === 'editor') {
|
||||
return (
|
||||
!evaluationCadence.custom.repeatEvery ||
|
||||
!evaluationCadence.custom.occurence.length ||
|
||||
!evaluationCadence.custom.startAt ||
|
||||
!evaluationCadence.custom.timezone
|
||||
);
|
||||
}
|
||||
return (
|
||||
!evaluationCadence.rrule.rrule ||
|
||||
!evaluationCadence.rrule.date ||
|
||||
!evaluationCadence.rrule.startAt ||
|
||||
!isValidRRule(evaluationCadence.rrule.rrule)
|
||||
);
|
||||
}, [evaluationCadence, activeTab]);
|
||||
|
||||
const schedule = useMemo(() => {
|
||||
if (activeTab === 'rrule') {
|
||||
return buildAlertScheduleFromRRule(
|
||||
evaluationCadence.rrule.rrule,
|
||||
evaluationCadence.rrule.date,
|
||||
evaluationCadence.rrule.startAt,
|
||||
15,
|
||||
);
|
||||
}
|
||||
return buildAlertScheduleFromCustomSchedule(
|
||||
evaluationCadence.custom.repeatEvery,
|
||||
evaluationCadence.custom.occurence,
|
||||
evaluationCadence.custom.startAt,
|
||||
evaluationCadence.custom.timezone,
|
||||
15,
|
||||
);
|
||||
}, [evaluationCadence, activeTab]);
|
||||
|
||||
const handleChangeTab = (tab: 'editor' | 'rrule'): void => {
|
||||
setActiveTab(tab);
|
||||
const mode = tab === 'editor' ? 'custom' : 'rrule';
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
mode,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-cadence-details">
|
||||
<Typography.Text className="evaluation-cadence-details-title">
|
||||
Add Custom Schedule
|
||||
</Typography.Text>
|
||||
<div className="evaluation-cadence-details-content">
|
||||
<div className="evaluation-cadence-details-content-row">
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.value}
|
||||
className={classNames('list-view-tab', 'explorer-view-option', {
|
||||
'active-tab': activeTab === tab.value,
|
||||
})}
|
||||
onClick={(): void => {
|
||||
handleChangeTab(tab.value as 'editor' | 'rrule');
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'editor' && EditorView}
|
||||
{activeTab === 'rrule' && RRuleView}
|
||||
<div className="buttons-row">
|
||||
<Button type="default" onClick={handleDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSaveCustomSchedule}
|
||||
disabled={disableSaveButton}
|
||||
>
|
||||
Save Custom Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="evaluation-cadence-details-content-row">
|
||||
{schedule ? (
|
||||
<div className="schedule-preview">
|
||||
<div className="schedule-preview-header">
|
||||
<Calendar size={16} />
|
||||
<Typography.Text className="schedule-preview-title">
|
||||
Schedule Preview
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="schedule-preview-list">
|
||||
{schedule.map((date) => (
|
||||
<div key={date.toISOString()} className="schedule-preview-item">
|
||||
<div className="schedule-preview-timeline">
|
||||
<div className="schedule-preview-timeline-line" />
|
||||
</div>
|
||||
<div className="schedule-preview-content">
|
||||
<div className="schedule-preview-date">
|
||||
{date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
,{' '}
|
||||
{date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div className="schedule-preview-separator" />
|
||||
<div className="schedule-preview-timezone">
|
||||
UTC {date.getTimezoneOffset() <= 0 ? '+' : '-'}{' '}
|
||||
{Math.abs(Math.floor(date.getTimezoneOffset() / 60))}:
|
||||
{String(Math.abs(date.getTimezoneOffset() % 60)).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-schedule">
|
||||
<Info size={32} />
|
||||
<Typography.Text>
|
||||
Please fill the relevant information to generate a schedule
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditCustomSchedule({
|
||||
setIsEvaluationCadenceDetailsVisible,
|
||||
}: {
|
||||
setIsEvaluationCadenceDetailsVisible: (isOpen: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
if (advancedOptions.evaluationCadence.mode === 'custom') {
|
||||
return (
|
||||
<Typography.Text>
|
||||
<Typography.Text>Every</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.repeatEvery
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
advancedOptions.evaluationCadence.custom.repeatEvery.slice(1)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>on</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.occurence
|
||||
.map(
|
||||
(occurence) => occurence.charAt(0).toUpperCase() + occurence.slice(1),
|
||||
)
|
||||
.join(', ')}
|
||||
</Typography.Text>
|
||||
<Typography.Text>at</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.startAt}
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography.Text>
|
||||
<Typography.Text>Starting on</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.rrule.date?.format('DD/MM/YYYY')}
|
||||
</Typography.Text>
|
||||
<Typography.Text>at</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.rrule.startAt}
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
);
|
||||
}, [advancedOptions.evaluationCadence]);
|
||||
|
||||
const handlePreviewAndEdit = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(true);
|
||||
};
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(false);
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'default',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="edit-custom-schedule">
|
||||
{displayText}
|
||||
<div className="button-row">
|
||||
<Button.Group>
|
||||
<Button type="default" onClick={handlePreviewAndEdit}>
|
||||
<Edit size={12} />
|
||||
<Typography.Text>Edit custom schedule</Typography.Text>
|
||||
</Button>
|
||||
<Button type="default" onClick={handlePreviewAndEdit}>
|
||||
<Calendar1 size={12} />
|
||||
<Typography.Text>Preview</Typography.Text>
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="discard-button"
|
||||
type="default"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationCadence(): JSX.Element {
|
||||
const [
|
||||
isEvaluationCadenceDetailsVisible,
|
||||
setIsEvaluationCadenceDetailsVisible,
|
||||
] = useState(false);
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
const showCustomScheduleButton = useMemo(
|
||||
() =>
|
||||
!isEvaluationCadenceDetailsVisible &&
|
||||
advancedOptions.evaluationCadence.mode === 'default',
|
||||
[isEvaluationCadenceDetailsVisible, advancedOptions.evaluationCadence.mode],
|
||||
);
|
||||
|
||||
const showCustomSchedule = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(true);
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'custom',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-cadence-container">
|
||||
<div className="advanced-option-item evaluation-cadence-item">
|
||||
<div className="advanced-option-item-left-content">
|
||||
<Typography.Text className="advanced-option-item-title">
|
||||
Evaluation cadence
|
||||
</Typography.Text>
|
||||
<Typography.Text className="advanced-option-item-description">
|
||||
Customize when this Alert Rule will run. By default, it runs every 60
|
||||
seconds (1 minute).
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{showCustomScheduleButton && (
|
||||
<div className="advanced-option-item-right-content">
|
||||
<Input.Group className="advanced-option-item-input-group">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter time"
|
||||
style={{ width: 180 }}
|
||||
value={advancedOptions.evaluationCadence.default.value}
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
default: {
|
||||
...advancedOptions.evaluationCadence.default,
|
||||
value: Number(value.target.value),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
|
||||
placeholder="Select time unit"
|
||||
style={{ width: 120 }}
|
||||
value={advancedOptions.evaluationCadence.default.timeUnit}
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
default: {
|
||||
...advancedOptions.evaluationCadence.default,
|
||||
timeUnit: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Input.Group>
|
||||
<Button
|
||||
className="advanced-option-item-button"
|
||||
onClick={showCustomSchedule}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<Typography.Text>Add custom schedule</Typography.Text>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEvaluationCadenceDetailsVisible &&
|
||||
advancedOptions.evaluationCadence.mode !== 'default' && (
|
||||
<EditCustomSchedule
|
||||
setIsEvaluationCadenceDetailsVisible={
|
||||
setIsEvaluationCadenceDetailsVisible
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isEvaluationCadenceDetailsVisible && (
|
||||
<EvaluationCadenceDetails
|
||||
isOpen={isEvaluationCadenceDetailsVisible}
|
||||
setIsOpen={setIsEvaluationCadenceDetailsVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationCadence;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
import { IEditCustomScheduleProps } from 'container/CreateAlertV2/EvaluationSettings/types';
|
||||
import { Calendar1, Edit, Trash } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
function EditCustomSchedule({
|
||||
setIsEvaluationCadenceDetailsVisible,
|
||||
setIsPreviewVisible,
|
||||
}: IEditCustomScheduleProps): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
if (advancedOptions.evaluationCadence.mode === 'custom') {
|
||||
return (
|
||||
<Typography.Text>
|
||||
<Typography.Text>Every</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.repeatEvery
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
advancedOptions.evaluationCadence.custom.repeatEvery.slice(1)}
|
||||
</Typography.Text>
|
||||
{advancedOptions.evaluationCadence.custom.repeatEvery !== 'day' && (
|
||||
<>
|
||||
<Typography.Text>on</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.occurence
|
||||
.map(
|
||||
(occurence) => occurence.charAt(0).toUpperCase() + occurence.slice(1),
|
||||
)
|
||||
.join(', ')}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text>at</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.custom.startAt}
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography.Text>
|
||||
<Typography.Text>Starting on</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.rrule.date?.format('DD/MM/YYYY')}
|
||||
</Typography.Text>
|
||||
<Typography.Text>at</Typography.Text>
|
||||
<Typography.Text className="highlight">
|
||||
{advancedOptions.evaluationCadence.rrule.startAt}
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
);
|
||||
}, [advancedOptions.evaluationCadence]);
|
||||
|
||||
const handleEdit = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(true);
|
||||
};
|
||||
|
||||
const handlePreview = (): void => {
|
||||
setIsPreviewVisible(true);
|
||||
};
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(false);
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'default',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="edit-custom-schedule">
|
||||
{displayText}
|
||||
<div className="button-row">
|
||||
<Button.Group>
|
||||
<Button type="default" onClick={handleEdit}>
|
||||
<Edit size={12} />
|
||||
<Typography.Text>Edit custom schedule</Typography.Text>
|
||||
</Button>
|
||||
<Button type="default" onClick={handlePreview}>
|
||||
<Calendar1 size={12} />
|
||||
<Typography.Text>Preview</Typography.Text>
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="discard-button"
|
||||
type="default"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
<Trash size={12} />
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditCustomSchedule;
|
||||
@@ -0,0 +1,135 @@
|
||||
import './styles.scss';
|
||||
import '../AdvancedOptionItem/styles.scss';
|
||||
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../../context';
|
||||
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
|
||||
import EditCustomSchedule from './EditCustomSchedule';
|
||||
import EvaluationCadenceDetails from './EvaluationCadenceDetails';
|
||||
import EvaluationCadencePreview from './EvaluationCadencePreview';
|
||||
|
||||
function EvaluationCadence(): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
const [
|
||||
isEvaluationCadenceDetailsVisible,
|
||||
setIsEvaluationCadenceDetailsVisible,
|
||||
] = useState(false);
|
||||
const [
|
||||
isCustomScheduleButtonVisible,
|
||||
setIsCustomScheduleButtonVisible,
|
||||
] = useState(true);
|
||||
const [
|
||||
isEvaluationCadencePreviewVisible,
|
||||
setIsEvaluationCadencePreviewVisible,
|
||||
] = useState(false);
|
||||
const [isEditCustomScheduleVisible, setIsEditCustomScheduleVisible] = useState(
|
||||
() => advancedOptions.evaluationCadence.mode !== 'default',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditCustomScheduleVisible(
|
||||
advancedOptions.evaluationCadence.mode !== 'default',
|
||||
);
|
||||
}, [advancedOptions.evaluationCadence.mode]);
|
||||
|
||||
const showCustomSchedule = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(true);
|
||||
setIsCustomScheduleButtonVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-cadence-container">
|
||||
<div className="advanced-option-item evaluation-cadence-item">
|
||||
<div className="advanced-option-item-left-content">
|
||||
<Typography.Text className="advanced-option-item-title">
|
||||
How often to check
|
||||
<Tooltip title="Controls how frequently the alert evaluates your conditions. For most alerts, 1-5 minutes is sufficient.">
|
||||
<Info data-testid="evaluation-cadence-tooltip-icon" size={16} />
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="advanced-option-item-description">
|
||||
How frequently this alert checks your data. Default: Every 1 minute
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isCustomScheduleButtonVisible && (
|
||||
<div
|
||||
className="advanced-option-item-right-content"
|
||||
data-testid="evaluation-cadence-input-group"
|
||||
>
|
||||
<Input.Group className="advanced-option-item-input-group">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter time"
|
||||
style={{ width: 180 }}
|
||||
value={advancedOptions.evaluationCadence.default.value}
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
default: {
|
||||
...advancedOptions.evaluationCadence.default,
|
||||
value: Number(value.target.value),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
|
||||
placeholder="Select time unit"
|
||||
style={{ width: 120 }}
|
||||
value={advancedOptions.evaluationCadence.default.timeUnit}
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
default: {
|
||||
...advancedOptions.evaluationCadence.default,
|
||||
timeUnit: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Input.Group>
|
||||
{/* Add custom schedule - hidden for now */}
|
||||
{/* <Button
|
||||
className="advanced-option-item-button"
|
||||
onClick={showCustomSchedule}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<Typography.Text>Add custom schedule</Typography.Text>
|
||||
</Button> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditCustomScheduleVisible && (
|
||||
<EditCustomSchedule
|
||||
setIsEvaluationCadenceDetailsVisible={setIsEvaluationCadenceDetailsVisible}
|
||||
setIsPreviewVisible={setIsEvaluationCadencePreviewVisible}
|
||||
/>
|
||||
)}
|
||||
{isEvaluationCadenceDetailsVisible && (
|
||||
<EvaluationCadenceDetails
|
||||
isOpen={isEvaluationCadenceDetailsVisible}
|
||||
setIsOpen={setIsEvaluationCadenceDetailsVisible}
|
||||
setIsCustomScheduleButtonVisible={setIsCustomScheduleButtonVisible}
|
||||
/>
|
||||
)}
|
||||
{isEvaluationCadencePreviewVisible && (
|
||||
<EvaluationCadencePreview
|
||||
isOpen={isEvaluationCadencePreviewVisible}
|
||||
setIsOpen={setIsEvaluationCadencePreviewVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationCadence;
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Button, DatePicker, Select, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import classNames from 'classnames';
|
||||
import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import { AdvancedOptionsState } from 'container/CreateAlertV2/context/types';
|
||||
import dayjs from 'dayjs';
|
||||
import { Code, Edit3Icon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS,
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS,
|
||||
EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS,
|
||||
TIMEZONE_DATA,
|
||||
} from '../constants';
|
||||
import TimeInput from '../TimeInput';
|
||||
import { IEvaluationCadenceDetailsProps } from '../types';
|
||||
import {
|
||||
buildAlertScheduleFromCustomSchedule,
|
||||
buildAlertScheduleFromRRule,
|
||||
isValidRRule,
|
||||
} from '../utils';
|
||||
import { ScheduleList } from './EvaluationCadencePreview';
|
||||
|
||||
function EvaluationCadenceDetails({
|
||||
setIsOpen,
|
||||
setIsCustomScheduleButtonVisible,
|
||||
}: IEvaluationCadenceDetailsProps): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
const [evaluationCadence, setEvaluationCadence] = useState<
|
||||
AdvancedOptionsState['evaluationCadence']
|
||||
>({
|
||||
...advancedOptions.evaluationCadence,
|
||||
mode: 'custom',
|
||||
custom: {
|
||||
...advancedOptions.evaluationCadence.custom,
|
||||
startAt: dayjs().format('HH:mm:ss'),
|
||||
},
|
||||
rrule: {
|
||||
...advancedOptions.evaluationCadence.rrule,
|
||||
startAt: dayjs().format('HH:mm:ss'),
|
||||
},
|
||||
});
|
||||
|
||||
const [searchTimezoneString, setSearchTimezoneString] = useState('');
|
||||
const [occurenceSearchString, setOccurenceSearchString] = useState('');
|
||||
const [repeatEverySearchString, setRepeatEverySearchString] = useState('');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Editor',
|
||||
icon: <Edit3Icon size={14} />,
|
||||
value: 'editor',
|
||||
},
|
||||
{
|
||||
label: 'RRule',
|
||||
icon: <Code size={14} />,
|
||||
value: 'rrule',
|
||||
},
|
||||
];
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'rrule'>(() =>
|
||||
evaluationCadence.mode === 'custom' ? 'editor' : 'rrule',
|
||||
);
|
||||
|
||||
const occurenceOptions =
|
||||
evaluationCadence.custom.repeatEvery === 'week'
|
||||
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS
|
||||
: EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS;
|
||||
|
||||
useEffect(() => {
|
||||
if (!evaluationCadence.custom.occurence.length) {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const dayOfMonth = today.getDate();
|
||||
|
||||
const occurence =
|
||||
evaluationCadence.custom.repeatEvery === 'week'
|
||||
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS[dayOfWeek].value
|
||||
: dayOfMonth.toString();
|
||||
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
occurence: [occurence],
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [evaluationCadence.custom.repeatEvery]);
|
||||
|
||||
const EditorView = (
|
||||
<div className="editor-view" data-testid="editor-view">
|
||||
<div className="select-group">
|
||||
<Typography.Text>REPEAT EVERY</Typography.Text>
|
||||
<Select
|
||||
options={EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS}
|
||||
value={evaluationCadence.custom.repeatEvery || null}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
repeatEvery: value,
|
||||
occurence: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select repeat every"
|
||||
showSearch
|
||||
searchValue={repeatEverySearchString}
|
||||
onSearch={setRepeatEverySearchString}
|
||||
/>
|
||||
</div>
|
||||
{evaluationCadence.custom.repeatEvery !== 'day' && (
|
||||
<div className="select-group">
|
||||
<Typography.Text>ON DAY(S)</Typography.Text>
|
||||
<Select
|
||||
options={occurenceOptions}
|
||||
value={evaluationCadence.custom.occurence || null}
|
||||
mode="multiple"
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
occurence: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select day(s)"
|
||||
showSearch
|
||||
searchValue={occurenceSearchString}
|
||||
onSearch={setOccurenceSearchString}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="select-group">
|
||||
<Typography.Text>AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationCadence.custom.startAt}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
startAt: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationCadence.custom.timezone || null}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
custom: {
|
||||
...evaluationCadence.custom,
|
||||
timezone: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select timezone"
|
||||
onSearch={setSearchTimezoneString}
|
||||
searchValue={searchTimezoneString}
|
||||
showSearch
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const RRuleView = (
|
||||
<div className="rrule-view" data-testid="rrule-view">
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON</Typography.Text>
|
||||
<DatePicker
|
||||
value={evaluationCadence.rrule.date}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
date: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationCadence.rrule.startAt}
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
startAt: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
value={evaluationCadence.rrule.rrule}
|
||||
placeholder="Enter RRule"
|
||||
onChange={(value): void =>
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
rrule: {
|
||||
...evaluationCadence.rrule,
|
||||
rrule: value.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setIsOpen(false);
|
||||
setIsCustomScheduleButtonVisible(true);
|
||||
};
|
||||
|
||||
const handleSaveCustomSchedule = (): void => {
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...advancedOptions.evaluationCadence,
|
||||
custom: evaluationCadence.custom,
|
||||
rrule: evaluationCadence.rrule,
|
||||
},
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: evaluationCadence.mode,
|
||||
});
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const disableSaveButton = useMemo(() => {
|
||||
if (activeTab === 'editor') {
|
||||
if (evaluationCadence.custom.repeatEvery === 'day') {
|
||||
return (
|
||||
!evaluationCadence.custom.repeatEvery ||
|
||||
!evaluationCadence.custom.startAt ||
|
||||
!evaluationCadence.custom.timezone
|
||||
);
|
||||
}
|
||||
return (
|
||||
!evaluationCadence.custom.repeatEvery ||
|
||||
!evaluationCadence.custom.occurence.length ||
|
||||
!evaluationCadence.custom.startAt ||
|
||||
!evaluationCadence.custom.timezone
|
||||
);
|
||||
}
|
||||
return (
|
||||
!evaluationCadence.rrule.rrule ||
|
||||
!evaluationCadence.rrule.date ||
|
||||
!evaluationCadence.rrule.startAt ||
|
||||
!isValidRRule(evaluationCadence.rrule.rrule)
|
||||
);
|
||||
}, [evaluationCadence, activeTab]);
|
||||
|
||||
const schedule = useMemo(() => {
|
||||
if (activeTab === 'rrule') {
|
||||
return buildAlertScheduleFromRRule(
|
||||
evaluationCadence.rrule.rrule,
|
||||
evaluationCadence.rrule.date,
|
||||
evaluationCadence.rrule.startAt,
|
||||
15,
|
||||
);
|
||||
}
|
||||
return buildAlertScheduleFromCustomSchedule(
|
||||
evaluationCadence.custom.repeatEvery,
|
||||
evaluationCadence.custom.occurence,
|
||||
evaluationCadence.custom.startAt,
|
||||
15,
|
||||
);
|
||||
}, [evaluationCadence, activeTab]);
|
||||
|
||||
const handleChangeTab = (tab: 'editor' | 'rrule'): void => {
|
||||
setActiveTab(tab);
|
||||
const mode = tab === 'editor' ? 'custom' : 'rrule';
|
||||
setEvaluationCadence({
|
||||
...evaluationCadence,
|
||||
mode,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-cadence-details">
|
||||
<Typography.Text className="evaluation-cadence-details-title">
|
||||
Add Custom Schedule
|
||||
</Typography.Text>
|
||||
<div className="evaluation-cadence-details-content">
|
||||
<div className="evaluation-cadence-details-content-row">
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.value}
|
||||
className={classNames('list-view-tab', 'explorer-view-option', {
|
||||
'active-tab': activeTab === tab.value,
|
||||
})}
|
||||
onClick={(): void => {
|
||||
handleChangeTab(tab.value as 'editor' | 'rrule');
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'editor' && EditorView}
|
||||
{activeTab === 'rrule' && RRuleView}
|
||||
<div className="buttons-row">
|
||||
<Button type="default" onClick={handleDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSaveCustomSchedule}
|
||||
disabled={disableSaveButton}
|
||||
>
|
||||
Save Custom Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="evaluation-cadence-details-content-row">
|
||||
<ScheduleList
|
||||
schedule={schedule}
|
||||
currentTimezone={evaluationCadence.custom.timezone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationCadenceDetails;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Modal, Typography } from 'antd';
|
||||
import { Calendar, Info } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../../context';
|
||||
import { TIMEZONE_DATA } from '../constants';
|
||||
import { IEvaluationCadencePreviewProps, IScheduleListProps } from '../types';
|
||||
import {
|
||||
buildAlertScheduleFromCustomSchedule,
|
||||
buildAlertScheduleFromRRule,
|
||||
} from '../utils';
|
||||
|
||||
export function ScheduleList({
|
||||
schedule,
|
||||
currentTimezone,
|
||||
}: IScheduleListProps): JSX.Element {
|
||||
if (schedule && schedule.length > 0) {
|
||||
return (
|
||||
<div className="schedule-preview" data-testid="schedule-preview">
|
||||
<div className="schedule-preview-header">
|
||||
<Calendar size={16} />
|
||||
<Typography.Text className="schedule-preview-title">
|
||||
Schedule Preview
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="schedule-preview-list">
|
||||
{schedule.map((date) => (
|
||||
<div key={date.toISOString()} className="schedule-preview-item">
|
||||
<div className="schedule-preview-timeline">
|
||||
<div className="schedule-preview-timeline-line" />
|
||||
</div>
|
||||
<div className="schedule-preview-content">
|
||||
<div className="schedule-preview-date">
|
||||
{date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
,{' '}
|
||||
{date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div className="schedule-preview-separator" />
|
||||
<div className="schedule-preview-timezone">
|
||||
{
|
||||
TIMEZONE_DATA.find((timezone) => timezone.value === currentTimezone)
|
||||
?.label
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="no-schedule" data-testid="no-schedule">
|
||||
<Info size={32} />
|
||||
<Typography.Text>
|
||||
Please fill the relevant information to generate a schedule
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationCadencePreview({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: IEvaluationCadencePreviewProps): JSX.Element {
|
||||
const { advancedOptions } = useCreateAlertState();
|
||||
|
||||
const schedule = useMemo(() => {
|
||||
if (advancedOptions.evaluationCadence.mode === 'rrule') {
|
||||
return buildAlertScheduleFromRRule(
|
||||
advancedOptions.evaluationCadence.rrule.rrule,
|
||||
advancedOptions.evaluationCadence.rrule.date,
|
||||
advancedOptions.evaluationCadence.rrule.startAt,
|
||||
15,
|
||||
);
|
||||
}
|
||||
return buildAlertScheduleFromCustomSchedule(
|
||||
advancedOptions.evaluationCadence.custom.repeatEvery,
|
||||
advancedOptions.evaluationCadence.custom.occurence,
|
||||
advancedOptions.evaluationCadence.custom.startAt,
|
||||
15,
|
||||
);
|
||||
}, [advancedOptions.evaluationCadence]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={(): void => setIsOpen(false)}
|
||||
footer={null}
|
||||
className="evaluation-cadence-preview-modal"
|
||||
width={800}
|
||||
centered
|
||||
>
|
||||
<div className="evaluation-cadence-details evaluation-cadence-preview">
|
||||
<div className="evaluation-cadence-details-content">
|
||||
<div className="evaluation-cadence-details-content-row">
|
||||
<ScheduleList
|
||||
schedule={schedule}
|
||||
currentTimezone={advancedOptions.evaluationCadence.custom.timezone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationCadencePreview;
|
||||
@@ -0,0 +1,5 @@
|
||||
import './styles.scss';
|
||||
|
||||
import EvaluationCadence from './EvaluationCadence';
|
||||
|
||||
export default EvaluationCadence;
|
||||
@@ -0,0 +1,700 @@
|
||||
.evaluation-cadence-container {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
.evaluation-cadence-item {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.edit-custom-schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
|
||||
.highlight {
|
||||
background-color: var(--bg-slate-500);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 500;
|
||||
margin: 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn-group {
|
||||
.ant-btn {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details {
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
|
||||
.evaluation-cadence-details-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding-left: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.query-section-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.query-section-query-actions {
|
||||
display: flex;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
flex-direction: row;
|
||||
border-bottom: none;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.explorer-view-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
border: none;
|
||||
padding: 9px;
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
border-left: 0.5px solid var(--bg-slate-400);
|
||||
border-bottom: 0.5px solid var(--bg-slate-400);
|
||||
width: 120px;
|
||||
height: 36px;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-ink-500);
|
||||
border-bottom: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-ink-300);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
border-left: 1px solid transparent !important;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details-content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
|
||||
.evaluation-cadence-details-content-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
height: 500px;
|
||||
overflow-y: scroll;
|
||||
padding-right: 16px;
|
||||
|
||||
.editor-view,
|
||||
.rrule-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
textarea {
|
||||
height: 200px;
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
font-family: 'Space Mono';
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-picker-input {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.no-schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.schedule-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
background-color: var(--bg-ink-400);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
.schedule-preview-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-top: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
|
||||
.schedule-preview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
|
||||
.schedule-preview-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 20px;
|
||||
|
||||
.schedule-preview-timeline-line {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
|
||||
.schedule-preview-date {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-preview-separator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
border-top: 1px dashed var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.schedule-preview-timezone {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker-date-panel {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-layout {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-header {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
// Custom modal styles for preview
|
||||
.evaluation-cadence-preview-modal {
|
||||
.ant-modal-content {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
background-color: var(--bg-ink-400);
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 16px 20px;
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
color: var(--bg-vanilla-400);
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
background-color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.evaluation-cadence-details {
|
||||
border: none;
|
||||
margin: 0;
|
||||
|
||||
.evaluation-cadence-details-content {
|
||||
border-top: none;
|
||||
padding: 0;
|
||||
|
||||
.evaluation-cadence-details-content-row {
|
||||
height: auto;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-400);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
.schedule-preview-header {
|
||||
background-color: var(--bg-ink-400);
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 12px 16px;
|
||||
margin: -12px -12px 16px -12px;
|
||||
|
||||
.schedule-preview-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-list {
|
||||
.schedule-preview-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.schedule-preview-timeline {
|
||||
.schedule-preview-timeline-line {
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background-color: var(--bg-robin-500);
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-content {
|
||||
.schedule-preview-date {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.schedule-preview-timezone {
|
||||
background-color: var(--bg-slate-500);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-schedule {
|
||||
min-height: 300px;
|
||||
padding: 40px 12px;
|
||||
|
||||
svg {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.evaluation-cadence-container {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.edit-custom-schedule {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
.highlight {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn-group {
|
||||
.ant-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.evaluation-cadence-details-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.query-section-tabs {
|
||||
.query-section-query-actions {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
.explorer-view-option {
|
||||
border-left: 0.5px solid var(--bg-vanilla-300);
|
||||
border-bottom: 0.5px solid var(--bg-vanilla-300);
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details-content {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.evaluation-cadence-details-content-row {
|
||||
.editor-view,
|
||||
.rrule-view {
|
||||
textarea {
|
||||
background: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.select-group {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-picker-input {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-schedule {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
.schedule-preview-header {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.schedule-preview-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-list {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.schedule-preview-item {
|
||||
.schedule-preview-timeline {
|
||||
.schedule-preview-timeline-line {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-content {
|
||||
.schedule-preview-date {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.schedule-preview-separator {
|
||||
border-top: 1px dashed var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.schedule-preview-timezone {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker-date-panel {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-layout {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-header {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
// Light mode styles for preview modal
|
||||
.evaluation-cadence-preview-modal {
|
||||
.ant-modal-content {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.evaluation-cadence-details {
|
||||
.evaluation-cadence-details-content {
|
||||
.evaluation-cadence-details-content-row {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
.schedule-preview-header {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.schedule-preview-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-list {
|
||||
.schedule-preview-item {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.schedule-preview-timeline {
|
||||
.schedule-preview-timeline-line {
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-content {
|
||||
.schedule-preview-date {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.schedule-preview-separator {
|
||||
border-top: 1px dashed var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.schedule-preview-timezone {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-schedule {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
svg {
|
||||
color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,6 @@ function EvaluationSettings(): JSX.Element {
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
isOpen={isEvaluationWindowPopoverOpen}
|
||||
setIsOpen={setIsEvaluationWindowPopoverOpen}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
@@ -43,7 +41,7 @@ function EvaluationSettings(): JSX.Element {
|
||||
>
|
||||
<Button>
|
||||
<div className="evaluate-alert-conditions-button-left">
|
||||
{getTimeframeText(evaluationWindow.windowType, evaluationWindow.timeframe)}
|
||||
{getTimeframeText(evaluationWindow)}
|
||||
</div>
|
||||
<div className="evaluate-alert-conditions-button-right">
|
||||
<div className="evaluate-alert-conditions-button-right-text">
|
||||
@@ -59,20 +57,28 @@ function EvaluationSettings(): JSX.Element {
|
||||
</Popover>
|
||||
);
|
||||
|
||||
// Layout consists of only the evaluation window popover
|
||||
if (showCondensedLayoutFlag) {
|
||||
return (
|
||||
<div className="condensed-evaluation-settings-container">
|
||||
<div
|
||||
className="condensed-evaluation-settings-container"
|
||||
data-testid="condensed-evaluation-settings-container"
|
||||
>
|
||||
{popoverContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Layout consists of
|
||||
// - Stepper header
|
||||
// - Evaluation window popover
|
||||
// - Advanced options
|
||||
return (
|
||||
<div className="evaluation-settings-container">
|
||||
<Stepper stepNumber={3} label="Evaluation settings" />
|
||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
|
||||
<div className="evaluate-alert-conditions-container">
|
||||
<Typography.Text>Evaluate Alert Conditions over</Typography.Text>
|
||||
<Typography.Text>Check conditions using data from</Typography.Text>
|
||||
<div className="evaluate-alert-conditions-separator" />
|
||||
{popoverContent}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
/* eslint-disable jsx-a11y/interactive-supports-focus */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Input, Select, Typography } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
|
||||
import {
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
} from './constants';
|
||||
import TimeInput from './TimeInput';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
IEvaluationWindowDetailsProps,
|
||||
IEvaluationWindowPopoverProps,
|
||||
RollingWindowTimeframes,
|
||||
} from './types';
|
||||
import { TIMEZONE_DATA } from './utils';
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
TIMEZONE_DATA,
|
||||
} from '../constants';
|
||||
import TimeInput from '../TimeInput';
|
||||
import { IEvaluationWindowDetailsProps } from '../types';
|
||||
import { getCumulativeWindowTimeframeText } from '../utils';
|
||||
|
||||
function EvaluationWindowDetails({
|
||||
evaluationWindow,
|
||||
@@ -38,7 +31,27 @@ function EvaluationWindowDetails({
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
const displayText = useMemo(() => {
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe === 'custom'
|
||||
) {
|
||||
return `Last ${evaluationWindow.startingAt.number} ${
|
||||
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
|
||||
(option) => option.value === evaluationWindow.startingAt.unit,
|
||||
)?.label
|
||||
}`;
|
||||
}
|
||||
if (evaluationWindow.windowType === 'cumulative') {
|
||||
return getCumulativeWindowTimeframeText(evaluationWindow);
|
||||
}
|
||||
return '';
|
||||
}, [evaluationWindow]);
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe !== 'custom'
|
||||
) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
@@ -59,6 +72,7 @@ function EvaluationWindowDetails({
|
||||
number: value,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -70,6 +84,19 @@ function EvaluationWindowDetails({
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: value,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnitChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -81,6 +108,7 @@ function EvaluationWindowDetails({
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: value,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -88,6 +116,10 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentHour) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription('currentHour')}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING AT MINUTE</Typography.Text>
|
||||
<Select
|
||||
@@ -104,6 +136,10 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentDay) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription('currentDay')}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
<TimeInput
|
||||
@@ -127,6 +163,10 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentMonth) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription('currentMonth')}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON DAY</Typography.Text>
|
||||
<Select
|
||||
@@ -156,103 +196,36 @@ function EvaluationWindowDetails({
|
||||
);
|
||||
}
|
||||
|
||||
return <div />;
|
||||
}
|
||||
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const renderEvaluationWindowContent = (
|
||||
label: string,
|
||||
contentOptions: Array<{ label: string; value: string }>,
|
||||
currentValue: string,
|
||||
onChange: (value: string) => void,
|
||||
): JSX.Element => (
|
||||
<div className="evaluation-window-content-item">
|
||||
<Typography.Text className="evaluation-window-content-item-label">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<div className="evaluation-window-content-list">
|
||||
{contentOptions.map((option) => (
|
||||
<div
|
||||
className={classNames('evaluation-window-content-list-item', {
|
||||
active: currentValue === option.value,
|
||||
})}
|
||||
key={option.value}
|
||||
role="button"
|
||||
onClick={(): void => onChange(option.value)}
|
||||
>
|
||||
<Typography.Text>{option.label}</Typography.Text>
|
||||
{currentValue === option.value && <Check size={12} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectionContent = (): JSX.Element => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
A Rolling Window has a fixed size and shifts its starting point over time
|
||||
based on when the rules are evaluated.
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
!evaluationWindow.timeframe
|
||||
) {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
A Cumulative Window has a fixed starting point and expands over time.
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-window-popover">
|
||||
<div className="evaluation-window-content">
|
||||
{renderEvaluationWindowContent(
|
||||
'EVALUATION WINDOW',
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
evaluationWindow.windowType,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
}),
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>
|
||||
{getRollingWindowDescription(
|
||||
`${evaluationWindow.startingAt.number}${evaluationWindow.startingAt.unit}`,
|
||||
)}
|
||||
{renderEvaluationWindowContent(
|
||||
'TIMEFRAME',
|
||||
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
|
||||
evaluationWindow.timeframe,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
}),
|
||||
)}
|
||||
{renderSelectionContent()}
|
||||
</Typography.Text>
|
||||
<Typography.Text>Specify custom duration</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>VALUE</Typography.Text>
|
||||
<Input
|
||||
name="value"
|
||||
type="number"
|
||||
value={evaluationWindow.startingAt.number}
|
||||
onChange={(e): void => handleNumberChange(e.target.value)}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>UNIT</Typography.Text>
|
||||
<Select
|
||||
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
|
||||
value={evaluationWindow.startingAt.unit || null}
|
||||
onChange={handleUnitChange}
|
||||
placeholder="Select unit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
export default EvaluationWindowDetails;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
} from '../constants';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
IEvaluationWindowPopoverProps,
|
||||
RollingWindowTimeframes,
|
||||
} from '../types';
|
||||
import EvaluationWindowDetails from './EvaluationWindowDetails';
|
||||
import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNavigation';
|
||||
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const {
|
||||
containerRef,
|
||||
firstItemRef,
|
||||
} = useKeyboardNavigationForEvaluationWindowPopover({
|
||||
onSelect: (value: string, sectionId: string): void => {
|
||||
if (sectionId === 'window-type') {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
});
|
||||
} else if (sectionId === 'timeframe') {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
});
|
||||
}
|
||||
},
|
||||
onEscape: (): void => {
|
||||
const triggerElement = document.querySelector(
|
||||
'[aria-haspopup="true"]',
|
||||
) as HTMLElement;
|
||||
triggerElement?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const renderEvaluationWindowContent = (
|
||||
label: string,
|
||||
contentOptions: Array<{ label: string; value: string }>,
|
||||
currentValue: string,
|
||||
onChange: (value: string) => void,
|
||||
sectionId: string,
|
||||
): JSX.Element => (
|
||||
<div className="evaluation-window-content-item" data-section-id={sectionId}>
|
||||
<Typography.Text className="evaluation-window-content-item-label">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<div className="evaluation-window-content-list">
|
||||
{contentOptions.map((option, index) => (
|
||||
<div
|
||||
className={classNames('evaluation-window-content-list-item', {
|
||||
active: currentValue === option.value,
|
||||
})}
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(option.value);
|
||||
}
|
||||
}}
|
||||
ref={index === 0 ? firstItemRef : undefined}
|
||||
>
|
||||
<Typography.Text>{option.label}</Typography.Text>
|
||||
{currentValue === option.value && <Check size={12} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectionContent = (): JSX.Element => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
if (evaluationWindow.timeframe === 'custom') {
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
{getRollingWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
!evaluationWindow.timeframe
|
||||
) {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="evaluation-window-popover"
|
||||
ref={containerRef}
|
||||
role="menu"
|
||||
aria-label="Evaluation window options"
|
||||
>
|
||||
<div className="evaluation-window-content">
|
||||
{renderEvaluationWindowContent(
|
||||
'EVALUATION WINDOW',
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
evaluationWindow.windowType,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
}),
|
||||
'window-type',
|
||||
)}
|
||||
{renderEvaluationWindowContent(
|
||||
'TIMEFRAME',
|
||||
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
|
||||
evaluationWindow.timeframe,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
}),
|
||||
'timeframe',
|
||||
)}
|
||||
{renderSelectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@@ -0,0 +1,3 @@
|
||||
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseKeyboardNavigationOptions {
|
||||
onSelect?: (value: string, sectionId: string) => void;
|
||||
onEscape?: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavigationForEvaluationWindowPopover = ({
|
||||
onSelect,
|
||||
onEscape,
|
||||
}: UseKeyboardNavigationOptions = {}): {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
firstItemRef: React.RefObject<HTMLDivElement>;
|
||||
} => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const firstItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getFocusableItems = useCallback((): HTMLElement[] => {
|
||||
if (!containerRef.current) return [];
|
||||
|
||||
return Array.from(
|
||||
containerRef.current.querySelectorAll(
|
||||
'.evaluation-window-content-list-item[tabindex="0"]',
|
||||
),
|
||||
) as HTMLElement[];
|
||||
}, []);
|
||||
|
||||
const getInteractiveElements = useCallback((): HTMLElement[] => {
|
||||
if (!containerRef.current) return [];
|
||||
|
||||
const detailsSection = containerRef.current.querySelector(
|
||||
'.evaluation-window-details',
|
||||
);
|
||||
if (!detailsSection) return [];
|
||||
|
||||
return Array.from(
|
||||
detailsSection.querySelectorAll(
|
||||
'input, select, button, [tabindex="0"], [tabindex="-1"]',
|
||||
),
|
||||
) as HTMLElement[];
|
||||
}, []);
|
||||
|
||||
const getCurrentIndex = useCallback((items: HTMLElement[]): number => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
return items.findIndex((item) => item === activeElement);
|
||||
}, []);
|
||||
|
||||
const navigateWithinSection = useCallback(
|
||||
(direction: 'up' | 'down'): void => {
|
||||
const items = getFocusableItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = getCurrentIndex(items);
|
||||
let nextIndex: number;
|
||||
if (direction === 'down') {
|
||||
nextIndex = (currentIndex + 1) % items.length;
|
||||
} else {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
}
|
||||
|
||||
items[nextIndex]?.focus();
|
||||
},
|
||||
[getFocusableItems, getCurrentIndex],
|
||||
);
|
||||
|
||||
const navigateToDetails = useCallback((): void => {
|
||||
const interactiveElements = getInteractiveElements();
|
||||
interactiveElements[0]?.focus();
|
||||
}, [getInteractiveElements]);
|
||||
|
||||
const navigateBackToSection = useCallback((): void => {
|
||||
const items = getFocusableItems();
|
||||
items[0]?.focus();
|
||||
}, [getFocusableItems]);
|
||||
|
||||
const navigateBetweenSections = useCallback(
|
||||
(direction: 'left' | 'right'): void => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const isInDetails = activeElement?.closest('.evaluation-window-details');
|
||||
|
||||
if (isInDetails && direction === 'left') {
|
||||
navigateBackToSection();
|
||||
return;
|
||||
}
|
||||
|
||||
const items = getFocusableItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = getCurrentIndex(items);
|
||||
const DATA_ATTR = 'data-section-id';
|
||||
const currentSectionId = items[currentIndex]?.getAttribute(DATA_ATTR);
|
||||
|
||||
if (currentSectionId === 'window-type' && direction === 'right') {
|
||||
const timeframeItem = items.find(
|
||||
(item) => item.getAttribute(DATA_ATTR) === 'timeframe',
|
||||
);
|
||||
timeframeItem?.focus();
|
||||
} else if (currentSectionId === 'timeframe' && direction === 'left') {
|
||||
const windowTypeItem = items.find(
|
||||
(item) => item.getAttribute(DATA_ATTR) === 'window-type',
|
||||
);
|
||||
windowTypeItem?.focus();
|
||||
} else if (currentSectionId === 'timeframe' && direction === 'right') {
|
||||
navigateToDetails();
|
||||
}
|
||||
},
|
||||
[
|
||||
navigateBackToSection,
|
||||
navigateToDetails,
|
||||
getFocusableItems,
|
||||
getCurrentIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSelection = useCallback((): void => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (!activeElement || !onSelect) return;
|
||||
|
||||
const value = activeElement.getAttribute('data-value');
|
||||
const sectionId = activeElement.getAttribute('data-section-id');
|
||||
|
||||
if (value && sectionId) {
|
||||
onSelect(value, sectionId);
|
||||
}
|
||||
}, [onSelect]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
navigateWithinSection('down');
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
navigateWithinSection('up');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
navigateBetweenSections('left');
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
navigateBetweenSections('right');
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
handleSelection();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
onEscape?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigateWithinSection, navigateBetweenSections, handleSelection, onEscape],
|
||||
);
|
||||
|
||||
useEffect((): (() => void) | undefined => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => container.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (firstItemRef.current) {
|
||||
firstItemRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef: containerRef as React.RefObject<HTMLDivElement>,
|
||||
firstItemRef: firstItemRef as React.RefObject<HTMLDivElement>,
|
||||
};
|
||||
};
|
||||
@@ -49,3 +49,40 @@
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.time-input-container {
|
||||
.time-input-field {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,46 +25,80 @@ function TimeInput({
|
||||
if (value) {
|
||||
const timeParts = value.split(':');
|
||||
if (timeParts.length === 3) {
|
||||
setHours(timeParts[0].padStart(2, '0'));
|
||||
setMinutes(timeParts[1].padStart(2, '0'));
|
||||
setSeconds(timeParts[2].padStart(2, '0'));
|
||||
setHours(timeParts[0]);
|
||||
setMinutes(timeParts[1]);
|
||||
setSeconds(timeParts[2]);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Format time value
|
||||
const formatTimeValue = (h: string, m: string, s: string): string =>
|
||||
`${h.padStart(2, '0')}:${m.padStart(2, '0')}:${s.padStart(2, '0')}`;
|
||||
const notifyChange = (h: string, m: string, s: string): void => {
|
||||
const rawValue = `${h}:${m}:${s}`;
|
||||
onChange?.(rawValue);
|
||||
};
|
||||
|
||||
// Handle input change
|
||||
const handleTimeChange = (
|
||||
newHours: string,
|
||||
newMinutes: string,
|
||||
newSeconds: string,
|
||||
): void => {
|
||||
const formattedValue = formatTimeValue(newHours, newMinutes, newSeconds);
|
||||
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);
|
||||
};
|
||||
|
||||
// Handle hours change
|
||||
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newHours = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
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);
|
||||
handleTimeChange(newHours, minutes, seconds);
|
||||
notifyChange(newHours, minutes, seconds);
|
||||
};
|
||||
|
||||
// Handle minutes change
|
||||
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newMinutes = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
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);
|
||||
handleTimeChange(hours, newMinutes, seconds);
|
||||
notifyChange(hours, newMinutes, seconds);
|
||||
};
|
||||
|
||||
// Handle seconds change
|
||||
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newSeconds = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
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);
|
||||
handleTimeChange(hours, minutes, 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
|
||||
@@ -116,30 +150,36 @@ function TimeInput({
|
||||
data-field="hours"
|
||||
value={hours}
|
||||
onChange={handleHoursChange}
|
||||
onBlur={handleHoursBlur}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
data-field="minutes"
|
||||
value={minutes}
|
||||
onChange={handleMinutesChange}
|
||||
onBlur={handleMinutesBlur}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
data-field="seconds"
|
||||
value={seconds}
|
||||
onChange={handleSecondsChange}
|
||||
onBlur={handleSecondsBlur}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import AdvancedOptionItem from '../AdvancedOptionItem';
|
||||
import AdvancedOptionItem from '../AdvancedOptionItem/AdvancedOptionItem';
|
||||
|
||||
const TEST_INPUT_PLACEHOLDER = 'Test input';
|
||||
const TEST_TITLE = 'Test Title';
|
||||
const TEST_DESCRIPTION = 'Test Description';
|
||||
const TEST_VALUE = 'test value';
|
||||
const FIRST_INPUT_PLACEHOLDER = 'First input';
|
||||
const TEST_INPUT_TEST_ID = 'test-input';
|
||||
|
||||
describe('AdvancedOptionItem', () => {
|
||||
@@ -28,7 +27,7 @@ describe('AdvancedOptionItem', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render title and description', () => {
|
||||
it('should render title, description and switch', () => {
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
@@ -39,16 +38,6 @@ describe('AdvancedOptionItem', () => {
|
||||
|
||||
expect(screen.getByText(TEST_TITLE)).toBeInTheDocument();
|
||||
expect(screen.getByText(TEST_DESCRIPTION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render switch component', () => {
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
expect(switchElement).toBeInTheDocument();
|
||||
@@ -64,7 +53,9 @@ describe('AdvancedOptionItem', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
|
||||
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(inputElement).toBeInTheDocument();
|
||||
expect(inputElement).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should show input when switch is toggled on', async () => {
|
||||
@@ -77,11 +68,17 @@ describe('AdvancedOptionItem', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const initialInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(initialInputElement).toBeInTheDocument();
|
||||
expect(initialInputElement).not.toBeVisible();
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
expect(switchElement).toBeChecked();
|
||||
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
|
||||
const visibleInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(visibleInputElement).toBeInTheDocument();
|
||||
expect(visibleInputElement).toBeVisible();
|
||||
});
|
||||
|
||||
it('should hide input when switch is toggled off', async () => {
|
||||
@@ -96,80 +93,21 @@ describe('AdvancedOptionItem', () => {
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
|
||||
const initialInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(initialInputElement).toBeInTheDocument();
|
||||
expect(initialInputElement).not.toBeVisible();
|
||||
|
||||
// First toggle on
|
||||
await user.click(switchElement);
|
||||
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
// Then toggle off
|
||||
await user.click(switchElement);
|
||||
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle switch state correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
|
||||
// Initial state
|
||||
expect(switchElement).not.toBeChecked();
|
||||
|
||||
// After first click
|
||||
await user.click(switchElement);
|
||||
expect(switchElement).toBeChecked();
|
||||
|
||||
// After second click
|
||||
await user.click(switchElement);
|
||||
expect(switchElement).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should render input with correct props when visible', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(inputElement).toBeInTheDocument();
|
||||
expect(inputElement).toHaveAttribute('placeholder', TEST_INPUT_PLACEHOLDER);
|
||||
});
|
||||
expect(inputElement).toBeVisible();
|
||||
|
||||
it('should handle multiple toggle operations', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
|
||||
// Toggle on
|
||||
// Then toggle off - input should be hidden but still in DOM
|
||||
await user.click(switchElement);
|
||||
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
// Toggle off
|
||||
await user.click(switchElement);
|
||||
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
// Toggle on again
|
||||
await user.click(switchElement);
|
||||
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
|
||||
const hiddenInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(hiddenInputElement).toBeInTheDocument();
|
||||
expect(hiddenInputElement).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should maintain input state when toggling', async () => {
|
||||
@@ -190,59 +128,41 @@ describe('AdvancedOptionItem', () => {
|
||||
await user.type(inputElement, TEST_VALUE);
|
||||
expect(inputElement).toHaveValue(TEST_VALUE);
|
||||
|
||||
// Toggle off
|
||||
// Toggle off - input should still be in DOM but hidden
|
||||
await user.click(switchElement);
|
||||
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
|
||||
const hiddenInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(hiddenInputElement).toBeInTheDocument();
|
||||
expect(hiddenInputElement).not.toBeVisible();
|
||||
|
||||
// Toggle back on - input should be recreated (fresh state)
|
||||
// Toggle back on - input should maintain its previous state
|
||||
await user.click(switchElement);
|
||||
const inputElementAgain = screen.getByTestId(TEST_INPUT_TEST_ID);
|
||||
expect(inputElementAgain).toHaveValue(''); // Fresh input, no previous state
|
||||
expect(inputElementAgain).toHaveValue(TEST_VALUE); // State preserved!
|
||||
});
|
||||
|
||||
it('should render with different title and description', () => {
|
||||
const customTitle = 'Custom Title';
|
||||
const customDescription = 'Custom Description';
|
||||
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={customTitle}
|
||||
description={customDescription}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(customTitle)).toBeInTheDocument();
|
||||
expect(screen.getByText(customDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with complex input component', async () => {
|
||||
const user = userEvent.setup();
|
||||
const complexInput = (
|
||||
<div data-testid="complex-input">
|
||||
<input placeholder={FIRST_INPUT_PLACEHOLDER} />
|
||||
<select>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
it('should not render tooltip icon if tooltipText is not provided', () => {
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={complexInput}
|
||||
input={defaultProps.input}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
const tooltipIcon = screen.queryByTestId('tooltip-icon');
|
||||
expect(tooltipIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('complex-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(FIRST_INPUT_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
it('should render tooltip icon if tooltipText is provided', () => {
|
||||
render(
|
||||
<AdvancedOptionItem
|
||||
title={defaultProps.title}
|
||||
description={defaultProps.description}
|
||||
input={defaultProps.input}
|
||||
tooltipText="mock tooltip text"
|
||||
/>,
|
||||
);
|
||||
const tooltipIcon = screen.getByTestId('tooltip-icon');
|
||||
expect(tooltipIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,193 +1,141 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from '../../context/constants';
|
||||
import AdvancedOptions from '../AdvancedOptions';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dayjs timezone
|
||||
jest.mock('dayjs', () => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = jest.fn((date) => originalDayjs(date));
|
||||
Object.assign(mockDayjs, originalDayjs);
|
||||
((mockDayjs as unknown) as { tz: { guess: jest.Mock } }).tz = {
|
||||
guess: jest.fn(() => 'UTC'),
|
||||
};
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
// Mock Y_AXIS_CATEGORIES
|
||||
jest.mock('components/YAxisUnitSelector/constants', () => ({
|
||||
Y_AXIS_CATEGORIES: [
|
||||
{
|
||||
name: 'Time',
|
||||
units: [
|
||||
{ name: 'Second', id: 's' },
|
||||
{ name: 'Minute', id: 'm' },
|
||||
{ name: 'Hour', id: 'h' },
|
||||
{ name: 'Day', id: 'd' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Mock the context
|
||||
const mockSetAdvancedOptions = jest.fn();
|
||||
jest.mock('../../context', () => ({
|
||||
...jest.requireActual('../../context'),
|
||||
useCreateAlertState: (): {
|
||||
advancedOptions: typeof INITIAL_ADVANCED_OPTIONS_STATE;
|
||||
setAdvancedOptions: jest.Mock;
|
||||
evaluationWindow: typeof INITIAL_EVALUATION_WINDOW_STATE;
|
||||
setEvaluationWindow: jest.Mock;
|
||||
} => ({
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
setEvaluationWindow: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
);
|
||||
|
||||
// Mock EvaluationCadence component
|
||||
jest.mock('../EvaluationCadence', () => ({
|
||||
__esModule: true,
|
||||
default: function MockEvaluationCadence(): JSX.Element {
|
||||
return (
|
||||
<div data-testid="evaluation-cadence">Evaluation Cadence Component</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const TOLERANCE_LIMIT_PLACEHOLDER = 'Enter tolerance limit...';
|
||||
|
||||
const renderAdvancedOptions = (): void => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider>
|
||||
<AdvancedOptions />
|
||||
</CreateAlertProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
const ALERT_WHEN_DATA_STOPS_COMING_TEXT = 'Alert when data stops coming';
|
||||
const MINIMUM_DATA_REQUIRED_TEXT = 'Minimum data required';
|
||||
const ACCOUNT_FOR_DATA_DELAY_TEXT = 'Account for data delay';
|
||||
const ADVANCED_OPTION_ITEM_CLASS = '.advanced-option-item';
|
||||
const SWITCH_ROLE_SELECTOR = '[role="switch"]';
|
||||
|
||||
describe('AdvancedOptions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
it('should render evaluation cadence and the advanced options minimized by default', () => {
|
||||
render(<AdvancedOptions />);
|
||||
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
|
||||
expect(screen.queryByText('How often to check')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const expandAdvancedOptions = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
const collapseHeader = screen.getByRole('button');
|
||||
await user.click(collapseHeader);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('evaluation-cadence')).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it('should render and allow expansion of advanced options', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvancedOptions();
|
||||
|
||||
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
|
||||
|
||||
await expandAdvancedOptions(user);
|
||||
it('should be able to expand the advanced options', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Send a notification if data is missing'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Enforce minimum datapoints')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delay evaluation')).toBeInTheDocument();
|
||||
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
expect(screen.getByText('How often to check')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
|
||||
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Account for data delay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should enable advanced option inputs when switches are toggled', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvancedOptions();
|
||||
it('"Alert when data stops coming" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
await expandAdvancedOptions(user);
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const alertWhenDataStopsComingContainer = screen
|
||||
.getByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const alertWhenDataStopsComingSwitch = alertWhenDataStopsComingContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
// Toggle the first switch (send notification)
|
||||
await user.click(switches[0]);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(TOLERANCE_LIMIT_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(alertWhenDataStopsComingSwitch);
|
||||
|
||||
// Toggle the second switch (minimum datapoints)
|
||||
await user.click(switches[1]);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('Enter minimum datapoints...'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update advanced options state when user interacts with inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvancedOptions();
|
||||
|
||||
await expandAdvancedOptions(user);
|
||||
|
||||
// Enable send notification option
|
||||
const switches = screen.getAllByRole('switch');
|
||||
await user.click(switches[0]);
|
||||
|
||||
// Wait for tolerance input to appear and test interaction
|
||||
const toleranceInput = await screen.findByPlaceholderText(
|
||||
TOLERANCE_LIMIT_PLACEHOLDER,
|
||||
const toleranceInput = screen.getByPlaceholderText(
|
||||
'Enter tolerance limit...',
|
||||
);
|
||||
await user.clear(toleranceInput);
|
||||
await user.type(toleranceInput, '10');
|
||||
fireEvent.change(toleranceInput, { target: { value: '10' } });
|
||||
|
||||
const timeUnitSelect = screen.getByRole('combobox');
|
||||
await user.click(timeUnitSelect);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Minute')).toBeInTheDocument();
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit: 10,
|
||||
timeUnit: 'min',
|
||||
},
|
||||
});
|
||||
await user.click(screen.getByText('Minute'));
|
||||
});
|
||||
|
||||
// Verify that the state update function was called (testing behavior, not exact values)
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalled();
|
||||
it('"Minimum data required" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
// Verify the function was called with the expected action types
|
||||
const { calls } = mockSetAdvancedOptions.mock;
|
||||
const actionTypes = calls.map((call) => call[0].type);
|
||||
expect(actionTypes).toContain('SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING');
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const minimumDataRequiredContainer = screen
|
||||
.getByText(MINIMUM_DATA_REQUIRED_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const minimumDataRequiredSwitch = minimumDataRequiredContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(minimumDataRequiredSwitch);
|
||||
|
||||
const minimumDataRequiredInput = screen.getByPlaceholderText(
|
||||
'Enter minimum datapoints...',
|
||||
);
|
||||
fireEvent.change(minimumDataRequiredInput, { target: { value: '10' } });
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: {
|
||||
minimumDatapoints: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('"Account for data delay" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const accountForDataDelayContainer = screen
|
||||
.getByText(ACCOUNT_FOR_DATA_DELAY_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const accountForDataDelaySwitch = accountForDataDelayContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(accountForDataDelaySwitch);
|
||||
|
||||
const delayInput = screen.getByPlaceholderText('Enter delay...');
|
||||
fireEvent.change(delayInput, { target: { value: '10' } });
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: 10,
|
||||
timeUnit: 'min',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
|
||||
import { TIMEZONE_DATA } from '../constants';
|
||||
import EditCustomSchedule from '../EvaluationCadence/EditCustomSchedule';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
const mockSetAdvancedOptions = jest.fn();
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockSetIsEvaluationCadenceDetailsVisible = jest.fn();
|
||||
const mockSetIsPreviewVisible = jest.fn();
|
||||
|
||||
const EDIT_CUSTOM_SCHEDULE_TEST_ID = '.edit-custom-schedule';
|
||||
|
||||
describe('EditCustomSchedule', () => {
|
||||
it('should render the correct display text for custom mode with daily occurrence', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
custom: {
|
||||
repeatEvery: 'day',
|
||||
startAt: '00:00:00',
|
||||
occurence: [],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<EditCustomSchedule
|
||||
setIsEvaluationCadenceDetailsVisible={
|
||||
mockSetIsEvaluationCadenceDetailsVisible
|
||||
}
|
||||
setIsPreviewVisible={mockSetIsPreviewVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Use textContent to verify the complete text across multiple Typography components
|
||||
const container = screen
|
||||
.getByText('Every')
|
||||
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
|
||||
expect(container).toHaveTextContent('EveryDayat00:00:00');
|
||||
});
|
||||
|
||||
it('should render the correct display text for custom mode with weekly occurrence', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
custom: {
|
||||
repeatEvery: 'week',
|
||||
startAt: '00:00:00',
|
||||
occurence: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<EditCustomSchedule
|
||||
setIsEvaluationCadenceDetailsVisible={
|
||||
mockSetIsEvaluationCadenceDetailsVisible
|
||||
}
|
||||
setIsPreviewVisible={mockSetIsPreviewVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
const container = screen
|
||||
.getByText('Every')
|
||||
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
|
||||
expect(container).toHaveTextContent(
|
||||
'EveryWeekonMonday, Tuesday, Wednesday, Thursday, Fridayat00:00:00',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render the correct display text for custom mode with monthly occurrence', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
custom: {
|
||||
repeatEvery: 'month',
|
||||
startAt: '00:00:00',
|
||||
occurence: ['1'],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<EditCustomSchedule
|
||||
setIsEvaluationCadenceDetailsVisible={
|
||||
mockSetIsEvaluationCadenceDetailsVisible
|
||||
}
|
||||
setIsPreviewVisible={mockSetIsPreviewVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
const container = screen
|
||||
.getByText('Every')
|
||||
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
|
||||
expect(container).toHaveTextContent('EveryMonthon1at00:00:00');
|
||||
});
|
||||
|
||||
it('edit custom schedule action works correctly', () => {
|
||||
render(
|
||||
<EditCustomSchedule
|
||||
setIsEvaluationCadenceDetailsVisible={
|
||||
mockSetIsEvaluationCadenceDetailsVisible
|
||||
}
|
||||
setIsPreviewVisible={mockSetIsPreviewVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Edit custom schedule'));
|
||||
expect(mockSetIsEvaluationCadenceDetailsVisible).toHaveBeenCalledWith(true);
|
||||
expect(mockSetIsPreviewVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preview custom schedule action works correctly', () => {
|
||||
render(
|
||||
<EditCustomSchedule
|
||||
setIsEvaluationCadenceDetailsVisible={
|
||||
mockSetIsEvaluationCadenceDetailsVisible
|
||||
}
|
||||
setIsPreviewVisible={mockSetIsPreviewVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Preview'));
|
||||
expect(mockSetIsPreviewVisible).toHaveBeenCalledWith(true);
|
||||
expect(mockSetIsEvaluationCadenceDetailsVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,210 +1,162 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
|
||||
import * as context from '../../context';
|
||||
import EvaluationCadence, {
|
||||
EvaluationCadenceDetails,
|
||||
} from '../EvaluationCadence';
|
||||
import { TIMEZONE_DATA } from '../constants';
|
||||
import EvaluationCadence from '../EvaluationCadence';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
jest.mock('../EvaluationCadence/EditCustomSchedule', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
setIsPreviewVisible,
|
||||
}: {
|
||||
setIsPreviewVisible: (isPreviewVisible: boolean) => void;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="edit-custom-schedule">
|
||||
<div>EditCustomSchedule</div>
|
||||
<button type="button" onClick={(): void => setIsPreviewVisible(true)}>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('../EvaluationCadence/EvaluationCadenceDetails', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="evaluation-cadence-details">EvaluationCadenceDetails</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('../EvaluationCadence/EvaluationCadencePreview', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="evaluation-cadence-preview">EvaluationCadencePreview</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSetAdvancedOptions = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
} as any);
|
||||
|
||||
const EDIT_CUSTOM_SCHEDULE_TEXT = 'Edit custom schedule';
|
||||
const PREVIEW_TEXT = 'Preview';
|
||||
const EVALUATION_CADENCE_TEXT = 'Evaluation cadence';
|
||||
const EVALUATION_CADENCE_DESCRIPTION_TEXT =
|
||||
'Customize when this Alert Rule will run. By default, it runs every 60 seconds (1 minute).';
|
||||
const EVALUATION_CADENCE_DETAILS_TEST_ID = 'evaluation-cadence-details';
|
||||
const ADD_CUSTOM_SCHEDULE_TEXT = 'Add custom schedule';
|
||||
const SAVE_CUSTOM_SCHEDULE_TEXT = 'Save Custom Schedule';
|
||||
const DISCARD_TEXT = 'Discard';
|
||||
const EVALUATION_CADENCE_PREVIEW_TEST_ID = 'evaluation-cadence-preview';
|
||||
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
}),
|
||||
);
|
||||
|
||||
const EVALUATION_CADENCE_INPUT_GROUP = 'evaluation-cadence-input-group';
|
||||
|
||||
describe('EvaluationCadence', () => {
|
||||
it('should render evaluation cadence component in default mode', () => {
|
||||
it('should render the title, description, tooltip and input group with default values', () => {
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText('How often to check')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
|
||||
screen.getByText(
|
||||
'How frequently this alert checks your data. Default: Every 1 minute',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('evaluation-cadence-tooltip-icon'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_CADENCE_INPUT_GROUP),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter time')).toHaveValue(1);
|
||||
expect(screen.getByText('Minutes')).toBeInTheDocument();
|
||||
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render evaluation cadence component in custom mode', () => {
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
it('should hide the input group when add custom schedule button is clicked', () => {
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
|
||||
screen.getByTestId(EVALUATION_CADENCE_INPUT_GROUP),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_CADENCE_INPUT_GROUP),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render evaluation cadence component in rrule mode', () => {
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'rrule',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
it('should not show the edit custom schedule component in default mode', () => {
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-custom-schedule')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on discard button should reset the evaluation cadence mode to default', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
it('should show the custom schedule text when the mode is custom with selected values', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
custom: {
|
||||
repeatEvery: 'day',
|
||||
startAt: '00:00:00',
|
||||
occurence: [],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
} as any);
|
||||
|
||||
}),
|
||||
);
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
|
||||
|
||||
const discardButton = screen.getByTestId('discard-button');
|
||||
await user.click(discardButton);
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'default',
|
||||
});
|
||||
expect(screen.getByTestId('edit-custom-schedule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on preview button should open the preview modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
} as any);
|
||||
it('should not show evaluation cadence details component in default mode', () => {
|
||||
render(<EvaluationCadence />);
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show evaluation cadence details component when clicked on add custom schedule button', () => {
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.queryByText(SAVE_CUSTOM_SCHEDULE_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(DISCARD_TEXT)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT));
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const previewButton = screen.getByText(PREVIEW_TEXT);
|
||||
await user.click(previewButton);
|
||||
it('should not show evaluation cadence preview component in default mode', () => {
|
||||
render(<EvaluationCadence />);
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking on edit custom schedule button should open the edit custom schedule modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
} as any);
|
||||
|
||||
render(<EvaluationCadence />);
|
||||
|
||||
expect(screen.queryByText(SAVE_CUSTOM_SCHEDULE_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(DISCARD_TEXT)).not.toBeInTheDocument();
|
||||
|
||||
const editCustomScheduleButton = screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT);
|
||||
await user.click(editCustomScheduleButton);
|
||||
|
||||
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const mockSetIsOpen = jest.fn();
|
||||
|
||||
const RULE_VIEW_TEXT = 'RRule';
|
||||
const EDITOR_VIEW_TEST_ID = 'editor-view';
|
||||
const RULE_VIEW_TEST_ID = 'rrule-view';
|
||||
|
||||
describe('EvaluationCadenceDetails', () => {
|
||||
it('should render evaluation cadence details component', () => {
|
||||
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByText('Add Custom Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open the editor tab by default', () => {
|
||||
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(RULE_VIEW_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open the rrule tab when rrule tab is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(RULE_VIEW_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
const rruleTab = screen.getByText(RULE_VIEW_TEXT);
|
||||
await user.click(rruleTab);
|
||||
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
it('should show evaluation cadence preview component when clicked on preview button in custom mode', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<EvaluationCadence />);
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Preview'));
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
import { AdvancedOptionsState } from 'container/CreateAlertV2/context/types';
|
||||
|
||||
import EvaluationCadenceDetails from '../EvaluationCadence/EvaluationCadenceDetails';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
const ENTER_RRULE_PLACEHOLDER = 'Enter RRule';
|
||||
|
||||
jest.mock('dayjs', () => {
|
||||
const actualDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = (date?: any): any => {
|
||||
if (date) {
|
||||
return actualDayjs(date);
|
||||
}
|
||||
// 21 Jan 2025
|
||||
return actualDayjs('2025-01-21T16:31:36.982Z');
|
||||
};
|
||||
Object.keys(actualDayjs).forEach((key) => {
|
||||
if (typeof (actualDayjs as any)[key] === 'function') {
|
||||
(mockDayjs as any)[key] = (actualDayjs as any)[key];
|
||||
}
|
||||
});
|
||||
(mockDayjs as any).tz = {
|
||||
guess: (): string => 'Asia/Saigon',
|
||||
};
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
const INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE: AdvancedOptionsState = {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
};
|
||||
|
||||
const mockSetAdvancedOptions = jest.fn();
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockSetIsOpen = jest.fn();
|
||||
const mockSetIsCustomScheduleButtonVisible = jest.fn();
|
||||
|
||||
const SCHEDULE_PREVIEW_TEST_ID = 'schedule-preview';
|
||||
const NO_SCHEDULE_TEST_ID = 'no-schedule';
|
||||
const EDITOR_VIEW_TEST_ID = 'editor-view';
|
||||
const RULE_VIEW_TEST_ID = 'rrule-view';
|
||||
const SAVE_CUSTOM_SCHEDULE_TEXT = 'Save Custom Schedule';
|
||||
|
||||
describe('EvaluationCadenceDetails', () => {
|
||||
it('should render the evaluation cadence details component with editor mode in daily occurence by default', () => {
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Add Custom Schedule')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('rrule-view')).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('REPEAT EVERY')).toBeInTheDocument();
|
||||
expect(screen.getByText('AT')).toBeInTheDocument();
|
||||
expect(screen.getByText('TIMEZONE')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Discard')).toBeInTheDocument();
|
||||
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('when switching to rrule mode, the rrule view should be rendered with no schedule preview', () => {
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('RRule'));
|
||||
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId(SCHEDULE_PREVIEW_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId(NO_SCHEDULE_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('STARTING ON')).toBeInTheDocument();
|
||||
expect(screen.getByText('AT')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Discard')).toBeInTheDocument();
|
||||
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('when showing weekly occurence, the occurence options should be rendered', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
|
||||
custom: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
|
||||
.custom,
|
||||
repeatEvery: 'week',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify that the "ON DAY(S)" section is rendered for weekly occurrence
|
||||
expect(screen.getByText('ON DAY(S)')).toBeInTheDocument();
|
||||
|
||||
// Verify that the schedule preview is shown as today is selected by default
|
||||
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render schedule preview in weekly occurence when days are selected', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
|
||||
custom: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
|
||||
.custom,
|
||||
repeatEvery: 'week',
|
||||
occurence: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify that the schedule preview is shown because days are selected
|
||||
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('when showing monthly occurence, the occurence options should be rendered', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
|
||||
custom: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
|
||||
.custom,
|
||||
repeatEvery: 'month',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify that the "ON DAY(S)" section is rendered for monthly occurrence
|
||||
expect(screen.getByText('ON DAY(S)')).toBeInTheDocument();
|
||||
|
||||
// Verify that the schedule preview is shown as today is selected by default
|
||||
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render schedule preview in monthly occurence when days are selected', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
|
||||
custom: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
|
||||
.custom,
|
||||
repeatEvery: 'month',
|
||||
occurence: ['1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify that the schedule preview is shown because days are selected
|
||||
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('discard action works correctly', () => {
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Discard'));
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockSetIsCustomScheduleButtonVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('save custom schedule action works correctly', () => {
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT));
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledTimes(2);
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
|
||||
custom: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
|
||||
.custom,
|
||||
// today selected by default
|
||||
occurence: [new Date().getDate().toString()],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'custom',
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert context mock state verification', () => {
|
||||
it('should set the evaluation cadence tab to rrule from custom', () => {
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Switch to RRule tab
|
||||
fireEvent.click(screen.getByText('RRule'));
|
||||
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
// Type in the text box
|
||||
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue('');
|
||||
fireEvent.change(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER), {
|
||||
target: { value: 'RRULE:FREQ=DAILY' },
|
||||
});
|
||||
// Ensure text box content is updated
|
||||
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue(
|
||||
'RRULE:FREQ=DAILY',
|
||||
);
|
||||
});
|
||||
|
||||
it('ensure rrule content is not modified by previous test', () => {
|
||||
render(
|
||||
<EvaluationCadenceDetails
|
||||
isOpen
|
||||
setIsOpen={mockSetIsOpen}
|
||||
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Switch to RRule tab
|
||||
fireEvent.click(screen.getByText('RRule'));
|
||||
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
// Verify text box content
|
||||
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
|
||||
import { TIMEZONE_DATA } from '../constants';
|
||||
import EvaluationCadencePreview, {
|
||||
ScheduleList,
|
||||
} from '../EvaluationCadence/EvaluationCadencePreview';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
jest
|
||||
.spyOn(alertState, 'useCreateAlertState')
|
||||
.mockReturnValue(createMockAlertContextState());
|
||||
|
||||
const mockSetIsOpen = jest.fn();
|
||||
|
||||
describe('EvaluationCadencePreview', () => {
|
||||
it('should render list of dates when schedule is generated', () => {
|
||||
render(<EvaluationCadencePreview isOpen setIsOpen={mockSetIsOpen} />);
|
||||
expect(screen.getByTestId('schedule-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render empty state when no schedule is generated', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
custom: {
|
||||
repeatEvery: 'week',
|
||||
startAt: '00:00:00',
|
||||
occurence: [],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<EvaluationCadencePreview isOpen setIsOpen={mockSetIsOpen} />);
|
||||
expect(screen.getByTestId('no-schedule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleList', () => {
|
||||
const schedule = [
|
||||
new Date('2024-01-15T00:00:00Z'),
|
||||
new Date('2024-01-16T00:00:00Z'),
|
||||
new Date('2024-01-17T00:00:00Z'),
|
||||
new Date('2024-01-18T00:00:00Z'),
|
||||
new Date('2024-01-19T00:00:00Z'),
|
||||
];
|
||||
it('should render list of dates when schedule is generated', () => {
|
||||
render(
|
||||
<ScheduleList
|
||||
schedule={schedule}
|
||||
currentTimezone={TIMEZONE_DATA[0].value}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Please fill the relevant information to generate a schedule',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify all dates are rendered correctly
|
||||
schedule.forEach((date) => {
|
||||
const dateString = date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
const timeString = date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
const combinedString = `${dateString}, ${timeString}`;
|
||||
expect(screen.getByText(combinedString)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify timezone is rendered correctly with each date
|
||||
const timezoneElements = screen.getAllByText(TIMEZONE_DATA[0].label);
|
||||
expect(timezoneElements).toHaveLength(schedule.length);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
import * as utils from 'container/CreateAlertV2/utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import * as context from '../../context';
|
||||
import { INITIAL_EVALUATION_WINDOW_STATE } from '../../context/constants';
|
||||
import EvaluationSettings from '../EvaluationSettings';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
setEvaluationWindow: mockSetEvaluationWindow,
|
||||
} as any);
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'../AdvancedOptions',
|
||||
() =>
|
||||
function MockAdvancedOptions(): JSX.Element {
|
||||
return <div data-testid="advanced-options">Advanced Options</div>;
|
||||
},
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setEvaluationWindow: mockSetEvaluationWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('../AdvancedOptions', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="advanced-options">AdvancedOptions</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const EVALUATION_SETTINGS_TEXT = 'Evaluation settings';
|
||||
const CHECK_CONDITIONS_USING_DATA_FROM_TEXT =
|
||||
'Check conditions using data from';
|
||||
|
||||
describe('EvaluationSettings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render evaluation settings container', () => {
|
||||
it('should render the default evaluation settings layout', () => {
|
||||
render(<EvaluationSettings />);
|
||||
expect(screen.getByText('Evaluation settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render evaluation alert conditions text', () => {
|
||||
render(<EvaluationSettings />);
|
||||
|
||||
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Evaluate Alert Conditions over'),
|
||||
screen.getByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.getByTestId('advanced-options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display correct timeframe text for rolling window', () => {
|
||||
it('should not render evaluation window for anomaly based alert', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
}),
|
||||
);
|
||||
render(<EvaluationSettings />);
|
||||
|
||||
expect(screen.getByText('Last 5 minutes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rolling')).toBeInTheDocument();
|
||||
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display correct timeframe text for cumulative window', () => {
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
evaluationWindow: {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
},
|
||||
} as any);
|
||||
it('should render the condensed evaluation settings layout', () => {
|
||||
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
|
||||
render(<EvaluationSettings />);
|
||||
|
||||
expect(screen.getByText('Current day')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cumulative')).toBeInTheDocument();
|
||||
// Header, check conditions using data from and advanced options should be hidden
|
||||
expect(screen.queryByText(EVALUATION_SETTINGS_TEXT)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('advanced-options')).not.toBeInTheDocument();
|
||||
// Only evaluation window popover should be visible
|
||||
expect(
|
||||
screen.getByTestId('condensed-evaluation-settings-container'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
|
||||
import EvaluationWindowDetails from '../EvaluationWindowPopover/EvaluationWindowDetails';
|
||||
import { createMockEvaluationWindowState } from './testUtils';
|
||||
|
||||
const mockEvaluationWindowState = createMockEvaluationWindowState();
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
|
||||
describe('EvaluationWindowDetails', () => {
|
||||
it('should render the evaluation window details for rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '5',
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Specify custom duration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last 5 Minutes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current hour', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current hour, starting at minute 1 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current day', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current day, starting from 00:00:00 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current month', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentMonth',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current month, starting from day 1 at 00:00:00 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to change the value in rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '5',
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueInput = screen.getByPlaceholderText('Enter value');
|
||||
fireEvent.change(valueInput, { target: { value: '10' } });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: { ...mockEvaluationWindowState.startingAt, number: '10' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current hour', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectComponent = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectComponent);
|
||||
const option = screen.getByText('10');
|
||||
fireEvent.click(option);
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: 10,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current day', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeInputs = screen.getAllByDisplayValue('00');
|
||||
const hoursInput = timeInputs[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '10' } });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '10:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current month', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentMonth',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
const daySelectComponent = comboboxes[0];
|
||||
fireEvent.mouseDown(daySelectComponent);
|
||||
const option = screen.getByText('10');
|
||||
fireEvent.click(option);
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: { ...mockEvaluationWindowState.startingAt, number: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { EvaluationWindowState } from 'container/CreateAlertV2/context/types';
|
||||
|
||||
import {
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
} from '../constants';
|
||||
import EvaluationWindowPopover from '../EvaluationWindowPopover';
|
||||
import { createMockEvaluationWindowState } from './testUtils';
|
||||
|
||||
const mockEvaluationWindow: EvaluationWindowState = createMockEvaluationWindowState();
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
|
||||
const EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS =
|
||||
'.evaluation-window-content-list-item';
|
||||
const EVALUATION_WINDOW_DETAILS_TEST_ID = 'evaluation-window-details';
|
||||
const ENTER_VALUE_PLACEHOLDER = 'Enter value';
|
||||
const EVALUATION_WINDOW_TEXT = 'EVALUATION WINDOW';
|
||||
const LAST_5_MINUTES_TEXT = 'Last 5 minutes';
|
||||
|
||||
jest.mock('../EvaluationWindowPopover/EvaluationWindowDetails', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid={EVALUATION_WINDOW_DETAILS_TEST_ID}>
|
||||
<input placeholder={ENTER_VALUE_PLACEHOLDER} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('EvaluationWindowPopover', () => {
|
||||
it('should render the evaluation window popover with 3 sections', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(EVALUATION_WINDOW_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all window type options with rolling selected', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TYPE.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(rollingItem).toHaveClass('active');
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(cumulativeItem).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all window type options with cumulative selected', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TYPE.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(cumulativeItem).toHaveClass('active');
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(rollingItem).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all timeframe options in rolling mode with last 5 minutes selected by default', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TIMEFRAME.rolling.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const last5MinutesItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(last5MinutesItem).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all timeframe options in cumulative mode with current hour selected by default', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TIMEFRAME.cumulative.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const currentHourItem = screen
|
||||
.getByText('Current hour')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(currentHourItem).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('renders help text in details section for rolling mode with non-custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EvaluationWindowDetails component in details section for rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
timeframe: 'custom',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EvaluationWindowDetails component in details section for cumulative mode', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'A Cumulative Window has a fixed starting point and expands over time.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should navigate down through window type options', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
rollingItem?.focus();
|
||||
|
||||
fireEvent.keyDown(rollingItem, { key: 'ArrowDown' });
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(cumulativeItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate up through window type options', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: 'ArrowUp' });
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(rollingItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate right from window type to timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
rollingItem?.focus();
|
||||
|
||||
fireEvent.keyDown(rollingItem, { key: 'ArrowRight' });
|
||||
const timeframeItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(timeframeItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate left from timeframe to window type', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeframeItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
timeframeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(timeframeItem, { key: 'ArrowLeft' });
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(rollingItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should select option with Enter key', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: 'Enter' });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: 'cumulative',
|
||||
});
|
||||
});
|
||||
|
||||
it('should select option with Space key', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: ' ' });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: 'cumulative',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ describe('TimeInput', () => {
|
||||
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
|
||||
});
|
||||
|
||||
it('should handle value changes', () => {
|
||||
it('should handle hours changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
@@ -51,11 +51,12 @@ describe('TimeInput', () => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
|
||||
});
|
||||
|
||||
it('should pad single digits with zeros', () => {
|
||||
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');
|
||||
});
|
||||
@@ -118,41 +119,6 @@ describe('TimeInput', () => {
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should wrap around navigation from seconds to hours', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
|
||||
await user.click(secondsInput);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(hoursInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should wrap around navigation from hours to seconds', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(secondsInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<TimeInput className="custom-class" />);
|
||||
|
||||
expect(container.firstChild).toHaveClass(
|
||||
'time-input-container',
|
||||
'custom-class',
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable inputs when disabled prop is true', () => {
|
||||
render(<TimeInput disabled />);
|
||||
|
||||
@@ -176,19 +142,100 @@ describe('TimeInput', () => {
|
||||
expect(screen.getByDisplayValue('06')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle malformed time values gracefully', () => {
|
||||
render(<TimeInput value="invalid:time:format" />);
|
||||
|
||||
// Should show the invalid values as they are
|
||||
expect(screen.getByDisplayValue('invalid')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('time')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle partial time values', () => {
|
||||
render(<TimeInput value="12:34" />);
|
||||
|
||||
// Should fall back to default values for incomplete format
|
||||
expect(screen.getAllByDisplayValue('00')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should cap hours at 23 when user enters value > 23', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '25' } });
|
||||
|
||||
expect(hoursInput).toHaveValue('23');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
|
||||
});
|
||||
|
||||
it('should cap hours at 23 when user enters value = 24', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '24' } });
|
||||
|
||||
expect(hoursInput).toHaveValue('23');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
|
||||
});
|
||||
|
||||
it('should allow hours value of 23', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '23' } });
|
||||
|
||||
expect(hoursInput).toHaveValue('23');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
|
||||
});
|
||||
|
||||
it('should cap minutes at 59 when user enters value > 59', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
fireEvent.change(minutesInput, { target: { value: '65' } });
|
||||
|
||||
expect(minutesInput).toHaveValue('59');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
|
||||
});
|
||||
|
||||
it('should cap minutes at 59 when user enters value = 60', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
fireEvent.change(minutesInput, { target: { value: '60' } });
|
||||
|
||||
expect(minutesInput).toHaveValue('59');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
|
||||
});
|
||||
|
||||
it('should allow minutes value of 59', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
fireEvent.change(minutesInput, { target: { value: '59' } });
|
||||
|
||||
expect(minutesInput).toHaveValue('59');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
|
||||
});
|
||||
|
||||
it('should cap seconds at 59 when user enters value > 59', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
fireEvent.change(secondsInput, { target: { value: '75' } });
|
||||
|
||||
expect(secondsInput).toHaveValue('59');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
|
||||
});
|
||||
|
||||
it('should cap seconds at 59 when user enters value = 60', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
fireEvent.change(secondsInput, { target: { value: '60' } });
|
||||
|
||||
expect(secondsInput).toHaveValue('59');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
|
||||
});
|
||||
|
||||
it('should allow seconds value of 59', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
fireEvent.change(secondsInput, { target: { value: '59' } });
|
||||
|
||||
expect(secondsInput).toHaveValue('59');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import {
|
||||
EvaluationWindowState,
|
||||
ICreateAlertContextProps,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
export const createMockAlertContextState = (
|
||||
overrides?: Partial<ICreateAlertContextProps>,
|
||||
): ICreateAlertContextProps => ({
|
||||
alertState: INITIAL_ALERT_STATE,
|
||||
setAlertState: jest.fn(),
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
setAlertType: jest.fn(),
|
||||
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
setThresholdState: jest.fn(),
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
setAdvancedOptions: jest.fn(),
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
setEvaluationWindow: jest.fn(),
|
||||
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
setNotificationSettings: jest.fn(),
|
||||
discardAlertRule: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockEvaluationWindowState = (
|
||||
overrides?: Partial<EvaluationWindowState>,
|
||||
): EvaluationWindowState => ({
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
...overrides,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user