[PM-29297] Add MigrateToMyItemsScreen (#6239)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Patrick Honkonen
2025-12-15 15:49:43 -05:00
committed by GitHub
parent 30ce512091
commit d09945d80b
8 changed files with 1037 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the migrate to my items screen.
*
* @property organizationId The ID of the organization requiring migration.
* @property organizationName The name of the organization requiring migration.
*/
@OmitFromCoverage
@Serializable
data class MigrateToMyItemsRoute(
val organizationId: String,
val organizationName: String,
)
/**
* Class to retrieve migrate to my items arguments from the [SavedStateHandle].
*
* @property organizationId The ID of the organization requiring migration.
* @property organizationName The name of the organization requiring migration.
*/
data class MigrateToMyItemsArgs(
val organizationId: String,
val organizationName: String,
)
/**
* Constructs a [MigrateToMyItemsArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toMigrateToMyItemsArgs(): MigrateToMyItemsArgs {
val route = this.toRoute<MigrateToMyItemsRoute>()
return MigrateToMyItemsArgs(
organizationId = route.organizationId,
organizationName = route.organizationName,
)
}
/**
* Navigate to the migrate to my items screen.
*
* @param organizationId The ID of the organization requiring migration.
* @param organizationName The name of the organization requiring migration.
*/
fun NavController.navigateToMigrateToMyItems(
organizationId: String,
organizationName: String,
navOptions: NavOptions? = null,
) {
this.navigate(
route = MigrateToMyItemsRoute(
organizationId = organizationId,
organizationName = organizationName,
),
navOptions = navOptions,
)
}
/**
* Add the migrate to my items screen to the nav graph.
*/
fun NavGraphBuilder.migrateToMyItemsDestination(
onNavigateToVault: () -> Unit,
onNavigateToLeaveOrganization: () -> Unit,
) {
composableWithSlideTransitions<MigrateToMyItemsRoute> {
MigrateToMyItemsScreen(
onNavigateToVault = onNavigateToVault,
onNavigateToLeaveOrganization = onNavigateToLeaveOrganization,
)
}
}

View File

@@ -0,0 +1,221 @@
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.handler.rememberMigrateToMyItemsHandler
/**
* Top level screen component for the MigrateToMyItems screen.
*/
@Composable
fun MigrateToMyItemsScreen(
onNavigateToVault: () -> Unit,
onNavigateToLeaveOrganization: () -> Unit,
viewModel: MigrateToMyItemsViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handlers = rememberMigrateToMyItemsHandler(viewModel)
EventsEffect(viewModel = viewModel) { event ->
when (event) {
MigrateToMyItemsEvent.NavigateToVault -> onNavigateToVault()
MigrateToMyItemsEvent.NavigateToLeaveOrganization -> onNavigateToLeaveOrganization()
is MigrateToMyItemsEvent.LaunchUri -> intentManager.launchUri(event.uri.toUri())
}
}
MigrateToMyItemsDialogs(
dialog = state.dialog,
onDismissRequest = handlers.onDismissDialog,
)
BitwardenScaffold {
MigrateToMyItemsContent(
state = state,
onAcceptClick = handlers.onAcceptClick,
onDeclineClick = handlers.onDeclineClick,
onHelpClick = handlers.onHelpClick,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
)
}
}
@Composable
private fun MigrateToMyItemsDialogs(
dialog: MigrateToMyItemsState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (dialog) {
is MigrateToMyItemsState.DialogState.Error -> {
BitwardenBasicDialog(
title = dialog.title(),
message = dialog.message(),
onDismissRequest = onDismissRequest,
)
}
is MigrateToMyItemsState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialog.message())
}
null -> Unit
}
}
@Composable
private fun MigrateToMyItemsContent(
state: MigrateToMyItemsState,
onAcceptClick: () -> Unit,
onDeclineClick: () -> Unit,
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = rememberVectorPainter(id = BitwardenDrawable.ill_migrate_to_my_items),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.standardHorizontalMargin()
.size(100.dp),
)
Spacer(modifier = Modifier.height(24.dp))
MigrateToMyItemsTextContent(organizationName = state.organizationName)
Spacer(modifier = Modifier.height(24.dp))
MigrateToMyItemsActions(
onContinueClick = onAcceptClick,
onDeclineClick = onDeclineClick,
onHelpClick = onHelpClick,
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun MigrateToMyItemsTextContent(
organizationName: String,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Text(
text = stringResource(
id = BitwardenString.transfer_items_to_org,
organizationName,
),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(
id = BitwardenString.transfer_items_description,
organizationName,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
}
@Composable
private fun MigrateToMyItemsActions(
onContinueClick: () -> Unit,
onDeclineClick: () -> Unit,
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BitwardenFilledButton(
label = stringResource(id = BitwardenString.accept),
onClick = onContinueClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.decline_and_leave),
onClick = onDeclineClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(id = BitwardenString.why_am_i_seeing_this),
onClick = onHelpClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
}
@Preview(showBackground = true)
@Composable
private fun MigrateToMyItemsScreen_preview() {
BitwardenTheme {
BitwardenScaffold {
MigrateToMyItemsContent(
state = MigrateToMyItemsState(
organizationId = "test-org-id",
organizationName = "Bitwarden",
dialog = null,
),
onAcceptClick = {},
onDeclineClick = {},
onHelpClick = {},
)
}
}
}

View File

@@ -0,0 +1,214 @@
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the [MigrateToMyItemsScreen].
*/
@HiltViewModel
class MigrateToMyItemsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<MigrateToMyItemsState, MigrateToMyItemsEvent, MigrateToMyItemsAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val args = savedStateHandle.toMigrateToMyItemsArgs()
MigrateToMyItemsState(
organizationId = args.organizationId,
organizationName = args.organizationName,
dialog = null,
)
},
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: MigrateToMyItemsAction) {
when (action) {
MigrateToMyItemsAction.AcceptClicked -> handleAcceptClicked()
MigrateToMyItemsAction.DeclineAndLeaveClicked -> handleDeclineAndLeaveClicked()
MigrateToMyItemsAction.HelpLinkClicked -> handleHelpLinkClicked()
MigrateToMyItemsAction.DismissDialogClicked -> handleDismissDialogClicked()
is MigrateToMyItemsAction.Internal -> handleInternalAction(action)
}
}
private fun handleAcceptClicked() {
mutableStateFlow.update {
it.copy(
dialog = MigrateToMyItemsState.DialogState.Loading(
message = BitwardenString.migrating_items_to_x.asText(
it.organizationName,
),
),
)
}
viewModelScope.launch {
// TODO: Replace `delay` with actual migration using `state.organizationId` (PM-28444).
delay(timeMillis = 100L)
trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
success = true,
),
)
}
}
private fun handleDeclineAndLeaveClicked() {
sendEvent(MigrateToMyItemsEvent.NavigateToLeaveOrganization)
}
private fun handleHelpLinkClicked() {
sendEvent(
MigrateToMyItemsEvent.LaunchUri(
uri = "https://bitwarden.com/help/transfer-ownership/",
),
)
}
private fun handleDismissDialogClicked() {
clearDialog()
}
private fun handleInternalAction(action: MigrateToMyItemsAction.Internal) {
when (action) {
is MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived -> {
handleMigrateToMyItemsResultReceived(action)
}
}
}
private fun handleMigrateToMyItemsResultReceived(
action: MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived,
) {
if (action.success) {
clearDialog()
sendEvent(MigrateToMyItemsEvent.NavigateToVault)
} else {
mutableStateFlow.update {
it.copy(
dialog = MigrateToMyItemsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.failed_to_migrate_items_to_x.asText(
it.organizationName,
),
),
)
}
}
}
private fun clearDialog() {
mutableStateFlow.update { it.copy(dialog = null) }
}
}
/**
* Models the state for the [MigrateToMyItemsScreen].
*/
@Parcelize
data class MigrateToMyItemsState(
val organizationId: String,
val organizationName: String,
val dialog: DialogState?,
) : Parcelable {
/**
* Models the dialog state for the [MigrateToMyItemsScreen].
*/
sealed class DialogState : Parcelable {
/**
* Displays a loading dialog.
*/
@Parcelize
data class Loading(val message: Text) : DialogState()
/**
* Displays an error dialog.
*/
@Parcelize
data class Error(
val title: Text,
val message: Text,
) : DialogState()
}
}
/**
* Models the events that can be sent from the [MigrateToMyItemsViewModel].
*/
sealed class MigrateToMyItemsEvent {
/**
* Navigate to the vault screen after accepting migration.
*/
data object NavigateToVault : MigrateToMyItemsEvent()
/**
* Navigate to the leave organization flow after declining.
*/
data object NavigateToLeaveOrganization : MigrateToMyItemsEvent()
/**
* Launch a URI in the browser or appropriate handler.
*/
data class LaunchUri(val uri: String) : MigrateToMyItemsEvent()
}
/**
* Models the actions that can be handled by the [MigrateToMyItemsViewModel].
*/
sealed class MigrateToMyItemsAction {
/**
* User clicked the Accept button.
*/
data object AcceptClicked : MigrateToMyItemsAction()
/**
* User clicked the Decline and Leave button.
*/
data object DeclineAndLeaveClicked : MigrateToMyItemsAction()
/**
* User clicked the "Why am I seeing this?" help link.
*/
data object HelpLinkClicked : MigrateToMyItemsAction()
/**
* User dismissed the dialog.
*/
data object DismissDialogClicked : MigrateToMyItemsAction()
/**
* Models internal actions that the [MigrateToMyItemsViewModel] itself may send.
*/
sealed class Internal : MigrateToMyItemsAction() {
/**
* The result of the migration has been received.
*/
data class MigrateToMyItemsResultReceived(
// TODO: Replace `success` with actual migration result (PM-28444).
val success: Boolean,
) : Internal()
}
}

View File

@@ -0,0 +1,52 @@
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.handler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsAction
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsScreen
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsViewModel
/**
* Action handlers for the [MigrateToMyItemsScreen].
*
* @property onAcceptClick Handler for when the user clicks the Accept button to accept migration.
* @property onDeclineClick Handler for when the user clicks the decline and leave button.
* @property onHelpClick Handler for when the user clicks the help link.
* @property onDismissDialog Handler for when the user dismisses a dialog.
*/
class MigrateToMyItemsHandler(
val onAcceptClick: () -> Unit,
val onDeclineClick: () -> Unit,
val onHelpClick: () -> Unit,
val onDismissDialog: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates an instance of [MigrateToMyItemsHandler] using the provided
* [MigrateToMyItemsViewModel].
*/
fun create(viewModel: MigrateToMyItemsViewModel) = MigrateToMyItemsHandler(
onAcceptClick = {
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
},
onDeclineClick = {
viewModel.trySendAction(MigrateToMyItemsAction.DeclineAndLeaveClicked)
},
onHelpClick = {
viewModel.trySendAction(MigrateToMyItemsAction.HelpLinkClicked)
},
onDismissDialog = {
viewModel.trySendAction(MigrateToMyItemsAction.DismissDialogClicked)
},
)
}
}
/**
* Helper function to remember a [MigrateToMyItemsHandler] instance in a [Composable] scope.
*/
@Composable
fun rememberMigrateToMyItemsHandler(viewModel: MigrateToMyItemsViewModel): MigrateToMyItemsHandler =
remember(viewModel) { MigrateToMyItemsHandler.create(viewModel) }

View File

@@ -0,0 +1,176 @@
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
class MigrateToMyItemsScreenTest : BitwardenComposeTest() {
private var onNavigateToVaultCalled = false
private var onNavigateToLeaveOrganizationCalled = false
private val intentManager: IntentManager = mockk {
every { launchUri(any()) } just runs
}
private val mutableEventFlow = bufferedMutableSharedFlow<MigrateToMyItemsEvent>()
private val mutableStateFlow = MutableStateFlow(
MigrateToMyItemsState(
organizationId = "test-org-id",
organizationName = "Test Organization",
dialog = null,
),
)
private val viewModel = mockk<MigrateToMyItemsViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent(intentManager = intentManager) {
MigrateToMyItemsScreen(
viewModel = viewModel,
onNavigateToVault = { onNavigateToVaultCalled = true },
onNavigateToLeaveOrganization = { onNavigateToLeaveOrganizationCalled = true },
)
}
}
@Test
fun `title should display with organization name`() {
composeTestRule
.onNodeWithText("Transfer items to Test Organization")
.assertIsDisplayed()
}
@Test
fun `description text should be displayed`() {
composeTestRule
.onNodeWithText(
"Test Organization is requiring all items to be owned by the " +
"organization for security and compliance. Click accept to transfer " +
"ownership of your items.",
substring = true,
)
.assertIsDisplayed()
}
@Test
fun `Accept button click should send AcceptClicked action`() {
composeTestRule.onNodeWithText("Accept").performClick()
verify {
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
}
}
@Test
fun `Decline and leave button click should send DeclineAndLeaveClicked action`() {
composeTestRule.onNodeWithText("Decline and leave").performClick()
verify {
viewModel.trySendAction(MigrateToMyItemsAction.DeclineAndLeaveClicked)
}
}
@Test
fun `Why am I seeing this link click should send HelpLinkClicked action`() {
composeTestRule.onNodeWithText("Why am I seeing this?").performClick()
verify {
viewModel.trySendAction(MigrateToMyItemsAction.HelpLinkClicked)
}
}
@Test
fun `NavigateToVault event should trigger navigation callback`() {
mutableEventFlow.tryEmit(MigrateToMyItemsEvent.NavigateToVault)
assertTrue(onNavigateToVaultCalled)
}
@Test
fun `NavigateToLeaveOrganization event should trigger navigation callback`() {
mutableEventFlow.tryEmit(MigrateToMyItemsEvent.NavigateToLeaveOrganization)
assertTrue(onNavigateToLeaveOrganizationCalled)
}
@Test
fun `LaunchUri event should launch URI via intent manager`() {
val testUri = "https://bitwarden.com/help/transfer-ownership/"
mutableEventFlow.tryEmit(MigrateToMyItemsEvent.LaunchUri(testUri))
verify {
intentManager.launchUri(testUri.toUri())
}
}
@Test
fun `Loading dialog should display when dialog state is Loading`() {
mutableStateFlow.value = MigrateToMyItemsState(
organizationId = "test-org-id",
organizationName = "Test Organization",
dialog = MigrateToMyItemsState.DialogState.Loading(
message = "Migrating items to Test Organization...".asText(),
),
)
composeTestRule
.onNodeWithText("Migrating items to Test Organization...")
.assertIsDisplayed()
}
@Test
fun `Error dialog should display when dialog state is Error`() {
mutableStateFlow.value = MigrateToMyItemsState(
organizationId = "test-org-id",
organizationName = "Test Organization",
dialog = MigrateToMyItemsState.DialogState.Error(
title = "An error has occurred".asText(),
message = "Failed to migrate items".asText(),
),
)
composeTestRule
.onNodeWithText("An error has occurred")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Failed to migrate items")
.assertIsDisplayed()
}
@Test
fun `Error dialog dismiss should send DismissDialogClicked action`() {
mutableStateFlow.value = MigrateToMyItemsState(
organizationId = "test-org-id",
organizationName = "Test Organization",
dialog = MigrateToMyItemsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = "Failed to migrate items".asText(),
),
)
composeTestRule
.onNodeWithText("Okay")
.performClick()
verify {
viewModel.trySendAction(MigrateToMyItemsAction.DismissDialogClicked)
}
}
}

View File

@@ -0,0 +1,167 @@
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
@BeforeEach
fun setup() {
mockkStatic(SavedStateHandle::toMigrateToMyItemsArgs)
}
@AfterEach
fun tearDown() {
unmockkStatic(SavedStateHandle::toMigrateToMyItemsArgs)
}
@Test
fun `initial state should be set from organization data`() {
val viewModel = createViewModel()
assertEquals(ORGANIZATION_NAME, viewModel.stateFlow.value.organizationName)
assertNull(viewModel.stateFlow.value.dialog)
}
@Test
fun `AcceptClicked should show loading dialog and trigger migration`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(null, awaitItem().dialog)
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
val loadingState = awaitItem()
assert(loadingState.dialog is MigrateToMyItemsState.DialogState.Loading)
assertEquals(
BitwardenString.migrating_items_to_x.asText(ORGANIZATION_NAME),
(loadingState.dialog as MigrateToMyItemsState.DialogState.Loading).message,
)
}
}
@Test
fun `AcceptClicked should navigate to vault on success`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
assertEquals(MigrateToMyItemsEvent.NavigateToVault, awaitItem())
}
}
@Test
fun `MigrateToMyItemsResultReceived with success should clear dialog and navigate to vault`() =
runTest {
val viewModel = createViewModel()
// First show the loading dialog
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
viewModel.eventFlow.test {
viewModel.trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
success = true,
),
)
assertEquals(MigrateToMyItemsEvent.NavigateToVault, awaitItem())
}
assertNull(viewModel.stateFlow.value.dialog)
}
@Test
fun `MigrateToMyItemsResultReceived with failure should show error dialog`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
awaitItem() // Initial state
viewModel.trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
success = false,
),
)
val errorState = awaitItem()
assert(errorState.dialog is MigrateToMyItemsState.DialogState.Error)
val errorDialog = errorState.dialog as MigrateToMyItemsState.DialogState.Error
assertEquals(BitwardenString.an_error_has_occurred.asText(), errorDialog.title)
assertEquals(
BitwardenString.failed_to_migrate_items_to_x.asText(ORGANIZATION_NAME),
errorDialog.message,
)
}
}
@Test
fun `DeclineAndLeaveClicked sends NavigateToLeaveOrganization event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MigrateToMyItemsAction.DeclineAndLeaveClicked)
assertEquals(MigrateToMyItemsEvent.NavigateToLeaveOrganization, awaitItem())
}
}
@Test
fun `HelpLinkClicked sends LaunchUri event with help URL`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MigrateToMyItemsAction.HelpLinkClicked)
val event = awaitItem()
assert(event is MigrateToMyItemsEvent.LaunchUri)
assertEquals(
"https://bitwarden.com/help/transfer-ownership/",
(event as MigrateToMyItemsEvent.LaunchUri).uri,
)
}
}
@Test
fun `DismissDialogClicked should clear dialog`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
awaitItem() // Initial state
// First show an error dialog
viewModel.trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
success = false,
),
)
val errorState = awaitItem()
assert(errorState.dialog is MigrateToMyItemsState.DialogState.Error)
// Dismiss the dialog
viewModel.trySendAction(MigrateToMyItemsAction.DismissDialogClicked)
val clearedState = awaitItem()
assertNull(clearedState.dialog)
}
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): MigrateToMyItemsViewModel {
every { savedStateHandle.toMigrateToMyItemsArgs() } returns MigrateToMyItemsArgs(
organizationId = ORGANIZATION_ID,
organizationName = ORGANIZATION_NAME,
)
return MigrateToMyItemsViewModel(
savedStateHandle = savedStateHandle,
)
}
}
private const val ORGANIZATION_ID = "test-organization-id"
private const val ORGANIZATION_NAME = "Test Organization"