PM-13627 show action card on vault settings if applicable (#4101)

This commit is contained in:
Dave Severns 2024-10-21 16:51:49 -04:00 committed by GitHub
parent 09c11f4890
commit c704cd2eca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 240 additions and 4 deletions

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.vault package com.x8bit.bitwarden.ui.platform.feature.settings.vault
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -18,12 +19,17 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.x8bit.bitwarden.ui.platform.components.card.actionCardExitAnimation
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
@ -90,6 +96,35 @@ fun VaultSettingsScreen(
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
AnimatedVisibility(
visible = state.shouldShowImportCard,
label = "ImportLoginsActionCard",
exit = actionCardExitAnimation(),
) {
BitwardenActionCard(
cardTitle = stringResource(id = R.string.import_saved_logins),
actionText = stringResource(R.string.get_started),
cardSubtitle = stringResource(R.string.use_a_computer_to_import_logins),
onActionClick = remember(viewModel) {
{
viewModel.trySendAction(VaultSettingsAction.ImportLoginsCardCtaClick)
}
},
onDismissClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultSettingsAction.ImportLoginsCardDismissClick,
)
}
},
leadingContent = {
NotificationBadge(notificationCount = 1)
},
modifier = Modifier
.standardHorizontalMargin()
.padding(top = 12.dp, bottom = 16.dp),
)
}
BitwardenTextRow( BitwardenTextRow(
text = stringResource(R.string.folders), text = stringResource(R.string.folders),
onClick = remember(viewModel) { onClick = remember(viewModel) {

View file

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.vault package com.x8bit.bitwarden.ui.platform.feature.settings.vault
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.toBaseWebVaultImportUrl import com.x8bit.bitwarden.data.platform.repository.util.toBaseWebVaultImportUrl
@ -16,12 +18,16 @@ import javax.inject.Inject
/** /**
* View model for the vault screen. * View model for the vault screen.
*/ */
@Suppress("TooManyFunctions")
@HiltViewModel @HiltViewModel
class VaultSettingsViewModel @Inject constructor( class VaultSettingsViewModel @Inject constructor(
environmentRepository: EnvironmentRepository, environmentRepository: EnvironmentRepository,
val featureFlagManager: FeatureFlagManager, featureFlagManager: FeatureFlagManager,
private val authRepository: AuthRepository,
private val firstTimeActionManager: FirstTimeActionManager,
) : BaseViewModel<VaultSettingsState, VaultSettingsEvent, VaultSettingsAction>( ) : BaseViewModel<VaultSettingsState, VaultSettingsEvent, VaultSettingsAction>(
initialState = run { initialState = run {
val firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState
VaultSettingsState( VaultSettingsState(
importUrl = environmentRepository importUrl = environmentRepository
.environment .environment
@ -29,6 +35,7 @@ class VaultSettingsViewModel @Inject constructor(
.toBaseWebVaultImportUrl, .toBaseWebVaultImportUrl,
isNewImportLoginsFlowEnabled = featureFlagManager isNewImportLoginsFlowEnabled = featureFlagManager
.getFeatureFlag(FlagKey.ImportLoginsFlow), .getFeatureFlag(FlagKey.ImportLoginsFlow),
showImportActionCard = firstTimeState.showImportLoginsCard,
) )
}, },
) { ) {
@ -38,6 +45,16 @@ class VaultSettingsViewModel @Inject constructor(
.map { VaultSettingsAction.Internal.ImportLoginsFeatureFlagChanged(it) } .map { VaultSettingsAction.Internal.ImportLoginsFeatureFlagChanged(it) }
.onEach(::sendAction) .onEach(::sendAction)
.launchIn(viewModelScope) .launchIn(viewModelScope)
firstTimeActionManager
.firstTimeStateFlow
.map {
VaultSettingsAction.Internal.UserFirstTimeStateChanged(
showImportLoginsCard = it.showImportLoginsCard,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
} }
override fun handleAction(action: VaultSettingsAction): Unit = when (action) { override fun handleAction(action: VaultSettingsAction): Unit = when (action) {
@ -45,9 +62,38 @@ class VaultSettingsViewModel @Inject constructor(
VaultSettingsAction.ExportVaultClick -> handleExportVaultClicked() VaultSettingsAction.ExportVaultClick -> handleExportVaultClicked()
VaultSettingsAction.FoldersButtonClick -> handleFoldersButtonClicked() VaultSettingsAction.FoldersButtonClick -> handleFoldersButtonClicked()
VaultSettingsAction.ImportItemsClick -> handleImportItemsClicked() VaultSettingsAction.ImportItemsClick -> handleImportItemsClicked()
VaultSettingsAction.ImportLoginsCardCtaClick -> handleImportLoginsCardClicked()
VaultSettingsAction.ImportLoginsCardDismissClick -> handleImportLoginsCardDismissClicked()
is VaultSettingsAction.Internal -> handleInternalAction(action)
}
private fun handleInternalAction(action: VaultSettingsAction.Internal) {
when (action) {
is VaultSettingsAction.Internal.ImportLoginsFeatureFlagChanged -> { is VaultSettingsAction.Internal.ImportLoginsFeatureFlagChanged -> {
handleImportLoginsFeatureFlagChanged(action) handleImportLoginsFeatureFlagChanged(action)
} }
is VaultSettingsAction.Internal.UserFirstTimeStateChanged -> {
handleUserFirstTimeStateChanged(action)
}
}
}
private fun handleImportLoginsCardDismissClicked() {
dismissImportLoginsCard()
}
private fun handleImportLoginsCardClicked() {
dismissImportLoginsCard()
sendEvent(VaultSettingsEvent.NavigateToImportVault(state.importUrl))
}
private fun handleUserFirstTimeStateChanged(
action: VaultSettingsAction.Internal.UserFirstTimeStateChanged,
) {
mutableStateFlow.update {
it.copy(showImportActionCard = action.showImportLoginsCard)
}
} }
private fun handleImportLoginsFeatureFlagChanged( private fun handleImportLoginsFeatureFlagChanged(
@ -75,6 +121,11 @@ class VaultSettingsViewModel @Inject constructor(
VaultSettingsEvent.NavigateToImportVault(state.importUrl), VaultSettingsEvent.NavigateToImportVault(state.importUrl),
) )
} }
private fun dismissImportLoginsCard() {
if (!state.shouldShowImportCard) return
authRepository.setShowImportLogins(showImportLogins = false)
}
} }
/** /**
@ -83,7 +134,14 @@ class VaultSettingsViewModel @Inject constructor(
data class VaultSettingsState( data class VaultSettingsState(
val importUrl: String, val importUrl: String,
val isNewImportLoginsFlowEnabled: Boolean, val isNewImportLoginsFlowEnabled: Boolean,
) private val showImportActionCard: Boolean,
) {
/**
* Should only show the import action card if the import logins feature flag is enabled.
*/
val shouldShowImportCard: Boolean
get() = showImportActionCard && isNewImportLoginsFlowEnabled
}
/** /**
* Models events for the vault screen. * Models events for the vault screen.
@ -141,6 +199,16 @@ sealed class VaultSettingsAction {
*/ */
data object ImportItemsClick : VaultSettingsAction() data object ImportItemsClick : VaultSettingsAction()
/**
* Indicates that the user clicked the CTA on the action card.
*/
data object ImportLoginsCardCtaClick : VaultSettingsAction()
/**
* Indicates that the user dismissed the action card.
*/
data object ImportLoginsCardDismissClick : VaultSettingsAction()
/** /**
* Internal actions not performed by user interation * Internal actions not performed by user interation
*/ */
@ -152,5 +220,12 @@ sealed class VaultSettingsAction {
data class ImportLoginsFeatureFlagChanged( data class ImportLoginsFeatureFlagChanged(
val isEnabled: Boolean, val isEnabled: Boolean,
) : Internal() ) : Internal()
/**
* Indicates user first time state has changed.
*/
data class UserFirstTimeStateChanged(
val showImportLoginsCard: Boolean,
) : Internal()
} }
} }

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
@ -34,6 +35,7 @@ class VaultSettingsScreenTest : BaseComposeTest() {
VaultSettingsState( VaultSettingsState(
importUrl = "testUrl/#/tools/import", importUrl = "testUrl/#/tools/import",
isNewImportLoginsFlowEnabled = false, isNewImportLoginsFlowEnabled = false,
showImportActionCard = false,
), ),
) )
private val intentManager: IntentManager = mockk(relaxed = true) { private val intentManager: IntentManager = mockk(relaxed = true) {
@ -157,4 +159,58 @@ class VaultSettingsScreenTest : BaseComposeTest() {
assertTrue(onNavigateToImportLoginsCalled) assertTrue(onNavigateToImportLoginsCalled)
verify(exactly = 0) { intentManager.launchUri(testUrl.toUri()) } verify(exactly = 0) { intentManager.launchUri(testUrl.toUri()) }
} }
@Test
fun `when new show action card is true the import logins card should show`() {
mutableStateFlow.update {
it.copy(
showImportActionCard = true,
isNewImportLoginsFlowEnabled = true,
)
}
composeTestRule
.onNodeWithText("Import saved logins")
.performScrollTo()
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(showImportActionCard = false)
}
composeTestRule
.onNodeWithText("Import saved logins")
.assertDoesNotExist()
}
@Test
fun `when action card is visible clicking the close icon should send correct action`() {
mutableStateFlow.update {
it.copy(
showImportActionCard = true,
isNewImportLoginsFlowEnabled = true,
)
}
composeTestRule
.onNodeWithContentDescription("Close")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(VaultSettingsAction.ImportLoginsCardDismissClick)
}
}
@Test
fun `when action card is visible get started button should send correct action`() {
mutableStateFlow.update {
it.copy(
showImportActionCard = true,
isNewImportLoginsFlowEnabled = true,
)
}
composeTestRule
.onNodeWithText("Get started")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(VaultSettingsAction.ImportLoginsCardCtaClick)
}
}
} }

View file

@ -1,12 +1,18 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.vault package com.x8bit.bitwarden.ui.platform.feature.settings.vault
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -22,6 +28,14 @@ class VaultSettingsViewModelTest : BaseViewModelTest() {
every { getFeatureFlagFlow(FlagKey.ImportLoginsFlow) } returns mutableImportLoginsFlagFlow every { getFeatureFlagFlow(FlagKey.ImportLoginsFlow) } returns mutableImportLoginsFlagFlow
every { getFeatureFlag(FlagKey.ImportLoginsFlow) } returns false every { getFeatureFlag(FlagKey.ImportLoginsFlow) } returns false
} }
private val authRepository = mockk<AuthRepository> {
every { setShowImportLogins(any()) } just runs
}
private val mutableFirstTimeStateFlow = MutableStateFlow(DEFAULT_FIRST_TIME_STATE)
private val firstTimeActionManager = mockk<FirstTimeActionManager> {
every { currentOrDefaultUserFirstTimeState } returns DEFAULT_FIRST_TIME_STATE
every { firstTimeStateFlow } returns mutableFirstTimeStateFlow
}
@Test @Test
fun `BackClick should emit NavigateBack`() = runTest { fun `BackClick should emit NavigateBack`() = runTest {
@ -67,8 +81,64 @@ class VaultSettingsViewModelTest : BaseViewModelTest() {
assertTrue(viewModel.stateFlow.value.isNewImportLoginsFlowEnabled) assertTrue(viewModel.stateFlow.value.isNewImportLoginsFlowEnabled)
} }
@Test
fun `shouldShowImportCard should update when first time state changes`() = runTest {
mutableImportLoginsFlagFlow.update { true }
val viewModel = createViewModel()
assertTrue(viewModel.stateFlow.value.shouldShowImportCard)
mutableFirstTimeStateFlow.update {
it.copy(showImportLoginsCard = false)
}
assertFalse(viewModel.stateFlow.value.shouldShowImportCard)
}
@Test
fun `shouldShowImportCard should be false when feature flag not enabled`() = runTest {
val viewModel = createViewModel()
mutableImportLoginsFlagFlow.update { false }
assertFalse(viewModel.stateFlow.value.shouldShowImportCard)
}
@Suppress("MaxLineLength")
@Test
fun `ImportLoginsCardCtaClick action should set repository value to false and send navigation event`() =
runTest {
val viewModel = createViewModel()
val expected = "https://vault.bitwarden.com/#/tools/import"
mutableImportLoginsFlagFlow.update { true }
viewModel.eventFlow.test {
viewModel.trySendAction(VaultSettingsAction.ImportLoginsCardCtaClick)
assertEquals(
VaultSettingsEvent.NavigateToImportVault(url = expected),
awaitItem(),
)
}
verify(exactly = 1) { authRepository.setShowImportLogins(false) }
}
@Test
fun `ImportLoginsCardDismissClick action should set repository value to false `() = runTest {
val viewModel = createViewModel()
mutableImportLoginsFlagFlow.update { true }
viewModel.trySendAction(VaultSettingsAction.ImportLoginsCardDismissClick)
verify(exactly = 1) { authRepository.setShowImportLogins(false) }
}
@Suppress("MaxLineLength")
@Test
fun `ImportLoginsCardDismissClick action should not set repository value to false if already false`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(VaultSettingsAction.ImportLoginsCardDismissClick)
verify(exactly = 0) { authRepository.setShowImportLogins(false) }
}
private fun createViewModel(): VaultSettingsViewModel = VaultSettingsViewModel( private fun createViewModel(): VaultSettingsViewModel = VaultSettingsViewModel(
environmentRepository = environmentRepository, environmentRepository = environmentRepository,
featureFlagManager = featureFlagManager, featureFlagManager = featureFlagManager,
authRepository = authRepository,
firstTimeActionManager = firstTimeActionManager,
) )
} }
private val DEFAULT_FIRST_TIME_STATE = FirstTimeState(showImportLoginsCard = true)