From 3f0e44d42f518812a0543eaf93336f4fc1d3b84a Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Thu, 28 Dec 2023 16:33:38 -0600 Subject: [PATCH] BIT-842, BIT-843: Add Vault Filter and Vault Selection menu UI (#448) --- .../ui/vault/feature/vault/VaultFilter.kt | 99 ++++++++++++ .../ui/vault/feature/vault/VaultScreen.kt | 90 +++++++---- .../ui/vault/feature/vault/VaultViewModel.kt | 41 +++++ .../feature/vault/model/VaultFilterData.kt | 13 ++ .../feature/vault/model/VaultFilterType.kt | 53 +++++++ .../feature/vault/util/UserStateExtensions.kt | 29 ++++ .../vault/util/VaultFilterDataExtensions.kt | 17 ++ .../ui/vault/feature/vault/VaultScreenTest.kt | 147 ++++++++++++++++++ .../vault/feature/vault/VaultViewModelTest.kt | 77 ++++++++- .../vault/util/UserStateExtensionsTest.kt | 62 ++++++++ .../util/VaultFilterDataExtensionsTest.kt | 33 ++++ 11 files changed, 631 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultFilter.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/model/VaultFilterData.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/model/VaultFilterType.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultFilterDataExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultFilterDataExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultFilter.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultFilter.kt new file mode 100644 index 000000000..f22b3fee8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultFilter.kt @@ -0,0 +1,99 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.scrolledContainerBackground +import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import kotlinx.collections.immutable.ImmutableList + +/** + * Displays the current [selectedVaultFilterType] and allows for a new selection from the + * given [vaultFilterTypes]. + * + * @param selectedVaultFilterType The currently selected filter type. + * @param vaultFilterTypes The list of possible filter types. + * @param onVaultFilterTypeSelect A callback for when a new type is selected. + * @param topAppBarScrollBehavior Used to derive the background color of the content and keep it in + * sync with the associated app bar. + * @param modifier A [Modifier] for the composable. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VaultFilter( + selectedVaultFilterType: VaultFilterType, + vaultFilterTypes: ImmutableList, + onVaultFilterTypeSelect: (VaultFilterType) -> Unit, + topAppBarScrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, +) { + var shouldShowSelectionDialog by remember { mutableStateOf(false) } + + if (shouldShowSelectionDialog) { + BitwardenSelectionDialog( + title = stringResource(id = R.string.filter_by_vault), + onDismissRequest = { shouldShowSelectionDialog = false }, + ) { + vaultFilterTypes.forEach { filterType -> + BitwardenSelectionRow( + text = filterType.description, + isSelected = filterType == selectedVaultFilterType, + onClick = { + shouldShowSelectionDialog = false + onVaultFilterTypeSelect(filterType) + }, + ) + } + } + } + + Row( + modifier = Modifier + .scrolledContainerBackground(topAppBarScrollBehavior) + .padding(vertical = 8.dp) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource( + id = R.string.vault_filter_description, + selectedVaultFilterType.name(), + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + IconButton( + onClick = { shouldShowSelectionDialog = true }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_more_horizontal), + contentDescription = stringResource(id = R.string.filter_by_vault), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 ad07a71fd..6d3e8a096 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 @@ -5,10 +5,13 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBarDefaults @@ -42,6 +45,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -90,6 +94,9 @@ fun VaultScreen( } VaultScreenScaffold( state = viewModel.stateFlow.collectAsState().value, + vaultFilterTypeSelect = remember(viewModel) { + { viewModel.trySendAction(VaultAction.VaultFilterTypeSelect(it)) } + }, addItemClickAction = remember(viewModel) { { viewModel.trySendAction(VaultAction.AddItemClick) } }, @@ -161,6 +168,7 @@ fun VaultScreen( @Composable private fun VaultScreenScaffold( state: VaultState, + vaultFilterTypeSelect: (VaultFilterType) -> Unit, addItemClickAction: () -> Unit, searchIconClickAction: () -> Unit, accountLockClickAction: (AccountSummary) -> Unit, @@ -230,7 +238,7 @@ private fun VaultScreenScaffold( BitwardenScaffold( topBar = { BitwardenMediumTopAppBar( - title = stringResource(id = R.string.my_vault), + title = state.appBarTitle(), scrollBehavior = scrollBehavior, actions = { BitwardenAccountActionItem( @@ -283,36 +291,60 @@ private fun VaultScreenScaffold( }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> - val modifier = Modifier - .fillMaxSize() - .padding(paddingValues) Box { - when (val viewState = state.viewState) { - is VaultState.ViewState.Content -> VaultContent( - state = viewState, - vaultItemClick = vaultItemClick, - folderClick = folderClick, - collectionClick = collectionClick, - loginGroupClick = loginGroupClick, - cardGroupClick = cardGroupClick, - identityGroupClick = identityGroupClick, - secureNoteGroupClick = secureNoteGroupClick, - trashClick = trashClick, - modifier = modifier, - ) + val innerModifier = Modifier + .fillMaxSize() + val outerModifier = Modifier + .fillMaxSize() + .padding(paddingValues) + Column(modifier = outerModifier) { + val vaultFilterData = state.vaultFilterData + if (state.viewState.hasVaultFilter && vaultFilterData != null) { + VaultFilter( + selectedVaultFilterType = vaultFilterData.selectedVaultFilterType, + vaultFilterTypes = vaultFilterData.vaultFilterTypes.toImmutableList(), + onVaultFilterTypeSelect = vaultFilterTypeSelect, + topAppBarScrollBehavior = scrollBehavior, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) - is VaultState.ViewState.Loading -> VaultLoading(modifier = modifier) - is VaultState.ViewState.NoItems -> VaultNoItems( - modifier = modifier, - addItemClickAction = addItemClickAction, - ) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier + .fillMaxWidth(), + ) + } - is VaultState.ViewState.Error -> BitwardenErrorContent( - message = viewState.message(), - onTryAgainClick = tryAgainClick, - modifier = modifier - .padding(horizontal = 16.dp), - ) + when (val viewState = state.viewState) { + is VaultState.ViewState.Content -> VaultContent( + state = viewState, + vaultItemClick = vaultItemClick, + folderClick = folderClick, + collectionClick = collectionClick, + loginGroupClick = loginGroupClick, + cardGroupClick = cardGroupClick, + identityGroupClick = identityGroupClick, + secureNoteGroupClick = secureNoteGroupClick, + trashClick = trashClick, + modifier = innerModifier, + ) + + is VaultState.ViewState.Loading -> VaultLoading(modifier = innerModifier) + is VaultState.ViewState.NoItems -> VaultNoItems( + modifier = innerModifier, + addItemClickAction = addItemClickAction, + ) + + is VaultState.ViewState.Error -> BitwardenErrorContent( + message = viewState.message(), + onTryAgainClick = tryAgainClick, + modifier = innerModifier + .padding(horizontal = 16.dp), + ) + } } BitwardenAccountSwitcher( @@ -324,7 +356,7 @@ private fun VaultScreenScaffold( onAddAccountClick = addAccountClickAction, onDismissRequest = { updateAccountMenuVisibility(false) }, topAppBarScrollBehavior = scrollBehavior, - modifier = modifier, + modifier = outerModifier, ) } } 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 86d280625..348a410fa 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 @@ -17,9 +17,13 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.base.util.hexToColor 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.initials import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAppBarTitle +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toVaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import dagger.hilt.android.lifecycle.HiltViewModel @@ -45,10 +49,14 @@ class VaultViewModel @Inject constructor( val userState = requireNotNull(authRepository.userStateFlow.value) val accountSummaries = userState.toAccountSummaries() val activeAccountSummary = userState.toActiveAccountSummary() + val vaultFilterData = userState.activeAccount.toVaultFilterData() + val appBarTitle = vaultFilterData.toAppBarTitle() VaultState( + appBarTitle = appBarTitle, initials = activeAccountSummary.initials, avatarColorString = activeAccountSummary.avatarColorHex, accountSummaries = accountSummaries, + vaultFilterData = vaultFilterData, viewState = VaultState.ViewState.Loading, ) }, @@ -96,6 +104,7 @@ class VaultViewModel @Inject constructor( is VaultAction.SyncClick -> handleSyncClick() is VaultAction.LockClick -> handleLockClick() is VaultAction.ExitConfirmationClick -> handleExitConfirmationClick() + is VaultAction.VaultFilterTypeSelect -> handleVaultFilterTypeSelect(action) is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick() is VaultAction.TrashClick -> handleTrashClick() is VaultAction.VaultItemClick -> handleVaultItemClick(action) @@ -183,6 +192,16 @@ class VaultViewModel @Inject constructor( sendEvent(VaultEvent.NavigateOutOfApp) } + private fun handleVaultFilterTypeSelect(action: VaultAction.VaultFilterTypeSelect) { + mutableStateFlow.update { + it.copy( + vaultFilterData = it.vaultFilterData?.copy( + selectedVaultFilterType = action.vaultFilterType, + ), + ) + } + } + private fun handleTrashClick() { sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Trash)) } @@ -214,13 +233,17 @@ class VaultViewModel @Inject constructor( // navigating. if (state.isSwitchingAccounts) return + val vaultFilterData = userState.activeAccount.toVaultFilterData() + val appBarTitle = vaultFilterData.toAppBarTitle() mutableStateFlow.update { val accountSummaries = userState.toAccountSummaries() val activeAccountSummary = userState.toActiveAccountSummary() it.copy( + appBarTitle = appBarTitle, initials = activeAccountSummary.initials, avatarColorString = activeAccountSummary.avatarColorHex, accountSummaries = accountSummaries, + vaultFilterData = vaultFilterData, ) } } @@ -284,9 +307,11 @@ class VaultViewModel @Inject constructor( */ @Parcelize data class VaultState( + val appBarTitle: Text, private val avatarColorString: String, val initials: String, val accountSummaries: List, + val vaultFilterData: VaultFilterData? = null, val viewState: ViewState, val dialog: DialogState? = null, // Internal-use properties @@ -310,12 +335,18 @@ data class VaultState( */ abstract val hasFab: Boolean + /** + * Determines whether or not the the Vault Filter may be shown (when applicable). + */ + abstract val hasVaultFilter: Boolean + /** * Loading state for the [VaultScreen], signifying that the content is being processed. */ @Parcelize data object Loading : ViewState() { override val hasFab: Boolean get() = false + override val hasVaultFilter: Boolean get() = false } /** @@ -324,6 +355,7 @@ data class VaultState( @Parcelize data object NoItems : ViewState() { override val hasFab: Boolean get() = true + override val hasVaultFilter: Boolean get() = true } /** @@ -335,6 +367,7 @@ data class VaultState( val message: Text, ) : ViewState() { override val hasFab: Boolean get() = false + override val hasVaultFilter: Boolean get() = false } /** @@ -363,6 +396,7 @@ data class VaultState( val trashItemsCount: Int, ) : ViewState() { override val hasFab: Boolean get() = true + override val hasVaultFilter: Boolean get() = true } /** @@ -621,6 +655,13 @@ sealed class VaultAction { */ data object ExitConfirmationClick : VaultAction() + /** + * User selected a [VaultFilterType] from the Vault Filter menu. + */ + data class VaultFilterTypeSelect( + val vaultFilterType: VaultFilterType, + ) : VaultAction() + /** * Action to trigger when a specific vault item is clicked. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/model/VaultFilterData.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/model/VaultFilterData.kt new file mode 100644 index 000000000..b1669fc1d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/model/VaultFilterData.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents a currently [selectedVaultFilterType] and a list of possible [vaultFilterTypes]. + */ +@Parcelize +data class VaultFilterData( + val selectedVaultFilterType: VaultFilterType, + val vaultFilterTypes: List, +) : Parcelable diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/model/VaultFilterType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/model/VaultFilterType.kt new file mode 100644 index 000000000..400a3002f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/model/VaultFilterType.kt @@ -0,0 +1,53 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.model + +import android.os.Parcelable +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import kotlinx.parcelize.Parcelize + +/** + * Represents a way to filter on vaults when more than one may be present in a list of vault items. + */ +sealed class VaultFilterType : Parcelable { + /** + * A short name for the filter. + */ + abstract val name: Text + + /** + * A potentially longer description of the filter. This may be the same as the [name] when there + * is no distinction necessary. + */ + abstract val description: Text + + /** + * Data from all vaults should be present (i.e. there is no filtering). + */ + @Parcelize + data object AllVaults : VaultFilterType() { + override val name: Text get() = R.string.all.asText() + override val description: Text get() = R.string.all_vaults.asText() + } + + /** + * Only data from the user's personal vault shoudl be present. + */ + @Parcelize + data object MyVault : VaultFilterType() { + override val name: Text get() = R.string.my_vault.asText() + override val description: Text get() = R.string.my_vault.asText() + } + + /** + * Only data from the organization with the given [organizationId] should be present. + */ + @Parcelize + data class OrganizationVault( + val organizationId: String, + val organizationName: String, + ) : VaultFilterType() { + override val name: Text get() = organizationName.asText() + override val description: Text get() = organizationName.asText() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt index 8571fdecc..765b40ec0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util import com.x8bit.bitwarden.data.auth.repository.model.UserState 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 /** * Converts the given [UserState] to a list of [AccountSummary]. @@ -39,3 +41,30 @@ fun UserState.Account.toAccountSummary( isActive = isActive, isVaultUnlocked = this.isVaultUnlocked, ) + +/** + * Converts the given [UserState.Account] to a [VaultFilterData] (if applicable). Filter data is + * only relevant when the given account is associated with one or more organizations. + */ +fun UserState.Account.toVaultFilterData(): VaultFilterData? = + this + .organizations + .takeIf { it.isNotEmpty() } + ?.let { organizations -> + VaultFilterData( + selectedVaultFilterType = VaultFilterType.AllVaults, + vaultFilterTypes = listOf( + VaultFilterType.AllVaults, + VaultFilterType.MyVault, + *organizations + .sortedBy { it.name } + .map { organization -> + VaultFilterType.OrganizationVault( + organizationId = organization.id, + organizationName = organization.name.orEmpty(), + ) + } + .toTypedArray(), + ), + ) + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultFilterDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultFilterDataExtensions.kt new file mode 100644 index 000000000..56430d36e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultFilterDataExtensions.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData + +/** + * Derives an app bar title given the [VaultFilterData]. + */ +fun VaultFilterData?.toAppBarTitle(): Text = + if (this != null) { + R.string.vaults + } else { + R.string.my_vault + } + .asText() 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 9e7dace9a..e655c813d 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 @@ -14,6 +14,7 @@ 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 com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -32,6 +33,8 @@ import com.x8bit.bitwarden.ui.util.performAddAccountClick import com.x8bit.bitwarden.ui.util.performLockAccountClick import com.x8bit.bitwarden.ui.util.performLogoutAccountClick import com.x8bit.bitwarden.ui.util.performLogoutAccountConfirmationClick +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.model.VaultItemListingType import io.mockk.every import io.mockk.mockk @@ -80,6 +83,135 @@ class VaultScreenTest : BaseComposeTest() { } } + @Test + fun `app bar title should update according to state`() { + composeTestRule.onNodeWithText("My vault").assertIsDisplayed() + composeTestRule.onNodeWithText("Vaults").assertDoesNotExist() + + mutableStateFlow.update { + it.copy(appBarTitle = R.string.vaults.asText()) + } + + composeTestRule.onNodeWithText("My vault").assertDoesNotExist() + composeTestRule.onNodeWithText("Vaults").assertIsDisplayed() + } + + @Test + fun `vault filter should update according to state`() { + composeTestRule.onNodeWithText("Vault: All").assertDoesNotExist() + composeTestRule.onNodeWithText("Vault: My vault").assertDoesNotExist() + composeTestRule.onNodeWithText("Vault: Test Organization").assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + vaultFilterData = VAULT_FILTER_DATA, + viewState = DEFAULT_CONTENT_VIEW_STATE, + ) + } + + composeTestRule.onNodeWithText("Vault: All").assertIsDisplayed() + composeTestRule.onNodeWithText("Vault: My vault").assertDoesNotExist() + composeTestRule.onNodeWithText("Vault: Test Organization").assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + vaultFilterData = VAULT_FILTER_DATA.copy( + selectedVaultFilterType = VaultFilterType.MyVault, + ), + ) + } + + composeTestRule.onNodeWithText("Vault: All").assertDoesNotExist() + composeTestRule.onNodeWithText("Vault: My vault").assertIsDisplayed() + composeTestRule.onNodeWithText("Vault: Test Organization").assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + vaultFilterData = VAULT_FILTER_DATA.copy( + selectedVaultFilterType = ORGANIZATION_VAULT_FILTER, + ), + ) + } + + composeTestRule.onNodeWithText("Vault: All").assertDoesNotExist() + composeTestRule.onNodeWithText("Vault: My vault").assertDoesNotExist() + composeTestRule.onNodeWithText("Vault: Test Organization").assertIsDisplayed() + } + + @Test + fun `vault filter menu click should display the filter selection dialog`() { + // Display the vault filter + mutableStateFlow.update { + it.copy( + vaultFilterData = VAULT_FILTER_DATA, + viewState = DEFAULT_CONTENT_VIEW_STATE, + ) + } + + composeTestRule.assertNoDialogExists() + + composeTestRule.onNodeWithContentDescription("Filter items by vault").performClick() + + composeTestRule + .onAllNodesWithText("All vaults") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("My vault") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Test Organization") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `cancel click in the filter selection dialog should close the dialog`() { + // Display the vault selection dialog + mutableStateFlow.update { + it.copy( + vaultFilterData = VAULT_FILTER_DATA, + viewState = DEFAULT_CONTENT_VIEW_STATE, + ) + } + composeTestRule.onNodeWithContentDescription("Filter items by vault").performClick() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `vault filter click in the filter selection dialog should send VaultFilterTypeSelect and close the dialog`() { + // Display the vault selection dialog + mutableStateFlow.update { + it.copy( + vaultFilterData = VAULT_FILTER_DATA, + viewState = DEFAULT_CONTENT_VIEW_STATE, + ) + } + composeTestRule.onNodeWithContentDescription("Filter items by vault").performClick() + + composeTestRule + .onAllNodesWithText("All vaults") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction(VaultAction.VaultFilterTypeSelect(VaultFilterType.AllVaults)) + } + composeTestRule.assertNoDialogExists() + } + @Suppress("MaxLineLength") @Test fun `account icon click should show the account switcher and trigger the nav bar dim request`() { @@ -858,6 +990,20 @@ private val LOCKED_ACCOUNT_SUMMARY = AccountSummary( isVaultUnlocked = false, ) +private val ORGANIZATION_VAULT_FILTER = VaultFilterType.OrganizationVault( + organizationId = "testOrganizationId", + organizationName = "Test Organization", +) + +private val VAULT_FILTER_DATA = VaultFilterData( + selectedVaultFilterType = VaultFilterType.AllVaults, + vaultFilterTypes = listOf( + VaultFilterType.AllVaults, + VaultFilterType.MyVault, + ORGANIZATION_VAULT_FILTER, + ), +) + private val DEFAULT_STATE: VaultState = VaultState( avatarColorString = "#aa00aa", initials = "AU", @@ -865,6 +1011,7 @@ private val DEFAULT_STATE: VaultState = VaultState( ACTIVE_ACCOUNT_SUMMARY, LOCKED_ACCOUNT_SUMMARY, ), + appBarTitle = R.string.my_vault.asText(), viewState = VaultState.ViewState.Loading, ) 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 004188311..1463b7982 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 @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault import app.cash.turbine.test import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState.SpecialCircumstance @@ -16,6 +17,8 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest 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.model.VaultItemListingType import io.mockk.every import io.mockk.just @@ -126,13 +129,19 @@ class VaultViewModelTest : BaseViewModelTest() { environment = Environment.Us, isPremium = true, isVaultUnlocked = true, - organizations = emptyList(), + organizations = listOf( + Organization( + id = "organiationId", + name = "Test Organization", + ), + ), ), ), ) assertEquals( DEFAULT_STATE.copy( + appBarTitle = R.string.vaults.asText(), avatarColorString = "#00aaaa", initials = "OU", accountSummaries = listOf( @@ -146,6 +155,17 @@ class VaultViewModelTest : BaseViewModelTest() { isVaultUnlocked = true, ), ), + vaultFilterData = VaultFilterData( + selectedVaultFilterType = VaultFilterType.AllVaults, + vaultFilterTypes = listOf( + VaultFilterType.AllVaults, + VaultFilterType.MyVault, + VaultFilterType.OrganizationVault( + organizationId = "organiationId", + organizationName = "Test Organization", + ), + ), + ), ), viewModel.stateFlow.value, ) @@ -286,6 +306,46 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @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, + ) + mutableUserStateFlow.value = DEFAULT_USER_STATE + .copy( + accounts = listOf( + DEFAULT_USER_STATE.accounts[0].copy( + organizations = listOf( + Organization( + id = "testOrganizationId", + name = "Test Organization", + ), + ), + ), + DEFAULT_USER_STATE.accounts[1], + ), + ) + assertEquals( + initialState, + viewModel.stateFlow.value, + ) + + viewModel.trySendAction(VaultAction.VaultFilterTypeSelect(VaultFilterType.MyVault)) + + assertEquals( + initialState.copy( + vaultFilterData = VAULT_FILTER_DATA.copy( + selectedVaultFilterType = VaultFilterType.MyVault, + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `vaultDataStateFlow Loaded with items should update state to Content`() = runTest { mutableVaultDataStateFlow.tryEmit( @@ -760,6 +820,20 @@ class VaultViewModelTest : BaseViewModelTest() { ) } +private val ORGANIZATION_VAULT_FILTER = VaultFilterType.OrganizationVault( + organizationId = "testOrganizationId", + organizationName = "Test Organization", +) + +private val VAULT_FILTER_DATA = VaultFilterData( + selectedVaultFilterType = VaultFilterType.AllVaults, + vaultFilterTypes = listOf( + VaultFilterType.AllVaults, + VaultFilterType.MyVault, + ORGANIZATION_VAULT_FILTER, + ), +) + private val DEFAULT_STATE: VaultState = createMockVaultState(viewState = VaultState.ViewState.Loading) @@ -794,6 +868,7 @@ private fun createMockVaultState( dialog: VaultState.DialogState? = null, ): VaultState = VaultState( + appBarTitle = R.string.my_vault.asText(), avatarColorString = "#aa00aa", initials = "AU", accountSummaries = listOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt index d3cceb61c..56f909b7c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt @@ -5,7 +5,10 @@ import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.model.Environment 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 org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test class UserStateExtensionsTest { @@ -197,4 +200,63 @@ class UserStateExtensionsTest { .toActiveAccountSummary(), ) } + + @Test + fun `toVaultFilterData for an account with no organizations should return a null value`() { + assertNull( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + environment = Environment.Us, + isPremium = true, + isVaultUnlocked = true, + organizations = emptyList(), + ) + .toVaultFilterData(), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toVaultFilterData for an account with organizations should return data with the available types in the correct order`() { + assertEquals( + VaultFilterData( + selectedVaultFilterType = VaultFilterType.AllVaults, + vaultFilterTypes = listOf( + VaultFilterType.AllVaults, + VaultFilterType.MyVault, + VaultFilterType.OrganizationVault( + organizationId = "organizationId-A", + organizationName = "Organization A", + ), + VaultFilterType.OrganizationVault( + organizationId = "organizationId-B", + organizationName = "Organization B", + ), + ), + ), + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + environment = Environment.Us, + isPremium = true, + isVaultUnlocked = true, + organizations = listOf( + Organization( + id = "organizationId-B", + name = "Organization B", + ), + Organization( + id = "organizationId-A", + name = "Organization A", + ), + ), + ) + .toVaultFilterData(), + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultFilterDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultFilterDataExtensionsTest.kt new file mode 100644 index 000000000..ba51083de --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultFilterDataExtensionsTest.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class VaultFilterDataExtensionsTest { + @Test + fun `toAppBarTitle for a null value should return My Vault`() { + assertEquals( + R.string.my_vault.asText(), + (null as VaultFilterData?).toAppBarTitle(), + ) + } + + @Test + fun `toAppBarTitle for a non-null value should return Vaults`() { + assertEquals( + R.string.vaults.asText(), + VaultFilterData( + selectedVaultFilterType = VaultFilterType.MyVault, + vaultFilterTypes = listOf( + VaultFilterType.AllVaults, + VaultFilterType.MyVault, + ), + ) + .toAppBarTitle(), + ) + } +}