Skip to content

Commit c3ae8fc

Browse files
committed
Bug 2011793 - Separate enrolled experiments in the full list r=azinovyev
Separates out the available list of experiments into an enrolled section for easier distinction against the unenrolled.
1 parent 131497b commit c3ae8fc

5 files changed

Lines changed: 291 additions & 17 deletions

File tree

mobile/android/fenix/app/src/main/java/org/mozilla/fenix/nimbus/NimbusExperimentsFragment.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,23 @@ package org.mozilla.fenix.nimbus
77
import android.os.Bundle
88
import android.view.LayoutInflater
99
import android.view.ViewGroup
10+
import androidx.compose.runtime.LaunchedEffect
11+
import androidx.compose.runtime.getValue
12+
import androidx.compose.runtime.mutableStateOf
13+
import androidx.compose.runtime.remember
14+
import androidx.compose.runtime.setValue
15+
import androidx.compose.ui.platform.LocalContext
1016
import androidx.fragment.app.Fragment
1117
import androidx.fragment.compose.content
1218
import androidx.navigation.fragment.findNavController
19+
import kotlinx.coroutines.Dispatchers.IO
20+
import kotlinx.coroutines.withContext
21+
import mozilla.components.service.nimbus.NimbusApi
1322
import org.mozilla.fenix.R
1423
import org.mozilla.fenix.ext.components
1524
import org.mozilla.fenix.ext.showToolbar
25+
import org.mozilla.fenix.nimbus.ext.partitionedExperimentLists
26+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem
1627
import org.mozilla.fenix.nimbus.view.NimbusExperiments
1728
import org.mozilla.fenix.theme.FirefoxTheme
1829

@@ -32,8 +43,12 @@ class NimbusExperimentsFragment : Fragment() {
3243
savedInstanceState: Bundle?,
3344
) = content {
3445
FirefoxTheme {
35-
val experiments =
36-
requireContext().components.nimbus.sdk.getAvailableExperiments()
46+
var experiments by remember { mutableStateOf(emptyList<NimbusExperimentItem>()) }
47+
val nimbusSdk = LocalContext.current.components.nimbus.sdk
48+
49+
LaunchedEffect(Unit) {
50+
experiments = nimbusSdk.fetchPartitionedExperimentListsAsync()
51+
}
3752

3853
NimbusExperiments(
3954
experiments = experiments,
@@ -50,3 +65,6 @@ class NimbusExperimentsFragment : Fragment() {
5065
}
5166
}
5267
}
68+
69+
private suspend fun NimbusApi.fetchPartitionedExperimentListsAsync(): List<NimbusExperimentItem> =
70+
withContext(IO) { partitionedExperimentLists() }
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.fenix.nimbus.ext
6+
7+
import mozilla.components.service.nimbus.NimbusApi
8+
import org.mozilla.fenix.R
9+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem
10+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem.EmptyState
11+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem.Experiment
12+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem.Header
13+
14+
/**
15+
* Separates the experiment list into an "active" and "inactive" items based on enrollment for
16+
* rendering with [org.mozilla.fenix.nimbus.view.NimbusExperiments].
17+
*/
18+
internal fun NimbusApi.partitionedExperimentLists(): List<NimbusExperimentItem> {
19+
val availableExperiments = getAvailableExperiments()
20+
val activeExperimentSlugs = getActiveExperiments().map { it.slug }.toSet()
21+
22+
val (active, inactive) = availableExperiments.partition { it.slug in activeExperimentSlugs }
23+
24+
return buildList {
25+
add(Header(R.string.preferences_nimbus_experiments_active))
26+
27+
if (active.isEmpty()) {
28+
add(EmptyState(R.string.preferences_nimbus_experiments_no_items))
29+
} else {
30+
addAll(active.map { Experiment(it) })
31+
}
32+
33+
add(Header(R.string.preferences_nimbus_experiments_inactive))
34+
35+
if (inactive.isEmpty()) {
36+
add(EmptyState(R.string.preferences_nimbus_experiments_no_items))
37+
} else {
38+
addAll(inactive.map { Experiment(it) })
39+
}
40+
}
41+
}

mobile/android/fenix/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusExperiments.kt

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,112 @@
44

55
package org.mozilla.fenix.nimbus.view
66

7+
import androidx.annotation.StringRes
78
import androidx.compose.foundation.layout.fillMaxSize
9+
import androidx.compose.foundation.layout.padding
810
import androidx.compose.foundation.lazy.LazyColumn
911
import androidx.compose.foundation.lazy.items
12+
import androidx.compose.material3.MaterialTheme
13+
import androidx.compose.material3.Text
1014
import androidx.compose.runtime.Composable
1115
import androidx.compose.ui.Modifier
16+
import androidx.compose.ui.res.stringResource
1217
import androidx.compose.ui.tooling.preview.PreviewLightDark
18+
import androidx.compose.ui.unit.dp
1319
import org.mozilla.experiments.nimbus.AvailableExperiment
20+
import org.mozilla.fenix.R
1421
import org.mozilla.fenix.compose.list.TextListItem
1522
import org.mozilla.fenix.theme.FirefoxTheme
1623

24+
/**
25+
* Item types for the list of experiments to be displayed.
26+
*/
27+
sealed class NimbusExperimentItem {
28+
/**
29+
* Title header for an experiment section. Typically, for "enrolled" or "unenrolled" sections.
30+
*
31+
* @param title the title to display.
32+
*/
33+
data class Header(
34+
@param:StringRes val title: Int,
35+
) : NimbusExperimentItem()
36+
37+
/**
38+
* An experiment item.
39+
*
40+
* @param experiment the experiment to display.
41+
*/
42+
data class Experiment(val experiment: AvailableExperiment) : NimbusExperimentItem()
43+
44+
/**
45+
* An empty section if there are no items to show.
46+
*/
47+
data class EmptyState(
48+
@param:StringRes val text: Int,
49+
) : NimbusExperimentItem()
50+
}
51+
52+
@Composable
53+
private fun NimbusExperimentHeader(
54+
@StringRes titleResourceId: Int,
55+
) {
56+
Text(
57+
text = stringResource(titleResourceId),
58+
style = MaterialTheme.typography.titleSmall,
59+
color = MaterialTheme.colorScheme.primary,
60+
modifier = Modifier.padding(
61+
start = 16.dp,
62+
end = 16.dp,
63+
top = 16.dp,
64+
bottom = 8.dp,
65+
),
66+
)
67+
}
68+
69+
@Composable
70+
private fun NimbusExperimentEmptyState(
71+
@StringRes text: Int,
72+
) {
73+
Text(
74+
text = stringResource(text),
75+
style = MaterialTheme.typography.bodyMedium,
76+
color = MaterialTheme.colorScheme.secondary,
77+
modifier = Modifier.padding(
78+
start = 16.dp,
79+
end = 16.dp,
80+
top = 8.dp,
81+
bottom = 8.dp,
82+
),
83+
)
84+
}
85+
1786
/**
1887
* List of Nimbus Experiments.
1988
*
20-
* @param experiments List of [AvailableExperiment] that are going to be displayed.
89+
* @param experiments List of [NimbusExperimentItem] that are going to be displayed.
2190
* @param onExperimentClick Invoked when the user clicks on an [AvailableExperiment].
2291
*/
2392
@Composable
2493
fun NimbusExperiments(
25-
experiments: List<AvailableExperiment> = listOf(),
94+
experiments: List<NimbusExperimentItem> = listOf(),
2695
onExperimentClick: (AvailableExperiment) -> Unit,
2796
) {
2897
LazyColumn(
2998
modifier = Modifier.fillMaxSize(),
3099
) {
31-
items(experiments) { experiment ->
32-
TextListItem(
33-
label = experiment.userFacingName,
34-
description = experiment.userFacingDescription,
35-
maxDescriptionLines = Int.MAX_VALUE,
36-
onClick = {
37-
onExperimentClick(experiment)
38-
},
39-
)
100+
items(experiments) { item ->
101+
when (item) {
102+
is NimbusExperimentItem.Header -> NimbusExperimentHeader(titleResourceId = item.title)
103+
is NimbusExperimentItem.Experiment -> TextListItem(
104+
label = item.experiment.userFacingName,
105+
description = item.experiment.userFacingDescription,
106+
maxDescriptionLines = Int.MAX_VALUE,
107+
onClick = {
108+
onExperimentClick(item.experiment)
109+
},
110+
)
111+
is NimbusExperimentItem.EmptyState -> NimbusExperimentEmptyState(text = item.text)
112+
}
40113
}
41114
}
42115
}
@@ -55,10 +128,11 @@ private fun NimbusExperimentsPreview() {
55128
FirefoxTheme {
56129
NimbusExperiments(
57130
experiments = listOf(
58-
testExperiment,
59-
testExperiment,
60-
testExperiment,
61-
testExperiment,
131+
NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_active),
132+
NimbusExperimentItem.EmptyState(R.string.preferences_nimbus_experiments_no_items),
133+
NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_inactive),
134+
NimbusExperimentItem.Experiment(testExperiment),
135+
NimbusExperimentItem.Experiment(testExperiment),
62136
),
63137
onExperimentClick = {},
64138
)

mobile/android/fenix/app/src/main/res/values/static_strings.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
<string name="preferences_debug_settings_allow_third_party_root_certs_summary">Allows the use of third party certificates from the Android CA store</string>
3636
<!-- Label for the Nimbus experiments preference -->
3737
<string name="preferences_nimbus_experiments">Nimbus Experiments</string>
38+
<!-- Label for the title section of active experiments the user is enrolled in. -->
39+
<string name="preferences_nimbus_experiments_active">Active Experiments</string>
40+
<!-- Label for an empty list of experiments. -->
41+
<string name="preferences_nimbus_experiments_no_items">None</string>
42+
<!-- Label for the title section of inactive experiments the user is not enrolled in. -->
43+
<string name="preferences_nimbus_experiments_inactive">Inactive Experiments</string>
3844
<!-- Label for using the nimbus collections preview -->
3945
<string name="preferences_nimbus_use_preview_collection">Use Nimbus Preview Collection (requires restart)</string>
4046
<!-- Label for custom Glean server URL -->
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.fenix.nimbus.ext
6+
7+
import io.mockk.every
8+
import io.mockk.mockk
9+
import mozilla.components.service.nimbus.NimbusApi
10+
import org.junit.Assert.assertEquals
11+
import org.junit.Test
12+
import org.mozilla.experiments.nimbus.AvailableExperiment
13+
import org.mozilla.experiments.nimbus.EnrolledExperiment
14+
import org.mozilla.fenix.R
15+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem
16+
17+
class ExperimentsListNimbusApiTest {
18+
19+
@Test
20+
fun `WHEN no experiments available THEN returns headers with empty states`() {
21+
val nimbusApi = mockk<NimbusApi>()
22+
every { nimbusApi.getAvailableExperiments() } returns emptyList()
23+
every { nimbusApi.getActiveExperiments() } returns emptyList()
24+
25+
val result = nimbusApi.partitionedExperimentLists()
26+
27+
assertEquals(4, result.size)
28+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_active), result[0])
29+
assertEquals(NimbusExperimentItem.EmptyState(R.string.preferences_nimbus_experiments_no_items), result[1])
30+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_inactive), result[2])
31+
assertEquals(NimbusExperimentItem.EmptyState(R.string.preferences_nimbus_experiments_no_items), result[3])
32+
}
33+
34+
@Test
35+
fun `WHEN all experiments are active THEN returns only active experiments with inactive empty state`() {
36+
val experiment1 = createAvailableExperiment("exp1")
37+
val experiment2 = createAvailableExperiment("exp2")
38+
val enrolledExperiment1 = createEnrolledExperiment("exp1")
39+
val enrolledExperiment2 = createEnrolledExperiment("exp2")
40+
41+
val nimbusApi = mockk<NimbusApi>()
42+
every { nimbusApi.getAvailableExperiments() } returns listOf(experiment1, experiment2)
43+
every { nimbusApi.getActiveExperiments() } returns listOf(enrolledExperiment1, enrolledExperiment2)
44+
45+
val result = nimbusApi.partitionedExperimentLists()
46+
47+
assertEquals(5, result.size)
48+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_active), result[0])
49+
assertEquals(NimbusExperimentItem.Experiment(experiment1), result[1])
50+
assertEquals(NimbusExperimentItem.Experiment(experiment2), result[2])
51+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_inactive), result[3])
52+
assertEquals(NimbusExperimentItem.EmptyState(R.string.preferences_nimbus_experiments_no_items), result[4])
53+
}
54+
55+
@Test
56+
fun `WHEN all experiments are inactive THEN returns only inactive experiments`() {
57+
val experiment1 = createAvailableExperiment("exp1")
58+
val experiment2 = createAvailableExperiment("exp2")
59+
60+
val nimbusApi = mockk<NimbusApi>()
61+
every { nimbusApi.getAvailableExperiments() } returns listOf(experiment1, experiment2)
62+
every { nimbusApi.getActiveExperiments() } returns emptyList()
63+
64+
val result = nimbusApi.partitionedExperimentLists()
65+
66+
assertEquals(5, result.size)
67+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_active), result[0])
68+
assertEquals(NimbusExperimentItem.EmptyState(R.string.preferences_nimbus_experiments_no_items), result[1])
69+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_inactive), result[2])
70+
assertEquals(NimbusExperimentItem.Experiment(experiment1), result[3])
71+
assertEquals(NimbusExperimentItem.Experiment(experiment2), result[4])
72+
}
73+
74+
@Test
75+
fun `WHEN experiments are mixed active and inactive THEN returns both sections with experiments`() {
76+
val activeExperiment1 = createAvailableExperiment("active1")
77+
val activeExperiment2 = createAvailableExperiment("active2")
78+
val inactiveExperiment1 = createAvailableExperiment("inactive1")
79+
val inactiveExperiment2 = createAvailableExperiment("inactive2")
80+
val enrolledExperiment1 = createEnrolledExperiment("active1")
81+
val enrolledExperiment2 = createEnrolledExperiment("active2")
82+
83+
val nimbusApi = mockk<NimbusApi>()
84+
every { nimbusApi.getAvailableExperiments() } returns listOf(
85+
activeExperiment1,
86+
inactiveExperiment1,
87+
activeExperiment2,
88+
inactiveExperiment2,
89+
)
90+
every { nimbusApi.getActiveExperiments() } returns listOf(enrolledExperiment1, enrolledExperiment2)
91+
92+
val result = nimbusApi.partitionedExperimentLists()
93+
94+
assertEquals(6, result.size)
95+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_active), result[0])
96+
assertEquals(NimbusExperimentItem.Experiment(activeExperiment1), result[1])
97+
assertEquals(NimbusExperimentItem.Experiment(activeExperiment2), result[2])
98+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_inactive), result[3])
99+
assertEquals(NimbusExperimentItem.Experiment(inactiveExperiment1), result[4])
100+
assertEquals(NimbusExperimentItem.Experiment(inactiveExperiment2), result[5])
101+
}
102+
103+
@Test
104+
fun `WHEN single inactive experiment THEN returns correct structure`() {
105+
val experiment = createAvailableExperiment("exp1")
106+
107+
val nimbusApi = mockk<NimbusApi>()
108+
every { nimbusApi.getAvailableExperiments() } returns listOf(experiment)
109+
every { nimbusApi.getActiveExperiments() } returns emptyList()
110+
111+
val result = nimbusApi.partitionedExperimentLists()
112+
113+
assertEquals(4, result.size)
114+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_active), result[0])
115+
assertEquals(NimbusExperimentItem.EmptyState(R.string.preferences_nimbus_experiments_no_items), result[1])
116+
assertEquals(NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_inactive), result[2])
117+
assertEquals(NimbusExperimentItem.Experiment(experiment), result[3])
118+
}
119+
120+
private fun createAvailableExperiment(slug: String) = AvailableExperiment(
121+
slug = slug,
122+
userFacingName = "Experiment $slug",
123+
userFacingDescription = "Description for $slug",
124+
branches = emptyList(),
125+
referenceBranch = null,
126+
)
127+
128+
private fun createEnrolledExperiment(slug: String) = EnrolledExperiment(
129+
featureIds = emptyList(),
130+
slug = slug,
131+
userFacingName = "Enrolled $slug",
132+
userFacingDescription = "Enrolled description for $slug",
133+
branchSlug = "control",
134+
)
135+
}

0 commit comments

Comments
 (0)