diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsNavigation.kt new file mode 100644 index 0000000000..904c26274a --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsNavigation.kt @@ -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() + 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 { + MigrateToMyItemsScreen( + onNavigateToVault = onNavigateToVault, + onNavigateToLeaveOrganization = onNavigateToLeaveOrganization, + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreen.kt new file mode 100644 index 0000000000..216f5b69f4 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreen.kt @@ -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 = {}, + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt new file mode 100644 index 0000000000..dff00f3850 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt @@ -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( + 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() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/handler/MigrateToMyItemsHandler.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/handler/MigrateToMyItemsHandler.kt new file mode 100644 index 0000000000..c59d21db72 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/handler/MigrateToMyItemsHandler.kt @@ -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) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreenTest.kt new file mode 100644 index 0000000000..4875cf5fc4 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreenTest.kt @@ -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() + + private val mutableStateFlow = MutableStateFlow( + MigrateToMyItemsState( + organizationId = "test-org-id", + organizationName = "Test Organization", + dialog = null, + ), + ) + + private val viewModel = mockk(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) + } + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt new file mode 100644 index 0000000000..26f61fcf0b --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt @@ -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" diff --git a/ui/src/main/res/drawable/ill_migrate_to_my_items.xml b/ui/src/main/res/drawable/ill_migrate_to_my_items.xml new file mode 100644 index 0000000000..6409e853d9 --- /dev/null +++ b/ui/src/main/res/drawable/ill_migrate_to_my_items.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 299cc0a769..447bef4f70 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1166,4 +1166,10 @@ Do you want to switch to this account? Loading vault data… Resending You left the organization + Transfer items to %1$s + %1$s is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items. + Decline and leave + Why am I seeing this? + Migrating items to %s + Failed to migrate items to %s