BIT-844: Move to Organization UI (#638)

This commit is contained in:
Ramsey Smith 2024-01-16 15:53:45 -07:00 committed by Álison Fernandes
parent 10bf584c90
commit 21a9802ed4
7 changed files with 791 additions and 6 deletions

View file

@ -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),
)
}
}
}
}
}

View file

@ -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),
)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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<Organization>,
) : 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<Collection>,
) : 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<VaultMoveToOrganizationState.ViewState.Content.Organization>.toUpdatedOrganizations(
selectedOrganizationId: String,
selectedCollectionId: String,
): List<VaultMoveToOrganizationState.ViewState.Content.Organization> =
map { organization ->
if (organization.id != selectedOrganizationId) return@map organization
organization.copy(
collections = organization
.collections
.toUpdatedCollections(selectedCollectionId = selectedCollectionId),
)
}
private fun List<VaultMoveToOrganizationState.ViewState.Content.Collection>.toUpdatedCollections(
selectedCollectionId: String,
): List<VaultMoveToOrganizationState.ViewState.Content.Collection> =
map { collection ->
collection.copy(
isSelected = if (selectedCollectionId == collection.id) {
!collection.isSelected
} else {
collection.isSelected
},
)
}

View file

@ -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,
)

View file

@ -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,
)
}

View file

@ -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<VaultMoveToOrganizationState.ViewState.Content.Organization> =
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(),
),
)