Move to organization navigation (#620)

This commit is contained in:
Ramsey Smith 2024-01-15 11:58:20 -07:00 committed by Álison Fernandes
parent df3a6598b3
commit 0f0fe81f41
9 changed files with 423 additions and 2 deletions

View file

@ -21,6 +21,8 @@ import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem
import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.navigateToManualCodeEntryScreen import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.navigateToManualCodeEntryScreen
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.vaultManualCodeEntryDestination import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.vaultManualCodeEntryDestination
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.navigateToVaultMoveToOrganization
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.vaultMoveToOrganizationDestination
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.vaultQrCodeScanDestination import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.vaultQrCodeScanDestination
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
@ -37,6 +39,7 @@ fun NavController.navigateToVaultUnlockedGraph(navOptions: NavOptions? = null) {
/** /**
* Add vault unlocked destinations to the root nav graph. * Add vault unlocked destinations to the root nav graph.
*/ */
@Suppress("LongMethod")
fun NavGraphBuilder.vaultUnlockedGraph( fun NavGraphBuilder.vaultUnlockedGraph(
navController: NavController, navController: NavController,
) { ) {
@ -68,11 +71,17 @@ fun NavGraphBuilder.vaultUnlockedGraph(
}, },
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
) )
vaultMoveToOrganizationDestination(
onNavigateBack = { navController.popBackStack() },
)
vaultItemDestination( vaultItemDestination(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToVaultEditItem = { onNavigateToVaultEditItem = {
navController.navigateToVaultAddEdit(VaultAddEditType.EditItem(it)) navController.navigateToVaultAddEdit(VaultAddEditType.EditItem(it))
}, },
onNavigateToMoveToOrganization = {
navController.navigateToVaultMoveToOrganization(it)
},
) )
vaultQrCodeScanDestination( vaultQrCodeScanDestination(
onNavigateToManualCodeEntryScreen = { onNavigateToManualCodeEntryScreen = {

View file

@ -29,6 +29,7 @@ data class VaultItemArgs(val vaultItemId: String) {
fun NavGraphBuilder.vaultItemDestination( fun NavGraphBuilder.vaultItemDestination(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit, onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
onNavigateToMoveToOrganization: (vaultItemId: String) -> Unit,
) { ) {
composableWithSlideTransitions( composableWithSlideTransitions(
route = VAULT_ITEM_ROUTE, route = VAULT_ITEM_ROUTE,
@ -39,6 +40,7 @@ fun NavGraphBuilder.vaultItemDestination(
VaultItemScreen( VaultItemScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToVaultAddEditItem = onNavigateToVaultEditItem, onNavigateToVaultAddEditItem = onNavigateToVaultEditItem,
onNavigateToMoveToOrganization = onNavigateToMoveToOrganization,
) )
} }
} }

View file

@ -56,6 +56,7 @@ fun VaultItemScreen(
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current), intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToVaultAddEditItem: (vaultItemId: String) -> Unit, onNavigateToVaultAddEditItem: (vaultItemId: String) -> Unit,
onNavigateToMoveToOrganization: (vaultItemId: String) -> Unit,
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
@ -82,8 +83,7 @@ fun VaultItemScreen(
} }
is VaultItemEvent.NavigateToMoveToOrganization -> { is VaultItemEvent.NavigateToMoveToOrganization -> {
// TODO Implement move to organization in BIT-844 onNavigateToMoveToOrganization(event.itemId)
Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show()
} }
is VaultItemEvent.ShowToast -> { is VaultItemEvent.ShowToast -> {

View file

@ -0,0 +1,56 @@
package com.x8bit.bitwarden.ui.vault.feature.movetoorganization
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val VAULT_MOVE_TO_ORGANIZATION_PREFIX = "vault_move_to_organization"
private const val VAULT_MOVE_TO_ORGANIZATION_ID = "vault_move_to_organization_id"
private const val VAULT_MOVE_TO_ORGANIZATION_ROUTE =
"$VAULT_MOVE_TO_ORGANIZATION_PREFIX/{$VAULT_MOVE_TO_ORGANIZATION_ID}"
/**
* Class to retrieve vault move to organization arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class VaultMoveToOrganizationArgs(val vaultItemId: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[VAULT_MOVE_TO_ORGANIZATION_ID]) as String,
)
}
/**
* Add the vault move to organization screen to the nav graph.
*/
fun NavGraphBuilder.vaultMoveToOrganizationDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = VAULT_MOVE_TO_ORGANIZATION_ROUTE,
arguments = listOf(
navArgument(VAULT_MOVE_TO_ORGANIZATION_ID) { type = NavType.StringType },
),
) {
VaultMoveToOrganizationScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the vault move to organization screen.
*/
fun NavController.navigateToVaultMoveToOrganization(
vaultItemId: String,
navOptions: NavOptions? = null,
) {
navigate(
route = "$VAULT_MOVE_TO_ORGANIZATION_PREFIX/$vaultItemId",
navOptions = navOptions,
)
}

View file

@ -0,0 +1,97 @@
package com.x8bit.bitwarden.ui.vault.feature.movetoorganization
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
/**
* Displays the vault move to organization screen.
*/
@Composable
fun VaultMoveToOrganizationScreen(
viewModel: VaultMoveToOrganizationViewModel = hiltViewModel(),
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(
state = state,
closeClick = remember(viewModel) {
{ viewModel.trySendAction(VaultMoveToOrganizationAction.BackClick) }
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun VaultMoveToOrganizationScaffold(
state: VaultMoveToOrganizationState,
closeClick: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.move_to_organization),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = closeClick,
)
},
) { innerPadding ->
val modifier = Modifier
.imePadding()
.fillMaxSize()
.padding(innerPadding)
when (state.viewState) {
is VaultMoveToOrganizationState.ViewState.Content -> {
// TODO add real views in BIT-844 UI
Text(text = "Content")
}
is VaultMoveToOrganizationState.ViewState.Error -> {
BitwardenErrorContent(
message = state.viewState.message(),
modifier = modifier,
)
}
is VaultMoveToOrganizationState.ViewState.Loading -> {
BitwardenLoadingContent(modifier = modifier)
}
}
}
}

View file

@ -0,0 +1,110 @@
package com.x8bit.bitwarden.ui.vault.feature.movetoorganization
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* ViewModel responsible for handling user interactions in the [VaultMoveToOrganizationScreen].
*
* @param savedStateHandle Handles the navigation arguments of this ViewModel.
*/
@HiltViewModel
@Suppress("MaxLineLength")
class VaultMoveToOrganizationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<VaultMoveToOrganizationState, VaultMoveToOrganizationEvent, VaultMoveToOrganizationAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
VaultMoveToOrganizationState(
vaultItemId = VaultMoveToOrganizationArgs(savedStateHandle).vaultItemId,
viewState = VaultMoveToOrganizationState.ViewState.Loading,
)
},
) {
override fun handleAction(action: VaultMoveToOrganizationAction) {
when (action) {
is VaultMoveToOrganizationAction.BackClick -> handleBackClick()
}
}
private fun handleBackClick() {
sendEvent(VaultMoveToOrganizationEvent.NavigateBack)
}
}
/**
* Models state for the [VaultMoveToOrganizationScreen].
*
* @property vaultItemId Indicates whether the VM is in add or edit mode.
* @property viewState indicates what view state the screen is in.
*/
@Parcelize
data class VaultMoveToOrganizationState(
val vaultItemId: String,
val viewState: ViewState,
) : Parcelable {
/**
* Represents the specific view states for the [VaultMoveToOrganizationScreen].
*/
sealed class ViewState : Parcelable {
/**
* Represents an error state for the [VaultMoveToOrganizationScreen].
*
* @property message the error message to display.
*/
@Parcelize
data class Error(
val message: Text,
) : ViewState()
/**
* Represents a loading state for the [VaultMoveToOrganizationScreen].
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents a loaded content state for the [VaultMoveToOrganizationScreen].
*/
@Parcelize
data object Content : ViewState()
}
}
/**
* Models events for the [VaultMoveToOrganizationScreen].
*/
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()
}
/**
* Models actions for the [VaultMoveToOrganizationScreen].
*/
sealed class VaultMoveToOrganizationAction {
/**
* Click the back button.
*/
data object BackClick : VaultMoveToOrganizationAction()
}

View file

@ -51,6 +51,7 @@ class VaultItemScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false private var onNavigateBackCalled = false
private var onNavigateToVaultEditItemId: String? = null private var onNavigateToVaultEditItemId: String? = null
private var onNavigateToMoveToOrganizationItemId: String? = null
private val intentHandler = mockk<IntentHandler>() private val intentHandler = mockk<IntentHandler>()
@ -68,6 +69,7 @@ class VaultItemScreenTest : BaseComposeTest() {
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true }, onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVaultAddEditItem = { onNavigateToVaultEditItemId = it }, onNavigateToVaultAddEditItem = { onNavigateToVaultEditItemId = it },
onNavigateToMoveToOrganization = { onNavigateToMoveToOrganizationItemId = it },
intentHandler = intentHandler, intentHandler = intentHandler,
) )
} }
@ -81,6 +83,13 @@ class VaultItemScreenTest : BaseComposeTest() {
assertEquals(id, onNavigateToVaultEditItemId) assertEquals(id, onNavigateToVaultEditItemId)
} }
@Test
fun `NavigateToMoveToOrganization event should invoke onNavigateToMoveToOrganization`() {
val id = "id1234"
mutableEventFlow.tryEmit(VaultItemEvent.NavigateToMoveToOrganization(itemId = id))
assertEquals(id, onNavigateToMoveToOrganizationItemId)
}
@Test @Test
fun `on close click should send CloseClick`() { fun `on close click should send CloseClick`() {
composeTestRule.onNodeWithContentDescription(label = "Close").performClick() composeTestRule.onNodeWithContentDescription(label = "Close").performClick()

View file

@ -0,0 +1,61 @@
package com.x8bit.bitwarden.ui.vault.feature.movetoorganization
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class VaultMoveToOrganizationScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<VaultMoveToOrganizationEvent>()
private val mutableStateFlow = MutableStateFlow(createVaultMoveToOrganizationState())
private val viewModel = mockk<VaultMoveToOrganizationViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
composeTestRule.setContent {
VaultMoveToOrganizationScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `on NavigateBack event should invoke onNavigateBack`() {
mutableEventFlow.tryEmit(VaultMoveToOrganizationEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `clicking close button should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription(label = "Close")
.performClick()
verify {
viewModel.trySendAction(
VaultMoveToOrganizationAction.BackClick,
)
}
}
}
private fun createVaultMoveToOrganizationState(): VaultMoveToOrganizationState =
VaultMoveToOrganizationState(
vaultItemId = "mockId",
viewState = VaultMoveToOrganizationState.ViewState.Content,
)

View file

@ -0,0 +1,77 @@
package com.x8bit.bitwarden.ui.vault.feature.movetoorganization
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
private val initialState = createVaultMoveToOrganizationState()
private val initialSavedStateHandle = createSavedStateHandleWithState(
state = initialState,
)
@Test
fun `initial state should be correct when state is null`() = runTest {
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = null,
),
)
assertEquals(
initialState.copy(viewState = VaultMoveToOrganizationState.ViewState.Loading),
viewModel.stateFlow.value,
)
}
@Test
fun `initial state should be correct`() = runTest {
val initState = createVaultMoveToOrganizationState()
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
),
)
assertEquals(initState, viewModel.stateFlow.value)
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel(
savedStateHandle = initialSavedStateHandle,
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.BackClick)
assertEquals(VaultMoveToOrganizationEvent.NavigateBack, awaitItem())
}
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = initialSavedStateHandle,
): VaultMoveToOrganizationViewModel =
VaultMoveToOrganizationViewModel(
savedStateHandle = savedStateHandle,
)
private fun createSavedStateHandleWithState(
state: VaultMoveToOrganizationState? = null,
vaultItemId: String = "mockId",
) = SavedStateHandle().apply {
set("state", state)
set("vault_move_to_organization_id", vaultItemId)
}
@Suppress("MaxLineLength")
private fun createVaultMoveToOrganizationState(
viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Content,
vaultItemId: String = "mockId",
): VaultMoveToOrganizationState =
VaultMoveToOrganizationState(
vaultItemId = vaultItemId,
viewState = viewState,
)
}