feat: revamp Conditions in alerts and pipelines (#9267)

Co-authored-by: Uddhav Dave <uddhav@MacBook-Pro.local>
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
This commit is contained in:
udev
2025-12-08 11:36:56 +05:30
committed by GitHub
co-authored by Uddhav Dave sai nikhil kethe Shrinath Rao
parent fa9fca0c55
commit 9056a2c92d
27 changed files with 9076 additions and 1392 deletions
+1
View File
@@ -193,6 +193,7 @@ jobs:
- testfolder: "Pipelines"
browser: "chrome"
run_files: [
"pipeline-conditions.spec.js",
"pipelines.spec.js",
"pipeline-dynamic.spec.js",
"pipeline-core.spec.js",
+571 -1
View File
@@ -341,7 +341,7 @@ pub struct QueryCondition {
#[serde(rename = "type")]
pub query_type: QueryType,
#[schema(value_type = Option<Object>)]
pub conditions: Option<ConditionList>,
pub conditions: Option<AlertConditionParams>,
pub sql: Option<String>,
pub promql: Option<String>, // (cpu usage / cpu total)
pub promql_condition: Option<Condition>, // value >= 80
@@ -634,6 +634,155 @@ impl std::fmt::Display for Operator {
}
}
// Condition system v2 for pipeline conditions with linear evaluation
// This provides an alternative to the tree-based ConditionList (v1) for expressing
// mixed boolean operations with natural left-to-right ordering
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "UPPERCASE")]
pub enum LogicalOperator {
And,
Or,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(tag = "filterType", rename_all = "lowercase")]
pub enum ConditionItem {
Condition {
column: String,
operator: Operator,
#[schema(value_type = Object)]
value: Value,
#[serde(skip_serializing_if = "Option::is_none")]
ignore_case: Option<bool>,
#[serde(rename = "logicalOperator")]
logical_operator: LogicalOperator,
},
Group {
#[serde(rename = "logicalOperator")]
logical_operator: LogicalOperator,
conditions: Vec<ConditionItem>,
},
}
impl ConditionItem {
/// Get the logical operator for this condition item
pub fn logical_operator(&self) -> &LogicalOperator {
match self {
ConditionItem::Condition {
logical_operator, ..
} => logical_operator,
ConditionItem::Group {
logical_operator, ..
} => logical_operator,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ConditionGroup {
pub filter_type: String, // Always "group"
#[serde(rename = "logicalOperator")]
pub logical_operator: LogicalOperator,
pub conditions: Vec<ConditionItem>,
}
impl ConditionGroup {
/// Checks if the condition group has conditions
pub fn has_conditions(&self) -> bool {
!self.conditions.is_empty()
}
/// Validates the condition group structure
pub fn validate(&self) -> Result<(), String> {
if self.filter_type != "group" {
return Err(format!(
"Invalid filterType: expected 'group', got '{}'",
self.filter_type
));
}
if !self.has_conditions() {
return Err("Condition Group should have atleast 1 condition".to_owned());
}
Ok(())
}
}
/// Versioned condition parameters for alerts
/// Supports both v1 (tree-based ConditionList) and v2 (linear ConditionGroup)
#[derive(Clone, Debug, PartialEq, ToSchema)]
pub enum AlertConditionParams {
/// v1 format: Tree-based ConditionList (default when no version field)
V1(ConditionList),
/// v2 format: Linear ConditionGroup (version: 2)
V2(ConditionGroup),
}
impl<'de> Deserialize<'de> for AlertConditionParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
use serde_json::Value;
let value = Value::deserialize(deserializer)?;
// Get version field, default to 1 if not present
let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(1);
match version {
1 => {
let conditions: ConditionList = serde_json::from_value(value).map_err(|e| {
D::Error::custom(format!("Failed to parse V1 conditions: {}", e))
})?;
Ok(AlertConditionParams::V1(conditions))
}
2 => {
// Get conditions field
let conditions_value = value
.get("conditions")
.ok_or_else(|| D::Error::custom("conditions field missing for v2"))?;
let conditions: ConditionGroup = serde_json::from_value(conditions_value.clone())
.map_err(|e| {
D::Error::custom(format!("Failed to parse V2 conditions: {}", e))
})?;
Ok(AlertConditionParams::V2(conditions))
}
_ => Err(D::Error::custom(format!(
"Unsupported version: {}",
version
))),
}
}
}
impl Serialize for AlertConditionParams {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
match self {
AlertConditionParams::V1(conditions) => {
// V1: Serialize directly as ConditionList (no version field)
conditions.serialize(serializer)
}
AlertConditionParams::V2(conditions) => {
// V2: Include version field
let mut state = serializer.serialize_struct("AlertConditionParams", 2)?;
state.serialize_field("version", &2)?;
state.serialize_field("conditions", conditions)?;
state.end()
}
}
}
}
#[cfg(test)]
mod test {
use chrono::{DateTime, FixedOffset, TimeZone};
@@ -1390,6 +1539,165 @@ mod test {
}
}
#[test]
fn test_condition_group_serialization() {
// Test simple ConditionGroup serialization
let condition_json = r#"{
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "status",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "condition",
"column": "level",
"operator": "=",
"value": "critical",
"logicalOperator": "OR"
}
]
}"#;
let condition_group: ConditionGroup = serde_json::from_str(condition_json).unwrap();
assert_eq!(condition_group.filter_type, "group");
assert_eq!(condition_group.logical_operator, LogicalOperator::And);
assert_eq!(condition_group.conditions.len(), 2);
// Test serialization back to JSON
let serialized = serde_json::to_value(&condition_group).unwrap();
assert_eq!(serialized["filterType"], "group");
assert_eq!(serialized["logicalOperator"], "AND");
}
#[test]
fn test_condition_group_with_nested_groups() {
let condition_json = r#"{
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "status",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "service",
"operator": "=",
"value": "api",
"logicalOperator": "OR"
},
{
"filterType": "condition",
"column": "service",
"operator": "=",
"value": "web",
"logicalOperator": "AND"
}
]
}
]
}"#;
let condition_group: ConditionGroup = serde_json::from_str(condition_json).unwrap();
assert_eq!(condition_group.conditions.len(), 2);
// Check nested group
if let ConditionItem::Group { conditions, .. } = &condition_group.conditions[1] {
assert_eq!(conditions.len(), 2);
} else {
panic!("Expected nested group");
}
}
#[test]
fn test_condition_item_logical_operator() {
let condition_item = ConditionItem::Condition {
column: "status".to_string(),
operator: Operator::EqualTo,
value: Value::String("error".to_string()),
ignore_case: None,
logical_operator: LogicalOperator::And,
};
assert_eq!(*condition_item.logical_operator(), LogicalOperator::And);
let group_item = ConditionItem::Group {
logical_operator: LogicalOperator::Or,
conditions: vec![],
};
assert_eq!(*group_item.logical_operator(), LogicalOperator::Or);
}
#[test]
fn test_condition_group_validation() {
// Valid condition group (needs at least 2 conditions)
let condition_group = ConditionGroup {
filter_type: "group".to_string(),
logical_operator: LogicalOperator::And,
conditions: vec![
ConditionItem::Condition {
column: "status".to_string(),
operator: Operator::EqualTo,
value: Value::String("error".to_string()),
ignore_case: None,
logical_operator: LogicalOperator::And,
},
ConditionItem::Condition {
column: "level".to_string(),
operator: Operator::EqualTo,
value: Value::String("critical".to_string()),
ignore_case: None,
logical_operator: LogicalOperator::And,
},
],
};
assert!(condition_group.validate().is_ok());
// Invalid filter type
let invalid_condition_group = ConditionGroup {
filter_type: "invalid".to_string(),
logical_operator: LogicalOperator::And,
conditions: vec![
ConditionItem::Condition {
column: "status".to_string(),
operator: Operator::EqualTo,
value: Value::String("error".to_string()),
ignore_case: None,
logical_operator: LogicalOperator::And,
},
ConditionItem::Condition {
column: "level".to_string(),
operator: Operator::EqualTo,
value: Value::String("critical".to_string()),
ignore_case: None,
logical_operator: LogicalOperator::And,
},
],
};
assert!(invalid_condition_group.validate().is_err());
// Empty conditions (should fail - needs at least 2)
let empty_condition_group = ConditionGroup {
filter_type: "group".to_string(),
logical_operator: LogicalOperator::And,
conditions: vec![],
};
assert!(empty_condition_group.validate().is_err());
}
#[test]
fn test_backward_compatibility_with_fixed_offset() {
// Test that alerts without timezone string still work with FixedOffset
@@ -1476,4 +1784,266 @@ mod test {
// London is close to UTC (0 or +60 during BST)
assert!(london_offset >= 0 && london_offset <= 60);
}
#[test]
fn test_alert_condition_params_v2_deserialization() {
// Test V2 format deserialization
let v2_json = r#"{
"version": 2,
"conditions": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "level",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "condition",
"column": "service",
"operator": "=",
"value": "api",
"logicalOperator": "OR"
}
]
}
}"#;
let result: Result<AlertConditionParams, _> = serde_json::from_str(v2_json);
assert!(
result.is_ok(),
"V2 deserialization failed: {:?}",
result.err()
);
let params = result.unwrap();
match params {
AlertConditionParams::V2(group) => {
assert_eq!(group.filter_type, "group");
assert_eq!(group.logical_operator, LogicalOperator::And);
assert_eq!(group.conditions.len(), 2);
}
_ => panic!("Expected V2 variant"),
}
}
#[test]
fn test_alert_condition_params_v1_deserialization() {
// Test V1 format deserialization (without version field)
let v1_json = r#"{
"and": [
{
"column": "level",
"operator": "=",
"value": "error",
"ignore_case": false
},
{
"column": "service",
"operator": "=",
"value": "api",
"ignore_case": false
}
]
}"#;
let result: Result<AlertConditionParams, _> = serde_json::from_str(v1_json);
assert!(
result.is_ok(),
"V1 deserialization failed: {:?}",
result.err()
);
let params = result.unwrap();
match params {
AlertConditionParams::V1(conditions) => {
assert!(matches!(conditions, ConditionList::AndNode { .. }));
}
_ => panic!("Expected V1 variant"),
}
}
#[test]
fn test_query_condition_v2_deserialization() {
// Test full QueryCondition with V2 format
let query_condition_json = r#"{
"type": "custom",
"conditions": {
"version": 2,
"conditions": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "level",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "condition",
"column": "service",
"operator": "=",
"value": "api",
"logicalOperator": "OR"
}
]
}
},
"sql": null,
"promql": null,
"aggregation": null,
"promql_condition": null,
"vrl_function": null,
"multi_time_range": null
}"#;
let result: Result<QueryCondition, _> = serde_json::from_str(query_condition_json);
assert!(
result.is_ok(),
"QueryCondition deserialization failed: {:?}",
result.err()
);
let query_cond = result.unwrap();
assert_eq!(query_cond.query_type, QueryType::Custom);
assert!(query_cond.conditions.is_some());
match query_cond.conditions.unwrap() {
AlertConditionParams::V2(group) => {
assert_eq!(group.filter_type, "group");
assert_eq!(group.logical_operator, LogicalOperator::And);
assert_eq!(group.conditions.len(), 2);
}
_ => panic!("Expected V2 variant"),
}
}
#[test]
fn test_query_condition_v2_with_empty_strings() {
// Test with empty strings for optional fields (like the user's curl payload)
let query_condition_json = r#"{
"type": "custom",
"conditions": {
"version": 2,
"conditions": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "level",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "condition",
"column": "service",
"operator": "=",
"value": "api",
"logicalOperator": "OR"
}
]
}
},
"sql": "",
"promql": "",
"aggregation": null,
"promql_condition": null,
"vrl_function": null,
"multi_time_range": []
}"#;
let result: Result<QueryCondition, _> = serde_json::from_str(query_condition_json);
assert!(
result.is_ok(),
"QueryCondition deserialization with empty strings failed: {:?}",
result.err()
);
let query_cond = result.unwrap();
assert_eq!(query_cond.query_type, QueryType::Custom);
assert!(query_cond.conditions.is_some());
match query_cond.conditions.unwrap() {
AlertConditionParams::V2(group) => {
assert_eq!(group.filter_type, "group");
assert_eq!(group.logical_operator, LogicalOperator::And);
assert_eq!(group.conditions.len(), 2);
}
_ => panic!("Expected V2 variant"),
}
// Verify empty strings are treated as Some("") not None
assert!(query_cond.sql.is_some());
assert_eq!(query_cond.sql.unwrap(), "");
}
#[test]
fn test_exact_user_payload() {
// Test the exact structure from the user's curl command
let user_json = r#"{
"type": "custom",
"conditions": {
"version": 2,
"conditions": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "level",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "condition",
"column": "service",
"operator": "=",
"value": "api",
"logicalOperator": "OR"
}
]
}
},
"sql": null,
"promql": null,
"aggregation": null,
"promql_condition": null,
"vrl_function": null,
"search_event_type": null,
"multi_time_range": null
}"#;
println!("Testing user JSON...");
let result: Result<QueryCondition, _> = serde_json::from_str(user_json);
if let Err(ref e) = result {
println!("Deserialization error: {}", e);
// Try parsing just the conditions field
let value: serde_json::Value = serde_json::from_str(user_json).unwrap();
if let Some(conditions) = value.get("conditions") {
println!("Conditions value: {:#?}", conditions);
let cond_result: Result<AlertConditionParams, _> =
serde_json::from_value(conditions.clone());
println!(
"Direct AlertConditionParams parse result: {:?}",
cond_result
);
}
}
assert!(
result.is_ok(),
"User payload deserialization failed: {:?}",
result.err()
);
}
}
+221 -4
View File
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::meta::{
alerts::{ConditionList, QueryCondition, TriggerCondition},
alerts::{ConditionGroup, ConditionList, QueryCondition, TriggerCondition},
stream::{RemoteStreamParams, StreamParams, StreamType},
};
@@ -165,9 +165,79 @@ pub struct FunctionParams {
pub num_args: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct ConditionParams {
pub conditions: ConditionList,
#[derive(Debug, Clone, PartialEq, ToSchema)]
pub enum ConditionParams {
/// v1 format: Tree-based ConditionList (default when no version field)
V1 { conditions: ConditionList },
/// v2 format: Linear ConditionGroup (version: 2)
V2 { conditions: ConditionGroup },
}
// Custom deserializer to handle missing version field (defaults to V1 for backward compatibility)
impl<'de> Deserialize<'de> for ConditionParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
use serde_json::Value;
let value = Value::deserialize(deserializer)?;
// If version field is missing, default to 1 for backward compatibility
let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(1);
let Some(conditions) = value.get("conditions") else {
return Err(D::Error::missing_field("conditions"));
};
match version {
1 => {
let conditions: ConditionList = serde_json::from_value(conditions.clone())
.map_err(|e| {
D::Error::custom(format!("Failed to parse v1 conditions: {}", e))
})?;
Ok(ConditionParams::V1 { conditions })
}
2 => {
let conditions: ConditionGroup = serde_json::from_value(conditions.clone())
.map_err(|e| {
D::Error::custom(format!("Failed to parse v2 conditions: {}", e))
})?;
Ok(ConditionParams::V2 { conditions })
}
_ => Err(D::Error::custom(format!(
"Unsupported version: {}",
version
))),
}
}
}
// Custom serializer to omit version field for V1 (backward compatibility)
impl Serialize for ConditionParams {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
match self {
ConditionParams::V1 { conditions } => {
// V1: Omit version field for backward compatibility
let mut state = serializer.serialize_struct("ConditionParams", 1)?;
state.serialize_field("conditions", conditions)?;
state.end()
}
ConditionParams::V2 { conditions } => {
// V2: Include version field
let mut state = serializer.serialize_struct("ConditionParams", 2)?;
state.serialize_field("version", &2)?;
state.serialize_field("conditions", conditions)?;
state.end()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
@@ -325,4 +395,151 @@ mod tests {
let or_node_data = json::from_value::<NodeData>(or_payload);
assert!(or_node_data.is_ok());
}
#[test]
fn test_condition_node_v2_serialization() {
// Test v2 condition format with version field
let payload = json::json!({
"node_type": "condition",
"version": 2,
"conditions": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "status",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "condition",
"column": "level",
"operator": "=",
"value": "critical",
"logicalOperator": "OR"
}
]
}
});
let node_data = json::from_value::<NodeData>(payload);
assert!(node_data.is_ok());
// Verify it's V2
if let Ok(NodeData::Condition(params)) = node_data {
assert!(matches!(params, ConditionParams::V2 { .. }));
}
}
#[test]
fn test_condition_node_v2_nested_groups() {
// Test with nested groups
let nested_payload = json::json!({
"node_type": "condition",
"version": 2,
"conditions": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "status",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "group",
"logicalOperator": "OR",
"conditions": [
{
"filterType": "condition",
"column": "service",
"operator": "=",
"value": "api",
"logicalOperator": "OR"
},
{
"filterType": "condition",
"column": "service",
"operator": "=",
"value": "web",
"logicalOperator": "AND"
}
],
"logicalOperator": "AND"
}
]
}
});
let nested_node_data = json::from_value::<NodeData>(nested_payload);
assert!(nested_node_data.is_ok());
}
#[test]
fn test_deserialize_v2_nested_condition_nodes() {
let json_str = r#"[
{
"id": "7035edaa-e96c-4114-8b3c-6c77db44418d",
"data": {
"node_type": "condition",
"version": 2,
"conditions": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "kubernetes_annotations_prometheus_io_path",
"operator": "=",
"value": "asdfa",
"logicalOperator": "AND"
},
{
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"column": "distinct_field_hc_0",
"operator": "=",
"value": "123",
"logicalOperator": "OR"
},
{
"filterType": "condition",
"column": "kubernetes_annotations_prometheus_io_path",
"operator": "=",
"value": "123",
"logicalOperator": "AND"
}
]
},
{
"filterType": "condition",
"column": "kubernetes_container_hash",
"operator": "=",
"value": "dasfg",
"logicalOperator": "AND"
}
]
}
},
"position": {
"x": 271.25,
"y": 335
},
"io_type": "default"
}
]"#;
let result: Result<Vec<Node>, _> = json::from_str(json_str);
match &result {
Ok(nodes) => println!("✓ Successfully deserialized {} nodes", nodes.len()),
Err(e) => println!("✗ Deserialization error: {}", e),
}
assert!(result.is_ok(), "Failed to deserialize: {:?}", result.err());
}
}
+13 -4
View File
@@ -128,10 +128,19 @@ impl Pipeline {
for node in self.nodes.iter_mut() {
// ck 4
if matches!(&node.data, NodeData::Condition(condition_params) if !condition_params.conditions.has_conditions())
{
return Err(anyhow!("ConditionNode must have non-empty conditions"));
} else if let NodeData::Stream(stream_params) = &mut node.data {
if let NodeData::Condition(condition_params) = &node.data {
let has_empty_conditions = match condition_params {
components::ConditionParams::V1 { conditions } => !conditions.has_conditions(),
components::ConditionParams::V2 { conditions } => {
conditions.conditions.is_empty()
}
};
if has_empty_conditions {
return Err(anyhow!("ConditionNode must have non-empty conditions"));
}
}
if let NodeData::Stream(stream_params) = &mut node.data {
// ck 8
if stream_params.stream_type == StreamType::EnrichmentTables
&& matches!(&self.source, PipelineSource::Realtime(_))
+115 -1
View File
@@ -163,7 +163,7 @@ pub struct QueryCondition {
#[serde(rename = "type")]
pub query_type: QueryType,
#[schema(value_type = Option<Object>)]
pub conditions: Option<meta_alerts::ConditionList>,
pub conditions: Option<meta_alerts::AlertConditionParams>,
pub sql: Option<String>,
pub promql: Option<String>,
pub promql_condition: Option<Condition>,
@@ -353,6 +353,7 @@ impl From<meta_alerts::QueryCondition> for QueryCondition {
fn from(value: meta_alerts::QueryCondition) -> Self {
Self {
query_type: value.query_type.into(),
// Pass AlertConditionParams directly (supports both V1 and V2)
conditions: value.conditions,
sql: value.sql,
promql: value.promql,
@@ -531,6 +532,7 @@ impl From<QueryCondition> for meta_alerts::QueryCondition {
fn from(value: QueryCondition) -> Self {
Self {
query_type: value.query_type.into(),
// Pass AlertConditionParams directly (supports both V1 and V2)
conditions: value.conditions,
sql: value.sql,
promql: value.promql,
@@ -639,3 +641,115 @@ impl From<StreamType> for meta_stream::StreamType {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_query_condition_v2_deserialization() {
// Test that the API layer can deserialize V2 conditions
let json = r#"{
"type": "custom",
"conditions": {
"version": 2,
"conditions": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": [
{
"filterType": "condition",
"type": "condition",
"column": "level",
"operator": "=",
"value": "error",
"logicalOperator": "AND"
},
{
"filterType": "condition",
"type": "condition",
"column": "service",
"operator": "=",
"value": "api",
"logicalOperator": "OR"
}
]
}
},
"sql": null,
"promql": null,
"aggregation": null,
"promql_condition": null,
"vrl_function": null,
"search_event_type": null,
"multi_time_range": null
}"#;
let result: Result<QueryCondition, _> = serde_json::from_str(json);
assert!(
result.is_ok(),
"V2 deserialization failed: {:?}",
result.err()
);
let query_cond = result.unwrap();
assert_eq!(query_cond.query_type, QueryType::Custom);
assert!(query_cond.conditions.is_some());
// Verify it's V2
match query_cond.conditions.unwrap() {
meta_alerts::AlertConditionParams::V2(group) => {
assert_eq!(group.filter_type, "group");
assert_eq!(group.conditions.len(), 2);
}
_ => panic!("Expected V2 variant"),
}
}
#[test]
fn test_query_condition_v1_deserialization() {
// Test that the API layer still supports V1 conditions
let json = r#"{
"type": "custom",
"conditions": {
"and": [
{
"column": "level",
"operator": "=",
"value": "error",
"ignore_case": false
},
{
"column": "service",
"operator": "=",
"value": "api",
"ignore_case": false
}
]
},
"sql": null,
"promql": null,
"aggregation": null,
"promql_condition": null,
"vrl_function": null,
"search_event_type": null,
"multi_time_range": null
}"#;
let result: Result<QueryCondition, _> = serde_json::from_str(json);
assert!(
result.is_ok(),
"V1 deserialization failed: {:?}",
result.err()
);
let query_cond = result.unwrap();
assert!(query_cond.conditions.is_some());
// Verify it's V1
match query_cond.conditions.unwrap() {
meta_alerts::AlertConditionParams::V1(_) => {}
_ => panic!("Expected V1 variant"),
}
}
}
+2 -3
View File
@@ -18,8 +18,7 @@ use std::str::FromStr;
use chrono::{DateTime, FixedOffset, TimeZone, Utc};
use config::meta::{
alerts::{
ConditionList, QueryCondition as MetaQueryCondition,
TriggerCondition as MetaTriggerCondition,
QueryCondition as MetaQueryCondition, TriggerCondition as MetaTriggerCondition,
alert::{Alert as MetaAlert, ListAlertsParams},
deduplication::DeduplicationConfig as MetaDeduplicationConfig,
},
@@ -77,7 +76,7 @@ impl TryFrom<alerts::Model> for MetaAlert {
.context_attributes
.map(serde_json::from_value)
.transpose()?;
let query_conditions: Option<ConditionList> = value
let query_conditions: Option<config::meta::alerts::AlertConditionParams> = value
.query_conditions
.map(serde_json::from_value)
.transpose()?;
+1 -1
View File
@@ -241,7 +241,7 @@ async fn migrate_pipelines() -> Result<(), anyhow::Error> {
pos_y += pos_offset;
let condition_node = Node::new(
ider::uuid(),
NodeData::Condition(ConditionParams {
NodeData::Condition(ConditionParams::V1 {
conditions: ConditionList::LegacyConditions(routing_conditions),
}),
pos_x,
+1052 -3
View File
File diff suppressed because it is too large Load Diff
+15 -6
View File
@@ -45,7 +45,7 @@ use tokio::{
use crate::{
common::infra::config::QUERY_FUNCTIONS,
service::{
alerts::ConditionExt,
alerts::{ConditionExt, ConditionGroupExt},
ingestion::{apply_vrl_fn, compile_vrl_function},
self_reporting::publish_error,
},
@@ -651,12 +651,21 @@ async fn process_node(
};
flattened = true;
}
// Evaluate based on condition version
let passes = match condition_params {
config::meta::pipeline::components::ConditionParams::V1 { conditions } => {
// v1: Use tree-based ConditionList evaluation
conditions.evaluate(record.as_object().unwrap()).await
}
config::meta::pipeline::components::ConditionParams::V2 { conditions } => {
// v2: Use linear ConditionGroup evaluation
conditions.evaluate(record.as_object().unwrap()).await
}
};
// only send to children when passing all condition evaluations
if condition_params
.conditions
.evaluate(record.as_object().unwrap())
.await
{
if passes {
send_to_children(
&mut child_senders,
PipelineItem {
@@ -1,5 +1,6 @@
// pipelinesPage.js
const { expect } = require('@playwright/test')
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const randomNodeName = `remote-node-${Math.floor(Math.random() * 1000)}`;
@@ -110,6 +111,36 @@ export class PipelinesPage {
this.deletedSuccessfullyText = page.getByText('deleted successfully');
this.conditionDropdown = page.locator("div:nth-child(2) > div:nth-child(2) > .q-field > .q-field__inner > .q-field__control > .q-field__control-container > .q-field__native");
this.deleteButtonNth1 = page.locator("button").filter({ hasText: "delete" }).nth(1);
// Condition-specific locators for comprehensive testing
this.addConditionSection = page.locator('[data-test="add-condition-section"]');
this.addConditionGroupBtn = page.locator('[data-test="alert-conditions-add-condition-group-btn"]');
this.toggleOperatorBtn = page.locator('[data-test="alert-conditions-toggle-operator-btn"]');
this.deleteConditionBtn = page.locator('[data-test="alert-conditions-delete-condition-btn"]');
this.reorderBtn = page.locator('[data-test="alert-conditions-reorder-btn"]');
this.addConditionCancelBtn = page.locator('[data-test="add-condition-cancel-btn"]');
this.addConditionDeleteBtn = page.locator('[data-test="add-condition-delete-btn"]');
this.scheduledAlertTabs = page.locator('[data-test="scheduled-alert-tabs"]');
this.nestedGroups = page.locator('.el-border');
this.operatorLabels = page.locator('span.tw-lowercase');
this.firstConditionLabel = page.locator('.tw-flex.tw-items-start.tw-gap-1').first().locator('span').first();
this.noteContainer = page.locator('.note-container');
this.noteHeading = page.locator('.note-heading');
this.noteInfo = page.locator('.note-info');
this.qDialog = page.locator('.q-dialog');
this.qDialogBackdrop = page.locator('.q-dialog__backdrop');
this.qNotificationMessage = page.locator('.q-notification__message');
this.qMenu = page.locator('.q-menu');
// Pipeline node locators
this.pipelineNodeOutputStreamNode = page.locator('[data-test="pipeline-node-output-stream-node"]');
this.pipelineNodeOutputDeleteBtn = page.locator('[data-test="pipeline-node-output-delete-btn"]');
this.pipelineNodeDefaultConditionNode = page.locator('[data-test="pipeline-node-default-condition-node"]');
this.pipelineNodeInputOutputHandle = page.locator('[data-test="pipeline-node-input-output-handle"]');
this.pipelineNodeDefaultInputHandle = page.locator('[data-test="pipeline-node-default-input-handle"]');
this.pipelineNodeDefaultOutputHandle = page.locator('[data-test="pipeline-node-default-output-handle"]');
this.pipelineNodeOutputInputHandle = page.locator('[data-test="pipeline-node-output-input-handle"]');
this.addPipelineBackBtn = page.locator('[data-test="add-pipeline-back-btn"]');
}
// Methods from original PipelinesPage
@@ -390,7 +421,7 @@ export class PipelinesPage {
if (functionName?.trim() === fileName) {
fileFound = true;
console.log("Uploaded file found:", functionName);
testLogger.debug("Uploaded file found", { functionName });
// Click the 'Delete Function' button
await row.locator('[title="Delete Function"]').click();
@@ -624,4 +655,283 @@ export class PipelinesPage {
await this.confirmDeletePipeline();
await this.verifyPipelineDeleted();
}
// ========== Condition-specific methods ==========
async fillCondition(columnName, operator, value, index = 0) {
const columnSelects = this.columnSelect;
// Try clicking the input first
await columnSelects.nth(index).locator('input').click();
await this.page.waitForTimeout(200);
// Check if dropdown opened, if not click the dropdown icon
let menuVisible = await this.qMenu.isVisible().catch(() => false);
if (!menuVisible) {
await columnSelects.nth(index).locator('i').click();
await this.page.waitForTimeout(200);
}
await columnSelects.nth(index).locator('input').fill(columnName);
await this.page.waitForTimeout(500);
// Select from dropdown
const options = this.qMenu.locator('.q-item');
await options.first().click();
await this.page.waitForTimeout(300);
// Select operator
await this.operatorSelect.nth(index).click();
await this.page.waitForTimeout(500);
// Scope to the visible dropdown menu and select the operator
const visibleMenu = this.qMenu.last();
await visibleMenu.locator('.q-item').getByText(operator, { exact: true }).click();
await this.page.waitForTimeout(300);
// Fill value
await this.valueInput.nth(index).locator('input').click();
await this.valueInput.nth(index).locator('input').fill(value);
await this.page.waitForTimeout(300);
}
async addNewCondition() {
await this.addConditionButton.first().click();
await this.page.waitForTimeout(500);
}
async addConditionGroup() {
await this.addConditionGroupBtn.first().click();
await this.page.waitForTimeout(500);
}
async toggleConditionOperator(index = 0) {
await this.toggleOperatorBtn.nth(index).click();
await this.page.waitForTimeout(300);
}
async deleteConditionByIndex(index) {
await this.deleteConditionBtn.nth(index).click();
await this.page.waitForTimeout(300);
}
async createPipelineWithCondition(streamName) {
await this.openPipelineMenu();
await this.page.waitForTimeout(1000);
await this.addPipeline();
await this.selectStream();
await this.dragStreamToTarget(this.streamButton);
await this.selectLogs();
await this.enterStreamName(streamName);
await this.page.waitForTimeout(2000);
await this.page.getByRole("option", { name: streamName, exact: true }).click();
await this.saveInputNodeStream();
await this.page.waitForTimeout(2000);
// Remove default output stream node
await this.pipelineNodeOutputStreamNode.first().hover();
await this.page.waitForTimeout(500);
await this.pipelineNodeOutputDeleteBtn.first().click();
await this.confirmButton.click();
// Add condition node
await this.selectAndDragCondition();
await this.page.waitForTimeout(1000);
}
async saveConditionAndCompletePipeline(pipelineName) {
await this.saveCondition();
await this.page.waitForTimeout(2000);
// Wait for the condition dialog to close and the condition node to appear on canvas
await this.page.waitForSelector('[data-test="add-condition-section"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
await this.page.waitForTimeout(1000);
// Verify the condition node was created successfully
await expect(this.pipelineNodeDefaultConditionNode).toBeVisible({ timeout: 5000 });
// Add destination stream
await this.selectAndDragSecondStream();
await this.streamNameInput.click();
await this.streamNameInput.fill("destination-node");
await this.page.waitForTimeout(1000);
await this.clickInputNodeStreamSave();
await this.page.waitForTimeout(2000);
await this.page.waitForSelector('[data-test="pipeline-node-input-output-handle"]', { state: 'visible', timeout: 10000 });
await this.page.waitForSelector('[data-test="pipeline-node-default-input-handle"]', { state: 'visible', timeout: 10000 });
await this.page.waitForSelector('[data-test="pipeline-node-output-input-handle"]', { state: 'visible', timeout: 10000 });
await this.page.waitForSelector('.q-dialog__backdrop', { state: 'hidden', timeout: 3000 }).catch(() => {});
// Connect nodes
await this.pipelineNodeInputOutputHandle.hover({ force: true });
await this.page.mouse.down();
await this.pipelineNodeDefaultInputHandle.hover({ force: true });
await this.page.mouse.up();
await this.page.waitForTimeout(500);
await this.pipelineNodeDefaultOutputHandle.hover({ force: true });
await this.page.mouse.down();
await this.pipelineNodeOutputInputHandle.hover({ force: true });
await this.page.mouse.up();
await this.page.waitForTimeout(1000);
await this.enterPipelineName(pipelineName);
await this.savePipeline();
// Wait for save to complete
await this.page.waitForTimeout(3000);
}
async deletePipelineByName(pipelineName) {
await this.searchPipeline(pipelineName);
await this.page.waitForTimeout(1000);
const deletePipelineButton = this.page.locator(
`[data-test="pipeline-list-${pipelineName}-delete-pipeline"]`
);
await deletePipelineButton.waitFor({ state: "visible" });
await deletePipelineButton.click();
await this.confirmDeletePipeline();
await this.verifyPipelineDeleted();
}
async openPipelineForEdit(pipelineName) {
await this.page.locator(`[data-test="pipeline-list-${pipelineName}-update-pipeline"]`).click();
await this.page.waitForTimeout(2000);
}
async clickConditionNode() {
await this.pipelineNodeDefaultConditionNode.click();
await this.page.waitForTimeout(1000);
}
async reconnectSourceToDestination() {
await this.pipelineNodeInputOutputHandle.hover({ force: true });
await this.page.mouse.down();
await this.pipelineNodeOutputInputHandle.hover({ force: true });
await this.page.mouse.up();
await this.page.waitForTimeout(1000);
}
async verifyConditionDialogOpen() {
await expect(this.addConditionSection).toBeVisible();
}
async verifyConditionDialogClosed() {
await expect(this.addConditionSection).not.toBeVisible();
}
async verifyFirstConditionLabel(text) {
await expect(this.firstConditionLabel).toHaveText(text);
}
async verifyConditionCount(count) {
await expect(this.valueInput).toHaveCount(count);
}
async verifyNestedGroupsExist() {
const count = await this.nestedGroups.count();
expect(count).toBeGreaterThan(1);
}
async verifyScheduledAlertTabsVisible() {
await expect(this.scheduledAlertTabs.first()).toBeVisible();
}
async verifyOperatorLabel(text, index = 0) {
await expect(this.operatorLabels.nth(index)).toContainText(text);
}
async verifyNoteContainer() {
await expect(this.noteContainer).toBeVisible();
await expect(this.noteHeading).toContainText("Condition value Guidelines");
}
async captureConditionValues() {
const valueInputs = this.valueInput.locator('input');
const values = [];
const count = await valueInputs.count();
for (let i = 0; i < count; i++) {
const value = await valueInputs.nth(i).inputValue();
values.push(value);
}
return values;
}
async clickReorderButton(index = 1) {
await this.reorderBtn.nth(index).click();
await this.page.waitForTimeout(500);
}
async captureConditionScreenshot() {
return await this.addConditionSection.screenshot();
}
async cancelConditionDialog() {
await this.addConditionCancelBtn.click();
await this.page.waitForTimeout(500);
}
async clickDeleteConditionNode() {
const deleteButton = this.addConditionDeleteBtn;
if (await deleteButton.count() > 0) {
await deleteButton.click();
await this.page.waitForTimeout(500);
await this.confirmButton.click();
await this.page.waitForTimeout(1000);
}
}
async verifyConditionValueInputValue(value, index = 0) {
await expect(this.valueInput.nth(index).locator('input')).toHaveValue(value);
}
async clearAndFillConditionValue(value, index = 0) {
await this.valueInput.nth(index).locator('input').clear();
await this.valueInput.nth(index).locator('input').fill(value);
}
async verifyDeleteButtonCount(expectedCount) {
const count = await this.deleteConditionBtn.count();
expect(count).toBeLessThan(expectedCount);
}
async tryToSaveWithoutValidConditions() {
await this.addConditionSaveButton.click();
await this.page.waitForTimeout(1000);
}
async verifyNotificationVisible() {
const notification = this.qNotificationMessage;
if (await notification.count() > 0) {
await expect(notification.first()).toBeVisible();
}
await this.page.waitForTimeout(2000);
}
async fillPartialCondition(columnName) {
await this.columnSelect.first().locator('input').click();
await this.columnSelect.first().locator('input').fill(columnName);
await this.page.waitForTimeout(300);
const options = this.qMenu.locator('.q-item');
if (await options.count() > 0) {
await options.first().click();
}
}
async selectOperatorFromMenu(operator) {
await this.operatorSelect.first().click();
await this.page.waitForTimeout(500);
const visibleMenu = this.qMenu.last();
await visibleMenu.locator('.q-item').getByText(operator, { exact: true }).click();
}
async verifyConfirmationDialog() {
const dialog = this.qDialog;
if (await dialog.count() > 0) {
await expect(dialog.first()).toBeVisible();
await this.confirmButton.click();
}
}
}
@@ -0,0 +1,381 @@
import { test, expect } from "../baseFixtures.js";
import logData from "../../fixtures/log.json";
import logsdata from "../../../test-data/logs_data.json";
import PageManager from "../../pages/page-manager.js";
const testLogger = require('../utils/test-logger.js');
test.describe.configure({ mode: "parallel" });
test.use({
contextOptions: {
slowMo: 1000
}
});
async function login(page) {
await page.goto(process.env["ZO_BASE_URL"]);
if (await page.getByText('Login as internal user').isVisible()) {
await page.getByText('Login as internal user').click();
}
await page.waitForTimeout(1000);
await page
.locator('[data-cy="login-user-id"]')
.fill(process.env["ZO_ROOT_USER_EMAIL"]);
await page.locator("label").filter({ hasText: "Password *" }).click();
await page
.locator('[data-cy="login-password"]')
.fill(process.env["ZO_ROOT_USER_PASSWORD"]);
await page.locator('[data-cy="login-sign-in"]').click();
}
async function ingestion(page, streamName = "e2e_automate_conditions") {
const orgId = process.env["ORGNAME"];
const basicAuthCredentials = Buffer.from(
`${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}`
).toString('base64');
const headers = {
"Authorization": `Basic ${basicAuthCredentials}`,
"Content-Type": "application/json",
};
const response = await page.evaluate(async ({ url, headers, orgId, streamName, logsdata }) => {
const fetchResponse = await fetch(`${url}/api/${orgId}/${streamName}/_json`, {
method: 'POST',
headers: headers,
body: JSON.stringify(logsdata)
});
return await fetchResponse.json();
}, {
url: process.env.INGESTION_URL,
headers: headers,
orgId: orgId,
streamName: streamName,
logsdata: logsdata
});
testLogger.debug('API response received', { response, streamName });
}
test.describe("Pipeline Conditions - Comprehensive Tests", () => {
let pageManager;
test.beforeEach(async ({ page }) => {
await login(page);
pageManager = new PageManager(page);
await page.waitForTimeout(5000);
// Ingest data to unique streams for each test to avoid conflicts
await ingestion(page, "e2e_conditions_basic");
await ingestion(page, "e2e_conditions_groups");
await ingestion(page, "e2e_conditions_validation");
await ingestion(page, "e2e_conditions_precedence");
await ingestion(page, "e2e_conditions_multiple");
await ingestion(page, "e2e_conditions_delete");
await ingestion(page, "e2e_conditions_operators");
await page.goto(
`${logData.logsUrl}?org_identifier=${process.env["ORGNAME"]}`
);
await page.waitForTimeout(2000);
});
test("should create, edit, and test basic condition operations with multiple operators", async ({ page }) => {
const pipelineName = `pipeline-basic-ops-${Math.random().toString(36).substring(7)}`;
// Create pipeline with condition
await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_basic");
// Verify condition dialog is open
await pageManager.pipelinesPage.verifyConditionDialogOpen();
// Test 1: Verify first condition shows "if" label
await pageManager.pipelinesPage.verifyFirstConditionLabel("if");
// Test 2: Fill first condition with "Contains" operator
await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "Contains", "prometheus", 0);
await pageManager.pipelinesPage.verifyConditionValueInputValue("prometheus", 0);
// Test 3: Add second condition with "=" operator
await pageManager.pipelinesPage.addNewCondition();
await pageManager.pipelinesPage.fillCondition("kubernetes_host", "=", "test-host", 1);
// Test 4: Verify both conditions exist
await pageManager.pipelinesPage.verifyConditionCount(2);
// Test 5: Toggle second condition to OR operator
await pageManager.pipelinesPage.toggleConditionOperator(0);
await page.waitForTimeout(500);
// Test 6: Add third condition and test other operators
await pageManager.pipelinesPage.addNewCondition();
await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "!=", "excluded-pod", 2);
await pageManager.pipelinesPage.verifyConditionCount(3);
// Test 7: Delete second condition (not first, not last)
await pageManager.pipelinesPage.deleteConditionByIndex(1);
await page.waitForTimeout(500);
await pageManager.pipelinesPage.verifyConditionCount(2);
// Test 8: Verify first condition cannot be deleted (Bug #9075)
await pageManager.pipelinesPage.verifyDeleteButtonCount(2);
// Save and complete pipeline
await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName);
await ingestion(page);
// Test 9: Edit existing condition
await pageManager.pipelinesPage.openPipelineMenu();
await page.waitForTimeout(1000);
await pageManager.pipelinesPage.openPipelineForEdit(pipelineName);
await pageManager.pipelinesPage.clickConditionNode();
await pageManager.pipelinesPage.verifyConditionDialogOpen();
// Verify existing value and modify
await pageManager.pipelinesPage.verifyConditionValueInputValue("prometheus", 0);
await pageManager.pipelinesPage.clearAndFillConditionValue("modified-value", 0);
await pageManager.pipelinesPage.saveCondition();
await page.waitForTimeout(1000);
await pageManager.pipelinesPage.savePipeline();
// Cleanup
await pageManager.pipelinesPage.openPipelineMenu();
await pageManager.pipelinesPage.deletePipelineByName(pipelineName);
});
test("should handle condition groups, nesting, and reordering", async ({ page }) => {
await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_groups");
// Test 1: Add first condition
await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "=", "test-A", 0);
// Test 2: Add nested condition group
await pageManager.pipelinesPage.addConditionGroup();
await page.waitForTimeout(500);
// Verify nested group exists
await pageManager.pipelinesPage.verifyNestedGroupsExist();
// Test 3: Add conditions in nested group
const addConditionButtons = pageManager.pipelinesPage.addConditionButton;
const buttonCount = await addConditionButtons.count();
expect(buttonCount).toBeGreaterThan(1);
// Fill the first condition in nested group
await pageManager.pipelinesPage.fillCondition("kubernetes_namespace_name", "=", "test-B", 1);
// Add another condition in nested group
await addConditionButtons.nth(1).click();
await page.waitForTimeout(500);
// Fill the second condition in nested group
await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "=", "test-C", 2);
// Test 4: Verify group-level tabs exist for nested group
await pageManager.pipelinesPage.verifyScheduledAlertTabsVisible();
// Test 5: Test reorder button with actual reordering validation
const reorderButtons = pageManager.pipelinesPage.reorderBtn;
const reorderButtonCount = await reorderButtons.count();
expect(reorderButtonCount).toBeGreaterThan(1);
// Capture order of condition values before reorder
const beforeValues = await pageManager.pipelinesPage.captureConditionValues();
testLogger.info('Condition values before reorder:', { beforeValues });
// Take screenshot before reorder
const beforeScreenshot = await pageManager.pipelinesPage.captureConditionScreenshot();
// Click reorder button at nested level
await pageManager.pipelinesPage.clickReorderButton(1);
// Capture order of condition values after reorder
const afterValues = await pageManager.pipelinesPage.captureConditionValues();
testLogger.info('Condition values after reorder:', { afterValues });
// Take screenshot after reorder
const afterScreenshot = await pageManager.pipelinesPage.captureConditionScreenshot();
// Verify screenshots were captured
expect(beforeScreenshot.length).toBeGreaterThan(0);
expect(afterScreenshot.length).toBeGreaterThan(0);
// Verify that the order has changed (arrays should not be equal)
expect(beforeValues.length).toBe(afterValues.length);
expect(beforeValues).not.toEqual(afterValues);
// Verify reorder button is still visible after click
await expect(reorderButtons.nth(1)).toBeVisible();
// Cancel without saving
await pageManager.pipelinesPage.cancelConditionDialog();
await pageManager.pipelinesPage.verifyConfirmationDialog();
});
test("should validate fields and show proper error messages", async ({ page }) => {
await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_validation");
// Test 1: Verify guidelines are displayed
await pageManager.pipelinesPage.verifyNoteContainer();
const noteInfo = pageManager.pipelinesPage.noteInfo;
await expect(noteInfo).toContainText('""');
await expect(noteInfo).toContainText('null');
// Test 2: Try to save without any valid conditions
await pageManager.pipelinesPage.tryToSaveWithoutValidConditions();
// Should show error notification
await pageManager.pipelinesPage.verifyNotificationVisible();
// Test 3: Fill partial condition (missing value)
await pageManager.pipelinesPage.fillPartialCondition("kubernetes");
await pageManager.pipelinesPage.selectOperatorFromMenu("=");
// Try to save without value
await pageManager.pipelinesPage.tryToSaveWithoutValidConditions();
// Test 4: Test cancel with unsaved changes
await pageManager.pipelinesPage.valueInput.first().locator('input').fill("test-value");
await pageManager.pipelinesPage.addNewCondition();
await page.waitForTimeout(500);
await pageManager.pipelinesPage.cancelConditionDialog();
await page.waitForTimeout(500);
// Should show confirmation dialog
await pageManager.pipelinesPage.verifyConfirmationDialog();
});
test("should test complex conditions and operator precedence (A OR B AND C)", async ({ page }) => {
const pipelineName = `pipeline-complex-${Math.random().toString(36).substring(7)}`;
await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_precedence");
// Create: A OR B AND C (tests operator precedence)
// Condition A
await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "=", "value-A", 0);
// Condition B with OR
await pageManager.pipelinesPage.addNewCondition();
await pageManager.pipelinesPage.toggleConditionOperator(0);
await page.waitForTimeout(300);
await pageManager.pipelinesPage.fillCondition("kubernetes_host", "=", "value-B", 1);
// Condition C with AND (default)
await pageManager.pipelinesPage.addNewCondition();
await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "=", "value-C", 2);
// Verify 3 conditions exist
await pageManager.pipelinesPage.verifyConditionCount(3);
// Verify operators are set correctly
const operatorLabels = pageManager.pipelinesPage.operatorLabels;
if (await operatorLabels.count() >= 2) {
await pageManager.pipelinesPage.verifyOperatorLabel("OR", 0);
}
await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName);
await ingestion(page);
await pageManager.pipelinesPage.openPipelineMenu();
await pageManager.pipelinesPage.deletePipelineByName(pipelineName);
});
test("should test multiple conditions: A, B, C, and D", async ({ page }) => {
const pipelineName = `pipeline-multiple-${Math.random().toString(36).substring(7)}`;
await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_multiple");
// Wait for condition dialog to be fully loaded
await pageManager.pipelinesPage.verifyConditionDialogOpen();
await page.waitForTimeout(1000);
// Condition A at root level
await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "=", "value-A", 0);
// Add condition B
await pageManager.pipelinesPage.addNewCondition();
await page.waitForTimeout(500);
await pageManager.pipelinesPage.fillCondition("kubernetes_host", "=", "value-B", 1);
// Add condition C
await pageManager.pipelinesPage.addNewCondition();
await page.waitForTimeout(500);
await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "=", "value-C", 2);
// Add condition D
await pageManager.pipelinesPage.addNewCondition();
await page.waitForTimeout(500);
await pageManager.pipelinesPage.fillCondition("kubernetes_namespace_name", "=", "value-D", 3);
// Verify all 4 conditions exist
await pageManager.pipelinesPage.verifyConditionCount(4);
await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName);
await ingestion(page);
await pageManager.pipelinesPage.openPipelineMenu();
await pageManager.pipelinesPage.deletePipelineByName(pipelineName);
});
test("should delete condition node from pipeline", async ({ page }) => {
const pipelineName = `pipeline-delete-${Math.random().toString(36).substring(7)}`;
// Create pipeline with condition
await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_delete");
await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "=", "test", 0);
await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName);
// Open pipeline in edit mode
await pageManager.pipelinesPage.openPipelineMenu();
await page.waitForTimeout(1000);
await pageManager.pipelinesPage.openPipelineForEdit(pipelineName);
// Click on condition node to open
await pageManager.pipelinesPage.clickConditionNode();
// Verify delete button exists and click it
await pageManager.pipelinesPage.clickDeleteConditionNode();
// Verify dialog closed
await pageManager.pipelinesPage.verifyConditionDialogClosed();
// After deleting condition node, reconnect source to destination
await page.waitForTimeout(1000);
await pageManager.pipelinesPage.reconnectSourceToDestination();
// Save the updated pipeline
await pageManager.pipelinesPage.savePipeline();
await page.waitForTimeout(3000);
// Cleanup
await pageManager.pipelinesPage.openPipelineMenu();
await pageManager.pipelinesPage.deletePipelineByName(pipelineName);
});
test("should test different comparison operators (>=, !=, Contains, NotContains)", async ({ page }) => {
const pipelineName = `pipeline-operators-${Math.random().toString(36).substring(7)}`;
await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_operators");
// Test >= operator
await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", ">=", "100", 0);
// Add != operator condition
await pageManager.pipelinesPage.addNewCondition();
await pageManager.pipelinesPage.fillCondition("kubernetes_host", "!=", "excluded", 1);
// Add NotContains operator condition
await pageManager.pipelinesPage.addNewCondition();
await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "NotContains", "ignore", 2);
// Verify all 3 conditions exist
await pageManager.pipelinesPage.verifyConditionCount(3);
await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName);
await ingestion(page);
await pageManager.pipelinesPage.openPipelineMenu();
await pageManager.pipelinesPage.deletePipelineByName(pipelineName);
});
});
+129
View File
@@ -21,6 +21,7 @@ import store from "@/test/unit/helpers/store";
import { installQuasar } from "@/test/unit/helpers";
import router from "@/test/unit/helpers/router";
import { generateWhereClause } from "@/utils/alerts/alertQueryBuilder";
import { detectConditionsVersion } from "@/utils/alerts/alertDataTransforms";
import PreviewAlert from "@/components/alerts/PreviewAlert.vue";
@@ -1159,6 +1160,134 @@ describe("AddAlert Component", () => {
});
});
describe('V2 Conditions Version Field Placement', () => {
let w: any;
beforeEach(() => {
w = mount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } });
w.vm.formData.name = "test_v2_alert";
w.vm.formData.stream_name = "_rundata";
w.vm.formData.stream_type = "logs";
w.vm.formData.is_real_time = false;
w.vm.scheduledAlertRef = { tab: 'custom' };
w.vm.isAggregationEnabled = false;
});
it('should wrap V2 conditions with version field INSIDE conditions object when saving', () => {
// Set up V2 format conditions
const v2Conditions = {
filterType: 'group',
logicalOperator: 'AND',
groupId: 'test-group',
conditions: [
{
id: 'test-id',
filterType: 'condition',
column: 'status',
operator: '=',
value: '200',
logicalOperator: 'AND',
}
]
};
w.vm.formData.query_condition.conditions = v2Conditions;
w.vm.formData.query_condition.aggregation = null;
w.vm.formData.query_condition.type = 'custom';
const payload = w.vm.getAlertPayload();
// Simulate the version wrapping logic from onSubmit()
const version = detectConditionsVersion(w.vm.formData.query_condition.conditions);
if (version === 2) {
payload.query_condition.conditions = {
version: 2,
conditions: w.vm.formData.query_condition.conditions,
};
}
// Backend expects: conditions: { version: 2, conditions: {...} }
expect(payload.query_condition.conditions).toHaveProperty('version');
expect(payload.query_condition.conditions.version).toBe(2);
expect(payload.query_condition.conditions).toHaveProperty('conditions');
expect(payload.query_condition.conditions.conditions.filterType).toBe('group');
expect(payload.query_condition.conditions.conditions.logicalOperator).toBe('AND');
});
it('should NOT have version field at query_condition level for V2', () => {
// Set up V2 format conditions
w.vm.formData.query_condition.conditions = {
filterType: 'group',
logicalOperator: 'OR',
groupId: 'test-group',
conditions: []
};
const payload = w.vm.getAlertPayload();
// Simulate the version wrapping logic from onSubmit()
const version = detectConditionsVersion(w.vm.formData.query_condition.conditions);
if (version === 2) {
payload.query_condition.conditions = {
version: 2,
conditions: w.vm.formData.query_condition.conditions,
};
}
// Version should NOT be at this level
expect(payload.query_condition).not.toHaveProperty('version');
// But should be inside conditions
expect(payload.query_condition.conditions).toHaveProperty('version');
});
it('should handle V1 conditions without version field', () => {
// Set up V1 format conditions (tree-based)
w.vm.formData.query_condition.conditions = {
or: [
{ column: 'status', operator: '=', value: '200', ignore_case: true }
]
};
const payload = w.vm.getAlertPayload();
// V1 format should not have version field at any level
expect(payload.query_condition).not.toHaveProperty('version');
expect(payload.query_condition.conditions).not.toHaveProperty('version');
// Should be transformed to V1 backend format
expect(payload.query_condition.conditions).toHaveProperty('or');
});
it('should correctly extract V2 conditions when loading from backend', async () => {
// Simulate backend response with V2 format
w.vm.formData.query_condition.conditions = {
version: 2,
conditions: {
filterType: 'group',
logicalOperator: 'AND',
groupId: 'backend-group',
conditions: [
{
id: 'backend-id',
filterType: 'condition',
column: 'level',
operator: '=',
value: 'error',
logicalOperator: 'AND',
}
]
}
};
// Call the data loading logic (simulating mounted hook behavior)
await w.vm.$nextTick();
// After loading, conditions should be extracted from nested structure
// The actual extraction happens in the component's data loading logic
// This test verifies the structure matches backend expectations
expect(w.vm.formData.query_condition.conditions.version).toBe(2);
expect(w.vm.formData.query_condition.conditions.conditions).toBeDefined();
expect(w.vm.formData.query_condition.conditions.conditions.filterType).toBe('group');
});
});
describe('validateFormAndNavigateToErrorField and navigateToErrorField', () => {
it('navigates on invalid and returns false', async () => {
const w = mount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } });
+77 -18
View File
@@ -525,6 +525,11 @@ import {
removeConditionGroup as removeConditionGroupUtil,
transformFEToBE as transformFEToBEUtil,
retransformBEToFE as retransformBEToFEUtil,
detectConditionsVersion,
convertV0ToV2,
convertV1ToV2,
convertV1BEToV2,
ensureIds,
type TransformContext,
} from "@/utils/alerts/alertDataTransforms";
@@ -537,12 +542,18 @@ const defaultValue: any = () => {
query_condition: {
conditions:
{
"or": [
filterType: "group",
logicalOperator: "AND",
groupId: "",
conditions: [
{
"column": "",
"operator": ">",
"value": "",
"ignore_case": false
filterType: "condition",
column: "",
operator: ">",
value: "",
values: [],
logicalOperator: "AND",
id: ""
}
]
},
@@ -1205,20 +1216,26 @@ export default defineComponent({
const conditions: any[] = [];
query.fields.filter.forEach((filter: any) => {
if (filter.type === 'list' && filter.values && filter.values.length > 0) {
// V2: Create condition with filterType and logicalOperator
conditions.push({
filterType: 'condition',
column: filter.column,
operator: "=",
value: filter.values[0],
ignore_case: false
values: [],
logicalOperator: 'AND',
id: getUUID()
});
}
});
if (conditions.length > 0) {
// V2: Create group with filterType and conditions array
formData.value.query_condition.conditions = {
filterType: 'group',
logicalOperator: 'AND',
groupId: getUUID(),
label: 'and',
items: conditions
conditions: conditions
};
}
}
@@ -1503,18 +1520,54 @@ export default defineComponent({
id: getUUID(),
};
});
//this is done because
//if we are getting the conditions as null or undefined then we need to create a new group
//if we are getting the conditions as an object then we need to transform it to the frontend format
if(this.formData.query_condition.conditions && ( !Array.isArray(this.formData.query_condition.conditions) && Object.keys(this.formData.query_condition.conditions).length != 0)){
this.formData.query_condition.conditions = this.retransformBEToFE(this.formData.query_condition.conditions);
// VERSION DETECTION AND CONVERSION
// Supports three versions:
// - V0: Flat array of conditions with implicit AND between all (no groups)
// - V1: Tree-based structure with {and: [...]} or {or: [...]} or {label, items, groupId}
// - V2: Linear structure with filterType, logicalOperator per condition
// Check for V2 format from backend
// Backend sends: query_condition: { conditions: { version: 2, conditions: {...} } }
if (this.formData.query_condition.conditions?.version === "2" || this.formData.query_condition.conditions?.version === 2) {
// V2: Extract the nested conditions object
// Backend sends { version: 2, conditions: {...} }, we need just the inner conditions
this.formData.query_condition.conditions = ensureIds(this.formData.query_condition.conditions.conditions);
} else if(this.formData.query_condition.conditions && ( !Array.isArray(this.formData.query_condition.conditions) && Object.keys(this.formData.query_condition.conditions).length != 0)){
// No version field - could be V1 or old V2
// Detect version by structure
const version = detectConditionsVersion(this.formData.query_condition.conditions);
if (version === 0) {
// V0: Flat array format - convert to V2
// V0 had implicit AND between all conditions (no groups)
this.formData.query_condition.conditions = ensureIds(convertV0ToV2(this.formData.query_condition.conditions));
} else if (version === 1) {
// V1 format - need to convert to V2
// First check if it's BE format ({and: [...]}) or FE format ({label, items, groupId})
if (this.formData.query_condition.conditions.and || this.formData.query_condition.conditions.or) {
// V1 Backend format - convert to V2
this.formData.query_condition.conditions = ensureIds(convertV1BEToV2(this.formData.query_condition.conditions));
} else if (this.formData.query_condition.conditions.label && this.formData.query_condition.conditions.items) {
// V1 Frontend format - convert to V2
// this wont be executed atleast once but we are keeping it incase any FE logics got saved already
this.formData.query_condition.conditions = ensureIds(convertV1ToV2(this.formData.query_condition.conditions));
}
} else {
// V2 format - ensure all IDs exist
this.formData.query_condition.conditions = ensureIds(this.formData.query_condition.conditions);
}
} else if (Array.isArray(this.formData.query_condition.conditions) && this.formData.query_condition.conditions.length > 0) {
// V0: Flat array of conditions - convert to V2
// This handles the case where conditions is directly an array at the top level
this.formData.query_condition.conditions = ensureIds(convertV0ToV2(this.formData.query_condition.conditions));
}
else if (this.formData.query_condition.conditions == null || this.formData.query_condition.conditions == undefined || this.formData.query_condition.conditions.length == 0 || Object.keys(this.formData.query_condition.conditions).length == 0){
// No conditions - create empty V2 group
this.formData.query_condition.conditions = {
filterType: 'group',
logicalOperator: 'AND',
conditions: [],
groupId: getUUID(),
label: 'and',
items: []
}
}
},
@@ -1654,8 +1707,14 @@ export default defineComponent({
}
}
// Transform the form data to the backend format
payload.query_condition.conditions = this.transformFEToBE(this.formData.query_condition.conditions);
// VERSION HANDLING
// All conditions are converted to V2 format during data loading,
// so we always wrap with version field for backend
// Backend expects: query_condition: { conditions: { version: 2, conditions: {...} } }
payload.query_condition.conditions = {
version: 2,
conditions: this.formData.query_condition.conditions,
};
if (this.beingUpdated) {
payload.folder_id = this.router.currentRoute.value.query.folder || "default";
+61 -59
View File
@@ -1325,66 +1325,27 @@ export default defineComponent({
//localAllAlerts is the alerts that we use to store
localAllAlerts = localAllAlerts.map((data: any) => {
let conditions = "--";
//this is deprecated because we are using the new condition format
//the new format looks like this
// {
// "or": [
// {
// "column": "_timestamp",
// "operator": "<=",
// "value": "100",
// "ignore_case": false
// },
// {
// "column": "job",
// "operator": "not_contains",
// "value": "12",
// "ignore_case": true
// },
// {
// "or": [
// {
// "column": "job",
// "operator": "contains",
// "value": "1222",
// "ignore_case": true
// },
// {
// "column": "level",
// "operator": "not_contains",
// "value": "dsff",
// "ignore_case": true
// },
// {
// "or": [
// {
// "column": "job",
// "operator": "=",
// "value": "111",
// "ignore_case": true
// },
// {
// "column": "level",
// "operator": "contains",
// "value": "1222",
// "ignore_case": true
// }
// ]
// },
// {
// "column": "log",
// "operator": "!=",
// "value": "33",
// "ignore_case": true
// }
// ]
// }
// ]
// }
//converted into
// (_timestamp <= '100' OR job not_contains '12' OR (job contains '1222' OR level not_contains 'dsff' OR (job = '111' OR level contains '1222') OR log != '33'))
// Handle different condition formats based on structure
if (Object.keys(data.condition).length && data.condition.type == 'custom') {
conditions = transformToExpression(data.condition.conditions)
const conditionData = data.condition.conditions;
// Detect format by structure, not by version field (more reliable)
if (conditionData?.filterType === 'group') {
// V2 format: {filterType: "group", logicalOperator: "AND", conditions: [...]}
// This works whether or not there's a version field
conditions = transformV2ToExpression(conditionData);
} else if (conditionData?.version === 2 && conditionData?.conditions) {
// V2 format with version wrapper: {version: 2, conditions: {filterType: "group", ...}}
conditions = transformV2ToExpression(conditionData.conditions);
} else if (conditionData?.or || conditionData?.and) {
// V1 format: {or: [...]} or {and: [...]}
conditions = transformToExpression(conditionData);
} else {
// V0 format (legacy query_condition) or other formats
// Try to display as-is if it's a string, otherwise show placeholder
conditions = typeof conditionData === 'string' ? conditionData : '--';
}
} else if (data.condition.sql) {
conditions = data.condition.sql;
} else if (data.condition.promql) {
@@ -2383,6 +2344,7 @@ export default defineComponent({
folderIdToBeCloned.value = folderId.value;
};
// V1 format: {or: [...]} or {and: [...]}
function transformToExpression(data: any, wrap = true): any {
if (!data) return null;
@@ -2406,6 +2368,46 @@ export default defineComponent({
const joined = parts.join(` ${label} `);
return wrap ? `(${joined})` : joined;
}
// V2 format: {filterType: "group", logicalOperator: "AND", conditions: [...]}
function transformV2ToExpression(group: any, isRoot = true): string {
if (!group || !group.conditions || group.conditions.length === 0) {
return '';
}
const parts: string[] = [];
group.conditions.forEach((item: any, index: number) => {
if (item.filterType === 'group') {
// Nested group - recursively build expression
const nestedExpr = transformV2ToExpression(item, false);
if (nestedExpr) {
// Use parent group's logicalOperator for the operator before nested groups
if (index > 0) {
parts.push(`${group.logicalOperator} (${nestedExpr})`);
} else {
parts.push(`(${nestedExpr})`);
}
}
} else {
// Condition
const column = item.column || 'field';
const operator = item.operator || '=';
const value = typeof item.value === 'string' ? `'${item.value}'` : item.value;
const conditionStr = `${column} ${operator} ${value}`;
// Add operator before condition (except for first item)
if (index > 0 && item.logicalOperator) {
parts.push(`${item.logicalOperator} ${conditionStr}`);
} else {
parts.push(conditionStr);
}
}
});
const joined = parts.join(' ');
return isRoot ? `(${joined})` : joined;
}
//this function is used to filter the alerts by the local search not the global search
//this will be used when the user is searching for the alerts in the same folder
const filterAlertsByQuery = (query: string) => {
@@ -32,6 +32,7 @@ describe('FilterCondition.vue Branch Coverage', () => {
column: '',
operator: '',
value: '',
logicalOperator: 'AND',
},
streamFields: [
{ label: 'Field 1', value: 'field1' },
@@ -41,6 +42,7 @@ describe('FilterCondition.vue Branch Coverage', () => {
index: 0,
label: 'AND',
depth: 0,
isFirstInGroup: true,
};
beforeEach(() => {
@@ -68,12 +70,17 @@ describe('FilterCondition.vue Branch Coverage', () => {
expect(labelElement.text().trim()).toBe('if');
});
it('should show computed label when index is not 0', async () => {
it('should show lowercase operator when not first in group', async () => {
const wrapper = mount(FilterCondition, {
props: {
...defaultProps,
condition: {
...defaultProps.condition,
logicalOperator: 'OR',
},
index: 1, // Branch condition: index != 0
depth: 0,
isFirstInGroup: false,
label: 'OR',
},
global: {
@@ -84,17 +91,20 @@ describe('FilterCondition.vue Branch Coverage', () => {
},
});
// Branch: computedLabel (when index != 0)
const labelElement = wrapper.find('.tw-text-sm');
expect(labelElement.text().trim()).toBe('OR');
// Branch: computedLabel (when not first in group) - CSS lowercase applied
const labelElement = wrapper.find('.tw-lowercase');
// Text content is still 'OR' but CSS applies text-transform: lowercase
expect(labelElement.exists()).toBe(true);
expect(labelElement.text().trim()).toBe('OR'); // Content is uppercase, CSS transforms to lowercase visually
});
it('should show "if" when index is 0 regardless of depth', async () => {
it('should show "if" only for first condition in root group (index 0, depth 0)', async () => {
const wrapper = mount(FilterCondition, {
props: {
...defaultProps,
index: 0,
depth: 1, // Even with depth != 0, index 0 should show "if"
depth: 0, // V2: "if" only shown for root group first condition
isFirstInGroup: true,
label: 'AND',
},
global: {
@@ -105,10 +115,34 @@ describe('FilterCondition.vue Branch Coverage', () => {
},
});
// Branch: index == 0 shows "if" regardless of depth (updated logic)
// Branch: index == 0 && depth == 0 shows "if"
const labelElement = wrapper.find('.tw-text-sm');
expect(labelElement.text().trim()).toBe('if');
});
it('should show empty space for first condition in nested groups', async () => {
const wrapper = mount(FilterCondition, {
props: {
...defaultProps,
index: 0,
depth: 1, // Nested group
isFirstInGroup: true,
label: 'AND',
},
global: {
plugins: [Quasar, mockI18n],
provide: {
store: mockStore,
},
},
});
// Branch: isFirstInGroup && depth > 0 shows empty space (no label)
const labelContainer = wrapper.find('.tw-min-w-\\[60px\\]');
expect(labelContainer.exists()).toBe(true);
// Should not contain operator label or "if"
expect(labelContainer.text().trim()).toBe('');
});
});
@@ -371,11 +405,16 @@ describe('FilterCondition.vue Branch Coverage', () => {
});
describe('Computed Label Branch Coverage', () => {
it('should return the correct computed label', async () => {
it('should return the correct computed label when not first in group', async () => {
const wrapper = mount(FilterCondition, {
props: {
...defaultProps,
label: 'CUSTOM_LABEL',
isFirstInGroup: false, // V2: Only show label when not first
condition: {
...defaultProps.condition,
logicalOperator: 'AND',
},
},
global: {
plugins: [Quasar, mockI18n],
@@ -385,8 +424,26 @@ describe('FilterCondition.vue Branch Coverage', () => {
},
});
// Test computedLabel computed property
expect((wrapper.vm as any).computedLabel).toBe('CUSTOM_LABEL');
// Test computedLabel computed property - should return condition's logicalOperator
expect((wrapper.vm as any).computedLabel).toBe('AND');
});
it('should return empty string for first condition in group', async () => {
const wrapper = mount(FilterCondition, {
props: {
...defaultProps,
isFirstInGroup: true, // First condition in group
},
global: {
plugins: [Quasar, mockI18n],
provide: {
store: mockStore,
},
},
});
// Test computedLabel computed property - should return empty string
expect((wrapper.vm as any).computedLabel).toBe('');
});
});
});
+65 -6
View File
@@ -1,9 +1,39 @@
<template>
<div class=" tw-flex tw-items-start tw-gap-2 tw-flex-no-wrap ">
<div class="tw-text-sm tw-w-[20px] tw-mr-2 tw-mt-2">
{{
index == 0 ? 'if' : computedLabel
}}
<div class=" tw-flex tw-items-start tw-gap-1 tw-flex-no-wrap ">
<!-- V2: Fixed-width left column for alignment -->
<!-- All conditions have the same width for the operator/label section -->
<div class="tw-flex tw-items-center tw-justify-center tw-mt-2 tw-min-w-[60px]">
<!-- First condition in root group: show "if" centered -->
<template v-if="index === 0 && depth === 0">
<span class="tw-text-sm">if</span>
</template>
<!-- First condition in nested groups: empty space for alignment -->
<template v-else-if="isFirstInGroup">
<!-- Empty space to maintain alignment -->
</template>
<!-- Other conditions: show operator + toggle button -->
<template v-else>
<span class="tw-text-sm tw-font-medium tw-min-w-[30px] tw-lowercase">
{{ computedLabel }}
</span>
<!-- Toggle AND/OR button after label -->
<q-btn
data-test="alert-conditions-toggle-operator-btn"
flat
dense
round
size="sm"
icon="restart_alt"
class="tw-h-[26px] tw-flex-shrink-0 operator-toggle-btn"
@click="toggleOperator"
>
<q-tooltip>
Toggle between and/or
</q-tooltip>
</q-btn>
</template>
</div>
<div
data-test="alert-conditions-select-column"
@@ -112,6 +142,11 @@
default: '',
required: false,
},
isFirstInGroup: {
type: Boolean,
default: false,
required: false,
},
});
import { ref, computed } from "vue";
@@ -151,9 +186,26 @@ const addGroupApiHeader = (groupId: string) => {
};
const computedLabel = computed(() => {
// V2: First condition in any group should not show AND/OR operator
// Only subsequent conditions show the operator
if (props.isFirstInGroup) {
return ''; // No operator for first condition in group
}
// V2: Use condition's logicalOperator if available
if (props.condition.logicalOperator) {
return props.condition.logicalOperator;
}
return props.label;
});
// Toggle operator between AND/OR for this condition
const toggleOperator = () => {
if (props.condition.logicalOperator) {
props.condition.logicalOperator = props.condition.logicalOperator === 'AND' ? 'OR' : 'AND';
emits('input:update', 'conditions', props.condition);
}
};
const computedInputWidth = computed(() => {
// If custom width is provided, use it; otherwise use default responsive width
return props.inputWidth || (store.state.isAiChatEnabled ? '' : 'xl:tw-min-w-[200px] lg:tw-min-w-[90px] lg:tw-w-fit');
@@ -180,6 +232,13 @@ const filterColumns = (val: string, update: Function) => {
};
</script>
<style >
<style scoped>
.operator-toggle-btn {
color: var(--o2-primary-btn-bg) !important;
}
.operator-toggle-btn:hover {
background-color: rgba(var(--o2-primary-btn-bg-rgb), 0.1) !important;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+82 -54
View File
@@ -30,25 +30,30 @@
backgroundColor: computedStyleMap
}"
>
<div class="tw-w-fit condition-tabs el-border">
<AppTabs
data-test="scheduled-alert-tabs"
:tabs="tabOptions"
class="tw-h-[20px] custom-tabs-selection-container"
v-model:active-tab="label"
@update:active-tab="toggleLabel"
/>
<!-- V2: Group-level toggle only for nested groups (depth > 0) -->
<!-- Root group (depth 0) doesn't need toggle - its logicalOperator is dummy -->
<div v-if="depth > 0" class="tw-w-fit condition-tabs el-border">
<AppTabs
data-test="scheduled-alert-tabs"
:tabs="tabOptions"
class="tw-h-[20px] custom-tabs-selection-container"
v-model:active-tab="label"
@update:active-tab="toggleLabel"
/>
</div>
<!-- Spacer for root group to maintain consistent spacing -->
<div v-else class="tw-h-[14px]"></div>
<!-- Group content -->
<div v-if="isOpen" class="tw-overflow-x-auto group-container" :class="store.state.theme === 'dark' ? 'dark-mode-group' : 'light-mode-group'">
<!-- Items in group -->
<div class="tw-ml-2 tw-whitespace-nowrap " v-for="(item, index) in props.group.items" :key="index">
<!-- Items in group (V2 uses 'conditions' array) -->
<div class="tw-ml-2 tw-whitespace-nowrap " v-for="(item, index) in props.group.conditions" :key="index">
<FilterGroup
v-if="isGroup(item)"
:group="item"
:depth="depth + 1"
:is-first-group="index === 0 && depth === 0"
@add-condition="emit('add-condition', $event)"
@add-group="emit('add-group', $event)"
@remove-group="emit('remove-group', $event)"
@@ -67,11 +72,12 @@
:stream-fields="props.streamFields"
@input:update="(name, field) => inputUpdate(name, field)"
:index="index"
:label="group.label"
:label="group.logicalOperator?.toLowerCase() || 'and'"
:depth="depth"
:input-width="props.conditionInputWidth"
:is-first-in-group="index === 0"
/>
<div class="tw-mb-1" v-if="!(props.disableFirstCondition && index === 0 && depth === 0)">
<div class="tw-mb-3" v-if="!(props.disableFirstCondition && index === 0 && depth === 0)">
<q-btn data-test="alert-conditions-delete-condition-btn" icon="close" size="10px" flat border-less @click="removeCondition(item.id)" />
</div>
</div>
@@ -182,6 +188,11 @@
default: true,
required: false,
},
isFirstGroup: {
type: Boolean,
default: false,
required: false,
},
});
const emit = defineEmits<{
@@ -198,7 +209,8 @@
const store = useStore();
const label = ref(props.group.label);
// V2: Use logicalOperator (AND/OR) instead of label (and/or)
const label = ref(props.group.logicalOperator?.toLowerCase() || 'and');
const confirmDialog = ref({
show: false,
@@ -211,72 +223,87 @@
// Watch for prop changes to keep groups in sync with parent
watch(() => props.group, (newGroup) => {
groups.value = newGroup;
label.value = newGroup.label;
// V2: Use logicalOperator instead of label
label.value = newGroup.logicalOperator?.toLowerCase() || 'and';
}, { deep: true });
const tabOptions = computed(() => [
{
label: "OR",
value: "or",
disabled: hasOnlyOneCondition.value,
tooltipLabel: hasOnlyOneCondition.value ? "Add at least one more condition to enable AND/OR toggle" : undefined,
},
{
label: "AND",
value: "and",
disabled: hasOnlyOneCondition.value,
tooltipLabel: hasOnlyOneCondition.value ? "Add at least one more condition to enable AND/OR toggle" : undefined,
},
]);
function isGroup(item: any) {
return item && item.items && Array.isArray(item.items);
// V2: Check for filterType === "group" with conditions array
if (item && item.filterType === "group" && item.conditions && Array.isArray(item.conditions)) {
return true;
}
// V1 compatibility: Check for items array (legacy)
if (item && item.items && Array.isArray(item.items) && item.groupId) {
return true;
}
return false;
}
const addCondition = (groupId: string) => {
groups.value.items.push({
// V2: Create condition with filterType and logicalOperator
const newCondition = {
filterType: 'condition',
column: '',
operator: '=',
value: '',
ignore_case: true,
values: [],
logicalOperator: groups.value.logicalOperator || 'AND',
id: getUUID(),
});
};
groups.value.conditions.push(newCondition);
emit('add-condition', groups.value);
};
const addGroup = (groupId: string) => {
groups.value.items.push({
// V2: Create group with filterType, logicalOperator, and conditions array
const newGroup = {
filterType: 'group',
logicalOperator: 'OR',
groupId: getUUID(),
label: 'or',
items: [
conditions: [
{
filterType: 'condition',
column: '',
operator: '=',
value: '',
ignore_case: true,
values: [],
logicalOperator: 'OR',
id: getUUID(),
}
]
});
};
groups.value.conditions.push(newGroup);
emit('add-group', groups.value);
};
// Toggle AND/OR
const toggleLabel = (newLabel?: string) => {
// V2: Use logicalOperator instead of label
// If newLabel is provided, use it; otherwise toggle
if (newLabel) {
groups.value.label = newLabel;
groups.value.logicalOperator = newLabel.toUpperCase();
} else {
groups.value.label = groups.value.label === 'and' ? 'or' : 'and';
groups.value.logicalOperator = groups.value.logicalOperator === 'AND' ? 'OR' : 'AND';
}
emit('add-group', groups.value); // optional, sync with parent
emit('input:update', 'conditions', groups.value);
};
const removeCondition = (id: string) => {
// V2: Use conditions array instead of items
// First, check what will happen after removing this condition
const itemsAfterRemoval = groups.value.items.filter((item: any) => item.id !== id);
const itemsAfterRemoval = groups.value.conditions.filter((item: any) => item.id !== id);
const hasConditionsAfterRemoval = itemsAfterRemoval.some((item: any) => !isGroup(item));
// Count sub-groups that will be deleted
@@ -303,14 +330,15 @@
};
const performRemoveCondition = (id: string) => {
groups.value.items = groups.value.items.filter((item: any) => item.id !== id);
// V2: Use conditions array instead of items
groups.value.conditions = groups.value.conditions.filter((item: any) => item.id !== id);
// Check if there are any conditions left (not sub-groups)
const hasConditions = groups.value.items.some((item: any) => !isGroup(item));
const hasConditions = groups.value.conditions.some((item: any) => !isGroup(item));
if (!hasConditions) {
// No conditions left, clear all items (including sub-groups) and remove this entire group
groups.value.items = [];
groups.value.conditions = [];
emit('remove-group', props.group.groupId);
} else {
emit('add-group', groups.value); // update as usual
@@ -322,12 +350,13 @@
};
const reorderItems = () => {
// V2: Use conditions array instead of items
// Separate conditions and groups
const conditions = groups.value.items.filter((item: any) => !isGroup(item));
const subGroups = groups.value.items.filter((item: any) => isGroup(item));
const conditions = groups.value.conditions.filter((item: any) => !isGroup(item));
const subGroups = groups.value.conditions.filter((item: any) => isGroup(item));
// Reorder: conditions first, then groups
groups.value.items = [...conditions, ...subGroups];
groups.value.conditions = [...conditions, ...subGroups];
// Emit update
emit('add-group', groups.value);
@@ -392,18 +421,21 @@ const computedOpacity = computed(() => {
// Build preview string recursively
const buildPreviewString = (group: any): string => {
if (!group || !group.items || group.items.length === 0) {
// V2: Use conditions array instead of items
if (!group || !group.conditions || group.conditions.length === 0) {
return '';
}
const parts: string[] = [];
group.items.forEach((item: any) => {
group.conditions.forEach((item: any, index: number) => {
let conditionStr = '';
if (isGroup(item)) {
// Nested group - recursively build its preview
const nestedPreview = buildPreviewString(item);
if (nestedPreview) {
parts.push(`(${nestedPreview})`);
conditionStr = `(${nestedPreview})`;
}
} else {
// Condition - show full condition: column operator value
@@ -412,13 +444,19 @@ const buildPreviewString = (group: any): string => {
const value = item.value !== undefined && item.value !== null && item.value !== ''
? `'${item.value}'`
: "''";
parts.push(`${column} ${operator} ${value}`);
conditionStr = `${column} ${operator} ${value}`;
}
// Add logical operator before condition (except for first condition)
if (index > 0 && item.logicalOperator) {
parts.push(`${item.logicalOperator.toLowerCase()} ${conditionStr}`);
} else {
parts.push(conditionStr);
}
});
// Join with the group's label (AND/OR)
const operator = (group.label || 'or').toUpperCase();
return parts.join(` ${operator} `);
// Join all parts with spaces (operators are already included)
return parts.join(' ');
};
// Computed preview string
@@ -428,15 +466,6 @@ const previewString = computed(() => {
return preview ? `(${preview})` : '';
});
// Check if group has only one condition and no sub-groups (to disable AND/OR toggle)
const hasOnlyOneCondition = computed(() => {
const conditions = groups?.value?.items?.filter((item: any) => !isGroup(item));
const subGroups = groups?.value?.items?.filter((item: any) => isGroup(item));
// Disable only if there's exactly 1 condition AND no sub-groups
return conditions?.length === 1 && subGroups?.length === 0;
});
// Expose functions for testing
defineExpose({
isGroup,
@@ -451,7 +480,6 @@ defineExpose({
hslToCSS,
computedStyleMap,
computedOpacity,
hasOnlyOneCondition,
groups,
label,
isOpen,
+104 -11
View File
@@ -414,6 +414,12 @@ import alertsService from "../../services/alerts";
import useStreams from "@/composables/useStreams";
import BaseImport from "../common/BaseImport.vue";
import SelectFolderDropDown from "../common/sidebar/SelectFolderDropDown.vue";
import {
detectConditionsVersion,
convertV0ToV2,
convertV1ToV2,
convertV1BEToV2,
} from "@/utils/alerts/alertDataTransforms";
export default defineComponent({
name: "ImportAlert",
@@ -732,8 +738,44 @@ export default defineComponent({
// 6. Validate 'query_condition' field
if (input.query_condition && input.query_condition.conditions) {
const validateCondition = (condition: any) => {
// Check if it's a simple condition
const validateV2Condition = (item: any): boolean => {
if (item.filterType === 'group') {
// V2 group - validate it has conditions array
if (!Array.isArray(item.conditions)) {
alertErrors.push(
`Alert - ${index}: V2 group must have a conditions array.`,
);
return false;
}
// Recursively validate nested conditions
return item.conditions.every((nestedItem: any) => validateV2Condition(nestedItem));
} else if (item.filterType === 'condition') {
// V2 condition - validate required fields
if (!item.column || !item.operator || item.value === undefined) {
alertErrors.push(
`Alert - ${index}: V2 condition must have column, operator, and value.`,
);
return false;
}
// Validate operator for custom type
if (
input.query_condition.type === "custom" &&
!["=", ">", "<", ">=", "<=", "Contains", "NotContains","contains","not_contains"].includes(
item.operator,
)
) {
alertErrors.push(
`Alert - ${index}: Invalid operator '${item.operator}'. Allowed: '=', '>', '<', '>=', '<=', 'Contains', 'NotContains'.`,
);
return false;
}
return true;
}
return true;
};
const validateV1Condition = (condition: any) => {
// Check if it's a simple condition (V0/V1 format)
if (condition.column && condition.operator && condition.value !== undefined) {
if (
input.query_condition.type === "custom" &&
@@ -748,7 +790,7 @@ export default defineComponent({
return;
}
// Check if it's a nested condition with 'and' or 'or'
// Check if it's a nested condition with 'and' or 'or' (V1 format)
if (condition.and || condition.or) {
const conditions = condition.and || condition.or;
if (!Array.isArray(conditions)) {
@@ -757,7 +799,7 @@ export default defineComponent({
);
return;
}
conditions.forEach(validateCondition);
conditions.forEach(validateV1Condition);
return;
}
@@ -767,19 +809,35 @@ export default defineComponent({
);
};
// Handle both array format and nested format
if (Array.isArray(input.query_condition.conditions)) {
// Old format - array of conditions
input.query_condition.conditions.forEach((condition:any) => {
if (!condition.column || !condition.operator || !condition.value) {
let conditionsToValidate = input.query_condition.conditions;
// Check if conditions is wrapped with version field (new format from backend)
if (conditionsToValidate.version !== undefined) {
// Wrapped format: { version: 2, conditions: {...} }
conditionsToValidate = conditionsToValidate.conditions;
}
// Determine format and validate accordingly
if (Array.isArray(conditionsToValidate)) {
// V0 format - flat array of conditions
conditionsToValidate.forEach((condition:any) => {
if (!condition.column || !condition.operator || condition.value === undefined) {
alertErrors.push(
`Alert - ${index}: Each query condition must have 'column', 'operator', and 'value'.`,
);
}
});
} else if (conditionsToValidate.filterType === 'group') {
// V2 format - new structure with filterType
validateV2Condition(conditionsToValidate);
} else if (conditionsToValidate.and || conditionsToValidate.or) {
// V1 format - nested conditions with and/or
validateV1Condition(conditionsToValidate);
} else {
// New format - nested conditions with and/or
validateCondition(input.query_condition.conditions);
// Unknown format
alertErrors.push(
`Alert - ${index}: Unrecognized query condition format.`,
);
}
}
// 7. Validate 'sql' and 'promql'
@@ -992,6 +1050,41 @@ export default defineComponent({
input.owner = store.state.userInfo.email;
input.last_edited_by = store.state.userInfo.email;
// VERSION DETECTION AND CONVERSION
// Convert V0 and V1 conditions to V2 format before creating alert
if (input.query_condition && input.query_condition.conditions) {
let convertedConditions = input.query_condition.conditions;
// Check if it's already wrapped with version
if (convertedConditions.version === 2 || convertedConditions.version === "2") {
// Already wrapped, extract the inner conditions for detection
convertedConditions = convertedConditions.conditions;
}
const version = detectConditionsVersion(convertedConditions);
if (version === 0) {
// V0: Flat array format - convert to V2
convertedConditions = convertV0ToV2(convertedConditions);
} else if (version === 1) {
// V1: Tree-based format - convert to V2
if (convertedConditions.and || convertedConditions.or) {
// V1 Backend format
convertedConditions = convertV1BEToV2(convertedConditions);
} else if (convertedConditions.label && convertedConditions.items) {
// V1 Frontend format
convertedConditions = convertV1ToV2(convertedConditions);
}
}
// For version === 2, convertedConditions is already in correct format
// Backend expects: query_condition: { conditions: { version: 2, conditions: {...} } }
input.query_condition.conditions = {
version: 2,
conditions: convertedConditions,
};
}
try {
await alertsService.create_by_alert_id(
store.state.selectedOrganization.identifier,
+140 -14
View File
@@ -442,6 +442,12 @@ import destinationService from "@/services/alert_destination";
import jstransform from "@/services/jstransform";
import usePipelines from "@/composables/usePipelines";
import BaseImport from "../common/BaseImport.vue";
import {
detectConditionsVersion,
convertV0ToV2,
convertV1ToV2,
convertV1BEToV2,
} from "@/utils/alerts/alertDataTransforms";
export default defineComponent({
name: "ImportPipeline",
@@ -1122,23 +1128,106 @@ export default defineComponent({
};
const validateConditionNode = (input: any) => {
const isValid = !input.nodes.some((node: any) => {
return (
node.io_type == "default" &&
node.data.node_type == "condition" &&
!node.data.conditions
);
let hasErrors = false;
input.nodes.forEach((node: any, nodeIndex: number) => {
if (node.io_type === "default" && node.data.node_type === "condition") {
// Check if conditions exist
if (!node.data.conditions) {
pipelineErrors.push({
message: `Pipeline - ${index}, Node ${nodeIndex}: Condition is required`,
field: "empty_condition",
});
hasErrors = true;
return;
}
// Validate the condition format (V0, V1, or V2)
const validateV2Condition = (item: any): boolean => {
if (item.filterType === 'group') {
if (!Array.isArray(item.conditions)) {
pipelineErrors.push({
message: `Pipeline - ${index}, Node ${nodeIndex}: V2 group must have a conditions array.`,
field: "condition_format",
});
return false;
}
return item.conditions.every((nestedItem: any) => validateV2Condition(nestedItem));
} else if (item.filterType === 'condition') {
if (!item.column || !item.operator || item.value === undefined) {
pipelineErrors.push({
message: `Pipeline - ${index}, Node ${nodeIndex}: V2 condition must have column, operator, and value.`,
field: "condition_format",
});
return false;
}
return true;
}
return true;
};
const validateV1Condition = (condition: any): boolean => {
if (condition.column && condition.operator && condition.value !== undefined) {
return true;
}
if (condition.and || condition.or) {
const conditions = condition.and || condition.or;
if (!Array.isArray(conditions)) {
pipelineErrors.push({
message: `Pipeline - ${index}, Node ${nodeIndex}: V1 'and'/'or' conditions must be an array.`,
field: "condition_format",
});
return false;
}
return conditions.every((cond: any) => validateV1Condition(cond));
}
return false;
};
let conditionsToValidate = node.data.conditions;
// Determine format and validate
if (Array.isArray(conditionsToValidate)) {
// V0 format - flat array
const valid = conditionsToValidate.every((condition: any) => {
return condition.column && condition.operator && condition.value !== undefined;
});
if (!valid) {
pipelineErrors.push({
message: `Pipeline - ${index}, Node ${nodeIndex}: V0 format - each condition must have column, operator, and value.`,
field: "condition_format",
});
hasErrors = true;
}
} else if (conditionsToValidate.filterType === 'group') {
// V2 format
if (!validateV2Condition(conditionsToValidate)) {
hasErrors = true;
}
} else if (conditionsToValidate.and || conditionsToValidate.or) {
// V1 format
if (!validateV1Condition(conditionsToValidate)) {
pipelineErrors.push({
message: `Pipeline - ${index}, Node ${nodeIndex}: Invalid V1 condition format.`,
field: "condition_format",
});
hasErrors = true;
}
} else {
pipelineErrors.push({
message: `Pipeline - ${index}, Node ${nodeIndex}: Unrecognized condition format.`,
field: "condition_format",
});
hasErrors = true;
}
}
});
return isValid;
return !hasErrors;
};
validateFunctionNode(input, index);
//validate condition node
if (!validateConditionNode(input)) {
pipelineErrors.push({
message: `Pipeline - ${index}: Condition is required`,
field: "empty_condition",
});
}
//validate condition node - errors are added inside the function
validateConditionNode(input);
const isValidRemoteDestination = validateRemoteDestination(input);
if (!isValidRemoteDestination) {
pipelineErrors.push({
@@ -1165,6 +1254,43 @@ export default defineComponent({
};
const createPipeline = async (input: any, index: any) => {
// VERSION DETECTION AND CONVERSION
// Convert V0 and V1 conditions to V2 format in condition nodes before creating pipeline
if (input.nodes && Array.isArray(input.nodes)) {
input.nodes.forEach((node: any) => {
if (node.data?.node_type === "condition" && node.data?.conditions) {
let convertedConditions = node.data.conditions;
// Check if version field is already present (V2)
if (node.data.version !== 2 && node.data.version !== "2") {
const version = detectConditionsVersion(convertedConditions);
if (version === 0) {
// V0: Flat array format - convert to V2
convertedConditions = convertV0ToV2(convertedConditions);
} else if (version === 1) {
// V1: Tree-based format - convert to V2
if (convertedConditions.and || convertedConditions.or) {
// V1 Backend format
convertedConditions = convertV1BEToV2(convertedConditions);
} else if (convertedConditions.label && convertedConditions.items) {
// V1 Frontend format
convertedConditions = convertV1ToV2(convertedConditions);
}
}
// For version === 2, convertedConditions is already in correct format
// Update node data with converted conditions
node.data.conditions = convertedConditions;
}
// Ensure version is set as integer (matching Condition.vue structure)
// Backend expects: node.data = { node_type: "condition", version: 2, conditions: {...} }
node.data.version = 2;
}
});
}
try {
await pipelinesService.createPipeline({
data: input,
@@ -168,9 +168,9 @@ describe("Condition Component", () => {
const conditionGroup = wrapper.vm.conditionGroup;
expect(conditionGroup).toBeDefined();
expect(conditionGroup.groupId).toBeDefined();
expect(conditionGroup.label).toBe("and");
expect(conditionGroup.items).toBeDefined();
expect(Array.isArray(conditionGroup.items)).toBe(true);
expect(conditionGroup.logicalOperator).toBe('AND');
expect(conditionGroup.conditions).toBeDefined();
expect(Array.isArray(conditionGroup.conditions)).toBe(true);
});
it.skip("loads fields from input stream node", async () => {
@@ -191,9 +191,10 @@ describe("Condition Component", () => {
it("updates condition group when conditions are modified", () => {
const initialGroupId = wrapper.vm.conditionGroup.groupId;
const updatedGroup = {
groupId: initialGroupId,
label: "and",
items: [
filterType: 'group',
groupId: initialGroupId,
logicalOperator: 'AND',
conditions: [
{
column: "level",
operator: "=",
@@ -205,24 +206,29 @@ describe("Condition Component", () => {
};
wrapper.vm.updateGroup(updatedGroup);
expect(wrapper.vm.conditionGroup.items).toHaveLength(1);
expect(wrapper.vm.conditionGroup.items[0].column).toBe("level");
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(1);
expect(wrapper.vm.conditionGroup.conditions[0].column).toBe("level");
});
it("removes condition group by id", () => {
// Create nested structure
const parentGroup = wrapper.vm.conditionGroup;
const childGroupId = "child-group-id";
parentGroup.items = [
parentGroup.conditions = [
{
filterType: 'group',
groupId: childGroupId,
label: "or",
items: []
logicalOperator: 'OR',
conditions: []
}
];
wrapper.vm.removeConditionGroup(childGroupId);
expect(wrapper.vm.conditionGroup.items).toHaveLength(0);
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(0);
});
});
@@ -230,9 +236,10 @@ describe("Condition Component", () => {
it("prevents saving when no conditions are present", async () => {
// Empty group with no items
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "and",
items: []
filterType: 'group',
groupId: "test-group",
logicalOperator: 'AND',
conditions: []
};
await wrapper.vm.saveCondition();
@@ -248,9 +255,10 @@ describe("Condition Component", () => {
it("saves condition with valid data", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "and",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'AND',
conditions: [
{
column: "level",
operator: "=",
@@ -266,16 +274,19 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
node_type: "condition",
conditions: {
and: [
{
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'AND',
conditions: expect.arrayContaining([
expect.objectContaining({
column: "level",
operator: "=",
value: "error",
ignore_case: false
}
]
}
})
])
})
})
);
expect(wrapper.emitted()["cancel:hideform"]).toBeTruthy();
@@ -285,7 +296,7 @@ describe("Condition Component", () => {
describe("Dialog Handling", () => {
it("opens cancel dialog when form has changes", async () => {
// Modify the condition group
wrapper.vm.conditionGroup.items = [
wrapper.vm.conditionGroup.conditions = [
{
column: "level",
operator: "=",
@@ -355,9 +366,9 @@ describe("Condition Component", () => {
it("loads existing condition data in edit mode", () => {
expect(wrapper.vm.conditionGroup).toBeDefined();
expect(wrapper.vm.conditionGroup.label).toBe("and");
expect(wrapper.vm.conditionGroup.items).toHaveLength(1);
expect(wrapper.vm.conditionGroup.items[0]).toEqual(
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('AND');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(1);
expect(wrapper.vm.conditionGroup.conditions[0]).toEqual(
expect.objectContaining({
column: "level",
operator: "=",
@@ -418,9 +429,10 @@ describe("Condition Component", () => {
describe("Condition Validation", () => {
it("validates empty value condition", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "and",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'AND',
conditions: [
{
column: "message",
operator: "!=",
@@ -434,8 +446,12 @@ describe("Condition Component", () => {
await wrapper.vm.saveCondition();
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
and: expect.arrayContaining([
filterType: 'group',
logicalOperator: 'AND',
conditions: expect.arrayContaining([
expect.objectContaining({ value: '""' })
])
})
@@ -445,9 +461,10 @@ describe("Condition Component", () => {
it("validates null value condition", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "and",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'AND',
conditions: [
{
column: "message",
operator: "!=",
@@ -461,8 +478,12 @@ describe("Condition Component", () => {
await wrapper.vm.saveCondition();
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
and: expect.arrayContaining([
filterType: 'group',
logicalOperator: 'AND',
conditions: expect.arrayContaining([
expect.objectContaining({ value: "null" })
])
})
@@ -474,9 +495,10 @@ describe("Condition Component", () => {
describe("Multiple Conditions", () => {
it("handles multiple conditions correctly", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "and",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'AND',
conditions: [
{
column: "level",
operator: "=",
@@ -498,21 +520,26 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
conditions: {
and: expect.arrayContaining([
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'AND',
conditions: expect.arrayContaining([
expect.objectContaining({ column: "level", value: "error" }),
expect.objectContaining({ column: "message", value: "failed" })
])
}
})
})
);
});
it("handles OR conditions with nested groups", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "or",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'OR',
conditions: [
{
column: "level",
operator: "=",
@@ -534,12 +561,16 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
conditions: {
or: expect.arrayContaining([
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'OR',
conditions: expect.arrayContaining([
expect.objectContaining({ column: "level", value: "error" }),
expect.objectContaining({ column: "level", value: "critical" })
])
}
})
})
);
});
@@ -548,9 +579,10 @@ describe("Condition Component", () => {
describe("Edge Cases", () => {
it("prevents saving condition with empty column", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "and",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'AND',
conditions: [
{
column: "",
operator: "=",
@@ -574,9 +606,10 @@ describe("Condition Component", () => {
it("prevents saving condition with empty operator", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "and",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'AND',
conditions: [
{
column: "level",
operator: "",
@@ -600,9 +633,10 @@ describe("Condition Component", () => {
it("preserves nested group structure", () => {
const nestedGroup = {
groupId: "nested-group",
label: "or",
items: [
filterType: 'group',
groupId: "nested-group",
logicalOperator: 'OR',
conditions: [
{
column: "status",
operator: "=",
@@ -613,19 +647,20 @@ describe("Condition Component", () => {
]
};
wrapper.vm.conditionGroup.items = [nestedGroup];
wrapper.vm.conditionGroup.conditions = [nestedGroup];
expect(wrapper.vm.conditionGroup.items[0].groupId).toBe("nested-group");
expect(wrapper.vm.conditionGroup.items[0].label).toBe("or");
expect(wrapper.vm.conditionGroup.conditions[0].groupId).toBe("nested-group");
expect(wrapper.vm.conditionGroup.conditions[0].logicalOperator).toBe('OR');
});
});
describe("Complex Nested Conditions with OR/AND", () => {
it("handles complex nested AND within OR", async () => {
wrapper.vm.conditionGroup = {
groupId: "root-group",
label: "or",
items: [
filterType: 'group',
groupId: "root-group",
logicalOperator: 'OR',
conditions: [
{
column: "level",
operator: "=",
@@ -634,9 +669,14 @@ describe("Condition Component", () => {
id: "test-id-1"
},
{
filterType: 'group',
groupId: "nested-and-group",
label: "and",
items: [
logicalOperator: 'AND',
conditions: [
{
column: "status",
operator: "=",
@@ -660,26 +700,26 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
conditions: {
or: [
expect.objectContaining({ column: "level", value: "error" }),
{
and: [
expect.objectContaining({ column: "status", value: "failed" }),
expect.objectContaining({ column: "retry_count", value: "3" })
]
}
]
}
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'OR',
groupId: expect.any(String),
conditions: expect.arrayContaining([
expect.objectContaining({ column: "level", value: "error" })
])
})
})
);
});
it("handles complex nested OR within AND", async () => {
wrapper.vm.conditionGroup = {
groupId: "root-group",
label: "and",
items: [
filterType: 'group',
groupId: "root-group",
logicalOperator: 'AND',
conditions: [
{
column: "app_name",
operator: "=",
@@ -688,9 +728,14 @@ describe("Condition Component", () => {
id: "test-id-1"
},
{
filterType: 'group',
groupId: "nested-or-group",
label: "or",
items: [
logicalOperator: 'OR',
conditions: [
{
column: "level",
operator: "=",
@@ -714,26 +759,26 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
conditions: {
and: [
expect.objectContaining({ column: "app_name", value: "myapp" }),
{
or: [
expect.objectContaining({ column: "level", value: "error" }),
expect.objectContaining({ column: "level", value: "critical" })
]
}
]
}
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'AND',
groupId: expect.any(String),
conditions: expect.arrayContaining([
expect.objectContaining({ column: "app_name", value: "myapp" })
])
})
})
);
});
it("handles triple-nested conditions", async () => {
wrapper.vm.conditionGroup = {
groupId: "root-group",
label: "and",
items: [
filterType: 'group',
groupId: "root-group",
logicalOperator: 'AND',
conditions: [
{
column: "env",
operator: "=",
@@ -742,9 +787,14 @@ describe("Condition Component", () => {
id: "test-id-1"
},
{
filterType: 'group',
groupId: "level-1-group",
label: "or",
items: [
logicalOperator: 'OR',
conditions: [
{
column: "severity",
operator: "=",
@@ -753,9 +803,14 @@ describe("Condition Component", () => {
id: "test-id-2"
},
{
filterType: 'group',
groupId: "level-2-group",
label: "and",
items: [
logicalOperator: 'AND',
conditions: [
{
column: "user_type",
operator: "=",
@@ -781,31 +836,26 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
conditions: {
and: [
expect.objectContaining({ column: "env", value: "production" }),
{
or: [
expect.objectContaining({ column: "severity", value: "high" }),
{
and: [
expect.objectContaining({ column: "user_type", value: "premium" }),
expect.objectContaining({ column: "error_count", value: "10" })
]
}
]
}
]
}
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'AND',
groupId: expect.any(String),
conditions: expect.arrayContaining([
expect.objectContaining({ column: "env", value: "production" })
])
})
})
);
});
it("correctly updates nested groups", () => {
const rootGroup = {
groupId: "root-group",
label: "and",
items: [
filterType: 'group',
groupId: "root-group",
logicalOperator: 'AND',
conditions: [
{
column: "field1",
operator: "=",
@@ -814,9 +864,14 @@ describe("Condition Component", () => {
id: "test-id-1"
},
{
filterType: 'group',
groupId: "nested-group",
label: "or",
items: [
logicalOperator: 'OR',
conditions: [
{
column: "field2",
operator: "=",
@@ -833,9 +888,10 @@ describe("Condition Component", () => {
// Update nested group
const updatedNestedGroup = {
groupId: "nested-group",
label: "or",
items: [
filterType: 'group',
groupId: "nested-group",
logicalOperator: 'OR',
conditions: [
{
column: "field2",
operator: "=",
@@ -855,15 +911,16 @@ describe("Condition Component", () => {
wrapper.vm.updateGroup(updatedNestedGroup);
expect(wrapper.vm.conditionGroup.items[1].items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.items[1].items[1].column).toBe("field3");
expect(wrapper.vm.conditionGroup.conditions[1].conditions).toHaveLength(2);
expect(wrapper.vm.conditionGroup.conditions[1].conditions[1].column).toBe("field3");
});
it("handles case sensitivity correctly in OR conditions", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "or",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'OR',
conditions: [
{
column: "message",
operator: "Contains",
@@ -885,8 +942,13 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
conditions: {
or: [
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'OR',
groupId: expect.any(String),
conditions: expect.arrayContaining([
expect.objectContaining({
column: "message",
value: "Error",
@@ -897,8 +959,8 @@ describe("Condition Component", () => {
value: "CRITICAL",
ignore_case: false
})
]
}
])
})
})
);
});
@@ -907,9 +969,10 @@ describe("Condition Component", () => {
describe("OR Operator Edge Cases", () => {
it("handles single condition in OR group", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "or",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'OR',
conditions: [
{
column: "level",
operator: "=",
@@ -924,20 +987,26 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
conditions: {
or: [
node_type: "condition",
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'OR',
groupId: expect.any(String),
conditions: expect.arrayContaining([
expect.objectContaining({ column: "level", value: "error" })
]
}
])
})
})
);
});
it("validates OR group with nested empty group", async () => {
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "or",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'OR',
conditions: [
{
column: "level",
operator: "=",
@@ -946,9 +1015,14 @@ describe("Condition Component", () => {
id: "test-id-1"
},
{
filterType: 'group',
groupId: "empty-nested-group",
label: "and",
items: []
logicalOperator: 'AND',
conditions: []
}
]
};
@@ -961,17 +1035,28 @@ describe("Condition Component", () => {
it("removes deeply nested group correctly", () => {
wrapper.vm.conditionGroup = {
groupId: "root-group",
label: "and",
items: [
filterType: 'group',
groupId: "root-group",
logicalOperator: 'AND',
conditions: [
{
filterType: 'group',
groupId: "level-1-group",
label: "or",
items: [
logicalOperator: 'OR',
conditions: [
{
filterType: 'group',
groupId: "level-2-group",
label: "and",
items: [
logicalOperator: 'AND',
conditions: [
{
column: "test",
operator: "=",
@@ -989,7 +1074,7 @@ describe("Condition Component", () => {
wrapper.vm.removeConditionGroup("level-2-group");
// After removing level-2-group, level-1-group should also be removed (empty)
expect(wrapper.vm.conditionGroup.items).toHaveLength(0);
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(0);
});
});
@@ -1027,9 +1112,9 @@ describe("Condition Component", () => {
await flushPromises();
expect(wrapper.vm.conditionGroup.label).toBe("and");
expect(wrapper.vm.conditionGroup.items).toHaveLength(1);
expect(wrapper.vm.conditionGroup.items[0].column).toBe("status");
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('AND');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(1);
expect(wrapper.vm.conditionGroup.conditions[0].column).toBe("status");
});
});
@@ -1047,9 +1132,10 @@ describe("Condition Component", () => {
vi.setSystemTime(now);
wrapper.vm.conditionGroup = {
groupId: "test-group",
label: "and",
items: [
filterType: 'group',
groupId: "test-group",
logicalOperator: 'AND',
conditions: [
{
column: "level",
operator: "=",
@@ -1064,16 +1150,20 @@ describe("Condition Component", () => {
expect(mockAddNode).toHaveBeenCalledWith({
node_type: "condition",
conditions: {
and: [
{
version: 2,
conditions: expect.objectContaining({
filterType: 'group',
logicalOperator: 'AND',
groupId: expect.any(String),
conditions: expect.arrayContaining([
expect.objectContaining({
column: "level",
operator: "=",
value: "error",
ignore_case: false
}
]
}
})
])
})
});
});
@@ -1121,8 +1211,8 @@ describe("Condition Component", () => {
await flushPromises();
// Verify the condition group was loaded correctly
expect(wrapper.vm.conditionGroup.label).toBe("or");
expect(wrapper.vm.conditionGroup.items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('OR');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(2);
});
});
});
@@ -40,12 +40,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Wrapper for FilterGroup with pipeline-specific styling -->
<div class="pipeline-filter-group-wrapper" @submit.stop.prevent>
<FilterGroup
v-if="conditionGroup && conditionGroup.items"
v-if="conditionGroup && (conditionGroup.conditions || conditionGroup.items)"
:key="filterGroupKey"
:stream-fields="filteredColumns"
:group="conditionGroup"
:depth="0"
condition-input-width="tw-w-[150px]"
condition-input-width="tw-w-[130px]"
@add-condition="(updatedGroup) => updateGroup(updatedGroup)"
@add-group="(updatedGroup) => updateGroup(updatedGroup)"
@remove-group="(groupId) => removeConditionGroup(groupId)"
@@ -163,8 +163,14 @@ import searchService from "@/services/search";
import { convertDateToTimestamp } from "@/utils/date";
import useDragAndDrop from "@/plugins/pipelines/useDnD";
import {
transformFEToBE,
retransformBEToFE,
detectConditionsVersion,
convertV0ToV2,
convertV1ToV2,
convertV1BEToV2,
updateGroup as updateGroupUtil,
removeConditionGroup as removeConditionGroupUtil,
ensureIds,
type V2Group,
} from "@/utils/alerts/alertDataTransforms";
@@ -172,6 +178,7 @@ const VariablesInput = defineAsyncComponent(
() => import("@/components/alerts/VariablesInput.vue"),
);
// V1 interfaces (legacy support)
interface FilterCondition {
column: string;
operator: string;
@@ -181,9 +188,14 @@ interface FilterCondition {
}
interface ConditionGroup {
groupId: string;
label: 'and' | 'or';
items: (FilterCondition | ConditionGroup)[];
// V2 properties
filterType?: 'group';
logicalOperator?: 'AND' | 'OR';
conditions?: (FilterCondition | ConditionGroup)[];
// V1 properties (legacy)
groupId?: string;
label?: 'and' | 'or';
items?: (FilterCondition | ConditionGroup)[];
}
interface StreamRoute {
@@ -285,31 +297,56 @@ const getDefaultStreamRoute: any = () => {
};
};
// Initialize condition group - convert from backend format if editing
// Initialize condition group - V2: Auto-convert V0/V1 to V2 format
// Supports three versions:
// - V0: Flat array of conditions with implicit AND between all (no groups)
// - V1: Tree-based structure with {and: [...]} or {or: [...]} or {label, items, groupId}
// - V2: Linear structure with filterType, logicalOperator per condition
const getDefaultConditionGroup = (): ConditionGroup => {
if (pipelineObj.isEditNode && pipelineObj.currentSelectedNodeData?.data?.conditions) {
try {
// Convert backend ConditionList format to FilterGroup format
const converted = retransformBEToFE(pipelineObj.currentSelectedNodeData.data.conditions);
if (converted) {
return converted;
const conditions = pipelineObj.currentSelectedNodeData.data.conditions;
const version = detectConditionsVersion(conditions);
if (version === 0) {
// V0: Flat array format - convert to V2
// V0 had implicit AND between all conditions (no groups)
const converted = convertV0ToV2(conditions);
return ensureIds(converted) as any;
} else if (version === 1) {
// V1: Convert to V2
let converted;
if (conditions.and || conditions.or) {
// V1 Backend format
converted = convertV1BEToV2(conditions);
} else if (conditions.label && conditions.items) {
// V1 Frontend format
converted = convertV1ToV2(conditions);
}
return ensureIds(converted) as any;
} else {
// V2: Use as-is, but ensure all groupIds and ids exist recursively
return ensureIds(conditions) as any;
}
} catch (error) {
console.error("Error converting condition to group format:", error);
}
}
// Default empty group
// Default empty V2 group
return {
filterType: 'group',
logicalOperator: 'AND',
groupId: getUUID(),
label: 'and',
items: [{
conditions: [{
filterType: 'condition',
column: '',
operator: '=',
value: '',
ignore_case: true,
values: [],
logicalOperator: 'AND',
id: getUUID(),
}]
};
} as any;
};
onBeforeMount(async () => {
@@ -449,57 +486,43 @@ const getFields = async () => {
}
};
// Group management functions
// Group management functions - Using shared utilities from alertDataTransforms
// These functions are called when FilterGroup emits add-condition, add-group, or remove-group events
const updateGroup = (updatedGroup: any) => {
// If the updated group has the same groupId as the root, replace entirely
if (updatedGroup.groupId === conditionGroup.value.groupId) {
conditionGroup.value = updatedGroup;
} else {
// Otherwise, it's a nested group - find and update it recursively
const updateNestedGroup = (group: any, updated: any): boolean => {
if (!group.items || !Array.isArray(group.items)) return false;
for (let i = 0; i < group.items.length; i++) {
const item = group.items[i];
if (item.groupId && item.groupId === updated.groupId) {
group.items[i] = updated;
return true;
}
if (item.groupId && item.items) {
if (updateNestedGroup(item, updated)) {
return true;
}
}
// Create a context object that matches the alert utility's expected structure
// The utility expects: context.formData.query_condition.conditions
// We need to create a temporary wrapper and then extract the updated value
const tempContext = {
formData: {
query_condition: {
conditions: conditionGroup.value
}
return false;
};
const updatedRoot = JSON.parse(JSON.stringify(conditionGroup.value));
if (updateNestedGroup(updatedRoot, updatedGroup)) {
conditionGroup.value = updatedRoot;
}
}
};
// Call the shared utility
updateGroupUtil(updatedGroup, tempContext as any);
// Extract the updated value back
conditionGroup.value = tempContext.formData.query_condition.conditions;
};
const removeConditionGroup = (targetGroupId: string) => {
if (!conditionGroup.value?.items || !Array.isArray(conditionGroup.value.items)) return;
const filterEmptyGroups = (items: any[]): any[] => {
return items.filter((item: any) => {
if (item.groupId === targetGroupId) {
return false;
// Create a context object that matches the alert utility's expected structure
const tempContext = {
formData: {
query_condition: {
conditions: conditionGroup.value
}
if (item.items && Array.isArray(item.items)) {
item.items = filterEmptyGroups(item.items);
return item.items.length > 0;
}
return true;
});
}
};
conditionGroup.value.items = filterEmptyGroups(conditionGroup.value.items);
// Call the shared utility
removeConditionGroupUtil(targetGroupId, conditionGroup.value, tempContext as any);
// Extract the updated value back
conditionGroup.value = tempContext.formData.query_condition.conditions;
};
const onInputUpdate = (name: string, field: any) => {
@@ -537,9 +560,12 @@ const openCancelDialog = () => {
const saveCondition = async () => {
try {
// Check if there are any valid conditions
const hasValidConditions = conditionGroup.value.items.some((item: any) => {
if (item.groupId && item.items) return true;
// V2: Check if there are any valid conditions (use 'conditions' array)
const conditionsArray = (conditionGroup.value as any).conditions || [];
const hasValidConditions = conditionsArray.some((item: any) => {
// Check for nested groups (V2 format)
if (item.filterType === 'group' && item.conditions) return true;
// Check for valid conditions
return item.column && item.operator;
});
@@ -552,12 +578,12 @@ const saveCondition = async () => {
return;
}
// Transform FilterGroup format to backend ConditionList format
const backendCondition = transformFEToBE(conditionGroup.value);
// V2: Send directly to backend (no transformation needed)
// The conditionGroup is already in V2 format which matches backend
let conditionData = {
node_type: "condition",
conditions: backendCondition,
version: 2, // Numeric version for consistency with alerts
conditions: conditionGroup.value,
};
// Ensure currentSelectedNodeData has proper structure
@@ -154,158 +154,178 @@ describe("Condition Component - OR Operator Tests", () => {
it("should initialize conditions with nested OR group structure", () => {
const conditionGroup = wrapper.vm.conditionGroup;
expect(conditionGroup).toBeDefined();
expect(conditionGroup.items).toBeDefined();
expect(Array.isArray(conditionGroup.items)).toBe(true);
expect(conditionGroup.filterType).toBe('group');
expect(conditionGroup.conditions).toBeDefined();
expect(Array.isArray(conditionGroup.conditions)).toBe(true);
});
it("should support OR condition with multiple fields", () => {
wrapper.vm.conditionGroup = {
filterType: 'group',
groupId: 'root-group',
label: 'or',
items: [
{ id: '1', column: 'status_code', operator: '=', value: '500', ignore_case: false },
{ id: '2', column: 'status_code', operator: '=', value: '503', ignore_case: false }
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status_code', operator: '=', value: '500', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'status_code', operator: '=', value: '503', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.label).toBe('or');
expect(wrapper.vm.conditionGroup.items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('OR');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(2);
});
it("should support AND condition within condition node", () => {
wrapper.vm.conditionGroup = {
groupId: 'root-group',
label: 'and',
items: [
{ id: '1', column: 'level', operator: '=', value: 'error', ignore_case: true },
{ id: '2', column: 'status_code', operator: '>=', value: '500', ignore_case: false }
filterType: 'group',
groupId: 'root-group',
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '1', column: 'level', operator: '=', value: 'error', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'status_code', operator: '>=', value: '500', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.label).toBe('and');
expect(wrapper.vm.conditionGroup.items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('AND');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(2);
});
it("should support nested OR within AND condition", () => {
wrapper.vm.conditionGroup = {
groupId: 'root-and',
label: 'and',
items: [
{ id: '1', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false },
filterType: 'group',
groupId: 'root-and',
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '1', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false, logicalOperator: 'OR' },
{
filterType: 'group',
groupId: 'nested-or',
label: 'or',
items: [
{ id: '2', column: 'level', operator: '=', value: 'error', ignore_case: true },
{ id: '3', column: 'level', operator: '=', value: 'critical', ignore_case: true }
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '2', column: 'level', operator: '=', value: 'error', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'level', operator: '=', value: 'critical', ignore_case: true, logicalOperator: 'OR' }
]
}
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.items[1].label).toBe('or');
expect(wrapper.vm.conditionGroup.items[1].items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(2);
expect(wrapper.vm.conditionGroup.conditions[1].logicalOperator).toBe('OR');
expect(wrapper.vm.conditionGroup.conditions[1].conditions).toHaveLength(2);
});
it("should support nested AND within OR condition", () => {
wrapper.vm.conditionGroup = {
groupId: 'root-or',
label: 'or',
items: [
{ id: '1', column: 'status_code', operator: '=', value: '404', ignore_case: false },
filterType: 'group',
groupId: 'root-or',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status_code', operator: '=', value: '404', ignore_case: false, logicalOperator: 'OR' },
{
filterType: 'group',
groupId: 'nested-and',
label: 'and',
items: [
{ id: '2', column: 'status_code', operator: '>=', value: '500', ignore_case: false },
{ id: '3', column: 'message', operator: 'Contains', value: 'timeout', ignore_case: true }
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '2', column: 'status_code', operator: '>=', value: '500', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'message', operator: 'Contains', value: 'timeout', ignore_case: true, logicalOperator: 'OR' }
]
}
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.items[1].label).toBe('and');
expect(wrapper.vm.conditionGroup.items[1].items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(2);
expect(wrapper.vm.conditionGroup.conditions[1].logicalOperator).toBe('AND');
expect(wrapper.vm.conditionGroup.conditions[1].conditions).toHaveLength(2);
});
});
describe("OR Operator - Multiple Conditions Scenarios", () => {
it("should handle OR with three conditions", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'status_code', operator: '=', value: '500', ignore_case: false },
{ id: '2', column: 'status_code', operator: '=', value: '502', ignore_case: false },
{ id: '3', column: 'status_code', operator: '=', value: '503', ignore_case: false }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status_code', operator: '=', value: '500', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'status_code', operator: '=', value: '502', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'status_code', operator: '=', value: '503', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(3);
wrapper.vm.conditionGroup.items.forEach(item => {
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(3);
wrapper.vm.conditionGroup.conditions.forEach(item => {
expect(item.column).toBe('status_code');
});
});
it("should handle OR with different operators", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'status_code', operator: '>=', value: '500', ignore_case: false },
{ id: '2', column: 'message', operator: 'Contains', value: 'error', ignore_case: true },
{ id: '3', column: 'level', operator: '=', value: 'critical', ignore_case: true }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status_code', operator: '>=', value: '500', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'message', operator: 'Contains', value: 'error', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'level', operator: '=', value: 'critical', ignore_case: true, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items[0].operator).toBe('>=');
expect(wrapper.vm.conditionGroup.items[1].operator).toBe('Contains');
expect(wrapper.vm.conditionGroup.items[2].operator).toBe('=');
expect(wrapper.vm.conditionGroup.conditions[0].operator).toBe('>=');
expect(wrapper.vm.conditionGroup.conditions[1].operator).toBe('Contains');
expect(wrapper.vm.conditionGroup.conditions[2].operator).toBe('=');
});
it("should handle OR with mixed case sensitivity", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'message', operator: 'Contains', value: 'ERROR', ignore_case: true },
{ id: '2', column: 'message', operator: 'Contains', value: 'Error', ignore_case: false },
{ id: '3', column: 'message', operator: '=', value: 'error', ignore_case: true }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'message', operator: 'Contains', value: 'ERROR', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'message', operator: 'Contains', value: 'Error', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'message', operator: '=', value: 'error', ignore_case: true, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items[0].ignore_case).toBe(true);
expect(wrapper.vm.conditionGroup.items[1].ignore_case).toBe(false);
expect(wrapper.vm.conditionGroup.items[2].ignore_case).toBe(true);
expect(wrapper.vm.conditionGroup.conditions[0].ignore_case).toBe(true);
expect(wrapper.vm.conditionGroup.conditions[1].ignore_case).toBe(false);
expect(wrapper.vm.conditionGroup.conditions[2].ignore_case).toBe(true);
});
it("should handle OR with NotContains operator", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'message', operator: 'NotContains', value: 'debug', ignore_case: true },
{ id: '2', column: 'message', operator: 'NotContains', value: 'trace', ignore_case: true }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'message', operator: 'NotContains', value: 'debug', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'message', operator: 'NotContains', value: 'trace', ignore_case: true, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items[0].operator).toBe('NotContains');
expect(wrapper.vm.conditionGroup.items[1].operator).toBe('NotContains');
expect(wrapper.vm.conditionGroup.conditions[0].operator).toBe('NotContains');
expect(wrapper.vm.conditionGroup.conditions[1].operator).toBe('NotContains');
});
it("should handle OR with != (not equals) operator", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'status', operator: '!=', value: 'success', ignore_case: false },
{ id: '2', column: 'status', operator: '!=', value: 'pending', ignore_case: false }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status', operator: '!=', value: 'success', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'status', operator: '!=', value: 'pending', ignore_case: false, logicalOperator: 'OR' }
]
};
wrapper.vm.conditionGroup.items.forEach(item => {
wrapper.vm.conditionGroup.conditions.forEach(item => {
expect(item.operator).toBe('!=');
});
});
@@ -314,21 +334,32 @@ describe("Condition Component - OR Operator Tests", () => {
describe("OR Operator - Complex Nested Structures", () => {
it("should handle deeply nested OR-AND-OR structure", () => {
wrapper.vm.conditionGroup = {
groupId: 'root',
label: 'or',
items: [
{ id: '1', column: 'level', operator: '=', value: 'critical', ignore_case: true },
filterType: 'group',
groupId: 'root',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'level', operator: '=', value: 'critical', ignore_case: true, logicalOperator: 'OR' },
{
filterType: 'group',
groupId: 'mid-and',
label: 'and',
items: [
{ id: '2', column: 'region', operator: '=', value: 'us-west-1', ignore_case: false },
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '2', column: 'region', operator: '=', value: 'us-west-1', ignore_case: false, logicalOperator: 'OR' },
{
filterType: 'group',
groupId: 'deep-or',
label: 'or',
items: [
{ id: '3', column: 'status_code', operator: '=', value: '500', ignore_case: false },
{ id: '4', column: 'status_code', operator: '=', value: '503', ignore_case: false }
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '3', column: 'status_code', operator: '=', value: '500', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'status_code', operator: '=', value: '503', ignore_case: false, logicalOperator: 'OR' }
]
}
]
@@ -336,69 +367,82 @@ describe("Condition Component - OR Operator Tests", () => {
]
};
const rootItems = wrapper.vm.conditionGroup.items;
const rootItems = wrapper.vm.conditionGroup.conditions;
expect(rootItems).toHaveLength(2);
expect(rootItems[1].items[1].label).toBe('or');
expect(rootItems[1].items[1].items).toHaveLength(2);
expect(rootItems[1].conditions[1].logicalOperator).toBe('OR');
expect(rootItems[1].conditions[1].conditions).toHaveLength(2);
});
it("should handle multiple OR groups at same level", () => {
wrapper.vm.conditionGroup = {
groupId: 'root-and',
label: 'and',
items: [
filterType: 'group',
groupId: 'root-and',
logicalOperator: 'AND',
conditions: [
{
filterType: 'group',
groupId: 'or-1',
label: 'or',
items: [
{ id: '1', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false },
{ id: '2', column: 'region', operator: '=', value: 'us-west-1', ignore_case: false }
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'region', operator: '=', value: 'us-west-1', ignore_case: false, logicalOperator: 'OR' }
]
},
{
filterType: 'group',
groupId: 'or-2',
label: 'or',
items: [
{ id: '3', column: 'tier', operator: '=', value: 'gold', ignore_case: false },
{ id: '4', column: 'tier', operator: '=', value: 'platinum', ignore_case: false }
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '3', column: 'tier', operator: '=', value: 'gold', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'tier', operator: '=', value: 'platinum', ignore_case: false, logicalOperator: 'OR' }
]
}
]
};
const items = wrapper.vm.conditionGroup.items;
const items = wrapper.vm.conditionGroup.conditions;
expect(items).toHaveLength(2);
expect(items[0].label).toBe('or');
expect(items[1].label).toBe('or');
expect(items[0].logicalOperator).toBe('OR');
expect(items[1].logicalOperator).toBe('OR');
});
it("should handle OR with empty value (checking for empty strings)", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ id: '1', column: 'app_name', operator: '=', value: '""', ignore_case: false },
{ id: '2', column: 'app_name', operator: '=', value: 'null', ignore_case: false }
{ filterType: 'condition', id: '2', column: 'app_name', operator: '=', value: 'null', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items[0].value).toBe('""');
expect(wrapper.vm.conditionGroup.items[1].value).toBe('null');
expect(wrapper.vm.conditionGroup.conditions[0].value).toBe('""');
expect(wrapper.vm.conditionGroup.conditions[1].value).toBe('null');
});
it("should handle OR with numeric comparison operators", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'response_time', operator: '>', value: '1000', ignore_case: false },
{ id: '2', column: 'response_time', operator: '<', value: '10', ignore_case: false },
{ id: '3', column: 'response_time', operator: '>=', value: '5000', ignore_case: false },
{ id: '4', column: 'response_time', operator: '<=', value: '1', ignore_case: false }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'response_time', operator: '>', value: '1000', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'response_time', operator: '<', value: '10', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'response_time', operator: '>=', value: '5000', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'response_time', operator: '<=', value: '1', ignore_case: false, logicalOperator: 'OR' }
]
};
const operators = wrapper.vm.conditionGroup.items.map(item => item.operator);
const operators = wrapper.vm.conditionGroup.conditions.map(item => item.operator);
expect(operators).toEqual(['>', '<', '>=', '<=']);
});
});
@@ -406,229 +450,257 @@ describe("Condition Component - OR Operator Tests", () => {
describe("OR Operator - Edge Cases", () => {
it("should handle single OR condition", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'status', operator: '=', value: 'active', ignore_case: true }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status', operator: '=', value: 'active', ignore_case: true, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(1);
expect(wrapper.vm.conditionGroup.label).toBe('or');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(1);
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('OR');
});
it("should handle OR with special characters in values", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'message', operator: 'Contains', value: 'error: connection failed!', ignore_case: true },
{ id: '2', column: 'message', operator: 'Contains', value: 'special@#$%^&*()', ignore_case: false }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'message', operator: 'Contains', value: 'error: connection failed!', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'message', operator: 'Contains', value: 'special@#$%^&*()', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items[0].value).toContain('!');
expect(wrapper.vm.conditionGroup.items[1].value).toContain('@#$%^&*()');
expect(wrapper.vm.conditionGroup.conditions[0].value).toContain('!');
expect(wrapper.vm.conditionGroup.conditions[1].value).toContain('@#$%^&*()');
});
it("should handle OR with unicode characters", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'message', operator: 'Contains', value: '错误', ignore_case: true },
{ id: '2', column: 'message', operator: 'Contains', value: 'エラー', ignore_case: true },
{ id: '3', column: 'message', operator: 'Contains', value: 'خطأ', ignore_case: true }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'message', operator: 'Contains', value: '错误', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'message', operator: 'Contains', value: 'エラー', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'message', operator: 'Contains', value: 'خطأ', ignore_case: true, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(3);
expect(wrapper.vm.conditionGroup.items[0].value).toBe('错误');
expect(wrapper.vm.conditionGroup.items[1].value).toBe('エラー');
expect(wrapper.vm.conditionGroup.items[2].value).toBe('خطأ');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(3);
expect(wrapper.vm.conditionGroup.conditions[0].value).toBe('错误');
expect(wrapper.vm.conditionGroup.conditions[1].value).toBe('エラー');
expect(wrapper.vm.conditionGroup.conditions[2].value).toBe('خطأ');
});
it("should handle OR with whitespace in values", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'message', operator: '=', value: ' leading spaces', ignore_case: false },
{ id: '2', column: 'message', operator: '=', value: 'trailing spaces ', ignore_case: false },
{ id: '3', column: 'message', operator: '=', value: ' both sides ', ignore_case: false }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'message', operator: '=', value: ' leading spaces', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'message', operator: '=', value: 'trailing spaces ', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'message', operator: '=', value: ' both sides ', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items[0].value).toBe(' leading spaces');
expect(wrapper.vm.conditionGroup.items[1].value).toBe('trailing spaces ');
expect(wrapper.vm.conditionGroup.items[2].value).toBe(' both sides ');
expect(wrapper.vm.conditionGroup.conditions[0].value).toBe(' leading spaces');
expect(wrapper.vm.conditionGroup.conditions[1].value).toBe('trailing spaces ');
expect(wrapper.vm.conditionGroup.conditions[2].value).toBe(' both sides ');
});
it("should handle OR with numeric string values", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'count', operator: '=', value: '100', ignore_case: false },
{ id: '2', column: 'count', operator: '=', value: '1000.50', ignore_case: false },
{ id: '3', column: 'count', operator: '=', value: '-50', ignore_case: false }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'count', operator: '=', value: '100', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'count', operator: '=', value: '1000.50', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'count', operator: '=', value: '-50', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items[0].value).toBe('100');
expect(wrapper.vm.conditionGroup.items[1].value).toBe('1000.50');
expect(wrapper.vm.conditionGroup.items[2].value).toBe('-50');
expect(wrapper.vm.conditionGroup.conditions[0].value).toBe('100');
expect(wrapper.vm.conditionGroup.conditions[1].value).toBe('1000.50');
expect(wrapper.vm.conditionGroup.conditions[2].value).toBe('-50');
});
it("should handle OR with boolean-like string values", () => {
wrapper.vm.conditionGroup = {
groupId: 'group-1',
label: 'or',
items: [
{ id: '1', column: 'is_valid', operator: '=', value: 'true', ignore_case: false },
{ id: '2', column: 'is_valid', operator: '=', value: 'false', ignore_case: false }
filterType: 'group',
groupId: 'group-1',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'is_valid', operator: '=', value: 'true', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'is_valid', operator: '=', value: 'false', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items[0].value).toBe('true');
expect(wrapper.vm.conditionGroup.items[1].value).toBe('false');
expect(wrapper.vm.conditionGroup.conditions[0].value).toBe('true');
expect(wrapper.vm.conditionGroup.conditions[1].value).toBe('false');
});
});
describe("OR Operator - Real-World Scenarios", () => {
it("should handle HTTP error codes OR condition", () => {
wrapper.vm.conditionGroup = {
groupId: 'http-errors',
label: 'or',
items: [
{ id: '1', column: 'status_code', operator: '=', value: '400', ignore_case: false },
{ id: '2', column: 'status_code', operator: '=', value: '401', ignore_case: false },
{ id: '3', column: 'status_code', operator: '=', value: '403', ignore_case: false },
{ id: '4', column: 'status_code', operator: '=', value: '404', ignore_case: false },
{ id: '5', column: 'status_code', operator: '=', value: '500', ignore_case: false },
{ id: '6', column: 'status_code', operator: '=', value: '502', ignore_case: false },
{ id: '7', column: 'status_code', operator: '=', value: '503', ignore_case: false }
filterType: 'group',
groupId: 'http-errors',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status_code', operator: '=', value: '400', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'status_code', operator: '=', value: '401', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'status_code', operator: '=', value: '403', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'status_code', operator: '=', value: '404', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '5', column: 'status_code', operator: '=', value: '500', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '6', column: 'status_code', operator: '=', value: '502', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '7', column: 'status_code', operator: '=', value: '503', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(7);
expect(wrapper.vm.conditionGroup.label).toBe('or');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(7);
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('OR');
});
it("should handle log level filtering with OR", () => {
wrapper.vm.conditionGroup = {
groupId: 'log-levels',
label: 'or',
items: [
{ id: '1', column: 'level', operator: '=', value: 'error', ignore_case: true },
{ id: '2', column: 'level', operator: '=', value: 'critical', ignore_case: true },
{ id: '3', column: 'level', operator: '=', value: 'fatal', ignore_case: true },
{ id: '4', column: 'level', operator: '=', value: 'emergency', ignore_case: true }
filterType: 'group',
groupId: 'log-levels',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'level', operator: '=', value: 'error', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'level', operator: '=', value: 'critical', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'level', operator: '=', value: 'fatal', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'level', operator: '=', value: 'emergency', ignore_case: true, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(4);
wrapper.vm.conditionGroup.items.forEach(item => {
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(4);
wrapper.vm.conditionGroup.conditions.forEach(item => {
expect(item.ignore_case).toBe(true);
});
});
it("should handle multi-region filtering", () => {
wrapper.vm.conditionGroup = {
groupId: 'regions',
label: 'or',
items: [
{ id: '1', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false },
{ id: '2', column: 'region', operator: '=', value: 'us-west-1', ignore_case: false },
{ id: '3', column: 'region', operator: '=', value: 'eu-west-1', ignore_case: false },
{ id: '4', column: 'region', operator: '=', value: 'ap-southeast-1', ignore_case: false }
filterType: 'group',
groupId: 'regions',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'region', operator: '=', value: 'us-west-1', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'region', operator: '=', value: 'eu-west-1', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'region', operator: '=', value: 'ap-southeast-1', ignore_case: false, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(4);
expect(wrapper.vm.conditionGroup.items.every(item => item.column === 'region')).toBe(true);
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(4);
expect(wrapper.vm.conditionGroup.conditions.every(item => item.column === 'region')).toBe(true);
});
it("should handle complex alert condition: critical errors OR high response time AND specific region", () => {
wrapper.vm.conditionGroup = {
groupId: 'root-or',
label: 'or',
items: [
filterType: 'group',
groupId: 'root-or',
logicalOperator: 'OR',
conditions: [
{
filterType: 'group',
groupId: 'critical-errors',
label: 'and',
items: [
{ id: '1', column: 'level', operator: '=', value: 'critical', ignore_case: true },
{ id: '2', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false }
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '1', column: 'level', operator: '=', value: 'critical', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false, logicalOperator: 'OR' }
]
},
{
filterType: 'group',
groupId: 'high-response',
label: 'and',
items: [
{ id: '3', column: 'response_time', operator: '>', value: '5000', ignore_case: false },
{ id: '4', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false }
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '3', column: 'response_time', operator: '>', value: '5000', ignore_case: false, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'region', operator: '=', value: 'us-east-1', ignore_case: false, logicalOperator: 'OR' }
]
}
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.items[0].label).toBe('and');
expect(wrapper.vm.conditionGroup.items[1].label).toBe('and');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(2);
expect(wrapper.vm.conditionGroup.conditions[0].logicalOperator).toBe('AND');
expect(wrapper.vm.conditionGroup.conditions[1].logicalOperator).toBe('AND');
});
it("should handle error pattern matching with OR", () => {
wrapper.vm.conditionGroup = {
groupId: 'error-patterns',
label: 'or',
items: [
{ id: '1', column: 'message', operator: 'Contains', value: 'timeout', ignore_case: true },
{ id: '2', column: 'message', operator: 'Contains', value: 'connection refused', ignore_case: true },
{ id: '3', column: 'message', operator: 'Contains', value: 'out of memory', ignore_case: true },
{ id: '4', column: 'message', operator: 'Contains', value: 'null pointer', ignore_case: true }
filterType: 'group',
groupId: 'error-patterns',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'message', operator: 'Contains', value: 'timeout', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'message', operator: 'Contains', value: 'connection refused', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'message', operator: 'Contains', value: 'out of memory', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'message', operator: 'Contains', value: 'null pointer', ignore_case: true, logicalOperator: 'OR' }
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(4);
expect(wrapper.vm.conditionGroup.items.every(item => item.operator === 'Contains')).toBe(true);
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(4);
expect(wrapper.vm.conditionGroup.conditions.every(item => item.operator === 'Contains')).toBe(true);
});
it("should handle service status monitoring", () => {
wrapper.vm.conditionGroup = {
groupId: 'root-and',
label: 'and',
items: [
{ id: '1', column: 'service', operator: '=', value: 'api-gateway', ignore_case: false },
filterType: 'group',
groupId: 'root-and',
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '1', column: 'service', operator: '=', value: 'api-gateway', ignore_case: false, logicalOperator: 'OR' },
{
filterType: 'group',
groupId: 'status-or',
label: 'or',
items: [
{ id: '2', column: 'status', operator: '=', value: 'degraded', ignore_case: true },
{ id: '3', column: 'status', operator: '=', value: 'down', ignore_case: true },
{ id: '4', column: 'status', operator: '=', value: 'maintenance', ignore_case: true }
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '2', column: 'status', operator: '=', value: 'degraded', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '3', column: 'status', operator: '=', value: 'down', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'status', operator: '=', value: 'maintenance', ignore_case: true, logicalOperator: 'OR' }
]
}
]
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.items[1].items).toHaveLength(3);
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(2);
expect(wrapper.vm.conditionGroup.conditions[1].conditions).toHaveLength(3);
});
});
describe("OR Operator - Data Validation", () => {
it("should validate OR conditions with valid data", () => {
wrapper.vm.conditionGroup = {
groupId: 'valid-or',
label: 'or',
items: [
{ id: '1', column: 'status', operator: '=', value: 'active', ignore_case: true },
{ id: '2', column: 'status', operator: '=', value: 'pending', ignore_case: true }
filterType: 'group',
groupId: 'valid-or',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status', operator: '=', value: 'active', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'status', operator: '=', value: 'pending', ignore_case: true, logicalOperator: 'OR' }
]
};
const isValid = wrapper.vm.conditionGroup.items.every(item =>
const isValid = wrapper.vm.conditionGroup.conditions.every(item =>
item.column && item.operator && item.value !== undefined
);
@@ -637,44 +709,47 @@ describe("Condition Component - OR Operator Tests", () => {
it("should handle OR conditions with missing column", () => {
wrapper.vm.conditionGroup = {
groupId: 'invalid-or',
label: 'or',
items: [
filterType: 'group',
groupId: 'invalid-or',
logicalOperator: 'OR',
conditions: [
{ id: '1', column: '', operator: '=', value: 'test', ignore_case: false },
{ id: '2', column: 'status', operator: '=', value: 'active', ignore_case: false }
{ filterType: 'condition', id: '2', column: 'status', operator: '=', value: 'active', ignore_case: false, logicalOperator: 'OR' }
]
};
const hasInvalid = wrapper.vm.conditionGroup.items.some(item => !item.column);
const hasInvalid = wrapper.vm.conditionGroup.conditions.some(item => !item.column);
expect(hasInvalid).toBe(true);
});
it("should handle OR conditions with missing operator", () => {
wrapper.vm.conditionGroup = {
groupId: 'invalid-or',
label: 'or',
items: [
filterType: 'group',
groupId: 'invalid-or',
logicalOperator: 'OR',
conditions: [
{ id: '1', column: 'status', operator: '', value: 'test', ignore_case: false },
{ id: '2', column: 'level', operator: '=', value: 'error', ignore_case: false }
{ filterType: 'condition', id: '2', column: 'level', operator: '=', value: 'error', ignore_case: false, logicalOperator: 'OR' }
]
};
const hasInvalid = wrapper.vm.conditionGroup.items.some(item => !item.operator);
const hasInvalid = wrapper.vm.conditionGroup.conditions.some(item => !item.operator);
expect(hasInvalid).toBe(true);
});
it("should allow OR conditions with empty string value when checking for empty", () => {
wrapper.vm.conditionGroup = {
groupId: 'empty-check',
label: 'or',
items: [
filterType: 'group',
groupId: 'empty-check',
logicalOperator: 'OR',
conditions: [
{ id: '1', column: 'field1', operator: '=', value: '""', ignore_case: false },
{ id: '2', column: 'field2', operator: '!=', value: '""', ignore_case: false }
]
};
expect(wrapper.vm.conditionGroup.items[0].value).toBe('""');
expect(wrapper.vm.conditionGroup.items[1].value).toBe('""');
expect(wrapper.vm.conditionGroup.conditions[0].value).toBe('""');
expect(wrapper.vm.conditionGroup.conditions[1].value).toBe('""');
});
});
@@ -689,32 +764,44 @@ describe("Condition Component - OR Operator Tests", () => {
}));
wrapper.vm.conditionGroup = {
filterType: 'group',
groupId: 'many-conditions',
label: 'or',
items
logicalOperator: 'OR',
conditions: items
};
expect(wrapper.vm.conditionGroup.items).toHaveLength(15);
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(15);
});
it("should handle deeply nested structure (3 levels)", () => {
wrapper.vm.conditionGroup = {
groupId: 'level-1-or',
label: 'or',
items: [
{ id: '1', column: 'priority', operator: '=', value: 'critical', ignore_case: false },
filterType: 'group',
groupId: 'level-1-or',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'priority', operator: '=', value: 'critical', ignore_case: false, logicalOperator: 'OR' },
{
filterType: 'group',
groupId: 'level-2-and',
label: 'and',
items: [
{ id: '2', column: 'service', operator: '=', value: 'auth', ignore_case: false },
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '2', column: 'service', operator: '=', value: 'auth', ignore_case: false, logicalOperator: 'OR' },
{
filterType: 'group',
groupId: 'level-3-or',
label: 'or',
items: [
{ id: '3', column: 'error_type', operator: '=', value: 'timeout', ignore_case: true },
{ id: '4', column: 'error_type', operator: '=', value: 'connection', ignore_case: true },
{ id: '5', column: 'error_type', operator: '=', value: 'auth_failed', ignore_case: true }
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '3', column: 'error_type', operator: '=', value: 'timeout', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '4', column: 'error_type', operator: '=', value: 'connection', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '5', column: 'error_type', operator: '=', value: 'auth_failed', ignore_case: true, logicalOperator: 'OR' }
]
}
]
@@ -723,31 +810,32 @@ describe("Condition Component - OR Operator Tests", () => {
};
const level1 = wrapper.vm.conditionGroup;
const level2 = level1.items[1];
const level3 = level2.items[1];
const level2 = level1.conditions[1];
const level3 = level2.conditions[1];
expect(level1.label).toBe('or');
expect(level2.label).toBe('and');
expect(level3.label).toBe('or');
expect(level3.items).toHaveLength(3);
expect(level1.logicalOperator).toBe('OR');
expect(level2.logicalOperator).toBe('AND');
expect(level3.logicalOperator).toBe('OR');
expect(level3.conditions).toHaveLength(3);
});
});
describe("OR Operator - Integration with Pipeline", () => {
it("should preserve OR structure when saving condition node", () => {
const orCondition = {
groupId: 'save-test',
label: 'or',
items: [
{ id: '1', column: 'status', operator: '=', value: 'error', ignore_case: true },
{ id: '2', column: 'status', operator: '=', value: 'failed', ignore_case: true }
filterType: 'group',
groupId: 'save-test',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'status', operator: '=', value: 'error', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'status', operator: '=', value: 'failed', ignore_case: true, logicalOperator: 'OR' }
]
};
wrapper.vm.conditionGroup = orCondition;
expect(wrapper.vm.conditionGroup).toEqual(orCondition);
expect(wrapper.vm.conditionGroup.label).toBe('or');
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('OR');
});
it("should handle updating existing condition node with OR", () => {
@@ -756,10 +844,11 @@ describe("Condition Component - OR Operator Tests", () => {
data: {
query_condition: {
conditions: {
groupId: 'existing',
label: 'and',
items: [
{ id: '1', column: 'old_field', operator: '=', value: 'old_value', ignore_case: false }
filterType: 'group',
groupId: 'existing',
logicalOperator: 'AND',
conditions: [
{ filterType: 'condition', id: '1', column: 'old_field', operator: '=', value: 'old_value', ignore_case: false, logicalOperator: 'OR' }
]
}
}
@@ -768,18 +857,19 @@ describe("Condition Component - OR Operator Tests", () => {
};
const newOrCondition = {
groupId: 'updated',
label: 'or',
items: [
{ id: '1', column: 'new_field', operator: '=', value: 'new_value', ignore_case: true },
{ id: '2', column: 'another_field', operator: '!=', value: 'test', ignore_case: false }
filterType: 'group',
groupId: 'updated',
logicalOperator: 'OR',
conditions: [
{ filterType: 'condition', id: '1', column: 'new_field', operator: '=', value: 'new_value', ignore_case: true, logicalOperator: 'OR' },
{ filterType: 'condition', id: '2', column: 'another_field', operator: '!=', value: 'test', ignore_case: false, logicalOperator: 'OR' }
]
};
wrapper.vm.conditionGroup = newOrCondition;
expect(wrapper.vm.conditionGroup.label).toBe('or');
expect(wrapper.vm.conditionGroup.items).toHaveLength(2);
expect(wrapper.vm.conditionGroup.logicalOperator).toBe('OR');
expect(wrapper.vm.conditionGroup.conditions).toHaveLength(2);
});
});
});
+81 -20
View File
@@ -305,45 +305,106 @@ const getTruncatedConditions = (conditionData) => {
// Handle null/undefined
if (!conditionData) return '';
// Helper to recursively extract all conditions from ConditionList
const extractConditions = (node) => {
if (!node) return [];
// Build preview string recursively
const buildPreviewString = (node) => {
if (!node) return '';
// Handle OR node
// V2 Format: Group
if (node.filterType === 'group' && node.conditions && Array.isArray(node.conditions)) {
if (node.conditions.length === 0) return '';
const parts = [];
node.conditions.forEach((item, index) => {
let conditionStr = '';
if (item.filterType === 'group') {
// Nested group
const nestedPreview = buildPreviewString(item);
if (nestedPreview) {
conditionStr = `(${nestedPreview})`;
}
} else if (item.filterType === 'condition') {
// Condition
const column = item.column || 'field';
const operator = item.operator || '=';
const value = item.value !== undefined && item.value !== null && item.value !== ''
? `'${item.value}'`
: "''";
conditionStr = `${column} ${operator} ${value}`;
}
// Add logical operator before condition (except for first)
if (index > 0 && item.logicalOperator) {
parts.push(`${item.logicalOperator.toLowerCase()} ${conditionStr}`);
} else {
parts.push(conditionStr);
}
});
return parts.join(' ');
}
// V1 Backend Format: OR node
if (node.or && Array.isArray(node.or)) {
return node.or.flatMap(item => extractConditions(item));
const parts = node.or.map(item => {
const nested = buildPreviewString(item);
return nested ? `(${nested})` : '';
}).filter(Boolean);
return parts.join(' or ');
}
// Handle AND node
// V1 Backend Format: AND node
if (node.and && Array.isArray(node.and)) {
return node.and.flatMap(item => extractConditions(item));
const parts = node.and.map(item => {
const nested = buildPreviewString(item);
return nested ? `(${nested})` : '';
}).filter(Boolean);
return parts.join(' and ');
}
// Handle NOT node
// V1 Backend Format: NOT node
if (node.not) {
return extractConditions(node.not);
const nested = buildPreviewString(node.not);
return nested ? `not (${nested})` : '';
}
// Handle single condition (EndCondition) - has column/operator/value
// V1 Frontend Format: items array
if (node.items && Array.isArray(node.items)) {
const operator = node.label?.toLowerCase() || 'and';
const parts = node.items.map(item => buildPreviewString(item)).filter(Boolean);
return parts.join(` ${operator} `);
}
// Single condition
if (node.column && node.operator) {
return [node];
const column = node.column || 'field';
const operator = node.operator || '=';
const value = node.value !== undefined && node.value !== null && node.value !== ''
? `'${node.value}'`
: "''";
return `${column} ${operator} ${value}`;
}
// If it's an array (legacy format), return as-is
// V0 Format: Array
if (Array.isArray(node)) {
return node.filter(c => c.column && c.operator);
const parts = node.filter(c => c.column && c.operator).map(c => {
const column = c.column || 'field';
const operator = c.operator || '=';
const value = c.value !== undefined && c.value !== null && c.value !== ''
? `'${c.value}'`
: "''";
return `${column} ${operator} ${value}`;
});
return parts.join(' and ');
}
return [];
return '';
};
const allConditions = extractConditions(conditionData);
const previewText = buildPreviewString(conditionData);
const allConditionsText = allConditions.map(condition =>
`${condition.column} ${condition.operator} ${condition.value || ''}`
).join(', ');
return allConditionsText.length > 30 ? allConditionsText.substring(0, 30) + '...' : allConditionsText;
// Truncate to 20 characters
return previewText.length > 20 ? previewText.substring(0, 20) + '...' : previewText;
}
const confirmDialogMeta = ref({
File diff suppressed because it is too large Load Diff
+396 -10
View File
@@ -1,6 +1,10 @@
/**
* Alert Data Transformation Utilities
* Extracted from AddAlert.vue to reduce file complexity
*
* VERSION SUPPORT:
* - Version 1: Old structure with {and: [...]} or {or: [...]} format
* - Version 2: New structure with filterType, logicalOperator, and conditions array
*/
import { getUUID } from "@/utils/zincutils";
@@ -9,13 +13,122 @@ export interface TransformContext {
formData: any;
}
// ============================================================================
// VERSION 2 INTERFACES
// ============================================================================
export interface V2Condition {
filterType: "condition";
column: string;
operator: string;
value: string | number;
values?: any[];
logicalOperator: "AND" | "OR";
ignore_case?: boolean;
id?: string;
}
export interface V2Group {
filterType: "group";
logicalOperator: "AND" | "OR";
conditions: (V2Condition | V2Group)[];
groupId?: string;
}
export interface V1Condition {
column: string;
operator: string;
value: string | number;
ignore_case?: boolean;
id?: string;
}
export interface V1Group {
groupId: string;
label: "and" | "or";
items: (V1Condition | V1Group)[];
}
/**
* Ensures all groups have groupId and all conditions have id
* This is needed because data from backend might not have these fields
*/
export const ensureIds = (group: any): any => {
if (!group) return group;
// Ensure this group has a groupId
if (group.filterType === "group" && !group.groupId) {
group.groupId = getUUID();
}
// Process conditions array
if (group.conditions && Array.isArray(group.conditions)) {
group.conditions = group.conditions.map((item: any) => {
if (item.filterType === "group") {
// Recursively ensure IDs for nested groups
return ensureIds(item);
} else {
// Ensure condition has an id
if (!item.id) {
item.id = getUUID();
}
return item;
}
});
}
return group;
};
/**
* Updates a condition group (root or nested) with new data
* This is called when FilterGroup emits add-condition or add-group events
*
* @param updatedGroup - The updated group emitted by FilterGroup
* @param context - Context object containing formData
*/
export const updateGroup = (updatedGroup: any, context: TransformContext): void => {
const { formData } = context;
formData.query_condition.conditions.items.forEach((element: any) => {
if (element.groupId === updatedGroup.groupId) {
element.items = updatedGroup.items;
const rootGroup = formData.query_condition.conditions;
// If the updated group is the root group, replace it entirely
if (rootGroup.groupId === updatedGroup.groupId) {
formData.query_condition.conditions = updatedGroup;
return;
}
// Otherwise, find and update the nested group recursively
const updateNestedGroup = (group: any, updated: any): boolean => {
// V2: Use conditions array, V1: use items array
const itemsArray = group.conditions || group.items;
if (!itemsArray || !Array.isArray(itemsArray)) return false;
for (let i = 0; i < itemsArray.length; i++) {
const item = itemsArray[i];
// Check if this is the group to update
if (item.groupId && item.groupId === updated.groupId) {
// Replace the entire group object
if (group.conditions) {
group.conditions[i] = updated;
} else {
group.items[i] = updated;
}
return true;
}
// Recursively check nested groups
const nestedItems = item.conditions || item.items;
if (nestedItems && Array.isArray(nestedItems)) {
if (updateNestedGroup(item, updated)) {
return true;
}
}
}
});
return false;
};
updateNestedGroup(rootGroup, updatedGroup);
};
export const removeConditionGroup = (
@@ -25,8 +138,10 @@ export const removeConditionGroup = (
): void => {
const { formData } = context;
const groupToProcess = currentGroup || formData.query_condition.conditions;
if (!groupToProcess?.items || !Array.isArray(groupToProcess.items)) return;
// V2: Use conditions array, V1: use items array
const itemsArray = groupToProcess?.conditions || groupToProcess?.items;
if (!itemsArray || !Array.isArray(itemsArray)) return;
const filterEmptyGroups = (items: any[]): any[] => {
return items.filter((item: any) => {
@@ -34,16 +149,29 @@ export const removeConditionGroup = (
return false;
}
if (item.items && Array.isArray(item.items)) {
item.items = filterEmptyGroups(item.items);
return item.items.length > 0;
// V2: check conditions, V1: check items
const nestedItems = item.conditions || item.items;
if (nestedItems && Array.isArray(nestedItems)) {
const filtered = filterEmptyGroups(nestedItems);
if (item.conditions) {
item.conditions = filtered;
return item.conditions.length > 0;
} else {
item.items = filtered;
return item.items.length > 0;
}
}
return true;
});
};
groupToProcess.items = filterEmptyGroups(groupToProcess.items);
const filtered = filterEmptyGroups(itemsArray);
if (groupToProcess.conditions) {
groupToProcess.conditions = filtered;
} else {
groupToProcess.items = filtered;
}
};
export const transformFEToBE = (node: any): any => {
@@ -113,4 +241,262 @@ export const retransformBEToFE = (data: any): any => {
label,
items: transformedItems,
};
};
// ============================================================================
// VERSION 2 UTILITIES
// ============================================================================
/**
* Detects which version of conditions structure we have:
*
* V0 (Original): Flat array of conditions, no groups, implicit AND between all
* Example: [{column: "x", operator: "=", value: "1"}, ...]
*
* V1 (Tree-based): Nested structure with {and: [...]} or {or: [...]} or {label, items, groupId}
* Example: {and: [{column: "x", operator: "=", value: "1"}]} or
* {label: "and", items: [...], groupId: "..."}
*
* V2 (Linear): New structure with filterType, logicalOperator per condition
* Example: {filterType: "group", logicalOperator: "AND", conditions: [...]}
*
* Note: version field should be at parent level (query_condition.version), not inside conditions
*/
export const detectConditionsVersion = (conditions: any): 0 | 1 | 2 => {
if (!conditions) return 0;
// Check for V2 structure (newest)
if (conditions.filterType === "group" && conditions.conditions && Array.isArray(conditions.conditions)) {
return 2;
}
// Check for V1 structure (tree-based with nesting)
if (conditions.and || conditions.or || (conditions.label && conditions.items)) {
return 1;
}
// Check for V0 structure (flat array of conditions)
if (Array.isArray(conditions) && conditions.length > 0) {
// It's a flat array - V0
return 0;
}
// Empty or unknown - default to V0
return 0;
};
/**
* Converts V0 flat array to V2 format
* V0: [{column: "x", operator: "=", value: "1"}, ...]
* V2: {filterType: "group", logicalOperator: "AND", conditions: [...]}
* Note: V0 had implicit AND between all conditions (no groups)
*/
export const convertV0ToV2 = (v0Data: any[]): V2Group => {
if (!Array.isArray(v0Data) || v0Data.length === 0) {
return {
filterType: "group",
logicalOperator: "AND",
conditions: [],
groupId: getUUID(),
};
}
// Convert each flat condition to V2 format
const conditions: V2Condition[] = v0Data.map((item: any, index: number) => {
const condition: V2Condition = {
filterType: "condition",
column: item.column || "",
operator: item.operator || "=",
value: item.value || "",
values: item.values || [],
logicalOperator: "AND", // V0 had implicit AND between all
id: item.id || getUUID(),
};
return condition;
});
return {
filterType: "group",
logicalOperator: "AND",
conditions: conditions,
groupId: getUUID(),
};
};
/**
* Converts V1 frontend format (with groupId, label, items) to V2 format
* V1: {groupId, label: "and", items: [...]}
* V2: {filterType: "group", logicalOperator: "AND", conditions: [...]}
*/
export const convertV1ToV2 = (v1Data: any, isFirstGroup: boolean = true): V2Group => {
if (!v1Data) {
return {
filterType: "group",
logicalOperator: "AND",
conditions: [],
};
}
// If it's already V2 format, return as is
if (v1Data.filterType === "group") {
return v1Data;
}
// ["c1","c2","g1","c3","g4"]
// ["g1"]
const items = v1Data.items || [];
const label = v1Data.label || "and";
const logicalOperator = label.toUpperCase() as "AND" | "OR";
const conditions: (V2Condition | V2Group)[] = items.map((item: any, index: number) => {
// Check if it's a nested group
// V1 groups have 'items' array (and optionally 'label' and 'groupId')
if (item.items && Array.isArray(item.items)) {
// It's a V1 group, convert recursively
return convertV1ToV2(item, false);
}
// It's a condition
const condition: V2Condition = {
filterType: "condition",
column: item.column || "",
operator: item.operator || "=",
value: item.value || "",
values: item.values || [],
logicalOperator: logicalOperator,
};
// Keep the id if it exists
if (item.id) {
condition.id = item.id;
}
return condition;
});
const result: V2Group = {
filterType: "group",
logicalOperator: logicalOperator,
conditions: conditions,
};
// Keep groupId if it exists (useful for frontend tracking)
if (v1Data.groupId) {
result.groupId = v1Data.groupId;
}
return result;
};
/**
* Converts V1 backend format to V2 format
* V1 BE: {and: [{column, operator, value}]} or {or: [...]}
* V2: {filterType: "group", logicalOperator: "AND", conditions: [...]}
*
* In V1: Group has operator that applies between all items in that group
* In V2: Each condition has operator that determines how it connects to the NEXT item
*/
export const convertV1BEToV2 = (v1BEData: any): V2Group => {
// we will check if v1bedata is there or not
// if not we will return empty v2 format
if (!v1BEData) {
return {
filterType: "group",
logicalOperator: "AND",
conditions: [],
};
}
// If it's already V2 format, return as is
// it wont execute because we are checking the version before triggering this function
// but incase if we get to this point
// we will return the entire data as v2 format
if (v1BEData.filterType === "group") {
return v1BEData;
}
// Get the operator key (and/or)
// here we will get the toplevel operator like or / and
// becuase based on that only v1 got built for example
// {
// "or":[
// "cond1",
// "cond2",
// "group1",
// "cond3",
// "group2"
// ]
// }
// only group
// {
// "or":[
// "g1"
// ]
// }
//only condition
// {
// "or":[
// "c1"
// ]
// }
//
// here also if we dont have that key we will return empty list
const keys = Object.keys(v1BEData); //or / and
if (keys.length === 0) {
return {
filterType: "group",
logicalOperator: "AND",
conditions: [],
};
}
const operatorKey = keys[0]; // "and" or "or" ["or"]
// here we will get operator key and based on that we will extract all the condition and groups
// items will be after extracting
// ["cond1", "cond2", "group1", "cond3", "group2"]
//if only group is there
// ["group1"]
const items = v1BEData[operatorKey];
const logicalOperator = operatorKey.toUpperCase() as "AND" | "OR";
//in recursive call
//the current logicalOperator will be and
const conditions: (V2Condition | V2Group)[] = items.map((item: any, index: number) => {
// Check if it's a nested group
// here we will map all the items one by one and if we find any group
// then we will again send that to conversion recursively and whatever operator we had for this particualr group before we send
// we will assign it to that nested group logicalOperator
if (item.and || item.or) {
// For nested groups, recursively convert
const nestedGroup = convertV1BEToV2(item);
// CRITICAL: The nested group's logicalOperator should be set to the PARENT's operator
// This is used to determine what operator comes BEFORE this group
nestedGroup.logicalOperator = logicalOperator;
return nestedGroup;
}
// It's a condition
// The condition's logicalOperator is the parent group's operator
// (All items in a group use the same operator from the parent)
const condition: V2Condition = {
filterType: "condition",
column: item.column || "",
operator: item.operator || "=",
value: item.value || "",
values: item.values || [],
logicalOperator: logicalOperator,
id: item.id || getUUID(),
};
return condition;
});
return {
filterType: "group",
logicalOperator: logicalOperator,
conditions: conditions,
groupId: getUUID(),
};
};