From 3b33360e588873f567360f4f38ec67d4e65b1cec Mon Sep 17 00:00:00 2001 From: Oleg Semenenko <146032743+oleg-livefront@users.noreply.github.com> Date: Fri, 8 Mar 2024 14:24:32 -0600 Subject: [PATCH] BIT-2009 Add support for nested collections (#1111) --- .../itemlisting/VaultItemListingContent.kt | 32 +++- .../itemlisting/VaultItemListingScreen.kt | 7 +- .../itemlisting/VaultItemListingViewModel.kt | 36 ++++ .../handlers/VaultItemListingHandlers.kt | 4 + .../util/VaultItemListingDataExtensions.kt | 41 ++++- .../feature/util/CollectionViewExtensions.kt | 91 ++++++++++ .../feature/vault/util/VaultDataExtensions.kt | 6 +- .../sdk/model/CollectionViewUtil.kt | 4 +- .../itemlisting/VaultItemListingScreenTest.kt | 169 +++++++++++++++--- .../VaultItemListingViewModelTest.kt | 18 ++ .../VaultItemListingDataExtensionsTest.kt | 50 +++++- .../util/CollectionViewExtensionsTest.kt | 69 +++++++ .../vault/util/VaultDataExtensionsTest.kt | 23 ++- 13 files changed, 506 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/util/CollectionViewExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/util/CollectionViewExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt index eef00b55c..3a28ea12a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt @@ -39,6 +39,7 @@ import kotlinx.collections.immutable.toPersistentList fun VaultItemListingContent( state: VaultItemListingState.ViewState.Content, policyDisablesSend: Boolean, + collectionClick: (id: String) -> Unit, folderClick: (id: String) -> Unit, vaultItemClick: (id: String) -> 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()) { item { BitwardenListHeaderTextWithSupportLabel( @@ -140,7 +170,7 @@ fun VaultItemListingContent( } } - if (state.displayItemList.isNotEmpty() && state.displayFolderList.isNotEmpty()) { + if (state.shouldShowDivider) { item { HorizontalDivider( thickness = 1.dp, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index fa386863f..d105889e4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -56,7 +56,7 @@ import kotlinx.collections.immutable.toImmutableList */ @OptIn(ExperimentalMaterial3Api::class) @Composable -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") fun VaultItemListingScreen( onNavigateBack: () -> Unit, onNavigateToVaultItem: (id: String) -> Unit, @@ -124,6 +124,10 @@ fun VaultItemListingScreen( is VaultItemListingEvent.NavigateToFolderItem -> { onNavigateToVaultItemListing(VaultItemListingType.Folder(event.folderId)) } + + is VaultItemListingEvent.NavigateToCollectionItem -> { + onNavigateToVaultItemListing(VaultItemListingType.Collection(event.collectionId)) + } } } @@ -244,6 +248,7 @@ private fun VaultItemListingScaffold( policyDisablesSend = state.policyDisablesSend && state.itemListingType is VaultItemListingState.ItemListingType.Send, vaultItemClick = vaultItemListingHandlers.itemClick, + collectionClick = vaultItemListingHandlers.collectionClick, folderClick = vaultItemListingHandlers.folderClick, masterPasswordRepromptSubmit = vaultItemListingHandlers.masterPasswordRepromptSubmit, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 01f6f0154..414238365 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -139,6 +139,7 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick() is VaultItemListingsAction.BackClick -> handleBackClick() is VaultItemListingsAction.FolderClick -> handleFolderClick(action) + is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action) is VaultItemListingsAction.LockClick -> handleLockClick() is VaultItemListingsAction.SyncClick -> handleSyncClick() is VaultItemListingsAction.SearchIconClick -> handleSearchIconClick() @@ -168,6 +169,10 @@ class VaultItemListingViewModel @Inject constructor( authRepository.switchAccount(userId = action.accountSummary.userId) } + private fun handleCollectionClick(action: VaultItemListingsAction.CollectionClick) { + sendEvent(VaultItemListingEvent.NavigateToCollectionItem(action.id)) + } + private fun handleFolderClick(action: VaultItemListingsAction.FolderClick) { sendEvent(VaultItemListingEvent.NavigateToFolderItem(action.id)) } @@ -839,12 +844,18 @@ data class VaultItemListingState( * Content state for the [VaultItemListingScreen] showing the actual content or items. * * @property displayItemList List of items to display. + * @property displayFolderList list of folders to display. + * @property displayCollectionList list of collections to display. */ data class Content( val displayItemList: List, val displayFolderList: List, + val displayCollectionList: List, ) : ViewState() { 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, ) + /** + * 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. */ @@ -1031,6 +1055,11 @@ sealed class VaultItemListingEvent { */ data object NavigateToAddVaultItem : VaultItemListingEvent() + /** + * Navigates to the collection. + */ + data class NavigateToCollectionItem(val collectionId: String) : VaultItemListingEvent() + /** * Navigates to the folder. */ @@ -1165,6 +1194,13 @@ sealed class 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. * diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt index 0c54ffe1a..82c88c747 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt @@ -19,6 +19,7 @@ data class VaultItemListingHandlers( val addVaultItemClick: () -> Unit, val itemClick: (id: String) -> Unit, val folderClick: (id: String) -> Unit, + val collectionClick: (id: String) -> Unit, val masterPasswordRepromptSubmit: (password: String, MasterPasswordRepromptData) -> Unit, val refreshClick: () -> Unit, val syncClick: () -> Unit, @@ -50,6 +51,9 @@ data class VaultItemListingHandlers( addVaultItemClick = { viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) }, + collectionClick = { + viewModel.trySendAction(VaultItemListingsAction.CollectionClick(it)) + }, itemClick = { viewModel.trySendAction(VaultItemListingsAction.ItemClick(it)) }, folderClick = { viewModel.trySendAction(VaultItemListingsAction.FolderClick(it)) }, masterPasswordRepromptSubmit = { password, data -> diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index cc7ffb470..4ee9e3edf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -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.toOverflowActions 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.toCollectionDisplayName 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.toOverflowActions @@ -101,15 +103,20 @@ fun VaultData.toViewState( } .toFilteredList(vaultFilterType) - val folderList = if (itemListingType is VaultItemListingState.ItemListingType.Vault.Folder && - !itemListingType.folderId.isNullOrBlank() - ) { - folderViewList.getFolders(itemListingType.folderId) - } else { - emptyList() - } + val folderList = + (itemListingType as? VaultItemListingState.ItemListingType.Vault.Folder) + ?.folderId + ?.let { folderViewList.getFolders(it) } + .orEmpty() - 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( displayItemList = filteredCipherViewList.toDisplayItemList( 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 { // 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 } + is VaultItemListingState.ItemListingType.Vault.Collection -> { + R.string.no_items_collection + } + VaultItemListingState.ItemListingType.Vault.Trash -> { R.string.no_items_trash } @@ -177,6 +200,7 @@ fun List.toViewState( clock = clock, ), displayFolderList = emptyList(), + displayCollectionList = emptyList(), ) } else { VaultItemListingState.ViewState.NoItems( @@ -196,6 +220,7 @@ fun VaultItemListingState.ItemListingType.updateWithAdditionalDataIfNecessary( collectionName = collectionList .find { it.id == collectionId } ?.name + ?.toCollectionDisplayName(collectionList) .orEmpty(), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/util/CollectionViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/util/CollectionViewExtensions.kt new file mode 100644 index 000000000..019fa31ea --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/util/CollectionViewExtensions.kt @@ -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.getCollections(collectionId: String): List { + 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.getFilteredCollections( + collectionName: String? = null, +): List = + 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): 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 +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index c694cf709..b9d10906d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -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.orNullIfBlank 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.toLabelIcons import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions @@ -47,7 +48,10 @@ fun VaultData.toViewState( val filteredFolderViewList = folderViewList.toFilteredList(vaultFilterType).getFilteredFolders() - val filteredCollectionViewList = collectionViewList.toFilteredList(vaultFilterType) + val filteredCollectionViewList = collectionViewList + .toFilteredList(vaultFilterType) + .getFilteredCollections() + val noFolderItems = filteredCipherViewList .filter { it.folderId.isNullOrBlank() } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt index c99d2e4cf..eef5d8728 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt @@ -5,12 +5,12 @@ import com.bitwarden.core.CollectionView /** * Create a mock [CollectionView] with a given [number]. */ -fun createMockCollectionView(number: Int): CollectionView = +fun createMockCollectionView(number: Int, name: String? = null): CollectionView = CollectionView( id = "mockId-$number", organizationId = "mockOrganizationId-$number", hidePasswords = false, - name = "mockName-$number", + name = name ?: "mockName-$number", externalId = "mockExternalId-$number", readOnly = false, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 14ea6f347..be83d2ae4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -6,8 +6,6 @@ import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filterToOne 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.isDisplayed 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.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri 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.assertSwitcherIsNotDisplayed 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.performAccountIconClick import com.x8bit.bitwarden.ui.util.performAccountLongClick @@ -566,14 +564,12 @@ class VaultItemListingScreenTest : BaseComposeTest() { name = "test", id = "1", count = 0, ), ), + displayCollectionList = emptyList(), ), ) } composeTestRule - .onNode(hasScrollToNodeAction()) - .performScrollToNode(hasText(folders)) - composeTestRule - .onNodeWithText(text = folders) + .onNodeWithTextAfterScroll(text = folders) .assertIsDisplayed() } @@ -592,14 +588,12 @@ class VaultItemListingScreenTest : BaseComposeTest() { displayFolderList = listOf( VaultItemListingState.FolderDisplayItem(name = "test", id = "1", count = 0), ), + displayCollectionList = emptyList(), ), ) } composeTestRule - .onNode(hasScrollToNodeAction()) - .performScrollToNode(hasText(folders)) - composeTestRule - .onNodeWithText(text = folders) + .onNodeWithTextAfterScroll(text = folders) .assertIsDisplayed() .assertTextEquals(folders, 1.toString()) @@ -624,15 +618,13 @@ class VaultItemListingScreenTest : BaseComposeTest() { count = 0, ), ), + displayCollectionList = emptyList(), ), ) } composeTestRule - .onNode(hasScrollToNodeAction()) - .performScrollToNode(hasText(folders)) - composeTestRule - .onNodeWithText(text = folders) + .onNodeWithTextAfterScroll(text = folders) .assertIsDisplayed() .assertTextEquals(folders, 3.toString()) } @@ -648,6 +640,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = emptyList(), displayFolderList = listOf( VaultItemListingState.FolderDisplayItem( @@ -665,6 +658,123 @@ class VaultItemListingScreenTest : BaseComposeTest() { .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 fun `Items text should be displayed according to state`() { val items = "Items" @@ -676,6 +786,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1), ), @@ -684,10 +795,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { ) } composeTestRule - .onNode(hasScrollToNodeAction()) - .performScrollToNode(hasText(items)) - composeTestRule - .onNodeWithText(text = items) + .onNodeWithTextAfterScroll(text = items) .assertIsDisplayed() } @@ -702,6 +810,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1), ), @@ -710,16 +819,14 @@ class VaultItemListingScreenTest : BaseComposeTest() { ) } composeTestRule - .onNode(hasScrollToNodeAction()) - .performScrollToNode(hasText(items)) - composeTestRule - .onNodeWithText(text = items) + .onNodeWithTextAfterScroll(text = items) .assertIsDisplayed() .assertTextEquals(items, 1.toString()) mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1), createDisplayItem(number = 2), @@ -732,10 +839,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { } composeTestRule - .onNode(hasScrollToNodeAction()) - .performScrollToNode(hasText(items)) - composeTestRule - .onNodeWithText(text = items) + .onNodeWithTextAfterScroll(text = items) .assertIsDisplayed() .assertTextEquals(items, 4.toString()) } @@ -745,6 +849,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1), ), @@ -767,6 +872,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1) .copy( @@ -794,6 +900,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1) .copy( @@ -845,6 +952,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1) .copy( @@ -875,6 +983,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1) .copy( @@ -1015,6 +1124,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { it.copy( itemListingType = VaultItemListingState.ItemListingType.Vault.Login, viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf(createCipherDisplayItem(number = 1)), displayFolderList = emptyList(), ), @@ -1052,6 +1162,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { it.copy( itemListingType = VaultItemListingState.ItemListingType.Vault.Login, viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createCipherDisplayItem(number = 1) .copy(shouldShowMasterPasswordReprompt = true), @@ -1083,6 +1194,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { it.copy( itemListingType = VaultItemListingState.ItemListingType.Send.SendFile, viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf(createDisplayItem(number = 1)), displayFolderList = emptyList(), ), @@ -1109,6 +1221,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf(createDisplayItem(number = number)), displayFolderList = emptyList(), ), @@ -1133,6 +1246,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf(createDisplayItem(number = number)), displayFolderList = emptyList(), ), @@ -1222,6 +1336,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf(createDisplayItem(number = 1)), displayFolderList = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 0013766ad..22272cc12 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -305,6 +305,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } returns ValidatePasswordResult.Error val initialState = createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), @@ -354,6 +355,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) val initialState = createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( 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 fun `RefreshClick should sync`() = runTest { val viewModel = createVaultItemListingViewModel() @@ -842,6 +855,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertEquals( createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), @@ -889,6 +903,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertEquals( createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForCipher(number = 1).copy(isAutofill = true), ), @@ -990,6 +1005,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertEquals( createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), @@ -1097,6 +1113,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertEquals( createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), @@ -1211,6 +1228,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertEquals( createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 131aee40a..450e36f30 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -397,6 +397,7 @@ class VaultItemListingDataExtensionsTest { assertEquals( VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForCipher( number = 1, @@ -469,6 +470,7 @@ class VaultItemListingDataExtensionsTest { assertEquals( VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForCipher( number = 1, @@ -582,6 +584,7 @@ class VaultItemListingDataExtensionsTest { assertEquals( VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf( createMockDisplayItemForSend(number = 1, sendType = SendType.FILE), createMockDisplayItemForSend(number = 2, sendType = SendType.TEXT), @@ -683,7 +686,7 @@ class VaultItemListingDataExtensionsTest { @Test fun `toViewState should properly filter and return the correct folders`() { val vaultData = VaultData( - listOf(createMockCipherView(number = 1)), + cipherViewList = listOf(createMockCipherView(number = 1)), collectionViewList = emptyList(), folderViewList = listOf( FolderView("1", "test", clock.instant()), @@ -705,6 +708,7 @@ class VaultItemListingDataExtensionsTest { assertEquals( VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), displayItemList = listOf(), displayFolderList = listOf( VaultItemListingState.FolderDisplayItem( @@ -717,4 +721,48 @@ class VaultItemListingDataExtensionsTest { 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, + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/util/CollectionViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/util/CollectionViewExtensionsTest.kt new file mode 100644 index 000000000..0cfbc06e0 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/util/CollectionViewExtensionsTest.kt @@ -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 = 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 = 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 = 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), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index c8a04197e..6b628edf7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -494,10 +494,16 @@ class VaultDataExtensionsTest { } @Test - fun `toViewState should properly filter nested folders out`() { + fun `toViewState should properly filter nested items out`() { val vaultData = VaultData( 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( FolderView("1", "test", clock.instant()), FolderView("2", "test/test", clock.instant()), @@ -522,6 +528,18 @@ class VaultDataExtensionsTest { identityItemsCount = 0, secureNoteItemsCount = 0, 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( VaultState.ViewState.FolderItem( id = "1", @@ -540,7 +558,6 @@ class VaultDataExtensionsTest { ), ), - collectionItems = listOf(), noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1,