[fenix] For https://github.com/mozilla-mobile/fenix/issues/18993 - Nimbus: Allow internal tooling to opt into specific branches of an experiment (https://github.com/mozilla-mobile/fenix/pull/19333)
parent
9855b1d05a
commit
744e447c64
@ -0,0 +1,95 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.nimbus
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import mozilla.components.lib.state.ext.consumeFrom
|
||||
import mozilla.components.support.base.log.logger.Logger
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.components.StoreProvider
|
||||
import org.mozilla.fenix.ext.components
|
||||
import org.mozilla.fenix.ext.showToolbar
|
||||
import org.mozilla.fenix.ext.withExperiment
|
||||
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
|
||||
import org.mozilla.fenix.nimbus.view.NimbusBranchesView
|
||||
|
||||
/**
|
||||
* A fragment to show the branches of a Nimbus experiment.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
class NimbusBranchesFragment : Fragment() {
|
||||
|
||||
private lateinit var nimbusBranchesStore: NimbusBranchesStore
|
||||
private lateinit var nimbusBranchesView: NimbusBranchesView
|
||||
private lateinit var controller: NimbusBranchesController
|
||||
|
||||
private val args by navArgs<NimbusBranchesFragmentArgs>()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view =
|
||||
inflater.inflate(R.layout.mozac_service_nimbus_experiment_details, container, false)
|
||||
|
||||
nimbusBranchesStore = StoreProvider.get(this) {
|
||||
NimbusBranchesStore(NimbusBranchesState(branches = emptyList()))
|
||||
}
|
||||
|
||||
controller = NimbusBranchesController(
|
||||
nimbusBranchesStore = nimbusBranchesStore,
|
||||
experiments = requireContext().components.analytics.experiments,
|
||||
experimentId = args.experimentId
|
||||
)
|
||||
|
||||
nimbusBranchesView =
|
||||
NimbusBranchesView(view.findViewById(R.id.nimbus_experiment_branches_list), controller)
|
||||
|
||||
loadExperimentBranches()
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
consumeFrom(nimbusBranchesStore) { state ->
|
||||
nimbusBranchesView.update(state)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
showToolbar(args.experimentName)
|
||||
}
|
||||
|
||||
private fun loadExperimentBranches() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val experiments = requireContext().components.analytics.experiments
|
||||
val branches = experiments.getExperimentBranches(args.experimentId) ?: emptyList()
|
||||
val selectedBranch = experiments.withExperiment(args.experimentId) ?: ""
|
||||
|
||||
nimbusBranchesStore.dispatch(
|
||||
NimbusBranchesAction.UpdateBranches(
|
||||
branches,
|
||||
selectedBranch
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Logger.error("Failed to getActiveExperiments()", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.nimbus
|
||||
|
||||
import mozilla.components.lib.state.Action
|
||||
import mozilla.components.lib.state.State
|
||||
import mozilla.components.lib.state.Store
|
||||
import org.mozilla.experiments.nimbus.Branch
|
||||
|
||||
/**
|
||||
* The [Store] for holding the [NimbusBranchesState] and applying [NimbusBranchesAction]s.
|
||||
*/
|
||||
class NimbusBranchesStore(initialState: NimbusBranchesState) :
|
||||
Store<NimbusBranchesState, NimbusBranchesAction>(
|
||||
initialState, ::nimbusBranchesFragmentStateReducer
|
||||
)
|
||||
|
||||
/**
|
||||
* The state for [NimbusBranchesFragment].
|
||||
*
|
||||
* @property branches The list of [Branch]s to display in the branches list.
|
||||
* @property selectedBranch The selected [Branch] slug for a Nimbus experiment.
|
||||
* @property isLoading True if the branches are still being loaded from storage, otherwise false.
|
||||
*/
|
||||
data class NimbusBranchesState(
|
||||
val branches: List<Branch>,
|
||||
val selectedBranch: String = "",
|
||||
val isLoading: Boolean = true
|
||||
) : State
|
||||
|
||||
/**
|
||||
* Actions to dispatch through the [NimbusBranchesStore] to modify the [NimbusBranchesState]
|
||||
* through the [nimbusBranchesFragmentStateReducer].
|
||||
*/
|
||||
sealed class NimbusBranchesAction : Action {
|
||||
/**
|
||||
* Updates the list of Nimbus branches and selected branch.
|
||||
*
|
||||
* @param branches The list of [Branch]s to display in the branches list.
|
||||
* @param selectedBranch The selected [Branch] slug for a Nimbus experiment.
|
||||
*/
|
||||
data class UpdateBranches(val branches: List<Branch>, val selectedBranch: String) :
|
||||
NimbusBranchesAction()
|
||||
|
||||
/**
|
||||
* Updates the selected branch.
|
||||
*
|
||||
* @param selectedBranch The selected [Branch] slug for a Nimbus experiment.
|
||||
*/
|
||||
data class UpdateSelectedBranch(val selectedBranch: String) : NimbusBranchesAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces the Nimbus branches state from the current state with the provided [action] to
|
||||
* be performed.
|
||||
*
|
||||
* @param state The current Nimbus branches state.
|
||||
* @param action The action to be performed on the state.
|
||||
* @return the new [NimbusBranchesState] with the [action] executed.
|
||||
*/
|
||||
private fun nimbusBranchesFragmentStateReducer(
|
||||
state: NimbusBranchesState,
|
||||
action: NimbusBranchesAction
|
||||
): NimbusBranchesState {
|
||||
return when (action) {
|
||||
is NimbusBranchesAction.UpdateBranches -> {
|
||||
state.copy(
|
||||
branches = action.branches,
|
||||
selectedBranch = action.selectedBranch,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
is NimbusBranchesAction.UpdateSelectedBranch -> {
|
||||
state.copy(selectedBranch = action.selectedBranch)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.nimbus
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import mozilla.components.service.nimbus.ui.NimbusDetailAdapter
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ext.showToolbar
|
||||
|
||||
/**
|
||||
* A fragment to show the details of a Nimbus experiment.
|
||||
*/
|
||||
class NimbusDetailsFragment : Fragment(R.layout.mozac_service_nimbus_experiment_details) {
|
||||
|
||||
private val args by navArgs<NimbusDetailsFragmentArgs>()
|
||||
private var adapter: NimbusDetailAdapter? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindRecyclerView(view)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
showToolbar(args.experiment)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
// Letting go of the resources to avoid memory leak.
|
||||
adapter = null
|
||||
}
|
||||
|
||||
private fun bindRecyclerView(view: View) {
|
||||
val recyclerView = view.findViewById<RecyclerView>(R.id.nimbus_experiment_branches_list)
|
||||
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
val shouldRefresh = adapter != null
|
||||
|
||||
// Dummy data until we have the appropriate Nimbus API.
|
||||
val branches = listOf(
|
||||
"Control",
|
||||
"Treatment"
|
||||
)
|
||||
|
||||
if (!shouldRefresh) {
|
||||
adapter = NimbusDetailAdapter(branches)
|
||||
}
|
||||
|
||||
recyclerView.adapter = adapter
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.nimbus.controller
|
||||
|
||||
import mozilla.components.service.nimbus.NimbusApi
|
||||
import mozilla.components.service.nimbus.ui.NimbusBranchesAdapterDelegate
|
||||
import org.mozilla.experiments.nimbus.Branch
|
||||
import org.mozilla.fenix.nimbus.NimbusBranchesAction
|
||||
import org.mozilla.fenix.nimbus.NimbusBranchesStore
|
||||
|
||||
/**
|
||||
* [NimbusBranchesFragment] controller. This implements [NimbusBranchesAdapterDelegate] to handle
|
||||
* interactions with a Nimbus branch item.
|
||||
*
|
||||
* @param nimbusBranchesStore An instance of [NimbusBranchesStore] for dispatching
|
||||
* [NimbusBranchesAction]s.
|
||||
* @param experiments An instance of [NimbusApi] for interacting with the Nimbus experiments.
|
||||
* @param experimentId The string experiment-id or "slug" for a Nimbus experiment.
|
||||
*/
|
||||
class NimbusBranchesController(
|
||||
private val nimbusBranchesStore: NimbusBranchesStore,
|
||||
private val experiments: NimbusApi,
|
||||
private val experimentId: String
|
||||
) : NimbusBranchesAdapterDelegate {
|
||||
|
||||
override fun onBranchItemClicked(branch: Branch) {
|
||||
experiments.optInWithBranch(experimentId, branch.slug)
|
||||
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(branch.slug))
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.nimbus.view
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.android.extensions.LayoutContainer
|
||||
import mozilla.components.service.nimbus.ui.NimbusBranchAdapter
|
||||
import org.mozilla.fenix.nimbus.NimbusBranchesState
|
||||
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
|
||||
|
||||
/**
|
||||
* View used for managing a Nimbus experiment's branches.
|
||||
*/
|
||||
class NimbusBranchesView(
|
||||
override val containerView: ViewGroup,
|
||||
val controller: NimbusBranchesController
|
||||
) : LayoutContainer {
|
||||
|
||||
private val nimbusAdapter = NimbusBranchAdapter(controller)
|
||||
|
||||
init {
|
||||
val recyclerView: RecyclerView = containerView as RecyclerView
|
||||
recyclerView.apply {
|
||||
adapter = nimbusAdapter
|
||||
layoutManager = LinearLayoutManager(containerView.context)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(state: NimbusBranchesState) {
|
||||
nimbusAdapter.updateData(state.branches, state.selectedBranch)
|
||||
}
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.nimbus
|
||||
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import mozilla.components.service.nimbus.NimbusApi
|
||||
import mozilla.components.support.test.mock
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mozilla.experiments.nimbus.Branch
|
||||
import org.mozilla.experiments.nimbus.FeatureConfig
|
||||
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
|
||||
|
||||
class NimbusBranchesControllerTest {
|
||||
|
||||
private val experiments: NimbusApi = mockk(relaxed = true)
|
||||
private val experimentId = "id"
|
||||
|
||||
private lateinit var controller: NimbusBranchesController
|
||||
private lateinit var nimbusBranchesStore: NimbusBranchesStore
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
nimbusBranchesStore = mock()
|
||||
controller = NimbusBranchesController(nimbusBranchesStore, experiments, experimentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN branch item is clicked THEN branch is opted into and selectedBranch state is updated`() {
|
||||
val branch = Branch(
|
||||
slug = "slug",
|
||||
ratio = 1,
|
||||
feature = FeatureConfig(
|
||||
featureId = "1",
|
||||
enabled = true
|
||||
)
|
||||
)
|
||||
|
||||
controller.onBranchItemClicked(branch)
|
||||
|
||||
verify {
|
||||
experiments.optInWithBranch(experimentId, branch.slug)
|
||||
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(branch.slug))
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.nimbus
|
||||
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mozilla.experiments.nimbus.Branch
|
||||
|
||||
class NimbusBranchesStoreTest {
|
||||
|
||||
private lateinit var nimbusBranchesState: NimbusBranchesState
|
||||
private lateinit var nimbusBranchesStore: NimbusBranchesStore
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
nimbusBranchesState = NimbusBranchesState(branches = emptyList())
|
||||
nimbusBranchesStore = NimbusBranchesStore(nimbusBranchesState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a new branch and selected branch WHEN UpdateBranches action is dispatched THEN state is updated`() = runBlocking {
|
||||
assertTrue(nimbusBranchesStore.state.isLoading)
|
||||
|
||||
val branches: List<Branch> = listOf(mockk(), mockk())
|
||||
val selectedBranch = "control"
|
||||
|
||||
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateBranches(branches, selectedBranch))
|
||||
.join()
|
||||
|
||||
assertEquals(branches, nimbusBranchesStore.state.branches)
|
||||
assertEquals(selectedBranch, nimbusBranchesStore.state.selectedBranch)
|
||||
assertFalse(nimbusBranchesStore.state.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a new selected branch WHEN UpdateSelectedBranch action is dispatched THEN selectedBranch state is updated`() = runBlocking {
|
||||
assertEquals("", nimbusBranchesStore.state.selectedBranch)
|
||||
|
||||
val selectedBranch = "control"
|
||||
|
||||
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(selectedBranch)).join()
|
||||
|
||||
assertEquals(selectedBranch, nimbusBranchesStore.state.selectedBranch)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue