From f18c43dd164beaf07bb5423e5bbeb0d4291f57e2 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Wed, 13 Dec 2023 11:37:52 -0600 Subject: [PATCH] BIT-603: Display Collections on Vault screen (#386) --- .../ui/vault/feature/vault/VaultContent.kt | 35 ++++++++++ .../ui/vault/feature/vault/VaultScreen.kt | 7 ++ .../ui/vault/feature/vault/VaultViewModel.kt | 31 +++++++++ .../feature/vault/util/VaultDataExtensions.kt | 12 ++++ app/src/main/res/drawable/ic_collection.xml | 18 ++++++ .../datasource/sdk/model/CipherViewUtil.kt | 2 +- .../bitwarden/ui/util/ComposeTestHelpers.kt | 16 +++-- .../ui/vault/feature/vault/VaultScreenTest.kt | 64 +++++++++++++++++++ .../vault/feature/vault/VaultViewModelTest.kt | 23 +++++++ .../vault/util/VaultDataExtensionsTest.kt | 14 ++++ 10 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 app/src/main/res/drawable/ic_collection.xml diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index df1c5bbaf3..3ff29eef47 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -25,6 +25,7 @@ fun VaultContent( state: VaultState.ViewState.Content, vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit, folderClick: (VaultState.ViewState.FolderItem) -> Unit, + collectionClick: (VaultState.ViewState.CollectionItem) -> Unit, loginGroupClick: () -> Unit, cardGroupClick: () -> Unit, identityGroupClick: () -> Unit, @@ -208,6 +209,40 @@ fun VaultContent( } } + if (state.collectionItems.isNotEmpty()) { + item { + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), + ) + } + + item { + BitwardenListHeaderTextWithSupportLabel( + label = stringResource(id = R.string.collections), + supportingLabel = state.collectionItems.count().toString(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + items(state.collectionItems) { collection -> + VaultGroupListItem( + startIcon = painterResource(id = R.drawable.ic_collection), + label = collection.name, + supportingLabel = collection.itemCount.toString(), + onClick = { collectionClick(collection) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + item { HorizontalDivider( thickness = 1.dp, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 443c4c9482..bea063797d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -105,6 +105,11 @@ fun VaultScreen( folderClick = remember(viewModel) { { folderItem -> viewModel.trySendAction(VaultAction.FolderClick(folderItem)) } }, + collectionClick = remember(viewModel) { + { collectionItem -> + viewModel.trySendAction(VaultAction.CollectionClick(collectionItem)) + } + }, loginGroupClick = remember(viewModel) { { viewModel.trySendAction(VaultAction.LoginGroupClick) } }, @@ -140,6 +145,7 @@ private fun VaultScreenScaffold( onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit, folderClick: (VaultState.ViewState.FolderItem) -> Unit, + collectionClick: (VaultState.ViewState.CollectionItem) -> Unit, loginGroupClick: () -> Unit, cardGroupClick: () -> Unit, identityGroupClick: () -> Unit, @@ -209,6 +215,7 @@ private fun VaultScreenScaffold( state = viewState, vaultItemClick = vaultItemClick, folderClick = folderClick, + collectionClick = collectionClick, loginGroupClick = loginGroupClick, cardGroupClick = cardGroupClick, identityGroupClick = identityGroupClick, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index c6043a163a..c7bdc7186d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -84,6 +84,7 @@ class VaultViewModel @Inject constructor( is VaultAction.AddItemClick -> handleAddItemClick() is VaultAction.CardGroupClick -> handleCardClick() is VaultAction.FolderClick -> handleFolderItemClick(action) + is VaultAction.CollectionClick -> handleCollectionItemClick(action) is VaultAction.IdentityGroupClick -> handleIdentityClick() is VaultAction.LoginGroupClick -> handleLoginClick() is VaultAction.SearchIconClick -> handleSearchIconClick() @@ -118,6 +119,13 @@ class VaultViewModel @Inject constructor( ) } + private fun handleCollectionItemClick(action: VaultAction.CollectionClick) { + // TODO: Navigate to the listing screen for collections (BIT-406). + sendEvent( + VaultEvent.ShowToast(message = "Not yet implemented."), + ) + } + private fun handleIdentityClick() { sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Identity)) } @@ -282,6 +290,7 @@ data class VaultState( * @property favoriteItems The list of favorites to be displayed. * @property folderItems The list of folders to be displayed. * @property noFolderItems The list of non-folders to be displayed. + * @property collectionItems The list of collections to be displayed. * @property trashItemsCount The number of items present in the trash. */ @Parcelize @@ -293,6 +302,7 @@ data class VaultState( val favoriteItems: List, val folderItems: List, val noFolderItems: List, + val collectionItems: List, val trashItemsCount: Int, ) : ViewState() @@ -311,6 +321,20 @@ data class VaultState( val itemCount: Int, ) : Parcelable + /** + * Represents a collection. + * + * @property id The unique identifier for this collection. + * @property name The display name of the collection. + * @property itemCount The number of items this collection contains. + */ + @Parcelize + data class CollectionItem( + val id: String, + val name: String, + val itemCount: Int, + ) : Parcelable + /** * A sealed class hierarchy representing different types of items in the vault. */ @@ -516,6 +540,13 @@ sealed class VaultAction { val folderItem: VaultState.ViewState.FolderItem, ) : VaultAction() + /** + * Action to trigger when a specific collection item is clicked. + */ + data class CollectionClick( + val collectionItem: VaultState.ViewState.CollectionItem, + ) : VaultAction() + /** * User clicked the login types button. */ 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 c40ddccbac..9014dc119d 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 @@ -82,6 +82,18 @@ fun VaultData.toViewState(): VaultState.ViewState = noFolderItems = cipherViewList .filter { it.folderId.isNullOrBlank() } .mapNotNull { it.toVaultItemOrNull() }, + collectionItems = collectionViewList + .map { collectionView -> + VaultState.ViewState.CollectionItem( + id = collectionView.id, + name = collectionView.name, + itemCount = cipherViewList + .count { + !it.id.isNullOrBlank() && + collectionView.id in it.collectionIds + }, + ) + }, // TODO need to populate trash item count in BIT-969 trashItemsCount = 0, ) diff --git a/app/src/main/res/drawable/ic_collection.xml b/app/src/main/res/drawable/ic_collection.xml new file mode 100644 index 0000000000..a6dabe9119 --- /dev/null +++ b/app/src/main/res/drawable/ic_collection.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index ccc110db5f..d0830ee909 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -25,7 +25,7 @@ fun createMockCipherView(number: Int): CipherView = id = "mockId-$number", organizationId = "mockOrganizationId-$number", folderId = "mockId-$number", - collectionIds = listOf("mockCollectionId-$number"), + collectionIds = listOf("mockId-$number"), key = "mockKey-$number", name = "mockName-$number", notes = "mockNotes-$number", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt index 00420c6cdd..03922987d9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt @@ -41,11 +41,14 @@ fun ComposeContentTestRule.assertNoDialogExists() { /** * A helper that asserts that the node does not exist in the scrollable list. */ -fun ComposeContentTestRule.assertScrollableNodeDoesNotExist(text: String) { +fun ComposeContentTestRule.assertScrollableNodeDoesNotExist( + text: String, + substring: Boolean = false, +) { val scrollableNodeInteraction = onNode(hasScrollToNodeAction()) assertThrows { // throws since it cannot find the node. - scrollableNodeInteraction.performScrollToNode(hasText(text)) + scrollableNodeInteraction.performScrollToNode(hasText(text, substring)) } } @@ -53,9 +56,12 @@ fun ComposeContentTestRule.assertScrollableNodeDoesNotExist(text: String) { * A helper used to scroll to and get the matching node in a scrollable list. This is intended to * be used with lazy lists that would otherwise fail when calling [performScrollToNode]. */ -fun ComposeContentTestRule.onNodeWithTextAfterScroll(text: String): SemanticsNodeInteraction { - onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text)) - return onNodeWithText(text) +fun ComposeContentTestRule.onNodeWithTextAfterScroll( + text: String, + substring: Boolean = false, +): SemanticsNodeInteraction { + onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text, substring)) + return onNodeWithText(text, substring) } /** diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index f6843d790c..e06ed8284b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.vault +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasClickAction @@ -15,8 +16,10 @@ import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertNoDialogExists +import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed +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 @@ -319,6 +322,66 @@ class VaultScreenTest : BaseComposeTest() { } } + @Test + fun `collection data should update according to the state`() { + val collectionsHeader = "Collections" + val collectionsCount = 1 + val collectionName = "Test Collection" + val collectionCount = 3 + val collectionItem = VaultState.ViewState.CollectionItem( + id = "12345", + name = collectionName, + itemCount = collectionCount, + ) + + composeTestRule.assertScrollableNodeDoesNotExist(collectionsHeader, substring = true) + composeTestRule.assertScrollableNodeDoesNotExist(collectionName, substring = true) + + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + collectionItems = listOf(collectionItem), + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll(collectionsHeader, substring = true) + .assertTextEquals(collectionsHeader, collectionsCount.toString()) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(collectionName) + .assertTextEquals(collectionName, collectionCount.toString()) + } + + @Test + fun `clicking a collection item should send CollectionClick with the correct item`() { + val collectionName = "Test Collection" + val collectionCount = 3 + val collectionItem = VaultState.ViewState.CollectionItem( + id = "12345", + name = collectionName, + itemCount = collectionCount, + ) + + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + collectionItems = listOf(collectionItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(collectionName)) + composeTestRule + .onNodeWithText(collectionName) + .assertTextEquals(collectionName, collectionCount.toString()) + .performClick() + verify { + viewModel.trySendAction(VaultAction.CollectionClick(collectionItem)) + } + } + @Test fun `clicking a no folder item should send VaultItemClick with the correct item`() { val itemText = "Test Item" @@ -583,5 +646,6 @@ private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultStat favoriteItems = emptyList(), folderItems = emptyList(), noFolderItems = emptyList(), + collectionItems = emptyList(), trashItemsCount = 0, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 51bb44e64d..b45c9f1998 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -284,6 +284,13 @@ class VaultViewModelTest : BaseViewModelTest() { itemCount = 1, ), ), + collectionItems = listOf( + VaultState.ViewState.CollectionItem( + id = "mockId-1", + name = "mockName-1", + itemCount = 1, + ), + ), noFolderItems = listOf(), trashItemsCount = 0, ), @@ -437,6 +444,22 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `CollectionClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + val collectionId = "12345" + val collection = mockk { + every { id } returns collectionId + } + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.CollectionClick(collection)) + assertEquals( + VaultEvent.ShowToast(message = "Not yet implemented."), + awaitItem(), + ) + } + } + @Test fun `IdentityGroupClick should emit NavigateToItemListing event with Identity type`() = runTest { 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 a0333fd68c..6114156e52 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 @@ -56,6 +56,13 @@ class VaultDataExtensionsTest { itemCount = 1, ), ), + collectionItems = listOf( + VaultState.ViewState.CollectionItem( + id = "mockId-1", + name = "mockName-1", + itemCount = 1, + ), + ), noFolderItems = listOf(), trashItemsCount = 0, ), @@ -103,6 +110,13 @@ class VaultDataExtensionsTest { itemCount = 0, ), ), + collectionItems = listOf( + VaultState.ViewState.CollectionItem( + id = "mockId-1", + name = "mockName-1", + itemCount = 0, + ), + ), noFolderItems = emptyList(), trashItemsCount = 0, ),