diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index c7fd460e0b..9d8ff23922 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -21380,6 +21380,7 @@ "add": "81c5e9c7bb53cc85", "funnelOnly": "718c4d6397114746", "funnelTitle": "def5075533ff7f33", + "funnelTrendTitle": "b33250f5a57105c7", "needsTwoSteps": "2b508475b3d896ed", "snapshot": "127fa27601541b1f", "step": "8e6a6cca7aae1d1e" @@ -21611,18 +21612,49 @@ "alertPartial": "0c63f6c7c218102e", "alertSource": "08d9890299e6380f", "alertSourceNoApp": "fc063af055a3a9f3", + "alertTrend": "ce2c920894d2d1c3", "alertWaiting": "530bfd8f1bb31445", "atLeast": "e9213756a2e67dcb", - "breakdownLabel": "3ffe201a20342a4c", - "breakdownNone": "dc937b59892604f5", - "breakdownTimeHidden": "5d6f45fe981e37dc", + "breakDownByStep": "4f10ab4deb779079", + "breakdownAbout": "b1cdad1da9806151", + "breakdownAboutAny": "842b457e57a2b9fd", + "breakdownAboutAuto": "90e92263bbb903a1", + "breakdownAboutLeft": "8dafcac169f347a6", + "breakdownAboutPinned": "e661c2952adfd82d", + "breakdownAdd": "bed02a99659056c0", + "breakdownBy": "2a78f242ab5020b9", + "breakdownCaption": "92d24b39607213d5", + "breakdownFieldMissing": "c4b1585c2736a23d", + "breakdownOneAtATime": "122408dbab20b98b", + "breakdownPrevTooltip": "8e6e7179a8190a0f", + "breakdownRemove": "abe53e2c8504716e", + "breakdownReplace": "3b25bb55335427e7", + "breakdownValueFrom": "d2ed735e6132a019", "builtinOnly": "093467b110cb5ce7", "byAppRules": "3a37caa52df25a92", + "changeHeader": "9c6e8e0156fef278", + "changeHeaderTooltip": "db51f26d6813c942", + "changePts": "bff5e37b1f2e6b88", + "changeTooltip": "7111a2000b01417b", "clear": "83b12c2216efb4fd", "clearSteps": "f751acbdd806ccfc", "coldDescription": "2c6f5fba43a79bfe", "coldTitle": "895378b29592ac6c", "collapseRail": "48b09313fd3e04c7", + "compareAgo1d": "961f4ea2623a7fd9", + "compareAgo1h": "e4bc2973fad40459", + "compareAgo1w": "b3a32d4c827ad4bd", + "compareEmpty": "dc478b15a65f24f6", + "compareNoLineAbout": "f953d041fded453a", + "compareNoLineBreakdown": "7046d2c049f42785", + "compareOff": "ca7981b46ecf2c17", + "compareOption": "fce297d23b6e718c", + "compareOptionDisabled": "6ca14a6398fff3cd", + "compareOverlaps": "ac411ee7cc3c427f", + "comparePrev": "6d23071bcd0cd538", + "compareSameAsPrev": "6adec20a56bccc5d", + "compareTo": "7c54b8f2d5e1b1c3", + "compareTooLong": "093f3dc5c4296129", "condCredential": "402970ff5230bedd", "condHosts": "780097afcd988f2f", "condInvalid": "3e868d24a71557f7", @@ -21646,6 +21678,7 @@ "suffix": "19ecc104ac1b52b3" }, "conversion": "1f04a8531a463901", + "conversionFromStep": "1f8ac860136ad494", "countBy": "6d9ca04b5373ad60", "dashboardSnapshot": "d9594490b09920ea", "deletedEvent": "fb4b2df95cfd8424", @@ -21661,6 +21694,9 @@ }, "dropped": "f3e695c556d67055", "droppedAria": "d8d0435326e3c9c7", + "droppedCompare": "b535f15e75e76513", + "droppedCompareAria": "4ca8a25a96241db6", + "droppedCompareTooltip": "baaaa8fb38ca257d", "droppedTooltip": "6cc61297f0ea9184", "editTooLongForLink": "ad66d5663c1a675d", "entriesFailed": "fb4cb1dc93563c38", @@ -21707,6 +21743,10 @@ "filterMinTime": "fff9acc4ed2d3ae1", "filterNoField": "b762492c5f823ec6", "filterNoFields": "c77f35a8396b3dce", + "filterNotice": "1933ace35b4539fd", + "filterNoticeAction": "61ffee2d6cb21ee2", + "filterNoticeReason": "56697bbf749b39f9", + "filterNoticeWhy": "57a2ea97a813cc6a", "filterOnPage": "4b2a9a2f125522cb", "filterPressEnter": "d045ff7872f414b3", "filterRejected": "15f5f698fdf254ea", @@ -21714,37 +21754,88 @@ "filterSelected": "c4b07f41437a6599", "filterValuesFailed": "0c963becff830771", "filterWhere": "b48111c10c65fc11", + "fromAnyStep": "6c75e31364d82135", + "fromAutomatic": "15e7d765e6850bf7", + "fromAutomaticNone": "d461a493a3753877", + "fromStep": "572d672bcbcb42da", + "fromStepItem": "566b6925b8defbf7", + "headlineBreakdown": "dcb07833b8e51ebe", + "headlineEstimated": "72ad362e05bd6fd7", + "headlineMedian": "b7b8988b58cf62c7", + "headlineWithin": "4f1ac942bb4dab15", "identityHint": "2aaaccf30574f1bb", "identityHintLink": "12cf486de9dde761", "identityHintReason": "4dc0babcd6eb8006", + "intervalAuto": "0286249762f7c943", + "intervalAutoResolved": "be99031bd841e136", + "intervalDay": "8f2364e11b8be3ff", + "intervalMonth": "310ca503ef36f177", + "intervalNameDay": "944c27e5b97ab779", + "intervalNameMonth": "a5c7d1719e284f2c", + "intervalNameWeek": "6df37daaa46ce714", + "intervalWeek": "e78041ab51a818a2", + "leftBefore": "7cb2e07f11281052", "leftOut": "33a891540eb0929a", + "lowVolume": "d7392969c01babc9", + "lowVolumeNote": "0c42a437548a338d", + "lowVolumeTag": "a2833abba88150b5", "maxExclusions": "b9335161f4ab9d68", "maxFilters": "46a2d58101e89a47", "maxSteps": "68b44b546fcca5f2", + "medianTime": "86635b5efcc23b24", "moveDown": "40bb50da160cdc21", "moveUp": "c66feb5eb8f217c7", "needsNewer": "ee4c0c52d90ca01c", "nextStep": "097264ad2201b7ae", - "noBreakdown": "0cc7965c90ef7f5c", "noneDropped": "9baaca8ec32c4a9c", "notSeen": "88dfae91dfb57cbe", "notSet": "34292f4267fcc405", - "ofFirst": "997bb8222d097f86", + "ofFirst": "fc47d72aaff201af", + "ofFirstCompareTooltip": "9c062ae30384508a", "ofFirstTooltip": "c8f2be3c430bfc4f", - "ofPrevious": "5ab452d92a80fe45", - "ofPreviousTooltip": "cbf1ebfe11e770e9", + "ofPrevious": "69c8887b53b19b22", + "ofPreviousTooltip": "41c698ad34b7922b", "onlyWhen": "3ddcbfe612b2f8d3", "orderLabel": "b2391b3f14e8047a", "other": "f97e9da0e3b879f0", "overall": "5a0c07e7b91a0963", "overallOf": "a70875b2de8f0254", - "overallTime": "af594831628cb460", + "overallVs": "e5cc796b89444c45", + "overallVsAgo": "b807c9504a475e5d", "p90": "f1305bff5633e7c1", "pickFirst": "db06fd0f8aaee98b", + "pickerAllFields": "b2525b1e37fccd0d", + "pickerKeyFields": "82721bb8faf1dfea", + "pickerManyValues": "eb64a0a0f4b8fadb", + "pickerOnStep": "228c6e9121694cf1", + "pickerOnSteps": "6653979843bee928", + "pickerSampleFailed": "4ddd70ceb9ae388d", + "pickerSampling": "f4bb44b880b9f8f2", + "pickerStepHint": "b7bce3cf2906fb4b", + "pickerStepHintMore": "9205b91729440263", + "pickerUser": "b512d97e7cbf97c2", + "pickerUserReason": "dde9dadeab2fab62", + "previousCount": "40fabefc2b1de312", + "previousFailed": "301c27f91a730db2", + "propConditions": { + "contains": "beeb20fbd6769508", + "in": "fa51fd49abf67705", + "notIn": "e1bf1560b1c24336", + "regex": "a54084383e3cf7c2" + }, + "propertyAdd": "49ddf7c4d21883b4", + "propertyAddValue": "8e37953d23daca5f", + "propertyField": "f45fc1dfdc96db27", + "propertyFieldsFailed": "2de3609a08dde3b0", + "propertyFieldsLoading": "c4ecc5b2247dfac2", + "propertyInvalid": "675e566ab1a7f414", + "propertyNoFields": "de8dbcd0fd5e743a", + "propertyRemove": "4870f06022b65693", + "propertyTitle": "40040cb181f7f0ee", + "propertyValueCount": "1e3c53583737e574", "railBreakdown": "3ffe201a20342a4c", - "railSettings": "74a883a037bc227f", "railSteps": "1de3df70ddf4faab", - "railWindow": "19734a1b58cabc22", + "reachedNoValue": "794ce2dd498267c2", "recent": "690dbe9dc0993c42", "remove": "d4c76f463480c865", "removeCond": "5ac0b06fc9f0d038", @@ -21758,18 +21849,60 @@ "step": "8e6a6cca7aae1d1e", "step1EmptyDescription": "eed26ddf894b391d", "step1EmptyTitle": "4fb8686dfdfcf387", + "stepFieldMany": "3b68d256a3f32d52", + "stepFieldValues": "ff2ef73c7926bc4d", "stepFilters": "8ecf2b2e95466ad3", + "stepMenu": "63adffd3e546cec1", "stepsLoading": "ffe6a6578eea3a93", "suggested": "93b63bab4c3a28d7", "suggestedCaption": "b1b590fa7919b4a0", + "thisPeriod": "8ed3e11f85f561ce", "timeFromFirst": "53d599b73f0e93a0", "timeTooltip": "fb0925b1954839e9", "tooLongForLink": "ec1a85f1e4b1db1e", "total": "c9b3c38247f744e1", + "trendArriving": "88fbe06e4aa9b995", + "trendAverage": "7356ed3788622d58", + "trendAverageTooltip": "722694f77d12e53f", + "trendAxis": "b4dbd4a5e48bdec2", + "trendCaption": "911a83a663685b75", + "trendEmpty": "d4243d4882cd89f2", + "trendEmptyDescription": "852556a823199f1e", + "trendEntered": "82bf72604424aa01", + "trendFrom": "859e44a04eb10889", + "trendFromHere": "2bb5b70a17bb11e7", + "trendInterval": "6f45b0005e3b7c18", + "trendLastStep": "1a148e563e50b52c", + "trendLowVolume": "a2833abba88150b5", + "trendLowVolumeTooltip": "94e306c6467f2bb5", + "trendNoDataBefore": "d19efa6bade6fd8d", + "trendNoUnits": "091db1feebdc4ac5", + "trendPairOption": "7fb991266b475c4b", + "trendPartial": "a4d50fb854036548", + "trendPartialTooltip": "c2e1307873dff23b", + "trendPrevCell": "42301fea1f70cce2", + "trendPrevEmpty": "3a71001a0ca683a1", + "trendPrevSameDay": "5118c2cb84dbecd6", + "trendPrevSameMonth": "de9aaf92cdadd295", + "trendPrevSameWeek": "f1513d132be96f97", + "trendPrevSeriesOf": "4d7bab9d39bd6701", + "trendReached": "2ff05123d3c7392b", + "trendStillConverting": "8e5e425208ed570f", + "trendStillConvertingToday": "32fb90267c283978", + "trendStillConvertingTooltip": "135745af2daf9c0a", + "trendTo": "663ea1bfffe5038f", + "trendTooMany": "180c4fa7d8cebbbc", + "trendTooShort": "e61ccbb0320e39c6", + "trendTooltipPrev": "2d420fe630698d8c", + "trendTooltipRate": "53aa76405b151078", "unitTooltip": "13c192e4b4a44f57", "usersExact": "e2e9a093c085caf0", "usersFallback": "5261a4aa0d099341", + "viewAs": "5787b5eda2ab9604", + "viewSteps": "1de3df70ddf4faab", + "viewTrend": "9e217716c4e03eee", "where": "b48111c10c65fc11", + "whoFinishIn": "4f39ddda81e85213", "whyApp": "f4d19be09fd818f4", "whyAppFilters": "62d0f4985864ea3d", "whyBots": "fa91ce7bc3c6e64b", @@ -21786,10 +21919,12 @@ "whyStep1": "92dcdaad0820d15d", "whySynthetic": "f668f1c8d2a22120", "whyTitle": "865b4575e7a77af0", - "window1d": "635ed6bd6796642b", - "window1h": "b0c16d79225bf106", - "window7d": "b10f0b5ea1da77c1", - "windowSession": "f7a61eb02afe4f60" + "window1d": "fa665d95d29e83bc", + "window1h": "f8b8883f0c9d8d82", + "window7d": "7f920bb639c93075", + "windowOfStep1": "b60213cfbeeea2f9", + "windowSession": "50dcffdeed9a0dc8", + "windowSessionShort": "b828373b2bb21b53" }, "identity": { "absent": "923a9ae5620f9b88", diff --git a/src/core/src/rum_pa/mod.rs b/src/core/src/rum_pa/mod.rs index 4e2e729182..d7a8f4da48 100644 --- a/src/core/src/rum_pa/mod.rs +++ b/src/core/src/rum_pa/mod.rs @@ -59,6 +59,20 @@ const WINDOWS: [&str; 4] = ["session", "1h", "1d", "7d"]; const BREAKDOWNS: [&str; 7] = [ "browser", "os", "device", "country", "version", "env", "app", ]; +/// `SessionField` names; a breakdown or step condition never names one as an attribute column. +const SESSION_FIELDS: [&str; 10] = [ + "user_agent_user_agent_family", + "user_agent_os_family", + "user_agent_device_family", + "geo_info_country", + "geo_info_city", + "usr_id", + "usr_email", + "usr_anonymous_id", + "env", + "version", +]; +const MAX_STEP_CONDS: usize = 10; /// A rule must never select on a URL parameter that carries a credential. const CREDENTIAL_PARAMS: [&str; 4] = ["id_token", "access_token", "code", "token"]; @@ -264,8 +278,12 @@ pub struct FunnelDef { pub u: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub w: Option, + /// A built-in key dimension, or any stream column but a session-filter field, env or version. #[serde(default, skip_serializing_if = "Option::is_none")] pub b: Option, + /// 1-based step an attribute `b` reads its value at; absent lets the data pick it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bs: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[schema(value_type = Option>)] pub x: Option>, @@ -303,7 +321,7 @@ impl Step { } } -/// A page step's filters: time on page (ms), `fr: 1`, `er: 1` and view conditions. +/// Step filters; only `pc`, the property conditions, applies to non-page steps. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StepOpts { @@ -315,6 +333,14 @@ pub struct StepOpts { pub er: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub vc: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pc: Vec, +} + +impl StepOpts { + fn page_only(&self) -> bool { + self.min.is_some() || self.fr.is_some() || self.er.is_some() || self.vc.is_some() + } } /// A page step's view condition: a URL parameter, or the referrer host, equals one of `values`. @@ -344,7 +370,7 @@ pub struct Exclusion { pub w: Option, } -/// Filter all steps: keeps sessions where `field` matches one of `values`, or never does if `not`. +/// Matches `field` to `values`, inverted by `not`; `f` filters sessions with it, `pc` step events. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct FunnelFilter { @@ -719,14 +745,7 @@ pub fn validate_def(def: &FunnelDef) -> Result<(serde_json::Value, Vec), WINDOWS.join(", ") ))); } - if let Some(b) = def.b.as_deref() - && !BREAKDOWNS.contains(&b) - { - return Err(invalid_def(format!( - "b must be one of {}", - BREAKDOWNS.join(", ") - ))); - } + validate_breakdown(def)?; let stored = FunnelDef { s: def.s.clone(), u: Some(unit.to_string()), @@ -739,6 +758,7 @@ pub fn validate_def(def: &FunnelDef) -> Result<(serde_json::Value, Vec), .to_string(), ), b: def.b.clone(), + bs: def.bs, x: def.x.clone(), f: def.f.clone(), }; @@ -844,11 +864,12 @@ fn validate_step(step: &Step, event_ids: &mut Vec) -> Result<(), RumPaEr let Some(opts) = step.opts() else { return Ok(()); }; - if kind != "p" { + if kind != "p" && (opts.page_only() || opts.pc.is_empty()) { return Err(invalid_def( - "step options apply only to p steps".to_string(), + "only property conditions apply to c, e and r steps".to_string(), )); } + validate_step_conds(&opts.pc)?; if opts .min .is_some_and(|min| !(0..=MAX_STEP_MS).contains(&min)) @@ -933,6 +954,61 @@ fn validate_filters(filters: Option<&[FunnelFilter]>) -> Result<(), RumPaError> Ok(()) } +// A column named like a dimension reads as the dimension, as on the web. +fn validate_breakdown(def: &FunnelDef) -> Result<(), RumPaError> { + let attribute = match def.b.as_deref() { + None => false, + Some(b) if BREAKDOWNS.contains(&b) => false, + Some(b) if is_attribute_column(b) => true, + Some(_) => { + return Err(invalid_def(format!( + "b is one of {} or a stream column name", + BREAKDOWNS.join(", ") + ))); + } + }; + match def.bs { + None => Ok(()), + Some(_) if !attribute => Err(invalid_def( + "bs applies only to a stream column breakdown".to_string(), + )), + Some(bs) if bs >= 1 && usize::from(bs) <= def.s.len() => Ok(()), + Some(_) => Err(invalid_def(format!( + "bs is 1 to {} for this funnel", + def.s.len() + ))), + } +} + +fn validate_step_conds(conds: &[FunnelFilter]) -> Result<(), RumPaError> { + if conds.len() > MAX_STEP_CONDS { + return Err(invalid_def(format!( + "a step has at most {MAX_STEP_CONDS} property conditions" + ))); + } + for cond in conds { + if !matches!(&cond.field, FilterField::Attribute(name) if is_attribute_column(name)) { + return Err(invalid_def( + "a property condition field is a stream column name".to_string(), + )); + } + if cond.values.is_empty() || cond.values.len() > MAX_RULE_VALUES { + return Err(invalid_def(format!( + "a property condition has 1 to {MAX_RULE_VALUES} values" + ))); + } + for value in &cond.values { + validate_match_value(value, cond.op == MatchOp::Regex).map_err(as_def)?; + } + } + Ok(()) +} + +/// A stream column that is not a built-in session field, as the web's `isFilterColumn` allows it. +fn is_attribute_column(name: &str) -> bool { + is_filter_column(name) && !SESSION_FIELDS.contains(&name) +} + // The name is quoted into SQL, so only what ingest writes passes; `_` columns are internal. fn is_filter_column(name: &str) -> bool { !name.is_empty() @@ -1338,7 +1414,8 @@ mod tests { let (stored, _) = validate_def(&def(&steps, None, None, Some(b))).unwrap(); assert_eq!(stored["b"], b); } - assert!(validate_def(&def(&steps, None, None, Some("city"))).is_err()); + assert!(validate_def(&def(&steps, None, None, Some("geo_info_city"))).is_err()); + assert!(validate_def(&def(&steps, None, None, Some("City"))).is_err()); } #[test] @@ -1412,19 +1489,6 @@ mod tests { ); } - const SESSION_FIELDS: [&str; 10] = [ - "user_agent_user_agent_family", - "user_agent_os_family", - "user_agent_device_family", - "geo_info_country", - "geo_info_city", - "usr_id", - "usr_email", - "usr_anonymous_id", - "env", - "version", - ]; - fn settings_body(sessions: serde_json::Value, pages: serde_json::Value) -> serde_json::Value { serde_json::json!({"bots": true, "sessions": sessions, "pages": pages, "version": 0}) } @@ -1996,4 +2060,187 @@ mod tests { assert_eq!(def_code(with_vc(vc)), "invalid_definition", "{key}"); } } + + fn p8_def(extra: serde_json::Value) -> Result<(serde_json::Value, Vec), RumPaError> { + let mut def = serde_json::json!({"s": [["c", "product_viewed"], ["c", "payment_info_submitted"], ["c", "order_completed"]]}); + for (k, v) in extra.as_object().unwrap() { + def[k] = v.clone(); + } + validate_def(&fdef(def)) + } + + /// Steps `/a`, then `step`, with the given property conditions on `step`. + fn with_pc( + step: (&str, &str), + pc: serde_json::Value, + ) -> Result<(serde_json::Value, Vec), RumPaError> { + validate_def(&fdef( + serde_json::json!({"s": [["p", "/a"], [step.0, step.1, {"pc": pc}]]}), + )) + } + + fn pc_rule(field: &str, op: &str, values: serde_json::Value) -> serde_json::Value { + serde_json::json!({"field": field, "op": op, "values": values}) + } + + #[test] + fn p8_an_attribute_breakdown_is_a_stream_column_that_is_not_a_session_field() { + let (stored, _) = p8_def(serde_json::json!({"b": "context_payment_method"})).unwrap(); + assert_eq!(stored["b"], "context_payment_method", "{stored}"); + assert!(stored.get("bs").is_none(), "{stored}"); + for b in ["Context_plan", "context-plan", "_timestamp", "", "a\"b"] { + assert_eq!( + def_code(p8_def(serde_json::json!({"b": b}))), + "invalid_definition", + "{b}" + ); + } + // env and version are also dimension names, so they read as the dimension. + for b in SESSION_FIELDS.iter().filter(|f| !BREAKDOWNS.contains(f)) { + assert_eq!( + def_code(p8_def(serde_json::json!({"b": b}))), + "invalid_definition", + "{b}" + ); + } + for b in BREAKDOWNS { + assert_eq!(def_code(p8_def(serde_json::json!({"b": b}))), "ok", "{b}"); + } + } + + #[test] + fn p8_bs_pins_a_step_of_an_attribute_breakdown() { + let (stored, _) = + p8_def(serde_json::json!({"b": "context_payment_method", "bs": 2})).unwrap(); + assert_eq!(stored["bs"], 2, "{stored}"); + assert_eq!( + def_code(p8_def( + serde_json::json!({"b": "context_payment_method", "bs": 3}) + )), + "ok" + ); + for bad in [ + serde_json::json!({"bs": 1}), + serde_json::json!({"b": "browser", "bs": 1}), + serde_json::json!({"b": "context_payment_method", "bs": 0}), + serde_json::json!({"b": "context_payment_method", "bs": 4}), + ] { + assert_eq!(def_code(p8_def(bad.clone())), "invalid_definition", "{bad}"); + } + let def = serde_json::json!({"s": [["p", "/a"], ["p", "/b"]], "b": "context_x", "bs": 256}); + assert!(serde_json::from_value::(def).is_err()); + } + + #[test] + fn p8_a_property_condition_rides_on_any_step_kind() { + let pc = serde_json::json!([pc_rule( + "context_payment_method", + "in", + serde_json::json!(["upi"]) + )]); + for step in [("p", "/b"), ("c", "Buy"), ("e", ID_A), ("r", "Boom")] { + let (stored, _) = with_pc(step, pc.clone()).unwrap(); + assert_eq!(stored["s"][1][2], serde_json::json!({"pc": pc}), "{stored}"); + } + let not = serde_json::json!([{"field": "context_plan", "op": "regex", "values": ["^pro"], "not": true}]); + let (stored, _) = with_pc(("c", "Buy"), not.clone()).unwrap(); + assert_eq!(stored["s"][1][2]["pc"], not, "{stored}"); + let page = fdef(serde_json::json!({"s": [["p", "/a", {"min": 1, "pc": pc}], ["p", "/b"]]})); + assert_eq!(def_code(validate_def(&page)), "ok"); + } + + #[test] + fn p8_other_options_stay_on_page_steps() { + let pc = serde_json::json!([pc_rule("context_plan", "eq", serde_json::json!(["pro"]))]); + for opts in [ + serde_json::json!({"pc": pc, "min": 1}), + serde_json::json!({"pc": pc, "fr": 1}), + serde_json::json!({"pc": pc, "er": 1}), + serde_json::json!({"pc": []}), + serde_json::json!({}), + ] { + let def = fdef(serde_json::json!({"s": [["p", "/a"], ["c", "Buy", opts]]})); + assert_eq!(def_code(validate_def(&def)), "invalid_definition", "{opts}"); + } + } + + #[test] + fn p8_property_conditions_are_bounded_like_filters() { + let rule = pc_rule("context_plan", "eq", serde_json::json!(["pro"])); + let twenty: Vec = (0..20).map(|i| format!("v{i}")).collect(); + let too_many: Vec = (0..21).map(|i| format!("v{i}")).collect(); + assert_eq!( + def_code(with_pc( + ("c", "Buy"), + serde_json::json!(vec![rule.clone(); 10]) + )), + "ok" + ); + assert_eq!( + def_code(with_pc( + ("c", "Buy"), + serde_json::json!([pc_rule("context_plan", "in", twenty.into())]) + )), + "ok" + ); + for pc in [ + serde_json::json!(vec![rule.clone(); 11]), + serde_json::json!([pc_rule("context_plan", "in", serde_json::json!([]))]), + serde_json::json!([pc_rule("context_plan", "in", too_many.into())]), + serde_json::json!([pc_rule("context_plan", "eq", serde_json::json!([""]))]), + serde_json::json!([pc_rule("context_plan", "regex", serde_json::json!(["(a"]))]), + serde_json::json!([pc_rule("Context_plan", "eq", serde_json::json!(["x"]))]), + serde_json::json!([pc_rule("geo_info_country", "eq", serde_json::json!(["x"]))]), + serde_json::json!([pc_rule("env", "eq", serde_json::json!(["x"]))]), + serde_json::json!([pc_rule("version", "eq", serde_json::json!(["x"]))]), + ] { + assert_eq!( + def_code(with_pc(("c", "Buy"), pc.clone())), + "invalid_definition", + "{pc}" + ); + } + let unknown = serde_json::json!({"s": [["p", "/a"], ["c", "Buy", {"pc": [{"field": "context_plan", "op": "eq", "values": ["x"], "y": 1}]}]]}); + assert!(serde_json::from_value::(unknown).is_err()); + } + + #[test] + fn p8_a_definition_without_new_keys_serializes_unchanged() { + let def = serde_json::json!({ + "s": [["p", "/a", {"min": 1000, "fr": 1, "er": 1, "vc": [{"t": "ref", "op": "eq", "values": ["google.com"]}]}], ["c", "Buy"], ["e", ID_A]], + "u": "users", + "w": "1d", + "b": "browser", + "x": [{"s": ["p", "/z"], "a": 1, "b": 2}], + "f": [{"field": "context_plan", "op": "in", "values": ["pro"]}] + }); + let (stored, _) = validate_def(&fdef(def.clone())).unwrap(); + assert_eq!(stored.to_string(), def.to_string()); + let text = format!( + r#"{{"s":[["p","/a",{{"min":1000,"fr":1,"er":1,"vc":[{{"t":"ref","op":"eq","values":["google.com"]}}]}}],["c","Buy"],["e","{ID_A}"]],"u":"users","w":"1d","b":"browser","x":[{{"s":["p","/z"],"a":1,"b":2}}],"f":[{{"field":"context_plan","op":"in","values":["pro"]}}]}}"# + ); + let parsed: FunnelDef = serde_json::from_str(&text).unwrap(); + assert_eq!(serde_json::to_string(&parsed).unwrap(), text); + let plain = serde_json::json!({"s": [["p", "/a"], ["p", "/b"]]}); + let (stored, _) = validate_def(&fdef(plain)).unwrap(); + assert_eq!( + stored.to_string(), + r#"{"s":[["p","/a"],["p","/b"]],"u":"sessions","w":"session"}"# + ); + } + + #[test] + fn p8_an_exclusion_step_takes_no_property_conditions() { + let pc = serde_json::json!([pc_rule("context_plan", "eq", serde_json::json!(["pro"]))]); + for step in [ + serde_json::json!(["c", "Buy", {"pc": pc}]), + serde_json::json!(["p", "/z", {"pc": pc}]), + serde_json::json!(["p", "/z", {"min": 1, "pc": pc}]), + ] { + let x = serde_json::json!([{"s": step, "a": 1, "b": 3}]); + assert_eq!(def_code(with_x(x)), "invalid_definition", "{step}"); + } + let plain = serde_json::json!([{"s": ["p", "/z"], "a": 1, "b": 3}]); + assert_eq!(def_code(with_x(plain)), "ok"); + } } diff --git a/tests/ui-testing/pages/rumPages/rumProductAnalyticsPage.js b/tests/ui-testing/pages/rumPages/rumProductAnalyticsPage.js index 4d314a130d..b4de362de7 100644 --- a/tests/ui-testing/pages/rumPages/rumProductAnalyticsPage.js +++ b/tests/ui-testing/pages/rumPages/rumProductAnalyticsPage.js @@ -74,7 +74,6 @@ export class RumProductAnalyticsPage { this.funnelAddStepBtn = page.locator('[data-test="rum-analytics-funnel-add-step-btn"]'); this.funnelAddStep = page.locator('[data-test="rum-analytics-funnel-add-step"]'); this.funnelAddDashboardBtn = page.locator('[data-test="rum-analytics-funnel-add-dashboard-btn"]'); - this.breakdownSelect = page.locator('[data-test="rum-analytics-funnel-breakdown-select"]'); this.breakdown = page.locator('[data-test="rum-analytics-funnel-breakdown"]'); this.dropoffDrawer = page.locator('[data-test="rum-analytics-dropoff-drawer"]'); @@ -593,7 +592,12 @@ export class RumProductAnalyticsPage { async pickConversionWindow(value) { await this.pickOption('rum-analytics-funnel-window-select', { value }); - await expect(this.funnelWindowTrigger).toHaveAttribute('data-test-selected-value', value); + await expect.poll(() => this.funnelFromUrl()?.w).toBe(value); + } + + async pickBreakdown(dim) { + await this.page.locator('[data-test="rum-analytics-funnel-add-breakdown-btn"]').click(); + await this.page.locator(`[data-test="rum-analytics-funnel-field-picker-dim-${dim}"]`).click(); } dropoffSessionReplayTag(i) { diff --git a/tests/ui-testing/playwright-tests/RUM-Analytics/rum-product-analytics.spec.js b/tests/ui-testing/playwright-tests/RUM-Analytics/rum-product-analytics.spec.js index ef03837db1..376e9862b3 100644 --- a/tests/ui-testing/playwright-tests/RUM-Analytics/rum-product-analytics.spec.js +++ b/tests/ui-testing/playwright-tests/RUM-Analytics/rum-product-analytics.spec.js @@ -351,7 +351,7 @@ test.describe('RUM Product Analytics', () => { const funnel = funnelParam([['p', '/web/a'], ['c', 'b-btn']]); await pa.goto('funnels', { app: facts.appId, period: '7d', funnel }); await pa.expectFunnelCounts([6, 3]); - await pa.pickOption('rum-analytics-funnel-breakdown-select', { value: 'browser' }); + await pa.pickBreakdown('browser'); await expect(pa.breakdown).toBeVisible({ timeout: 30000 }); await expect(pa.breakdown).toContainText('Chrome'); await expect(pa.breakdown).toContainText('Firefox'); @@ -663,7 +663,7 @@ test.describe('RUM Product Analytics', () => { await expect(fresh.locator('[data-test="rum-analytics-funnel-saved-name"]')).toHaveText(name, { timeout: 30000 }); await reader.expectFunnelCounts([6, 3]); - await pa.pickOption('rum-analytics-funnel-breakdown-select', { value: 'browser' }); + await pa.pickBreakdown('browser'); await expect(edited).toHaveText('Edited'); const put = pa.waitForAnalyticsWrite('funnel', 'PUT'); await page.locator('[data-test="rum-analytics-funnel-save-btn"]').click(); @@ -730,12 +730,12 @@ test.describe('RUM Product Analytics', () => { await p.goto('funnels', { app: facts.appId, period: '7d', sf: row.id }); await expect(p.page.locator('[data-test="rum-analytics-funnel-saved-name"]')).toHaveText(name, { timeout: 30000 }); } - await first.pickOption('rum-analytics-funnel-breakdown-select', { value: 'browser' }); + await first.pickBreakdown('browser'); const put = first.waitForAnalyticsWrite('funnel', 'PUT'); await page.locator('[data-test="rum-analytics-funnel-save-btn"]').click(); expect((await put).status).toBe(200); - await second.pickOption('rum-analytics-funnel-breakdown-select', { value: 'os' }); + await second.pickBreakdown('os'); const conflict = second.waitForAnalyticsWrite('funnel', 'PUT'); await other.locator('[data-test="rum-analytics-funnel-save-btn"]').click(); expect((await conflict).status).toBe(409); @@ -790,7 +790,7 @@ test.describe('RUM Product Analytics', () => { await page.unroute(list); const unbroken = pa.query().get('funnel'); - await pa.pickOption('rum-analytics-funnel-breakdown-select', { value: 'browser' }); + await pa.pickBreakdown('browser'); await expect(edited).toHaveText('Edited'); // The URL is synced after the debounced recompute, so the edited funnel is read once it lands. await expect.poll(() => pa.query().get('funnel')).not.toBe(unbroken); @@ -1034,9 +1034,9 @@ test.describe('RUM Product Analytics', () => { const pa = new PageManager(page).rumProductAnalyticsPage; await pa.goto('funnels', { app: usersApp.appId, period: '7d', funnel: funnelParam([['p', '/web/a'], ['p', '/web/c']]) }); await pa.expectFunnelCounts([usersApp.sessionsAtA, 0]); - // Sessions are one window by definition, so Sessions mode shows a fixed tag, not a picker. - await expect(pa.funnelWindowFixed).toBeVisible(); + // Sessions are one window by definition, so Sessions mode shows no window control. await expect(pa.funnelWindowTrigger).toHaveCount(0); + await expect(pa.funnelWindowFixed).toHaveCount(0); await pa.selectCountBy('users'); for (const w of ['session', '1h', '1d']) { diff --git a/web/src/components/rum/productAnalytics/AnalyticsFunnels.spec.ts b/web/src/components/rum/productAnalytics/AnalyticsFunnels.spec.ts index 7ed60c850b..11756a6a4c 100644 --- a/web/src/components/rum/productAnalytics/AnalyticsFunnels.spec.ts +++ b/web/src/components/rum/productAnalytics/AnalyticsFunnels.spec.ts @@ -76,10 +76,12 @@ import useProductAnalytics, { resetProductAnalytics } from "@/composables/rum/us import AnalyticsFunnels from "./AnalyticsFunnels.vue"; import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue"; import OButton from "@/lib/core/Button/OButton.vue"; +import OBadge from "@/lib/core/Badge/OBadge.vue"; import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; import i18n from "@/locales"; import { assertJoinFree } from "@/utils/rum/productAnalyticsQueries"; import { decodeDef, encodeDef } from "@/utils/rum/productAnalyticsModel"; +import { formatWindow } from "@/utils/rum/funnelCompare"; import { alertCreationDialog } from "@/composables/alerts/useAlertCreation"; type Hit = Record; @@ -227,6 +229,7 @@ describe("AnalyticsFunnels", () => { template: "
", }, ODropdown: { template: '
' }, + ODropdownSub: { template: '
' }, OPopover: { props: ["open"], emits: ["update:open"], @@ -273,6 +276,19 @@ describe("AnalyticsFunnels", () => { const text = (dt: string) => wrapper!.find(`[data-test="${dt}"]`).text(); + // A searchable OSelect's popover trigger replaces our id, so its label points at nothing. + const expectLabelFocusesSelect = (dt: string, label: string) => { + const select = wrapper!.findComponent(`[data-test="${dt}"]`); + expect(select.props("searchable")).toBe(false); + const id = select.props("id") as string; + const labelEl = document.querySelector(`label[for="${id}"]`); + expect(labelEl?.textContent?.trim()).toBe(label); + const trigger = select.find(`[data-test="${dt}-trigger"]`).element; + expect(document.getElementById(id)).toBe(trigger); + labelEl!.click(); + expect(document.activeElement).toBe(trigger); + }; + // Suggestions and the step picker render only once the toolbar Add step has been clicked. const openAddStep = async () => { if (wrapper!.find('[data-test="rum-analytics-funnel-add-step"]').exists()) return; @@ -361,9 +377,9 @@ describe("AnalyticsFunnels", () => { expect(header.findAll("[data-test^=rum-analytics-funnel-col-]").map((c) => c.text())).toEqual([ "Step", "Sessions", - "From previous", + "Conversion from previous step", "Time from step 1", - "Of step 1", + "Conversion from step 1", ]); expect(header.text()).not.toContain("Reached, as share of step 1"); expect( @@ -376,6 +392,11 @@ describe("AnalyticsFunnels", () => { .find('[data-test="rum-analytics-funnel-col-unit"] [aria-label]') .attributes("aria-label"), ).toBe("Sessions that reached this step"); + expect( + wrapper! + .find('[data-test="rum-analytics-funnel-col-of-previous"] [aria-label]') + .attributes("aria-label"), + ).toBe("Share of the step above that reached this step"); }); it("computes with one join-free search scoped to the app (AC-12, AC-14)", async () => { @@ -435,7 +456,8 @@ describe("AnalyticsFunnels", () => { expect( wrapper!.find('[data-test="rum-analytics-funnel-count-by-users"]').attributes("disabled"), ).toBeDefined(); - expect(wrapper!.find('[data-test="rum-analytics-funnel-window-fixed"]').exists()).toBe(true); + expect(wrapper!.find('[data-test="rum-analytics-funnel-window-fixed"]').exists()).toBe(false); + expect(wrapper!.find('[data-test="rum-analytics-funnel-window-select"]').exists()).toBe(false); await wrapper!.find('[data-test="rum-analytics-funnel-identity-hint-link"]').trigger("click"); await flushPromises(); expect(router.currentRoute.value.name).toBe("productAnalyticsRetention"); @@ -603,7 +625,8 @@ describe("AnalyticsFunnels", () => { expect(text("rum-analytics-funnel-step-0-time")).toBe("—"); expect(text("rum-analytics-funnel-step-1-time")).toContain("24 s"); expect(text("rum-analytics-funnel-step-1-time")).toContain("2.0 min"); - expect(text("rum-analytics-funnel-overall-time")).toContain("36 s"); + expect(text("rum-analytics-funnel-overall-median")).toBe("median 36 s to convert"); + expect(text("rum-analytics-funnel-overall-p90")).toBe("p90 3.0 min"); }); it("splits by a breakdown dimension into rows summing to the total and keeps it in the URL (AC-47)", async () => { @@ -621,12 +644,36 @@ describe("AnalyticsFunnels", () => { expect(funnelSqls().at(-1)).toContain("SELECT dim, "); expect(text("rum-analytics-funnel-breakdown-row-0")).toContain("Chrome"); expect(text("rum-analytics-funnel-breakdown")).toContain("200"); + expect(funnelSqls().at(-1)).toContain("AS mt1"); + expect(wrapper!.text()).not.toContain("Time to convert is shown"); + expect(wrapper!.find('[data-test="rum-analytics-funnel-breakdown-summary"]').exists()).toBe( + false, + ); expect(wrapper!.find('[data-test="rum-analytics-funnel-step-1-time"]').exists()).toBe(false); expect((decodeDef(router.currentRoute.value.query.funnel as string) as { b: string }).b).toBe( "browser", ); }); + it("says when a linked attribute breakdown names a field this app's data lacks", async () => { + await mountFunnels(); + await setSteps([ + ["p", "/a"], + ["c", "b"], + ]); + useProductAnalytics().funnel.value = { + ...useProductAnalytics().funnel.value, + breakdown: { f: "context_missing" }, + }; + await settle(); + expect(text("rum-analytics-funnel-breakdown-start")).toBe( + "context_missing is not a field in this app's data, so all sessions read as not set.", + ); + expect((decodeDef(router.currentRoute.value.query.funnel as string) as { b: string }).b).toBe( + "context_missing", + ); + }); + it("a drop-off opens the drawer for that step in one click and See paths hands over the cohort (AC-16, AC-25)", async () => { await mountFunnels(); await setSteps([ @@ -2114,6 +2161,125 @@ describe("AnalyticsFunnels", () => { }); }); + it("an attribute filter few step-1 sessions carry gets its notice without opening Why N?, and Break down swaps it", async () => { + const PAY = "context_payment_method"; + withSchema(["usr_email", PAY]); + routes = [[/AS opened/, [{ opened: 84, kept: 8, carry1: 36 }]], ...baseRoutes()]; + respond(); + await mountFunnels(); + await twoSteps(); + const whySql = () => sqls().filter((q) => q.includes("AS opened")); + expect(whySql()).toHaveLength(0); + const pa = useProductAnalytics(); + layout().vm.$emit("update:def", { + ...pa.funnel.value, + f: [{ field: PAY, op: "in", values: ["upi"] }], + }); + await settle(); + await settle(); + expect(whySql()).toHaveLength(1); + expect(whySql()[0]).toContain("AS carry1"); + expect(layout().props("filterNotice")).toEqual({ + field: PAY, + kept: 8, + opened: 84, + carry: 36, + }); + layout().vm.$emit("break-down", PAY); + await settle(); + expect(pa.funnel.value.f).toBeUndefined(); + expect(pa.funnel.value.breakdown).toEqual({ f: PAY }); + }); + + it("a built-in filter never runs Why N? on its own and gets no notice", async () => { + withSchema(["usr_email", "geo_info_country"]); + await mountFunnels(); + await twoSteps(); + layout().vm.$emit("update:def", { + ...useProductAnalytics().funnel.value, + f: [{ field: "geo_info_country", op: "in", values: ["India"] }], + }); + await settle(); + expect(sqls().filter((q) => q.includes("AS opened"))).toHaveLength(0); + expect(layout().props("filterNotice")).toBeNull(); + }); + + it("the step-field sample runs once per funnel on request and hands its fields to the layout", async () => { + const PAY = "context_payment_method"; + withSchema(["usr_email", PAY, "view_url"]); + routes = [ + [/PARTITION BY s1 ORDER BY _timestamp DESC/, [{ s1: 0, s2: 1, [PAY]: "card" }]], + ...baseRoutes(), + ]; + respond(); + await mountFunnels(); + await twoSteps(); + const sampleSql = () => + sqls().filter((q) => q.includes("PARTITION BY s1 ORDER BY _timestamp DESC")); + expect(sampleSql()).toHaveLength(0); + expect(layout().props("fieldSampleState")).toBe("idle"); + layout().vm.$emit("load-fields"); + await settle(); + layout().vm.$emit("load-fields"); + await settle(); + expect(sampleSql()).toHaveLength(1); + expect(sampleSql()[0]).toContain(`"${PAY}"`); + expect(sampleSql()[0]).not.toContain('view_url"'); + expect(layout().props("fieldSample")).toEqual({ + fields: [{ field: PAY, steps: [2], top: ["card"], more: 0, many: false }], + }); + }); + + it("a step menu's break-down opens the rail's Breakdown picker on that step's fields and pins the pick", async () => { + const PAY = "context_payment_method"; + withSchema(["usr_email", PAY]); + routes = [ + [/PARTITION BY s1 ORDER BY _timestamp DESC/, [{ s1: 0, s2: 1, [PAY]: "card" }]], + ...baseRoutes(), + ]; + respond(); + await mountFunnels(); + await twoSteps(); + layout().vm.$emit("load-fields"); + layout().vm.$emit("break-down-step", 2); + await settle(); + const picker = wrapper! + .find("aside") + .find('[data-test="rum-analytics-funnel-breakdown-section"]') + .find('[data-test="rum-analytics-funnel-field-picker"]'); + expect(picker.text()).toContain("On step 2 (1)"); + await wrapper! + .find(`[data-test="rum-analytics-funnel-field-picker-field-${PAY}"]`) + .trigger("click"); + await settle(); + expect(useProductAnalytics().funnel.value.breakdown).toEqual({ f: PAY, s: 2 }); + }); + + it("a step menu's break-down expands a collapsed rail first, then opens the picker on that step", async () => { + const PAY = "context_payment_method"; + withSchema(["usr_email", PAY]); + routes = [ + [/PARTITION BY s1 ORDER BY _timestamp DESC/, [{ s1: 0, s2: 1, [PAY]: "card" }]], + ...baseRoutes(), + ]; + respond(); + window.localStorage.setItem("o2.rum.analytics.funnelRailCollapsed", "1"); + await mountFunnels(); + await twoSteps(); + const toggle = () => wrapper!.find('[data-test="rum-analytics-funnel-rail-collapse-btn"]'); + expect(toggle().attributes("aria-expanded")).toBe("false"); + layout().vm.$emit("load-fields"); + layout().vm.$emit("break-down-step", 2); + await settle(); + expect(toggle().attributes("aria-expanded")).toBe("true"); + const section = wrapper! + .find("aside") + .find('[data-test="rum-analytics-funnel-breakdown-section"]'); + expect(section.find('[data-test="rum-analytics-funnel-field-picker"]').text()).toContain( + "On step 2 (1)", + ); + }); + it("Why N? runs its own query only when opened, and its lines add up to the entry count", async () => { routes = [ [ @@ -2198,4 +2364,1181 @@ describe("AnalyticsFunnels", () => { expect(panels[0].queries[0].query).not.toContain("env IN"); }); }); + + describe("toolbar and rail: count, window, breakdown section, headline badges", () => { + const at = (dt: string) => wrapper!.find(`[data-test="${dt}"]`); + const controls = () => at("rum-analytics-funnel-toolbar-controls"); + const badge = (dt: string) => + wrapper!.findAllComponents(OBadge).find((b) => b.attributes("data-test") === dt); + const threeSteps = async () => { + await setSteps([ + ["p", "/a"], + ["c", "b"], + ["p", "/c"], + ]); + }; + const patch = async (d: Record) => { + const pa = useProductAnalytics(); + pa.funnel.value = { ...pa.funnel.value, ...d }; + await settle(); + }; + const COLLAPSED_KEY = "o2.rum.analytics.funnelRailCollapsed"; + const BREAKDOWN_SECTION = '[data-test="rum-analytics-funnel-breakdown-section"]'; + const follows = (a: Element, b: Element) => + !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + + it("reads View as and Count by, with Compare to pushed to the end; Sessions has no window control", async () => { + await mountFunnels(); + await threeSteps(); + const row = controls(); + expect(row.text()).toContain("View as"); + expect(row.text()).toContain("Count by"); + expect(at("rum-analytics-funnel-window-select").exists()).toBe(false); + expect(at("rum-analytics-funnel-window-fixed").exists()).toBe(false); + expect(row.text()).not.toContain("Same session"); + expect(row.text()).not.toContain("who finish steps in"); + const compare = row.find('[data-test="rum-analytics-funnel-compare"]'); + expect(compare.classes()).toContain("ms-auto"); + expect(row.element.lastElementChild).toBe(compare.element); + }); + + it("Visitors adds who finish steps in after Count by, its menu naming what each clock starts from", async () => { + await mountFunnels(); + await threeSteps(); + await at("rum-analytics-funnel-count-by-users").trigger("click"); + await settle(); + expect(controls().text()).toContain("who finish steps in"); + const select = wrapper!.findComponent('[data-test="rum-analytics-funnel-window-select"]'); + expect(select.exists()).toBe(true); + expect(follows(at("rum-analytics-funnel-count-by").element, select.element)).toBe(true); + const options = select.props("options") as { label: string; value: string }[]; + expect(options.map((o) => [String(o.label), o.value])).toEqual([ + ["the same session · one visit", "session"], + ["1 hour · of step 1", "1h"], + ["1 day · of step 1", "1d"], + ["7 days · of step 1", "7d"], + ]); + }); + + it("who finish steps in and Compare to are plain menus their labels focus, both narrow", async () => { + await mountFunnels(); + await threeSteps(); + await at("rum-analytics-funnel-count-by-users").trigger("click"); + await settle(); + expectLabelFocusesSelect("rum-analytics-funnel-window-select", "who finish steps in"); + expectLabelFocusesSelect("rum-analytics-funnel-compare-select", "Compare to"); + const window = wrapper!.findComponent('[data-test="rum-analytics-funnel-window-select"]'); + expect(window.props("width")).toBe("sm"); + }); + + it("the who finish steps in trigger shows a short window while its menu keeps the long labels", async () => { + await mountFunnels(); + await threeSteps(); + await patch({ unit: "users", window: "session" }); + const select = wrapper!.findComponent('[data-test="rum-analytics-funnel-window-select"]'); + const trigger = () => at("rum-analytics-funnel-window-select-trigger").text().trim(); + expect(select.props("width")).toBe("sm"); + expect(trigger()).toBe("the same session"); + await patch({ window: "1d" }); + expect(trigger()).toBe("1 day"); + const labels = (select.props("options") as { label: string }[]).map((o) => String(o.label)); + expect(labels).toContain("the same session · one visit"); + expect(labels).toContain("1 day · of step 1"); + }); + + it("Breakdown is the rail's first section, not a toolbar control, and its + sets the breakdown", async () => { + await mountFunnels(); + await threeSteps(); + expect(controls().find('[data-test="rum-analytics-funnel-breakdown-select"]').exists()).toBe( + false, + ); + expect(controls().find(BREAKDOWN_SECTION).exists()).toBe(false); + const aside = wrapper!.find("aside"); + const section = aside.find(BREAKDOWN_SECTION); + expect(section.exists()).toBe(true); + expect( + follows( + section.element, + aside.find('[data-test="rum-analytics-funnel-filter-all"]').element, + ), + ).toBe(true); + await section.find('[data-test="rum-analytics-funnel-add-breakdown-btn"]').trigger("click"); + await settle(); + await section + .find('[data-test="rum-analytics-funnel-field-picker-dim-browser"]') + .trigger("click"); + await settle(); + expect(useProductAnalytics().funnel.value.breakdown).toBe("browser"); + expect(aside.find('[data-test="rum-analytics-funnel-breakdown-chip"]').text()).toContain( + "Browser", + ); + }); + + it("the Sessions headline shows count, median and p90 as badges, then Why N?", async () => { + await mountFunnels(); + await threeSteps(); + const ids = [ + "rum-analytics-funnel-overall-count", + "rum-analytics-funnel-overall-median", + "rum-analytics-funnel-overall-p90", + ]; + for (const id of ids) { + expect(badge(id)?.props("variant")).toBe("default-soft"); + expect(badge(id)?.props("size")).toBe("sm"); + } + expect(text("rum-analytics-funnel-overall-count")).toBe("40 of 200 sessions"); + expect(at("rum-analytics-funnel-overall-window").exists()).toBe(false); + expect(at("rum-analytics-funnel-overall-breakdown").exists()).toBe(false); + const order = at("rum-analytics-funnel-overall") + .findAll("[data-test]") + .map((e) => e.attributes("data-test")) + .filter((id) => [...ids, "rum-analytics-funnel-why-btn"].includes(id!)); + expect(order).toEqual([...ids, "rum-analytics-funnel-why-btn"]); + }); + + it("Visitors with a window adds within 1 day of step 1, and the same-session window adds none", async () => { + await mountFunnels(); + await threeSteps(); + await patch({ unit: "users", window: "1d" }); + expect(badge("rum-analytics-funnel-overall-window")?.props("variant")).toBe("default-soft"); + expect(text("rum-analytics-funnel-overall-window")).toBe("within 1 day of step 1"); + expect( + follows( + at("rum-analytics-funnel-overall-count").element, + at("rum-analytics-funnel-overall-window").element, + ), + ).toBe(true); + expect(at("rum-analytics-funnel-why-btn").exists()).toBe(false); + await patch({ window: "session" }); + expect(at("rum-analytics-funnel-overall-window").exists()).toBe(false); + }); + + it("a breakdown adds a clickable by-field badge tied to the rail, and drops median and p90", async () => { + await mountFunnels(); + await threeSteps(); + await patch({ breakdown: "browser" }); + const by = badge("rum-analytics-funnel-overall-breakdown")!; + expect(by).toBeDefined(); + expect(by.text()).toBe("by Browser"); + expect(by.props("clickable")).toBe(true); + expect(by.props("variant")).toBe("primary-soft"); + expect(by.element.tagName).toBe("BUTTON"); + expect(by.attributes("aria-expanded")).toBe("true"); + expect(at("rum-analytics-funnel-overall-median").exists()).toBe(false); + expect(at("rum-analytics-funnel-overall-p90").exists()).toBe(false); + await at("rum-analytics-funnel-rail-collapse-btn").trigger("click"); + expect(at("rum-analytics-funnel-overall-breakdown").attributes("aria-expanded")).toBe( + "false", + ); + }); + + it("the by-field badge expands a collapsed rail and moves focus to the Breakdown section", async () => { + window.localStorage.setItem(COLLAPSED_KEY, "1"); + await mountFunnels(); + await threeSteps(); + await patch({ breakdown: "browser" }); + const toggle = () => at("rum-analytics-funnel-rail-collapse-btn"); + expect(toggle().attributes("aria-expanded")).toBe("false"); + await at("rum-analytics-funnel-overall-breakdown").trigger("click"); + await settle(); + expect(toggle().attributes("aria-expanded")).toBe("true"); + expect(at("rum-analytics-funnel-overall-breakdown").attributes("aria-expanded")).toBe("true"); + const section = wrapper!.find("aside").find(BREAKDOWN_SECTION).element; + expect(section.contains(document.activeElement)).toBe(true); + }); + + it("the by-field badge on a collapsed rail focuses the breakdown chip with a visible focus ring", async () => { + window.localStorage.setItem(COLLAPSED_KEY, "1"); + await mountFunnels(); + await threeSteps(); + await patch({ breakdown: "browser" }); + const focus = vi.spyOn(HTMLElement.prototype, "focus"); + try { + await at("rum-analytics-funnel-overall-breakdown").trigger("click"); + await settle(); + const chip = wrapper!.find('[data-test="rum-analytics-funnel-breakdown-chip"]').element; + expect(document.activeElement).toBe(chip); + const onChip = focus.mock.calls.filter((_, i) => focus.mock.contexts[i] === chip); + expect(onChip.length).toBeGreaterThan(0); + expect(onChip.at(-1)).toEqual([{ focusVisible: true }]); + } finally { + focus.mockRestore(); + } + }); + + it("the by-field badge on an expanded rail keeps it expanded and moves focus to the Breakdown section", async () => { + await mountFunnels(); + await threeSteps(); + await patch({ breakdown: "browser" }); + const toggle = () => at("rum-analytics-funnel-rail-collapse-btn"); + expect(toggle().attributes("aria-expanded")).toBe("true"); + await at("rum-analytics-funnel-overall-breakdown").trigger("click"); + await settle(); + expect(toggle().attributes("aria-expanded")).toBe("true"); + expect(at("rum-analytics-funnel-overall-breakdown").attributes("aria-expanded")).toBe("true"); + const section = wrapper!.find("aside").find(BREAKDOWN_SECTION).element; + expect(section.contains(document.activeElement)).toBe(true); + }); + + it("the by-field badge still shows in Trend view", async () => { + await mountFunnels(); + await threeSteps(); + await patch({ breakdown: "browser" }); + await at("rum-analytics-funnel-view-trend").trigger("click"); + await settle(); + expect(router.currentRoute.value.query.fv).toBe("trend"); + expect(text("rum-analytics-funnel-overall-breakdown")).toBe("by Browser"); + }); + + it("orders the badges count, window, breakdown, change for Visitors with a window, a breakdown and compare", async () => { + await mountFunnels({ fc: "prev" }); + await threeSteps(); + await patch({ unit: "users", window: "1d", breakdown: "browser" }); + const ids = ["count", "window", "breakdown", "change"].map( + (k) => `rum-analytics-funnel-overall-${k}`, + ); + const order = at("rum-analytics-funnel-overall") + .findAll("[data-test]") + .map((e) => e.attributes("data-test")) + .filter((id) => ids.includes(id!)); + expect(order).toEqual(ids); + }); + + it("the by-field badge and a step menu's break-down scroll the Breakdown section into view", async () => { + const scroll = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scroll, + }); + try { + await mountFunnels(); + await threeSteps(); + await patch({ breakdown: "browser" }); + const section = () => wrapper!.find("aside").find(BREAKDOWN_SECTION).element; + await at("rum-analytics-funnel-overall-breakdown").trigger("click"); + await settle(); + expect(scroll).toHaveBeenCalledWith({ block: "nearest" }); + expect(scroll.mock.contexts.at(-1)).toBe(section()); + scroll.mockClear(); + wrapper!.findComponent({ name: "FunnelLayoutRail" }).vm.$emit("break-down-step", 2); + await settle(); + expect(scroll).toHaveBeenCalledWith({ block: "nearest" }); + expect(scroll.mock.contexts.at(-1)).toBe(section()); + } finally { + delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView; + } + }); + + it("shows no breakdown values line above the steps table", async () => { + await mountFunnels(); + await threeSteps(); + await patch({ breakdown: "browser" }); + expect(text("rum-analytics-funnel-breakdown-row-0")).toContain("Chrome"); + expect(at("rum-analytics-funnel-breakdown-summary").exists()).toBe(false); + expect(wrapper!.text()).not.toMatch(/Chrome 40\.0%\s*·\s*Firefox/); + }); + }); + + describe("compare to the previous period (P1-11)", () => { + const isFunnel = (sql: string) => /FROM (ps|pu) LIMIT 1|SELECT dim, /.test(sql); + type Call = { sql: string; start_time: number; end_time: number }; + const calls = (): Call[] => + vi.mocked(searchService.search).mock.calls.map((c) => c[0].query.query as Call); + const prevCalls = () => { + const cur = useProductAnalytics().range.value!; + return calls().filter((q) => isFunnel(q.sql) && q.end_time <= cur.startUs); + }; + + // The previous window answers with its own numbers; everything else goes to the shared routes. + const answerPrevious = (prev: (sql: string, q: Call) => Hit[] | Promise) => { + const base = vi.mocked(searchService.search).getMockImplementation()!; + vi.mocked(searchService.search).mockImplementation((async (args: { + query: { query: Call }; + }) => { + const q = args.query.query; + const cur = useProductAnalytics().range.value; + if (cur && q.end_time <= cur.startUs && isFunnel(q.sql)) { + return { data: { hits: await prev(q.sql, q) } }; + } + return base(args as never); + }) as never); + }; + + const prevHit = (counts: number[]) => (sql: string) => { + const n = (sql.match(/AS m\d+/g) ?? []).length; + return [ + Object.fromEntries( + counts.slice(0, n).flatMap((c, i) => [ + [`c${i + 1}`, c], + [`seen${i + 1}`, c], + ]), + ), + ]; + }; + + const DAY_US = 86_400_000_000; + const COMPARE = "rum-analytics-funnel-compare-select"; + const compareSelect = () => wrapper!.findComponent(`[data-test="${COMPARE}"]`); + const pickCompare = async (v: string) => { + compareSelect().vm.$emit("update:modelValue", v); + await settle(); + }; + const turnOn = () => pickCompare("prev"); + const turnOff = () => pickCompare("off"); + const compareOptions = () => + ( + compareSelect().props("options") as { label: unknown; value: string; disabled?: boolean }[] + ).map((o) => ({ label: String(o.label), value: o.value, disabled: !!o.disabled })); + const shifted = (byUs: number) => { + const cur = useProductAnalytics().range.value!; + return formatWindow({ startUs: cur.startUs - byUs, endUs: cur.endUs - byUs }, "UTC"); + }; + const colTip = (id: string) => + wrapper! + .find(`[data-test="rum-analytics-funnel-col-${id}"] [aria-label]`) + .attributes("aria-label"); + + const threeSteps = [ + ["p", "/a"], + ["c", "b"], + ["p", "/c"], + ]; + + it("runs the same funnel over the window before and never changes this period's numbers", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels(); + await setSteps(threeSteps); + const before = { + counts: [0, 1, 2].map((i) => text(`rum-analytics-funnel-step-${i}-count`)), + overall: text("rum-analytics-funnel-overall"), + funnelRuns: calls().filter((q) => isFunnel(q.sql)).length, + }; + const current = calls() + .filter((q) => isFunnel(q.sql)) + .at(-1)!; + await turnOn(); + expect(router.currentRoute.value.query.fc).toBe("prev"); + expect(calls().filter((q) => isFunnel(q.sql))).toHaveLength(before.funnelRuns + 1); + const prev = prevCalls(); + expect(prev).toHaveLength(1); + expect(prev[0].sql).toBe(current.sql); + expect(prev[0].start_time).toBe(current.start_time - (current.end_time - current.start_time)); + expect([0, 1, 2].map((i) => text(`rum-analytics-funnel-step-${i}-count`))).toEqual( + before.counts, + ); + expect(text("rum-analytics-funnel-overall")).toContain(before.overall.split(" ")[0]); + expect(text("rum-analytics-funnel-overall")).toContain("20.0%"); + expect(text("rum-analytics-funnel-step-1-prev-count")).toBe("vs 60"); + expect(text("rum-analytics-funnel-step-1-prev-rate")).toBe("60.0%"); + expect(text("rum-analytics-funnel-step-1-change")).toContain("▼ 20.0 pts"); + expect(text("rum-analytics-funnel-step-0-change")).toBe("—"); + expect(text("rum-analytics-funnel-overall-change")).toContain("vs 30.0% in"); + await turnOff(); + expect(useProductAnalytics().funnelCompare.value).toBeNull(); + expect(router.currentRoute.value.query.fc).toBeUndefined(); + expect([0, 1, 2].map((i) => text(`rum-analytics-funnel-step-${i}-count`))).toEqual( + before.counts, + ); + expect(calls().filter((q) => isFunnel(q.sql))).toHaveLength(before.funnelRuns + 1); + }); + + it("splits the step-to-step column under a group header and labels drop-offs with both periods", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ fc: "prev" }); + await setSteps(threeSteps); + const header = wrapper!.find('[data-test="rum-analytics-funnel-steps-header"]'); + expect(header.findAll("[data-test^=rum-analytics-funnel-col-]").map((c) => c.text())).toEqual( + [ + "Conversion from previous step", + "Step", + "Sessions", + "This period", + "Previous period", + "Change (pts)", + "Time from step 1", + "Conversion from step 1", + ], + ); + expect( + wrapper! + .find('[data-test="rum-analytics-funnel-col-change"] [aria-label]') + .attributes("aria-label"), + ).toBe("Change in percentage points, in conversion from the step above."); + expect(text("rum-analytics-funnel-dropoff-0")).toBe( + "60.0% dropped (120) vs 40.0% (40) previous period", + ); + expect(wrapper!.find('[data-test="rum-analytics-funnel-bar-previous"]').exists()).toBe(true); + // The previous run is enqueued after the current one, so it is the last funnel search. + const funnelRuns = calls().filter((q) => isFunnel(q.sql)); + expect(funnelRuns.at(-1)!.end_time).toBe(useProductAnalytics().range.value!.startUs); + expect(funnelRuns.at(-2)!.end_time).toBe(useProductAnalytics().range.value!.endUs); + }); + + it.each([ + ["down", [100, 60, 30], "error-soft"], + ["up", [100, 60, 10], "success-soft"], + ["low volume", [10, 6, 3], "default-soft"], + ] as const)( + "the headline change is a badge toned by its direction (%s)", + async (kind, counts, tone) => { + answerPrevious(prevHit([...counts])); + await mountFunnels({ fc: "prev" }); + await setSteps(threeSteps); + const change = wrapper! + .findAllComponents(OBadge) + .find((b) => b.attributes("data-test") === "rum-analytics-funnel-overall-change"); + expect(change?.props("variant")).toBe(tone); + expect(change?.props("size")).toBe("sm"); + if (kind === "low volume") expect(change!.text()).toMatch(/low volume/i); + }, + ); + + it("the headline change badge keeps its ±CI tooltip", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ fc: "prev" }); + await setSteps(threeSteps); + const headline = wrapper!.find('[data-test="rum-analytics-funnel-overall"]').element; + const change = wrapper!.find('[data-test="rum-analytics-funnel-overall-change"]'); + expect(change.exists()).toBe(true); + const tips = wrapper! + .findAllComponents(OTooltip) + .filter((t) => headline.contains(t.element)) + .map((t) => String(t.props("content"))); + expect(tips.some((c) => /95% interval: ±/.test(c))).toBe(true); + }); + + it("puts no plain range text between the headline badges", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ fc: "prev" }); + await setSteps(threeSteps); + const headline = wrapper!.find('[data-test="rum-analytics-funnel-overall"]'); + expect(headline.find('[data-test="rum-analytics-funnel-overall-change"]').exists()).toBe( + true, + ); + expect(headline.find('[data-test="rum-analytics-funnel-overall-range"]').exists()).toBe( + false, + ); + expect(headline.text()).not.toContain(`· ${shifted(0)}`); + }); + + it("a one-step funnel shows no range and no change in the headline", async () => { + answerPrevious(prevHit([100])); + await mountFunnels({ fc: "prev" }); + await setSteps([["p", "/a"]]); + expect(wrapper!.find('[data-test="rum-analytics-funnel-overall-range"]').exists()).toBe( + false, + ); + expect(wrapper!.find('[data-test="rum-analytics-funnel-overall-change"]').exists()).toBe( + false, + ); + }); + + it("a funnel with no steps keeps the compare-off layout", async () => { + await mountFunnels({ fc: "prev" }); + expect(useProductAnalytics().funnelCompare.value).toBe("prev"); + expect(wrapper!.find('[data-test="rum-analytics-funnel-col-previous-period"]').exists()).toBe( + false, + ); + expect(wrapper!.find('[data-test="rum-analytics-funnel-overall-change"]').exists()).toBe( + false, + ); + expect(prevCalls()).toHaveLength(0); + }); + + it("an empty previous period says so in words and shows dashes, not 0% or 100%", async () => { + answerPrevious(prevHit([0, 0, 0])); + await mountFunnels({ fc: "prev" }); + await setSteps(threeSteps); + expect(text("rum-analytics-funnel-compare-empty")).toMatch( + /^No sessions in .+ to compare with$/, + ); + expect(text("rum-analytics-funnel-step-1-prev-rate")).toBe("—"); + expect(text("rum-analytics-funnel-step-1-change")).toBe("—"); + expect(wrapper!.find('[data-test="rum-analytics-funnel-step-1-prev-count"]').exists()).toBe( + false, + ); + expect(text("rum-analytics-funnel-dropoff-0")).toBe("120 dropped (60.0%)"); + expect(wrapper!.find('[data-test="rum-analytics-funnel-overall-change"]').exists()).toBe( + false, + ); + }); + + it("a failed previous run blanks only the compare cells", async () => { + answerPrevious(() => + Promise.reject(Object.assign(new Error("boom"), { response: { status: 500 } })), + ); + await mountFunnels({ fc: "prev" }); + await setSteps(threeSteps); + expect(text("rum-analytics-funnel-step-1-count")).toBe("80"); + expect(text("rum-analytics-funnel-step-1-prev-rate")).toBe("—"); + expect(text("rum-analytics-funnel-step-1-change")).toBe("—"); + expect(text("rum-analytics-funnel-dropoff-0")).toBe("120 dropped (60.0%)"); + }); + + it("is offered only up to 31 days and stays on across a range change", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ fc: "prev", period: "60d" }); + await setSteps(threeSteps); + expect(compareSelect().props("disabled")).toBe(true); + const trigger = wrapper!.find(`[data-test="${COMPARE}-trigger"]`).text().trim(); + expect(trigger).toBe("Previous period"); + expect(trigger).not.toMatch(/·|overlaps this range|same as previous period/); + const tips = wrapper! + .find('[data-test="rum-analytics-funnel-compare"]') + .findAllComponents(OTooltip) + .map((t) => String(t.props("content"))); + expect(tips).toContain( + "Compare works on ranges up to 31 days. Pick a shorter range to compare.", + ); + expect(prevCalls()).toHaveLength(0); + useProductAnalytics().setScope({ + datetime: { valueType: "relative", relativeTimePeriod: "7d", startTime: 0, endTime: 0 }, + }); + await settle(); + expect(compareSelect().props("disabled")).toBeFalsy(); + expect(useProductAnalytics().funnelCompare.value).toBe("prev"); + expect(prevCalls()).toHaveLength(1); + expect(text("rum-analytics-funnel-overall-change")).toContain("vs 30.0% in"); + }); + + it("is one labelled Compare to select, Off by default, replacing the switch and its vs text", async () => { + await mountFunnels(); + await setSteps(threeSteps); + const wrap = wrapper!.find('[data-test="rum-analytics-funnel-compare"]'); + expect(wrap.find(`[data-test="${COMPARE}"]`).exists()).toBe(true); + const id = compareSelect().props("id") as string; + expect(id).toBeTruthy(); + expect(wrap.find(`label[for="${id}"]`).text()).toBe("Compare to"); + expect(compareSelect().props("modelValue")).toBe("off"); + expect(wrapper!.find('[data-test="rum-analytics-funnel-compare-switch"]').exists()).toBe( + false, + ); + expect(wrapper!.find('[data-test="rum-analytics-funnel-compare-vs"]').exists()).toBe(false); + expect(compareOptions().map((o) => o.value)).toEqual(["off", "prev", "1h", "1d", "1w"]); + expect(compareOptions()[0]).toEqual({ label: "Off", value: "off", disabled: false }); + }); + + it("the Compare to trigger names only the mode, while its menu options keep their dates", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ period: "1d" }); + await setSteps(threeSteps); + const trigger = () => wrapper!.find(`[data-test="${COMPARE}-trigger"]`); + expect(compareSelect().props("width")).toBe("sm"); + expect(trigger().text().trim()).toBe("Off"); + await pickCompare("prev"); + const range = useProductAnalytics().range.value!; + const prevDates = shifted(range.endUs - range.startUs); + expect(trigger().text().trim()).toBe("Previous period"); + expect(compareOptions()[1].label).toContain(prevDates); + await pickCompare("1w"); + expect(trigger().text().trim()).toBe("1 week ago"); + expect(trigger().text()).not.toContain(shifted(7 * DAY_US)); + expect(compareOptions()[4].label).toContain(shifted(7 * DAY_US)); + }); + + it("on a 7-day range offers only Previous period, with its dates; the offsets say why they are greyed", async () => { + await mountFunnels(); + await setSteps(threeSteps); + const range = useProductAnalytics().range.value!; + const [, prev, h, d, w] = compareOptions(); + expect(prev.disabled).toBe(false); + expect(prev.label).toContain("Previous period"); + expect(prev.label).toContain(shifted(range.endUs - range.startUs)); + expect(h).toMatchObject({ disabled: true }); + expect(h.label).toContain("1 hour ago"); + expect(h.label).toContain("overlaps this range"); + expect(d).toMatchObject({ disabled: true }); + expect(d.label).toContain("1 day ago"); + expect(d.label).toContain("overlaps this range"); + expect(w).toMatchObject({ disabled: true }); + expect(w.label).toContain("1 week ago"); + expect(w.label).toContain("same as previous period"); + }); + + it("on a 1-day range offers 1 week ago with its dates, greys 1 day ago as the previous period", async () => { + await mountFunnels({ period: "1d" }); + await setSteps(threeSteps); + const [, prev, h, d, w] = compareOptions(); + expect(prev.disabled).toBe(false); + expect(h.disabled).toBe(true); + expect(h.label).toContain("overlaps this range"); + expect(d.disabled).toBe(true); + expect(d.label).toContain("same as previous period"); + expect(w.disabled).toBe(false); + expect(w.label).toContain("1 week ago"); + expect(w.label).toContain(shifted(7 * DAY_US)); + expect(w.label).not.toContain("overlaps this range"); + }); + + it("1 week ago runs the funnel a week back and names that period in the headline and the table", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ period: "1d" }); + await setSteps(threeSteps); + await pickCompare("1w"); + expect(router.currentRoute.value.query.fc).toBe("1w"); + expect(compareSelect().props("modelValue")).toBe("1w"); + const cur = useProductAnalytics().range.value!; + const prev = prevCalls(); + expect(prev).toHaveLength(1); + expect(prev[0].start_time).toBe(cur.startUs - 7 * DAY_US); + expect(prev[0].end_time).toBe(cur.endUs - 7 * DAY_US); + const change = text("rum-analytics-funnel-overall-change"); + expect(change).toContain("30.0%"); + expect(change).toContain("1 week ago"); + expect(change).toContain(shifted(7 * DAY_US)); + expect(text("rum-analytics-funnel-col-previous-period")).toBe("1 week ago"); + expect(text("rum-analytics-funnel-step-1-prev-rate")).toBe("60.0%"); + }); + + it("switching the mode reruns the previous funnel for the new window", async () => { + answerPrevious((sql, q) => { + const cur = useProductAnalytics().range.value!; + return prevHit(q.end_time === cur.startUs ? [100, 60, 30] : [100, 90, 80])(sql); + }); + await mountFunnels({ period: "1d" }); + await setSteps(threeSteps); + await pickCompare("prev"); + expect(text("rum-analytics-funnel-overall-change")).toContain("vs 30.0%"); + await pickCompare("1w"); + expect(prevCalls()).toHaveLength(2); + expect(text("rum-analytics-funnel-overall-change")).toContain("80.0%"); + expect(text("rum-analytics-funnel-step-2-prev-rate")).toBe("88.9%"); + await pickCompare("prev"); + expect(text("rum-analytics-funnel-overall-change")).toContain("vs 30.0%"); + expect(text("rum-analytics-funnel-step-2-prev-rate")).toBe("50.0%"); + }); + + it("a range the offset no longer fits compares with the previous period and keeps fc, so going back restores it", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ fc: "1w", period: "1d" }); + await setSteps(threeSteps); + const setPeriod = async (p: string) => { + useProductAnalytics().setScope({ + datetime: { valueType: "relative", relativeTimePeriod: p, startTime: 0, endTime: 0 }, + }); + await settle(); + }; + const last = () => prevCalls().at(-1)!; + expect(last().end_time).toBe(useProductAnalytics().range.value!.endUs - 7 * DAY_US); + await setPeriod("7d"); + expect(router.currentRoute.value.query.fc).toBe("1w"); + expect(useProductAnalytics().funnelCompare.value).toBe("1w"); + expect(compareSelect().props("modelValue")).toBe("prev"); + expect(wrapper!.find(`[data-test="${COMPARE}-trigger"]`).text().trim()).toBe( + "Previous period", + ); + expect(last().end_time).toBe(useProductAnalytics().range.value!.startUs); + expect(text("rum-analytics-funnel-col-previous-period")).toBe("Previous period"); + await setPeriod("1d"); + expect(compareSelect().props("modelValue")).toBe("1w"); + expect(last().end_time).toBe(useProductAnalytics().range.value!.endUs - 7 * DAY_US); + expect(text("rum-analytics-funnel-col-previous-period")).toBe("1 week ago"); + }); + + it("picking Previous period while a remembered offset falls back to it writes fc=prev", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ fc: "1d" }); + await setSteps(threeSteps); + expect(compareSelect().props("modelValue")).toBe("prev"); + await pickCompare("prev"); + expect(useProductAnalytics().funnelCompare.value).toBe("prev"); + expect(router.currentRoute.value.query.fc).toBe("prev"); + }); + + it("re-picking Previous period while a remembered offset already falls back to it starts no new previous run", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels({ fc: "1d" }); + await setSteps(threeSteps); + expect(prevCalls()).toHaveLength(1); + await pickCompare("prev"); + expect(router.currentRoute.value.query.fc).toBe("prev"); + expect(prevCalls()).toHaveLength(1); + expect(text("rum-analytics-funnel-overall-change")).toContain("vs 30.0% in"); + }); + + it("has no legend row above the steps table; the step-1 column tooltip explains the bars while comparing", async () => { + answerPrevious(prevHit([100, 60, 30])); + await mountFunnels(); + await setSteps(threeSteps); + expect(colTip("of-first")).toBe( + "Share of step 1 that reached this step. Hatched part: removed by exclusion steps", + ); + await turnOn(); + expect(wrapper!.find('[data-test="rum-analytics-funnel-compare-legend"]').exists()).toBe( + false, + ); + expect(wrapper!.text()).not.toContain("Bars: share of step-1"); + expect(wrapper!.text()).not.toContain("Change: conversion from the step above."); + expect(colTip("of-first")).toBe( + "Share of step 1 that reached this step. Solid bar: this period. Hatched bar: previous period.", + ); + expect(colTip("change")).toBe( + "Change in percentage points, in conversion from the step above.", + ); + }); + + it("with a breakdown and 1 week ago, the breakdown table names that period", async () => { + answerPrevious(() => [{ dim: "Chrome", c1: 100, c2: 50 }]); + await mountFunnels({ fc: "1w", period: "1d" }); + await setSteps([ + ["p", "/a"], + ["c", "b"], + ]); + useProductAnalytics().funnel.value = { + ...useProductAnalytics().funnel.value, + breakdown: "browser", + }; + await settle(); + const table = wrapper!.find('[data-test="rum-analytics-funnel-breakdown"]'); + expect(table.text()).toContain("1 week ago"); + expect(table.text()).not.toContain("Previous period"); + expect(text("rum-analytics-funnel-breakdown-row-0-prev")).toBe("50.0%"); + }); + + it("with a breakdown, compares each value's overall conversion in its own columns", async () => { + answerPrevious(() => [ + { dim: "Chrome", c1: 100, c2: 50 }, + { dim: "Safari", c1: 10, c2: 5 }, + ]); + await mountFunnels({ fc: "prev" }); + await setSteps([ + ["p", "/a"], + ["c", "b"], + ]); + useProductAnalytics().funnel.value = { + ...useProductAnalytics().funnel.value, + breakdown: "browser", + }; + await settle(); + expect(prevCalls().at(-1)!.sql).toMatch(/SELECT dim, /); + const table = wrapper!.find('[data-test="rum-analytics-funnel-breakdown"]'); + expect(table.text()).toContain("Previous period"); + expect(table.text()).toContain("Change (pts)"); + expect(text("rum-analytics-funnel-breakdown-row-0-prev")).toBe("50.0%"); + expect(text("rum-analytics-funnel-breakdown-row-1-prev")).toBe("—"); + expect(table.text()).toContain("Safari"); + const safari = table + .findAll("[data-test^=rum-analytics-funnel-breakdown-row-]") + .find((c) => c.text() === "Safari")! + .element.closest("tr")!.textContent!; + // Steps, this period's conversion and its median are dashes; only the previous period has a value. + expect(safari).toBe("Safari————50.0%—"); + }); + }); + + describe("conversion over time, Steps | Trend (P0-9)", () => { + type Call = { sql: string; start_time: number; end_time: number }; + const calls = (): Call[] => + vi.mocked(searchService.search).mock.calls.map((c) => c[0].query.query as Call); + const trendCalls = () => calls().filter((q) => / AS bk, | AS gd, /.test(q.sql)); + const fourSteps = [ + ["p", "/a"], + ["c", "b"], + ["p", "/c"], + ["c", "d"], + ]; + + const fourPages = [ + ["p", "/a"], + ["p", "/b"], + ["p", "/c"], + ["p", "/d"], + ]; + const FROM = "rum-analytics-funnel-trend-from-select"; + const TO = "rum-analytics-funnel-trend-to-select"; + const sel = (dt: string) => wrapper!.findComponent(`[data-test="${dt}"]`); + const follows = (a: Element, b: Element) => + !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + + beforeEach(() => { + routes.unshift([/ AS bk, | AS gd, /, () => [{ bk: 1, ca: 20, cb: 5 }]]); + }); + + it("Steps view and page load never run the trend query", async () => { + await mountFunnels(); + await setSteps(fourSteps); + expect(trendCalls()).toHaveLength(0); + expect(wrapper!.find('[data-test="rum-analytics-funnel-trend"]').exists()).toBe(false); + expect(wrapper!.find('[data-test="rum-analytics-funnel-trend-steps"]').exists()).toBe(false); + }); + + it("Trend runs once, writes fv=trend, holds Alert me back, and Steps comes back without a rerun", async () => { + await mountFunnels(); + await setSteps(fourSteps); + const stepsRuns = funnelSqls().length; + await wrapper!.find('[data-test="rum-analytics-funnel-view-trend"]').trigger("click"); + await settle(); + expect(router.currentRoute.value.query.fv).toBe("trend"); + expect(trendCalls()).toHaveLength(1); + expect(trendCalls()[0].sql).toContain("MIN(CASE WHEN m1 = 1 THEN ts END) AS entry_ts"); + expect(funnelSqls()).toHaveLength(stepsRuns); + expect(wrapper!.find('[data-test="rum-analytics-funnel-trend-table"]').text()).toContain( + "Entered step 1", + ); + expect( + wrapper!.find('[data-test="rum-analytics-funnel-alert-btn"]').attributes("disabled"), + ).toBeDefined(); + expect(wrapper!.find('[data-test="rum-analytics-funnel-steps-table"]').exists()).toBe(false); + await wrapper!.find('[data-test="rum-analytics-funnel-view-steps"]').trigger("click"); + await settle(); + expect(router.currentRoute.value.query.fv).toBeUndefined(); + expect(trendCalls()).toHaveLength(1); + expect(funnelSqls()).toHaveLength(stepsRuns); + expect(wrapper!.find('[data-test="rum-analytics-funnel-steps-table"]').exists()).toBe(true); + }); + + it("an attribute breakdown's lines rank at its pinned step, or wait for the funnel's start step", async () => { + const PAY = "context_payment_method"; + const { performanceState } = usePerformance(); + performanceState.data.streams._rumdata = { + name: "_rumdata", + schema: { usr_email: { name: "usr_email" }, [PAY]: { name: PAY } }, + }; + routes.unshift([ + /SELECT dim, /, + () => [ + { dim: "card", c1: 20, c2: 18, c3: 17, c4: 17, dc1: 0, dc2: 0, dc3: 17, dc4: 17 }, + { dim: null, c1: 60, c2: 30, c3: 3, c4: 1, dc1: 0, dc2: 0, dc3: 0, dc4: 0 }, + ], + ]); + await mountFunnels({ fv: "trend" }); + await setSteps(fourSteps); + const pa = useProductAnalytics(); + pa.funnel.value = { ...pa.funnel.value, breakdown: { f: PAY, s: 2 } }; + await settle(); + expect(trendCalls().at(-1)!.sql).toContain("SUM(r1) AS c1, SUM(r2) AS c2, SUM(r1) AS ca"); + pa.funnel.value = { ...pa.funnel.value, breakdown: { f: PAY } }; + await settle(); + await settle(); + expect(trendCalls().at(-1)!.sql).toContain("SUM(r3) AS c3, SUM(r1) AS ca"); + expect(trendCalls().at(-1)!.sql).toContain("un = 0 AND tot > 0 AND rk <= 5"); + }); + + it("a pair from the link picks its own reach columns; a pair the funnel cannot hold falls back", async () => { + await mountFunnels({ fv: "trend", fp: "2-3" }); + await setSteps(fourSteps); + expect(trendCalls().at(-1)!.sql).toMatch( + /SUM\(r2\) AS ca, SUM\(r3\) AS cb FROM ps WHERE r2 = 1/, + ); + expect(sel(FROM).props("modelValue")).toBe(2); + expect(sel(TO).props("modelValue")).toBe(3); + useProductAnalytics().funnelPair.value = { a: 3, b: 9 }; + await settle(); + expect(trendCalls().at(-1)!.sql).toMatch(/SUM\(r1\) AS ca, SUM\(r4\) AS cb/); + expect(sel(FROM).props("modelValue")).toBe(1); + expect(sel(TO).props("modelValue")).toBe(4); + }); + + it("removing a step resets a pair it breaks to step 1 → the last step", async () => { + await mountFunnels({ fv: "trend", fp: "3-4" }); + await setSteps(fourSteps); + expect(sel(FROM).props("modelValue")).toBe(3); + await setSteps(fourSteps.slice(0, 3)); + expect(sel(FROM).props("modelValue")).toBe(1); + expect(sel(TO).props("modelValue")).toBe(3); + expect(trendCalls().at(-1)!.sql).toMatch(/SUM\(r1\) AS ca, SUM\(r3\) AS cb/); + }); + + it("Trend puts Conversion from, to and Interval in a row under the toolbar, with no pair select left in the toolbar", async () => { + await mountFunnels({ fv: "trend" }); + await setSteps(fourSteps); + const row = wrapper!.find('[data-test="rum-analytics-funnel-trend-steps"]'); + expect(row.exists()).toBe(true); + expect(row.text()).toContain("Conversion from"); + expect(row.text()).toContain("Interval"); + const fromId = sel(FROM).props("id") as string; + expect(fromId).toBeTruthy(); + expect(row.find(`label[for="${fromId}"]`).text()).toBe("Conversion from"); + const toId = sel(TO).props("id") as string; + expect(toId).toBeTruthy(); + expect(row.find(`label[for="${toId}"]`).text()).toBe("to"); + for (const dt of [FROM, TO, "rum-analytics-funnel-trend-interval-select"]) + expect(row.find(`[data-test="${dt}"]`).exists()).toBe(true); + const controls = wrapper!.find('[data-test="rum-analytics-funnel-toolbar-controls"]'); + expect(controls.find(`[data-test="${FROM}"]`).exists()).toBe(false); + expect( + controls.find('[data-test="rum-analytics-funnel-trend-interval-select"]').exists(), + ).toBe(false); + expect(wrapper!.find('[data-test="rum-analytics-funnel-trend-pair"]').exists()).toBe(false); + expect(wrapper!.find('[data-test="rum-analytics-funnel-trend-pair-select"]').exists()).toBe( + false, + ); + expect(controls.text()).not.toContain("Conversion from"); + const chart = wrapper!.find('[data-test="rum-analytics-funnel-trend"]'); + expect(follows(controls.element, row.element)).toBe(true); + expect(follows(row.element, chart.element)).toBe(true); + }); + + it("Conversion from, to and Interval are plain menus their labels focus; from and to fit a step name", async () => { + await mountFunnels({ fv: "trend" }); + await setSteps(fourSteps); + expectLabelFocusesSelect(FROM, "Conversion from"); + expectLabelFocusesSelect(TO, "to"); + expectLabelFocusesSelect("rum-analytics-funnel-trend-interval-select", "Interval"); + expect(sel(FROM).props("width")).toBe("md"); + expect(sel(TO).props("width")).toBe("md"); + }); + + it("from lists steps 1 to n-1 and to the later steps, as N · step name, with the last step marked", async () => { + await mountFunnels({ fv: "trend" }); + await setSteps(fourPages); + const labels = (dt: string) => + (sel(dt).props("options") as { label: unknown }[]).map((o) => String(o.label)); + const values = (dt: string) => + (sel(dt).props("options") as { value: unknown }[]).map((o) => o.value); + expect(labels(FROM)).toEqual(["1 · /a", "2 · /b", "3 · /c"]); + expect(values(FROM)).toEqual([1, 2, 3]); + expect(values(TO)).toEqual([2, 3, 4]); + const to = labels(TO); + expect(to[0]).toBe("2 · /b"); + expect(to[1]).toBe("3 · /c"); + expect(to[2]).toContain("4 · /d"); + expect(to[2]).toContain("last step"); + expect(sel(FROM).props("modelValue")).toBe(1); + expect(sel(TO).props("modelValue")).toBe(4); + }); + + it("picking from and to writes fp, from at or past to moves to to the last step, and step 1 → last drops fp", async () => { + await mountFunnels({ fv: "trend" }); + await setSteps(fourSteps); + const pick = async (dt: string, v: number) => { + sel(dt).vm.$emit("update:modelValue", v); + await settle(); + }; + await pick(FROM, 2); + expect(router.currentRoute.value.query.fp).toBe("2-4"); + expect((sel(TO).props("options") as { value: unknown }[]).map((o) => o.value)).toEqual([ + 3, 4, + ]); + expect(trendCalls().at(-1)!.sql).toMatch(/SUM\(r2\) AS ca, SUM\(r4\) AS cb/); + await pick(TO, 3); + expect(router.currentRoute.value.query.fp).toBe("2-3"); + await pick(FROM, 3); + expect(router.currentRoute.value.query.fp).toBe("3-4"); + expect(sel(TO).props("modelValue")).toBe(4); + await pick(FROM, 1); + expect(router.currentRoute.value.query.fp).toBeUndefined(); + expect(sel(TO).props("modelValue")).toBe(4); + }); + + it("a 2-step funnel shows both step selects disabled", async () => { + await mountFunnels({ fv: "trend" }); + await setSteps(fourSteps.slice(0, 2)); + expect(sel(FROM).props("disabled")).toBe(true); + expect(sel(TO).props("disabled")).toBe(true); + await setSteps(fourSteps.slice(0, 3)); + expect(sel(FROM).props("disabled")).toBeFalsy(); + expect(sel(TO).props("disabled")).toBeFalsy(); + }); + + it("a step's Show conversion trend from this step opens Trend from that step to the last", async () => { + await mountFunnels(); + await setSteps(fourSteps); + expect(wrapper!.find('[data-test="rum-analytics-funnel-step-3-trend-from"]').exists()).toBe( + false, + ); + await wrapper!.find('[data-test="rum-analytics-funnel-step-1-trend-from"]').trigger("click"); + await settle(); + expect(router.currentRoute.value.query.fv).toBe("trend"); + expect(router.currentRoute.value.query.fp).toBe("2-4"); + expect(sel(FROM).props("modelValue")).toBe(2); + expect(sel(TO).props("modelValue")).toBe(4); + expect(trendCalls().at(-1)!.sql).toMatch(/SUM\(r2\) AS ca, SUM\(r4\) AS cb/); + }); + + it("Show conversion trend from this step moves focus to the Conversion from select", async () => { + await mountFunnels(); + await setSteps(fourSteps); + await wrapper!.find('[data-test="rum-analytics-funnel-step-1-trend-from"]').trigger("click"); + await settle(); + expect(document.activeElement).not.toBe(document.body); + expect(sel(FROM).element.contains(document.activeElement)).toBe(true); + }); + + it("trend-from-step on step 1 opens Trend at the default pair, clearing a picked one", async () => { + await mountFunnels({ fp: "2-3" }); + await setSteps(fourSteps); + wrapper!.findComponent({ name: "FunnelLayoutRail" }).vm.$emit("trend-from-step", 1); + await settle(); + expect(router.currentRoute.value.query.fv).toBe("trend"); + expect(router.currentRoute.value.query.fp).toBeUndefined(); + expect(sel(FROM).props("modelValue")).toBe(1); + expect(sel(TO).props("modelValue")).toBe(4); + }); + + it("with compare on, runs the trend again over the window before", async () => { + await mountFunnels({ fv: "trend", fc: "prev" }); + await setSteps(fourSteps); + const cur = useProductAnalytics().range.value!; + const prev = trendCalls().filter((q) => q.end_time === cur.startUs); + expect(prev).toHaveLength(1); + expect(prev[0].start_time).toBe(cur.startUs - (cur.endUs - cur.startUs)); + expect( + wrapper!.find('[data-test="rum-analytics-funnel-trend-table"] thead').text(), + ).toContain("Previous period, same day"); + }); + + it("switching compare from the previous period to 1 week ago runs the trend's previous line again", async () => { + await mountFunnels({ fv: "trend", fc: "prev", period: "3d" }); + await setSteps(fourSteps); + const cur = useProductAnalytics().range.value!; + const prevTrend = () => trendCalls().filter((q) => q.end_time <= cur.startUs); + expect(prevTrend()).toHaveLength(1); + expect(prevTrend()[0].end_time).toBe(cur.startUs); + sel("rum-analytics-funnel-compare-select").vm.$emit("update:modelValue", "1w"); + await settle(); + expect(router.currentRoute.value.query.fc).toBe("1w"); + expect(prevTrend()).toHaveLength(2); + expect(prevTrend()[1].start_time).toBe(cur.startUs - 7 * 86_400_000_000); + expect(prevTrend()[1].end_time).toBe(cur.endUs - 7 * 86_400_000_000); + }); + + it("an offset the 7-day range cannot use runs the trend's previous line over the previous period and keeps fc", async () => { + await mountFunnels({ fv: "trend", fc: "1d" }); + await setSteps(fourSteps); + const cur = useProductAnalytics().range.value!; + const prevTrend = trendCalls().filter((q) => q.end_time <= cur.startUs); + expect(prevTrend).toHaveLength(1); + expect(prevTrend[0].end_time).toBe(cur.startUs); + expect(prevTrend[0].start_time).toBe(cur.startUs - (cur.endUs - cur.startUs)); + expect(router.currentRoute.value.query.fc).toBe("1d"); + }); + + describe("the missing comparison line with a breakdown", () => { + const WARN = "rum-analytics-funnel-trend-compare-warning"; + const warn = () => wrapper!.find(`[data-test="${WARN}"]`); + const withBreakdown = async () => { + const pa = useProductAnalytics(); + pa.funnel.value = { ...pa.funnel.value, breakdown: "browser" }; + await settle(); + }; + + it("shows a warning icon at the end of the Conversion from row only in Trend with compare and a breakdown", async () => { + await mountFunnels({ fv: "trend", fc: "prev" }); + await setSteps(fourSteps); + expect(warn().exists()).toBe(false); + await withBreakdown(); + const row = wrapper!.find('[data-test="rum-analytics-funnel-trend-steps"]'); + const btn = row.find(`[data-test="${WARN}"]`); + expect(btn.exists()).toBe(true); + expect(btn.attributes("aria-label")).toBe("About the missing comparison line"); + expect(btn.text()).toBe(""); + expect(btn.element.closest(".ms-auto")).not.toBeNull(); + sel("rum-analytics-funnel-compare-select").vm.$emit("update:modelValue", "off"); + await settle(); + expect(warn().exists()).toBe(false); + }); + + it("is not shown when the range is too long to compare", async () => { + await mountFunnels({ fv: "trend", fc: "prev", period: "60d" }); + await setSteps(fourSteps); + await withBreakdown(); + expect(wrapper!.find('[data-test="rum-analytics-funnel-trend-steps"]').exists()).toBe(true); + expect(warn().exists()).toBe(false); + }); + + it("names the offset's dates in the popover with 1 week ago on a 1-day range", async () => { + await mountFunnels({ fv: "trend", fc: "1w", period: "1d" }); + await setSteps(fourSteps); + await withBreakdown(); + const range = useProductAnalytics().range.value!; + const week = 7 * 86_400_000_000; + const dates = formatWindow( + { startUs: range.startUs - week, endUs: range.endUs - week }, + "UTC", + ); + await warn().trigger("click"); + await settle(); + expect(wrapper!.text()).toContain(`the whole funnel with ${dates}.`); + }); + + it("is not shown in Steps view with compare and a breakdown", async () => { + await mountFunnels({ fc: "prev" }); + await setSteps(fourSteps); + await withBreakdown(); + expect(warn().exists()).toBe(false); + }); + + it("moves focus to the Conversion from select after Remove breakdown unmounts the popover", async () => { + await mountFunnels({ fv: "trend", fc: "prev" }); + await setSteps(fourSteps); + await withBreakdown(); + await warn().trigger("click"); + await settle(); + const remove = wrapper!.find( + '[data-test="rum-analytics-funnel-trend-compare-remove-breakdown"]', + ); + (remove.element as HTMLElement).focus(); + await remove.trigger("click"); + await settle(); + expect(warn().exists()).toBe(false); + expect(document.activeElement).not.toBe(document.body); + expect(sel(FROM).element.contains(document.activeElement)).toBe(true); + }); + + it("opens a popover saying why, and its Remove breakdown clears the breakdown and closes it", async () => { + await mountFunnels({ fv: "trend", fc: "prev" }); + await setSteps(fourSteps); + await withBreakdown(); + const range = useProductAnalytics().range.value!; + const prevRange = formatWindow( + { startUs: range.startUs - (range.endUs - range.startUs), endUs: range.startUs }, + "UTC", + ); + expect(wrapper!.text()).not.toContain("The chart has no comparison line"); + await warn().trigger("click"); + await settle(); + const body = wrapper!.text(); + expect(body).toMatch( + /The chart has no comparison line while a breakdown is on: .+ is its own line\. The headline still compares the whole funnel with /, + ); + expect(body).toMatch(/breakdown is on: [^.]*\bBrowser\b[^.]* is its own line\./); + expect(body).not.toContain("each Browser is its own line"); + expect(body).toContain(`the whole funnel with ${prevRange}.`); + const remove = wrapper! + .findAllComponents(OButton) + .find((b) => b.text() === "Remove breakdown" && !b.element.closest("aside")); + expect(remove).toBeTruthy(); + expect(remove!.props("variant")).toBe("outline"); + await remove!.trigger("click"); + await settle(); + expect(useProductAnalytics().funnel.value.breakdown).toBeFalsy(); + expect(warn().exists()).toBe(false); + expect(wrapper!.text()).not.toContain("The chart has no comparison line"); + }); + }); + + it("Add to dashboard from Trend adds a line panel bucketed by histogram", async () => { + await mountFunnels({ fv: "trend" }); + await setSteps(fourSteps); + await wrapper!.find('[data-test="rum-analytics-funnel-add-dashboard-btn"]').trigger("click"); + await flushPromises(); + const panels = wrapper!.findComponent({ name: "AddToDashboard" }).props("panels") as { + type: string; + queries: { query: string }[]; + }[]; + expect(panels[0].type).toBe("line"); + expect(panels[0].queries[0].query).toContain("histogram(entry_ts, '1 day')"); + }); + + it("Add to dashboard from Trend bakes the picked steps and interval into the panel", async () => { + await mountFunnels({ fv: "trend", period: "30d" }); + await setSteps(fourSteps); + sel(FROM).vm.$emit("update:modelValue", 2); + await settle(); + sel(TO).vm.$emit("update:modelValue", 3); + await settle(); + sel("rum-analytics-funnel-trend-interval-select").vm.$emit("update:modelValue", "week"); + await settle(); + expect(useProductAnalytics().funnelPair.value).toEqual({ a: 2, b: 3 }); + await wrapper!.find('[data-test="rum-analytics-funnel-add-dashboard-btn"]').trigger("click"); + await flushPromises(); + const panels = wrapper!.findComponent({ name: "AddToDashboard" }).props("panels") as { + title: string; + queries: { query: string }[]; + }[]; + expect(String(panels[0].title)).toBe("Conversion over time: b → /c"); + expect(panels[0].queries[0].query).toContain("histogram(entry_ts, '1 week')"); + }); + }); }); diff --git a/web/src/components/rum/productAnalytics/AnalyticsFunnels.vue b/web/src/components/rum/productAnalytics/AnalyticsFunnels.vue index f020ee390e..8132115503 100644 --- a/web/src/components/rum/productAnalytics/AnalyticsFunnels.vue +++ b/web/src/components/rum/productAnalytics/AnalyticsFunnels.vue @@ -164,6 +164,7 @@ along with this program. If not, see . . :app="pa.state.app" :app-filters="appFilters.counts.value" :include-hidden="pa.state.includeExcluded" - :settings="layoutSettings" :filter-fields="filterFields" :filter-field-types="pa.schemaTypes.value" :filter-values="filterValues" :failed="funnelFailed" + :compare="compareView" + :trend="trend.shown.value" + :field-sample="fieldSample.sample.value" + :field-sample-state="fieldSample.state.value" + :filter-notice="filterNotice" @update:def="applyDef" @filter-values="loadFilterValues" + @load-fields="fieldSample.load()" + @break-down="breakDownBy" + @break-down-step="breakDownAtStep" + @trend-from-step="trendFromStep" @dropoff="openDropoff" @suggestions-retry="retrySuggestions" @include-hidden="appFilters.setIncludeHidden(router, true)" @hide-hidden="appFilters.setIncludeHidden(router, false)" @edit-app-filters="emit('open-exclusions')" > + +