From af737b3f07caed36d663d148e8f9a49046e0abaa Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:01:17 -0400 Subject: [PATCH] [PM-26803] Show empty state when no items are available for export (#6023) --- .../reviewexport/ReviewExportNavigation.kt | 2 + .../reviewexport/ReviewExportScreen.kt | 121 +++++++++++++----- .../reviewexport/ReviewExportViewModel.kt | 72 +++++++++-- .../handlers/ReviewExportHandlers.kt | 4 + .../selectaccount/SelectAccountNavigation.kt | 10 ++ .../reviewexport/ReviewExportScreenTest.kt | 50 +++++++- .../reviewexport/ReviewExportViewModelTest.kt | 118 ++++++++++++----- ui/src/main/res/values/strings.xml | 3 + 8 files changed, 306 insertions(+), 74 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportNavigation.kt index e6643b1b6e..f197321f79 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportNavigation.kt @@ -7,6 +7,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.ui.platform.base.util.composableWithPushTransitions +import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.popUpToSelectAccountScreen import kotlinx.serialization.Serializable /** @@ -34,6 +35,7 @@ fun NavGraphBuilder.reviewExportDestination( composableWithPushTransitions { ReviewExportScreen( onNavigateBack = { navController.popBackStack() }, + onNavigateToAccountSelection = { navController.popUpToSelectAccountScreen() }, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreen.kt index 5e69b0bfa6..b867586a20 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreen.kt @@ -1,6 +1,5 @@ package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport -import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -32,7 +31,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager -import com.bitwarden.cxf.model.ImportCredentialsRequestData import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager import com.bitwarden.ui.platform.base.util.EventsEffect import com.bitwarden.ui.platform.base.util.cardStyle @@ -40,6 +38,8 @@ import com.bitwarden.ui.platform.base.util.nullableTestTag 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.model.BitwardenButtonData +import com.bitwarden.ui.platform.components.content.BitwardenEmptyContent import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText @@ -72,10 +72,12 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.handlers.re * export process. * Defaults to the manager provided by [LocalCredentialExchangeCompletionManager]. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReviewExportScreen( onNavigateBack: () -> Unit, + onNavigateToAccountSelection: () -> Unit, viewModel: ReviewExportViewModel = hiltViewModel(), credentialExchangeCompletionManager: CredentialExchangeCompletionManager = LocalCredentialExchangeCompletionManager.current, @@ -86,6 +88,7 @@ fun ReviewExportScreen( EventsEffect(viewModel) { when (it) { is ReviewExportEvent.NavigateBack -> onNavigateBack() + is ReviewExportEvent.NavigateToAccountSelection -> onNavigateToAccountSelection() is ReviewExportEvent.CompleteExport -> { credentialExchangeCompletionManager.completeCredentialExport(it.result) } @@ -107,14 +110,39 @@ fun ReviewExportScreen( .nestedScroll(scrollBehavior.nestedScrollConnection) .fillMaxSize(), ) { - ReviewExportContent( - state = state, - onImportItemsClick = handler.onImportItemsClick, - onCancelClick = handler.onCancelClick, - modifier = Modifier - .fillMaxSize() - .standardHorizontalMargin(), - ) + when (val viewState = state.viewState) { + ReviewExportState.ViewState.NoItems -> { + BitwardenEmptyContent( + title = stringResource(BitwardenString.no_items_available_to_import), + text = stringResource( + BitwardenString + .your_vault_may_be_empty_or_import_some_item_types_isnt_supported, + ), + primaryButton = BitwardenButtonData( + label = BitwardenString.select_a_different_account.asText(), + testTag = "SelectADifferentAccountButton", + onClick = handler.onSelectAnotherAccountClick, + ), + secondaryButton = BitwardenButtonData( + label = BitwardenString.cancel.asText(), + testTag = "NoItemsCancelButton", + onClick = handler.onCancelClick, + ), + modifier = Modifier.fillMaxSize(), + ) + } + + is ReviewExportState.ViewState.Content -> { + ReviewExportContent( + content = viewState, + onImportItemsClick = handler.onImportItemsClick, + onCancelClick = handler.onCancelClick, + modifier = Modifier + .fillMaxSize() + .standardHorizontalMargin(), + ) + } + } } } @@ -153,7 +181,7 @@ private fun ReviewExportDialogs( * This composable lays out the illustrative image, titles, list of items to export, * and action buttons. * - * @param state The current [ReviewExportState] to render. + * @param content The current [ReviewExportState] to render. * @param onImportItemsClick Callback invoked when the "Import Items" button is clicked. * @param onCancelClick Callback invoked when the "Cancel" button is clicked. * @param modifier The modifier to be applied to the content root. @@ -161,7 +189,7 @@ private fun ReviewExportDialogs( @Suppress("LongMethod") @Composable private fun ReviewExportContent( - state: ReviewExportState, + content: ReviewExportState.ViewState.Content, onImportItemsClick: () -> Unit, onCancelClick: () -> Unit, modifier: Modifier = Modifier, @@ -211,27 +239,27 @@ private fun ReviewExportContent( ItemCountRow( label = stringResource(BitwardenString.passwords).asText(), - itemCount = state.viewState.itemTypeCounts.passwordCount, + itemCount = content.itemTypeCounts.passwordCount, cardStyle = CardStyle.Top(), ) ItemCountRow( label = stringResource(BitwardenString.passkeys).asText(), - itemCount = state.viewState.itemTypeCounts.passkeyCount, + itemCount = content.itemTypeCounts.passkeyCount, cardStyle = CardStyle.Middle(), ) ItemCountRow( label = stringResource(BitwardenString.identities).asText(), - itemCount = state.viewState.itemTypeCounts.identityCount, + itemCount = content.itemTypeCounts.identityCount, cardStyle = CardStyle.Middle(), ) ItemCountRow( label = stringResource(BitwardenString.cards).asText(), - itemCount = state.viewState.itemTypeCounts.cardCount, + itemCount = content.itemTypeCounts.cardCount, cardStyle = CardStyle.Middle(), ) ItemCountRow( label = stringResource(BitwardenString.secure_notes).asText(), - itemCount = state.viewState.itemTypeCounts.secureNoteCount, + itemCount = content.itemTypeCounts.secureNoteCount, cardStyle = CardStyle.Bottom, ) @@ -298,24 +326,23 @@ private fun ItemCountRow( } } +@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true, name = "Review Export Content") @Composable private fun ReviewExportContent_preview() { - BitwardenTheme { + ExportItemsScaffold( + navIcon = rememberVectorPainter(BitwardenDrawable.ic_close), + navigationIconContentDescription = stringResource(BitwardenString.close), + onNavigationIconClick = { }, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + ) { ReviewExportContent( - state = ReviewExportState( - importCredentialsRequestData = ImportCredentialsRequestData( - uri = Uri.EMPTY, - requestJson = "", - ), - viewState = ReviewExportState.ViewState( - itemTypeCounts = ReviewExportState.ItemTypeCounts( - passwordCount = 14, - passkeyCount = 14, - identityCount = 3, - cardCount = 4, - secureNoteCount = 5, - ), + content = ReviewExportState.ViewState.Content( + itemTypeCounts = ReviewExportState.ItemTypeCounts( + passwordCount = 14, + passkeyCount = 14, + identityCount = 3, + secureNoteCount = 5, ), ), onImportItemsClick = {}, @@ -326,3 +353,35 @@ private fun ReviewExportContent_preview() { ) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true, name = "Review Export Empty Content") +@Composable +private fun ReviewExportContent_NoItems_preview() { + ExportItemsScaffold( + navIcon = rememberVectorPainter(BitwardenDrawable.ic_close), + navigationIconContentDescription = stringResource(BitwardenString.close), + onNavigationIconClick = { }, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + ) { + BitwardenEmptyContent( + title = stringResource(BitwardenString.no_items_available_to_import), + text = stringResource( + BitwardenString + .your_vault_may_be_empty_or_import_some_item_types_isnt_supported, + ), + primaryButton = BitwardenButtonData( + label = BitwardenString.select_a_different_account.asText(), + testTag = "SelectADifferentAccountButton", + onClick = { }, + ), + secondaryButton = BitwardenButtonData( + label = BitwardenString.cancel.asText(), + testTag = "NoItemsCancelButton", + onClick = { }, + ), + modifier = Modifier + .fillMaxSize(), + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModel.kt index 982b66688c..2b2e4b33f6 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModel.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport import android.os.Parcelable import androidx.compose.runtime.Stable +import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException import androidx.credentials.providerevents.exception.ImportCredentialsException import androidx.lifecycle.viewModelScope import com.bitwarden.core.data.repository.model.DataState @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -53,9 +55,10 @@ class ReviewExportViewModel @Inject constructor( .specialCircumstance ?.toImportCredentialsRequestDataOrNull(), ), - viewState = ReviewExportState.ViewState( + viewState = ReviewExportState.ViewState.Content( itemTypeCounts = ReviewExportState.ItemTypeCounts(), ), + dialog = null, ), ) { @@ -73,6 +76,7 @@ class ReviewExportViewModel @Inject constructor( is ReviewExportAction.CancelClick -> handleCancelClicked() is ReviewExportAction.DismissDialog -> handleDismissDialog() is ReviewExportAction.NavigateBackClick -> handleBackClick() + is ReviewExportAction.SelectAnotherAccountClick -> handleSelectAnotherAccountClick() is ReviewExportAction.Internal -> handleInternalAction(action) } } @@ -111,7 +115,13 @@ class ReviewExportViewModel @Inject constructor( } private fun handleCancelClicked() { - sendEvent(ReviewExportEvent.NavigateBack) + sendEvent( + ReviewExportEvent.CompleteExport( + ExportCredentialsResult.Failure( + ImportCredentialsCancellationException(), + ), + ), + ) } private fun handleDismissDialog() { @@ -122,6 +132,10 @@ class ReviewExportViewModel @Inject constructor( sendEvent(ReviewExportEvent.NavigateBack) } + private fun handleSelectAnotherAccountClick() { + sendEvent(ReviewExportEvent.NavigateToAccountSelection) + } + private fun handleInternalAction(action: ReviewExportAction.Internal) { when (action) { is ReviewExportAction.Internal.VaultDataReceive -> { @@ -168,7 +182,7 @@ class ReviewExportViewModel @Inject constructor( private fun handleVaultDataError(data: DataState.Error) { mutableStateFlow.update { it.copy( - viewState = it.viewState.copy( + viewState = ReviewExportState.ViewState.Content( itemTypeCounts = data.data.toItemTypeCounts(), ), dialog = ReviewExportState.DialogState.General( @@ -253,10 +267,17 @@ class ReviewExportViewModel @Inject constructor( clearDialog: Boolean, ) { mutableStateFlow.update { + val itemTypeCounts = data.data.toItemTypeCounts() + val viewState = if (itemTypeCounts.hasItemsToExport) { + ReviewExportState.ViewState.Content( + itemTypeCounts = itemTypeCounts, + ) + } else { + ReviewExportState.ViewState.NoItems + } + it.copy( - viewState = it.viewState.copy( - itemTypeCounts = data.data.toItemTypeCounts(), - ), + viewState = viewState, dialog = it.dialog.takeUnless { clearDialog }, ) } @@ -281,9 +302,20 @@ data class ReviewExportState( * Represents the view state with item type counts. */ @Parcelize - data class ViewState( - val itemTypeCounts: ItemTypeCounts, - ) : Parcelable + sealed class ViewState : Parcelable { + + /** + * Represents the content state with item type counts. + */ + data class Content( + val itemTypeCounts: ItemTypeCounts, + ) : ViewState() + + /** + * Represents the state when there are no items to be exported. + */ + data object NoItems : ViewState() + } /** * Represents the counts of different item types to be exported. @@ -295,7 +327,17 @@ data class ReviewExportState( val identityCount: Int = 0, val cardCount: Int = 0, val secureNoteCount: Int = 0, - ) : Parcelable + ) : Parcelable { + /** + * Whether there are any items to be exported. + */ + @IgnoredOnParcel + val hasItemsToExport: Boolean = passwordCount > 0 || + passkeyCount > 0 || + identityCount > 0 || + cardCount > 0 || + secureNoteCount > 0 + } /** * Represents the possible dialog states for the Review Import screen. @@ -350,6 +392,11 @@ sealed class ReviewExportAction { */ data object NavigateBackClick : ReviewExportAction() + /** + * Action triggered when the Select another account button is clicked by the user. + */ + data object SelectAnotherAccountClick : ReviewExportAction() + /** * Internal actions that the [ReviewExportViewModel] itself may send. */ @@ -382,6 +429,11 @@ sealed class ReviewExportEvent { */ data object NavigateBack : ReviewExportEvent() + /** + * Event to navigate to account selection. + */ + data object NavigateToAccountSelection : ReviewExportEvent() + /** * Event indicating that the import attempt has completed. * The consuming screen or navigation controller should handle this event to proceed diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/handlers/ReviewExportHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/handlers/ReviewExportHandlers.kt index fb9841bc33..a8dd520233 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/handlers/ReviewExportHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/handlers/ReviewExportHandlers.kt @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.ReviewExpor */ data class ReviewExportHandlers( val onImportItemsClick: () -> Unit, + val onSelectAnotherAccountClick: () -> Unit, val onCancelClick: () -> Unit, val onDismissDialog: () -> Unit, val onNavigateBackClick: () -> Unit, @@ -36,6 +37,9 @@ data class ReviewExportHandlers( onImportItemsClick = { viewModel.trySendAction(ReviewExportAction.ImportItemsClick) }, + onSelectAnotherAccountClick = { + viewModel.trySendAction(ReviewExportAction.SelectAnotherAccountClick) + }, onCancelClick = { viewModel.trySendAction(ReviewExportAction.CancelClick) }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountNavigation.kt index 06adfb1c3d..e36b3fb468 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountNavigation.kt @@ -40,3 +40,13 @@ fun NavController.navigateToSelectAccountScreen( navOptions = navOptions, ) } + +/** + * Pop up to the [SelectAccountScreen]. + */ +fun NavController.popUpToSelectAccountScreen() { + popBackStack( + route = SelectAccountRoute, + inclusive = false, + ) +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt index dc3908facc..554d0a25f1 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt @@ -1,12 +1,15 @@ package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport import android.net.Uri +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager @@ -28,6 +31,7 @@ import org.junit.Test class ReviewExportScreenTest : BitwardenComposeTest() { private var onNavigateBackCalled = false + private var onSelectAnotherAccountCalled = false private val credentialExchangeCompletionManager = mockk { every { completeCredentialExport(any()) } just runs } @@ -46,6 +50,7 @@ class ReviewExportScreenTest : BitwardenComposeTest() { ) { ReviewExportScreen( onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToAccountSelection = { onSelectAnotherAccountCalled = true }, viewModel = mockViewModel, ) } @@ -134,11 +139,52 @@ class ReviewExportScreenTest : BitwardenComposeTest() { mockViewModel.trySendAction(ReviewExportAction.NavigateBackClick) } } + + @Test + fun `EmptyContent should be displayed when no items to import`() { + // Verify initial state is ReviewExportContent + composeTestRule + .onNodeWithText("No items available to import") + .assertIsNotDisplayed() + + mockStateFlow.tryEmit( + DEFAULT_STATE.copy( + viewState = ReviewExportState.ViewState.NoItems, + ), + ) + + composeTestRule + .onNodeWithText("No items available to import") + .assertIsDisplayed() + } + + @Test + fun `SelectAnotherAccount click should send SelectAnotherAccountClick action`() { + mockStateFlow.tryEmit( + DEFAULT_STATE.copy( + viewState = ReviewExportState.ViewState.NoItems, + ), + ) + + composeTestRule + .onNodeWithText("Select a different account") + .performClick() + + verify { + mockViewModel.trySendAction(ReviewExportAction.SelectAnotherAccountClick) + } + } } private val DEFAULT_STATE = ReviewExportState( - viewState = ReviewExportState.ViewState( - itemTypeCounts = ReviewExportState.ItemTypeCounts(), + viewState = ReviewExportState.ViewState.Content( + itemTypeCounts = ReviewExportState.ItemTypeCounts( + passwordCount = 1, + passkeyCount = 1, + identityCount = 1, + cardCount = 1, + secureNoteCount = 1, + ), ), importCredentialsRequestData = ImportCredentialsRequestData( uri = Uri.EMPTY, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt index 2b7097a11f..e050edceee 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt @@ -46,12 +46,7 @@ class ReviewExportViewModelTest : BaseViewModelTest() { every { userStateFlow } returns mutableUserStateFlow } private val decryptCipherListResultFlow = MutableStateFlow>( - DataState.Loaded( - data = DecryptCipherListResult( - successes = emptyList(), - failures = emptyList(), - ), - ), + DataState.Loaded(data = createMockDecryptCipherListResult(number = 1)), ) private val vaultRepository = mockk { every { decryptCipherListResultStateFlow } returns decryptCipherListResultFlow @@ -68,13 +63,42 @@ class ReviewExportViewModelTest : BaseViewModelTest() { } @Nested - inner class Initialization { + inner class State { @Test - fun `initial state is correct when SavedStateHandle is empty`() = - runTest { - val viewModel = createViewModel() - assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + fun `State should be NoItems when no items to export`() = runTest { + val initialState = ReviewExportState( + viewState = ReviewExportState.ViewState.NoItems, + dialog = null, + importCredentialsRequestData = DEFAULT_REQUEST_DATA, + ) + decryptCipherListResultFlow.value = DataState.Loaded( + data = DecryptCipherListResult( + successes = emptyList(), + failures = emptyList(), + ), + ) + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) } + } + + @Test + fun `State should be Content when items to export`() = runTest { + val expectedState = ReviewExportState( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy( + passwordCount = 1, + ), + ), + dialog = null, + importCredentialsRequestData = DEFAULT_REQUEST_DATA, + ) + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } } @Nested @@ -83,12 +107,13 @@ class ReviewExportViewModelTest : BaseViewModelTest() { @Test fun `ImportItemsClick shows loading, and calls exportVaultDataToCxf with all active items if there are no item restrictions`() = runTest { - val mockActiveCipherListView = createMockCipherListView( + val mockActiveCardCipherListView = createMockCipherListView( number = 1, type = CipherListViewType.Card( createMockCardListView(number = 1), ), ) + val mockActiveLoginCipherListView = createMockCipherListView(number = 1) val mockDeletedCipherListView = createMockCipherListView( number = 1, isDeleted = true, @@ -98,14 +123,20 @@ class ReviewExportViewModelTest : BaseViewModelTest() { createMockDecryptCipherListResult( number = 1, successes = listOf( - mockActiveCipherListView, + mockActiveLoginCipherListView, + mockActiveCardCipherListView, mockDeletedCipherListView, ), ), ), ) coEvery { - vaultRepository.exportVaultDataToCxf(listOf(mockActiveCipherListView)) + vaultRepository.exportVaultDataToCxf( + listOf( + mockActiveLoginCipherListView, + mockActiveCardCipherListView, + ), + ) } just awaits val viewModel = createViewModel() @@ -114,8 +145,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() { // Check for loading dialog assertEquals( DEFAULT_STATE.copy( - viewState = DEFAULT_STATE.viewState.copy( - itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy( cardCount = 1, ), ), @@ -127,7 +158,12 @@ class ReviewExportViewModelTest : BaseViewModelTest() { ) coVerify { - vaultRepository.exportVaultDataToCxf(listOf(mockActiveCipherListView)) + vaultRepository.exportVaultDataToCxf( + listOf( + mockActiveLoginCipherListView, + mockActiveCardCipherListView, + ), + ) } } @@ -207,24 +243,41 @@ class ReviewExportViewModelTest : BaseViewModelTest() { } @Test - fun `CancelClicked sends NavigateBack event`() = runTest { + fun `NavigateToAccountSelection sends SelectAnotherAccount event`() = runTest { val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ReviewExportAction.SelectAnotherAccountClick) + assertEquals( + ReviewExportEvent.NavigateToAccountSelection, + awaitItem(), + ) + } + } + @Test + fun `CancelClicked sends CompleteExport event`() = runTest { + val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(ReviewExportAction.CancelClick) - assertEquals(ReviewExportEvent.NavigateBack, awaitItem()) + assertTrue(awaitItem() is ReviewExportEvent.CompleteExport) } } @Test fun `DismissDialog clears dialog from state`() = runTest { - decryptCipherListResultFlow.value = DataState.Loading val viewModel = createViewModel() + val exception = IllegalStateException() + decryptCipherListResultFlow.value = DataState.Error( + error = exception, + data = createMockDecryptCipherListResult(number = 1), + ) // Check for loading dialog assertEquals( DEFAULT_STATE.copy( - dialog = ReviewExportState.DialogState.Loading( - BitwardenString.loading.asText(), + dialog = ReviewExportState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + error = exception, ), ), viewModel.stateFlow.value, @@ -254,8 +307,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() { ) val expectedState = DEFAULT_STATE.copy( - viewState = DEFAULT_STATE.viewState.copy( - itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy( passwordCount = 1, ), ), @@ -283,8 +336,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() { ) val expectedState = DEFAULT_STATE.copy( - viewState = DEFAULT_STATE.viewState.copy( - itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy( passwordCount = 1, ), ), @@ -312,8 +365,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() { ) val expectedState = DEFAULT_STATE.copy( - viewState = DEFAULT_STATE.viewState.copy( - itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy( passwordCount = 1, ), ), @@ -365,11 +418,14 @@ private val DEFAULT_REQUEST_DATA = ImportCredentialsRequestData( uri = MOCK_URI, requestJson = "mockRequestJson", ) +private val DEFAULT_CONTENT_VIEW_STATE = ReviewExportState.ViewState.Content( + itemTypeCounts = ReviewExportState.ItemTypeCounts( + passwordCount = 1, + ), +) private val DEFAULT_STATE: ReviewExportState = ReviewExportState( importCredentialsRequestData = DEFAULT_REQUEST_DATA, - viewState = ReviewExportState.ViewState( - itemTypeCounts = ReviewExportState.ItemTypeCounts(), - ), + viewState = DEFAULT_CONTENT_VIEW_STATE, ) private const val DEFAULT_USER_ID: String = "activeUserId" private val DEFAULT_USER_STATE = UserState( diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index b49c2e63b6..12e912f202 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1132,4 +1132,7 @@ Do you want to switch to this account? Kdf update failed, active account not found. Please try again or contact us. An error occurred while trying to update your kdf settings. Please try again or contact us. The import request could not be processed. + Your vault may be empty, or importing some item types isn’t allowed for your account. + No items available to import + Select a different account