Skip to content

Commit 96100ab

Browse files
committed
Bug 2011793 - Separate enrolled experiments in the full list r=andrey_zinovyev
Separates out the available list of experiments into an enrolled section for easier distinction against the unenrolled. **🏗️ Try (fenix preset):** https://treeherder.mozilla.org/jobs?repo=try&landoCommitID=174285 | Light | Dark | |--------|--------| | <img width="1080" height="2424" alt="Screenshot_20260121_200545" src="https://github.com/user-attachments/assets/898b0a18-97fd-4309-98c9-5110c9f1373f" /> | <img width="1080" height="2424" alt="Screenshot_20260121_221214" src="https://github.com/user-attachments/assets/54df9d60-482f-42d8-a785-845c78e9527a" /> | Pull request: #36
1 parent c4aecf2 commit 96100ab

5 files changed

Lines changed: 297 additions & 17 deletions

File tree

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@ 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
1319
import org.mozilla.fenix.R
1420
import org.mozilla.fenix.ext.components
1521
import org.mozilla.fenix.ext.showToolbar
22+
import org.mozilla.fenix.nimbus.ext.fetchPartitionedExperimentListsAsync
23+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem
1624
import org.mozilla.fenix.nimbus.view.NimbusExperiments
1725
import org.mozilla.fenix.theme.FirefoxTheme
1826

@@ -32,8 +40,12 @@ class NimbusExperimentsFragment : Fragment() {
3240
savedInstanceState: Bundle?,
3341
) = content {
3442
FirefoxTheme {
35-
val experiments =
36-
requireContext().components.nimbus.sdk.getAvailableExperiments()
43+
var experiments by remember { mutableStateOf(emptyList<NimbusExperimentItem>()) }
44+
val nimbusSdk = LocalContext.current.components.nimbus.sdk
45+
46+
LaunchedEffect(Unit) {
47+
experiments = nimbusSdk.fetchPartitionedExperimentListsAsync()
48+
}
3749

3850
NimbusExperiments(
3951
experiments = experiments,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 kotlinx.coroutines.Dispatchers.IO
8+
import kotlinx.coroutines.withContext
9+
import mozilla.components.service.nimbus.NimbusApi
10+
import org.mozilla.fenix.R
11+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem
12+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem.EmptyState
13+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem.Experiment
14+
import org.mozilla.fenix.nimbus.view.NimbusExperimentItem.Header
15+
16+
/**
17+
* Separates the experiment list into an "active" and "inactive" items based on enrollment for
18+
* rendering with [org.mozilla.fenix.nimbus.view.NimbusExperiments].
19+
*/
20+
internal fun NimbusApi.partitionedExperimentLists(): List<NimbusExperimentItem> {
21+
val availableExperiments = getAvailableExperiments()
22+
val activeExperimentSlugs = getActiveExperiments().map { it.slug }.toSet()
23+
24+
val (active, inactive) = availableExperiments.partition { it.slug in activeExperimentSlugs }
25+
26+
return buildList {
27+
add(Header(R.string.preferences_nimbus_experiments_active))
28+
29+
if (active.isEmpty()) {
30+
add(EmptyState(R.string.preferences_nimbus_experiments_no_items))
31+
} else {
32+
addAll(active.map { Experiment(it) })
33+
}
34+
35+
add(Header(R.string.preferences_nimbus_experiments_inactive))
36+
37+
if (inactive.isEmpty()) {
38+
add(EmptyState(R.string.preferences_nimbus_experiments_no_items))
39+
} else {
40+
addAll(inactive.map { Experiment(it) })
41+
}
42+
}
43+
}
44+
45+
/**
46+
* Separates the experiment list into an "active" and "inactive" items based on enrollment for
47+
* rendering with [org.mozilla.fenix.nimbus.view.NimbusExperiments] using
48+
* the [kotlinx.coroutines.Dispatchers.IO] dispatcher.
49+
*/
50+
suspend fun NimbusApi.fetchPartitionedExperimentListsAsync(): List<NimbusExperimentItem> =
51+
withContext(IO) { partitionedExperimentLists() }

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

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,118 @@
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

1724
/**
1825
* List of Nimbus Experiments.
1926
*
20-
* @param experiments List of [AvailableExperiment] that are going to be displayed.
27+
* @param experiments List of [NimbusExperimentItem] that are going to be displayed.
2128
* @param onExperimentClick Invoked when the user clicks on an [AvailableExperiment].
2229
*/
2330
@Composable
2431
fun NimbusExperiments(
25-
experiments: List<AvailableExperiment> = listOf(),
32+
experiments: List<NimbusExperimentItem> = listOf(),
2633
onExperimentClick: (AvailableExperiment) -> Unit,
2734
) {
2835
LazyColumn(
2936
modifier = Modifier.fillMaxSize(),
3037
) {
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-
)
38+
items(experiments) { item ->
39+
when (item) {
40+
is NimbusExperimentItem.Header -> NimbusExperimentHeader(titleResourceId = item.title)
41+
is NimbusExperimentItem.Experiment -> TextListItem(
42+
label = item.experiment.userFacingName,
43+
description = item.experiment.userFacingDescription,
44+
maxDescriptionLines = Int.MAX_VALUE,
45+
onClick = {
46+
onExperimentClick(item.experiment)
47+
},
48+
)
49+
is NimbusExperimentItem.EmptyState -> NimbusExperimentEmptyState(text = item.text)
50+
}
4051
}
4152
}
4253
}
4354

55+
/**
56+
* Item types for the list of experiments to be displayed.
57+
*/
58+
sealed class NimbusExperimentItem {
59+
/**
60+
* Title header for an experiment section. Typically, for "enrolled" or "unenrolled" sections.
61+
*
62+
* @property title the title to display.
63+
*/
64+
data class Header(
65+
@param:StringRes val title: Int,
66+
) : NimbusExperimentItem()
67+
68+
/**
69+
* An experiment item.
70+
*
71+
* @property experiment the experiment to display.
72+
*/
73+
data class Experiment(val experiment: AvailableExperiment) : NimbusExperimentItem()
74+
75+
/**
76+
* An empty section if there are no items to show.
77+
*
78+
* @property text the string to show when we have an empty state.
79+
*/
80+
data class EmptyState(
81+
@param:StringRes val text: Int,
82+
) : NimbusExperimentItem()
83+
}
84+
85+
@Composable
86+
private fun NimbusExperimentHeader(
87+
@StringRes titleResourceId: Int,
88+
) {
89+
Text(
90+
text = stringResource(titleResourceId),
91+
style = MaterialTheme.typography.titleSmall,
92+
color = MaterialTheme.colorScheme.primary,
93+
modifier = Modifier.padding(
94+
start = 16.dp,
95+
end = 16.dp,
96+
top = 16.dp,
97+
bottom = 8.dp,
98+
),
99+
)
100+
}
101+
102+
@Composable
103+
private fun NimbusExperimentEmptyState(
104+
@StringRes text: Int,
105+
) {
106+
Text(
107+
text = stringResource(text),
108+
style = MaterialTheme.typography.bodyMedium,
109+
color = MaterialTheme.colorScheme.secondary,
110+
modifier = Modifier.padding(
111+
start = 16.dp,
112+
end = 16.dp,
113+
top = 8.dp,
114+
bottom = 8.dp,
115+
),
116+
)
117+
}
118+
44119
@Composable
45120
@PreviewLightDark
46121
private fun NimbusExperimentsPreview() {
@@ -55,10 +130,11 @@ private fun NimbusExperimentsPreview() {
55130
FirefoxTheme {
56131
NimbusExperiments(
57132
experiments = listOf(
58-
testExperiment,
59-
testExperiment,
60-
testExperiment,
61-
testExperiment,
133+
NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_active),
134+
NimbusExperimentItem.EmptyState(R.string.preferences_nimbus_experiments_no_items),
135+
NimbusExperimentItem.Header(R.string.preferences_nimbus_experiments_inactive),
136+
NimbusExperimentItem.Experiment(testExperiment),
137+
NimbusExperimentItem.Experiment(testExperiment),
62138
),
63139
onExperimentClick = {},
64140
)

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)