Compare commits

..

218 Commits

Author SHA1 Message Date
ahrefabhi
45d27304e1 Merge branch 'fix/tsc-trace-operator' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-05 18:17:59 +05:30
ahrefabhi
535eed828d Merge branch 'feat/trace-operator-dashboards' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-05 18:17:35 +05:30
ahrefabhi
6b9ada2a1e Merge branch 'feature/trace-operators' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-05 18:17:16 +05:30
Abhi kumar
d85c4cf9bd Merge branch 'feature/trace-operators' into feat/trace-operator-dashboards 2025-09-05 18:14:10 +05:30
Abhi kumar
298647cf79 Merge branch 'feature/trace-operators' into fix/tsc-trace-operator 2025-09-05 18:14:00 +05:30
ahrefabhi
254d174962 chore: added beta tag in trace opeartor 2025-09-05 18:13:24 +05:30
ahrefabhi
ec2c9f3d0a Merge branch 'feature/trace-operators' into feat/trace-operator-dashboards 2025-09-05 17:58:45 +05:30
ahrefabhi
ab26d6d3b2 Merge branch 'feature/trace-operators' into fix/tsc-trace-operator 2025-09-05 17:58:09 +05:30
ahrefabhi
3275af484b chore: updated file names + regenerated grammer 2025-09-05 17:56:54 +05:30
eKuG
f2525fb293 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-09-05 18:05:31 +07:00
eKuG
41716e16a2 feat: fixed span count in trace view 2025-09-05 18:04:34 +07:00
eKuG
c6dcdb8ba8 Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-09-05 12:27:19 +07:00
eKuG
b6b3b5d6a6 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-09-05 12:26:51 +07:00
eKuG
1a770f3b98 feat: added postprocess for timeseries and added limits to memory 2025-09-05 12:25:57 +07:00
ahrefabhi
28129fbcaf Merge branch 'feature/trace-operators' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-04 21:53:32 +05:30
ahrefabhi
dcba183872 chore: removed returnspansfrom field from traceoperator 2025-09-04 21:52:14 +05:30
ahrefabhi
ef0785fa69 Merge branch 'fix/tsc-trace-operator' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-04 21:03:43 +05:30
ahrefabhi
4a2ea9907c fix: fixed tsc issue 2025-09-04 21:01:52 +05:30
ahrefabhi
2419927f02 Merge branch 'feat/trace-operator-dashboards' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-04 20:56:34 +05:30
ahrefabhi
d2c94a82d6 Merge branch 'fix/tsc-trace-operator' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-04 20:56:17 +05:30
ahrefabhi
824576e176 Merge branch 'feature/trace-operators' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-04 20:56:09 +05:30
ahrefabhi
f97001df91 Merge branch 'feature/trace-operators' of https://github.com/SigNoz/signoz into feat/trace-operator-dashboards 2025-09-04 20:53:37 +05:30
ahrefabhi
448a2533bb Merge branch 'feature/trace-operators' of https://github.com/SigNoz/signoz into fix/tsc-trace-operator 2025-09-04 20:50:52 +05:30
ahrefabhi
b69583d017 Merge branch 'main' of https://github.com/SigNoz/signoz into feature/trace-operators 2025-09-04 20:50:17 +05:30
ahrefabhi
f02ffeb4ff chore: added changes to keep indirect descendent operator at bottom 2025-09-04 20:49:43 +05:30
ahrefabhi
d22211443d feat: added changes related to saved in views in trace opeartor 2025-09-04 20:43:45 +05:30
eKuG
b2cb00d993 feat: added comment for build all spans cte 2025-09-04 19:36:30 +05:30
ahrefabhi
0896ed9da9 fix: added fix for order by in trace opeartor 2025-09-04 18:23:14 +05:30
eKuG
d900076a77 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-09-04 18:10:50 +05:30
ahrefabhi
56b153adc2 chore: removed using spans from in trace opeartors 2025-09-04 18:10:13 +05:30
eKuG
4ae881f1e2 feat: refactored fingerprinting 2025-09-04 17:54:27 +05:30
eKuG
439889400b feat: refactored fingerprinting 2025-09-04 17:43:09 +05:30
eKuG
d33d4b2a2e feat: refactored fingerprinting 2025-09-04 17:25:17 +05:30
eKuG
7f3cf5e3c2 feat: refactored fingerprinting 2025-09-04 16:48:17 +05:30
eKuG
a28d94e790 feat: refactored fingerprinting 2025-09-04 16:47:01 +05:30
eKuG
b25d38e246 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-09-04 16:11:07 +05:30
Ekansh Gupta
4ce41aa586 Merge branch 'main' into trace_operator_implementation 2025-09-04 16:09:43 +05:30
eKuG
c9e7a19cc8 feat: refactored fingerprinting 2025-09-04 16:09:16 +05:30
Nityananda Gohain
b59c5af060 Merge branch 'main' into trace_operator_implementation 2025-09-04 14:20:45 +05:30
eKuG
0cdb0253cd feat: refactored fingerprinting 2025-09-04 12:35:27 +05:30
eKuG
33f05d0745 feat: added deep copy in ranged queries 2025-09-04 12:26:47 +05:30
eKuG
13aa670972 feat: fixed merge conflicts 2025-09-04 12:20:47 +05:30
eKuG
1185a981c3 feat: fixed merge conflicts 2025-09-04 12:15:24 +05:30
eKuG
9a3feea008 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-09-04 12:08:14 +05:30
eKuG
14c1e522fa feat: fixed merge conflicts 2025-09-04 12:03:28 +05:30
Ekansh Gupta
3a349d096a Merge branch 'main' into trace_operator_implementation 2025-09-04 11:15:50 +05:30
ahrefabhi
dfcbb40b62 Merge branch 'fix/tsc-trace-operator' into demo/trace-operators 2025-09-03 21:19:14 +05:30
ahrefabhi
7cf0d841ea chore: tsc fix 2025-09-03 21:17:31 +05:30
ahrefabhi
020b6c79d3 Merge branch 'feat/trace-operator-dashboards' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-03 20:02:04 +05:30
ahrefabhi
2b67faa794 Merge branch 'fix/tsc-trace-operator' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-03 20:01:46 +05:30
ahrefabhi
55509ad5c4 Merge branch 'feature/trace-operators' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-03 20:01:25 +05:30
Abhi kumar
e7ab38e947 Merge branch 'feature/trace-operators' into fix/tsc-trace-operator 2025-09-03 20:00:52 +05:30
Abhi kumar
711b85f607 Merge branch 'feature/trace-operators' into feat/trace-operator-dashboards 2025-09-03 20:00:44 +05:30
Abhi kumar
8c19228f87 Merge branch 'main' into feature/trace-operators 2025-09-03 19:59:49 +05:30
ahrefabhi
1632ad0396 Merge branch 'feature/trace-operators' of https://github.com/SigNoz/signoz into fix/tsc-trace-operator 2025-09-03 19:23:14 +05:30
ahrefabhi
e1c14a1dab Merge branch 'main' of https://github.com/SigNoz/signoz into feature/trace-operators 2025-09-03 19:22:33 +05:30
ahrefabhi
81e9b70842 chore: minor changes for list panel 2025-09-03 18:24:49 +05:30
ahrefabhi
45923f9a9c feat: added changes for supporting trace operators in dashboards 2025-09-03 18:14:43 +05:30
ahrefabhi
82f81879c1 chore: added callout message for multiple queries without trace operators 2025-09-03 16:00:24 +05:30
Ekansh Gupta
363fdfd646 Merge branch 'main' into demo/trace-operators 2025-09-03 12:27:55 +05:30
eKuG
31a917820c Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-09-03 12:26:17 +05:30
eKuG
8fc9c09914 feat: fixed merge conflicts 2025-09-03 12:25:09 +05:30
ahrefabhi
a1ca15fc81 chore: changed trace operator query name 2025-09-02 17:42:51 +05:30
eKuG
8357716c0a Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-09-02 13:10:37 +05:30
ahrefabhi
ea54aae57a Merge branch 'feature/trace-operators' into demo/trace-operators 2025-09-02 13:08:04 +05:30
ahrefabhi
7ae2ca503f Merge branch 'main' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-09-02 13:07:56 +05:30
eKuG
a2f9eccc8b feat: updated time series query 2025-09-02 01:16:53 +05:30
eKuG
e355c944c8 feat: replaced info to debug logs 2025-09-01 22:44:41 +05:30
eKuG
a805bdb637 feat: replaced info to debug logs 2025-09-01 22:43:13 +05:30
eKuG
fcfaf152b2 feat: replaced info to debug logs 2025-09-01 22:41:16 +05:30
eKuG
3ad600c4df feat: resolved conflicts 2025-09-01 22:38:41 +05:30
eKuG
7e3d17ce5f feat: resolved conflicts 2025-09-01 22:34:43 +05:30
Ekansh Gupta
bc888539e0 Merge branch 'main' into trace_operator_implementation 2025-09-01 22:25:25 +05:30
eKuG
688867b708 feat: resolved conflicts 2025-09-01 22:25:08 +05:30
eKuG
23948e72eb feat: resolved conflicts 2025-09-01 22:23:58 +05:30
ahrefabhi
d0e5f6b478 test: added test for traceopertor util 2025-08-28 17:54:30 +05:30
ahrefabhi
8c75ba298a chore: moved traceoperator utils 2025-08-28 17:46:04 +05:30
ahrefabhi
bc217a2aa3 fix: added tsc fixes for trace operator 2025-08-28 16:49:05 +05:30
ahrefabhi
6c0b5abbc0 chore: minor ui issue fix 2025-08-28 15:36:22 +05:30
ahrefabhi
0234829492 chore: updated docs link 2025-08-28 14:31:01 +05:30
ahrefabhi
fba946bf78 chore: fixed logic to show trace operator 2025-08-28 13:13:04 +05:30
ahrefabhi
55f96ca95f Merge branch 'main' of https://github.com/SigNoz/signoz into feature/trace-operators 2025-08-28 12:22:06 +05:30
ahrefabhi
20a87db5bc test: fixed breaking mapQueryDataFromApi test 2025-08-28 12:21:22 +05:30
ahrefabhi
ca774fe6a2 test: minor test fix 2025-08-28 11:59:42 +05:30
ahrefabhi
451c4bdeb7 chore: removed check for multiple queries in traceexplorer 2025-08-28 11:58:33 +05:30
ahrefabhi
dcd0de35a4 test: added test for traceoperatorcontext 2025-08-28 10:55:19 +05:30
ahrefabhi
00829423bd Merge branch 'main' of https://github.com/SigNoz/signoz into feature/trace-operators 2025-08-28 10:37:05 +05:30
ahrefabhi
585fadb867 Merge branch 'main' of https://github.com/SigNoz/signoz into feature/trace-operators 2025-08-27 11:43:15 +05:30
ahrefabhi
4a1e786f4e fix: updated grammer files 2025-08-27 11:39:59 +05:30
eKuG
0454a92b80 Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-24 20:35:14 +05:30
eKuG
2d6d342ef0 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-24 20:35:03 +05:30
eKuG
fa06bac37b feat: resolved conflicts 2025-08-24 20:34:40 +05:30
Ekansh Gupta
deb821617b Merge branch 'main' into trace_operator_implementation 2025-08-24 20:03:40 +05:30
Ekansh Gupta
f8b2bda431 Merge branch 'main' into demo/trace-operators 2025-08-24 20:03:10 +05:30
eKuG
6d95095d2f Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-24 20:02:17 +05:30
eKuG
28a2ed4273 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-24 20:01:51 +05:30
eKuG
0fee724730 feat: resolved conflicts 2025-08-24 20:01:25 +05:30
ahrefabhi
6f99d54a50 Merge branch 'feature/trace-operators' into demo/trace-operators 2025-08-24 13:44:30 +05:30
ahrefabhi
8dd3130701 chore: minor ui fix 2025-08-24 13:43:43 +05:30
ahrefabhi
8eaa609076 fix: pr reviews 2025-08-24 13:39:01 +05:30
ahrefabhi
ac3c98b112 feat: added queryname boost + operator constants 2025-08-24 13:37:15 +05:30
ahrefabhi
c0b96ed103 feat: added traceoperator editor 2025-08-24 13:21:42 +05:30
ahrefabhi
608d1565c0 chore: added traceoperator validation function 2025-08-24 13:16:31 +05:30
ahrefabhi
e665d7c352 feat: added traceoperator context util 2025-08-24 13:15:48 +05:30
ahrefabhi
a5f9273743 feat: added trace operator grammer + antlr files 2025-08-24 13:11:00 +05:30
eKuG
7a79a16300 Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-22 12:24:01 +05:30
eKuG
c39f48a41e Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-22 12:23:44 +05:30
eKuG
d492d00976 feat: resolved conflicts 2025-08-22 12:23:24 +05:30
Ekansh Gupta
4c0d2f0e6f Merge branch 'main' into trace_operator_implementation 2025-08-21 23:50:33 +05:30
Ekansh Gupta
02126b65b1 Merge branch 'main' into demo/trace-operators 2025-08-21 23:50:05 +05:30
eKuG
5235f65d9a Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-21 23:49:35 +05:30
eKuG
43457eedc0 feat: resolved conflicts 2025-08-21 23:49:13 +05:30
eKuG
40c6458b31 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-21 18:12:15 +05:30
eKuG
f70f238b84 feat: resolved conflicts 2025-08-21 18:11:47 +05:30
eKuG
43d0cee5b5 Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-21 17:53:01 +05:30
eKuG
33e7d852df Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-21 17:52:49 +05:30
eKuG
5e968ec202 feat: resolved conflicts 2025-08-21 17:52:30 +05:30
ahrefabhi
bbad7dca3e Merge branch 'feature/trace-operators' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-08-21 16:30:01 +05:30
ahrefabhi
7ce278778f feat: added changes for showing querynames in alerts 2025-08-21 16:28:36 +05:30
eKuG
f09b79e04f Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-21 16:03:40 +05:30
eKuG
1c72861290 Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-21 16:03:21 +05:30
eKuG
9116c02e1c feat: resolved conflicts 2025-08-21 16:03:02 +05:30
ahrefabhi
5d3254eeeb Merge branch 'feature/trace-operators' into demo/trace-operators 2025-08-21 15:19:57 +05:30
eKuG
44a3fbfdd6 Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-21 14:56:44 +05:30
eKuG
0dd41a07bd Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-21 14:56:04 +05:30
eKuG
6f8de8da4c feat: resolved conflicts 2025-08-21 14:55:43 +05:30
ahrefabhi
a5f57db0c7 chore: lint fixes 2025-08-21 14:55:04 +05:30
ahrefabhi
83f46aeff6 Merge branch 'feature/trace-operators' into demo/trace-operators 2025-08-21 14:12:11 +05:30
ahrefabhi
7372bf0291 chore: updated type 2025-08-21 14:11:23 +05:30
ahrefabhi
ee78805888 chore: linting fix + icon changes 2025-08-21 14:09:35 +05:30
eKuG
f6547210b2 Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-21 13:50:53 +05:30
eKuG
7206bb82fe Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-21 13:44:01 +05:30
eKuG
a1ad2b7835 feat: resolved conflicts 2025-08-21 13:43:17 +05:30
ahrefabhi
4cb70ec07e test: fixed mapquerydatafromapi test 2025-08-21 13:25:53 +05:30
ahrefabhi
0469233063 Merge branch 'feature/trace-operators' into demo/trace-operators 2025-08-21 13:17:15 +05:30
ahrefabhi
f3621e14bf chore: minor fix in traceoperator styles 2025-08-21 13:16:50 +05:30
ahrefabhi
fd035d885e fix: added limit support in traceoperator 2025-08-21 13:12:39 +05:30
ahrefabhi
c516825e41 fix: fixed minor ts issues 2025-08-21 12:57:16 +05:30
ahrefabhi
188ff014d1 Merge branch 'trace_operator_implementation' of https://github.com/SigNoz/signoz into demo/trace-operators 2025-08-21 10:21:20 +05:30
eKuG
a2ab97a347 Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-21 10:18:35 +05:30
ahrefabhi
da7cdec01f Merge branch 'main' of https://github.com/SigNoz/signoz into feature/trace-operators 2025-08-21 10:17:46 +05:30
ahrefabhi
7c1ca7544d Merge branch 'feature/trace-operators' into demo/trace-operators 2025-08-21 10:15:51 +05:30
ahrefabhi
1b0dcb86b5 chore: linter fix 2025-08-21 09:50:35 +05:30
ahrefabhi
cb49bc795b chore: minor pr review change 2025-08-21 01:33:10 +05:30
ahrefabhi
3f1aeb3077 chore: added traceoperators in alerts 2025-08-21 01:31:40 +05:30
ahrefabhi
cc2a905e0b chore: minor changes in queryaddon and aggregation for support 2025-08-21 01:30:37 +05:30
ahrefabhi
eba024fc5d chore: removed traceoperations and reused queryoperations 2025-08-21 01:29:30 +05:30
ahrefabhi
561ec8fd40 chore: added ui changes in the editor 2025-08-21 01:28:32 +05:30
ahrefabhi
aa1dfc6eb1 feat: added span selector 2025-08-21 01:27:58 +05:30
eKuG
3248012716 feat: resolved conflicts 2025-08-20 18:59:21 +05:30
eKuG
4ce56ebab4 feat: resolved conflicts 2025-08-20 18:58:43 +05:30
eKuG
bb80d69819 feat: resolved conflicts 2025-08-20 17:32:15 +05:30
eKuG
49aaecd02c feat: resolved conflicts 2025-08-20 17:30:52 +05:30
eKuG
98f4e840cd feat: resolved conflicts 2025-08-20 17:20:44 +05:30
eKuG
74824e7853 feat: resolved conflicts 2025-08-20 16:59:01 +05:30
ahrefabhi
b574fee2d4 chore: fixed minor styles + minor ux fix 2025-08-20 15:18:11 +05:30
eKuG
675b66a7b9 feat: resolved conflicts 2025-08-20 12:18:37 +05:30
Ekansh Gupta
f55aeb5b5a Merge branch 'main' into trace_operator_implementation 2025-08-20 11:45:46 +05:30
eKuG
ae3806ce64 feat: resolved conflicts 2025-08-20 11:45:04 +05:30
ahrefabhi
9c489ebc84 chore: Added changes to prepare request payload 2025-08-20 11:24:19 +05:30
ahrefabhi
f6d432cfce chore: added initialvalue for trace operators 2025-08-20 11:23:42 +05:30
ahrefabhi
6ca6f615b0 chore: type changes 2025-08-20 11:22:40 +05:30
ahrefabhi
36e7820edd chore: minor UI fixes 2025-08-20 11:21:16 +05:30
ahrefabhi
f51cce844b feat: added conditions for traceoperator 2025-08-20 11:20:51 +05:30
ahrefabhi
b2d3d61b44 chore: minor style improvments 2025-08-20 11:20:06 +05:30
ahrefabhi
4e2c7c6309 feat: added traceoperator component and styles 2025-08-20 11:19:35 +05:30
eKuG
885045d704 feat: resolved conflicts 2025-08-19 13:41:23 +05:30
Ekansh Gupta
9dc2e82ce1 Merge branch 'main' into trace_operator_implementation 2025-08-19 13:10:39 +05:30
eKuG
19e60ee688 feat: resolved conflicts 2025-08-19 12:26:51 +05:30
eKuG
ea89714cb4 feat: resolved conflicts 2025-08-19 11:20:32 +05:30
eKuG
4be618bcde feat: resolved conflicts 2025-08-18 16:45:47 +05:30
eKuG
2bfecce3cb feat: resolved conflicts 2025-08-18 16:17:48 +05:30
eKuG
eefbcbd1eb feat: resolved conflicts 2025-08-18 15:43:49 +05:30
eKuG
a3f366ee36 feat: resolved conflicts 2025-08-18 15:35:45 +05:30
eKuG
cff547c303 feat: resolved conflicts 2025-08-18 15:28:53 +05:30
Ekansh Gupta
d6287cba52 Merge branch 'main' into trace_operator_implementation 2025-08-18 15:26:31 +05:30
eKuG
44b09fbef2 feat: resolved conflicts 2025-08-18 15:26:08 +05:30
eKuG
081eb64893 feat: resolved conflicts 2025-08-11 13:03:23 +05:30
eKuG
6338af55dd feat: resolved conflicts 2025-08-11 12:44:17 +05:30
eKuG
5450b92650 feat: resolved conflicts 2025-08-11 11:52:33 +05:30
Ekansh Gupta
a9179321e1 Merge branch 'main' into trace_operator_implementation 2025-08-11 11:48:28 +05:30
eKuG
90366975d8 feat: resolved conflicts 2025-08-11 11:48:13 +05:30
eKuG
33f47993d3 feat: resolved conflicts 2025-08-11 11:46:47 +05:30
eKuG
9170846111 feat: resolved conflicts 2025-08-11 11:44:03 +05:30
Ekansh Gupta
54baa9d76d Merge branch 'main' into trace_operator_implementation 2025-07-29 15:43:40 +05:30
eKuG
0ed6aac74e feat: refactored the consume function 2025-07-29 13:09:49 +05:30
Ekansh Gupta
b994fed409 Merge branch 'main' into trace_operator_implementation 2025-07-29 13:08:40 +05:30
eKuG
a9eb992f67 feat: refactored the consume function 2025-07-29 13:08:20 +05:30
eKuG
ed95815a6a feat: refactored the consume function 2025-07-29 13:06:32 +05:30
eKuG
2e2888346f feat: refactored the consume function 2025-07-29 12:24:44 +05:30
eKuG
525c5ac081 feat: refactored the consume function 2025-07-29 12:23:22 +05:30
eKuG
66cede4c03 feat: added postprocess 2025-07-28 23:29:27 +05:30
eKuG
33ea94991a feat: added postprocess 2025-07-28 23:28:10 +05:30
Ekansh Gupta
bae461d1f8 Merge branch 'main' into trace_operator_implementation 2025-07-28 21:24:02 +05:30
eKuG
9df82cc952 feat: added postprocess 2025-07-28 21:19:53 +05:30
Ekansh Gupta
d3d927c84d Merge branch 'main' into trace_operator_implementation 2025-07-28 14:24:46 +05:30
eKuG
36ab1ce8a2 feat: refactor trace operator 2025-07-25 17:55:13 +05:30
Ekansh Gupta
7bbf3ffba3 Merge branch 'main' into trace_operator_implementation 2025-07-25 13:56:43 +05:30
Ekansh Gupta
6ab5c3cf2e Merge branch 'main' into trace_operator_implementation 2025-07-23 15:35:13 +05:30
eKuG
c2384e387d feat: added implementation of trace operators 2025-07-07 21:18:46 +05:30
eKuG
a00f263bad feat: added implementation of trace operators 2025-06-29 13:35:49 +05:30
eKuG
9d648915cc feat: added implementation of trace operators 2025-06-23 16:24:01 +05:30
eKuG
e6bd7484fa feat: added implementation of trace operators 2025-06-23 16:13:02 +05:30
Ekansh Gupta
d780c7482e Merge branch 'main' into trace_operator_implementation 2025-06-23 16:00:33 +05:30
eKuG
ffa8d0267e feat: added implementation of trace operators 2025-06-23 15:59:53 +05:30
Ekansh Gupta
f0505a9c0e Merge branch 'main' into trace_operator_implementation 2025-06-22 15:44:55 +05:30
eKuG
09e212bd64 feat: added implementation of trace operators 2025-06-22 15:43:33 +05:30
eKuG
75f3131e65 feat: added implementation of trace operators 2025-06-22 15:39:43 +05:30
eKuG
b1b571ace9 feat: added implementation of trace operators 2025-06-22 15:38:42 +05:30
Ekansh Gupta
876f580f75 Merge branch 'main' into trace_operator_implementation 2025-06-20 15:45:15 +05:30
eKuG
7999f261ef feat: added implementation of trace operators 2025-06-20 14:41:12 +05:30
eKuG
66b8574f74 feat: added implementation of trace operators 2025-06-20 14:37:07 +05:30
eKuG
d7b8be11a4 feat: [draft] added implementation of trace operators 2025-06-20 00:18:27 +05:30
eKuG
aa3935cc31 feat: [draft] added implementation of trace operators 2025-06-20 00:08:52 +05:30
Ekansh Gupta
002c755ca5 Merge branch 'main' into trace_operator_implementation 2025-06-19 15:03:00 +05:30
eKuG
558739b4e7 feat: [draft] added implementation of trace operators 2025-06-19 00:08:41 +05:30
Ekansh Gupta
efdfa48ad0 Merge branch 'main' into trace_operator_implementation 2025-06-18 23:52:48 +05:30
eKuG
693c4451ee feat: [draft] added implementation of trace operators 2025-06-18 23:49:49 +05:30
421 changed files with 3326 additions and 34780 deletions

View File

@@ -1,6 +1,6 @@
services:
clickhouse:
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:24.1.2-alpine
container_name: clickhouse
volumes:
- ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml
@@ -23,8 +23,6 @@ services:
retries: 3
depends_on:
- zookeeper
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
zookeeper:
image: signoz/zookeeper:3.7.1
container_name: zookeeper
@@ -42,7 +40,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.129.4
image: signoz/signoz-schema-migrator:v0.129.2
container_name: schema-migrator-sync
command:
- sync
@@ -55,7 +53,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.129.4
image: signoz/signoz-schema-migrator:v0.129.2
container_name: schema-migrator-async
command:
- async

39
.github/CODEOWNERS vendored
View File

@@ -5,45 +5,6 @@
/frontend/ @SigNoz/frontend @YounixM
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
# Dashboard, Alert, Metrics, Service Map, Services
/frontend/src/container/ListOfDashboard/ @srikanthccv
/frontend/src/container/NewDashboard/ @srikanthccv
/frontend/src/pages/DashboardsListPage/ @srikanthccv
/frontend/src/pages/DashboardWidget/ @srikanthccv
/frontend/src/pages/NewDashboard/ @srikanthccv
/frontend/src/providers/Dashboard/ @srikanthccv
# Alerts
/frontend/src/container/AlertHistory/ @srikanthccv
/frontend/src/container/AllAlertChannels/ @srikanthccv
/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv
/frontend/src/container/CreateAlertChannels/ @srikanthccv
/frontend/src/container/CreateAlertRule/ @srikanthccv
/frontend/src/container/EditAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertRules/ @srikanthccv
/frontend/src/container/ListAlertRules/ @srikanthccv
/frontend/src/container/TriggeredAlerts/ @srikanthccv
/frontend/src/pages/AlertChannelCreate/ @srikanthccv
/frontend/src/pages/AlertDetails/ @srikanthccv
/frontend/src/pages/AlertHistory/ @srikanthccv
/frontend/src/pages/AlertList/ @srikanthccv
/frontend/src/pages/CreateAlert/ @srikanthccv
/frontend/src/providers/Alert.tsx @srikanthccv
# Metrics
/frontend/src/container/MetricsExplorer/ @srikanthccv
/frontend/src/pages/MetricsApplication/ @srikanthccv
/frontend/src/pages/MetricsExplorer/ @srikanthccv
# Services and Service Map
/frontend/src/container/ServiceApplication/ @srikanthccv
/frontend/src/container/ServiceTable/ @srikanthccv
/frontend/src/pages/Services/ @srikanthccv
/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv
/frontend/src/container/Home/Services/ @srikanthccv
/deploy/ @SigNoz/devops
.github @SigNoz/devops

View File

@@ -8,7 +8,6 @@ linters:
- depguard
- iface
- unparam
- forbidigo
linters-settings:
sloglint:
@@ -25,10 +24,6 @@ linters-settings:
deny:
- pkg: "go.uber.org/zap"
desc: "Do not use zap logger. Use slog instead."
noerrors:
deny:
- pkg: "errors"
desc: "Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead."
iface:
enable:
- identical

View File

@@ -78,5 +78,4 @@ Need assistance? Join our Slack community:
- Set up your [development environment](docs/contributing/development.md)
- Deploy and observe [SigNoz in action with OpenTelemetry Demo Application](docs/otel-demo-docs.md)
- Explore the [SigNoz Community Advocate Program](ADVOCATE.md), which recognises contributors who support the community, share their expertise, and help shape SigNoz's future.
- Write [integration tests](docs/contributing/go/integration.md)
- Explore the [SigNoz Community Advocate Program](ADVOCATE.md), which recognises contributors who support the community, share their expertise, and help shape SigNoz's future.

View File

@@ -32,7 +32,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags)
config, err := cmd.NewSigNozConfig(currCmd.Context(), flags)
if err != nil {
return err
}

View File

@@ -2,6 +2,7 @@ package cmd
import (
"context"
"fmt"
"log/slog"
"os"
@@ -11,10 +12,9 @@ import (
"github.com/SigNoz/signoz/pkg/signoz"
)
func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.DeprecatedFlags) (signoz.Config, error) {
func NewSigNozConfig(ctx context.Context, flags signoz.DeprecatedFlags) (signoz.Config, error) {
config, err := signoz.NewConfig(
ctx,
logger,
config.ResolverConfig{
Uris: []string{"env:"},
ProviderFactories: []config.ProviderFactory{
@@ -31,10 +31,14 @@ func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.Depr
return config, nil
}
func NewJWTSecret(ctx context.Context, logger *slog.Logger) string {
func NewJWTSecret(_ context.Context, _ *slog.Logger) string {
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
if len(jwtSecret) == 0 {
logger.ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
fmt.Println("🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!")
fmt.Println("SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application.")
fmt.Println("Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access.")
fmt.Println("Please set the SIGNOZ_JWT_SECRET environment variable immediately.")
fmt.Println("For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
}
return jwtSecret

View File

@@ -35,7 +35,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags)
config, err := cmd.NewSigNozConfig(currCmd.Context(), flags)
if err != nil {
return err
}

View File

@@ -137,7 +137,10 @@ prometheus:
##################### Alertmanager #####################
alertmanager:
# Specifies the alertmanager provider to use.
provider: signoz
provider: legacy
legacy:
# The API URL (with prefix) of the legacy Alertmanager instance.
api_url: http://localhost:9093/api
signoz:
# The poll interval for periodically syncing the alertmanager with the config in the store.
poll_interval: 1m

View File

@@ -11,7 +11,7 @@ x-common: &common
max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:24.1.2-alpine
tty: true
deploy:
labels:
@@ -37,8 +37,6 @@ x-clickhouse-defaults: &clickhouse-defaults
nofile:
soft: 262144
hard: 262144
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: signoz/zookeeper:3.7.1
@@ -65,7 +63,7 @@ x-db-depend: &db-depend
services:
init-clickhouse:
!!merge <<: *common
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:24.1.2-alpine
command:
- bash
- -c
@@ -176,7 +174,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.94.1
image: signoz/signoz:v0.93.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,7 +207,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.4
image: signoz/signoz-otel-collector:v0.129.2
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -233,7 +231,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.4
image: signoz/signoz-schema-migrator:v0.129.2
deploy:
restart_policy:
condition: on-failure

View File

@@ -11,7 +11,7 @@ x-common: &common
max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:24.1.2-alpine
tty: true
deploy:
labels:
@@ -36,8 +36,6 @@ x-clickhouse-defaults: &clickhouse-defaults
nofile:
soft: 262144
hard: 262144
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: signoz/zookeeper:3.7.1
@@ -62,7 +60,7 @@ x-db-depend: &db-depend
services:
init-clickhouse:
!!merge <<: *common
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:24.1.2-alpine
command:
- bash
- -c
@@ -117,7 +115,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.94.1
image: signoz/signoz:v0.93.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,7 +148,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.4
image: signoz/signoz-otel-collector:v0.129.2
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -176,7 +174,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.4
image: signoz/signoz-schema-migrator:v0.129.2
deploy:
restart_policy:
condition: on-failure

View File

@@ -10,7 +10,7 @@ x-common: &common
x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common
# addding non LTS version due to this fix https://github.com/ClickHouse/ClickHouse/commit/32caf8716352f45c1b617274c7508c86b7d1afab
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:24.1.2-alpine
tty: true
labels:
signoz.io/scrape: "true"
@@ -40,8 +40,6 @@ x-clickhouse-defaults: &clickhouse-defaults
nofile:
soft: 262144
hard: 262144
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: signoz/zookeeper:3.7.1
@@ -67,7 +65,7 @@ x-db-depend: &db-depend
services:
init-clickhouse:
!!merge <<: *common
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:24.1.2-alpine
container_name: signoz-init-clickhouse
command:
- bash
@@ -179,7 +177,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.94.1}
image: signoz/signoz:${VERSION:-v0.93.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,7 +211,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.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.2}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -239,7 +237,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.2}
container_name: schema-migrator-sync
command:
- sync
@@ -250,7 +248,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.2}
container_name: schema-migrator-async
command:
- async

View File

@@ -9,7 +9,8 @@ x-common: &common
max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common
image: clickhouse/clickhouse-server:25.5.6
# addding non LTS version due to this fix https://github.com/ClickHouse/ClickHouse/commit/32caf8716352f45c1b617274c7508c86b7d1afab
image: clickhouse/clickhouse-server:24.1.2-alpine
tty: true
labels:
signoz.io/scrape: "true"
@@ -35,8 +36,6 @@ x-clickhouse-defaults: &clickhouse-defaults
nofile:
soft: 262144
hard: 262144
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: signoz/zookeeper:3.7.1
@@ -62,7 +61,7 @@ x-db-depend: &db-depend
services:
init-clickhouse:
!!merge <<: *common
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:24.1.2-alpine
container_name: signoz-init-clickhouse
command:
- bash
@@ -111,7 +110,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.94.1}
image: signoz/signoz:${VERSION:-v0.93.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,7 +143,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.2}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -166,7 +165,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.2}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +177,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.2}
container_name: schema-migrator-async
command:
- async

View File

@@ -1,213 +0,0 @@
# Integration Tests
SigNoz uses integration tests to verify that different components work together correctly in a real environment. These tests run against actual services (ClickHouse, PostgreSQL, etc.) to ensure end-to-end functionality.
## How to set up the integration test environment?
### Prerequisites
Before running integration tests, ensure you have the following installed:
- Python 3.13+
- Poetry (for dependency management)
- Docker (for containerized services)
### Initial Setup
1. Navigate to the integration tests directory:
```bash
cd tests/integration
```
2. Install dependencies using Poetry:
```bash
poetry install --no-root
```
### Starting the Test Environment
To spin up all the containers necessary for writing integration tests and keep them running:
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/setup.py::test_setup
```
This command will:
- Start all required services (ClickHouse, PostgreSQL, Zookeeper, etc.)
- Keep containers running due to the `--reuse` flag
- Verify that the setup is working correctly
### Stopping the Test Environment
When you're done writing integration tests, clean up the environment:
```bash
poetry run pytest --basetemp=./tmp/ -vv --teardown -s src/bootstrap/setup.py::test_teardown
```
This will destroy the running integration test setup and clean up resources.
## Understanding the Integration Test Framework
Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. Wiremock is used to spin up **test doubles** of other services.
- **Why Python/pytest?** It's expressive, low-boilerplate, and has powerful fixture capabilities that make integration testing straightforward. Extensive libraries for HTTP requests, JSON handling, and data analysis (numpy) make it easier to test APIs and verify data
- **Why testcontainers?** They let us spin up isolated dependencies that match our production environment without complex setup.
- **Why wiremock?** Well maintained, documented and extensible.
```
.
├── conftest.py
├── fixtures
│ ├── __init__.py
│ ├── auth.py
│ ├── clickhouse.py
│ ├── fs.py
│ ├── http.py
│ ├── migrator.py
│ ├── network.py
│ ├── postgres.py
│ ├── signoz.py
│ ├── sql.py
│ ├── sqlite.py
│ ├── types.py
│ └── zookeeper.py
├── poetry.lock
├── pyproject.toml
└── src
└── bootstrap
├── __init__.py
├── a_database.py
├── b_register.py
└── c_license.py
```
Each test suite follows some important principles:
1. **Organization**: Test suites live under `src/` in self-contained packages. Fixtures (a pytest concept) live inside `fixtures/`.
2. **Execution Order**: Files are prefixed with `a_`, `b_`, `c_` to ensure sequential execution.
3. **Time Constraints**: Each suite should complete in under 10 minutes (setup takes ~4 mins).
### Test Suite Design
Test suites should target functional domains or subsystems within SigNoz. When designing a test suite, consider these principles:
- **Functional Cohesion**: Group tests around a specific capability or service boundary
- **Data Flow**: Follow the path of data through related components
- **Change Patterns**: Components frequently modified together should be tested together
The exact boundaries for modules are intentionally flexible, allowing teams to define logical groupings based on their specific context and knowledge of the system.
Eg: The **bootstrap** integration test suite validates core system functionality:
- Database initialization
- Version check
Other test suites can be **pipelines, auth, querier.**
## How to write an integration test?
Now start writing an integration test. Create a new file `src/bootstrap/e_version.py` and paste the following:
```python
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_version(signoz: types.SigNoz) -> None:
response = requests.get(signoz.self.host_config.get("/api/v1/version"), timeout=2)
logger.info(response)
```
We have written a simple test which calls the `version` endpoint of the container in step 1. In **order to just run this function, run the following command:**
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/e_version.py::test_version
```
> Note: The `--reuse` flag is used to reuse the environment if it is already running. Always use this flag when writing and running integration tests. If you don't use this flag, the environment will be destroyed and recreated every time you run the test.
Here's another example of how to write a more comprehensive integration test:
```python
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_user_registration(signoz: types.SigNoz) -> None:
"""Test user registration functionality."""
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/register"),
json={
"name": "testuser",
"orgId": "",
"orgName": "test.org",
"email": "test@example.com",
"password": "password123Z$",
},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["setupCompleted"] is True
```
## How to run integration tests?
### Running All Tests
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/
```
### Running Specific Test Categories
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/<suite>
# Run querier tests
poetry run pytest --basetemp=./tmp/ -vv --reuse src/querier/
# Run auth tests
poetry run pytest --basetemp=./tmp/ -vv --reuse src/auth/
```
### Running Individual Tests
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse src/<suite>/<file>.py::test_name
# Run test_register in file a_register.py in auth suite
poetry run pytest --basetemp=./tmp/ -vv --reuse src/auth/a_register.py::test_register
```
## How to configure different options for integration tests?
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)
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
Example:
```bash
poetry run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/
```
## What should I remember?
- **Always use the `--reuse` flag** when setting up the environment to keep containers running
- **Use the `--teardown` flag** when cleaning up to avoid resource leaks
- **Follow the naming convention** with alphabetical prefixes for test execution order
- **Use proper timeouts** in HTTP requests to avoid hanging tests
- **Clean up test data** between tests to avoid interference
- **Use descriptive test names** that clearly indicate what is being tested
- **Leverage fixtures** for common setup and authentication
- **Test both success and failure scenarios** to ensure robust functionality

View File

@@ -44,6 +44,19 @@ import (
"go.uber.org/zap"
)
type ServerOptions struct {
Config signoz.Config
SigNoz *signoz.SigNoz
HTTPHostPort string
PrivateHostPort string
PreferSpanMetrics bool
FluxInterval string
FluxIntervalForTraceDetail string
Cluster string
GatewayUrl string
Jwt *authtypes.JWT
}
// Server runs HTTP, Mux and a grpc server
type Server struct {
config signoz.Config
@@ -56,6 +69,11 @@ type Server struct {
httpServer *http.Server
httpHostPort string
// private http
privateConn net.Listener
privateHTTP *http.Server
privateHostPort string
opampServer *opamp.Server
// Usage manager
@@ -165,6 +183,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
jwt: jwt,
ruleManager: rm,
httpHostPort: baseconst.HTTPHostPort,
privateHostPort: baseconst.PrivateHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
}
@@ -177,6 +196,13 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
s.httpServer = httpServer
privateServer, err := s.createPrivateServer(apiHandler)
if err != nil {
return nil, err
}
s.privateHTTP = privateServer
s.opampServer = opamp.InitializeServer(
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
)
@@ -189,6 +215,36 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
return s.unavailableChannel
}
func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, error) {
r := baseapp.NewRouter()
r.Use(middleware.NewAuth(s.jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
apiHandler.RegisterPrivateRoutes(r)
c := cors.New(cors.Options{
//todo(amol): find out a way to add exact domain or
// ip here for alert manager
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "SIGNOZ-API-KEY", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
})
handler := c.Handler(r)
handler = handlers.CompressHandler(handler)
return &http.Server{
Handler: handler,
}, nil
}
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger())
@@ -254,6 +310,19 @@ func (s *Server) initListeners() error {
zap.L().Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
// listen on private port to support internal services
privateHostPort := s.privateHostPort
if privateHostPort == "" {
return fmt.Errorf("baseconst.PrivateHostPort is required")
}
s.privateConn, err = net.Listen("tcp", privateHostPort)
if err != nil {
return err
}
zap.L().Info(fmt.Sprintf("Query server started listening on private port %s...", s.privateHostPort))
return nil
}
@@ -292,6 +361,26 @@ func (s *Server) Start(ctx context.Context) error {
}
}()
var privatePort int
if port, err := utils.GetPort(s.privateConn.Addr()); err == nil {
privatePort = port
}
go func() {
zap.L().Info("Starting Private HTTP server", zap.Int("port", privatePort), zap.String("addr", s.privateHostPort))
switch err := s.privateHTTP.Serve(s.privateConn); err {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
// normal exit, nothing to do
zap.L().Info("private http server closed")
default:
zap.L().Error("Could not start private HTTP server", zap.Error(err))
}
s.unavailableChannel <- healthcheck.Unavailable
}()
go func() {
zap.L().Info("Starting OpAmp Websocket server", zap.String("addr", baseconst.OpAmpWsEndpoint))
err := s.opampServer.Start(baseconst.OpAmpWsEndpoint)
@@ -311,6 +400,12 @@ func (s *Server) Stop(ctx context.Context) error {
}
}
if s.privateHTTP != nil {
if err := s.privateHTTP.Shutdown(ctx); err != nil {
return err
}
}
s.opampServer.Stop()
if s.ruleManager != nil {

View File

@@ -40,7 +40,7 @@ var IsDotMetricsEnabled = false
var IsPreferSpanMetrics = false
func init() {
if GetOrDefaultEnv(DotMetricsEnabled, "true") == "true" {
if GetOrDefaultEnv(DotMetricsEnabled, "false") == "true" {
IsDotMetricsEnabled = true
}

View File

@@ -35,6 +35,7 @@ import (
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
yaml "gopkg.in/yaml.v2"
)
const (
@@ -166,9 +167,16 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
)
st, en := r.Timestamps(ts)
start := st.UnixMilli()
end := en.UnixMilli()
start := ts.Add(-time.Duration(r.EvalWindow())).UnixMilli()
end := ts.UnixMilli()
if r.EvalDelay() > 0 {
start = start - int64(r.EvalDelay().Milliseconds())
end = end - int64(r.EvalDelay().Milliseconds())
}
// round to minute otherwise we could potentially miss data
start = start - (start % (60 * 1000))
end = end - (end % (60 * 1000))
compositeQuery := r.Condition().CompositeQuery
@@ -245,17 +253,10 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
for _, series := range queryResult.AnomalyScores {
if r.Condition() != nil && r.Condition().RequireMinPoints {
if len(series.Points) < r.Condition().RequiredNumPoints {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
smpl, shouldAlert := r.ShouldAlert(*series)
if shouldAlert {
resultVector = append(resultVector, smpl)
}
results, err := r.Threshold.ShouldAlert(*series)
if err != nil {
return nil, err
}
resultVector = append(resultVector, results...)
}
return resultVector, nil
}
@@ -295,17 +296,10 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
for _, series := range queryResult.AnomalyScores {
if r.Condition().RequireMinPoints {
if len(series.Points) < r.Condition().RequiredNumPoints {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
smpl, shouldAlert := r.ShouldAlert(*series)
if shouldAlert {
resultVector = append(resultVector, smpl)
}
results, err := r.Threshold.ShouldAlert(*series)
if err != nil {
return nil, err
}
resultVector = append(resultVector, results...)
}
return resultVector, nil
}
@@ -505,7 +499,7 @@ func (r *AnomalyRule) String() string {
PreferredChannels: r.PreferredChannels(),
}
byt, err := json.Marshal(ar)
byt, err := yaml.Marshal(ar)
if err != nil {
return fmt.Sprintf("error marshaling alerting rule: %s", err.Error())
}

View File

@@ -3,10 +3,8 @@ package rules
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
@@ -22,10 +20,6 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
var task baserules.Task
ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
}
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(
@@ -46,7 +40,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr)
// create ch rule task for evalution
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -68,7 +62,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr)
// create promql rule task for evalution
task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -90,7 +84,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar)
// create anomaly rule task for evalution
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)

View File

@@ -137,7 +137,6 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0",
"rrule": "2.8.1",
"stream": "^0.0.2",
"style-loader": "1.3.0",
"styled-components": "^5.3.11",
@@ -276,7 +275,6 @@
"serialize-javascript": "6.0.2",
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.4",
"brace-expansion": "^2.0.2"
"form-data": "4.0.4"
}
}

View File

@@ -1,115 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { ApiBaseInstance } from 'api';
import { getFieldKeys } from '../getFieldKeys';
// Mock the API instance
jest.mock('api', () => ({
ApiBaseInstance: {
get: jest.fn(),
},
}));
describe('getFieldKeys API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockSuccessResponse = {
status: 200,
data: {
status: 'success',
data: {
keys: {
'service.name': [],
'http.status_code': [],
},
complete: true,
},
},
};
it('should call API with correct parameters when no args provided', async () => {
// Mock successful API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call function with no parameters
await getFieldKeys();
// Verify API was called correctly with empty params object
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: {},
});
});
it('should call API with signal parameter when provided', async () => {
// Mock successful API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call function with signal parameter
await getFieldKeys('traces');
// Verify API was called with signal parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { signal: 'traces' },
});
});
it('should call API with name parameter when provided', async () => {
// Mock successful API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200,
data: {
status: 'success',
data: {
keys: { service: [] },
complete: false,
},
},
});
// Call function with name parameter
await getFieldKeys(undefined, 'service');
// Verify API was called with name parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { name: 'service' },
});
});
it('should call API with both signal and name when provided', async () => {
// Mock successful API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200,
data: {
status: 'success',
data: {
keys: { service: [] },
complete: false,
},
},
});
// Call function with both parameters
await getFieldKeys('logs', 'service');
// Verify API was called with both parameters
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { signal: 'logs', name: 'service' },
});
});
it('should return properly formatted response', async () => {
// Mock API to return our response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call the function
const result = await getFieldKeys('traces');
// Verify the returned structure matches SuccessResponseV2 format
expect(result).toEqual({
httpStatusCode: 200,
data: mockSuccessResponse.data.data,
});
});
});

View File

@@ -1,214 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { ApiBaseInstance } from 'api';
import { getFieldValues } from '../getFieldValues';
// Mock the API instance
jest.mock('api', () => ({
ApiBaseInstance: {
get: jest.fn(),
},
}));
describe('getFieldValues API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call the API with correct parameters (no options)', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200,
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
});
// Call function without parameters
await getFieldValues();
// Verify API was called correctly with empty params
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: {},
});
});
it('should call the API with signal parameter', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200,
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
});
// Call function with signal parameter
await getFieldValues('traces');
// Verify API was called with signal parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { signal: 'traces' },
});
});
it('should call the API with name parameter', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200,
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
});
// Call function with name parameter
await getFieldValues(undefined, 'service.name');
// Verify API was called with name parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { name: 'service.name' },
});
});
it('should call the API with value parameter', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200,
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend'],
},
complete: false,
},
},
});
// Call function with value parameter
await getFieldValues(undefined, 'service.name', 'front');
// Verify API was called with value parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { name: 'service.name', searchText: 'front' },
});
});
it('should call the API with time range parameters', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200,
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
});
// Call function with time range parameters
const startUnixMilli = 1625097600000000; // Note: nanoseconds
const endUnixMilli = 1625184000000000;
await getFieldValues(
'logs',
'service.name',
undefined,
startUnixMilli,
endUnixMilli,
);
// Verify API was called with time range parameters (converted to milliseconds)
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: {
signal: 'logs',
name: 'service.name',
startUnixMilli: '1625097600', // Should be converted to seconds (divided by 1000000)
endUnixMilli: '1625184000', // Should be converted to seconds (divided by 1000000)
},
});
});
it('should normalize the response values', async () => {
// Mock API response with multiple value types
const mockResponse = {
status: 200,
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
numberValues: [200, 404],
boolValues: [true, false],
},
complete: true,
},
},
};
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockResponse);
// Call the function
const result = await getFieldValues('traces', 'mixed.values');
// Verify the response has normalized values array
expect(result.data?.normalizedValues).toContain('frontend');
expect(result.data?.normalizedValues).toContain('backend');
expect(result.data?.normalizedValues).toContain('200');
expect(result.data?.normalizedValues).toContain('404');
expect(result.data?.normalizedValues).toContain('true');
expect(result.data?.normalizedValues).toContain('false');
expect(result.data?.normalizedValues?.length).toBe(6);
});
it('should return a properly formatted success response', async () => {
// Create mock response
const mockApiResponse = {
status: 200,
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
};
// Mock API to return our response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
// Call the function
const result = await getFieldValues('traces', 'service.name');
// Verify the returned structure matches SuccessResponseV2 format
expect(result).toEqual({
httpStatusCode: 200,
data: expect.objectContaining({
values: expect.any(Object),
normalizedValues: expect.any(Array),
complete: true,
}),
});
});
});

View File

@@ -1,38 +0,0 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
/**
* Get field keys for a given signal type
* @param signal Type of signal (traces, logs, metrics)
* @param name Optional search text
*/
export const getFieldKeys = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
): Promise<SuccessResponseV2<FieldKeyResponse>> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = encodeURIComponent(signal);
}
if (name) {
params.name = encodeURIComponent(name);
}
try {
const response = await ApiBaseInstance.get('/fields/keys', { params });
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getFieldKeys;

View File

@@ -1,87 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { ApiBaseInstance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
/**
* Get field values for a given signal type and field name
* @param signal Type of signal (traces, logs, metrics)
* @param name Name of the attribute for which values are being fetched
* @param value Optional search text
* @param existingQuery Optional existing query - across all present dynamic variables
*/
export const getFieldValues = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
searchText?: string,
startUnixMilli?: number,
endUnixMilli?: number,
existingQuery?: string,
): Promise<SuccessResponseV2<FieldValueResponse>> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = encodeURIComponent(signal);
}
if (name) {
params.name = encodeURIComponent(name);
}
if (searchText) {
params.searchText = encodeURIComponent(searchText);
}
if (startUnixMilli) {
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
}
if (endUnixMilli) {
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
}
if (existingQuery) {
params.existingQuery = existingQuery;
}
try {
const response = await ApiBaseInstance.get('/fields/values', { params });
// Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) {
const allValues: string[] = [];
Object.entries(response.data?.data?.values).forEach(
([key, valueArray]: [string, any]) => {
// Skip RelatedValues as they should be kept separate
if (key === 'relatedValues') {
return;
}
if (Array.isArray(valueArray)) {
allValues.push(...valueArray.map(String));
}
},
);
// Add a normalized values array to the response
response.data.data.normalizedValues = allValues;
// Add relatedValues to the response as per FieldValueResponse
if (response.data?.data?.values?.relatedValues) {
response.data.data.relatedValues =
response.data?.data?.values?.relatedValues;
}
}
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getFieldValues;

View File

@@ -2,7 +2,7 @@ import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Props, Signup as PayloadProps } from 'types/api/user/loginPrecheck';
import { PayloadProps, Props } from 'types/api/user/loginPrecheck';
const loginPrecheck = async (
props: Props,

View File

@@ -1,21 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Signup } from 'types/api/user/loginPrecheck';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/user/loginPrecheck';
import { Props } from 'types/api/user/signup';
const signup = async (props: Props): Promise<SuccessResponseV2<Signup>> => {
const signup = async (
props: Props,
): Promise<SuccessResponse<null | PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post<PayloadProps>(`/register`, {
const response = await axios.post(`/register`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data.data,
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data?.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -28,7 +28,6 @@ import {
TelemetryFieldKey,
TraceAggregation,
VariableItem,
VariableType,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -347,22 +346,11 @@ function createTraceOperatorBaseSpec(
| TelemetryFieldKey
)[])?.filter((c) => ('key' in c ? c?.key : c?.name));
const {
stepInterval,
groupBy,
limit,
offset,
legend,
having,
orderBy,
pageSize,
} = queryData;
return {
stepInterval: stepInterval || undefined,
stepInterval: queryData?.stepInterval || undefined,
groupBy:
groupBy?.length > 0
? groupBy.map(
queryData.groupBy?.length > 0
? queryData.groupBy.map(
(item: any): GroupByKey => ({
name: item.key,
fieldDataType: item?.dataType,
@@ -376,12 +364,15 @@ function createTraceOperatorBaseSpec(
: undefined,
limit:
panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST
? limit || pageSize || undefined
: limit || undefined,
offset: requestType === 'raw' || requestType === 'trace' ? offset : undefined,
? queryData.limit || queryData.pageSize || undefined
: queryData.limit || undefined,
offset:
requestType === 'raw' || requestType === 'trace'
? queryData.offset
: undefined,
order:
orderBy?.length > 0
? orderBy.map(
queryData.orderBy?.length > 0
? queryData.orderBy.map(
(order: any): OrderBy => ({
key: {
name: order.columnName,
@@ -390,8 +381,8 @@ function createTraceOperatorBaseSpec(
}),
)
: undefined,
legend: isEmpty(legend) ? undefined : legend,
having: isEmpty(having) ? undefined : (having as Having),
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: isEmpty(queryData.having) ? undefined : (queryData?.having as Having),
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map(
@@ -514,7 +505,6 @@ export const prepareQueryRangePayloadV5 = ({
formatForWeb,
originalGraphType,
fillGaps,
dynamicVariables,
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
let legendMap: Record<string, string> = {};
const requestType = mapPanelTypeToRequestType(graphType);
@@ -626,12 +616,7 @@ export const prepareQueryRangePayloadV5 = ({
fillGaps: fillGaps || false,
},
variables: Object.entries(variables).reduce((acc, [key, value]) => {
acc[key] = {
value,
type: dynamicVariables
?.find((v) => v.name === key)
?.type?.toLowerCase() as VariableType,
};
acc[key] = { value };
return acc;
}, {} as Record<string, VariableItem>),
};

View File

@@ -2,28 +2,10 @@
position: relative;
padding-left: 20px;
& :is(h1, h2, h3, h4, h5, h6, p, &-section-title) {
margin-bottom: 12px;
}
&-content {
display: flex;
flex-direction: column;
gap: 32px;
}
&-section-title {
font-size: 14px;
line-height: 20px;
color: var(--text-vanilla-400, #c0c1c3);
}
.changelog-release-date {
font-size: 14px;
line-height: 20px;
color: var(--text-vanilla-400, #c0c1c3);
display: block;
margin-bottom: 12px;
}
&-list {
@@ -99,7 +81,12 @@
}
}
& :is(h1, h2, h3, h4, h5, h6, p, &-section-title) {
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
color: var(--text-vanilla-100, #fff);
}
@@ -109,8 +96,7 @@
line-height: 32px;
}
h2,
&-section-title {
h2 {
font-size: 20px;
line-height: 28px;
}
@@ -122,7 +108,6 @@
overflow: hidden;
border-radius: 4px;
border: 1px solid var(--bg-slate-400, #1d212d);
margin-bottom: 28px;
}
.changelog-media-video {
@@ -139,8 +124,17 @@
&-line {
background-color: var(--bg-vanilla-300);
}
li,
p {
color: var(--text-ink-500);
}
& :is(h1, h2, h3, h4, h5, h6, p, li, &-section-title) {
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--text-ink-500);
}

View File

@@ -55,35 +55,33 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="inner-ball" />
</div>
<span className="changelog-release-date">{formattedReleaseDate}</span>
<div className="changelog-renderer-content">
{changelog.features && changelog.features.length > 0 && (
<div className="changelog-renderer-list">
{changelog.features.map((feature) => (
<div key={feature.id}>
<div className="changelog-renderer-section-title">{feature.title}</div>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown>{feature.description}</ReactMarkdown>
</div>
))}
</div>
)}
{changelog.bug_fixes && changelog.bug_fixes.length > 0 && (
<div className="changelog-renderer-bug-fixes">
<div className="changelog-renderer-section-title">Bug Fixes</div>
{changelog.bug_fixes && (
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
)}
</div>
)}
{changelog.maintenance && changelog.maintenance.length > 0 && (
<div className="changelog-renderer-maintenance">
<div className="changelog-renderer-section-title">Maintenance</div>
{changelog.maintenance && (
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
)}
</div>
)}
</div>
{changelog.features && changelog.features.length > 0 && (
<div className="changelog-renderer-list">
{changelog.features.map((feature) => (
<div key={feature.id}>
<h2>{feature.title}</h2>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown>{feature.description}</ReactMarkdown>
</div>
))}
</div>
)}
{changelog.bug_fixes && changelog.bug_fixes.length > 0 && (
<div>
<h2>Bug Fixes</h2>
{changelog.bug_fixes && (
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
)}
</div>
)}
{changelog.maintenance && changelog.maintenance.length > 0 && (
<div>
<h2>Maintenance</h2>
{changelog.maintenance && (
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
)}
</div>
)}
</div>
);
}

View File

@@ -119,9 +119,7 @@ const filterAndSortTimezones = (
return createTimezoneEntry(normalizedTz, offset);
});
export const generateTimezoneData = (
includeEtcTimezones = false,
): Timezone[] => {
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
const timezones: Timezone[] = [];

View File

@@ -169,7 +169,6 @@
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
}
.ant-drawer-close {
padding: 0px;
}

View File

@@ -78,7 +78,7 @@ function Metrics({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),

View File

@@ -49,7 +49,6 @@ function InputWithLabel({
value={inputValue}
onChange={handleChange}
name={label.toLowerCase()}
data-testid={`input-${label}`}
/>
{labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
{onClose && (

View File

@@ -208,11 +208,7 @@ function ListLogView({
fontSize={fontSize}
>
<div className="log-line">
<LogStateIndicator
fontSize={fontSize}
severityText={logData.severity_text}
severityNumber={logData.severity_number}
/>
<LogStateIndicator type={logType} fontSize={fontSize} />
<div>
<LogContainer fontSize={fontSize}>
{updatedSelecedFields.some((field) => field.name === 'body') && (

View File

@@ -7,6 +7,7 @@
height: 100%;
width: 3px;
border-radius: 50px;
background-color: transparent;
&.small {
min-height: 16px;
@@ -20,107 +21,24 @@
min-height: 24px;
}
// Severity variant CSS classes using design tokens
// Trace variants -
&.severity-trace-0 {
background-color: var(--bg-forest-600);
}
&.severity-trace-1 {
background-color: var(--bg-forest-500);
}
&.severity-trace-2 {
background-color: var(--bg-forest-400);
}
&.severity-trace-3 {
background-color: var(--bg-forest-300);
}
&.severity-trace-4 {
background-color: var(--bg-forest-200);
}
// Debug variants
&.severity-debug-0 {
background-color: var(--bg-aqua-600);
}
&.severity-debug-1 {
background-color: var(--bg-aqua-500);
}
&.severity-debug-2 {
background-color: var(--bg-aqua-400);
}
&.severity-debug-3 {
background-color: var(--bg-aqua-300);
}
&.severity-debug-4 {
background-color: var(--bg-aqua-200);
}
// Info variants
&.severity-info-0 {
background-color: var(--bg-robin-600);
}
&.severity-info-1 {
&.INFO {
background-color: var(--bg-robin-500);
}
&.severity-info-2 {
background-color: var(--bg-robin-400);
}
&.severity-info-3 {
background-color: var(--bg-robin-300);
}
&.severity-info-4 {
background-color: var(--bg-robin-200);
}
// Warn variants
&.severity-warn-0 {
background-color: var(--bg-amber-600);
}
&.severity-warn-1 {
&.WARNING,
&.WARN {
background-color: var(--bg-amber-500);
}
&.severity-warn-2 {
background-color: var(--bg-amber-400);
}
&.severity-warn-3 {
background-color: var(--bg-amber-300);
}
&.severity-warn-4 {
background-color: var(--bg-amber-200);
}
// Error variants
&.severity-error-0 {
background-color: var(--bg-cherry-600);
}
&.severity-error-1 {
&.ERROR {
background-color: var(--bg-cherry-500);
}
&.severity-error-2 {
background-color: var(--bg-cherry-400);
&.TRACE {
background-color: var(--bg-forest-400);
}
&.severity-error-3 {
background-color: var(--bg-cherry-300);
&.DEBUG {
background-color: var(--bg-aqua-500);
}
&.severity-error-4 {
background-color: var(--bg-cherry-200);
}
// Fatal variants
&.severity-fatal-0 {
background-color: var(--bg-sakura-600);
}
&.severity-fatal-1 {
&.FATAL {
background-color: var(--bg-sakura-500);
}
&.severity-fatal-2 {
background-color: var(--bg-sakura-400);
}
&.severity-fatal-3 {
background-color: var(--bg-sakura-300);
}
&.severity-fatal-4 {
background-color: var(--bg-sakura-200);
}
}
}

View File

@@ -6,41 +6,37 @@ import LogStateIndicator from './LogStateIndicator';
describe('LogStateIndicator', () => {
it('renders correctly with default props', () => {
const { container } = render(
<LogStateIndicator severityText="INFO" fontSize={FontSize.MEDIUM} />,
<LogStateIndicator type="INFO" fontSize={FontSize.MEDIUM} />,
);
const indicator = container.firstChild as HTMLElement;
expect(indicator.classList.contains('log-state-indicator')).toBe(true);
expect(indicator.classList.contains('isActive')).toBe(false);
expect(container.querySelector('.line')).toBeTruthy();
expect(
container.querySelector('.line')?.classList.contains('severity-info-0'),
).toBe(true);
expect(container.querySelector('.line')?.classList.contains('INFO')).toBe(
true,
);
});
it('renders correctly with different types', () => {
const { container: containerInfo } = render(
<LogStateIndicator severityText="INFO" fontSize={FontSize.MEDIUM} />,
<LogStateIndicator type="INFO" fontSize={FontSize.MEDIUM} />,
);
expect(containerInfo.querySelector('.line')?.classList.contains('INFO')).toBe(
true,
);
expect(
containerInfo.querySelector('.line')?.classList.contains('severity-info-0'),
).toBe(true);
const { container: containerWarning } = render(
<LogStateIndicator severityText="WARNING" fontSize={FontSize.MEDIUM} />,
<LogStateIndicator type="WARNING" fontSize={FontSize.MEDIUM} />,
);
expect(
containerWarning
.querySelector('.line')
?.classList.contains('severity-warn-0'),
containerWarning.querySelector('.line')?.classList.contains('WARNING'),
).toBe(true);
const { container: containerError } = render(
<LogStateIndicator severityText="ERROR" fontSize={FontSize.MEDIUM} />,
<LogStateIndicator type="ERROR" fontSize={FontSize.MEDIUM} />,
);
expect(
containerError
.querySelector('.line')
?.classList.contains('severity-error-0'),
containerError.querySelector('.line')?.classList.contains('ERROR'),
).toBe(true);
});
});

View File

@@ -3,8 +3,6 @@ import './LogStateIndicator.styles.scss';
import cx from 'classnames';
import { FontSize } from 'container/OptionsMenu/types';
import { getLogTypeBySeverityNumber } from './utils';
export const SEVERITY_TEXT_TYPE = {
TRACE: 'TRACE',
TRACE2: 'TRACE2',
@@ -44,112 +42,18 @@ export const LogType = {
UNKNOWN: 'UNKNOWN',
} as const;
// Severity variant mapping to CSS classes
const SEVERITY_VARIANT_CLASSES: Record<string, string> = {
// Trace variants - forest-600 to forest-200
TRACE: 'severity-trace-0',
Trace: 'severity-trace-1',
trace: 'severity-trace-2',
trc: 'severity-trace-3',
Trc: 'severity-trace-4',
// Debug variants - aqua-600 to aqua-200
DEBUG: 'severity-debug-0',
Debug: 'severity-debug-1',
debug: 'severity-debug-2',
dbg: 'severity-debug-3',
Dbg: 'severity-debug-4',
// Info variants - robin-600 to robin-200
INFO: 'severity-info-0',
Info: 'severity-info-1',
info: 'severity-info-2',
Information: 'severity-info-3',
information: 'severity-info-4',
// Warn variants - amber-600 to amber-200
WARN: 'severity-warn-0',
WARNING: 'severity-warn-0',
Warn: 'severity-warn-1',
warn: 'severity-warn-2',
warning: 'severity-warn-3',
Warning: 'severity-warn-4',
wrn: 'severity-warn-3',
Wrn: 'severity-warn-4',
// Error variants - cherry-600 to cherry-200
// eslint-disable-next-line sonarjs/no-duplicate-string
ERROR: 'severity-error-0',
Error: 'severity-error-1',
error: 'severity-error-2',
err: 'severity-error-3',
Err: 'severity-error-4',
ERR: 'severity-error-0',
fail: 'severity-error-2',
Fail: 'severity-error-3',
FAIL: 'severity-error-0',
// Fatal variants - sakura-600 to sakura-200
// eslint-disable-next-line sonarjs/no-duplicate-string
FATAL: 'severity-fatal-0',
Fatal: 'severity-fatal-1',
fatal: 'severity-fatal-2',
// eslint-disable-next-line sonarjs/no-duplicate-string
critical: 'severity-fatal-3',
Critical: 'severity-fatal-4',
CRITICAL: 'severity-fatal-0',
crit: 'severity-fatal-3',
Crit: 'severity-fatal-4',
CRIT: 'severity-fatal-0',
panic: 'severity-fatal-2',
Panic: 'severity-fatal-3',
PANIC: 'severity-fatal-0',
};
function getSeverityClass(
severityText?: string,
severityNumber?: number,
): string {
// Priority 1: Use severityText for exact variant mapping
if (severityText) {
const variantClass = SEVERITY_VARIANT_CLASSES[severityText.trim()];
if (variantClass) {
return variantClass;
}
}
// Priority 2: Use severityNumber for base color (use middle shade as default)
if (severityNumber) {
const logType = getLogTypeBySeverityNumber(severityNumber);
if (logType !== LogType.UNKNOWN) {
return `severity-${logType.toLowerCase()}-0`; // Use middle shade (index 2)
}
}
return 'severity-info-0'; // Fallback to CSS classes based on type
}
function LogStateIndicator({
type,
fontSize,
severityText,
severityNumber,
}: {
type: string;
fontSize: FontSize;
severityText?: string;
severityNumber?: number;
}): JSX.Element {
const severityClass = getSeverityClass(severityText, severityNumber);
return (
<div className="log-state-indicator">
<div className={cx('line', fontSize, severityClass)} />
<div className={cx('line', type, fontSize)}> </div>
</div>
);
}
LogStateIndicator.defaultProps = {
severityText: '',
severityNumber: 0,
};
export default LogStateIndicator;

View File

@@ -41,7 +41,7 @@ const getLogTypeBySeverityText = (severityText: string): string => {
};
// https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
export const getLogTypeBySeverityNumber = (severityNumber: number): string => {
const getLogTypeBySeverityNumber = (severityNumber: number): string => {
if (severityNumber < 1) {
return LogType.UNKNOWN;
}

View File

@@ -192,11 +192,7 @@ function RawLogView({
onMouseLeave={handleMouseLeave}
fontSize={fontSize}
>
<LogStateIndicator
fontSize={fontSize}
severityText={data.severity_text}
severityNumber={data.severity_number}
/>
<LogStateIndicator type={logType} fontSize={fontSize} />
<RawLogContent
className="raw-log-content"

View File

@@ -11,6 +11,7 @@ import { useTimezone } from 'providers/Timezone';
import { useMemo } from 'react';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorTypeForTable } from '../LogStateIndicator/utils';
import {
defaultListViewPanelStyle,
defaultTableStyle,
@@ -92,9 +93,8 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
children: (
<div className={cx('state-indicator', fontSize)}>
<LogStateIndicator
type={getLogIndicatorTypeForTable(item)}
fontSize={fontSize}
severityText={item.severity_text as string}
severityNumber={item.severity_number as number}
/>
</div>
),

View File

@@ -1,5 +1,3 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable no-nested-ternary */
@@ -14,11 +12,9 @@ import {
import { Color } from '@signozhq/design-tokens';
import { Button, Checkbox, Select, Typography } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { capitalize, isEmpty } from 'lodash-es';
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Info } from 'lucide-react';
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from 'lucide-react';
import type { BaseSelectRef } from 'rc-select';
import React, {
useCallback,
@@ -27,13 +23,11 @@ import React, {
useRef,
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { popupContainer } from 'utils/selectPopupContainer';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
filterOptionsBySearch,
handleScrollToBottom,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
@@ -43,7 +37,7 @@ enum ToggleTagValue {
All = 'All',
}
const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
placeholder = 'Search...',
@@ -68,12 +62,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
allowClear = false,
onRetry,
maxTagTextLength,
onDropdownVisibleChange,
showIncompleteDataMessage = false,
showLabels = false,
enableRegexOption = false,
isDynamicVariable = false,
showRetryButton = true,
...rest
}) => {
// ===== State & Refs =====
@@ -90,10 +78,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
const isClickInsideDropdownRef = useRef(false);
const justOpenedRef = useRef<boolean>(false);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
const isDarkMode = useIsDarkMode();
// Convert single string value to array for consistency
const selectedValues = useMemo(
@@ -140,12 +124,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return allAvailableValues.every((val) => selectedValues.includes(val));
}, [selectedValues, allAvailableValues, enableAllSelection]);
// Define allOptionShown earlier in the code
const allOptionShown = useMemo(
() => value === ALL_SELECTED_VALUE || value === 'ALL',
[value],
);
// Value passed to the underlying Ant Select component
const displayValue = useMemo(
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
@@ -154,18 +132,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// ===== Internal onChange Handler =====
const handleInternalChange = useCallback(
(newValue: string | string[], directCaller?: boolean): void => {
(newValue: string | string[]): void => {
// Ensure newValue is an array
const currentNewValue = Array.isArray(newValue) ? newValue : [];
if (
(allOptionShown || isAllSelected) &&
!directCaller &&
currentNewValue.length === 0
) {
return;
}
if (!onChange) return;
// Case 1: Cleared (empty array or undefined)
@@ -174,7 +144,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return;
}
// Case 2: "__ALL__" is selected (means select all actual values)
// Case 2: "__all__" is selected (means select all actual values)
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
const allActualOptions = allAvailableValues.map(
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
@@ -205,14 +175,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
}
},
[
allOptionShown,
isAllSelected,
onChange,
allAvailableValues,
options,
enableAllSelection,
],
[onChange, allAvailableValues, options, enableAllSelection],
);
// ===== Existing Callbacks (potentially needing adjustment later) =====
@@ -309,8 +272,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
: filteredOptions,
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredOptions, searchText, options]);
}, [filteredOptions, searchText, options, selectedValues]);
// ===== Text Selection Utilities =====
@@ -548,46 +510,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
// Normal single value handling
const trimmedValue = value.trim();
setSearchText(trimmedValue);
setSearchText(value.trim());
if (!isOpen) {
setIsOpen(true);
justOpenedRef.current = true;
}
// Reset active index when search changes if dropdown is open
if (isOpen && trimmedValue) {
setActiveIndex(-1);
// see if the trimmed value matched any option and set that active index
const matchedOption = filteredOptions.find(
(option) =>
option.label.toLowerCase() === trimmedValue.toLowerCase() ||
option.value?.toLowerCase() === trimmedValue.toLowerCase(),
);
if (matchedOption) {
setActiveIndex(1);
} else {
// check if the trimmed value is a regex pattern and set that active index
const isRegex =
trimmedValue.startsWith('.*') && trimmedValue.endsWith('.*');
if (isRegex && enableRegexOption) {
setActiveIndex(0);
} else {
setActiveIndex(enableRegexOption ? 1 : 0);
}
}
}
if (onSearch) onSearch(trimmedValue);
if (onSearch) onSearch(value.trim());
},
[
onSearch,
isOpen,
selectedValues,
onChange,
filteredOptions,
enableRegexOption,
],
[onSearch, isOpen, selectedValues, onChange],
);
// ===== UI & Rendering Functions =====
@@ -599,34 +528,28 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
try {
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
} catch (error) {
// If regex fails, return the original text without highlighting
console.error('Error in text highlighting:', error);
return text;
}
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
},
[highlightSearch],
);
@@ -637,10 +560,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
if (isAllSelected) {
// If all are selected, deselect all
handleInternalChange([], true);
handleInternalChange([]);
} else {
// Otherwise, select all
handleInternalChange([ALL_SELECTED_VALUE], true);
handleInternalChange([ALL_SELECTED_VALUE]);
}
}, [options, isAllSelected, handleInternalChange]);
@@ -815,26 +738,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Enhanced keyboard navigation with support for maxTagCount
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLElement>): void => {
// Simple early return if ALL is selected - block all possible keyboard interactions
// that could remove the ALL tag, but still allow dropdown navigation and search
if (
(allOptionShown || isAllSelected) &&
(e.key === 'Backspace' || e.key === 'Delete')
) {
// Only prevent default if the input is empty or cursor is at start position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
const isInputEmpty = isInputActive && !activeElement?.value;
const isCursorAtStart =
isInputActive && activeElement?.selectionStart === 0;
if (isInputEmpty || isCursorAtStart) {
e.preventDefault();
e.stopPropagation();
return;
}
}
// Get flattened list of all selectable options
const getFlatOptions = (): OptionData[] => {
if (!visibleOptions) return [];
@@ -849,13 +752,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
if (hasAll) {
flatList.push({
label: 'ALL',
value: ALL_SELECTED_VALUE, // Special value for the ALL option
value: '__all__', // Special value for the ALL option
type: 'defined',
});
}
// Add Regex to flat list
if (!isEmpty(searchText) && enableRegexOption) {
if (!isEmpty(searchText)) {
// Only add regex wrapper if it doesn't already look like a regex pattern
const isAlreadyRegex =
searchText.startsWith('.*') && searchText.endsWith('.*');
@@ -881,17 +784,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const flatOptions = getFlatOptions();
// If we just opened the dropdown and have options, set first option as active
if (justOpenedRef.current && flatOptions.length > 0) {
setActiveIndex(0);
justOpenedRef.current = false;
}
// If no option is active but we have options and dropdown is open, activate the first one
if (isOpen && activeIndex === -1 && flatOptions.length > 0) {
setActiveIndex(0);
}
// Get the active input element to check cursor position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
@@ -1237,7 +1129,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// If there's an active option in the dropdown, prioritize selecting it
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
const selectedOption = flatOptions[activeIndex];
if (selectedOption.value === ALL_SELECTED_VALUE) {
if (selectedOption.value === '__all__') {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
@@ -1267,10 +1159,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
if (onDropdownVisibleChange) {
onDropdownVisibleChange(false);
}
break;
case SPACEKEY:
@@ -1280,7 +1168,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const selectedOption = flatOptions[activeIndex];
// Check if it's the ALL option
if (selectedOption.value === ALL_SELECTED_VALUE) {
if (selectedOption.value === '__all__') {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
@@ -1326,7 +1214,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
e.stopPropagation();
e.preventDefault();
setIsOpen(true);
justOpenedRef.current = true; // Set flag to initialize active option on next render
setActiveIndex(0);
setActiveChipIndex(-1);
break;
@@ -1372,14 +1260,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
},
[
allOptionShown,
isAllSelected,
isOpen,
activeIndex,
getVisibleChipIndices,
getLastVisibleChipIndex,
selectedChips,
isSelectionMode,
isOpen,
activeChipIndex,
selectedValues,
visibleOptions,
@@ -1395,9 +1278,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
startSelection,
selectionEnd,
extendSelection,
onDropdownVisibleChange,
activeIndex,
handleSelectAll,
enableRegexOption,
getVisibleChipIndices,
getLastVisibleChipIndex,
],
);
@@ -1422,14 +1306,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
setIsOpen(false);
}, []);
// Add a scroll handler for the dropdown
const handleDropdownScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>): void => {
setIsScrolledToBottom(handleScrollToBottom(e));
},
[],
);
// Custom dropdown render with sections support
const customDropdownRender = useCallback((): React.ReactElement => {
// Process options based on current search
@@ -1448,7 +1324,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const customOptions: OptionData[] = [];
// add regex options first since they appear first in the UI
if (!isEmpty(searchText) && enableRegexOption) {
if (!isEmpty(searchText)) {
// Only add regex wrapper if it doesn't already look like a regex pattern
const isAlreadyRegex =
searchText.startsWith('.*') && searchText.endsWith('.*');
@@ -1471,17 +1347,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
});
}
// Now add all custom options at the beginning, removing duplicates based on value
const allOptions = [...customOptions, ...nonSectionOptions];
const seenValues = new Set<string>();
const enhancedNonSectionOptions = allOptions.filter((option) => {
const value = option.value || '';
if (seenValues.has(value)) {
return false;
}
seenValues.add(value);
return true;
});
// Now add all custom options at the beginning
const enhancedNonSectionOptions = [...customOptions, ...nonSectionOptions];
const allOptionValues = getAllAvailableValues(processedOptions);
const allOptionsSelected =
@@ -1515,7 +1382,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onMouseDown={handleDropdownMouseDown}
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onScroll={handleDropdownScroll}
onBlur={handleBlur}
role="listbox"
aria-multiselectable="true"
@@ -1557,39 +1423,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
}}
>
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Checkbox checked={allOptionsSelected} className="option-checkbox">
<div className="option-content">
<div className="all-option-text">ALL</div>
</div>
</Checkbox>
<div
onClick={(e): void => {
e.stopPropagation();
}}
onMouseDown={(e): void => {
e.stopPropagation();
}}
>
{isDynamicVariable && (
<TextToolTip
text="ALL in dynamic variable = No filter applied (unlike other variable types where ALL sends all selected values). Learn more"
url="https://signoz.io/docs/userguide/manage-variables/#note-about-all"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginLeft: 5,
}}
/>
}
/>
)}
<Checkbox
checked={allOptionsSelected}
style={{ width: '100%', height: '100%' }}
>
<div className="option-content">
<div>ALL</div>
</div>
</div>
</Checkbox>
</div>
<div className="divider" />
</>
@@ -1598,19 +1439,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{/* Non-section options when not searching */}
{enhancedNonSectionOptions.length > 0 && (
<div className="no-section-options">
<Virtuoso
style={{
minHeight: Math.min(300, enhancedNonSectionOptions.length * 40),
maxHeight: enhancedNonSectionOptions.length * 40,
}}
data={enhancedNonSectionOptions}
itemContent={(index, item): React.ReactNode =>
(mapOptions([item]) as unknown) as React.ReactElement
}
totalCount={enhancedNonSectionOptions.length}
itemSize={(): number => 40}
overscan={5}
/>
{mapOptions(enhancedNonSectionOptions)}
</div>
)}
@@ -1621,65 +1450,31 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
<div className="select-group" key={section.label}>
<div className="group-label" role="heading" aria-level={2}>
{section.label}
{isDynamicVariable && (
<TextToolTip
text="Related values: Filtered by other variable selections. All values: Unfiltered complete list. Learn more"
url="https://signoz.io/docs/userguide/manage-variables/#dynamic-variable-dropdowns-display-values-in-two-sections"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
/>
)}
</div>
<div role="group" aria-label={`${section.label} options`}>
<Virtuoso
style={{
minHeight: Math.min(300, (section.options?.length || 0) * 40),
maxHeight: (section.options?.length || 0) * 40,
}}
data={section.options || []}
itemContent={(index, item): React.ReactNode =>
(mapOptions([item]) as unknown) as React.ReactElement
}
totalCount={section.options?.length || 0}
itemSize={(): number => 40}
overscan={5}
/>
{section.options && mapOptions(section.options)}
</div>
</div>
) : (
<div key={section.label} />
),
) : null,
)}
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading &&
!errorMessage &&
!noDataMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text">Refreshing values...</div>
<div className="navigation-text">We are updating the values...</div>
</div>
)}
{errorMessage && !loading && (
@@ -1687,33 +1482,21 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
</div>
{onRetry && showRetryButton && (
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
onRetry();
}}
/>
</div>
)}
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
if (onRetry) onRetry();
}}
/>
</div>
</div>
)}
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!errorMessage && (
<div className="navigation-text-incomplete">
Don&apos;t see the value? Use search
</div>
)}
{noDataMessage &&
!loading &&
!(showIncompleteDataMessage && isScrolledToBottom) &&
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
</div>
</div>
);
@@ -1730,7 +1513,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
handleDropdownMouseDown,
handleDropdownClick,
handleKeyDown,
handleDropdownScroll,
handleBlur,
activeIndex,
loading,
@@ -1740,35 +1522,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
renderOptionWithIndex,
handleSelectAll,
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
enableRegexOption,
isDarkMode,
isDynamicVariable,
showRetryButton,
]);
// Custom handler for dropdown visibility changes
const handleDropdownVisibleChange = useCallback(
(visible: boolean): void => {
setIsOpen(visible);
if (visible) {
justOpenedRef.current = true;
setActiveIndex(0);
setActiveChipIndex(-1);
} else {
setSearchText('');
setActiveIndex(-1);
// Don't clear activeChipIndex when dropdown closes to maintain tag focus
}
// Pass through to the parent component's handler if provided
if (onDropdownVisibleChange) {
onDropdownVisibleChange(visible);
}
},
[onDropdownVisibleChange],
);
// ===== Side Effects =====
// Clear search when dropdown closes
@@ -1830,16 +1585,55 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Custom Tag Render (needs significant updates)
const tagRender = useCallback(
(props: CustomTagProps): React.ReactElement => {
const { label: labelProp, value, closable, onClose } = props;
const label = showLabels
? options.find((option) => option.value === value)?.label || labelProp
: labelProp;
const { label, value, closable, onClose } = props;
// If the display value is the special ALL value, render the ALL tag
if (allOptionShown) {
// Don't render a visible tag - will be shown as placeholder
return <div style={{ display: 'none' }} />;
if (value === ALL_SELECTED_VALUE && isAllSelected) {
const handleAllTagClose = (
e: React.MouseEvent | React.KeyboardEvent,
): void => {
e.stopPropagation();
e.preventDefault();
handleInternalChange([]); // Clear selection when ALL tag is closed
};
const handleAllTagKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
handleAllTagClose(e);
}
// Prevent Backspace/Delete propagation if needed, handle in main keydown handler
};
return (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': activeChipIndex === 0, // Treat ALL tag as index 0 when active
'ant-select-selection-item-selected': selectedChips.includes(0),
})}
style={
activeChipIndex === 0 || selectedChips.includes(0)
? {
borderColor: Color.BG_ROBIN_500,
backgroundColor: Color.BG_SLATE_400,
}
: undefined
}
>
<span className="ant-select-selection-item-content">ALL</span>
{closable && (
<span
className="ant-select-selection-item-remove"
onClick={handleAllTagClose}
onKeyDown={handleAllTagKeyDown}
role="button"
tabIndex={0}
aria-label="Remove ALL tag (deselect all)"
>
×
</span>
)}
</div>
);
}
// If not isAllSelected, render individual tags using previous logic
@@ -1919,69 +1713,52 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Fallback for safety, should not be reached
return <div />;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
[
isAllSelected,
handleInternalChange,
activeChipIndex,
selectedChips,
selectedValues,
maxTagCount,
],
);
// Simple onClear handler to prevent clearing ALL
const onClearHandler = useCallback((): void => {
// Skip clearing if ALL is selected
if (allOptionShown || isAllSelected) {
return;
}
// Normal clear behavior
handleInternalChange([], true);
if (onClear) onClear();
}, [onClear, handleInternalChange, allOptionShown, isAllSelected]);
// ===== Component Rendering =====
return (
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={handleInternalChange}
onClear={(): void => handleInternalChange([])}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? 1 : maxTagCount}
{...rest}
/>
);
};

View File

@@ -13,11 +13,9 @@ import {
import { Color } from '@signozhq/design-tokens';
import { Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { capitalize, isEmpty } from 'lodash-es';
import { ArrowDown, ArrowUp, Info } from 'lucide-react';
import { ArrowDown, ArrowUp } from 'lucide-react';
import type { BaseSelectRef } from 'rc-select';
import React, {
useCallback,
@@ -31,7 +29,6 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
handleScrollToBottom,
prioritizeOrAddOptionForSingleSelect,
SPACEKEY,
} from './utils';
@@ -60,33 +57,17 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
errorMessage,
allowClear = false,
onRetry,
showIncompleteDataMessage = false,
showRetryButton = true,
isDynamicVariable = false,
...rest
}) => {
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
const isDarkMode = useIsDarkMode();
// Refs for element access and scroll behavior
const selectRef = useRef<BaseSelectRef>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
// Flag to track if dropdown just opened
const justOpenedRef = useRef<boolean>(false);
// Add a scroll handler for the dropdown
const handleDropdownScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>): void => {
setIsScrolledToBottom(handleScrollToBottom(e));
},
[],
);
// ===== Option Filtering & Processing Utilities =====
@@ -149,33 +130,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
try {
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
} catch (error) {
console.error('Error in text highlighting:', error);
return text;
}
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
},
[highlightSearch],
);
@@ -275,14 +246,9 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const trimmedValue = value.trim();
setSearchText(trimmedValue);
// Reset active option index when search changes
if (isOpen) {
setActiveOptionIndex(0);
}
if (onSearch) onSearch(trimmedValue);
},
[onSearch, isOpen],
[onSearch],
);
/**
@@ -306,23 +272,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const flatList: OptionData[] = [];
// Process options
let processedOptions = isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
if (!isEmpty(searchText)) {
processedOptions = filterOptionsBySearch(processedOptions, searchText);
}
const { sectionOptions, nonSectionOptions } = splitOptions(
processedOptions,
isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
);
// Add custom option if needed
if (
!isEmpty(searchText) &&
!isLabelPresent(processedOptions, searchText)
) {
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
flatList.push({
label: searchText,
value: searchText,
@@ -343,52 +300,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const options = getFlatOptions();
// If we just opened the dropdown and have options, set first option as active
if (justOpenedRef.current && options.length > 0) {
setActiveOptionIndex(0);
justOpenedRef.current = false;
}
// If no option is active but we have options, activate the first one
if (activeOptionIndex === -1 && options.length > 0) {
setActiveOptionIndex(0);
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
break;
case 'ArrowUp':
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
}
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
break;
case 'Tab':
// Tab navigation with Shift key support
if (e.shiftKey) {
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
}
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
} else {
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
break;
@@ -401,7 +339,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
} else if (!isEmpty(searchText)) {
// Add custom value when no option is focused
@@ -414,7 +351,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(customOption.value, customOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
}
break;
@@ -423,7 +359,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
e.preventDefault();
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
break;
case ' ': // Space key
@@ -434,7 +369,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
}
break;
@@ -445,7 +379,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// Open dropdown when Down or Tab is pressed while closed
e.preventDefault();
setIsOpen(true);
justOpenedRef.current = true; // Set flag to initialize active option on next render
setActiveOptionIndex(0);
}
},
[
@@ -510,7 +444,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
className="custom-select-dropdown"
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onScroll={handleDropdownScroll}
role="listbox"
tabIndex={-1}
aria-activedescendant={
@@ -521,6 +454,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="no-section-options">
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
</div>
{/* Section options */}
{sectionOptions.length > 0 &&
sectionOptions.map((section) =>
@@ -528,23 +462,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="select-group" key={section.label}>
<div className="group-label" role="heading" aria-level={2}>
{section.label}
{isDynamicVariable && (
<TextToolTip
text="Related values: Filtered by other variable selections. All values: Unfiltered complete list. Learn more"
url="https://signoz.io/docs/userguide/manage-variables/#dynamic-variable-dropdowns-display-values-in-two-sections"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
/>
)}
</div>
<div role="group" aria-label={`${section.label} options`}>
{section.options && mapOptions(section.options)}
@@ -555,22 +472,19 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading &&
!errorMessage &&
!noDataMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text">Refreshing values...</div>
<div className="navigation-text">We are updating the values...</div>
</div>
)}
{errorMessage && !loading && (
@@ -578,33 +492,21 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
</div>
{onRetry && showRetryButton && (
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
onRetry();
}}
/>
</div>
)}
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
if (onRetry) onRetry();
}}
/>
</div>
</div>
)}
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!errorMessage && (
<div className="navigation-text-incomplete">
Don&apos;t see the value? Use search
</div>
)}
{noDataMessage &&
!loading &&
!(showIncompleteDataMessage && isScrolledToBottom) &&
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
</div>
</div>
);
@@ -618,7 +520,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
isLabelPresent,
handleDropdownClick,
handleKeyDown,
handleDropdownScroll,
activeOptionIndex,
loading,
errorMessage,
@@ -626,25 +527,8 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
dropdownRender,
renderOptionWithIndex,
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
showRetryButton,
isDarkMode,
isDynamicVariable,
]);
// Handle dropdown visibility changes
const handleDropdownVisibleChange = useCallback((visible: boolean): void => {
setIsOpen(visible);
if (visible) {
justOpenedRef.current = true;
setActiveOptionIndex(0);
} else {
setSearchText('');
setActiveOptionIndex(-1);
}
}, []);
// ===== Side Effects =====
// Clear search text when dropdown closes
@@ -698,7 +582,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}

View File

@@ -1,127 +0,0 @@
import {
fireEvent,
render,
RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import { VirtuosoMockContext } from 'react-virtuoso';
import CustomMultiSelect from '../CustomMultiSelect';
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Helper function to render with VirtuosoMockContext
const renderWithVirtuoso = (component: React.ReactElement): RenderResult =>
render(
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 100 }}
>
{component}
</VirtuosoMockContext.Provider>,
);
// Mock options data
const mockOptions = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];
// CSS selector for retry button
const RETRY_BUTTON_SELECTOR = '.navigation-icons .anticon-reload';
describe('CustomMultiSelect - Retry Functionality', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should show retry button when 5xx error occurs and error message is displayed', async () => {
const mockOnRetry = jest.fn();
const errorMessage = 'Internal Server Error (500)';
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
errorMessage={errorMessage}
onRetry={mockOnRetry}
showRetryButton
loading={false}
/>,
);
// Open dropdown to see error state
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear with error message
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
// Check that retry button (ReloadOutlined icon) is present
const retryButton = document.querySelector(RETRY_BUTTON_SELECTOR);
expect(retryButton).toBeInTheDocument();
});
it('should show retry button when 4xx error occurs and error message is displayed (current behavior)', async () => {
const mockOnRetry = jest.fn();
const errorMessage = 'Bad Request (400)';
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
errorMessage={errorMessage}
onRetry={mockOnRetry}
showRetryButton={false}
loading={false}
/>,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear with error message
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
const retryButton = document.querySelector(RETRY_BUTTON_SELECTOR);
expect(retryButton).not.toBeInTheDocument();
});
it('should call onRetry function when retry button is clicked', async () => {
const mockOnRetry = jest.fn();
const errorMessage = 'Internal Server Error (500)';
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
errorMessage={errorMessage}
onRetry={mockOnRetry}
showRetryButton
loading={false}
/>,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
// Find and click the retry button
const retryButton = document.querySelector(RETRY_BUTTON_SELECTOR);
expect(retryButton).toBeInTheDocument();
fireEvent.click(retryButton as Element);
// Verify onRetry was called
expect(mockOnRetry).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,27 +1,10 @@
import {
fireEvent,
render,
RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CustomMultiSelect from '../CustomMultiSelect';
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Helper function to render with VirtuosoMockContext
const renderWithVirtuoso = (component: React.ReactElement): RenderResult =>
render(
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 100 }}
>
{component}
</VirtuosoMockContext.Provider>,
);
// Mock options data
const mockOptions = [
{ label: 'Option 1', value: 'option1' },
@@ -49,7 +32,7 @@ const mockGroupedOptions = [
describe('CustomMultiSelect Component', () => {
it('renders with placeholder', () => {
const handleChange = jest.fn();
renderWithVirtuoso(
render(
<CustomMultiSelect
placeholder="Select multiple options"
options={mockOptions}
@@ -64,9 +47,7 @@ describe('CustomMultiSelect Component', () => {
it('opens dropdown when clicked', async () => {
const handleChange = jest.fn();
renderWithVirtuoso(
<CustomMultiSelect options={mockOptions} onChange={handleChange} />,
);
render(<CustomMultiSelect options={mockOptions} onChange={handleChange} />);
// Click to open the dropdown
const selectElement = screen.getByRole('combobox');
@@ -85,7 +66,7 @@ describe('CustomMultiSelect Component', () => {
const handleChange = jest.fn();
// Start with option1 already selected
renderWithVirtuoso(
render(
<CustomMultiSelect
options={mockOptions}
onChange={handleChange}
@@ -112,7 +93,7 @@ describe('CustomMultiSelect Component', () => {
it('selects ALL options when ALL is clicked', async () => {
const handleChange = jest.fn();
renderWithVirtuoso(
render(
<CustomMultiSelect
options={mockOptions}
onChange={handleChange}
@@ -145,7 +126,7 @@ describe('CustomMultiSelect Component', () => {
});
it('displays selected options as tags', async () => {
renderWithVirtuoso(
render(
<CustomMultiSelect options={mockOptions} value={['option1', 'option2']} />,
);
@@ -156,7 +137,7 @@ describe('CustomMultiSelect Component', () => {
it('removes a tag when clicked', async () => {
const handleChange = jest.fn();
renderWithVirtuoso(
render(
<CustomMultiSelect
options={mockOptions}
value={['option1', 'option2']}
@@ -178,7 +159,7 @@ describe('CustomMultiSelect Component', () => {
});
it('filters options when searching', async () => {
renderWithVirtuoso(<CustomMultiSelect options={mockOptions} />);
render(<CustomMultiSelect options={mockOptions} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
@@ -212,7 +193,7 @@ describe('CustomMultiSelect Component', () => {
});
it('renders grouped options correctly', async () => {
renderWithVirtuoso(<CustomMultiSelect options={mockGroupedOptions} />);
render(<CustomMultiSelect options={mockGroupedOptions} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
@@ -230,18 +211,18 @@ describe('CustomMultiSelect Component', () => {
});
it('shows loading state', () => {
renderWithVirtuoso(<CustomMultiSelect options={mockOptions} loading />);
render(<CustomMultiSelect options={mockOptions} loading />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check loading text is displayed
expect(screen.getByText('Refreshing values...')).toBeInTheDocument();
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
});
it('shows error message', () => {
renderWithVirtuoso(
render(
<CustomMultiSelect
options={mockOptions}
errorMessage="Test error message"
@@ -257,9 +238,7 @@ describe('CustomMultiSelect Component', () => {
});
it('shows no data message', () => {
renderWithVirtuoso(
<CustomMultiSelect options={[]} noDataMessage="No data available" />,
);
render(<CustomMultiSelect options={[]} noDataMessage="No data available" />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
@@ -270,7 +249,7 @@ describe('CustomMultiSelect Component', () => {
});
it('shows "ALL" tag when all options are selected', () => {
renderWithVirtuoso(
render(
<CustomMultiSelect
options={mockOptions}
value={['option1', 'option2', 'option3']}

View File

@@ -140,7 +140,7 @@ describe('CustomSelect Component', () => {
fireEvent.mouseDown(selectElement);
// Check loading text is displayed
expect(screen.getByText('Refreshing values...')).toBeInTheDocument();
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
});
it('shows error message', () => {

View File

@@ -1,624 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
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 { VirtuosoMockContext } from 'react-virtuoso';
import configureStore from 'redux-mock-store';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import VariableItem from '../../../container/NewDashboard/DashboardVariablesSelection/VariableItem';
// Mock the dashboard variables query
jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
payload: {
variableValues: ['option1', 'option2', 'option3', 'option4'],
},
}),
),
}));
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Constants
const TEST_VARIABLE_NAME = 'test_variable';
const TEST_VARIABLE_ID = 'test-var-id';
// Create a mock store
const mockStore = configureStore([])({
globalTime: {
minTime: Date.now() - 3600000, // 1 hour ago
maxTime: Date.now(),
},
});
// Test data
const createMockVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
description: 'Test variable description',
type: 'QUERY',
queryValue: 'SELECT DISTINCT value FROM table',
customValue: '',
sort: 'ASC',
multiSelect: false,
showALLOption: true,
selectedValue: [],
allSelected: false,
...overrides,
});
function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return (
<Provider store={mockStore}>
<QueryClientProvider client={queryClient}>
<VirtuosoMockContext.Provider
// eslint-disable-next-line react/jsx-no-constructed-context-values
value={{ viewportHeight: 300, itemHeight: 40 }}
>
{children}
</VirtuosoMockContext.Provider>
</QueryClientProvider>
</Provider>
);
}
describe('VariableItem Integration Tests', () => {
let user: ReturnType<typeof userEvent.setup>;
let mockOnValueUpdate: jest.Mock;
let mockSetVariablesToGetUpdated: jest.Mock;
beforeEach(() => {
user = userEvent.setup();
mockOnValueUpdate = jest.fn();
mockSetVariablesToGetUpdated = jest.fn();
jest.clearAllMocks();
});
// ===== 1. INTEGRATION WITH CUSTOMSELECT =====
describe('CustomSelect Integration (VI)', () => {
test('VI-01: Single select variable integration', async () => {
const variable = createMockVariable({
multiSelect: false,
type: 'CUSTOM',
customValue: 'option1,option2,option3',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Should render with CustomSelect
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('option1')).toBeInTheDocument();
expect(screen.getByText('option2')).toBeInTheDocument();
expect(screen.getByText('option3')).toBeInTheDocument();
});
// Select an option
const option1 = screen.getByText('option1');
await user.click(option1);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
TEST_VARIABLE_NAME,
TEST_VARIABLE_ID,
'option1',
false,
);
});
});
// ===== 2. INTEGRATION WITH CUSTOMMULTISELECT =====
describe('CustomMultiSelect Integration (VI)', () => {
test('VI-02: Multi select variable integration', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'option1,option2,option3,option4',
showALLOption: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Should render with CustomMultiSelect
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
await user.click(combobox);
await waitFor(() => {
// Should show ALL option
expect(screen.getByText('ALL')).toBeInTheDocument();
});
// Wait for Virtuoso to render the custom options
await waitFor(
() => {
expect(screen.getByText('option1')).toBeInTheDocument();
expect(screen.getByText('option2')).toBeInTheDocument();
expect(screen.getByText('option3')).toBeInTheDocument();
expect(screen.getByText('option4')).toBeInTheDocument();
},
{ timeout: 5000 },
);
});
});
// ===== 3. TEXTBOX VARIABLE TYPE =====
describe('Textbox Variable Integration', () => {
test('VI-03: Textbox variable handling', async () => {
const variable = createMockVariable({
type: 'TEXTBOX',
selectedValue: 'initial-value',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Should render a regular input
const textInput = screen.getByDisplayValue('initial-value');
expect(textInput).toBeInTheDocument();
expect(textInput.tagName).toBe('INPUT');
// Clear and type new value
await user.clear(textInput);
await user.type(textInput, 'new-text-value');
// Should call onValueUpdate after debounce
await waitFor(
() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
TEST_VARIABLE_NAME,
TEST_VARIABLE_ID,
'new-text-value',
false,
);
},
{ timeout: 1000 },
);
});
});
// ===== 4. VALUE PERSISTENCE AND STATE MANAGEMENT =====
describe('Value Persistence and State Management', () => {
test('VI-04: All selected state handling', () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'service1,service2,service3',
selectedValue: ['service1', 'service2', 'service3'],
allSelected: true,
showALLOption: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Should show "ALL" instead of individual values
expect(screen.getByText('ALL')).toBeInTheDocument();
});
test('VI-05: Dropdown behavior with temporary selections', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'item1,item2,item3',
selectedValue: ['item1'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Wait for dropdown to open
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
});
});
// ===== 6. ACCESSIBILITY AND USER EXPERIENCE =====
describe('Accessibility and User Experience', () => {
test('VI-06: Variable description tooltip', async () => {
const variable = createMockVariable({
description: 'This variable controls the service selection',
type: 'CUSTOM',
customValue: 'service1,service2',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Should show info icon
const infoIcon = document.querySelector('.info-icon');
expect(infoIcon).toBeInTheDocument();
// Hover to show tooltip
if (infoIcon) {
await user.hover(infoIcon);
}
await waitFor(() => {
expect(
screen.getByText('This variable controls the service selection'),
).toBeInTheDocument();
});
});
test('VI-07: Variable name display', () => {
const variable = createMockVariable({
name: 'service_name',
type: 'CUSTOM',
customValue: 'service1,service2',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Should show variable name with $ prefix
expect(screen.getByText('$service_name')).toBeInTheDocument();
});
test('VI-08: Max tag count behavior', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'tag1,tag2,tag3,tag4,tag5,tag6,tag7',
selectedValue: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Wait for component to render
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
// Should show limited number of tags with "+ X more"
const tags = document.querySelectorAll('.ant-select-selection-item');
// The component should render without crashing
expect(tags.length).toBeGreaterThanOrEqual(0);
});
});
// ===== 8. SEARCH INTERACTION TESTS =====
describe('Search Interaction Tests', () => {
test('VI-14: Search persistence across dropdown open/close', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
const searchInput = document.querySelector(
'.ant-select-selection-search-input',
);
expect(searchInput).toBeInTheDocument();
if (searchInput) {
await user.type(searchInput, 'search-text');
}
// Verify search text is in input
await waitFor(() => {
expect(searchInput).toHaveValue('search-text');
});
// Press Escape to close dropdown
await user.keyboard('{Escape}');
// Dropdown should close and search text should be cleared
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toHaveClass('ant-select-dropdown-hidden');
expect(searchInput).toHaveValue('');
});
});
});
// ===== 9. ADVANCED KEYBOARD NAVIGATION =====
describe('Advanced Keyboard Navigation (VI)', () => {
test('VI-15: Shift + Arrow + Del chip deletion in multiselect', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
selectedValue: ['option1', 'option2', 'option3'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Navigate to chips using arrow keys
await user.keyboard('{ArrowLeft}');
// Use Shift + Arrow to navigate between chips
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');
// Use Del to delete the active chip
await user.keyboard('{Delete}');
// Note: The component may not immediately call onValueUpdate
// This test verifies the chip deletion behavior
await waitFor(() => {
// Check if a chip was removed from the selection
const selectionItems = document.querySelectorAll(
'.ant-select-selection-item',
);
expect(selectionItems.length).toBeLessThan(3);
});
});
});
// ===== 11. ADVANCED UI STATES =====
describe('Advanced UI States (VI)', () => {
test('VI-19: No data with previous value selected in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: '',
multiSelect: true,
selectedValue: ['previous-value'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Wait for component to initialize
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Should show no data message (the component may not show this exact text)
await waitFor(() => {
// Check if dropdown is empty or shows no data indication
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
});
test('VI-20: Always editable accessibility in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
// Should be editable
expect(combobox).not.toBeDisabled();
await user.click(combobox);
expect(combobox).toHaveFocus();
// Should still be interactive
expect(combobox).not.toBeDisabled();
});
});
// ===== 13. DROPDOWN PERSISTENCE =====
describe('Dropdown Persistence (VI)', () => {
test('VI-24: Dropdown stays open for non-save actions in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
// Wait for component to initialize
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
const combobox = screen.getByRole('combobox');
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
// Navigate with arrow keys (non-save action)
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
// Dropdown should still be open
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
// Only ESC should close the dropdown
await user.keyboard('{Escape}');
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toHaveClass('ant-select-dropdown-hidden');
});
});
});
});

View File

@@ -35,50 +35,12 @@ $custom-border-color: #2c3044;
width: 100%;
position: relative;
&.is-all-selected {
.ant-select-selection-search-input {
caret-color: transparent;
}
.ant-select-selection-placeholder {
opacity: 1 !important;
color: var(--bg-vanilla-400) !important;
font-weight: 500;
visibility: visible !important;
pointer-events: none;
z-index: 2;
.lightMode & {
color: rgba(0, 0, 0, 0.85) !important;
}
}
&.ant-select-focused .ant-select-selection-placeholder {
opacity: 0.45 !important;
}
}
.all-selected-text {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--bg-vanilla-400);
z-index: 1;
pointer-events: none;
.lightMode & {
color: rgba(0, 0, 0, 0.85);
}
}
.ant-select-selector {
max-height: 200px;
overflow: auto;
scrollbar-width: thin;
background-color: var(--bg-ink-400);
border-color: var(--bg-slate-400);
cursor: text;
&::-webkit-scrollbar {
width: 6px;
@@ -94,16 +56,6 @@ $custom-border-color: #2c3044;
}
}
// Ensure adequate space for input area
.ant-select-selection-search {
min-width: 60px !important;
flex: 1 1 auto;
.ant-select-selection-search-input {
min-width: 60px !important;
cursor: text;
}
}
&.ant-select-focused {
.ant-select-selector {
border-color: var(--bg-robin-500);
@@ -206,7 +158,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;
max-height: 300px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -324,10 +276,6 @@ $custom-border-color: #2c3044;
font-size: 12px;
}
.navigation-text-incomplete {
color: var(--bg-amber-600) !important;
}
.navigation-error {
.navigation-text,
.navigation-icons {
@@ -374,7 +322,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 350px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -407,13 +355,8 @@ $custom-border-color: #2c3044;
.select-group {
margin-bottom: 12px;
overflow: hidden;
margin-top: 4px;
.group-label {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
padding: 4px 12px;
font-size: 13px;
@@ -461,13 +404,6 @@ $custom-border-color: #2c3044;
width: 100%;
}
.all-option-text {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.option-content {
display: flex;
justify-content: space-between;
@@ -701,7 +637,6 @@ $custom-border-color: #2c3044;
.ant-select-selector {
background-color: var(--bg-vanilla-100);
border-color: #e9e9e9;
cursor: text; // Make entire selector clickable for input focus
&::-webkit-scrollbar-thumb {
background-color: #ccc;
@@ -712,20 +647,6 @@ $custom-border-color: #2c3044;
}
}
.ant-select-selection-search {
min-width: 60px !important;
flex: 1 1 auto;
.ant-select-selection-search-input {
min-width: 60px !important;
cursor: text;
}
}
.ant-select-selector {
cursor: text;
}
.ant-select-selection-placeholder {
color: rgba(0, 0, 0, 0.45);
}
@@ -735,10 +656,6 @@ $custom-border-color: #2c3044;
border: 1px solid #e8e8e8;
color: rgba(0, 0, 0, 0.85);
font-size: 12px !important;
height: 20px;
line-height: 18px;
.ant-select-selection-item-content {
color: rgba(0, 0, 0, 0.85);
}
@@ -801,10 +718,6 @@ $custom-border-color: #2c3044;
.select-group {
.group-label {
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(0, 0, 0, 0.85);
background-color: #fafafa;
border-bottom: 1px solid #f0f0f0;
@@ -923,38 +836,3 @@ $custom-border-color: #2c3044;
}
}
}
.custom-multiselect-wrapper {
position: relative;
width: 100%;
&.all-selected {
.all-text {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--bg-vanilla-400);
font-weight: 500;
z-index: 2;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
.lightMode & {
color: rgba(0, 0, 0, 0.85);
}
}
&:focus-within .all-text {
opacity: 0.45;
}
.ant-select-selection-search-input {
caret-color: auto;
}
.ant-select-selection-placeholder {
display: none;
}
}
}

View File

@@ -24,12 +24,9 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
highlightSearch?: boolean;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
popupMatchSelectWidth?: boolean;
errorMessage?: string | null;
errorMessage?: string;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
showIncompleteDataMessage?: boolean;
showRetryButton?: boolean;
isDynamicVariable?: boolean;
}
export interface CustomTagProps {
@@ -54,16 +51,10 @@ export interface CustomMultiSelectProps
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
highlightSearch?: boolean;
errorMessage?: string | null;
errorMessage?: string;
popupClassName?: string;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
maxTagCount?: number;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
showLabels?: boolean;
enableRegexOption?: boolean;
isDynamicVariable?: boolean;
showRetryButton?: boolean;
}

View File

@@ -1,6 +1,4 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { uniqueOptions } from 'container/NewDashboard/DashboardVariablesSelection/util';
import { OptionData } from './types';
export const SPACEKEY = ' ';
@@ -100,10 +98,8 @@ export const prioritizeOrAddOptionForMultiSelect = (
label: labels?.[value] ?? value, // Use provided label or default to value
}));
const flatOutSelectedOptions = uniqueOptions([...newOptions, ...foundOptions]);
// Add found & new options to the top
return [...flatOutSelectedOptions, ...filteredOptions];
return [...newOptions, ...foundOptions, ...filteredOptions];
};
/**
@@ -137,15 +133,3 @@ export const filterOptionsBySearch = (
})
.filter(Boolean) as OptionData[];
};
/**
* Utility function to handle dropdown scroll and detect when scrolled to bottom
* Returns true when scrolled to within 20px of the bottom
*/
export const handleScrollToBottom = (
e: React.UIEvent<HTMLDivElement>,
): boolean => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// Consider "scrolled to bottom" when within 20px of the bottom or at the bottom
return scrollHeight - scrollTop - clientHeight < 20;
};

View File

@@ -102,7 +102,7 @@ function ListViewOrderBy({
onChange={onChange}
onSearch={handleSearch}
notFoundContent={<Loader isLoading={isLoading} />}
placeholder="Select a field"
placeholder="Select an attribute"
style={{ width: 200 }}
options={selectOptions}
filterOption={(input, option): boolean =>

View File

@@ -404,7 +404,6 @@
}
.qb-search-filter-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;

View File

@@ -9,7 +9,7 @@ import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/Orde
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { isEmpty } from 'lodash-es';
import { BarChart2, ChevronUp, ExternalLink, ScrollText } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -34,14 +34,6 @@ const ADD_ONS_KEYS = {
LEGEND_FORMAT: 'legend_format',
};
const ADD_ONS_KEYS_TO_QUERY_PATH = {
[ADD_ONS_KEYS.GROUP_BY]: 'groupBy',
[ADD_ONS_KEYS.HAVING]: 'having.expression',
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
[ADD_ONS_KEYS.LIMIT]: 'limit',
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
};
const ADD_ONS = [
{
icon: <BarChart2 size={14} />,
@@ -99,9 +91,6 @@ const REDUCE_TO = {
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
};
const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
// Custom tooltip content component
function TooltipContent({
label,
@@ -206,29 +195,21 @@ function QueryAddOns({
}
}
// add reduce to if showReduceTo is true
if (showReduceTo) {
filteredAddOns = [...filteredAddOns, REDUCE_TO];
}
setAddOns(filteredAddOns);
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
);
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
// Filter and set selected views: add-ons that are both active and available
setSelectedViews(
ADD_ONS.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
// Filter selectedViews to only include add-ons present in filteredAddOns
setSelectedViews((prevSelectedViews) =>
prevSelectedViews.filter((view) =>
filteredAddOns.some((addOn) => addOn.key === view.key),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panelType, isListViewPanel, query]);
}, [panelType, isListViewPanel, query.dataSource]);
const handleOptionClick = (e: RadioChangeEvent): void => {
if (selectedViews.find((view) => view.key === e.target.value.key)) {
@@ -304,7 +285,7 @@ function QueryAddOns({
{selectedViews.length > 0 && (
<div className="selected-add-ons-content">
{selectedViews.find((view) => view.key === 'group_by') && (
<div className="add-on-content" data-testid="group-by-content">
<div className="add-on-content">
<div className="periscope-input-with-label">
<Tooltip
title={
@@ -340,7 +321,7 @@ function QueryAddOns({
</div>
)}
{selectedViews.find((view) => view.key === 'having') && (
<div className="add-on-content" data-testid="having-content">
<div className="add-on-content">
<div className="periscope-input-with-label">
<Tooltip
title={
@@ -372,7 +353,7 @@ function QueryAddOns({
</div>
)}
{selectedViews.find((view) => view.key === 'limit') && (
<div className="add-on-content" data-testid="limit-content">
<div className="add-on-content">
<InputWithLabel
label="Limit"
onChange={handleChangeLimit}
@@ -386,7 +367,7 @@ function QueryAddOns({
</div>
)}
{selectedViews.find((view) => view.key === 'order_by') && (
<div className="add-on-content" data-testid="order-by-content">
<div className="add-on-content">
<div className="periscope-input-with-label">
<Tooltip
title={
@@ -424,7 +405,7 @@ function QueryAddOns({
)}
{selectedViews.find((view) => view.key === 'reduce_to') && showReduceTo && (
<div className="add-on-content" data-testid="reduce-to-content">
<div className="add-on-content">
<div className="periscope-input-with-label">
<Tooltip
title={
@@ -455,7 +436,7 @@ function QueryAddOns({
)}
{selectedViews.find((view) => view.key === 'legend_format') && (
<div className="add-on-content" data-testid="legend-format-content">
<div className="add-on-content">
<InputWithLabel
label="Legend format"
placeholder="Write legend format"

View File

@@ -686,10 +686,7 @@ function QueryAggregationSelect({
>
<Info
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
/>
</div>
</Tooltip>

View File

@@ -23,7 +23,6 @@ import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
QUERY_BUILDER_KEY_TYPES,
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions,
@@ -33,14 +32,12 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Info, TriangleAlert } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
IDetailedError,
IQueryContext,
IValidationResult,
} from 'types/antlrQueryTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
@@ -164,15 +161,13 @@ function QuerySearch({
const { handleRunQuery } = useQueryBuilder();
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
);
// const {
// data: queryKeySuggestions,
// refetch: refetchQueryKeySuggestions,
// } = useGetQueryKeySuggestions({
// signal: dataSource,
// name: searchText || '',
// });
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
@@ -987,25 +982,6 @@ function QuerySearch({
option.label.toLowerCase().includes(searchText),
);
// Add dynamic variables suggestions for the current key
const variableName = dynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === keyName,
)?.name;
if (variableName) {
const variableValue = `$${variableName}`;
const variableOption = {
label: variableValue,
type: 'variable',
apply: variableValue,
};
// Add variable suggestion at the beginning if it matches the search text
if (variableValue.toLowerCase().includes(searchText.toLowerCase())) {
options = [variableOption, ...options];
}
}
// Trigger fetch only if needed
const shouldFetch =
// Fetch only if key is available
@@ -1058,9 +1034,6 @@ function QuerySearch({
} else if (option.type === 'array') {
// Arrays are already formatted as arrays
processedOption.apply = option.label;
} else if (option.type === 'variable') {
// Variables should be used as-is (they already have the $ prefix)
processedOption.apply = option.label;
}
return processedOption;
@@ -1077,11 +1050,11 @@ function QuerySearch({
}
if (queryContext.isInFunction) {
options = Object.values(QUERY_BUILDER_FUNCTIONS).map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
options = [
{ label: 'HAS', type: 'function' },
{ label: 'HASANY', type: 'function' },
{ label: 'HASALL', type: 'function' },
];
// Add space after selection for functions
const optionsWithSpace = addSpaceToOptions(options);
@@ -1270,10 +1243,7 @@ function QuerySearch({
>
<Info
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
/>
</a>
</Tooltip>

View File

@@ -155,7 +155,7 @@ export const QueryV2 = memo(function QueryV2({
</div>
{!isCollapsed && showInlineQuerySearch && (
<div className="qb-search-filter-container">
<div className="qb-search-filter-container" style={{ flex: 1 }}>
<div className="query-search-container">
<QuerySearch
key={`query-search-${query.queryName}-${query.dataSource}`}

View File

@@ -16,14 +16,40 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--bg-slate-400),
var(--bg-slate-400) 4px,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
}
}
&-span-source-label {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
&-query {
font-size: 14px;
font-weight: 400;
color: var(--bg-vanilla-100);
}
&-query-name {
width: 18px;
height: 18px;
display: grid;
place-content: center;
padding: 2px;
border-radius: 2px;
border: 1px solid rgba(242, 71, 105, 0.2);
background: rgba(242, 71, 105, 0.1);
color: var(--Sakura-400, #f56c87);
font-size: 12px;
}
}
&-arrow {
position: relative;
&::before {
@@ -36,8 +62,8 @@
width: 20px;
background: repeating-linear-gradient(
to right,
var(--bg-slate-400),
var(--bg-slate-400) 4px,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);

View File

@@ -1,186 +0,0 @@
/* eslint-disable */
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
// Mocks: only what is required for this component to render and for us to assert handler calls
const mockHandleChangeQueryData = jest.fn();
const mockHandleSetQueryData = jest.fn();
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: () => ({
handleChangeQueryData: mockHandleChangeQueryData,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: () => ({
handleSetQueryData: mockHandleSetQueryData,
}),
}));
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
GroupByFilter: ({ onChange }: any) => (
<button data-testid="groupby" onClick={() => onChange(['service.name'])}>
GroupByFilter
</button>
),
}));
jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
OrderByFilter: ({ onChange }: any) => (
<button
data-testid="orderby"
onClick={() => onChange([{ columnName: 'duration', order: 'desc' }])}
>
OrderByFilter
</button>
),
}));
jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
__esModule: true,
default: ({ onChange, onClose }: any) => (
<div>
<button data-testid="having-change" onClick={() => onChange('p99 > 500')}>
HavingFilter
</button>
<button data-testid="having-close" onClick={onClose}>
close
</button>
</div>
),
}));
jest.mock(
'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter',
() => ({
ReduceToFilter: ({ onChange }: any) => (
<button data-testid="reduce-to" onClick={() => onChange('sum')}>
ReduceToFilter
</button>
),
}),
);
function baseQuery(overrides: Partial<any> = {}): any {
return {
dataSource: DataSource.TRACES,
aggregations: [{ id: 'a', operator: 'count' }],
groupBy: [],
orderBy: [],
legend: '',
limit: null,
having: { expression: '' },
...overrides,
};
}
describe('QueryAddOns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('VALUE panel: no sections auto-open when query has no active add-ons', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.VALUE}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.queryByTestId('legend-format-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('reduce-to-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('order-by-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('limit-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('having-content')).not.toBeInTheDocument();
});
it('hides group-by section for METRICS even if groupBy is set in query', () => {
render(
<QueryAddOns
query={baseQuery({
dataSource: DataSource.METRICS,
groupBy: ['service.name'],
})}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument();
});
it('defaults to Order By open in list view panel', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel
showReduceTo={false}
panelType={PANEL_TYPES.LIST}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
});
it('limit input auto-opens when limit is set and changing it calls handler', () => {
render(
<QueryAddOns
query={baseQuery({ limit: 5 })}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
const input = screen.getByTestId('input-Limit') as HTMLInputElement;
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],
limit: 7,
});
render(
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
const limitInput = screen.getByTestId('input-Limit') as HTMLInputElement;
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(limitInput.value).toBe('7');
});
});

View File

@@ -13,7 +13,6 @@ import {
convertAggregationToExpression,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
removeKeysFromExpression,
} from '../utils';
describe('convertFiltersToExpression', () => {
@@ -973,223 +972,3 @@ describe('convertAggregationToExpression', () => {
]);
});
});
describe('removeKeysFromExpression', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Backward compatibility (removeOnlyVariableExpressions = false)', () => {
it('should remove simple key-value pair from expression', () => {
const expression = "service.name = 'api-gateway' AND status = 'success'";
const result = removeKeysFromExpression(expression, ['service.name']);
expect(result).toBe("status = 'success'");
});
it('should remove multiple keys from expression', () => {
const expression =
"service.name = 'api-gateway' AND status = 'success' AND region = 'us-east-1'";
const result = removeKeysFromExpression(expression, [
'service.name',
'status',
]);
expect(result).toBe("region = 'us-east-1'");
});
it('should handle empty expression', () => {
const result = removeKeysFromExpression('', ['service.name']);
expect(result).toBe('');
});
it('should handle empty keys array', () => {
const expression = "service.name = 'api-gateway'";
const result = removeKeysFromExpression(expression, []);
expect(result).toBe(expression);
});
it('should handle key not found in expression', () => {
const expression = "service.name = 'api-gateway'";
const result = removeKeysFromExpression(expression, ['nonexistent.key']);
expect(result).toBe(expression);
});
// todo: Sagar check this - this is expected or not
// it('should remove last occurrence when multiple occurrences exist', () => {
// // This tests the original behavior - should remove the last occurrence
// const expression =
// "deployment.environment = $deployment.environment deployment.environment = 'default'";
// const result = removeKeysFromExpression(
// expression,
// ['deployment.environment'],
// false,
// );
// // Should remove the literal value (last occurrence), leaving the variable
// expect(result).toBe('deployment.environment = $deployment.environment');
// });
});
describe('Variable expression targeting (removeOnlyVariableExpressions = true)', () => {
it('should remove only variable expressions (values starting with $)', () => {
const expression =
"deployment.environment = $deployment.environment deployment.environment = 'default'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
// Should remove the variable expression, leaving the literal value
expect(result).toBe("deployment.environment = 'default'");
});
it('should not remove literal values when targeting variable expressions', () => {
const expression = "service.name = 'api-gateway' AND status = 'success'";
const result = removeKeysFromExpression(expression, ['service.name'], true);
// Should not remove anything since no variable expressions exist
expect(result).toBe("service.name = 'api-gateway' AND status = 'success'");
});
it('should remove multiple variable expressions', () => {
const expression =
"deployment.environment = $deployment.environment service.name = $service.name status = 'success'";
const result = removeKeysFromExpression(
expression,
['deployment.environment', 'service.name'],
true,
);
expect(result).toBe("status = 'success'");
});
it('should handle mixed variable and literal expressions correctly', () => {
const expression =
"deployment.environment = $deployment.environment service.name = 'api-gateway' region = $region";
const result = removeKeysFromExpression(
expression,
['deployment.environment', 'region'],
true,
);
// Should only remove variable expressions, leaving literal value
expect(result).toBe("service.name = 'api-gateway'");
});
it('should handle complex expressions with operators', () => {
const expression =
"deployment.environment IN [$env1, $env2] AND service.name = 'api-gateway'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe("service.name = 'api-gateway'");
});
});
describe('Edge cases and robustness', () => {
it('should handle case insensitive key matching', () => {
const expression = 'Service.Name = $Service.Name';
const result = removeKeysFromExpression(expression, ['service.name'], true);
expect(result).toBe('');
});
it('should clean up trailing AND/OR operators', () => {
const expression =
"deployment.environment = $deployment.environment AND service.name = 'api-gateway'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe("service.name = 'api-gateway'");
});
it('should clean up leading AND/OR operators', () => {
const expression =
"service.name = 'api-gateway' AND deployment.environment = $deployment.environment";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe("service.name = 'api-gateway'");
});
it('should handle expressions with only variable assignments', () => {
const expression = 'deployment.environment = $deployment.environment';
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe('');
});
it('should handle whitespace around operators', () => {
const expression =
"deployment.environment = $deployment.environment AND service.name = 'api-gateway'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result.trim()).toBe("service.name = 'api-gateway'");
});
});
describe('Real-world scenarios', () => {
it('should handle multiple variable instances of same key', () => {
const expression =
"deployment.environment = $env1 deployment.environment = $env2 deployment.environment = 'default'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
// Should remove one occurence as this case in itself is invalid to have multiple variable expressions for the same key
expect(result).toBe(
"deployment.environment = $env1 deployment.environment = 'default'",
);
});
it('should handle OR operators in expressions', () => {
const expression =
"deployment.environment = $deployment.environment OR service.name = 'api-gateway'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe("service.name = 'api-gateway'");
});
it('should maintain expression validity after removal', () => {
const expression =
"deployment.environment = $deployment.environment AND service.name = 'api-gateway' AND status = 'success'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
// Should maintain valid AND structure
expect(result).toBe("service.name = 'api-gateway' AND status = 'success'");
// Verify the result can be parsed by extractQueryPairs
const pairs = extractQueryPairs(result);
expect(pairs).toHaveLength(2);
});
});
});

View File

@@ -38,13 +38,6 @@ const isArrayOperator = (operator: string): boolean => {
return arrayOperators.includes(operator);
};
const isVariable = (value: string | string[] | number | boolean): boolean => {
if (Array.isArray(value)) {
return value.some((v) => typeof v === 'string' && v.trim().startsWith('$'));
}
return typeof value === 'string' && value.trim().startsWith('$');
};
/**
* Format a value for the expression string
* @param value - The value to format
@@ -55,10 +48,6 @@ const formatValueForExpression = (
value: string[] | string | number | boolean,
operator?: string,
): string => {
if (isVariable(value)) {
return String(value);
}
// For IN operators, ensure value is always an array
if (isArrayOperator(operator || '')) {
const arrayValue = Array.isArray(value) ? value : [value];
@@ -477,13 +466,11 @@ export const convertFiltersToExpressionWithExistingQuery = (
*
* @param expression - The full query string.
* @param keysToRemove - An array of keys (case-insensitive) that should be removed from the expression.
* @param removeOnlyVariableExpressions - When true, only removes key-value pairs where the value is a variable (starts with $). When false, uses the original behavior.
* @returns A new expression string with the specified keys and their associated clauses removed.
*/
export const removeKeysFromExpression = (
expression: string,
keysToRemove: string[],
removeOnlyVariableExpressions = false,
): string => {
if (!keysToRemove || keysToRemove.length === 0) {
return expression;
@@ -499,20 +486,9 @@ export const removeKeysFromExpression = (
let queryPairsMap: Map<string, IQueryPair>;
if (existingQueryPairs.length > 0) {
// Filter query pairs based on the removeOnlyVariableExpressions flag
const filteredQueryPairs = removeOnlyVariableExpressions
? existingQueryPairs.filter((pair) => {
const pairKey = pair.key?.trim().toLowerCase();
const matchesKey = pairKey === `${key}`.trim().toLowerCase();
if (!matchesKey) return false;
const value = pair.value?.toString().trim();
return value && value.includes('$');
})
: existingQueryPairs;
// Build a map for quick lookup of query pairs by their lowercase trimmed keys
queryPairsMap = new Map(
filteredQueryPairs.map((pair) => {
existingQueryPairs.map((pair) => {
const key = pair.key.trim().toLowerCase();
return [key, pair];
}),
@@ -548,12 +524,6 @@ export const removeKeysFromExpression = (
}
}
});
// Clean up any remaining trailing AND/OR operators and extra whitespace
updatedExpression = updatedExpression
.replace(/\s+(AND|OR)\s*$/i, '') // Remove trailing AND/OR
.replace(/^(AND|OR)\s+/i, '') // Remove leading AND/OR
.trim();
}
return updatedExpression;

View File

@@ -1,108 +0,0 @@
.span-hover-card {
width: 206px;
.ant-popover-inner {
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.32) 0%,
rgba(18, 19, 23, 0.36) 98.68%
);
padding: 12px 16px;
border: 1px solid var(--bg-slate-500);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.32) 0%,
rgba(18, 19, 23, 0.36) 98.68%
);
backdrop-filter: blur(20px);
border-radius: 4px;
z-index: -1;
will-change: background-color, backdrop-filter;
}
}
&__title {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__operation {
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
}
&__service {
font-size: 0.875rem;
color: var(--bg-vanilla-400);
font-weight: 400;
}
&__error {
font-size: 0.75rem;
color: var(--bg-cherry-500);
font-weight: 500;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 174px;
margin-top: 8px;
}
&__label {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&__value {
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: right;
}
&__relative-time {
display: flex;
align-items: center;
margin-top: 4px;
gap: 8px;
border-radius: 1px 0 0 1px;
background: linear-gradient(
90deg,
hsla(358, 75%, 59%, 0.2) 0%,
rgba(229, 72, 77, 0) 100%
);
&-icon {
width: 2px;
height: 20px;
flex-shrink: 0;
border-radius: 2px;
background: var(--bg-cherry-500);
}
}
&__relative-text {
color: var(--bg-cherry-300);
font-size: 12px;
line-height: 20px;
}
}

View File

@@ -1,101 +0,0 @@
import './SpanHoverCard.styles.scss';
import { Popover, Typography } from 'antd';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { ReactNode } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
const { time: formattedDuration, timeUnitName } = convertTimeToRelevantUnit(
duration,
);
// Calculate relative start time from trace start
const relativeStartTime = span.timestamp - traceMetadata.startTime;
const {
time: relativeTime,
timeUnitName: relativeTimeUnit,
} = convertTimeToRelevantUnit(relativeStartTime);
// Format absolute start time
const startTimeFormatted = dayjs(span.timestamp).format(
DATE_TIME_FORMATS.SPAN_POPOVER_DATE,
);
const getContent = (): JSX.Element => (
<div className="span-hover-card">
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Duration:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{toFixed(formattedDuration, 2)}
{timeUnitName}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Events:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{span.event?.length || 0}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Start time:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{startTimeFormatted}
</Typography.Text>
</div>
<div className="span-hover-card__relative-time">
<div className="span-hover-card__relative-time-icon" />
<Typography.Text className="span-hover-card__relative-text">
{toFixed(relativeTime, 2)}
{relativeTimeUnit} after trace start
</Typography.Text>
</div>
</div>
);
return (
<Popover
title={
<div className="span-hover-card__title">
<Typography.Text className="span-hover-card__operation">
{span.name}
</Typography.Text>
</div>
}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -8,7 +8,7 @@ import {
import { Tooltip } from 'antd';
import { themeColors } from 'constants/theme';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ReactNode, useMemo } from 'react';
import { useMemo } from 'react';
import { style } from './constant';
@@ -17,8 +17,6 @@ function TextToolTip({
url,
useFilledIcon = true,
urlText,
filledIcon,
outlinedIcon,
}: TextToolTipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -64,44 +62,27 @@ function TextToolTip({
[isDarkMode],
);
// Use provided icons or fallback to default icons
const defaultFilledIcon = <QuestionCircleFilled style={iconStyle} />;
const defaultOutlinedIcon = (
<QuestionCircleOutlined style={iconOutlinedStyle} />
);
const renderIcon = (): ReactNode => {
if (useFilledIcon) {
return filledIcon ? (
<div style={{ color: iconStyle.color }}>{filledIcon}</div>
return (
<Tooltip overlay={overlay}>
{useFilledIcon ? (
<QuestionCircleFilled style={iconStyle} />
) : (
defaultFilledIcon
);
}
return outlinedIcon ? (
<div style={{ color: iconOutlinedStyle.color }}>{outlinedIcon}</div>
) : (
defaultOutlinedIcon
);
};
return <Tooltip overlay={overlay}>{renderIcon()}</Tooltip>;
<QuestionCircleOutlined style={iconOutlinedStyle} />
)}
</Tooltip>
);
}
TextToolTip.defaultProps = {
url: '',
urlText: '',
useFilledIcon: true,
filledIcon: undefined,
outlinedIcon: undefined,
};
interface TextToolTipProps {
url?: string;
text: string;
useFilledIcon?: boolean;
urlText?: string;
filledIcon?: ReactNode;
outlinedIcon?: ReactNode;
}
export default TextToolTip;

View File

@@ -62,7 +62,7 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
useEffect(() => {
onCreateRef.current = onCreate;
onDeleteRef.current = onDelete;
}, [onCreate, onDelete]);
});
const destroy = useCallback((chart: uPlot | null) => {
if (chart) {
@@ -71,25 +71,12 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
chartRef.current = null;
}
// Clean up tooltip overlay that might be detached
// remove chart tooltip on cleanup
const overlay = document.getElementById('overlay');
if (overlay) {
// Remove all child elements from overlay
while (overlay.firstChild) {
overlay.removeChild(overlay.firstChild);
}
overlay.style.display = 'none';
}
// Clean up any remaining tooltips that might be detached
const tooltips = document.querySelectorAll(
'.uplot-tooltip, .tooltip-container',
);
tooltips.forEach((tooltip) => {
if (tooltip && tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
});
}, []);
const create = useCallback(() => {

View File

@@ -42,7 +42,6 @@ export const QUERY_BUILDER_FUNCTIONS = {
HAS: 'has',
HASANY: 'hasAny',
HASALL: 'hasAll',
HASTOKEN: 'hasToken',
};
export function negateOperator(operatorOrFunction: string): string {

View File

@@ -29,7 +29,6 @@ export const DATE_TIME_FORMATS = {
DATE_SHORT: 'MM/DD',
YEAR_SHORT: 'YY',
YEAR_MONTH: 'YY-MM',
SPAN_POPOVER_DATE: 'M/D/YY - HH:mm',
// Month name formats
MONTH_DATE_FULL: 'MMMM DD, YYYY',

View File

@@ -46,7 +46,6 @@ export enum QueryParams {
msgSystem = 'msgSystem',
destination = 'destination',
kindString = 'kindString',
summaryFilters = 'summaryFilters',
tab = 'tab',
thresholds = 'thresholds',
selectedExplorerView = 'selectedExplorerView',

View File

@@ -3168,6 +3168,7 @@ export const getStatusCodeBarChartWidgetData = (
},
description: '',
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
isStacked: false,
panelTypes: PANEL_TYPES.BAR,
title: '',
opacity: '',

View File

@@ -1,91 +0,0 @@
import './styles.scss';
import '../EvaluationSettings/styles.scss';
import { Button, Tooltip } from 'antd';
import classNames from 'classnames';
import { Activity, ChartLine } from 'lucide-react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AlertThreshold from './AlertThreshold';
import AnomalyThreshold from './AnomalyThreshold';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const showCondensedLayoutFlag = showCondensedLayout();
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
alertType === AlertTypes.METRICS_BASED_ALERT;
const tabs = [
{
label: 'Threshold',
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {
if (!showMultipleTabs) {
return;
}
setAlertType(value);
};
const getTabTooltip = (tab: { value: AlertTypes }): string => {
if (tab.value === AlertTypes.ANOMALY_BASED_ALERT) {
return ANOMALY_TAB_TOOLTIP;
}
return THRESHOLD_TAB_TOOLTIP;
};
return (
<div className="alert-condition-container">
<Stepper stepNumber={2} label="Set alert conditions" />
<div className="alert-condition">
<div className="alert-condition-tabs">
{tabs.map((tab) => (
<Tooltip key={tab.value} title={getTabTooltip(tab)}>
<Button
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': alertType === tab.value,
})}
onClick={(): void => {
if (alertType !== tab.value) {
handleAlertTypeChange(tab.value as AlertTypes);
}
}}
>
{tab.icon}
{tab.label}
</Button>
</Tooltip>
))}
</div>
</div>
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
{showCondensedLayoutFlag ? (
<div className="condensed-advanced-options-container">
<AdvancedOptions />
</div>
) : null}
</div>
);
}
export default AlertCondition;

View File

@@ -1,177 +0,0 @@
import './styles.scss';
import '../EvaluationSettings/styles.scss';
import { Button, Select, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Plus } from 'lucide-react';
import { useQuery } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import {
INITIAL_INFO_THRESHOLD,
INITIAL_RANDOM_THRESHOLD,
INITIAL_WARNING_THRESHOLD,
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { showCondensedLayout } from '../utils';
import ThresholdItem from './ThresholdItem';
import { UpdateThreshold } from './types';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
getQueryNames,
} from './utils';
function AlertThreshold(): JSX.Element {
const {
alertState,
thresholdState,
setThresholdState,
} = useCreateAlertState();
const { data, isLoading: isLoadingChannels } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const showCondensedLayoutFlag = showCondensedLayout();
const channels = data?.data || [];
const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery);
const selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || '');
const categorySelectOptions = getCategorySelectOptionByName(
selectedCategory || '',
);
const addThreshold = (): void => {
let newThreshold;
if (thresholdState.thresholds.length === 1) {
newThreshold = INITIAL_WARNING_THRESHOLD;
} else if (thresholdState.thresholds.length === 2) {
newThreshold = INITIAL_INFO_THRESHOLD;
} else {
newThreshold = INITIAL_RANDOM_THRESHOLD;
}
setThresholdState({
type: 'SET_THRESHOLDS',
payload: [...thresholdState.thresholds, newThreshold],
});
};
const removeThreshold = (id: string): void => {
if (thresholdState.thresholds.length > 1) {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.filter((t) => t.id !== id),
});
}
};
const updateThreshold: UpdateThreshold = (id, field, value) => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) =>
t.id === id ? { ...t, [field]: value } : t,
),
});
};
const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings />
) : (
<strong>Evaluation Window.</strong>
);
return (
<div
className={classNames('alert-threshold-container', {
'condensed-alert-threshold-container': showCondensedLayoutFlag,
})}
>
{/* Main condition sentence */}
<div className="alert-condition-sentences">
<div className="alert-condition-sentence">
<Typography.Text className="sentence-text">
Send a notification when
</Typography.Text>
<Select
value={thresholdState.selectedQuery}
onChange={(value): void => {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
}}
style={{ width: 80 }}
options={queryNames}
/>
</div>
<div className="alert-condition-sentence">
<Select
value={thresholdState.operator}
onChange={(value): void => {
setThresholdState({
type: 'SET_OPERATOR',
payload: value,
});
}}
style={{ width: 120 }}
options={THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text className="sentence-text">
the threshold(s)
</Typography.Text>
<Select
value={thresholdState.matchType}
onChange={(value): void => {
setThresholdState({
type: 'SET_MATCH_TYPE',
payload: value,
});
}}
style={{ width: 140 }}
options={THRESHOLD_MATCH_TYPE_OPTIONS}
/>
<Typography.Text className="sentence-text">
during the {evaluationWindowContext}
</Typography.Text>
</div>
</div>
<div className="thresholds-section">
{thresholdState.thresholds.map((threshold, index) => (
<ThresholdItem
key={threshold.id}
threshold={threshold}
updateThreshold={updateThreshold}
removeThreshold={removeThreshold}
showRemoveButton={index !== 0 && thresholdState.thresholds.length > 1}
channels={channels}
isLoadingChannels={isLoadingChannels}
units={categorySelectOptions}
/>
))}
<Button
type="dashed"
icon={<Plus size={16} />}
onClick={addThreshold}
className="add-threshold-btn"
>
Add Threshold
</Button>
</div>
</div>
);
}
export default AlertThreshold;

View File

@@ -1,174 +0,0 @@
import { Select, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useMemo } from 'react';
import { useCreateAlertState } from '../context';
import {
ANOMALY_ALGORITHM_OPTIONS,
ANOMALY_SEASONALITY_OPTIONS,
ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS,
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
ANOMALY_TIME_DURATION_OPTIONS,
} from '../context/constants';
import { getQueryNames } from './utils';
function AnomalyThreshold(): JSX.Element {
const { thresholdState, setThresholdState } = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery);
const deviationOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 7; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const updateThreshold = (id: string, field: string, value: string): void => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) =>
t.id === id ? { ...t, [field]: value } : t,
),
});
};
return (
<div className="anomaly-threshold-container">
<div className="alert-condition-sentences">
{/* Sentence 1 */}
<div className="alert-condition-sentence">
<Typography.Text data-testid="notification-text" className="sentence-text">
Send notification when the observed value for
</Typography.Text>
<Select
value={thresholdState.selectedQuery}
data-testid="query-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
}}
style={{ width: 80 }}
options={queryNames}
/>
<Typography.Text
data-testid="evaluation-window-text"
className="sentence-text"
>
during the last
</Typography.Text>
<Select
value={thresholdState.evaluationWindow}
data-testid="evaluation-window-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_EVALUATION_WINDOW',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_TIME_DURATION_OPTIONS}
/>
</div>
{/* Sentence 2 */}
<div className="alert-condition-sentence">
<Typography.Text data-testid="threshold-text" className="sentence-text">
is
</Typography.Text>
<Select
value={thresholdState.thresholds[0].thresholdValue}
data-testid="threshold-value-select"
onChange={(value): void => {
updateThreshold(
thresholdState.thresholds[0].id,
'thresholdValue',
value.toString(),
);
}}
style={{ width: 80 }}
options={deviationOptions}
/>
<Typography.Text data-testid="deviations-text" className="sentence-text">
deviations
</Typography.Text>
<Select
value={thresholdState.operator}
data-testid="operator-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_OPERATOR',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text
data-testid="predicted-data-text"
className="sentence-text"
>
the predicted data
</Typography.Text>
<Select
value={thresholdState.matchType}
data-testid="match-type-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_MATCH_TYPE',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
/>
</div>
{/* Sentence 3 */}
<div className="alert-condition-sentence">
<Typography.Text data-testid="using-the-text" className="sentence-text">
using the
</Typography.Text>
<Select
value={thresholdState.algorithm}
data-testid="algorithm-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_ALGORITHM',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_ALGORITHM_OPTIONS}
/>
<Typography.Text
data-testid="algorithm-with-text"
className="sentence-text"
>
algorithm with
</Typography.Text>
<Select
value={thresholdState.seasonality}
data-testid="seasonality-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_SEASONALITY',
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
</div>
</div>
</div>
);
}
export default AnomalyThreshold;

View File

@@ -1,135 +0,0 @@
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
import { ChartLine, CircleX } from 'lucide-react';
import { useMemo, useState } from 'react';
import { ThresholdItemProps } from './types';
function ThresholdItem({
threshold,
updateThreshold,
removeThreshold,
showRemoveButton,
channels,
units,
}: ThresholdItemProps): JSX.Element {
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
const yAxisUnitSelect = useMemo(() => {
let component = (
<Select
placeholder="Unit"
value={threshold.unit ? threshold.unit : null}
onChange={(value): void => updateThreshold(threshold.id, 'unit', value)}
style={{ width: 150 }}
options={units}
disabled={units.length === 0}
/>
);
if (units.length === 0) {
component = (
<Tooltip
trigger="hover"
title="Please select a Y-axis unit for the query first"
>
<Select
placeholder="Unit"
value={threshold.unit ? threshold.unit : null}
onChange={(value): void => updateThreshold(threshold.id, 'unit', value)}
style={{ width: 150 }}
options={units}
disabled={units.length === 0}
/>
</Tooltip>
);
}
return component;
}, [units, threshold.unit, updateThreshold, threshold.id]);
return (
<div key={threshold.id} className="threshold-item">
<div className="threshold-row">
<div className="threshold-indicator">
<div
className="threshold-dot"
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>
<Select
value={threshold.channels}
onChange={(value): void =>
updateThreshold(threshold.id, 'channels', value)
}
style={{ width: 260 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
/>
<Button.Group>
{!showRecoveryThreshold && (
<Button
type="default"
icon={<ChartLine size={16} />}
className="icon-btn"
onClick={(): void => setShowRecoveryThreshold(true)}
/>
)}
{showRemoveButton && (
<Button
type="default"
icon={<CircleX size={16} />}
onClick={(): void => removeThreshold(threshold.id)}
className="icon-btn"
/>
)}
</Button.Group>
</Space>
</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>
);
}
export default ThresholdItem;

View File

@@ -1,271 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock: any = jest.fn(() => ({
paths,
}));
uplotMock.paths = paths;
return uplotMock;
});
const STEPPER_TEST_ID = 'stepper';
const ALERT_THRESHOLD_TEST_ID = 'alert-threshold';
const ANOMALY_THRESHOLD_TEST_ID = 'anomaly-threshold';
const THRESHOLD_VIEW_TEST_ID = 'threshold-view';
const ANOMALY_VIEW_TEST_ID = 'anomaly-view';
const ANOMALY_TAB_TEXT = 'Anomaly';
const THRESHOLD_TAB_TEXT = 'Threshold';
const ACTIVE_TAB_CLASS = '.active-tab';
// Mock the Stepper component
jest.mock('../../Stepper', () => ({
__esModule: true,
default: function MockStepper({
stepNumber,
label,
}: {
stepNumber: number;
label: string;
}): JSX.Element {
return (
<div data-testid={STEPPER_TEST_ID}>{`Step ${stepNumber}: ${label}`}</div>
);
},
}));
// Mock the AlertThreshold component
jest.mock('../AlertThreshold', () => ({
__esModule: true,
default: function MockAlertThreshold(): JSX.Element {
return (
<div data-testid={ALERT_THRESHOLD_TEST_ID}>Alert Threshold Component</div>
);
},
}));
// Mock the AnomalyThreshold component
jest.mock('../AnomalyThreshold', () => ({
__esModule: true,
default: function MockAnomalyThreshold(): JSX.Element {
return (
<div data-testid={ANOMALY_THRESHOLD_TEST_ID}>
Anomaly Threshold Component
</div>
);
},
}));
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: {
builder: { queryData: unknown[]; queryFormulas: unknown[] };
dataSource: string;
queryName: string;
};
redirectWithQueryBuilderData: () => void;
} => ({
currentQuery: {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ dataSource: 'METRICS' }],
queryFormulas: [],
},
},
redirectWithQueryBuilderData: jest.fn(),
}),
}));
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderAlertCondition = (
alertType?: string,
): ReturnType<typeof render> => {
const queryClient = createTestQueryClient();
const initialEntries = alertType ? [`/?alertType=${alertType}`] : undefined;
return render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider>
<AlertCondition />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
);
};
describe('AlertCondition', () => {
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
'Step 2: Set alert conditions',
);
});
it('verifies default props and initial state', () => {
renderAlertCondition();
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
renderAlertCondition();
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// Verify default props
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
});
it('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows AlertThreshold component when alert type is not anomaly based', () => {
renderAlertCondition();
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
});
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
it('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
// Click anomaly tab
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
// Should show anomaly component
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
// Click threshold tab
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
fireEvent.click(thresholdTab);
// Should show threshold component again
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
});
it('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
// Threshold tab should be active by default
expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument();
// Click anomaly tab
fireEvent.click(anomalyTab);
// Anomaly tab should be active now
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument();
});
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// Both tabs should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
renderAlertCondition('ANOMALY_BASED_ALERT');
// Both tabs should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {
renderAlertCondition('LOGS_BASED_ALERT');
// Only threshold tab should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument();
});
it('shows only threshold tab for TRACES_BASED_ALERT', () => {
renderAlertCondition('TRACES_BASED_ALERT');
// Only threshold tab should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument();
});
it('shows only threshold tab for EXCEPTIONS_BASED_ALERT', () => {
renderAlertCondition('EXCEPTIONS_BASED_ALERT');
// Only threshold tab should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument();
});
});

View File

@@ -1,272 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { Channels } from 'types/api/channels/getAll';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock: any = jest.fn(() => ({
paths,
}));
uplotMock.paths = paths;
return uplotMock;
});
// Mock the ThresholdItem component
jest.mock('../ThresholdItem', () => ({
__esModule: true,
default: function MockThresholdItem({
threshold,
removeThreshold,
showRemoveButton,
}: {
threshold: Record<string, unknown>;
removeThreshold: (id: string) => void;
showRemoveButton: boolean;
}): JSX.Element {
return (
<div data-testid={`threshold-item-${threshold.id}`}>
<span>{threshold.label as string}</span>
{showRemoveButton && (
<button
type="button"
data-testid={`remove-threshold-${threshold.id}`}
onClick={(): void => removeThreshold(threshold.id as string)}
>
Remove
</button>
)}
</div>
);
},
}));
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: {
dataSource: string;
queryName: string;
builder: {
queryData: Array<{ queryName: string }>;
queryFormulas: Array<{ queryName: string }>;
};
unit: string;
};
} => ({
currentQuery: {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ queryName: 'Query A' }, { queryName: 'Query B' }],
queryFormulas: [{ queryName: 'Formula 1' }],
},
unit: 'bytes',
},
}),
}));
// Mock getAllChannels API
jest.mock('api/channels/getAll', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as Channels[],
}),
),
}));
// Mock alert format categories
jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
getCategoryByOptionId: jest.fn(() => ({ name: 'bytes' })),
getCategorySelectOptionByName: jest.fn(() => [
{ label: 'Bytes', value: 'bytes' },
{ label: 'KB', value: 'kb' },
]),
}));
const TEST_STRINGS = {
ADD_THRESHOLD: 'Add Threshold',
AT_LEAST_ONCE: 'AT LEAST ONCE',
IS_ABOVE: 'IS ABOVE',
} as const;
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderAlertThreshold = (): ReturnType<typeof render> => {
const queryClient = createTestQueryClient();
return render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider>
<AlertThreshold />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
);
};
const verifySelectRenders = (title: string): void => {
const select = screen.getByTitle(title);
expect(select).toBeInTheDocument();
};
describe('AlertThreshold', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the main condition sentence', () => {
renderAlertThreshold();
expect(screen.getByText('Send a notification when')).toBeInTheDocument();
expect(screen.getByText('the threshold(s)')).toBeInTheDocument();
expect(screen.getByText('during the')).toBeInTheDocument();
expect(screen.getByText('Evaluation Window.')).toBeInTheDocument();
});
it('renders query selection dropdown', async () => {
renderAlertThreshold();
await waitFor(() => {
const querySelect = screen.getByTitle('A');
expect(querySelect).toBeInTheDocument();
});
});
it('renders operator selection dropdown', () => {
renderAlertThreshold();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
});
it('renders match type selection dropdown', () => {
renderAlertThreshold();
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});
it('renders threshold items', () => {
renderAlertThreshold();
expect(screen.getByTestId(/threshold-item-/)).toBeInTheDocument();
});
it('renders add threshold button', () => {
renderAlertThreshold();
expect(screen.getByText(TEST_STRINGS.ADD_THRESHOLD)).toBeInTheDocument();
});
it('adds a new threshold when add button is clicked', () => {
renderAlertThreshold();
const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD);
fireEvent.click(addButton);
// Should now have multiple threshold items
const thresholdItems = screen.getAllByTestId(/threshold-item-/);
expect(thresholdItems).toHaveLength(2);
});
it('adds correct threshold types based on count', () => {
renderAlertThreshold();
const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD);
// First addition should add WARNING threshold
fireEvent.click(addButton);
expect(screen.getByText('WARNING')).toBeInTheDocument();
// Second addition should add INFO threshold
fireEvent.click(addButton);
expect(screen.getByText('INFO')).toBeInTheDocument();
// Third addition should add random threshold
fireEvent.click(addButton);
expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(4);
});
it('updates operator when operator dropdown changes', () => {
renderAlertThreshold();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
});
it('updates match type when match type dropdown changes', () => {
renderAlertThreshold();
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});
it('shows remove button for non-first thresholds', () => {
renderAlertThreshold();
// Add a threshold
const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD);
fireEvent.click(addButton);
// Second threshold should have remove button
expect(screen.getByTestId(/remove-threshold-/)).toBeInTheDocument();
});
it('does not show remove button for first threshold', () => {
renderAlertThreshold();
// First threshold should not have remove button
expect(screen.queryByTestId(/remove-threshold-/)).not.toBeInTheDocument();
});
it('removes threshold when remove button is clicked', () => {
renderAlertThreshold();
// Add a threshold first
const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD);
fireEvent.click(addButton);
// Get the remove button and click it
const removeButton = screen.getByTestId(/remove-threshold-/);
fireEvent.click(removeButton);
// Should be back to one threshold
expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(1);
});
it('does not remove threshold if only one remains', () => {
renderAlertThreshold();
// Should only have one threshold initially
expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(1);
// Try to remove (should not work)
const thresholdItems = screen.getAllByTestId(/threshold-item-/);
expect(thresholdItems).toHaveLength(1);
});
it('handles loading state for channels', () => {
renderAlertThreshold();
// Component should render even while channels are loading
expect(screen.getByText('Send a notification when')).toBeInTheDocument();
});
it('renders with correct initial state', () => {
renderAlertThreshold();
// Should have initial critical threshold
expect(screen.getByText('CRITICAL')).toBeInTheDocument();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});
});

View File

@@ -1,89 +0,0 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import {
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
} from 'container/CreateAlertV2/context/constants';
import * as context from '../../context';
import AnomalyThreshold from '../AnomalyThreshold';
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);
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: {
dataSource: string;
queryName: string;
builder: {
queryData: Array<{ queryName: string }>;
queryFormulas: Array<{ queryName: string }>;
};
};
} => ({
currentQuery: {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ queryName: 'Query A' }, { queryName: 'Query B' }],
queryFormulas: [{ queryName: 'Formula 1' }],
},
},
}),
}));
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
render(<AnomalyThreshold />);
describe('AnomalyThreshold', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the first condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('notification-text')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-window-text')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-window-select')).toBeInTheDocument();
});
it('renders the second condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('threshold-text')).toBeInTheDocument();
expect(screen.getByTestId('threshold-value-select')).toBeInTheDocument();
expect(screen.getByTestId('deviations-text')).toBeInTheDocument();
expect(screen.getByTestId('operator-select')).toBeInTheDocument();
expect(screen.getByTestId('predicted-data-text')).toBeInTheDocument();
expect(screen.getByTestId('match-type-select')).toBeInTheDocument();
});
it('renders the third condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('using-the-text')).toBeInTheDocument();
expect(screen.getByTestId('algorithm-select')).toBeInTheDocument();
expect(screen.getByTestId('algorithm-with-text')).toBeInTheDocument();
expect(screen.getByTestId('seasonality-select')).toBeInTheDocument();
expect(screen.getByTestId('seasonality-text')).toBeInTheDocument();
});
});

View File

@@ -1,393 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import ThresholdItem from '../ThresholdItem';
import { ThresholdItemProps } from '../types';
const TEST_CONSTANTS = {
THRESHOLD_ID: 'test-threshold-1',
CRITICAL_LABEL: 'CRITICAL',
WARNING_LABEL: 'WARNING',
INFO_LABEL: 'INFO',
CHANNEL_1: 'channel-1',
CHANNEL_2: 'channel-2',
CHANNEL_3: 'channel-3',
EMAIL_CHANNEL_NAME: 'Email Channel',
ENTER_THRESHOLD_NAME: 'Enter threshold name',
ENTER_THRESHOLD_VALUE: 'Enter threshold value',
ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value',
} as const;
const mockThreshold = {
id: TEST_CONSTANTS.THRESHOLD_ID,
label: TEST_CONSTANTS.CRITICAL_LABEL,
thresholdValue: 100,
recoveryThresholdValue: 80,
unit: 'bytes',
channels: [TEST_CONSTANTS.CHANNEL_1],
color: '#ff0000',
};
const mockChannels: Channels[] = [
{
id: TEST_CONSTANTS.CHANNEL_1,
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,
} as any,
{ id: TEST_CONSTANTS.CHANNEL_2, name: 'Slack Channel' } as any,
{ id: TEST_CONSTANTS.CHANNEL_3, name: 'PagerDuty Channel' } as any,
];
const mockUnits: DefaultOptionType[] = [
{ label: 'Bytes', value: 'bytes' },
{ label: 'KB', value: 'kb' },
{ label: 'MB', value: 'mb' },
];
const defaultProps: ThresholdItemProps = {
threshold: mockThreshold,
updateThreshold: jest.fn(),
removeThreshold: jest.fn(),
showRemoveButton: false,
channels: mockChannels,
isLoadingChannels: false,
units: mockUnits,
};
const renderThresholdItem = (
props: Partial<ThresholdItemProps> = {},
): ReturnType<typeof render> => {
const mergedProps = { ...defaultProps, ...props };
return render(<ThresholdItem {...mergedProps} />);
};
const verifySelectorWidth = (
selectorIndex: number,
expectedWidth: string,
): void => {
const selectors = screen.getAllByRole('combobox');
const selector = selectors[selectorIndex];
expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`);
};
const showRecoveryThreshold = (): void => {
const recoveryButton = screen.getByRole('button', { name: '' });
fireEvent.click(recoveryButton);
};
const verifyComponentRendersWithLoading = (): void => {
expect(
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_THRESHOLD_NAME),
).toBeInTheDocument();
};
const verifyUnitSelectorDisabled = (): void => {
const unitSelectors = screen.getAllByRole('combobox');
const unitSelector = unitSelectors[0]; // First combobox is the unit selector
expect(unitSelector).toBeDisabled();
};
describe('ThresholdItem', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders threshold indicator with correct color', () => {
renderThresholdItem();
// Find the threshold dot by its class
const thresholdDot = document.querySelector('.threshold-dot');
expect(thresholdDot).toHaveStyle('background-color: #ff0000');
});
it('renders threshold label input with correct value', () => {
renderThresholdItem();
const labelInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_NAME,
);
expect(labelInput).toHaveValue(TEST_CONSTANTS.CRITICAL_LABEL);
});
it('renders threshold value input with correct value', () => {
renderThresholdItem();
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(valueInput).toHaveValue('100');
});
it('renders unit selector with correct value', () => {
renderThresholdItem();
// Check for the unit selector by looking for the displayed text
expect(screen.getByText('Bytes')).toBeInTheDocument();
});
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),
).toBeInTheDocument();
});
it('updates threshold label when label input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
const labelInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_NAME,
);
fireEvent.change(labelInput, {
target: { value: TEST_CONSTANTS.WARNING_LABEL },
});
expect(updateThreshold).toHaveBeenCalledWith(
TEST_CONSTANTS.THRESHOLD_ID,
'label',
TEST_CONSTANTS.WARNING_LABEL,
);
});
it('updates threshold value when value input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
fireEvent.change(valueInput, { target: { value: '200' } });
expect(updateThreshold).toHaveBeenCalledWith(
TEST_CONSTANTS.THRESHOLD_ID,
'thresholdValue',
'200',
);
});
it('updates threshold unit when unit selector changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
// Find the unit selector by its role and simulate change
const unitSelectors = screen.getAllByRole('combobox');
const unitSelector = unitSelectors[0]; // First combobox is the unit selector
// Simulate clicking to open the dropdown and selecting a value
fireEvent.click(unitSelector);
// The actual change event might not work the same way with Ant Design Select
// So we'll just verify the selector is present and can be interacted with
expect(unitSelector).toBeInTheDocument();
});
it('updates threshold channels when channels selector changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
// Find the channels selector by its role and simulate change
const channelSelectors = screen.getAllByRole('combobox');
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
// Simulate clicking to open the dropdown
fireEvent.click(channelSelector);
// The actual change event might not work the same way with Ant Design Select
// So we'll just verify the selector is present and can be interacted with
expect(channelSelector).toBeInTheDocument();
});
it('shows remove button when showRemoveButton is true', () => {
renderThresholdItem({ showRemoveButton: true });
// The remove button is the second button (with circle-x icon)
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2); // Recovery button + remove button
});
it('does not show remove button when showRemoveButton is false', () => {
renderThresholdItem({ showRemoveButton: false });
// Only the recovery button should be present
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1); // Only recovery button
});
it('calls removeThreshold when remove button is clicked', () => {
const removeThreshold = jest.fn();
renderThresholdItem({ showRemoveButton: true, removeThreshold });
// The remove button is the second button (with circle-x icon)
const buttons = screen.getAllByRole('button');
const removeButton = buttons[1]; // Second button is the remove button
fireEvent.click(removeButton);
expect(removeThreshold).toHaveBeenCalledWith(TEST_CONSTANTS.THRESHOLD_ID);
});
it('shows recovery threshold button when recovery threshold is enabled', () => {
renderThresholdItem();
// The recovery button is the first button (with chart-line icon)
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1); // Recovery button
});
it('shows recovery threshold inputs when recovery button is clicked', () => {
renderThresholdItem();
// The recovery button is the first button (with chart-line icon)
const buttons = screen.getAllByRole('button');
const recoveryButton = buttons[0]; // First button is the recovery button
fireEvent.click(recoveryButton);
expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE),
).toBeInTheDocument();
});
it('updates recovery threshold value when input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
// Show recovery threshold first
const buttons = screen.getAllByRole('button');
const recoveryButton = buttons[0]; // First button is the recovery button
fireEvent.click(recoveryButton);
const recoveryValueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
);
fireEvent.change(recoveryValueInput, { target: { value: '90' } });
expect(updateThreshold).toHaveBeenCalledWith(
TEST_CONSTANTS.THRESHOLD_ID,
'recoveryThresholdValue',
'90',
);
});
it('disables unit selector when no units are available', () => {
renderThresholdItem({ units: [] });
verifyUnitSelectorDisabled();
});
it('shows tooltip when no units are available', () => {
renderThresholdItem({ units: [] });
// The tooltip should be present when hovering over disabled unit selector
verifyUnitSelectorDisabled();
});
it('renders channels as multiple select options', () => {
renderThresholdItem();
// Check that channels are rendered as multiple select
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
).toBeInTheDocument();
// Should be able to select multiple channels
const channelSelectors = screen.getAllByRole('combobox');
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
fireEvent.change(channelSelector, {
target: { value: [TEST_CONSTANTS.CHANNEL_1, TEST_CONSTANTS.CHANNEL_2] },
});
});
it('handles empty threshold values correctly', () => {
const emptyThreshold = {
...mockThreshold,
label: '',
thresholdValue: 0,
unit: '',
channels: [],
};
renderThresholdItem({ threshold: emptyThreshold });
expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue('');
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0');
});
it('renders with correct input widths', () => {
renderThresholdItem();
const labelInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_NAME,
);
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(labelInput).toHaveStyle('width: 260px');
expect(valueInput).toHaveStyle('width: 210px');
});
it('renders channels selector with correct width', () => {
renderThresholdItem();
verifySelectorWidth(1, '260px');
});
it('renders unit selector with correct width', () => {
renderThresholdItem();
verifySelectorWidth(0, '150px');
});
it('handles loading channels state', () => {
renderThresholdItem({ isLoadingChannels: true });
verifyComponentRendersWithLoading();
});
it('renders recovery threshold with correct initial value', () => {
renderThresholdItem();
showRecoveryThreshold();
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();
});
it('handles threshold without channels', () => {
const thresholdWithoutChannels = {
...mockThreshold,
channels: [],
};
renderThresholdItem({ threshold: thresholdWithoutChannels });
// Should render channels selector without selected values
const channelSelectors = screen.getAllByRole('combobox');
expect(channelSelectors).toHaveLength(2); // Should have both unit and channel selectors
});
});

View File

@@ -1,5 +0,0 @@
export const THRESHOLD_TAB_TOOLTIP =
'An alert is triggered when the metric crosses a threshold.';
export const ANOMALY_TAB_TOOLTIP =
'An alert is triggered whenever the metric deviates from an expected pattern.';

View File

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

View File

@@ -1,320 +0,0 @@
.alert-condition-container {
margin: 0 16px;
margin-top: 24px;
.alert-condition {
display: flex;
align-items: center;
margin-left: 12px;
margin-top: 24px;
.alert-condition-tabs {
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);
}
}
}
}
}
.alert-threshold-container,
.anomaly-threshold-container {
padding: 24px;
padding-right: 72px;
background-color: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
width: fit-content;
.alert-condition-sentences {
display: flex;
flex-direction: column;
gap: 12px;
.alert-condition-sentence {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
.sentence-text {
color: var(--text-vanilla-400);
font-size: 14px;
line-height: 1.5;
display: flex;
align-items: center;
gap: 8px;
}
.ant-select {
width: 240px !important;
.ant-select-selector {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--text-vanilla-400);
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-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
}
.thresholds-section {
margin-top: 16px;
margin-left: 24px;
.threshold-item {
display: flex;
flex-direction: column;
gap: 0;
margin-bottom: 16px;
.threshold-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 2px;
.threshold-indicator {
.threshold-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
}
.threshold-controls {
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);
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);
}
}
.icon-btn {
color: var(--bg-vanilla-400);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.recovery-threshold-input-group {
display: flex;
align-items: center;
gap: 0;
margin-left: 28px;
.recovery-threshold-label {
pointer-events: none;
cursor: default;
}
.recovery-threshold-btn {
pointer-events: none;
cursor: default;
color: var(--bg-vanilla-400);
background-color: var(--bg-ink-400) !important;
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.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);
}
}
}
}
.add-threshold-btn {
margin-top: 8px;
border: 1px dashed var(--bg-slate-400);
color: var(--bg-vanilla-300);
background-color: transparent;
border-radius: 4px;
height: 32px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: var(--bg-vanilla-300);
color: var(--bg-vanilla-100);
}
.anticon {
margin-right: 8px;
}
}
}
}
.condensed-alert-threshold-container,
.condensed-anomaly-threshold-container {
width: 100%;
}
.condensed-advanced-options-container {
margin-top: 16px;
width: fit-parent;
}
.condensed-evaluation-settings-container {
.ant-btn {
display: flex;
align-items: center;
width: 240px;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
}
.evaluate-alert-conditions-button-right {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
font-weight: 500;
background-color: var(--bg-slate-400);
padding: 1px 4px;
}
}
}
}

View File

@@ -1,23 +0,0 @@
import { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import { Threshold } from '../context/types';
export type UpdateThreshold = {
(thresholdId: string, field: 'channels', value: string[]): void;
(
thresholdId: string,
field: Exclude<keyof Threshold, 'channels'>,
value: string,
): void;
};
export interface ThresholdItemProps {
threshold: Threshold;
updateThreshold: UpdateThreshold;
removeThreshold: (thresholdId: string) => void;
showRemoveButton: boolean;
channels: Channels[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
}

View File

@@ -1,46 +0,0 @@
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 { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator(
currentQuery.builder.queryTraceOperator,
);
const queryConfig: Record<EQueryType, () => SelectProps['options']> = {
[EQueryType.QUERY_BUILDER]: () => [
...(getSelectedQueryOptions(currentQuery.builder.queryData)?.filter(
(option) =>
!involvedQueriesInTraceOperator.includes(option.value as string),
) || []),
...(getSelectedQueryOptions(currentQuery.builder.queryFormulas) || []),
...(getSelectedQueryOptions(currentQuery.builder.queryTraceOperator) || []),
],
[EQueryType.PROM]: () => getSelectedQueryOptions(currentQuery.promql),
[EQueryType.CLICKHOUSE]: () =>
getSelectedQueryOptions(currentQuery.clickhouse_sql),
};
return queryConfig[currentQuery.queryType]?.() || [];
}
export function getCategoryByOptionId(id: string): string | undefined {
return Y_AXIS_CATEGORIES.find((category) =>
category.units.some((unit) => unit.id === id),
)?.name;
}
export function getCategorySelectOptionByName(
name: string,
): DefaultOptionType[] {
return (
Y_AXIS_CATEGORIES.find((category) => category.name === name)?.units.map(
(unit) => ({
label: unit.name,
value: unit.id,
}),
) || []
);
}

View File

@@ -1,73 +0,0 @@
import './styles.scss';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCallback, useMemo } from 'react';
import { Labels } from 'types/api/alerts/def';
import { useCreateAlertState } from '../context';
import LabelsInput from './LabelsInput';
function CreateAlertHeader(): JSX.Element {
const { alertState, setAlertState } = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
currentQuery.builder.queryData.forEach((query) => {
query.groupBy.forEach((groupBy) => {
labels.push(groupBy.key);
});
});
return labels;
}, [currentQuery]);
// If the label key is a group by label, then it is not allowed to be used as a label key
const validateLabelsKey = useCallback(
(key: string): string | null => {
if (groupByLabels.includes(key)) {
return `Cannot use ${key} as a key`;
}
return null;
},
[groupByLabels],
);
return (
<div className="alert-header">
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
</div>
<div className="alert-header__content">
<input
type="text"
value={alertState.name}
onChange={(e): void =>
setAlertState({ type: 'SET_ALERT_NAME', payload: e.target.value })
}
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 =>
setAlertState({ type: 'SET_ALERT_LABELS', payload: labels })
}
validateLabelsKey={validateLabelsKey}
/>
</div>
</div>
);
}
export default CreateAlertHeader;

View File

@@ -1,168 +0,0 @@
import { CloseOutlined } from '@ant-design/icons';
import { useNotifications } from 'hooks/useNotifications';
import React, { useCallback, useState } from 'react';
import { LabelInputState, LabelsInputProps } from './types';
function LabelsInput({
labels,
onLabelsChange,
validateLabelsKey,
}: LabelsInputProps): JSX.Element {
const { notifications } = useNotifications();
const [inputState, setInputState] = useState<LabelInputState>({
key: '',
value: '',
isKeyInput: true,
});
const [isAdding, setIsAdding] = useState(false);
const handleAddLabelsClick = useCallback(() => {
setIsAdding(true);
setInputState({ key: '', value: '', isKeyInput: true });
}, []);
const handleKeyDown = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (inputState.isKeyInput) {
// Check if input contains a colon (key:value format)
if (inputState.key.includes(':')) {
const [key, ...valueParts] = inputState.key.split(':');
const value = valueParts.join(':'); // Rejoin in case value contains colons
if (key.trim() && value.trim()) {
if (labels[key.trim()]) {
notifications.error({
message: 'Label with this key already exists',
});
return;
}
const error = validateLabelsKey(key.trim());
if (error) {
notifications.error({
message: error,
});
return;
}
// Add the label immediately
const newLabels = {
...labels,
[key.trim()]: value.trim(),
};
onLabelsChange(newLabels);
// Reset input state
setInputState({ key: '', value: '', isKeyInput: true });
}
} else if (inputState.key.trim()) {
if (labels[inputState.key.trim()]) {
notifications.error({
message: 'Label with this key already exists',
});
return;
}
const error = validateLabelsKey(inputState.key.trim());
if (error) {
notifications.error({
message: error,
});
return;
}
setInputState((prev) => ({ ...prev, isKeyInput: false }));
}
} else if (inputState.value.trim()) {
// Add the label
const newLabels = {
...labels,
[inputState.key.trim()]: inputState.value.trim(),
};
onLabelsChange(newLabels);
// Reset and continue adding
setInputState({ key: '', value: '', isKeyInput: true });
}
} else if (e.key === 'Escape') {
// Cancel adding
setIsAdding(false);
setInputState({ key: '', value: '', isKeyInput: true });
}
},
[inputState, labels, notifications, onLabelsChange, validateLabelsKey],
);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (inputState.isKeyInput) {
setInputState((prev) => ({ ...prev, key: e.target.value }));
} else {
setInputState((prev) => ({ ...prev, value: e.target.value }));
}
},
[inputState.isKeyInput],
);
const handleRemoveLabel = useCallback(
(key: string) => {
const newLabels = { ...labels };
delete newLabels[key];
onLabelsChange(newLabels);
},
[labels, onLabelsChange],
);
const handleBlur = useCallback(() => {
if (!inputState.key && !inputState.value) {
setIsAdding(false);
setInputState({ key: '', value: '', isKeyInput: true });
}
}, [inputState]);
return (
<div className="labels-input">
{Object.keys(labels).length > 0 && (
<div className="labels-input__existing-labels">
{Object.entries(labels).map(([key, value]) => (
<span key={key} className="labels-input__label-pill">
{key}: {value}
<button
type="button"
className="labels-input__remove-button"
onClick={(): void => handleRemoveLabel(key)}
>
<CloseOutlined />
</button>
</span>
))}
</div>
)}
{!isAdding ? (
<button
className="labels-input__add-button"
type="button"
onClick={handleAddLabelsClick}
>
+ Add labels
</button>
) : (
<div className="labels-input__input-container">
<input
type="text"
value={inputState.isKeyInput ? inputState.key : inputState.value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className="labels-input__input"
placeholder={inputState.isKeyInput ? 'Enter key' : 'Enter value'}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</div>
)}
</div>
);
}
export default LabelsInput;

View File

@@ -1,77 +0,0 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { search: string } => ({
search: '',
}),
}));
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
render(
<CreateAlertProvider>
<CreateAlertHeader />
</CreateAlertProvider>,
);
describe('CreateAlertHeader', () => {
it('renders the header with title', () => {
renderCreateAlertHeader();
expect(screen.getByText('New Alert Rule')).toBeInTheDocument();
});
it('renders name input with placeholder', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
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();
});
it('updates name when typing in name input', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
fireEvent.change(nameInput, { target: { value: 'Test Alert' } });
expect(nameInput).toHaveValue('Test Alert');
});
it('updates description when typing in description input', () => {
renderCreateAlertHeader();
const descriptionInput = screen.getByPlaceholderText(
'Click to add description...',
);
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
expect(descriptionInput).toHaveValue('Test Description');
});
});

View File

@@ -1,510 +0,0 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import LabelsInput from '../LabelsInput';
import { LabelsInputProps } from '../types';
// Mock the CloseOutlined icon
jest.mock('@ant-design/icons', () => ({
CloseOutlined: (): JSX.Element => <span data-testid="close-icon">×</span>,
}));
const mockOnLabelsChange = jest.fn();
const mockValidateLabelsKey = jest.fn().mockReturnValue(null);
const defaultProps: LabelsInputProps = {
labels: {},
onLabelsChange: mockOnLabelsChange,
validateLabelsKey: mockValidateLabelsKey,
};
const ADD_LABELS_TEXT = '+ Add labels';
const ENTER_KEY_PLACEHOLDER = 'Enter key';
const ENTER_VALUE_PLACEHOLDER = 'Enter value';
const CLOSE_ICON_TEST_ID = 'close-icon';
const SEVERITY_HIGH_TEXT = 'severity: high';
const ENVIRONMENT_PRODUCTION_TEXT = 'environment: production';
const SEVERITY_HIGH_KEY_VALUE = 'severity:high';
const renderLabelsInput = (
props: Partial<LabelsInputProps> = {},
): ReturnType<typeof render> =>
render(<LabelsInput {...defaultProps} {...props} />);
describe('LabelsInput', () => {
beforeEach(() => {
jest.clearAllMocks();
mockValidateLabelsKey.mockReturnValue(null); // Reset validation to always pass
});
describe('Initial Rendering', () => {
it('renders add button when no labels exist', () => {
renderLabelsInput();
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
expect(screen.queryByTestId(CLOSE_ICON_TEST_ID)).not.toBeInTheDocument();
});
it('renders existing labels when provided', () => {
const labels = { severity: 'high', environment: 'production' };
renderLabelsInput({ labels });
expect(screen.getByText(SEVERITY_HIGH_TEXT)).toBeInTheDocument();
expect(screen.getByText(ENVIRONMENT_PRODUCTION_TEXT)).toBeInTheDocument();
expect(screen.getAllByTestId(CLOSE_ICON_TEST_ID)).toHaveLength(2);
});
it('does not render existing labels section when no labels', () => {
renderLabelsInput();
expect(screen.queryByText(SEVERITY_HIGH_TEXT)).not.toBeInTheDocument();
});
});
describe('Adding Labels', () => {
it('shows input field when add button is clicked', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(screen.queryByText(ADD_LABELS_TEXT)).not.toBeInTheDocument();
});
it('switches from key input to value input on Enter', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(
screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('adds label when both key and value are provided', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Enter value
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: 'high' } });
fireEvent.keyDown(valueInput, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('does not switch to value input if key is empty', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.keyDown(input, { key: 'Enter' });
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('does not add label if value is empty', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Try to add with empty value
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.keyDown(valueInput, { key: 'Enter' });
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('trims whitespace from key and value', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key with whitespace
fireEvent.change(input, { target: { value: ' severity ' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Enter value with whitespace
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: ' high ' } });
fireEvent.keyDown(valueInput, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('resets input state after adding label', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Add a label
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: 'high' } });
fireEvent.keyDown(valueInput, { key: 'Enter' });
// Should be back to key input
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
});
describe('Removing Labels', () => {
it('removes label when close button is clicked', () => {
const labels = { severity: 'high', environment: 'production' };
renderLabelsInput({ labels });
const removeButtons = screen.getAllByTestId(CLOSE_ICON_TEST_ID);
fireEvent.click(removeButtons[0]);
expect(mockOnLabelsChange).toHaveBeenCalledWith({
environment: 'production',
});
});
it('calls onLabelsChange with empty object when last label is removed', () => {
const labels = { severity: 'high' };
renderLabelsInput({ labels });
const removeButton = screen.getByTestId('close-icon');
fireEvent.click(removeButton);
expect(mockOnLabelsChange).toHaveBeenCalledWith({});
});
});
describe('Keyboard Interactions', () => {
it('cancels adding label on Escape key', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.keyDown(input, { key: 'Escape' });
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('cancels adding label on Escape key in value input', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Cancel in value input
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.keyDown(valueInput, { key: 'Escape' });
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
});
describe('Blur Behavior', () => {
it('closes input immediately when both key and value are empty', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.blur(input);
// The input should close immediately when both key and value are empty
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('does not close input immediately when key has value', () => {
jest.useFakeTimers();
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.blur(input);
jest.advanceTimersByTime(200);
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(screen.queryByText(ADD_LABELS_TEXT)).not.toBeInTheDocument();
jest.useRealTimers();
});
});
describe('Input Change Handling', () => {
it('updates key input value correctly', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(input, { target: { value: 'severity' } });
expect(input).toHaveValue('severity');
});
it('updates value input correctly', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Update value
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: 'high' } });
expect(valueInput).toHaveValue('high');
});
});
describe('Edge Cases', () => {
it('handles multiple labels correctly', () => {
const labels = {
severity: 'high',
environment: 'production',
service: 'api-gateway',
};
renderLabelsInput({ labels });
expect(screen.getByText(SEVERITY_HIGH_TEXT)).toBeInTheDocument();
expect(screen.getByText(ENVIRONMENT_PRODUCTION_TEXT)).toBeInTheDocument();
expect(screen.getByText('service: api-gateway')).toBeInTheDocument();
expect(screen.getAllByTestId(CLOSE_ICON_TEST_ID)).toHaveLength(3);
});
it('handles empty string values', () => {
const labels = { severity: '' };
renderLabelsInput({ labels });
expect(screen.getByText(/severity/)).toBeInTheDocument();
});
it('handles special characters in labels', () => {
const labels = { 'service-name': 'api-gateway-v1' };
renderLabelsInput({ labels });
expect(screen.getByText('service-name: api-gateway-v1')).toBeInTheDocument();
});
it('maintains focus on input after adding label', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Add a label
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
fireEvent.change(valueInput, { target: { value: 'high' } });
fireEvent.keyDown(valueInput, { key: 'Enter' });
// Should be focused on new key input
const newInput = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
expect(newInput).toHaveFocus();
});
});
describe('Key:Value Format Support', () => {
it('adds label when key:value format is entered and Enter is pressed', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('trims whitespace from key and value in key:value format', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format with whitespace
fireEvent.change(input, { target: { value: ' severity : high ' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('handles values with colons correctly', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format where value contains colons
fireEvent.change(input, {
target: { value: 'url:https://example.com:8080' },
});
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).toHaveBeenCalledWith({
url: 'https://example.com:8080',
});
});
it('does not add label if key is empty in key:value format', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format with empty key
fireEvent.change(input, { target: { value: ':high' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('does not add label if value is empty in key:value format', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter key:value format with empty value
fireEvent.change(input, { target: { value: 'severity:' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('does not add label if only colon is entered', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Enter only colon
fireEvent.change(input, { target: { value: ':' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('resets input state after adding label with key:value format', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Add label with key:value format
fireEvent.change(input, { target: { value: 'severity:high' } });
fireEvent.keyDown(input, { key: 'Enter' });
// Should be back to key input for next label
expect(
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('does not auto-save when typing key:value without pressing Enter', () => {
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Type key:value format but don't press Enter
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
// Should not have called onLabelsChange yet
expect(mockOnLabelsChange).not.toHaveBeenCalled();
});
it('handles multiple key:value entries correctly', () => {
const { rerender } = renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
// Add first label
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
fireEvent.keyDown(input, { key: 'Enter' });
// Simulate parent component updating labels
const firstLabels = { severity: 'high' };
rerender(
<LabelsInput
labels={firstLabels}
onLabelsChange={mockOnLabelsChange}
validateLabelsKey={mockValidateLabelsKey}
/>,
);
// Add second label
const newInput = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(newInput, { target: { value: 'environment:production' } });
fireEvent.keyDown(newInput, { key: 'Enter' });
// Check that we made two calls and the last one includes both labels
expect(mockOnLabelsChange).toHaveBeenCalledTimes(2);
expect(mockOnLabelsChange).toHaveBeenNthCalledWith(1, { severity: 'high' });
expect(mockOnLabelsChange).toHaveBeenNthCalledWith(2, {
severity: 'high',
environment: 'production',
});
});
});
});

View File

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

View File

@@ -1,151 +0,0 @@
.alert-header {
background-color: var(--bg-ink-500);
font-family: inherit;
color: var(--text-vanilla-100);
/* Top bar with diagonal stripes */
&__tab-bar {
height: 32px;
display: flex;
align-items: center;
background: repeating-linear-gradient(
-45deg,
#0f0f0f,
#0f0f0f 10px,
#101010 10px,
#101010 20px
);
padding-left: 0;
}
/* Tab block visuals */
&__tab {
display: flex;
align-items: center;
background-color: var(--bg-ink-500);
padding: 0 12px;
height: 32px;
font-size: 13px;
color: var(--text-vanilla-100);
margin-left: 12px;
margin-top: 12px;
}
&__tab::before {
content: '';
margin-right: 6px;
font-size: 14px;
color: var(--bg-slate-100);
}
&__content {
padding: 16px;
background: var(--bg-ink-500);
display: flex;
flex-direction: column;
gap: 8px;
}
&__input.title {
font-size: 18px;
font-weight: 500;
background-color: transparent;
color: var(--text-vanilla-100);
}
&__input:focus,
&__input:active {
border: none;
outline: none;
}
&__input.description {
font-size: 14px;
background-color: transparent;
color: var(--text-vanilla-300);
}
}
.labels-input {
display: flex;
flex-direction: column;
gap: 8px;
&__add-button {
width: fit-content;
font-size: 13px;
color: #ccc;
border: 1px solid #333;
background-color: transparent;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
&:hover {
border-color: #555;
color: #fff;
}
}
&__existing-labels {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__label-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background-color: #ad7f581a;
color: var(--bg-sienna-400);
padding: 4px 8px;
border-radius: 16px;
font-size: 12px;
border: 1px solid var(--bg-sienna-500);
font-family: 'Geist Mono';
}
&__remove-button {
background: none;
border: none;
color: var(--bg-sienna-400);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
&:hover {
color: var(--text-vanilla-100);
}
}
&__input-container {
display: flex;
align-items: center;
background-color: transparent;
border: none;
}
&__input {
flex: 1;
background-color: transparent;
border: none;
outline: none;
padding: 6px 8px;
color: #fff;
font-size: 13px;
&::placeholder {
color: #888;
}
&:focus,
&:active {
border: none;
outline: none;
}
}
}

View File

@@ -1,13 +0,0 @@
import { Labels } from 'types/api/alerts/def';
export interface LabelsInputProps {
labels: Labels;
onLabelsChange: (labels: Labels) => void;
validateLabelsKey: (key: string) => string | null;
}
export interface LabelInputState {
key: string;
value: string;
isKeyInput: boolean;
}

View File

@@ -1,18 +0,0 @@
$top-nav-background-1: #0f0f0f;
$top-nav-background-2: #101010;
.create-alert-v2-container {
background-color: var(--bg-ink-500);
padding-bottom: 50px;
}
.top-nav-container {
background: repeating-linear-gradient(
-45deg,
$top-nav-background-1,
$top-nav-background-1 10px,
$top-nav-background-2 10px,
$top-nav-background-2 20px
);
margin-bottom: 0;
}

View File

@@ -1,41 +0,0 @@
import './CreateAlertV2.styles.scss';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import AlertCondition from './AlertCondition';
import { CreateAlertProvider } from './context';
import CreateAlertHeader from './CreateAlertHeader';
import EvaluationSettings from './EvaluationSettings';
import NotificationSettings from './NotificationSettings';
import QuerySection from './QuerySection';
import { showCondensedLayout } from './utils';
function CreateAlertV2({
initialQuery = initialQueriesMap.metrics,
}: {
initialQuery?: Query;
}): JSX.Element {
useShareBuilderUrl({ defaultValue: initialQuery });
const showCondensedLayoutFlag = showCondensedLayout();
return (
<CreateAlertProvider>
<div className="create-alert-v2-container">
<CreateAlertHeader />
<QuerySection />
<AlertCondition />
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
<NotificationSettings />
</div>
</CreateAlertProvider>
);
}
CreateAlertV2.defaultProps = {
initialQuery: initialQueriesMap.metrics,
};
export default CreateAlertV2;

View File

@@ -1,35 +0,0 @@
import { Switch, Typography } from 'antd';
import { useState } from 'react';
import { IAdvancedOptionItemProps } from './types';
function AdvancedOptionItem({
title,
description,
input,
}: IAdvancedOptionItemProps): JSX.Element {
const [showInput, setShowInput] = useState<boolean>(false);
const onToggle = (): void => {
setShowInput((currentShowInput) => !currentShowInput);
};
return (
<div className="advanced-option-item">
<div className="advanced-option-item-left-content">
<Typography.Text className="advanced-option-item-title">
{title}
</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>
</div>
);
}
export default AdvancedOptionItem;

View File

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

View File

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

View File

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

View File

@@ -1,258 +0,0 @@
/* eslint-disable jsx-a11y/interactive-supports-focus */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Button, Select, Typography } from 'antd';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import { useMemo } from 'react';
import {
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
} from './constants';
import TimeInput from './TimeInput';
import {
CumulativeWindowTimeframes,
IEvaluationWindowDetailsProps,
IEvaluationWindowPopoverProps,
RollingWindowTimeframes,
} from './types';
import { TIMEZONE_DATA } from './utils';
function EvaluationWindowDetails({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowDetailsProps): JSX.Element {
const currentHourOptions = useMemo(() => {
const options = [];
for (let i = 0; i < 60; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const currentMonthOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 31; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
if (evaluationWindow.windowType === 'rolling') {
return <div />;
}
const isCurrentHour =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentHour';
const isCurrentDay =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentDay';
const isCurrentMonth =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentMonth';
const handleNumberChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: value,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
},
});
};
const handleTimeChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: value,
timezone: evaluationWindow.startingAt.timezone,
},
});
};
const handleTimezoneChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: value,
},
});
};
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text>
<Select
options={currentHourOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
</div>
);
}
if (isCurrentDay) {
return (
<div className="evaluation-window-details">
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
if (isCurrentMonth) {
return (
<div className="evaluation-window-details">
<div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text>
<Select
options={currentMonthOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
return <div />;
}
function EvaluationWindowPopover({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowPopoverProps): JSX.Element {
const renderEvaluationWindowContent = (
label: string,
contentOptions: Array<{ label: string; value: string }>,
currentValue: string,
onChange: (value: string) => void,
): JSX.Element => (
<div className="evaluation-window-content-item">
<Typography.Text className="evaluation-window-content-item-label">
{label}
</Typography.Text>
<div className="evaluation-window-content-list">
{contentOptions.map((option) => (
<div
className={classNames('evaluation-window-content-list-item', {
active: currentValue === option.value,
})}
key={option.value}
role="button"
onClick={(): void => onChange(option.value)}
>
<Typography.Text>{option.label}</Typography.Text>
{currentValue === option.value && <Check size={12} />}
</div>
))}
</div>
</div>
);
const renderSelectionContent = (): JSX.Element => {
if (evaluationWindow.windowType === 'rolling') {
return (
<div className="selection-content">
<Typography.Text>
A Rolling Window has a fixed size and shifts its starting point over time
based on when the rules are evaluated.
</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
if (
evaluationWindow.windowType === 'cumulative' &&
!evaluationWindow.timeframe
) {
return (
<div className="selection-content">
<Typography.Text>
A Cumulative Window has a fixed starting point and expands over time.
</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
};
return (
<div className="evaluation-window-popover">
<div className="evaluation-window-content">
{renderEvaluationWindowContent(
'EVALUATION WINDOW',
EVALUATION_WINDOW_TYPE,
evaluationWindow.windowType,
(value: string): void =>
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
}),
)}
{renderEvaluationWindowContent(
'TIMEFRAME',
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
evaluationWindow.timeframe,
(value: string): void =>
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
}),
)}
{renderSelectionContent()}
</div>
</div>
);
}
export default EvaluationWindowPopover;

View File

@@ -1,51 +0,0 @@
.time-input-container {
display: flex;
align-items: center;
gap: 0;
.time-input-field {
width: 40px;
height: 32px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
font-family: 'Space Mono', monospace;
font-size: 14px;
font-weight: 600;
text-align: center;
border-radius: 4px;
&::placeholder {
color: var(--bg-vanilla-400);
font-family: 'Space Mono', monospace;
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
outline: none;
}
&:disabled {
background-color: var(--bg-ink-300);
color: var(--bg-vanilla-400);
cursor: not-allowed;
&:hover {
border-color: var(--bg-slate-400);
}
}
}
.time-input-separator {
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 600;
margin: 0 4px;
user-select: none;
}
}

View File

@@ -1,155 +0,0 @@
import './TimeInput.scss';
import { Input } from 'antd';
import React, { useEffect, useState } from 'react';
export interface TimeInputProps {
value?: string; // Format: "HH:MM:SS"
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
function TimeInput({
value = '00:00:00',
onChange,
disabled = false,
className = '',
}: TimeInputProps): JSX.Element {
const [hours, setHours] = useState('00');
const [minutes, setMinutes] = useState('00');
const [seconds, setSeconds] = useState('00');
// Parse initial value
useEffect(() => {
if (value) {
const timeParts = value.split(':');
if (timeParts.length === 3) {
setHours(timeParts[0].padStart(2, '0'));
setMinutes(timeParts[1].padStart(2, '0'));
setSeconds(timeParts[2].padStart(2, '0'));
}
}
}, [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')}`;
// Handle input change
const handleTimeChange = (
newHours: string,
newMinutes: string,
newSeconds: string,
): void => {
const formattedValue = formatTimeValue(newHours, newMinutes, newSeconds);
onChange?.(formattedValue);
};
// Handle hours change
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newHours = e.target.value.replace(/\D/g, '').slice(0, 2);
setHours(newHours);
handleTimeChange(newHours, minutes, seconds);
};
// Handle minutes change
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newMinutes = e.target.value.replace(/\D/g, '').slice(0, 2);
setMinutes(newMinutes);
handleTimeChange(hours, newMinutes, seconds);
};
// Handle seconds change
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newSeconds = e.target.value.replace(/\D/g, '').slice(0, 2);
setSeconds(newSeconds);
handleTimeChange(hours, minutes, newSeconds);
};
// Helper functions for field navigation
const getNextField = (current: string): string => {
switch (current) {
case 'hours':
return 'minutes';
case 'minutes':
return 'seconds';
default:
return 'hours';
}
};
const getPrevField = (current: string): string => {
switch (current) {
case 'seconds':
return 'minutes';
case 'minutes':
return 'hours';
default:
return 'seconds';
}
};
// Handle key navigation
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
currentField: 'hours' | 'minutes' | 'seconds',
): void => {
if (e.key === 'ArrowRight' || e.key === 'Tab') {
e.preventDefault();
const nextField = document.querySelector(
`[data-field="${getNextField(currentField)}"]`,
) as HTMLInputElement;
nextField?.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prevField = document.querySelector(
`[data-field="${getPrevField(currentField)}"]`,
) as HTMLInputElement;
prevField?.focus();
}
};
return (
<div className={`time-input-container ${className}`}>
<Input
data-field="hours"
value={hours}
onChange={handleHoursChange}
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
disabled={disabled}
maxLength={2}
className="time-input-field"
/>
<span className="time-input-separator">:</span>
<Input
data-field="minutes"
value={minutes}
onChange={handleMinutesChange}
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
disabled={disabled}
maxLength={2}
className="time-input-field"
/>
<span className="time-input-separator">:</span>
<Input
data-field="seconds"
value={seconds}
onChange={handleSecondsChange}
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
disabled={disabled}
maxLength={2}
className="time-input-field"
/>
</div>
);
}
TimeInput.defaultProps = {
value: '00:00:00',
onChange: undefined,
disabled: false,
className: '',
};
export default TimeInput;

View File

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

View File

@@ -1,248 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AdvancedOptionItem from '../AdvancedOptionItem';
const TEST_INPUT_PLACEHOLDER = 'Test input';
const TEST_TITLE = 'Test Title';
const TEST_DESCRIPTION = 'Test Description';
const TEST_VALUE = 'test value';
const FIRST_INPUT_PLACEHOLDER = 'First input';
const TEST_INPUT_TEST_ID = 'test-input';
describe('AdvancedOptionItem', () => {
const mockInput = (
<input
data-testid={TEST_INPUT_TEST_ID}
placeholder={TEST_INPUT_PLACEHOLDER}
/>
);
const defaultProps = {
title: TEST_TITLE,
description: TEST_DESCRIPTION,
input: mockInput,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render title and description', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
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();
expect(switchElement).not.toBeChecked();
});
it('should not show input initially', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
});
it('should show input when switch is toggled on', 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);
expect(switchElement).toBeChecked();
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
});
it('should hide input when switch is toggled off', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
// 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);
});
it('should handle multiple toggle operations', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
// Toggle on
await user.click(switchElement);
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
// Toggle off
await user.click(switchElement);
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
// Toggle on again
await user.click(switchElement);
expect(screen.getByTestId(TEST_INPUT_TEST_ID)).toBeInTheDocument();
});
it('should maintain input state when toggling', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
// Toggle on and interact with input
await user.click(switchElement);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
await user.type(inputElement, TEST_VALUE);
expect(inputElement).toHaveValue(TEST_VALUE);
// Toggle off
await user.click(switchElement);
expect(screen.queryByTestId(TEST_INPUT_TEST_ID)).not.toBeInTheDocument();
// Toggle back on - input should be recreated (fresh state)
await user.click(switchElement);
const inputElementAgain = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElementAgain).toHaveValue(''); // Fresh input, no previous state
});
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>
);
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={complexInput}
/>,
);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
expect(screen.getByTestId('complex-input')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(FIRST_INPUT_PLACEHOLDER),
).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});

View File

@@ -1,193 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { CreateAlertProvider } from '../../context';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from '../../context/constants';
import AdvancedOptions from '../AdvancedOptions';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock dayjs timezone
jest.mock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = jest.fn((date) => originalDayjs(date));
Object.assign(mockDayjs, originalDayjs);
((mockDayjs as unknown) as { tz: { guess: jest.Mock } }).tz = {
guess: jest.fn(() => 'UTC'),
};
return mockDayjs;
});
// Mock Y_AXIS_CATEGORIES
jest.mock('components/YAxisUnitSelector/constants', () => ({
Y_AXIS_CATEGORIES: [
{
name: 'Time',
units: [
{ name: 'Second', id: 's' },
{ name: 'Minute', id: 'm' },
{ name: 'Hour', id: 'h' },
{ name: 'Day', id: 'd' },
],
},
],
}));
// Mock the context
const mockSetAdvancedOptions = jest.fn();
jest.mock('../../context', () => ({
...jest.requireActual('../../context'),
useCreateAlertState: (): {
advancedOptions: typeof INITIAL_ADVANCED_OPTIONS_STATE;
setAdvancedOptions: jest.Mock;
evaluationWindow: typeof INITIAL_EVALUATION_WINDOW_STATE;
setEvaluationWindow: jest.Mock;
} => ({
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
setAdvancedOptions: mockSetAdvancedOptions,
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
setEvaluationWindow: jest.fn(),
}),
}));
// Mock EvaluationCadence component
jest.mock('../EvaluationCadence', () => ({
__esModule: true,
default: function MockEvaluationCadence(): JSX.Element {
return (
<div data-testid="evaluation-cadence">Evaluation Cadence Component</div>
);
},
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const TOLERANCE_LIMIT_PLACEHOLDER = 'Enter tolerance limit...';
const renderAdvancedOptions = (): void => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<CreateAlertProvider>
<AdvancedOptions />
</CreateAlertProvider>
</MemoryRouter>
</Provider>
</QueryClientProvider>,
);
};
describe('AdvancedOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const expandAdvancedOptions = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
const collapseHeader = screen.getByRole('button');
await user.click(collapseHeader);
await waitFor(() => {
expect(screen.getByTestId('evaluation-cadence')).toBeInTheDocument();
});
};
it('should render and allow expansion of advanced options', async () => {
const user = userEvent.setup();
renderAdvancedOptions();
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
await expandAdvancedOptions(user);
expect(
screen.getByText('Send a notification if data is missing'),
).toBeInTheDocument();
expect(screen.getByText('Enforce minimum datapoints')).toBeInTheDocument();
expect(screen.getByText('Delay evaluation')).toBeInTheDocument();
});
it('should enable advanced option inputs when switches are toggled', async () => {
const user = userEvent.setup();
renderAdvancedOptions();
await expandAdvancedOptions(user);
const switches = screen.getAllByRole('switch');
// Toggle the first switch (send notification)
await user.click(switches[0]);
await waitFor(() => {
expect(
screen.getByPlaceholderText(TOLERANCE_LIMIT_PLACEHOLDER),
).toBeInTheDocument();
});
// Toggle the second switch (minimum datapoints)
await user.click(switches[1]);
await waitFor(() => {
expect(
screen.getByPlaceholderText('Enter minimum datapoints...'),
).toBeInTheDocument();
});
});
it('should update advanced options state when user interacts with inputs', async () => {
const user = userEvent.setup();
renderAdvancedOptions();
await expandAdvancedOptions(user);
// Enable send notification option
const switches = screen.getAllByRole('switch');
await user.click(switches[0]);
// Wait for tolerance input to appear and test interaction
const toleranceInput = await screen.findByPlaceholderText(
TOLERANCE_LIMIT_PLACEHOLDER,
);
await user.clear(toleranceInput);
await user.type(toleranceInput, '10');
const timeUnitSelect = screen.getByRole('combobox');
await user.click(timeUnitSelect);
await waitFor(() => {
expect(screen.getByText('Minute')).toBeInTheDocument();
});
await user.click(screen.getByText('Minute'));
// Verify that the state update function was called (testing behavior, not exact values)
expect(mockSetAdvancedOptions).toHaveBeenCalled();
// Verify the function was called with the expected action types
const { calls } = mockSetAdvancedOptions.mock;
const actionTypes = calls.map((call) => call[0].type);
expect(actionTypes).toContain('SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING');
});
});

View File

@@ -1,210 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import * as context from '../../context';
import EvaluationCadence, {
EvaluationCadenceDetails,
} from '../EvaluationCadence';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
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 ADD_CUSTOM_SCHEDULE_TEXT = 'Add custom schedule';
const SAVE_CUSTOM_SCHEDULE_TEXT = 'Save Custom Schedule';
const DISCARD_TEXT = 'Discard';
describe('EvaluationCadence', () => {
it('should render evaluation cadence component in default mode', () => {
render(<EvaluationCadence />);
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
expect(
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
).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);
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();
});
it('should render evaluation cadence component in rrule mode', () => {
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'rrule',
},
},
} as any);
render(<EvaluationCadence />);
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
expect(
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
).toBeInTheDocument();
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
});
it('clicking on discard button should reset the evaluation cadence mode to default', async () => {
const user = userEvent.setup();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
},
setAdvancedOptions: mockSetAdvancedOptions,
} as any);
render(<EvaluationCadence />);
expect(screen.getByText(EVALUATION_CADENCE_TEXT)).toBeInTheDocument();
expect(
screen.getByText(EVALUATION_CADENCE_DESCRIPTION_TEXT),
).toBeInTheDocument();
expect(screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(PREVIEW_TEXT)).toBeInTheDocument();
const discardButton = screen.getByTestId('discard-button');
await user.click(discardButton);
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'default',
});
});
it('clicking on preview button should open the preview modal', async () => {
const user = userEvent.setup();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
},
setAdvancedOptions: mockSetAdvancedOptions,
} as any);
render(<EvaluationCadence />);
expect(screen.queryByText(SAVE_CUSTOM_SCHEDULE_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(DISCARD_TEXT)).not.toBeInTheDocument();
const previewButton = screen.getByText(PREVIEW_TEXT);
await user.click(previewButton);
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
});
it('clicking on edit custom schedule button should open the edit custom schedule modal', async () => {
const user = userEvent.setup();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
},
setAdvancedOptions: mockSetAdvancedOptions,
} as any);
render(<EvaluationCadence />);
expect(screen.queryByText(SAVE_CUSTOM_SCHEDULE_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(DISCARD_TEXT)).not.toBeInTheDocument();
const editCustomScheduleButton = screen.getByText(EDIT_CUSTOM_SCHEDULE_TEXT);
await user.click(editCustomScheduleButton);
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
const mockSetIsOpen = jest.fn();
const RULE_VIEW_TEXT = 'RRule';
const EDITOR_VIEW_TEST_ID = 'editor-view';
const RULE_VIEW_TEST_ID = 'rrule-view';
describe('EvaluationCadenceDetails', () => {
it('should render evaluation cadence details component', () => {
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByText('Add Custom Schedule')).toBeInTheDocument();
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
it('should open the editor tab by default', () => {
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(RULE_VIEW_TEST_ID)).not.toBeInTheDocument();
});
it('should open the rrule tab when rrule tab is clicked', async () => {
const user = userEvent.setup();
render(<EvaluationCadenceDetails isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(RULE_VIEW_TEST_ID)).not.toBeInTheDocument();
const rruleTab = screen.getByText(RULE_VIEW_TEXT);
await user.click(rruleTab);
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
});
});

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