PM-23503: Update Move to Organization toasts to be snackbars (#5489)

This commit is contained in:
David Perez
2025-07-07 21:43:08 +00:00
committed by GitHub
parent eb5893dde4
commit 2ff3f3e23d
11 changed files with 156 additions and 28 deletions
@@ -10,6 +10,7 @@ import kotlinx.serialization.Serializable
@Serializable
enum class SnackbarRelay {
CIPHER_DELETED,
CIPHER_MOVED_TO_ORGANIZATION,
CIPHER_RESTORED,
LOGIN_APPROVAL,
LOGINS_IMPORTED,
@@ -68,6 +68,8 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialo
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.radio.BitwardenRadioButton
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager
import com.x8bit.bitwarden.ui.platform.composition.LocalCredentialProviderCompletionManager
@@ -124,6 +126,7 @@ fun VaultAddEditScreen(
orderedList = AddEditItemCoachMark.entries,
)
val scope = rememberCoroutineScope()
val snackbarHostState = rememberBitwardenSnackbarHostState()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultAddEditEvent.NavigateToQrCodeScan -> {
@@ -189,6 +192,8 @@ fun VaultAddEditScreen(
)
}
}
is VaultAddEditEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
}
}
@@ -394,6 +399,9 @@ fun VaultAddEditScreen(
},
)
},
snackbarHost = {
BitwardenSnackbarHost(bitwardenHostState = snackbarHostState)
},
) {
when (val viewState = state.viewState) {
is VaultAddEditState.ViewState.Content -> {
@@ -53,7 +53,10 @@ import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldAction
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldType
@@ -108,12 +111,13 @@ private const val KEY_STATE = "state"
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList", "LongMethod")
class VaultAddEditViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
generatorRepository: GeneratorRepository,
snackbarRelayManager: SnackbarRelayManager,
private val authRepository: AuthRepository,
private val clipboardManager: BitwardenClipboardManager,
private val policyManager: PolicyManager,
private val vaultRepository: VaultRepository,
private val bitwardenCredentialManager: BitwardenCredentialManager,
generatorRepository: GeneratorRepository,
private val settingsRepository: SettingsRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val resourceManager: ResourceManager,
@@ -255,6 +259,12 @@ class VaultAddEditViewModel @Inject constructor(
}
.onEach(::sendAction)
.launchIn(viewModelScope)
snackbarRelayManager
.getSnackbarDataFlow(SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION)
.map { VaultAddEditAction.Internal.SnackbarDataReceived(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultAddEditAction) {
@@ -1548,9 +1558,19 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditAction.Internal.AvailableFoldersReceive -> {
handleAvailableFoldersReceive(action)
}
is VaultAddEditAction.Internal.SnackbarDataReceived -> {
handleSnackbarDataReceived(action)
}
}
}
private fun handleSnackbarDataReceived(
action: VaultAddEditAction.Internal.SnackbarDataReceived,
) {
sendEvent(VaultAddEditEvent.ShowSnackbar(action.data))
}
private fun handleAvailableFoldersReceive(
action: VaultAddEditAction.Internal.AvailableFoldersReceive,
) {
@@ -2715,6 +2735,13 @@ data class VaultAddEditState(
* Each subclass of this sealed class denotes a distinct event that can occur.
*/
sealed class VaultAddEditEvent {
/**
* Shows a snackbar with the given [data].
*/
data class ShowSnackbar(
val data: BitwardenSnackbarData,
) : VaultAddEditEvent(), BackgroundEvent
/**
* Shows a toast with the given [message].
*/
@@ -3372,6 +3399,13 @@ sealed class VaultAddEditAction {
val generatorResult: GeneratorResult,
) : Internal()
/**
* Indicates that snackbar data has been received.
*/
data class SnackbarDataReceived(
val data: BitwardenSnackbarData,
) : Internal()
/**
* Indicates that the vault item data has been received.
*/
@@ -9,6 +9,7 @@ import com.bitwarden.core.data.repository.util.combineDataStates
import com.bitwarden.core.data.repository.util.mapNullable
import com.bitwarden.core.util.persistentListOfNotNull
import com.bitwarden.data.repository.util.baseIconUrl
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.util.Text
@@ -216,6 +217,12 @@ class VaultItemViewModel @Inject constructor(
.map { VaultItemAction.Internal.IsIconLoadingDisabledUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
snackbarRelayManager
.getSnackbarDataFlow(SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION)
.map { VaultItemAction.Internal.SnackbarDataReceived(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultItemAction) {
@@ -937,6 +944,7 @@ class VaultItemViewModel @Inject constructor(
when (action) {
is VaultItemAction.Internal.CopyValue -> handleCopyValue(action)
is VaultItemAction.Internal.PasswordBreachReceive -> handlePasswordBreachReceive(action)
is VaultItemAction.Internal.SnackbarDataReceived -> handleSnackbarDataReceived(action)
is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
is VaultItemAction.Internal.RestoreCipherReceive -> handleRestoreCipherReceive(action)
@@ -958,6 +966,10 @@ class VaultItemViewModel @Inject constructor(
clipboardManager.setText(action.value)
}
private fun handleSnackbarDataReceived(action: VaultItemAction.Internal.SnackbarDataReceived) {
sendEvent(VaultItemEvent.ShowSnackbar(action.data))
}
private fun handlePasswordBreachReceive(
action: VaultItemAction.Internal.PasswordBreachReceive,
) {
@@ -1784,7 +1796,7 @@ sealed class VaultItemEvent {
*/
data class ShowSnackbar(
val data: BitwardenSnackbarData,
) : VaultItemEvent() {
) : VaultItemEvent(), BackgroundEvent {
constructor(
message: Text,
messageHeader: Text? = null,
@@ -2107,6 +2119,13 @@ sealed class VaultItemAction {
val result: BreachCountResult,
) : Internal()
/**
* Indicates that snackbar data has been received.
*/
data class SnackbarDataReceived(
val data: BitwardenSnackbarData,
) : Internal()
/**
* Indicates that the vault item data has been received.
*/
@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.ui.vault.feature.movetoorganization
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
@@ -10,7 +9,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
@@ -37,14 +35,9 @@ fun VaultMoveToOrganizationScreen(
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultMoveToOrganizationEvent.NavigateBack -> onNavigateBack()
is VaultMoveToOrganizationEvent.ShowToast -> {
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
}
}
}
VaultMoveToOrganizationScaffold(
@@ -16,6 +16,9 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultCollection
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -37,8 +40,9 @@ private const val KEY_STATE = "state"
@Suppress("MaxLineLength", "TooManyFunctions")
class VaultMoveToOrganizationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val vaultRepository: VaultRepository,
authRepository: AuthRepository,
private val snackbarRelayManager: SnackbarRelayManager,
private val vaultRepository: VaultRepository,
) : BaseViewModel<VaultMoveToOrganizationState, VaultMoveToOrganizationEvent, VaultMoveToOrganizationAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
@@ -165,8 +169,11 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
}
is ShareCipherResult.Success -> {
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(message = action.message),
relay = SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION,
)
sendEvent(VaultMoveToOrganizationEvent.NavigateBack)
sendEvent(VaultMoveToOrganizationEvent.ShowToast(action.successToast))
}
}
}
@@ -302,7 +309,7 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
cipherView = cipherView,
collectionIds = collectionIds,
),
successToast = R.string.item_updated.asText(),
message = R.string.item_updated.asText(),
)
} else {
VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive(
@@ -312,7 +319,7 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
cipherView = cipherView,
collectionIds = collectionIds,
),
successToast = R.string.moved_item_to_org.asText(
message = R.string.moved_item_to_org.asText(
requireNotNull(contentState.cipherToMove).name,
contentState.selectedOrganization.name,
),
@@ -444,13 +451,6 @@ sealed class VaultMoveToOrganizationEvent {
* Navigates back to the previous screen.
*/
data object NavigateBack : VaultMoveToOrganizationEvent()
/**
* Show a toast with the given message.
*
* @property text the text to display.
*/
data class ShowToast(val text: Text) : VaultMoveToOrganizationEvent()
}
/**
@@ -508,7 +508,7 @@ sealed class VaultMoveToOrganizationAction {
*/
data class ShareCipherResultReceive(
val shareCipherResult: ShareCipherResult,
val successToast: Text,
val message: Text,
) : Internal()
}
}
@@ -54,6 +54,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager
import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
@@ -138,6 +139,15 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `on ShowSnackbar should display snackbar content`() {
val message = "message"
val data = BitwardenSnackbarData(message = message.asText())
composeTestRule.onNodeWithText(text = message).assertDoesNotExist()
mutableEventFlow.tryEmit(VaultAddEditEvent.ShowSnackbar(data = data))
composeTestRule.onNodeWithText(text = message).assertIsDisplayed()
}
@Test
fun `on ExitApp event should call the exitApplication of ExitManager`() {
mutableEventFlow.tryEmit(VaultAddEditEvent.ExitApp)
@@ -71,7 +71,9 @@ import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldAction
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldType
@@ -197,6 +199,13 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
private val featureFlagManager: FeatureFlagManager = mockk {
every { getFeatureFlag(key = FlagKey.RestrictCipherItemDeletion) } returns false
}
private val mutableSnackbarDataFlow: MutableSharedFlow<BitwardenSnackbarData> =
bufferedMutableSharedFlow()
private val snackbarRelayManager: SnackbarRelayManager = mockk {
every {
getSnackbarDataFlow(relay = any(), relays = anyVararg())
} returns mutableSnackbarDataFlow
}
@BeforeEach
fun setup() {
@@ -501,6 +510,16 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `snackbar relay emission should send ShowSnackbar`() = runTest {
val viewModel = createAddVaultItemViewModel()
val snackbarData = mockk<BitwardenSnackbarData>()
viewModel.eventFlow.test {
mutableSnackbarDataFlow.emit(snackbarData)
assertEquals(VaultAddEditEvent.ShowSnackbar(snackbarData), awaitItem())
}
}
@Test
fun `AttachmentsClick should emit NavigateToAttachments`() = runTest {
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
@@ -3474,6 +3493,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
bitwardenCredentialManager = bitwardenCredentialManager,
generatorRepository = generatorRepository,
settingsRepository = settingsRepository,
snackbarRelayManager = snackbarRelayManager,
specialCircumstanceManager = specialCircumstanceManager,
resourceManager = resourceManager,
clock = fixedClock,
@@ -4860,6 +4880,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
bitwardenCredentialManager = bitwardenCredentialManager,
generatorRepository = generatorRepo,
settingsRepository = settingsRepository,
snackbarRelayManager = snackbarRelayManager,
specialCircumstanceManager = specialCircumstanceManager,
resourceManager = bitwardenResourceManager,
clock = clock,
@@ -38,6 +38,7 @@ import com.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
@@ -137,6 +138,15 @@ class VaultItemScreenTest : BitwardenComposeTest() {
assertEquals(id, onNavigateToPasswordHistoryId)
}
@Test
fun `on ShowSnackbar should display snackbar content`() {
val message = "message"
val data = BitwardenSnackbarData(message = message.asText())
composeTestRule.onNodeWithText(text = message).assertDoesNotExist()
mutableEventFlow.tryEmit(VaultItemEvent.ShowSnackbar(data = data))
composeTestRule.onNodeWithText(text = message).assertIsDisplayed()
}
@Test
fun `on close click should send CloseClick`() {
composeTestRule.onNodeWithContentDescription(label = "Close").performClick()
@@ -4,6 +4,7 @@ import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.data.repository.util.baseIconUrl
import com.bitwarden.network.model.OrganizationType
@@ -63,6 +64,7 @@ import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
@@ -120,7 +122,12 @@ class VaultItemViewModelTest : BaseViewModelTest() {
private val featureFlagManager: FeatureFlagManager = mockk {
every { getFeatureFlag(key = FlagKey.RestrictCipherItemDeletion) } returns false
}
private val mutableSnackbarDataFlow: MutableSharedFlow<BitwardenSnackbarData> =
bufferedMutableSharedFlow()
private val snackbarRelayManager: SnackbarRelayManager = mockk {
every {
getSnackbarDataFlow(relay = any(), relays = anyVararg())
} returns mutableSnackbarDataFlow
every { sendSnackbarData(data = any(), relay = any()) } just runs
}
@@ -173,6 +180,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `snackbar relay emission should send ShowSnackbar`() = runTest {
val viewModel = createViewModel(DEFAULT_STATE)
val snackbarData = mockk<BitwardenSnackbarData>()
viewModel.eventFlow.test {
mutableSnackbarDataFlow.emit(snackbarData)
assertEquals(VaultItemEvent.ShowSnackbar(snackbarData), awaitItem())
}
}
@Nested
inner class CommonActions {
private lateinit var viewModel: VaultItemViewModel
@@ -20,14 +20,20 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util.createMockOrganizationList
import com.x8bit.bitwarden.ui.vault.model.VaultCollection
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
@@ -57,6 +63,10 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
every { userStateFlow } returns mutableUserStateFlow
}
private val snackbarRelayManager: SnackbarRelayManager = mockk {
every { sendSnackbarData(data = any(), relay = any()) } just runs
}
@BeforeEach
fun setup() {
mockkStatic(SavedStateHandle::toVaultMoveToOrganizationArgs)
@@ -394,14 +404,16 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
VaultMoveToOrganizationEvent.NavigateBack,
awaitItem(),
)
assertEquals(
VaultMoveToOrganizationEvent.ShowToast(
text = R.string.moved_item_to_org.asText(
}
verify {
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(
message = R.string.moved_item_to_org.asText(
"mockName-1",
"mockOrganizationName-1",
),
),
awaitItem(),
relay = SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION,
)
}
coVerify {
@@ -439,9 +451,11 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
VaultMoveToOrganizationEvent.NavigateBack,
awaitItem(),
)
assertEquals(
VaultMoveToOrganizationEvent.ShowToast(R.string.item_updated.asText()),
awaitItem(),
}
verify {
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(message = R.string.item_updated.asText()),
relay = SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION,
)
}
coVerify {
@@ -461,6 +475,7 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
savedStateHandle = savedStateHandle,
authRepository = authRepo,
vaultRepository = vaultRepo,
snackbarRelayManager = snackbarRelayManager,
)
private fun createSavedStateHandleWithState(