BIT-1244: Implement dynamic vault filtering (#451)

This commit is contained in:
Brian Yencho 2023-12-29 09:21:43 -06:00 committed by Álison Fernandes
parent 9e7cd65fe1
commit 12000b2746
4 changed files with 202 additions and 49 deletions

View file

@ -61,6 +61,11 @@ class VaultViewModel @Inject constructor(
)
},
) {
/**
* Helper for retrieving the selected vault filter type from the state (or a default).
*/
private val vaultFilterTypeOrDefault: VaultFilterType
get() = state.vaultFilterData?.selectedVaultFilterType ?: VaultFilterType.AllVaults
init {
vaultRepository
@ -193,6 +198,7 @@ class VaultViewModel @Inject constructor(
}
private fun handleVaultFilterTypeSelect(action: VaultAction.VaultFilterTypeSelect) {
// Update the current filter
mutableStateFlow.update {
it.copy(
vaultFilterData = it.vaultFilterData?.copy(
@ -200,6 +206,9 @@ class VaultViewModel @Inject constructor(
),
)
}
// Re-process the current vault data with the new filter
updateViewState(vaultData = vaultRepository.vaultDataStateFlow.value)
}
private fun handleTrashClick() {
@ -253,7 +262,11 @@ class VaultViewModel @Inject constructor(
// navigating.
if (state.isSwitchingAccounts) return
when (val vaultData = action.vaultData) {
updateViewState(vaultData = action.vaultData)
}
private fun updateViewState(vaultData: DataState<VaultData>) {
when (vaultData) {
is DataState.Error -> vaultErrorReceive(vaultData = vaultData)
is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData)
is DataState.Loading -> vaultLoadingReceive()
@ -265,13 +278,16 @@ class VaultViewModel @Inject constructor(
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
mutableStateFlow.updateToErrorStateOrDialog(
vaultData = vaultData.data,
vaultFilterType = vaultFilterTypeOrDefault,
errorTitle = R.string.an_error_has_occurred.asText(),
errorMessage = R.string.generic_error_message.asText(),
)
}
private fun vaultLoadedReceive(vaultData: DataState.Loaded<VaultData>) {
mutableStateFlow.update { it.copy(viewState = vaultData.data.toViewState()) }
mutableStateFlow.update {
it.copy(viewState = vaultData.data.toViewState(vaultFilterTypeOrDefault))
}
}
private fun vaultLoadingReceive() {
@ -281,6 +297,7 @@ class VaultViewModel @Inject constructor(
private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) {
mutableStateFlow.updateToErrorStateOrDialog(
vaultData = vaultData.data,
vaultFilterType = vaultFilterTypeOrDefault,
errorTitle = R.string.internet_connection_required_title.asText(),
errorMessage = R.string.internet_connection_required_message.asText(),
)
@ -288,7 +305,9 @@ class VaultViewModel @Inject constructor(
private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) {
// TODO update state to refresh state BIT-505
mutableStateFlow.update { it.copy(viewState = vaultData.data.toViewState()) }
mutableStateFlow.update {
it.copy(viewState = vaultData.data.toViewState(vaultFilterTypeOrDefault))
}
sendEvent(VaultEvent.ShowToast(message = "Refreshing"))
}
@ -741,13 +760,14 @@ sealed class VaultAction {
private fun MutableStateFlow<VaultState>.updateToErrorStateOrDialog(
vaultData: VaultData?,
vaultFilterType: VaultFilterType,
errorTitle: Text,
errorMessage: Text,
) {
this.update {
if (vaultData != null) {
it.copy(
viewState = vaultData.toViewState(),
viewState = vaultData.toViewState(vaultFilterType = vaultFilterType),
dialog = VaultState.DialogState.Error(
title = errorTitle,
message = errorMessage,

View file

@ -1,11 +1,15 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.core.CardView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.FolderView
import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
@ -17,6 +21,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import java.time.Instant
/**
@ -55,41 +60,45 @@ private fun CipherView.toVaultItemOrNull(): VaultState.ViewState.VaultItem? {
}
/**
* Transforms [VaultData] into [VaultState.ViewState].
* Transforms [VaultData] into [VaultState.ViewState] using the given [vaultFilterType].
*/
fun VaultData.toViewState(): VaultState.ViewState =
if (cipherViewList.isEmpty() && folderViewList.isEmpty()) {
fun VaultData.toViewState(
vaultFilterType: VaultFilterType,
): VaultState.ViewState {
val filteredCipherViewList = cipherViewList.toFilteredList(vaultFilterType)
val filteredFolderViewList = folderViewList.toFilteredList(vaultFilterType)
val filteredCollectionViewList = collectionViewList.toFilteredList(vaultFilterType)
return if (filteredCipherViewList.isEmpty()) {
VaultState.ViewState.NoItems
} else {
// Filter out any items with invalid IDs in the unlikely case they exist
val filteredCipherViewList = cipherViewList.filterNot { it.id.isNullOrBlank() }
VaultState.ViewState.Content(
loginItemsCount = filteredCipherViewList.count { it.type == CipherType.LOGIN },
cardItemsCount = filteredCipherViewList.count { it.type == CipherType.CARD },
identityItemsCount = filteredCipherViewList.count { it.type == CipherType.IDENTITY },
secureNoteItemsCount = filteredCipherViewList
.count { it.type == CipherType.SECURE_NOTE },
favoriteItems = cipherViewList
favoriteItems = filteredCipherViewList
.filter { it.favorite }
.mapNotNull { it.toVaultItemOrNull() },
folderItems = folderViewList.map { folderView ->
folderItems = filteredFolderViewList.map { folderView ->
VaultState.ViewState.FolderItem(
id = folderView.id,
name = folderView.name.asText(),
itemCount = cipherViewList
itemCount = filteredCipherViewList
.count { !it.id.isNullOrBlank() && folderView.id == it.folderId },
)
},
noFolderItems = cipherViewList
noFolderItems = filteredCipherViewList
.filter { it.folderId.isNullOrBlank() }
.mapNotNull { it.toVaultItemOrNull() },
collectionItems = collectionViewList
collectionItems = filteredCollectionViewList
.filter { it.id != null }
.map { collectionView ->
VaultState.ViewState.CollectionItem(
id = requireNotNull(collectionView.id),
name = collectionView.name,
itemCount = cipherViewList
itemCount = filteredCipherViewList
.count {
!it.id.isNullOrBlank() &&
collectionView.id in it.collectionIds
@ -100,6 +109,55 @@ fun VaultData.toViewState(): VaultState.ViewState =
trashItemsCount = 0,
)
}
}
@JvmName("toFilteredCipherList")
private fun List<CipherView>.toFilteredList(
vaultFilterType: VaultFilterType,
): List<CipherView> =
this
// Filter out any items with invalid IDs in the unlikely case they exist
.filterNot { it.id.isNullOrBlank() }
.filter {
when (vaultFilterType) {
VaultFilterType.AllVaults -> true
VaultFilterType.MyVault -> it.organizationId == null
is VaultFilterType.OrganizationVault -> {
it.organizationId == vaultFilterType.organizationId
}
}
}
@JvmName("toFilteredFolderList")
private fun List<FolderView>.toFilteredList(
vaultFilterType: VaultFilterType,
): List<FolderView> =
this
.filter {
when (vaultFilterType) {
// Folders are only included when including the user's personal data.
VaultFilterType.AllVaults,
VaultFilterType.MyVault,
-> true
is VaultFilterType.OrganizationVault -> false
}
}
@JvmName("toFilteredCollectionList")
private fun List<CollectionView>.toFilteredList(
vaultFilterType: VaultFilterType,
): List<CollectionView> =
this
.filter {
when (vaultFilterType) {
VaultFilterType.AllVaults -> true
VaultFilterType.MyVault -> false
is VaultFilterType.OrganizationVault -> {
it.organizationId == vaultFilterType.organizationId
}
}
}
/**
* Transforms a [VaultAddItemState.ViewState.ItemType] into [CipherView].

View file

@ -19,6 +19,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.every
import io.mockk.just
@ -306,14 +307,19 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `on VaultFilterTypeSelect should update the selected filter type`() {
val viewModel = createViewModel()
// Update to state with filters
val initialState = DEFAULT_STATE.copy(
appBarTitle = R.string.vaults.asText(),
vaultFilterData = VAULT_FILTER_DATA,
fun `on VaultFilterTypeSelect should update the selected filter type and re-filter any existing data`() {
// Update to state with filters and content
val vaultData = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
)
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = vaultData,
),
)
mutableUserStateFlow.value = DEFAULT_USER_STATE
.copy(
@ -329,6 +335,14 @@ class VaultViewModelTest : BaseViewModelTest() {
DEFAULT_USER_STATE.accounts[1],
),
)
val viewModel = createViewModel()
val initialState = createMockVaultState(
viewState = vaultData.toViewState(VaultFilterType.AllVaults),
)
.copy(
appBarTitle = R.string.vaults.asText(),
vaultFilterData = VAULT_FILTER_DATA,
)
assertEquals(
initialState,
viewModel.stateFlow.value,
@ -341,6 +355,7 @@ class VaultViewModelTest : BaseViewModelTest() {
vaultFilterData = VAULT_FILTER_DATA.copy(
selectedVaultFilterType = VaultFilterType.MyVault,
),
viewState = vaultData.toViewState(VaultFilterType.MyVault),
),
viewModel.stateFlow.value,
)

View file

@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.every
import io.mockk.mockkStatic
@ -36,15 +37,16 @@ class VaultDataExtensionsTest {
unmockkStatic(Instant::class)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should transform full VaultData into ViewState Content`() {
fun `toViewState for AllVaults should transform full VaultData into ViewState Content without filtering`() {
val vaultData = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
)
val actual = vaultData.toViewState()
val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults)
assertEquals(
VaultState.ViewState.Content(
@ -74,6 +76,86 @@ class VaultDataExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState for MyVault should transform full VaultData into ViewState Content with filtering of non-user data`() {
val vaultData = VaultData(
cipherViewList = listOf(
createMockCipherView(number = 1).copy(organizationId = null),
createMockCipherView(number = 2),
),
collectionViewList = listOf(createMockCollectionView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
)
val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.MyVault)
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = "mockId-1",
name = "mockName-1".asText(),
itemCount = 1,
),
),
collectionItems = listOf(),
noFolderItems = listOf(),
trashItemsCount = 0,
),
actual,
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState for OrganizationVault should transform full VaultData into ViewState Content with filtering of non-organization data`() {
val vaultData = VaultData(
cipherViewList = listOf(
createMockCipherView(number = 1),
createMockCipherView(number = 2),
),
collectionViewList = listOf(
createMockCollectionView(number = 1),
createMockCollectionView(number = 2),
),
folderViewList = listOf(createMockFolderView(number = 1)),
)
val actual = vaultData.toViewState(
vaultFilterType = VaultFilterType.OrganizationVault(
organizationId = "mockOrganizationId-1",
organizationName = "Mock Organization 1",
),
)
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(),
collectionItems = listOf(
VaultState.ViewState.CollectionItem(
id = "mockId-1",
name = "mockName-1",
itemCount = 1,
),
),
noFolderItems = listOf(),
trashItemsCount = 0,
),
actual,
)
}
@Test
fun `toViewState should transform empty VaultData into ViewState NoItems`() {
val vaultData = VaultData(
@ -82,7 +164,7 @@ class VaultDataExtensionsTest {
folderViewList = emptyList(),
)
val actual = vaultData.toViewState()
val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults)
assertEquals(
VaultState.ViewState.NoItems,
@ -98,32 +180,10 @@ class VaultDataExtensionsTest {
folderViewList = listOf(createMockFolderView(number = 1)),
)
val actual = vaultData.toViewState()
val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults)
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = "mockId-1",
name = "mockName-1".asText(),
itemCount = 0,
),
),
collectionItems = listOf(
VaultState.ViewState.CollectionItem(
id = "mockId-1",
name = "mockName-1",
itemCount = 0,
),
),
noFolderItems = emptyList(),
trashItemsCount = 0,
),
VaultState.ViewState.NoItems,
actual,
)
}