[PM-26803] Show empty state when no items are available for export (#6023)

This commit is contained in:
Patrick Honkonen 2025-10-14 16:01:17 -04:00 committed by GitHub
parent 5b5176db40
commit af737b3f07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 306 additions and 74 deletions

View File

@ -7,6 +7,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.popUpToSelectAccountScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@ -34,6 +35,7 @@ fun NavGraphBuilder.reviewExportDestination(
composableWithPushTransitions<ReviewExportRoute> { composableWithPushTransitions<ReviewExportRoute> {
ReviewExportScreen( ReviewExportScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToAccountSelection = { navController.popUpToSelectAccountScreen() },
) )
} }
} }

View File

@ -1,6 +1,5 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport
import android.net.Uri
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
import com.bitwarden.ui.platform.base.util.EventsEffect import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.cardStyle 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.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton 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.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText 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. * export process.
* Defaults to the manager provided by [LocalCredentialExchangeCompletionManager]. * Defaults to the manager provided by [LocalCredentialExchangeCompletionManager].
*/ */
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ReviewExportScreen( fun ReviewExportScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToAccountSelection: () -> Unit,
viewModel: ReviewExportViewModel = hiltViewModel(), viewModel: ReviewExportViewModel = hiltViewModel(),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager = credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
LocalCredentialExchangeCompletionManager.current, LocalCredentialExchangeCompletionManager.current,
@ -86,6 +88,7 @@ fun ReviewExportScreen(
EventsEffect(viewModel) { EventsEffect(viewModel) {
when (it) { when (it) {
is ReviewExportEvent.NavigateBack -> onNavigateBack() is ReviewExportEvent.NavigateBack -> onNavigateBack()
is ReviewExportEvent.NavigateToAccountSelection -> onNavigateToAccountSelection()
is ReviewExportEvent.CompleteExport -> { is ReviewExportEvent.CompleteExport -> {
credentialExchangeCompletionManager.completeCredentialExport(it.result) credentialExchangeCompletionManager.completeCredentialExport(it.result)
} }
@ -107,14 +110,39 @@ fun ReviewExportScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(scrollBehavior.nestedScrollConnection)
.fillMaxSize(), .fillMaxSize(),
) { ) {
ReviewExportContent( when (val viewState = state.viewState) {
state = state, ReviewExportState.ViewState.NoItems -> {
onImportItemsClick = handler.onImportItemsClick, BitwardenEmptyContent(
onCancelClick = handler.onCancelClick, title = stringResource(BitwardenString.no_items_available_to_import),
modifier = Modifier text = stringResource(
.fillMaxSize() BitwardenString
.standardHorizontalMargin(), .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, * This composable lays out the illustrative image, titles, list of items to export,
* and action buttons. * 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 onImportItemsClick Callback invoked when the "Import Items" button is clicked.
* @param onCancelClick Callback invoked when the "Cancel" button is clicked. * @param onCancelClick Callback invoked when the "Cancel" button is clicked.
* @param modifier The modifier to be applied to the content root. * @param modifier The modifier to be applied to the content root.
@ -161,7 +189,7 @@ private fun ReviewExportDialogs(
@Suppress("LongMethod") @Suppress("LongMethod")
@Composable @Composable
private fun ReviewExportContent( private fun ReviewExportContent(
state: ReviewExportState, content: ReviewExportState.ViewState.Content,
onImportItemsClick: () -> Unit, onImportItemsClick: () -> Unit,
onCancelClick: () -> Unit, onCancelClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -211,27 +239,27 @@ private fun ReviewExportContent(
ItemCountRow( ItemCountRow(
label = stringResource(BitwardenString.passwords).asText(), label = stringResource(BitwardenString.passwords).asText(),
itemCount = state.viewState.itemTypeCounts.passwordCount, itemCount = content.itemTypeCounts.passwordCount,
cardStyle = CardStyle.Top(), cardStyle = CardStyle.Top(),
) )
ItemCountRow( ItemCountRow(
label = stringResource(BitwardenString.passkeys).asText(), label = stringResource(BitwardenString.passkeys).asText(),
itemCount = state.viewState.itemTypeCounts.passkeyCount, itemCount = content.itemTypeCounts.passkeyCount,
cardStyle = CardStyle.Middle(), cardStyle = CardStyle.Middle(),
) )
ItemCountRow( ItemCountRow(
label = stringResource(BitwardenString.identities).asText(), label = stringResource(BitwardenString.identities).asText(),
itemCount = state.viewState.itemTypeCounts.identityCount, itemCount = content.itemTypeCounts.identityCount,
cardStyle = CardStyle.Middle(), cardStyle = CardStyle.Middle(),
) )
ItemCountRow( ItemCountRow(
label = stringResource(BitwardenString.cards).asText(), label = stringResource(BitwardenString.cards).asText(),
itemCount = state.viewState.itemTypeCounts.cardCount, itemCount = content.itemTypeCounts.cardCount,
cardStyle = CardStyle.Middle(), cardStyle = CardStyle.Middle(),
) )
ItemCountRow( ItemCountRow(
label = stringResource(BitwardenString.secure_notes).asText(), label = stringResource(BitwardenString.secure_notes).asText(),
itemCount = state.viewState.itemTypeCounts.secureNoteCount, itemCount = content.itemTypeCounts.secureNoteCount,
cardStyle = CardStyle.Bottom, cardStyle = CardStyle.Bottom,
) )
@ -298,24 +326,23 @@ private fun ItemCountRow(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true, name = "Review Export Content") @Preview(showBackground = true, name = "Review Export Content")
@Composable @Composable
private fun ReviewExportContent_preview() { private fun ReviewExportContent_preview() {
BitwardenTheme { ExportItemsScaffold(
navIcon = rememberVectorPainter(BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(BitwardenString.close),
onNavigationIconClick = { },
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
) {
ReviewExportContent( ReviewExportContent(
state = ReviewExportState( content = ReviewExportState.ViewState.Content(
importCredentialsRequestData = ImportCredentialsRequestData( itemTypeCounts = ReviewExportState.ItemTypeCounts(
uri = Uri.EMPTY, passwordCount = 14,
requestJson = "", passkeyCount = 14,
), identityCount = 3,
viewState = ReviewExportState.ViewState( secureNoteCount = 5,
itemTypeCounts = ReviewExportState.ItemTypeCounts(
passwordCount = 14,
passkeyCount = 14,
identityCount = 3,
cardCount = 4,
secureNoteCount = 5,
),
), ),
), ),
onImportItemsClick = {}, 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(),
)
}
}

View File

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException
import androidx.credentials.providerevents.exception.ImportCredentialsException import androidx.credentials.providerevents.exception.ImportCredentialsException
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.repository.model.DataState 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.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.inject.Inject import javax.inject.Inject
@ -53,9 +55,10 @@ class ReviewExportViewModel @Inject constructor(
.specialCircumstance .specialCircumstance
?.toImportCredentialsRequestDataOrNull(), ?.toImportCredentialsRequestDataOrNull(),
), ),
viewState = ReviewExportState.ViewState( viewState = ReviewExportState.ViewState.Content(
itemTypeCounts = ReviewExportState.ItemTypeCounts(), itemTypeCounts = ReviewExportState.ItemTypeCounts(),
), ),
dialog = null,
), ),
) { ) {
@ -73,6 +76,7 @@ class ReviewExportViewModel @Inject constructor(
is ReviewExportAction.CancelClick -> handleCancelClicked() is ReviewExportAction.CancelClick -> handleCancelClicked()
is ReviewExportAction.DismissDialog -> handleDismissDialog() is ReviewExportAction.DismissDialog -> handleDismissDialog()
is ReviewExportAction.NavigateBackClick -> handleBackClick() is ReviewExportAction.NavigateBackClick -> handleBackClick()
is ReviewExportAction.SelectAnotherAccountClick -> handleSelectAnotherAccountClick()
is ReviewExportAction.Internal -> handleInternalAction(action) is ReviewExportAction.Internal -> handleInternalAction(action)
} }
} }
@ -111,7 +115,13 @@ class ReviewExportViewModel @Inject constructor(
} }
private fun handleCancelClicked() { private fun handleCancelClicked() {
sendEvent(ReviewExportEvent.NavigateBack) sendEvent(
ReviewExportEvent.CompleteExport(
ExportCredentialsResult.Failure(
ImportCredentialsCancellationException(),
),
),
)
} }
private fun handleDismissDialog() { private fun handleDismissDialog() {
@ -122,6 +132,10 @@ class ReviewExportViewModel @Inject constructor(
sendEvent(ReviewExportEvent.NavigateBack) sendEvent(ReviewExportEvent.NavigateBack)
} }
private fun handleSelectAnotherAccountClick() {
sendEvent(ReviewExportEvent.NavigateToAccountSelection)
}
private fun handleInternalAction(action: ReviewExportAction.Internal) { private fun handleInternalAction(action: ReviewExportAction.Internal) {
when (action) { when (action) {
is ReviewExportAction.Internal.VaultDataReceive -> { is ReviewExportAction.Internal.VaultDataReceive -> {
@ -168,7 +182,7 @@ class ReviewExportViewModel @Inject constructor(
private fun handleVaultDataError(data: DataState.Error<DecryptCipherListResult>) { private fun handleVaultDataError(data: DataState.Error<DecryptCipherListResult>) {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = it.viewState.copy( viewState = ReviewExportState.ViewState.Content(
itemTypeCounts = data.data.toItemTypeCounts(), itemTypeCounts = data.data.toItemTypeCounts(),
), ),
dialog = ReviewExportState.DialogState.General( dialog = ReviewExportState.DialogState.General(
@ -253,10 +267,17 @@ class ReviewExportViewModel @Inject constructor(
clearDialog: Boolean, clearDialog: Boolean,
) { ) {
mutableStateFlow.update { mutableStateFlow.update {
val itemTypeCounts = data.data.toItemTypeCounts()
val viewState = if (itemTypeCounts.hasItemsToExport) {
ReviewExportState.ViewState.Content(
itemTypeCounts = itemTypeCounts,
)
} else {
ReviewExportState.ViewState.NoItems
}
it.copy( it.copy(
viewState = it.viewState.copy( viewState = viewState,
itemTypeCounts = data.data.toItemTypeCounts(),
),
dialog = it.dialog.takeUnless { clearDialog }, dialog = it.dialog.takeUnless { clearDialog },
) )
} }
@ -281,9 +302,20 @@ data class ReviewExportState(
* Represents the view state with item type counts. * Represents the view state with item type counts.
*/ */
@Parcelize @Parcelize
data class ViewState( sealed class ViewState : Parcelable {
val itemTypeCounts: ItemTypeCounts,
) : 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. * Represents the counts of different item types to be exported.
@ -295,7 +327,17 @@ data class ReviewExportState(
val identityCount: Int = 0, val identityCount: Int = 0,
val cardCount: Int = 0, val cardCount: Int = 0,
val secureNoteCount: 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. * Represents the possible dialog states for the Review Import screen.
@ -350,6 +392,11 @@ sealed class ReviewExportAction {
*/ */
data object NavigateBackClick : 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. * Internal actions that the [ReviewExportViewModel] itself may send.
*/ */
@ -382,6 +429,11 @@ sealed class ReviewExportEvent {
*/ */
data object NavigateBack : ReviewExportEvent() data object NavigateBack : ReviewExportEvent()
/**
* Event to navigate to account selection.
*/
data object NavigateToAccountSelection : ReviewExportEvent()
/** /**
* Event indicating that the import attempt has completed. * Event indicating that the import attempt has completed.
* The consuming screen or navigation controller should handle this event to proceed * The consuming screen or navigation controller should handle this event to proceed

View File

@ -15,6 +15,7 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.ReviewExpor
*/ */
data class ReviewExportHandlers( data class ReviewExportHandlers(
val onImportItemsClick: () -> Unit, val onImportItemsClick: () -> Unit,
val onSelectAnotherAccountClick: () -> Unit,
val onCancelClick: () -> Unit, val onCancelClick: () -> Unit,
val onDismissDialog: () -> Unit, val onDismissDialog: () -> Unit,
val onNavigateBackClick: () -> Unit, val onNavigateBackClick: () -> Unit,
@ -36,6 +37,9 @@ data class ReviewExportHandlers(
onImportItemsClick = { onImportItemsClick = {
viewModel.trySendAction(ReviewExportAction.ImportItemsClick) viewModel.trySendAction(ReviewExportAction.ImportItemsClick)
}, },
onSelectAnotherAccountClick = {
viewModel.trySendAction(ReviewExportAction.SelectAnotherAccountClick)
},
onCancelClick = { onCancelClick = {
viewModel.trySendAction(ReviewExportAction.CancelClick) viewModel.trySendAction(ReviewExportAction.CancelClick)
}, },

View File

@ -40,3 +40,13 @@ fun NavController.navigateToSelectAccountScreen(
navOptions = navOptions, navOptions = navOptions,
) )
} }
/**
* Pop up to the [SelectAccountScreen].
*/
fun NavController.popUpToSelectAccountScreen() {
popBackStack(
route = SelectAccountRoute,
inclusive = false,
)
}

View File

@ -1,12 +1,15 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport
import android.net.Uri 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.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
@ -28,6 +31,7 @@ import org.junit.Test
class ReviewExportScreenTest : BitwardenComposeTest() { class ReviewExportScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false private var onNavigateBackCalled = false
private var onSelectAnotherAccountCalled = false
private val credentialExchangeCompletionManager = mockk<CredentialExchangeCompletionManager> { private val credentialExchangeCompletionManager = mockk<CredentialExchangeCompletionManager> {
every { completeCredentialExport(any()) } just runs every { completeCredentialExport(any()) } just runs
} }
@ -46,6 +50,7 @@ class ReviewExportScreenTest : BitwardenComposeTest() {
) { ) {
ReviewExportScreen( ReviewExportScreen(
onNavigateBack = { onNavigateBackCalled = true }, onNavigateBack = { onNavigateBackCalled = true },
onNavigateToAccountSelection = { onSelectAnotherAccountCalled = true },
viewModel = mockViewModel, viewModel = mockViewModel,
) )
} }
@ -134,11 +139,52 @@ class ReviewExportScreenTest : BitwardenComposeTest() {
mockViewModel.trySendAction(ReviewExportAction.NavigateBackClick) 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( private val DEFAULT_STATE = ReviewExportState(
viewState = ReviewExportState.ViewState( viewState = ReviewExportState.ViewState.Content(
itemTypeCounts = ReviewExportState.ItemTypeCounts(), itemTypeCounts = ReviewExportState.ItemTypeCounts(
passwordCount = 1,
passkeyCount = 1,
identityCount = 1,
cardCount = 1,
secureNoteCount = 1,
),
), ),
importCredentialsRequestData = ImportCredentialsRequestData( importCredentialsRequestData = ImportCredentialsRequestData(
uri = Uri.EMPTY, uri = Uri.EMPTY,

View File

@ -46,12 +46,7 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
every { userStateFlow } returns mutableUserStateFlow every { userStateFlow } returns mutableUserStateFlow
} }
private val decryptCipherListResultFlow = MutableStateFlow<DataState<DecryptCipherListResult>>( private val decryptCipherListResultFlow = MutableStateFlow<DataState<DecryptCipherListResult>>(
DataState.Loaded( DataState.Loaded(data = createMockDecryptCipherListResult(number = 1)),
data = DecryptCipherListResult(
successes = emptyList(),
failures = emptyList(),
),
),
) )
private val vaultRepository = mockk<VaultRepository> { private val vaultRepository = mockk<VaultRepository> {
every { decryptCipherListResultStateFlow } returns decryptCipherListResultFlow every { decryptCipherListResultStateFlow } returns decryptCipherListResultFlow
@ -68,13 +63,42 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
} }
@Nested @Nested
inner class Initialization { inner class State {
@Test @Test
fun `initial state is correct when SavedStateHandle is empty`() = fun `State should be NoItems when no items to export`() = runTest {
runTest { val initialState = ReviewExportState(
val viewModel = createViewModel() viewState = ReviewExportState.ViewState.NoItems,
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) 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 @Nested
@ -83,12 +107,13 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
@Test @Test
fun `ImportItemsClick shows loading, and calls exportVaultDataToCxf with all active items if there are no item restrictions`() = fun `ImportItemsClick shows loading, and calls exportVaultDataToCxf with all active items if there are no item restrictions`() =
runTest { runTest {
val mockActiveCipherListView = createMockCipherListView( val mockActiveCardCipherListView = createMockCipherListView(
number = 1, number = 1,
type = CipherListViewType.Card( type = CipherListViewType.Card(
createMockCardListView(number = 1), createMockCardListView(number = 1),
), ),
) )
val mockActiveLoginCipherListView = createMockCipherListView(number = 1)
val mockDeletedCipherListView = createMockCipherListView( val mockDeletedCipherListView = createMockCipherListView(
number = 1, number = 1,
isDeleted = true, isDeleted = true,
@ -98,14 +123,20 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
createMockDecryptCipherListResult( createMockDecryptCipherListResult(
number = 1, number = 1,
successes = listOf( successes = listOf(
mockActiveCipherListView, mockActiveLoginCipherListView,
mockActiveCardCipherListView,
mockDeletedCipherListView, mockDeletedCipherListView,
), ),
), ),
), ),
) )
coEvery { coEvery {
vaultRepository.exportVaultDataToCxf(listOf(mockActiveCipherListView)) vaultRepository.exportVaultDataToCxf(
listOf(
mockActiveLoginCipherListView,
mockActiveCardCipherListView,
),
)
} just awaits } just awaits
val viewModel = createViewModel() val viewModel = createViewModel()
@ -114,8 +145,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
// Check for loading dialog // Check for loading dialog
assertEquals( assertEquals(
DEFAULT_STATE.copy( DEFAULT_STATE.copy(
viewState = DEFAULT_STATE.viewState.copy( viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy( itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy(
cardCount = 1, cardCount = 1,
), ),
), ),
@ -127,7 +158,12 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
) )
coVerify { coVerify {
vaultRepository.exportVaultDataToCxf(listOf(mockActiveCipherListView)) vaultRepository.exportVaultDataToCxf(
listOf(
mockActiveLoginCipherListView,
mockActiveCardCipherListView,
),
)
} }
} }
@ -207,24 +243,41 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `CancelClicked sends NavigateBack event`() = runTest { fun `NavigateToAccountSelection sends SelectAnotherAccount event`() = runTest {
val viewModel = createViewModel() 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.eventFlow.test {
viewModel.trySendAction(ReviewExportAction.CancelClick) viewModel.trySendAction(ReviewExportAction.CancelClick)
assertEquals(ReviewExportEvent.NavigateBack, awaitItem()) assertTrue(awaitItem() is ReviewExportEvent.CompleteExport)
} }
} }
@Test @Test
fun `DismissDialog clears dialog from state`() = runTest { fun `DismissDialog clears dialog from state`() = runTest {
decryptCipherListResultFlow.value = DataState.Loading
val viewModel = createViewModel() val viewModel = createViewModel()
val exception = IllegalStateException()
decryptCipherListResultFlow.value = DataState.Error(
error = exception,
data = createMockDecryptCipherListResult(number = 1),
)
// Check for loading dialog // Check for loading dialog
assertEquals( assertEquals(
DEFAULT_STATE.copy( DEFAULT_STATE.copy(
dialog = ReviewExportState.DialogState.Loading( dialog = ReviewExportState.DialogState.General(
BitwardenString.loading.asText(), title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
error = exception,
), ),
), ),
viewModel.stateFlow.value, viewModel.stateFlow.value,
@ -254,8 +307,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
) )
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(
viewState = DEFAULT_STATE.viewState.copy( viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy( itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy(
passwordCount = 1, passwordCount = 1,
), ),
), ),
@ -283,8 +336,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
) )
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(
viewState = DEFAULT_STATE.viewState.copy( viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy( itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy(
passwordCount = 1, passwordCount = 1,
), ),
), ),
@ -312,8 +365,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
) )
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(
viewState = DEFAULT_STATE.viewState.copy( viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy( itemTypeCounts = DEFAULT_CONTENT_VIEW_STATE.itemTypeCounts.copy(
passwordCount = 1, passwordCount = 1,
), ),
), ),
@ -365,11 +418,14 @@ private val DEFAULT_REQUEST_DATA = ImportCredentialsRequestData(
uri = MOCK_URI, uri = MOCK_URI,
requestJson = "mockRequestJson", requestJson = "mockRequestJson",
) )
private val DEFAULT_CONTENT_VIEW_STATE = ReviewExportState.ViewState.Content(
itemTypeCounts = ReviewExportState.ItemTypeCounts(
passwordCount = 1,
),
)
private val DEFAULT_STATE: ReviewExportState = ReviewExportState( private val DEFAULT_STATE: ReviewExportState = ReviewExportState(
importCredentialsRequestData = DEFAULT_REQUEST_DATA, importCredentialsRequestData = DEFAULT_REQUEST_DATA,
viewState = ReviewExportState.ViewState( viewState = DEFAULT_CONTENT_VIEW_STATE,
itemTypeCounts = ReviewExportState.ItemTypeCounts(),
),
) )
private const val DEFAULT_USER_ID: String = "activeUserId" private const val DEFAULT_USER_ID: String = "activeUserId"
private val DEFAULT_USER_STATE = UserState( private val DEFAULT_USER_STATE = UserState(

View File

@ -1132,4 +1132,7 @@ Do you want to switch to this account?</string>
<string name="kdf_update_failed_active_account_not_found">Kdf update failed, active account not found. Please try again or contact us.</string> <string name="kdf_update_failed_active_account_not_found">Kdf update failed, active account not found. Please try again or contact us.</string>
<string name="an_error_occurred_while_trying_to_update_your_kdf_settings">An error occurred while trying to update your kdf settings. Please try again or contact us.</string> <string name="an_error_occurred_while_trying_to_update_your_kdf_settings">An error occurred while trying to update your kdf settings. Please try again or contact us.</string>
<string name="the_import_request_could_not_be_processed">The import request could not be processed.</string> <string name="the_import_request_could_not_be_processed">The import request could not be processed.</string>
<string name="your_vault_may_be_empty_or_import_some_item_types_isnt_supported">Your vault may be empty, or importing some item types isnt allowed for your account.</string>
<string name="no_items_available_to_import">No items available to import</string>
<string name="select_a_different_account">Select a different account</string>
</resources> </resources>