Bug 1826772: conditionally displayed cards

fenix/121.0
alexandra.virvara 8 months ago committed by mergify[bot]
parent 2fc14dee2f
commit 76a7dd3422

@ -58,6 +58,9 @@ juno-onboarding:
cards:
type: json
description: Collection of user facing onboarding cards.
conditions:
type: json
description: "A collection of out the box conditional expressions to be used in determining whether a card should show or not. Each entry maps to a valid JEXL expression.\n"
messaging:
description: "The in-app messaging system.\n"
hasExposure: true

@ -5,6 +5,16 @@ features:
description: A feature that shows juno onboarding flow.
variables:
conditions:
description: >
A collection of out the box conditional expressions to be
used in determining whether a card should show or not.
Each entry maps to a valid JEXL expression.
type: Map<String, String>
default: {
ALWAYS: "true",
NEVER: "false"
}
cards:
description: Collection of user facing onboarding cards.
type: Map<String, OnboardingCardData>
@ -98,6 +108,20 @@ objects:
description: The text to display on the secondary button.
# This should never be defaulted.
default: ""
prerequisites:
type: List<String>
description: >
A list of strings corresponding to targeting expressions.
The card will be shown if all expressions are `true` and if
no expressions in the `disqualifiers` table are true, or
if the `disqualifiers` table is empty.
default: [ ALWAYS ]
disqualifiers:
type: List<String>
description: >
A list of strings corresponding to targeting expressions.
The card will not be shown if any expression is `true`.
default: [ NEVER ]
enums:

@ -4,12 +4,19 @@
package org.mozilla.fenix.onboarding.view
import io.mockk.every
import io.mockk.mockk
import mozilla.components.service.nimbus.evalJexlSafe
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mozilla.experiments.nimbus.GleanPlumbMessageHelper
import org.mozilla.experiments.nimbus.StringHolder
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.HomeActivityIntentTestRule
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.nimbus.JunoOnboarding
import org.mozilla.fenix.nimbus.OnboardingCardData
import org.mozilla.fenix.nimbus.OnboardingCardType
@ -19,28 +26,242 @@ class JunoOnboardingMapperTest {
val activityTestRule =
HomeActivityIntentTestRule.withDefaultSettingsOverrides(skipOnboarding = true)
private lateinit var junoOnboardingFeature: JunoOnboarding
private lateinit var jexlConditions: Map<String, String>
private lateinit var jexlHelper: GleanPlumbMessageHelper
private lateinit var evalFunction: (String) -> Boolean
@Before
fun setup() {
junoOnboardingFeature = FxNimbus.features.junoOnboarding.value()
jexlConditions = junoOnboardingFeature.conditions
jexlHelper = mockk(relaxed = true)
evalFunction = { condition -> jexlHelper.evalJexlSafe(condition) }
every { evalFunction("true") } returns true
every { evalFunction("false") } returns false
}
@Test
fun showNotificationTrue_showAddWidgetFalse_pagesToDisplay_returnsSortedListOfAllConvertedPages_withoutAddWidgetPage() {
val expected = listOf(defaultBrowserPageUiData, syncPageUiData, notificationPageUiData)
assertEquals(expected, unsortedAllKnownCardData.toPageUiData(true, false))
assertEquals(
expected,
unsortedAllKnownCardData.toPageUiData(
showNotificationPage = true,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun showNotificationFalse_showAddWidgetFalse_pagesToDisplay_returnsSortedListOfConvertedPages_withoutNotificationPage_and_addWidgetPage() {
val expected = listOf(defaultBrowserPageUiData, syncPageUiData)
assertEquals(expected, unsortedAllKnownCardData.toPageUiData(false, false))
assertEquals(
expected,
unsortedAllKnownCardData.toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun showNotificationFalse_showAddWidgetTrue_pagesToDisplay_returnsSortedListOfAllConvertedPages_withoutNotificationPage() {
val expected = listOf(defaultBrowserPageUiData, addSearchWidgetPageUiData, syncPageUiData)
assertEquals(expected, unsortedAllKnownCardData.toPageUiData(false, true))
assertEquals(
expected,
unsortedAllKnownCardData.toPageUiData(
showNotificationPage = false,
showAddWidgetPage = true,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun showNotificationTrue_and_showAddWidgetTrue_pagesToDisplay_returnsSortedListOfConvertedPages() {
val expected = listOf(defaultBrowserPageUiData, addSearchWidgetPageUiData, syncPageUiData, notificationPageUiData)
assertEquals(expected, unsortedAllKnownCardData.toPageUiData(true, true))
val expected = listOf(
defaultBrowserPageUiData,
addSearchWidgetPageUiData,
syncPageUiData,
notificationPageUiData,
)
assertEquals(
expected,
unsortedAllKnownCardData.toPageUiData(
showNotificationPage = true,
showAddWidgetPage = true,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun cardConditionsMatchJexlConditions_shouldDisplayCard_returnsConvertedPage() {
val jexlConditions = mapOf("ALWAYS" to "true", "NEVER" to "false")
val expected = listOf(defaultBrowserPageUiData)
assertEquals(
expected,
listOf(defaultBrowserCardData).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun noJexlConditionsAndNoCardConditions_shouldDisplayCard_returnsNoPage() {
val jexlConditions = mapOf<String, String>()
val expected = emptyList<OnboardingPageUiData>()
assertEquals(
expected,
listOf(addSearchWidgetCardDataNoConditions).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun noJexlConditions_shouldDisplayCard_returnsNoPage() {
val jexlConditions = mapOf<String, String>()
val expected = emptyList<OnboardingPageUiData>()
assertEquals(
expected,
listOf(defaultBrowserCardData).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun prerequisitesMatchJexlConditions_shouldDisplayCard_returnsConvertedPage() {
val jexlConditions = mapOf("ALWAYS" to "true")
val expected = listOf(defaultBrowserPageUiData)
assertEquals(
expected,
listOf(defaultBrowserCardData).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun prerequisitesDontMatchJexlConditions_shouldDisplayCard_returnsNoPage() {
val jexlConditions = mapOf("NEVER" to "false")
val expected = emptyList<OnboardingPageUiData>()
assertEquals(
expected,
listOf(defaultBrowserCardData).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun noCardConditions_shouldDisplayCard_returnsNoPage() {
val jexlConditions = mapOf("ALWAYS" to "true", "NEVER" to "false")
val expected = emptyList<OnboardingPageUiData>()
assertEquals(
expected,
listOf(addSearchWidgetCardDataNoConditions).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun noDisqualifiers_shouldDisplayCard_returnsConvertedPage() {
val jexlConditions = mapOf("ALWAYS" to "true", "NEVER" to "false")
val expected = listOf(defaultBrowserPageUiData)
assertEquals(
expected,
listOf(defaultBrowserCardDataNoDisqualifiers).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun disqualifiersMatchJexlConditions_shouldDisplayCard_returnsConvertedPage() {
val jexlConditions = mapOf("NEVER" to "false")
val expected = listOf(syncPageUiData)
assertEquals(
expected,
listOf(syncCardData).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun disqualifiersDontMatchJexlConditions_shouldDisplayCard_returnsNoPage() {
val jexlConditions = mapOf("NEVER" to "false")
val expected = listOf<OnboardingPageUiData>()
assertEquals(
expected,
listOf(notificationCardData).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
@Test
fun noPrerequisites_shouldDisplayCard_returnsConvertedPage() {
val jexlConditions = mapOf("ALWAYS" to "true", "NEVER" to "false")
val expected = listOf(syncPageUiData)
assertEquals(
expected,
listOf(syncCardData).toPageUiData(
showNotificationPage = false,
showAddWidgetPage = false,
jexlConditions = jexlConditions,
func = evalFunction,
),
)
}
}
@ -88,7 +309,36 @@ private val defaultBrowserCardData = OnboardingCardData(
primaryButtonLabel = StringHolder(null, "default browser primary button text"),
secondaryButtonLabel = StringHolder(null, "default browser secondary button text"),
ordering = 10,
prerequisites = listOf("ALWAYS"),
disqualifiers = listOf("NEVER"),
)
private val defaultBrowserCardDataNoDisqualifiers = OnboardingCardData(
cardType = OnboardingCardType.DEFAULT_BROWSER,
imageRes = R.drawable.ic_onboarding_welcome,
title = StringHolder(null, "default browser title"),
body = StringHolder(null, "default browser body with link text"),
linkText = StringHolder(null, "link text"),
primaryButtonLabel = StringHolder(null, "default browser primary button text"),
secondaryButtonLabel = StringHolder(null, "default browser secondary button text"),
ordering = 10,
prerequisites = listOf("ALWAYS"),
disqualifiers = listOf(),
)
private val addSearchWidgetCardDataNoConditions = OnboardingCardData(
cardType = OnboardingCardType.ADD_SEARCH_WIDGET,
imageRes = R.drawable.ic_onboarding_search_widget,
title = StringHolder(null, "add search widget title"),
body = StringHolder(null, "add search widget body with link text"),
linkText = StringHolder(null, "link text"),
primaryButtonLabel = StringHolder(null, "add search widget primary button text"),
secondaryButtonLabel = StringHolder(null, "add search widget secondary button text"),
ordering = 15,
prerequisites = listOf(),
disqualifiers = listOf(),
)
private val addSearchWidgetCardData = OnboardingCardData(
cardType = OnboardingCardType.ADD_SEARCH_WIDGET,
imageRes = R.drawable.ic_onboarding_search_widget,
@ -99,6 +349,7 @@ private val addSearchWidgetCardData = OnboardingCardData(
secondaryButtonLabel = StringHolder(null, "add search widget secondary button text"),
ordering = 15,
)
private val syncCardData = OnboardingCardData(
cardType = OnboardingCardType.SYNC_SIGN_IN,
imageRes = R.drawable.ic_onboarding_sync,
@ -107,7 +358,10 @@ private val syncCardData = OnboardingCardData(
primaryButtonLabel = StringHolder(null, "sync primary button text"),
secondaryButtonLabel = StringHolder(null, "sync secondary button text"),
ordering = 20,
prerequisites = listOf(),
disqualifiers = listOf("NEVER"),
)
private val notificationCardData = OnboardingCardData(
cardType = OnboardingCardType.NOTIFICATION_PERMISSION,
imageRes = R.drawable.ic_notification_permission,
@ -116,6 +370,8 @@ private val notificationCardData = OnboardingCardData(
primaryButtonLabel = StringHolder(null, "notification primary button text"),
secondaryButtonLabel = StringHolder(null, "notification secondary button text"),
ordering = 30,
prerequisites = listOf(),
disqualifiers = listOf("NEVER", "OTHER"),
)
private val unsortedAllKnownCardData = listOf(

@ -23,9 +23,11 @@ import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.Fragment
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.navigation.fragment.findNavController
import mozilla.components.service.nimbus.evalJexlSafe
import mozilla.components.support.base.ext.areNotificationsEnabledSafe
import org.mozilla.fenix.R
import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.hideToolbar
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.openSetDefaultBrowserOption
@ -221,9 +223,15 @@ class JunoOnboardingFragment : Fragment() {
private fun pagesToDisplay(
showNotificationPage: Boolean,
showAddWidgetPage: Boolean,
): List<OnboardingPageUiData> =
FxNimbus.features.junoOnboarding.value().cards.values.toPageUiData(
): List<OnboardingPageUiData> {
val junoOnboardingFeature = FxNimbus.features.junoOnboarding.value()
val jexlConditions = junoOnboardingFeature.conditions
val jexlHelper = requireContext().components.analytics.messagingStorage.helper
return FxNimbus.features.junoOnboarding.value().cards.values.toPageUiData(
showNotificationPage,
showAddWidgetPage,
)
jexlConditions,
) { condition -> jexlHelper.evalJexlSafe(condition) }
}
}

@ -15,21 +15,81 @@ import org.mozilla.fenix.settings.SupportUtils
internal fun Collection<OnboardingCardData>.toPageUiData(
showNotificationPage: Boolean,
showAddWidgetPage: Boolean,
): List<OnboardingPageUiData> =
filter {
when (it.cardType) {
OnboardingCardType.NOTIFICATION_PERMISSION -> {
it.enabled && showNotificationPage
}
OnboardingCardType.ADD_SEARCH_WIDGET -> {
it.enabled && showAddWidgetPage
jexlConditions: Map<String, String>,
func: (String) -> Boolean,
): List<OnboardingPageUiData> {
// we are first filtering the cards based on Nimbus configuration
return filter { it.shouldDisplayCard(func, jexlConditions) }
// we are then filtering again based on device capabilities
.filter { it.isCardEnabled(showNotificationPage, showAddWidgetPage) }
.sortedBy { it.ordering }
.map { it.toPageUiData() }
}
private fun OnboardingCardData.isCardEnabled(
showNotificationPage: Boolean,
showAddWidgetPage: Boolean,
): Boolean =
when (cardType) {
OnboardingCardType.NOTIFICATION_PERMISSION -> {
enabled && showNotificationPage
}
OnboardingCardType.ADD_SEARCH_WIDGET -> {
enabled && showAddWidgetPage
}
else -> {
enabled
}
}
/**
* Determines whether the given [OnboardingCardData] should be displayed.
*
* @param func Function that receives a condition as a [String] and returns its JEXL evaluation as a [Boolean].
* @param jexlConditions A <String, String> map containing the Nimbus conditions.
*
* @return True if the card should be displayed, otherwise false.
*/
private fun OnboardingCardData.shouldDisplayCard(
func: (String) -> Boolean,
jexlConditions: Map<String, String>,
): Boolean {
val jexlCache: MutableMap<String, Boolean> = mutableMapOf()
// Make sure the conditions exist and have a value, and that the number
// of valid conditions matches the number of conditions on the card's
// respective prerequisite or disqualifier table. If these mismatch,
// that means a card contains a condition that's not in the feature
// conditions lookup table. JEXLs can only be evaluated on
// supported conditions. Otherwise, consider the card invalid.
val allPrerequisites = prerequisites.mapNotNull { jexlConditions[it] }
val allDisqualifiers = disqualifiers.mapNotNull { jexlConditions[it] }
val validPrerequisites = if (allPrerequisites.size == prerequisites.size) {
allPrerequisites.all { condition ->
jexlCache.getOrPut(condition) {
func(condition)
}
else -> {
it.enabled
}
} else {
false
}
val hasDisqualifiers =
if (allDisqualifiers.isNotEmpty() && allDisqualifiers.size == disqualifiers.size) {
allDisqualifiers.all { condition ->
jexlCache.getOrPut(condition) {
func(condition)
}
}
} else {
false
}
}.sortedBy { it.ordering }
.map { it.toPageUiData() }
return validPrerequisites && !hasDisqualifiers
}
private fun OnboardingCardData.toPageUiData() = OnboardingPageUiData(
type = cardType.toPageUiDataType(),

Loading…
Cancel
Save