From 110392e94c138fdd6483475d64f431fd79ff0a23 Mon Sep 17 00:00:00 2001 From: Ramsey Smith <142836716+ramsey-livefront@users.noreply.github.com> Date: Tue, 16 Jan 2024 15:53:45 -0700 Subject: [PATCH] BIT-844: Move to Organization UI (#638) --- .../VaultMoveToOrganizationContent.kt | 114 ++++++++++ .../VaultMoveToOrganizationEmpty.kt | 38 ++++ .../VaultMoveToOrganizationScreen.kt | 67 +++++- .../VaultMoveToOrganizationViewModel.kt | 202 +++++++++++++++++- .../VaultMoveToOrganizationScreenTest.kt | 198 ++++++++++++++++- .../VaultMoveToOrganizationViewModelTest.kt | 120 ++++++++++- .../util/VaultMoveToOrganizationTestUtil.kt | 58 +++++ 7 files changed, 791 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationContent.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationEmpty.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationTestUtil.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationContent.kt new file mode 100644 index 0000000000..bda07a6a95 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationContent.kt @@ -0,0 +1,114 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch +import kotlinx.collections.immutable.toImmutableList + +/** + * Content view for the [VaultMoveToOrganizationScreen]. + */ +@Suppress("LongMethod") +@Composable +fun VaultMoveToOrganizationContent( + state: VaultMoveToOrganizationState.ViewState.Content, + organizationSelect: (VaultMoveToOrganizationState.ViewState.Content.Organization) -> Unit, + collectionSelect: (VaultMoveToOrganizationState.ViewState.Content.Collection) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenMultiSelectButton( + label = stringResource(id = R.string.organization), + options = state + .organizations + .map { it.name } + .toImmutableList(), + selectedOption = state.selectedOrganization.name, + onOptionSelected = { selectedString -> + organizationSelect( + state + .organizations + .first { it.name == selectedString }, + ) + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(id = R.string.move_to_org_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + ) + } + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.collections), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + if (state.selectedOrganization.collections.isNotEmpty()) { + items(state.selectedOrganization.collections) { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenWideSwitch( + label = it.name, + isChecked = it.isSelected, + onCheckedChange = { _ -> + collectionSelect(it) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } else { + item { + Spacer(modifier = Modifier.height(8.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.no_collections_to_list), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationEmpty.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationEmpty.kt new file mode 100644 index 0000000000..ab254703d2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationEmpty.kt @@ -0,0 +1,38 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R + +/** + * Empty view for the [VaultMoveToOrganizationScreen]. + */ +@Composable +fun VaultMoveToOrganizationEmpty(modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.no_orgs_to_list), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreen.kt index 07aaa30455..46c95175d9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreen.kt @@ -5,7 +5,6 @@ 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 @@ -20,10 +19,16 @@ 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.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState /** * Displays the vault move to organization screen. @@ -49,16 +54,54 @@ fun VaultMoveToOrganizationScreen( closeClick = remember(viewModel) { { viewModel.trySendAction(VaultMoveToOrganizationAction.BackClick) } }, + moveClick = remember(viewModel) { + { viewModel.trySendAction(VaultMoveToOrganizationAction.MoveClick) } + }, + dismissClick = remember(viewModel) { + { viewModel.trySendAction(VaultMoveToOrganizationAction.DismissClick) } + }, + organizationSelect = remember(viewModel) { + { viewModel.trySendAction(VaultMoveToOrganizationAction.OrganizationSelect(it)) } + }, + collectionSelect = remember(viewModel) { + { viewModel.trySendAction(VaultMoveToOrganizationAction.CollectionSelect(it)) } + }, ) } @OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") @Composable private fun VaultMoveToOrganizationScaffold( state: VaultMoveToOrganizationState, closeClick: () -> Unit, + moveClick: () -> Unit, + dismissClick: () -> Unit, + organizationSelect: (VaultMoveToOrganizationState.ViewState.Content.Organization) -> Unit, + collectionSelect: (VaultMoveToOrganizationState.ViewState.Content.Collection) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + when (val dialog = state.dialogState) { + is VaultMoveToOrganizationState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = dialog.message, + ), + onDismissRequest = dismissClick, + ) + } + + is VaultMoveToOrganizationState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = dialog.message, + ), + ) + } + + null -> Unit + } BitwardenScaffold( modifier = Modifier .fillMaxSize() @@ -70,6 +113,14 @@ private fun VaultMoveToOrganizationScaffold( navigationIcon = painterResource(id = R.drawable.ic_close), navigationIconContentDescription = stringResource(id = R.string.close), onNavigationIconClick = closeClick, + actions = { + BitwardenTextButton( + label = stringResource(id = R.string.move), + onClick = moveClick, + isEnabled = state.viewState is + VaultMoveToOrganizationState.ViewState.Content, + ) + }, ) }, ) { innerPadding -> @@ -80,18 +131,28 @@ private fun VaultMoveToOrganizationScaffold( when (state.viewState) { is VaultMoveToOrganizationState.ViewState.Content -> { - // TODO add real views in BIT-844 UI - Text(text = "Content") + VaultMoveToOrganizationContent( + state = state.viewState, + organizationSelect = organizationSelect, + collectionSelect = collectionSelect, + modifier = modifier, + ) } + is VaultMoveToOrganizationState.ViewState.Error -> { BitwardenErrorContent( message = state.viewState.message(), modifier = modifier, ) } + is VaultMoveToOrganizationState.ViewState.Loading -> { BitwardenLoadingContent(modifier = modifier) } + + is VaultMoveToOrganizationState.ViewState.Empty -> { + VaultMoveToOrganizationEmpty(modifier = modifier) + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt index c702461db3..6bb9a755ca 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt @@ -2,9 +2,15 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization import android.os.Parcelable import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -25,19 +31,91 @@ class VaultMoveToOrganizationViewModel @Inject constructor( VaultMoveToOrganizationState( vaultItemId = VaultMoveToOrganizationArgs(savedStateHandle).vaultItemId, viewState = VaultMoveToOrganizationState.ViewState.Loading, + dialogState = null, ) }, ) { + init { + // TODO Load real orgs/collections BIT-769 + viewModelScope.launch { + @Suppress("MagicNumber") + delay(1500) + mutableStateFlow.update { + it.copy( + viewState = VaultMoveToOrganizationState.ViewState.Empty, + ) + } + } + } + override fun handleAction(action: VaultMoveToOrganizationAction) { when (action) { is VaultMoveToOrganizationAction.BackClick -> handleBackClick() + is VaultMoveToOrganizationAction.CollectionSelect -> handleCollectionSelect(action) + is VaultMoveToOrganizationAction.MoveClick -> handleMoveClick() + is VaultMoveToOrganizationAction.DismissClick -> handleDismissClick() + is VaultMoveToOrganizationAction.OrganizationSelect -> handleOrganizationSelect(action) } } private fun handleBackClick() { sendEvent(VaultMoveToOrganizationEvent.NavigateBack) } + + private fun handleOrganizationSelect(action: VaultMoveToOrganizationAction.OrganizationSelect) { + updateContent { it.copy(selectedOrganizationId = action.organization.id) } + } + + private fun handleCollectionSelect(action: VaultMoveToOrganizationAction.CollectionSelect) { + updateContent { currentContentState -> + currentContentState.copy( + organizations = currentContentState + .organizations + .toUpdatedOrganizations( + selectedOrganizationId = currentContentState.selectedOrganizationId, + selectedCollectionId = action.collection.id, + ), + + ) + } + } + + private fun handleMoveClick() { + mutableStateFlow.update { + it.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ) + } + // TODO implement move organization functionality BIT-769 + viewModelScope.launch { + @Suppress("MagicNumber") + delay(1500) + sendEvent(VaultMoveToOrganizationEvent.ShowToast("Not yet implemented!".asText())) + mutableStateFlow.update { + it.copy(dialogState = null) + } + sendEvent(VaultMoveToOrganizationEvent.NavigateBack) + } + } + + private fun handleDismissClick() { + mutableStateFlow.update { it.copy(dialogState = null) } + } + + private inline fun updateContent( + crossinline block: ( + VaultMoveToOrganizationState.ViewState.Content, + ) -> VaultMoveToOrganizationState.ViewState.Content?, + ) { + val currentViewState = state.viewState + val updatedContent = (currentViewState as? VaultMoveToOrganizationState.ViewState.Content) + ?.let(block) + ?: return + mutableStateFlow.update { it.copy(viewState = updatedContent) } + } } /** @@ -45,13 +123,36 @@ class VaultMoveToOrganizationViewModel @Inject constructor( * * @property vaultItemId Indicates whether the VM is in add or edit mode. * @property viewState indicates what view state the screen is in. + * @property dialogState the dialog state. */ @Parcelize data class VaultMoveToOrganizationState( val vaultItemId: String, val viewState: ViewState, + val dialogState: DialogState?, ) : Parcelable { + /** + * Represents the current state of any dialogs on the screen. + */ + sealed class DialogState : Parcelable { + /** + * Represents an error dialog with the given [message]. + */ + @Parcelize + data class Error( + val message: Text, + ) : DialogState() + + /** + * Represents a loading dialog with the given [message]. + */ + @Parcelize + data class Loading( + val message: Text, + ) : DialogState() + } + /** * Represents the specific view states for the [VaultMoveToOrganizationScreen]. */ @@ -74,9 +175,53 @@ data class VaultMoveToOrganizationState( /** * Represents a loaded content state for the [VaultMoveToOrganizationScreen]. + * + * @property organizations the organizations available. */ @Parcelize - data object Content : ViewState() + data class Content( + val selectedOrganizationId: String, + val organizations: List, + ) : ViewState() { + + val selectedOrganization: Organization + get() = organizations.first { it.id == selectedOrganizationId } + + /** + * Models an organization. + * + * @property id the organization id. + * @property name the organization name. + * @property isSelected if the organization is selected or not. + * @property collections the list of collections associated with the organization. + */ + @Parcelize + data class Organization( + val id: String, + val name: String, + val collections: List, + ) : Parcelable + + /** + * Models a collection. + * + * @property id the collection id. + * @property name the collection name. + * @property isSelected if the collection is selected or not. + */ + @Parcelize + data class Collection( + val id: String, + val name: String, + val isSelected: Boolean, + ) : Parcelable + } + + /** + * Represents an empty state for the [VaultMoveToOrganizationScreen]. + */ + @Parcelize + data object Empty : ViewState() } } @@ -107,4 +252,59 @@ sealed class VaultMoveToOrganizationAction { * Click the back button. */ data object BackClick : VaultMoveToOrganizationAction() + + /** + * Click the move button. + */ + data object MoveClick : VaultMoveToOrganizationAction() + + /** + * Dismiss the dialog. + */ + data object DismissClick : VaultMoveToOrganizationAction() + + /** + * Select an organization. + * + * @property organization the organization to select. + */ + data class OrganizationSelect( + val organization: VaultMoveToOrganizationState.ViewState.Content.Organization, + ) : VaultMoveToOrganizationAction() + + /** + * Select a collection. + * + * @property collection the collection to select. + */ + data class CollectionSelect( + val collection: VaultMoveToOrganizationState.ViewState.Content.Collection, + ) : VaultMoveToOrganizationAction() } + +@Suppress("MaxLineLength") +private fun List.toUpdatedOrganizations( + selectedOrganizationId: String, + selectedCollectionId: String, +): List = + map { organization -> + if (organization.id != selectedOrganizationId) return@map organization + organization.copy( + collections = organization + .collections + .toUpdatedCollections(selectedCollectionId = selectedCollectionId), + ) + } + +private fun List.toUpdatedCollections( + selectedCollectionId: String, +): List = + map { collection -> + collection.copy( + isSelected = if (selectedCollectionId == collection.id) { + !collection.isSelected + } else { + collection.isSelected + }, + ) + } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt index 9610d0910f..24a88bb16d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt @@ -1,13 +1,28 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll +import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util.createMockOrganizationList 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 @@ -52,10 +67,191 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() { ) } } + + @Test + fun `clicking move button should send MoveClick action`() { + composeTestRule + .onNodeWithText(text = "Move") + .performClick() + + verify { + viewModel.trySendAction( + VaultMoveToOrganizationAction.MoveClick, + ) + } + } + + @Test + fun `selecting an organization should send OrganizationSelect action`() { + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 1") + .performClick() + // Choose the option from the menu + composeTestRule + .onAllNodesWithText(text = "Organization 2") + .onLast() + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + VaultMoveToOrganizationAction.OrganizationSelect( + VaultMoveToOrganizationState.ViewState.Content.Organization( + id = "2", + name = "Organization 2", + collections = listOf( + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "1", + name = "Collection 1", + isSelected = true, + ), + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "2", + name = "Collection 2", + isSelected = false, + ), + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "3", + name = "Collection 3", + isSelected = false, + ), + ), + ), + ), + ) + } + } + + @Test + fun `the organization option field should display according to state`() { + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 1") + .assertIsDisplayed() + + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "2", + ), + ) + } + + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 2") + .assertIsDisplayed() + } + + @Test + fun `selecting a collection should send CollectionSelect action`() { + composeTestRule + .onNodeWithText(text = "Collection 2") + .performClick() + + verify { + viewModel.trySendAction( + VaultMoveToOrganizationAction.CollectionSelect( + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "2", + name = "Collection 2", + isSelected = false, + ), + ), + ) + } + } + + @Test + fun `the collection list should display according to state`() { + composeTestRule + .onNodeWithText("Collection 1") + .assertIsOn() + composeTestRule + .onNodeWithText("Collection 2") + .assertIsOff() + composeTestRule + .onNodeWithText("Collection 3") + .assertIsOff() + + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList() + .map { organization -> + organization.copy( + collections = + if (organization.id == "1") { + organization + .collections + .map { collection -> + collection.copy(isSelected = collection.id != "1") + } + } else { + organization.collections + }, + ) + }, + selectedOrganizationId = "1", + ), + ) + } + + composeTestRule + .onNodeWithText("Collection 1") + .assertIsOff() + composeTestRule + .onNodeWithText("Collection 2") + .assertIsOn() + composeTestRule + .onNodeWithText("Collection 3") + .assertIsOn() + } + + @Test + fun `loading dialog should display according to state`() { + composeTestRule + .onAllNodesWithText("loading") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsNotDisplayed() + + mutableStateFlow.update { + it.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Loading("loading".asText()), + ) + } + + composeTestRule + .onAllNodesWithText("loading") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `error dialog should display according to state`() { + composeTestRule + .onAllNodesWithText("error") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsNotDisplayed() + + mutableStateFlow.update { + it.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Error("error".asText()), + ) + } + + composeTestRule + .onAllNodesWithText("error") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } } private fun createVaultMoveToOrganizationState(): VaultMoveToOrganizationState = VaultMoveToOrganizationState( vaultItemId = "mockId", - viewState = VaultMoveToOrganizationState.ViewState.Content, + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "1", + ), + dialogState = null, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt index 60fac3ad19..83285792bc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt @@ -2,7 +2,10 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util.createMockOrganizationList import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -50,6 +53,119 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { } } + @Test + fun `OrganizationSelect should update selected Organization`() = runTest { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultMoveToOrganizationState( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "1", + ), + ), + ), + ) + val action = VaultMoveToOrganizationAction.OrganizationSelect( + VaultMoveToOrganizationState.ViewState.Content.Organization( + id = "3", + name = "Organization 3", + collections = emptyList(), + ), + ) + val expectedState = createVaultMoveToOrganizationState( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "3", + ), + ) + + viewModel.actionChannel.trySend(action) + + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + fun `CollectionSelect should update selected Collections`() = runTest { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultMoveToOrganizationState( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "1", + ), + ), + ), + ) + val selectCollection3Action = VaultMoveToOrganizationAction.CollectionSelect( + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "3", + name = "Collection 3", + isSelected = false, + ), + ) + val unselectCollection1Action = VaultMoveToOrganizationAction.CollectionSelect( + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "1", + name = "Collection 1", + isSelected = true, + ), + ) + val expectedState = createVaultMoveToOrganizationState( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList() + .map { organization -> + organization.copy( + collections = + if (organization.id == "1") { + organization.collections.map { + it.copy(isSelected = it.id == "3") + } + } else { + organization.collections + }, + ) + }, + selectedOrganizationId = "1", + ), + ) + + viewModel.actionChannel.trySend(selectCollection3Action) + viewModel.actionChannel.trySend(unselectCollection1Action) + + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + fun `MoveClick should show dialog, and remove it once an item is moved`() = runTest { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = initialState, + ), + ) + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) + viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.MoveClick) + assertEquals( + initialState.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ), + awaitItem(), + ) + assertEquals( + initialState, + awaitItem(), + ) + } + } + private fun createViewModel( savedStateHandle: SavedStateHandle = initialSavedStateHandle, ): VaultMoveToOrganizationViewModel = @@ -67,11 +183,13 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") private fun createVaultMoveToOrganizationState( - viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Content, + viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Empty, vaultItemId: String = "mockId", + dialogState: VaultMoveToOrganizationState.DialogState? = null, ): VaultMoveToOrganizationState = VaultMoveToOrganizationState( vaultItemId = vaultItemId, viewState = viewState, + dialogState = dialogState, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationTestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationTestUtil.kt new file mode 100644 index 0000000000..f0a659788f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationTestUtil.kt @@ -0,0 +1,58 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util + +import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.VaultMoveToOrganizationState + +/** + * Creates a list of mock organizations. + */ +fun createMockOrganizationList(): + List = + listOf( + VaultMoveToOrganizationState.ViewState.Content.Organization( + id = "1", + name = "Organization 1", + collections = listOf( + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "1", + name = "Collection 1", + isSelected = true, + ), + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "2", + name = "Collection 2", + isSelected = false, + ), + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "3", + name = "Collection 3", + isSelected = false, + ), + ), + ), + VaultMoveToOrganizationState.ViewState.Content.Organization( + id = "2", + name = "Organization 2", + collections = listOf( + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "1", + name = "Collection 1", + isSelected = true, + ), + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "2", + name = "Collection 2", + isSelected = false, + ), + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "3", + name = "Collection 3", + isSelected = false, + ), + ), + ), + VaultMoveToOrganizationState.ViewState.Content.Organization( + id = "3", + name = "Organization 3", + collections = emptyList(), + ), + )