BIT-603: Display Collections on Vault screen (#386)

This commit is contained in:
Brian Yencho 2023-12-13 11:37:52 -06:00 committed by Álison Fernandes
parent 727b0c0efc
commit f18c43dd16
10 changed files with 216 additions and 6 deletions

View file

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

View file

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

View file

@ -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<VaultItem>,
val folderItems: List<FolderItem>,
val noFolderItems: List<VaultItem>,
val collectionItems: List<CollectionItem>,
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.
*/

View file

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

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3.25,6.375C3.25,6.03 3.53,5.75 3.875,5.75H20.125C20.47,5.75 20.75,6.03 20.75,6.375C20.75,6.72 20.47,7 20.125,7H3.875C3.53,7 3.25,6.72 3.25,6.375Z"
android:fillColor="#1B1B1F"
android:fillType="evenOdd"/>
<path
android:pathData="M4.5,3.875C4.5,3.53 4.74,3.25 5.036,3.25H18.964C19.26,3.25 19.5,3.53 19.5,3.875C19.5,4.22 19.26,4.5 18.964,4.5H5.036C4.74,4.5 4.5,4.22 4.5,3.875Z"
android:fillColor="#1B1B1F"
android:fillType="evenOdd"/>
<path
android:pathData="M3.875,8.25H20.125C21.16,8.25 22,9.09 22,10.125V20.125C22,21.161 21.16,22 20.125,22H3.875C2.839,22 2,21.161 2,20.125V10.125C2,9.09 2.839,8.25 3.875,8.25ZM3.875,9.5C3.53,9.5 3.25,9.78 3.25,10.125V20.125C3.25,20.47 3.53,20.75 3.875,20.75H20.125C20.47,20.75 20.75,20.47 20.75,20.125V10.125C20.75,9.78 20.47,9.5 20.125,9.5H3.875Z"
android:fillColor="#1B1B1F"
android:fillType="evenOdd"/>
</vector>

View file

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

View file

@ -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<AssertionError> {
// 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)
}
/**

View file

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

View file

@ -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<VaultState.ViewState.CollectionItem> {
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 {

View file

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