BIT-2009 Add support for nested collections (#1111)

This commit is contained in:
Oleg Semenenko 2024-03-08 14:24:32 -06:00 committed by Álison Fernandes
parent 5e1328eecb
commit 3b33360e58
13 changed files with 506 additions and 44 deletions

View file

@ -39,6 +39,7 @@ import kotlinx.collections.immutable.toPersistentList
fun VaultItemListingContent( fun VaultItemListingContent(
state: VaultItemListingState.ViewState.Content, state: VaultItemListingState.ViewState.Content,
policyDisablesSend: Boolean, policyDisablesSend: Boolean,
collectionClick: (id: String) -> Unit,
folderClick: (id: String) -> Unit, folderClick: (id: String) -> Unit,
vaultItemClick: (id: String) -> Unit, vaultItemClick: (id: String) -> Unit,
masterPasswordRepromptSubmit: (password: String, data: MasterPasswordRepromptData) -> Unit, masterPasswordRepromptSubmit: (password: String, data: MasterPasswordRepromptData) -> Unit,
@ -111,6 +112,35 @@ fun VaultItemListingContent(
} }
} }
if (state.displayCollectionList.isNotEmpty()) {
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.collections),
supportingLabel = state.displayCollectionList.count().toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
items(state.displayCollectionList) { collection ->
BitwardenGroupItem(
startIcon = painterResource(id = R.drawable.ic_collection),
label = collection.name,
supportingLabel = collection.count.toString(),
onClick = { collectionClick(collection.id) },
showDivider = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
if (state.displayFolderList.isNotEmpty()) { if (state.displayFolderList.isNotEmpty()) {
item { item {
BitwardenListHeaderTextWithSupportLabel( BitwardenListHeaderTextWithSupportLabel(
@ -140,7 +170,7 @@ fun VaultItemListingContent(
} }
} }
if (state.displayItemList.isNotEmpty() && state.displayFolderList.isNotEmpty()) { if (state.shouldShowDivider) {
item { item {
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,

View file

@ -56,7 +56,7 @@ import kotlinx.collections.immutable.toImmutableList
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Suppress("LongMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
fun VaultItemListingScreen( fun VaultItemListingScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToVaultItem: (id: String) -> Unit, onNavigateToVaultItem: (id: String) -> Unit,
@ -124,6 +124,10 @@ fun VaultItemListingScreen(
is VaultItemListingEvent.NavigateToFolderItem -> { is VaultItemListingEvent.NavigateToFolderItem -> {
onNavigateToVaultItemListing(VaultItemListingType.Folder(event.folderId)) onNavigateToVaultItemListing(VaultItemListingType.Folder(event.folderId))
} }
is VaultItemListingEvent.NavigateToCollectionItem -> {
onNavigateToVaultItemListing(VaultItemListingType.Collection(event.collectionId))
}
} }
} }
@ -244,6 +248,7 @@ private fun VaultItemListingScaffold(
policyDisablesSend = state.policyDisablesSend && policyDisablesSend = state.policyDisablesSend &&
state.itemListingType is VaultItemListingState.ItemListingType.Send, state.itemListingType is VaultItemListingState.ItemListingType.Send,
vaultItemClick = vaultItemListingHandlers.itemClick, vaultItemClick = vaultItemListingHandlers.itemClick,
collectionClick = vaultItemListingHandlers.collectionClick,
folderClick = vaultItemListingHandlers.folderClick, folderClick = vaultItemListingHandlers.folderClick,
masterPasswordRepromptSubmit = masterPasswordRepromptSubmit =
vaultItemListingHandlers.masterPasswordRepromptSubmit, vaultItemListingHandlers.masterPasswordRepromptSubmit,

View file

@ -139,6 +139,7 @@ class VaultItemListingViewModel @Inject constructor(
is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick() is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick()
is VaultItemListingsAction.BackClick -> handleBackClick() is VaultItemListingsAction.BackClick -> handleBackClick()
is VaultItemListingsAction.FolderClick -> handleFolderClick(action) is VaultItemListingsAction.FolderClick -> handleFolderClick(action)
is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action)
is VaultItemListingsAction.LockClick -> handleLockClick() is VaultItemListingsAction.LockClick -> handleLockClick()
is VaultItemListingsAction.SyncClick -> handleSyncClick() is VaultItemListingsAction.SyncClick -> handleSyncClick()
is VaultItemListingsAction.SearchIconClick -> handleSearchIconClick() is VaultItemListingsAction.SearchIconClick -> handleSearchIconClick()
@ -168,6 +169,10 @@ class VaultItemListingViewModel @Inject constructor(
authRepository.switchAccount(userId = action.accountSummary.userId) authRepository.switchAccount(userId = action.accountSummary.userId)
} }
private fun handleCollectionClick(action: VaultItemListingsAction.CollectionClick) {
sendEvent(VaultItemListingEvent.NavigateToCollectionItem(action.id))
}
private fun handleFolderClick(action: VaultItemListingsAction.FolderClick) { private fun handleFolderClick(action: VaultItemListingsAction.FolderClick) {
sendEvent(VaultItemListingEvent.NavigateToFolderItem(action.id)) sendEvent(VaultItemListingEvent.NavigateToFolderItem(action.id))
} }
@ -839,12 +844,18 @@ data class VaultItemListingState(
* Content state for the [VaultItemListingScreen] showing the actual content or items. * Content state for the [VaultItemListingScreen] showing the actual content or items.
* *
* @property displayItemList List of items to display. * @property displayItemList List of items to display.
* @property displayFolderList list of folders to display.
* @property displayCollectionList list of collections to display.
*/ */
data class Content( data class Content(
val displayItemList: List<DisplayItem>, val displayItemList: List<DisplayItem>,
val displayFolderList: List<FolderDisplayItem>, val displayFolderList: List<FolderDisplayItem>,
val displayCollectionList: List<CollectionDisplayItem>,
) : ViewState() { ) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true override val isPullToRefreshEnabled: Boolean get() = true
val shouldShowDivider: Boolean
get() = displayItemList.isNotEmpty() &&
(displayFolderList.isNotEmpty() || displayCollectionList.isNotEmpty())
} }
/** /**
@ -895,6 +906,19 @@ data class VaultItemListingState(
val count: Int, val count: Int,
) )
/**
* The collection that is displayed to the user on the ItemListingScreen.
*
* @property id the id of the collection.
* @property name the name of the collection.
* @property count the amount of ciphers in the collection.
*/
data class CollectionDisplayItem(
val id: String,
val name: String,
val count: Int,
)
/** /**
* Represents different types of item listing. * Represents different types of item listing.
*/ */
@ -1031,6 +1055,11 @@ sealed class VaultItemListingEvent {
*/ */
data object NavigateToAddVaultItem : VaultItemListingEvent() data object NavigateToAddVaultItem : VaultItemListingEvent()
/**
* Navigates to the collection.
*/
data class NavigateToCollectionItem(val collectionId: String) : VaultItemListingEvent()
/** /**
* Navigates to the folder. * Navigates to the folder.
*/ */
@ -1165,6 +1194,13 @@ sealed class VaultItemListingsAction {
*/ */
data class ItemClick(val id: String) : VaultItemListingsAction() data class ItemClick(val id: String) : VaultItemListingsAction()
/**
* Click on the collection.
*
* @property id the id of the collection that has been clicked
*/
data class CollectionClick(val id: String) : VaultItemListingsAction()
/** /**
* Click on the folder. * Click on the folder.
* *

View file

@ -19,6 +19,7 @@ data class VaultItemListingHandlers(
val addVaultItemClick: () -> Unit, val addVaultItemClick: () -> Unit,
val itemClick: (id: String) -> Unit, val itemClick: (id: String) -> Unit,
val folderClick: (id: String) -> Unit, val folderClick: (id: String) -> Unit,
val collectionClick: (id: String) -> Unit,
val masterPasswordRepromptSubmit: (password: String, MasterPasswordRepromptData) -> Unit, val masterPasswordRepromptSubmit: (password: String, MasterPasswordRepromptData) -> Unit,
val refreshClick: () -> Unit, val refreshClick: () -> Unit,
val syncClick: () -> Unit, val syncClick: () -> Unit,
@ -50,6 +51,9 @@ data class VaultItemListingHandlers(
addVaultItemClick = { addVaultItemClick = {
viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick)
}, },
collectionClick = {
viewModel.trySendAction(VaultItemListingsAction.CollectionClick(it))
},
itemClick = { viewModel.trySendAction(VaultItemListingsAction.ItemClick(it)) }, itemClick = { viewModel.trySendAction(VaultItemListingsAction.ItemClick(it)) },
folderClick = { viewModel.trySendAction(VaultItemListingsAction.FolderClick(it)) }, folderClick = { viewModel.trySendAction(VaultItemListingsAction.FolderClick(it)) },
masterPasswordRepromptSubmit = { password, data -> masterPasswordRepromptSubmit = { password, data ->

View file

@ -19,7 +19,9 @@ import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.tools.feature.send.util.toLabelIcons import com.x8bit.bitwarden.ui.tools.feature.send.util.toLabelIcons
import com.x8bit.bitwarden.ui.tools.feature.send.util.toOverflowActions import com.x8bit.bitwarden.ui.tools.feature.send.util.toOverflowActions
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
import com.x8bit.bitwarden.ui.vault.feature.util.getCollections
import com.x8bit.bitwarden.ui.vault.feature.util.getFolders import com.x8bit.bitwarden.ui.vault.feature.util.getFolders
import com.x8bit.bitwarden.ui.vault.feature.util.toCollectionDisplayName
import com.x8bit.bitwarden.ui.vault.feature.util.toFolderDisplayName import com.x8bit.bitwarden.ui.vault.feature.util.toFolderDisplayName
import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons
import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions
@ -101,15 +103,20 @@ fun VaultData.toViewState(
} }
.toFilteredList(vaultFilterType) .toFilteredList(vaultFilterType)
val folderList = if (itemListingType is VaultItemListingState.ItemListingType.Vault.Folder && val folderList =
!itemListingType.folderId.isNullOrBlank() (itemListingType as? VaultItemListingState.ItemListingType.Vault.Folder)
) { ?.folderId
folderViewList.getFolders(itemListingType.folderId) ?.let { folderViewList.getFolders(it) }
} else { .orEmpty()
emptyList()
}
return if (folderList.isNotEmpty() || filteredCipherViewList.isNotEmpty()) { val collectionList =
(itemListingType as? VaultItemListingState.ItemListingType.Vault.Collection)
?.let { collectionViewList.getCollections(it.collectionId) }
.orEmpty()
return if (folderList.isNotEmpty() || filteredCipherViewList.isNotEmpty() ||
collectionList.isNotEmpty()
) {
VaultItemListingState.ViewState.Content( VaultItemListingState.ViewState.Content(
displayItemList = filteredCipherViewList.toDisplayItemList( displayItemList = filteredCipherViewList.toDisplayItemList(
baseIconUrl = baseIconUrl, baseIconUrl = baseIconUrl,
@ -128,6 +135,18 @@ fun VaultData.toViewState(
}, },
) )
}, },
displayCollectionList = collectionList.map { collectionView ->
VaultItemListingState.CollectionDisplayItem(
id = requireNotNull(collectionView.id),
name = collectionView.name,
count = this.cipherViewList
.count {
!it.id.isNullOrBlank() &&
it.deletedDate == null &&
collectionView.id in it.collectionIds
},
)
},
) )
} else { } else {
// Use the autofill empty message if necessary, otherwise use normal type-specific message // Use the autofill empty message if necessary, otherwise use normal type-specific message
@ -141,6 +160,10 @@ fun VaultData.toViewState(
R.string.no_items_folder R.string.no_items_folder
} }
is VaultItemListingState.ItemListingType.Vault.Collection -> {
R.string.no_items_collection
}
VaultItemListingState.ItemListingType.Vault.Trash -> { VaultItemListingState.ItemListingType.Vault.Trash -> {
R.string.no_items_trash R.string.no_items_trash
} }
@ -177,6 +200,7 @@ fun List<SendView>.toViewState(
clock = clock, clock = clock,
), ),
displayFolderList = emptyList(), displayFolderList = emptyList(),
displayCollectionList = emptyList(),
) )
} else { } else {
VaultItemListingState.ViewState.NoItems( VaultItemListingState.ViewState.NoItems(
@ -196,6 +220,7 @@ fun VaultItemListingState.ItemListingType.updateWithAdditionalDataIfNecessary(
collectionName = collectionList collectionName = collectionList
.find { it.id == collectionId } .find { it.id == collectionId }
?.name ?.name
?.toCollectionDisplayName(collectionList)
.orEmpty(), .orEmpty(),
) )

View file

@ -0,0 +1,91 @@
package com.x8bit.bitwarden.ui.vault.feature.util
import com.bitwarden.core.CollectionView
private const val COLLECTION_DIVIDER: String = "/"
/**
* Retrieves the nested collections of a given [collectionId] and updates their names to proper
* display names. This function is necessary if we want to show the nested collections for a
* specific collection.
*/
@Suppress("ReturnCount")
fun List<CollectionView>.getCollections(collectionId: String): List<CollectionView> {
val currentCollection = this.find { it.id == collectionId } ?: return emptyList()
// If two collections have the same name the second collection should have no nested collections
val firstCollectionWithName = this.first { it.name == currentCollection.name }
if (firstCollectionWithName.id != collectionId) return emptyList()
val collectionList = this
.getFilteredCollections(currentCollection.name)
.map {
it.copy(name = it.name.substringAfter(currentCollection.name + COLLECTION_DIVIDER))
}
return collectionList
}
/**
* Filters out the nest collections of the nest collections from the given list. If a
* [collectionName] is provided, collections that are not nested of the specified [collectionName]
* will be filtered out.
*/
fun List<CollectionView>.getFilteredCollections(
collectionName: String? = null,
): List<CollectionView> =
this.filter { collectionView ->
// If the collection name is not null we filter out collections that are not nested
// collections.
if (collectionName != null &&
!collectionView.name.startsWith(collectionName + COLLECTION_DIVIDER)
) {
return@filter false
}
this.forEach {
val firstCollection = collectionName
?.let { name -> collectionView.name.substringAfter(name + COLLECTION_DIVIDER) }
?: collectionView.name
val secondCollection = collectionName
?.let { name -> it.name.substringAfter(name + COLLECTION_DIVIDER) }
?: it.name
// We don't want to compare the collection to itself or itself plus a slash.
if (firstCollection == secondCollection) {
return@forEach
}
// If the first collection name is blank or the first collection is a nested collection
// of the second collection, we want to filter it out.
if (firstCollection.isEmpty() ||
firstCollection.startsWith(secondCollection + COLLECTION_DIVIDER)
) {
return@filter false
}
}
true
}
/**
* Converts a collection name to a user-friendly display name. This function is necessary because
* the collection name we receive is often nested, and we want to extract just the relevant name for
* display to the user.
*/
fun String.toCollectionDisplayName(list: List<CollectionView>): String {
var collectionName = this
// cycle through the list and determine the correct display name of the collection.
list.forEach { collection ->
if (this.startsWith(collection.name + COLLECTION_DIVIDER)) {
val newName = this.substringAfter(collection.name + COLLECTION_DIVIDER)
if (newName.length < collectionName.length) {
collectionName = newName
}
}
}
return collectionName
}

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.vault.feature.util.getFilteredCollections
import com.x8bit.bitwarden.ui.vault.feature.util.getFilteredFolders import com.x8bit.bitwarden.ui.vault.feature.util.getFilteredFolders
import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons
import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions
@ -47,7 +48,10 @@ fun VaultData.toViewState(
val filteredFolderViewList = folderViewList.toFilteredList(vaultFilterType).getFilteredFolders() val filteredFolderViewList = folderViewList.toFilteredList(vaultFilterType).getFilteredFolders()
val filteredCollectionViewList = collectionViewList.toFilteredList(vaultFilterType) val filteredCollectionViewList = collectionViewList
.toFilteredList(vaultFilterType)
.getFilteredCollections()
val noFolderItems = filteredCipherViewList val noFolderItems = filteredCipherViewList
.filter { it.folderId.isNullOrBlank() } .filter { it.folderId.isNullOrBlank() }

View file

@ -5,12 +5,12 @@ import com.bitwarden.core.CollectionView
/** /**
* Create a mock [CollectionView] with a given [number]. * Create a mock [CollectionView] with a given [number].
*/ */
fun createMockCollectionView(number: Int): CollectionView = fun createMockCollectionView(number: Int, name: String? = null): CollectionView =
CollectionView( CollectionView(
id = "mockId-$number", id = "mockId-$number",
organizationId = "mockOrganizationId-$number", organizationId = "mockOrganizationId-$number",
hidePasswords = false, hidePasswords = false,
name = "mockName-$number", name = name ?: "mockName-$number",
externalId = "mockExternalId-$number", externalId = "mockExternalId-$number",
readOnly = false, readOnly = false,
) )

View file

@ -6,8 +6,6 @@ import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.isPopup
@ -15,7 +13,6 @@ import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri import androidx.core.net.toUri
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
@ -39,6 +36,7 @@ import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed
import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed
import com.x8bit.bitwarden.ui.util.isProgressBar import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.util.performAccountClick import com.x8bit.bitwarden.ui.util.performAccountClick
import com.x8bit.bitwarden.ui.util.performAccountIconClick import com.x8bit.bitwarden.ui.util.performAccountIconClick
import com.x8bit.bitwarden.ui.util.performAccountLongClick import com.x8bit.bitwarden.ui.util.performAccountLongClick
@ -566,14 +564,12 @@ class VaultItemListingScreenTest : BaseComposeTest() {
name = "test", id = "1", count = 0, name = "test", id = "1", count = 0,
), ),
), ),
displayCollectionList = emptyList(),
), ),
) )
} }
composeTestRule composeTestRule
.onNode(hasScrollToNodeAction()) .onNodeWithTextAfterScroll(text = folders)
.performScrollToNode(hasText(folders))
composeTestRule
.onNodeWithText(text = folders)
.assertIsDisplayed() .assertIsDisplayed()
} }
@ -592,14 +588,12 @@ class VaultItemListingScreenTest : BaseComposeTest() {
displayFolderList = listOf( displayFolderList = listOf(
VaultItemListingState.FolderDisplayItem(name = "test", id = "1", count = 0), VaultItemListingState.FolderDisplayItem(name = "test", id = "1", count = 0),
), ),
displayCollectionList = emptyList(),
), ),
) )
} }
composeTestRule composeTestRule
.onNode(hasScrollToNodeAction()) .onNodeWithTextAfterScroll(text = folders)
.performScrollToNode(hasText(folders))
composeTestRule
.onNodeWithText(text = folders)
.assertIsDisplayed() .assertIsDisplayed()
.assertTextEquals(folders, 1.toString()) .assertTextEquals(folders, 1.toString())
@ -624,15 +618,13 @@ class VaultItemListingScreenTest : BaseComposeTest() {
count = 0, count = 0,
), ),
), ),
displayCollectionList = emptyList(),
), ),
) )
} }
composeTestRule composeTestRule
.onNode(hasScrollToNodeAction()) .onNodeWithTextAfterScroll(text = folders)
.performScrollToNode(hasText(folders))
composeTestRule
.onNodeWithText(text = folders)
.assertIsDisplayed() .assertIsDisplayed()
.assertTextEquals(folders, 3.toString()) .assertTextEquals(folders, 3.toString())
} }
@ -648,6 +640,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = emptyList(), displayItemList = emptyList(),
displayFolderList = listOf( displayFolderList = listOf(
VaultItemListingState.FolderDisplayItem( VaultItemListingState.FolderDisplayItem(
@ -665,6 +658,123 @@ class VaultItemListingScreenTest : BaseComposeTest() {
.assertIsDisplayed() .assertIsDisplayed()
} }
@Test
fun `Collections text should be displayed according to state`() {
val collectionName = "Collections"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = collectionName)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = emptyList(),
displayFolderList = emptyList(),
displayCollectionList = listOf(
VaultItemListingState.CollectionDisplayItem(
name = "Collection",
id = "1",
count = 0,
),
),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll(collectionName)
.assertIsDisplayed()
}
@Test
fun `Collection text count should be displayed according to state`() {
val collections = "Collections"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = collections)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = emptyList(),
displayFolderList = emptyList(),
displayCollectionList = listOf(
VaultItemListingState.CollectionDisplayItem(
name = "Collection",
id = "1",
count = 0,
),
VaultItemListingState.CollectionDisplayItem(
name = "Collection2",
id = "2",
count = 0,
),
VaultItemListingState.CollectionDisplayItem(
name = "Collection3",
id = "3",
count = 0,
),
),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll(text = collections)
.assertIsDisplayed()
.assertTextEquals(collections, 3.toString())
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = emptyList(),
displayFolderList = emptyList(),
displayCollectionList = listOf(
VaultItemListingState.CollectionDisplayItem(
name = "Collection",
id = "1",
count = 0,
),
),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll(text = collections)
.assertIsDisplayed()
.assertTextEquals(collections, 1.toString())
}
@Test
fun `collectionDisplayItems should be displayed according to state`() {
val collectionName = "TestCollection"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = collectionName)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = emptyList(),
displayFolderList = emptyList(),
displayCollectionList = listOf(
VaultItemListingState.CollectionDisplayItem(
name = collectionName,
id = "1",
count = 0,
),
),
),
)
}
composeTestRule
.onNodeWithText(text = collectionName)
.assertIsDisplayed()
}
@Test @Test
fun `Items text should be displayed according to state`() { fun `Items text should be displayed according to state`() {
val items = "Items" val items = "Items"
@ -676,6 +786,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createDisplayItem(number = 1), createDisplayItem(number = 1),
), ),
@ -684,10 +795,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
) )
} }
composeTestRule composeTestRule
.onNode(hasScrollToNodeAction()) .onNodeWithTextAfterScroll(text = items)
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed() .assertIsDisplayed()
} }
@ -702,6 +810,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createDisplayItem(number = 1), createDisplayItem(number = 1),
), ),
@ -710,16 +819,14 @@ class VaultItemListingScreenTest : BaseComposeTest() {
) )
} }
composeTestRule composeTestRule
.onNode(hasScrollToNodeAction()) .onNodeWithTextAfterScroll(text = items)
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed() .assertIsDisplayed()
.assertTextEquals(items, 1.toString()) .assertTextEquals(items, 1.toString())
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createDisplayItem(number = 1), createDisplayItem(number = 1),
createDisplayItem(number = 2), createDisplayItem(number = 2),
@ -732,10 +839,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
} }
composeTestRule composeTestRule
.onNode(hasScrollToNodeAction()) .onNodeWithTextAfterScroll(text = items)
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed() .assertIsDisplayed()
.assertTextEquals(items, 4.toString()) .assertTextEquals(items, 4.toString())
} }
@ -745,6 +849,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createDisplayItem(number = 1), createDisplayItem(number = 1),
), ),
@ -767,6 +872,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createDisplayItem(number = 1) createDisplayItem(number = 1)
.copy( .copy(
@ -794,6 +900,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createDisplayItem(number = 1) createDisplayItem(number = 1)
.copy( .copy(
@ -845,6 +952,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createDisplayItem(number = 1) createDisplayItem(number = 1)
.copy( .copy(
@ -875,6 +983,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createDisplayItem(number = 1) createDisplayItem(number = 1)
.copy( .copy(
@ -1015,6 +1124,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
it.copy( it.copy(
itemListingType = VaultItemListingState.ItemListingType.Vault.Login, itemListingType = VaultItemListingState.ItemListingType.Vault.Login,
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf(createCipherDisplayItem(number = 1)), displayItemList = listOf(createCipherDisplayItem(number = 1)),
displayFolderList = emptyList(), displayFolderList = emptyList(),
), ),
@ -1052,6 +1162,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
it.copy( it.copy(
itemListingType = VaultItemListingState.ItemListingType.Vault.Login, itemListingType = VaultItemListingState.ItemListingType.Vault.Login,
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createCipherDisplayItem(number = 1) createCipherDisplayItem(number = 1)
.copy(shouldShowMasterPasswordReprompt = true), .copy(shouldShowMasterPasswordReprompt = true),
@ -1083,6 +1194,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
it.copy( it.copy(
itemListingType = VaultItemListingState.ItemListingType.Send.SendFile, itemListingType = VaultItemListingState.ItemListingType.Send.SendFile,
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf(createDisplayItem(number = 1)), displayItemList = listOf(createDisplayItem(number = 1)),
displayFolderList = emptyList(), displayFolderList = emptyList(),
), ),
@ -1109,6 +1221,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf(createDisplayItem(number = number)), displayItemList = listOf(createDisplayItem(number = number)),
displayFolderList = emptyList(), displayFolderList = emptyList(),
), ),
@ -1133,6 +1246,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf(createDisplayItem(number = number)), displayItemList = listOf(createDisplayItem(number = number)),
displayFolderList = emptyList(), displayFolderList = emptyList(),
), ),
@ -1222,6 +1336,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf(createDisplayItem(number = 1)), displayItemList = listOf(createDisplayItem(number = 1)),
displayFolderList = emptyList(), displayFolderList = emptyList(),
), ),

View file

@ -305,6 +305,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
} returns ValidatePasswordResult.Error } returns ValidatePasswordResult.Error
val initialState = createVaultItemListingState( val initialState = createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher(number = 1), createMockDisplayItemForCipher(number = 1),
), ),
@ -354,6 +355,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
) )
val initialState = createVaultItemListingState( val initialState = createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher(number = 1), createMockDisplayItemForCipher(number = 1),
), ),
@ -485,6 +487,17 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `CollectionClick for vault item should emit NavigateToCollectionItem`() = runTest {
val viewModel = createVaultItemListingViewModel()
val testId = "1"
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.CollectionClick(testId))
assertEquals(VaultItemListingEvent.NavigateToCollectionItem(testId), awaitItem())
}
}
@Test @Test
fun `RefreshClick should sync`() = runTest { fun `RefreshClick should sync`() = runTest {
val viewModel = createVaultItemListingViewModel() val viewModel = createVaultItemListingViewModel()
@ -842,6 +855,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher(number = 1), createMockDisplayItemForCipher(number = 1),
), ),
@ -889,6 +903,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher(number = 1).copy(isAutofill = true), createMockDisplayItemForCipher(number = 1).copy(isAutofill = true),
), ),
@ -990,6 +1005,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher(number = 1), createMockDisplayItemForCipher(number = 1),
), ),
@ -1097,6 +1113,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher(number = 1), createMockDisplayItemForCipher(number = 1),
), ),
@ -1211,6 +1228,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content( viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher(number = 1), createMockDisplayItemForCipher(number = 1),
), ),

View file

@ -397,6 +397,7 @@ class VaultItemListingDataExtensionsTest {
assertEquals( assertEquals(
VaultItemListingState.ViewState.Content( VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher( createMockDisplayItemForCipher(
number = 1, number = 1,
@ -469,6 +470,7 @@ class VaultItemListingDataExtensionsTest {
assertEquals( assertEquals(
VaultItemListingState.ViewState.Content( VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForCipher( createMockDisplayItemForCipher(
number = 1, number = 1,
@ -582,6 +584,7 @@ class VaultItemListingDataExtensionsTest {
assertEquals( assertEquals(
VaultItemListingState.ViewState.Content( VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf( displayItemList = listOf(
createMockDisplayItemForSend(number = 1, sendType = SendType.FILE), createMockDisplayItemForSend(number = 1, sendType = SendType.FILE),
createMockDisplayItemForSend(number = 2, sendType = SendType.TEXT), createMockDisplayItemForSend(number = 2, sendType = SendType.TEXT),
@ -683,7 +686,7 @@ class VaultItemListingDataExtensionsTest {
@Test @Test
fun `toViewState should properly filter and return the correct folders`() { fun `toViewState should properly filter and return the correct folders`() {
val vaultData = VaultData( val vaultData = VaultData(
listOf(createMockCipherView(number = 1)), cipherViewList = listOf(createMockCipherView(number = 1)),
collectionViewList = emptyList(), collectionViewList = emptyList(),
folderViewList = listOf( folderViewList = listOf(
FolderView("1", "test", clock.instant()), FolderView("1", "test", clock.instant()),
@ -705,6 +708,7 @@ class VaultItemListingDataExtensionsTest {
assertEquals( assertEquals(
VaultItemListingState.ViewState.Content( VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf(), displayItemList = listOf(),
displayFolderList = listOf( displayFolderList = listOf(
VaultItemListingState.FolderDisplayItem( VaultItemListingState.FolderDisplayItem(
@ -717,4 +721,48 @@ class VaultItemListingDataExtensionsTest {
actual, actual,
) )
} }
@Test
fun `toViewState should properly filter and return the correct collections`() {
val vaultData = VaultData(
cipherViewList = emptyList(),
collectionViewList = listOf(
createMockCollectionView(1, "test"),
createMockCollectionView(2, "test/test"),
createMockCollectionView(3, "Collection/test"),
createMockCollectionView(4, "test/Collection"),
createMockCollectionView(5, "Collection"),
),
folderViewList = emptyList(),
sendViewList = emptyList(),
)
val actual = vaultData.toViewState(
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
itemListingType = VaultItemListingState.ItemListingType.Vault.Collection("mockId-1"),
vaultFilterType = VaultFilterType.AllVaults,
)
assertEquals(
VaultItemListingState.ViewState.Content(
displayCollectionList = listOf(
VaultItemListingState.CollectionDisplayItem(
id = "mockId-2",
name = "test",
count = 0,
),
VaultItemListingState.CollectionDisplayItem(
id = "mockId-4",
name = "Collection",
count = 0,
),
),
displayItemList = emptyList(),
displayFolderList = emptyList(),
),
actual,
)
}
} }

View file

@ -0,0 +1,69 @@
package com.x8bit.bitwarden.ui.vault.feature.util
import com.bitwarden.core.CollectionView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class CollectionViewExtensionsTest {
@Test
fun `getCollections should get the collections for a collectionId with the correct names`() {
val collectionList: List<CollectionView> = listOf(
createMockCollectionView(number = 1, name = "test"),
createMockCollectionView(number = 2, name = "test/test"),
createMockCollectionView(number = 3, name = "test/Collection"),
createMockCollectionView(number = 4, name = "test/test/test"),
createMockCollectionView(number = 5, name = "Collection"),
)
val expected = listOf(
createMockCollectionView(number = 2, name = "test"),
createMockCollectionView(number = 3, name = "Collection"),
)
assertEquals(
expected,
collectionList.getCollections("mockId-1"),
)
}
@Test
fun `getFilteredCollections should properly filter out sub collections in a list`() {
val collectionList: List<CollectionView> = listOf(
createMockCollectionView(number = 1, name = "test"),
createMockCollectionView(number = 2, name = "test/test"),
createMockCollectionView(number = 3, name = "test/Collection"),
createMockCollectionView(number = 4, name = "test/test/test"),
createMockCollectionView(number = 5, name = "Collection"),
)
val expected = listOf(
createMockCollectionView(number = 1, name = "test"),
createMockCollectionView(number = 5, name = "Collection"),
)
assertEquals(
expected,
collectionList.getFilteredCollections(),
)
}
@Test
fun `toCollectionDisplayName should return the correct name`() {
val collectionName = "Collection/test/2"
val collectionList: List<CollectionView> = listOf(
createMockCollectionView(number = 1, name = "Collection/test"),
createMockCollectionView(number = 2, name = "test/test"),
createMockCollectionView(number = 3, name = "test/Collection"),
createMockCollectionView(number = 4, name = collectionName),
createMockCollectionView(number = 5, name = "Collection"),
)
assertEquals(
"2",
collectionName.toCollectionDisplayName(collectionList),
)
}
}

View file

@ -494,10 +494,16 @@ class VaultDataExtensionsTest {
} }
@Test @Test
fun `toViewState should properly filter nested folders out`() { fun `toViewState should properly filter nested items out`() {
val vaultData = VaultData( val vaultData = VaultData(
listOf(createMockCipherView(number = 1)), listOf(createMockCipherView(number = 1)),
collectionViewList = emptyList(), collectionViewList = listOf(
createMockCollectionView(1, "test"),
createMockCollectionView(2, "test/test"),
createMockCollectionView(3, "Collection/test"),
createMockCollectionView(4, "test/Collection"),
createMockCollectionView(5, "Collection"),
),
folderViewList = listOf( folderViewList = listOf(
FolderView("1", "test", clock.instant()), FolderView("1", "test", clock.instant()),
FolderView("2", "test/test", clock.instant()), FolderView("2", "test/test", clock.instant()),
@ -522,6 +528,18 @@ class VaultDataExtensionsTest {
identityItemsCount = 0, identityItemsCount = 0,
secureNoteItemsCount = 0, secureNoteItemsCount = 0,
favoriteItems = listOf(), favoriteItems = listOf(),
collectionItems = listOf(
VaultState.ViewState.CollectionItem(
id = "mockId-1",
name = "test",
itemCount = 1,
),
VaultState.ViewState.CollectionItem(
id = "mockId-5",
name = "Collection",
itemCount = 0,
),
),
folderItems = listOf( folderItems = listOf(
VaultState.ViewState.FolderItem( VaultState.ViewState.FolderItem(
id = "1", id = "1",
@ -540,7 +558,6 @@ class VaultDataExtensionsTest {
), ),
), ),
collectionItems = listOf(),
noFolderItems = listOf(), noFolderItems = listOf(),
trashItemsCount = 0, trashItemsCount = 0,
totpItemsCount = 1, totpItemsCount = 1,