mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 20:07:59 -06:00
PM-29491: Implement LeaveOrganizationScreen with vault navigation
- Add LeaveOrganizationScreen MVVM implementation following PR #6239 patterns - Implement parameterless navigation route with organizationId from PolicyManager - Send snackbar notification on successful organization departure - Navigate to VaultGraph after leaving organization (not back navigation) - Add comprehensive test coverage for ViewModel and Screen - Add LEFT_ORGANIZATION SnackbarRelay enum value - Add 4 new string resources for leave organization flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4a874668f2
commit
4e89b35377
@ -0,0 +1,40 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the leave organization screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object LeaveOrganizationRoute
|
||||
|
||||
/**
|
||||
* Add the leave organization screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.leaveOrganizationDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToVault: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<LeaveOrganizationRoute> {
|
||||
LeaveOrganizationScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToVault = onNavigateToVault,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the leave organization screen.
|
||||
*/
|
||||
fun NavController.navigateToLeaveOrganization(
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = LeaveOrganizationRoute,
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,216 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledErrorButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization.handler.rememberLeaveOrganizationHandler
|
||||
|
||||
/**
|
||||
* Top-level composable for the Leave Organization screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LeaveOrganizationScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToVault: () -> Unit,
|
||||
viewModel: LeaveOrganizationViewModel = hiltViewModel(),
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val handlers = rememberLeaveOrganizationHandler(viewModel)
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
LeaveOrganizationEvent.NavigateBack -> onNavigateBack()
|
||||
LeaveOrganizationEvent.NavigateToVault -> onNavigateToVault()
|
||||
is LeaveOrganizationEvent.LaunchUri -> {
|
||||
intentManager.launchUri(event.uri.toUri())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LeaveOrganizationDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = handlers.onDismissDialog,
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.leave_organization),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(id = BitwardenString.back),
|
||||
onNavigationIconClick = handlers.onBackClick,
|
||||
)
|
||||
},
|
||||
) {
|
||||
LeaveOrganizationContent(
|
||||
organizationName = state.viewState.organizationName,
|
||||
onLeaveClick = handlers.onLeaveClick,
|
||||
onHelpLinkClick = handlers.onHelpClick,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveOrganizationDialogs(
|
||||
dialogState: LeaveOrganizationState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
LeaveOrganizationState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(
|
||||
text = stringResource(id = BitwardenString.loading),
|
||||
)
|
||||
}
|
||||
|
||||
is LeaveOrganizationState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
title = stringResource(id = BitwardenString.an_error_has_occurred),
|
||||
message = dialogState.message(),
|
||||
throwable = dialogState.error,
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveOrganizationContent(
|
||||
organizationName: String,
|
||||
onLeaveClick: () -> Unit,
|
||||
onHelpLinkClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.standardHorizontalMargin(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Icon(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ic_organization),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(100.dp),
|
||||
tint = BitwardenTheme.colorScheme.icon.secondary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.are_you_sure_you_want_to_leave_organization,
|
||||
organizationName,
|
||||
),
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.leave_organization_warning),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
BitwardenFilledErrorButton(
|
||||
label = stringResource(
|
||||
id = BitwardenString.leave_organization_button,
|
||||
organizationName,
|
||||
),
|
||||
onClick = onLeaveClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = BitwardenString.how_to_manage_my_vault),
|
||||
onClick = onHelpLinkClick,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun LeaveOrganizationScreen_preview() {
|
||||
BitwardenTheme {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = "Leave organization",
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = "Back",
|
||||
onNavigationIconClick = {},
|
||||
)
|
||||
},
|
||||
) {
|
||||
LeaveOrganizationContent(
|
||||
organizationName = "Acme Corporation",
|
||||
onLeaveClick = {},
|
||||
onHelpLinkClick = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,232 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the Leave Organization screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LeaveOrganizationViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val policyManager: PolicyManager,
|
||||
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<LeaveOrganizationState, LeaveOrganizationEvent, LeaveOrganizationAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val organizationId = requireNotNull(
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId(),
|
||||
)
|
||||
val organization = requireNotNull(
|
||||
authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.activeAccount
|
||||
?.organizations
|
||||
?.firstOrNull { it.id == organizationId },
|
||||
)
|
||||
|
||||
LeaveOrganizationState(
|
||||
organizationId = organizationId,
|
||||
viewState = LeaveOrganizationState.ViewState(
|
||||
organizationName = organization.name.orEmpty(),
|
||||
),
|
||||
dialogState = null,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: LeaveOrganizationAction) {
|
||||
when (action) {
|
||||
LeaveOrganizationAction.BackClick -> handleBackClick()
|
||||
LeaveOrganizationAction.LeaveOrganizationClick -> handleLeaveOrganizationClick()
|
||||
LeaveOrganizationAction.HelpLinkClick -> handleHelpLinkClick()
|
||||
LeaveOrganizationAction.DismissDialog -> handleDismissDialog()
|
||||
is LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived -> {
|
||||
handleLeaveOrganizationResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(LeaveOrganizationEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleLeaveOrganizationClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = LeaveOrganizationState.DialogState.Loading)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.leaveOrganization(state.organizationId)
|
||||
sendAction(
|
||||
LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived(result),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHelpLinkClick() {
|
||||
sendEvent(
|
||||
LeaveOrganizationEvent.LaunchUri(
|
||||
uri = "https://bitwarden.com/help/transfer-ownership/",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDismissDialog() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLeaveOrganizationResultReceived(
|
||||
action: LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is LeaveOrganizationResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
snackbarRelayManager.sendSnackbarData(
|
||||
relay = SnackbarRelay.LEFT_ORGANIZATION,
|
||||
data = BitwardenSnackbarData(
|
||||
message = BitwardenString.you_left_the_organization.asText(),
|
||||
),
|
||||
)
|
||||
sendEvent(LeaveOrganizationEvent.NavigateToVault)
|
||||
}
|
||||
|
||||
is LeaveOrganizationResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LeaveOrganizationState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = result.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Leave Organization screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LeaveOrganizationState(
|
||||
val organizationId: String,
|
||||
val viewState: ViewState,
|
||||
val dialogState: DialogState?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Display data for the view.
|
||||
*/
|
||||
@Parcelize
|
||||
data class ViewState(
|
||||
val organizationName: String,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Dialog states for transient UI.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* Loading dialog during leave operation.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : DialogState()
|
||||
|
||||
/**
|
||||
* Error dialog when leave operation fails.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events for the Leave Organization screen.
|
||||
*/
|
||||
sealed class LeaveOrganizationEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : LeaveOrganizationEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the Vault screen.
|
||||
*/
|
||||
data object NavigateToVault : LeaveOrganizationEvent()
|
||||
|
||||
/**
|
||||
* Launch external URI.
|
||||
*/
|
||||
data class LaunchUri(val uri: String) : LeaveOrganizationEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for the Leave Organization screen.
|
||||
*/
|
||||
sealed class LeaveOrganizationAction {
|
||||
/**
|
||||
* User clicked the back button.
|
||||
*/
|
||||
data object BackClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User clicked the leave organization button.
|
||||
*/
|
||||
data object LeaveOrganizationClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User clicked the help link.
|
||||
*/
|
||||
data object HelpLinkClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User dismissed a dialog.
|
||||
*/
|
||||
data object DismissDialog : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* Internal actions for ViewModel processing.
|
||||
*/
|
||||
sealed class Internal : LeaveOrganizationAction() {
|
||||
/**
|
||||
* Leave organization result received from repository.
|
||||
*/
|
||||
data class LeaveOrganizationResultReceived(
|
||||
val result: LeaveOrganizationResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization.handler
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization.LeaveOrganizationAction
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization.LeaveOrganizationViewModel
|
||||
|
||||
/**
|
||||
* A class to handle user interactions for the Leave Organization screen.
|
||||
*/
|
||||
data class LeaveOrganizationHandler(
|
||||
val onBackClick: () -> Unit,
|
||||
val onLeaveClick: () -> Unit,
|
||||
val onHelpClick: () -> Unit,
|
||||
val onDismissDialog: () -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* Creates an instance of [LeaveOrganizationHandler] using the provided
|
||||
* [LeaveOrganizationViewModel].
|
||||
*/
|
||||
fun create(viewModel: LeaveOrganizationViewModel): LeaveOrganizationHandler =
|
||||
LeaveOrganizationHandler(
|
||||
onBackClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.BackClick)
|
||||
},
|
||||
onLeaveClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
},
|
||||
onHelpClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick)
|
||||
},
|
||||
onDismissDialog = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create and remember a [LeaveOrganizationHandler] instance.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberLeaveOrganizationHandler(
|
||||
viewModel: LeaveOrganizationViewModel,
|
||||
): LeaveOrganizationHandler = remember(viewModel) {
|
||||
LeaveOrganizationHandler.create(viewModel)
|
||||
}
|
||||
@ -24,4 +24,5 @@ enum class SnackbarRelay {
|
||||
LOGINS_IMPORTED,
|
||||
SEND_DELETED,
|
||||
SEND_UPDATED,
|
||||
LEFT_ORGANIZATION,
|
||||
}
|
||||
|
||||
@ -0,0 +1,184 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
|
||||
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class LeaveOrganizationScreenTest : BitwardenComposeTest() {
|
||||
|
||||
private var onNavigateBackCalled = false
|
||||
private var onNavigateToVaultCalled = false
|
||||
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<LeaveOrganizationEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<LeaveOrganizationViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setContent {
|
||||
LeaveOrganizationScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
onNavigateToVault = { onNavigateToVaultCalled = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(LeaveOrganizationEvent.NavigateBack)
|
||||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToVault event should call onNavigateToVault`() {
|
||||
mutableEventFlow.tryEmit(LeaveOrganizationEvent.NavigateToVault)
|
||||
assertTrue(onNavigateToVaultCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leave organization button click should emit LeaveOrganizationClick action`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Leave $ORGANIZATION_NAME")
|
||||
.filterToOne(hasClickAction())
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `help link button click should emit HelpLinkClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("How to manage My vault")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `organization name should be displayed in title`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Are you sure you want to leave $ORGANIZATION_NAME?")
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `organization name should be displayed in button`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Leave $ORGANIZATION_NAME")
|
||||
.filterToOne(hasClickAction())
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `warning text should be displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText(
|
||||
text = "You will lose access to items shared with this organization. " +
|
||||
"Your personal items will remain in your vault. " +
|
||||
"If you need access again, contact your organization administrator.",
|
||||
)
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading dialog should not be displayed by default`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Loading")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading dialog should be displayed when dialogState is Loading`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = LeaveOrganizationState.DialogState.Loading)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Loading")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog should not be displayed by default`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog should be displayed when dialogState is Error`() {
|
||||
val errorMessage = "Something went wrong"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LeaveOrganizationState.DialogState.Error(
|
||||
message = errorMessage.asText(),
|
||||
error = Throwable("Test error"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(errorMessage)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog dismiss should emit DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LeaveOrganizationState.DialogState.Error(
|
||||
message = "Error message".asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Okay")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ORGANIZATION_ID = "organization-id-1"
|
||||
private const val ORGANIZATION_NAME = "Test Organization"
|
||||
|
||||
private val DEFAULT_STATE = LeaveOrganizationState(
|
||||
organizationId = ORGANIZATION_ID,
|
||||
viewState = LeaveOrganizationState.ViewState(
|
||||
organizationName = ORGANIZATION_NAME,
|
||||
),
|
||||
dialogState = null,
|
||||
)
|
||||
@ -0,0 +1,230 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class LeaveOrganizationViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockAuthRepository: AuthRepository = mockk {
|
||||
every { userStateFlow } returns MutableStateFlow(DEFAULT_USER_STATE)
|
||||
}
|
||||
|
||||
private val mockPolicyManager: PolicyManager = mockk {
|
||||
every { getPersonalOwnershipPolicyOrganizationId() } returns ORGANIZATION_ID
|
||||
}
|
||||
|
||||
private val mockSnackbarRelayManager: SnackbarRelayManager<SnackbarRelay> = mockk {
|
||||
every { sendSnackbarData(data = any(), relay = any()) } just runs
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct`() {
|
||||
val viewModel = createViewModel()
|
||||
val expectedState = LeaveOrganizationState(
|
||||
organizationId = ORGANIZATION_ID,
|
||||
viewState = LeaveOrganizationState.ViewState(
|
||||
organizationName = ORGANIZATION_NAME,
|
||||
),
|
||||
dialogState = null,
|
||||
)
|
||||
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.BackClick)
|
||||
assertEquals(LeaveOrganizationEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HelpLinkClick should emit LaunchUri event with help URL`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick)
|
||||
val event = awaitItem()
|
||||
assert(event is LeaveOrganizationEvent.LaunchUri)
|
||||
assertEquals(
|
||||
"https://bitwarden.com/help/transfer-ownership/",
|
||||
(event as LeaveOrganizationEvent.LaunchUri).uri,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LeaveOrganizationClick should show loading dialog`() = runTest {
|
||||
coEvery {
|
||||
mockAuthRepository.leaveOrganization(any())
|
||||
} coAnswers {
|
||||
LeaveOrganizationResult.Success
|
||||
}
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(null, awaitItem().dialogState)
|
||||
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
|
||||
val loadingState = awaitItem()
|
||||
assert(loadingState.dialogState is LeaveOrganizationState.DialogState.Loading)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LeaveOrganizationClick with Success should send snackbar and navigate to vault`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
|
||||
} returns LeaveOrganizationResult.Success
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
assertEquals(LeaveOrganizationEvent.NavigateToVault, awaitItem())
|
||||
}
|
||||
|
||||
verify {
|
||||
mockSnackbarRelayManager.sendSnackbarData(
|
||||
relay = SnackbarRelay.LEFT_ORGANIZATION,
|
||||
data = BitwardenSnackbarData(
|
||||
message = BitwardenString.you_left_the_organization.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LeaveOrganizationClick with Error should show error dialog`() = runTest {
|
||||
val error = Throwable("Test error")
|
||||
coEvery {
|
||||
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
|
||||
} returns LeaveOrganizationResult.Error(error)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assert(state.dialogState is LeaveOrganizationState.DialogState.Error)
|
||||
val dialogState = state.dialogState as LeaveOrganizationState.DialogState.Error
|
||||
assertEquals(BitwardenString.generic_error_message.asText(), dialogState.message)
|
||||
assertEquals(error, dialogState.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissDialog should clear dialog state`() = runTest {
|
||||
coEvery {
|
||||
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
|
||||
} returns LeaveOrganizationResult.Error(Throwable("Error"))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
|
||||
assert(viewModel.stateFlow.value.dialogState != null)
|
||||
|
||||
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
|
||||
|
||||
assertNull(viewModel.stateFlow.value.dialogState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state should be restored from SavedStateHandle`() {
|
||||
val savedState = LeaveOrganizationState(
|
||||
organizationId = "saved-org-id",
|
||||
viewState = LeaveOrganizationState.ViewState(
|
||||
organizationName = "Saved Organization",
|
||||
),
|
||||
dialogState = null,
|
||||
)
|
||||
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
|
||||
|
||||
val viewModel = LeaveOrganizationViewModel(
|
||||
authRepository = mockAuthRepository,
|
||||
policyManager = mockPolicyManager,
|
||||
snackbarRelayManager = mockSnackbarRelayManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle(),
|
||||
): LeaveOrganizationViewModel = LeaveOrganizationViewModel(
|
||||
authRepository = mockAuthRepository,
|
||||
policyManager = mockPolicyManager,
|
||||
snackbarRelayManager = mockSnackbarRelayManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
}
|
||||
|
||||
private const val ORGANIZATION_ID = "organization-id-1"
|
||||
private const val ORGANIZATION_NAME = "Test Organization"
|
||||
|
||||
private val DEFAULT_ORGANIZATION = Organization(
|
||||
id = ORGANIZATION_ID,
|
||||
name = ORGANIZATION_NAME,
|
||||
shouldManageResetPassword = false,
|
||||
shouldUseKeyConnector = false,
|
||||
role = OrganizationType.USER,
|
||||
keyConnectorUrl = null,
|
||||
userIsClaimedByOrganization = false,
|
||||
limitItemDeletion = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_USER_STATE = UserState(
|
||||
activeUserId = "user-id-1",
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "user-id-1",
|
||||
name = "Test User",
|
||||
email = "test@example.com",
|
||||
avatarColorHex = "#175DDC",
|
||||
environment = Environment.Us,
|
||||
isPremium = false,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
needsPasswordReset = false,
|
||||
needsMasterPassword = false,
|
||||
hasMasterPassword = true,
|
||||
trustedDevice = null,
|
||||
organizations = listOf(DEFAULT_ORGANIZATION),
|
||||
isBiometricsEnabled = false,
|
||||
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
isUsingKeyConnector = false,
|
||||
onboardingStatus = OnboardingStatus.COMPLETE,
|
||||
firstTimeState = FirstTimeState(),
|
||||
isExportable = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -496,6 +496,10 @@ Scanning will happen automatically.</string>
|
||||
<string name="remove_master_password">Remove master password</string>
|
||||
<string name="leave_organization">Leave organization</string>
|
||||
<string name="leave_organization_name">Leave %1$s?</string>
|
||||
<string name="are_you_sure_you_want_to_leave_organization">Are you sure you want to leave %1$s?</string>
|
||||
<string name="leave_organization_button">Leave %1$s</string>
|
||||
<string name="leave_organization_warning">You will lose access to items shared with this organization. Your personal items will remain in your vault. If you need access again, contact your organization administrator.</string>
|
||||
<string name="how_to_manage_my_vault">How to manage My vault</string>
|
||||
<string name="fido2_authenticate_web_authn">Authenticate WebAuthn</string>
|
||||
<string name="fido2_return_to_app">Return to app</string>
|
||||
<string name="reset_password_auto_enroll_invite_warning">This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password.</string>
|
||||
@ -1161,4 +1165,5 @@ Do you want to switch to this account?</string>
|
||||
<string name="use_your_devices_lock_method_to_unlock_the_app">Use your device’s lock method to unlock the app</string>
|
||||
<string name="loading_vault_data">Loading vault data…</string>
|
||||
<string name="resending">Resending</string>
|
||||
<string name="you_left_the_organization">You left the organization</string>
|
||||
</resources>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user