BIT-842, BIT-843: Add Vault Filter and Vault Selection menu UI (#448)

This commit is contained in:
Brian Yencho 2023-12-28 16:33:38 -06:00 committed by Álison Fernandes
parent 3c29dccf62
commit 3f0e44d42f
11 changed files with 631 additions and 30 deletions

View file

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

View file

@ -5,10 +5,13 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults 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.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary 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 com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -90,6 +94,9 @@ fun VaultScreen(
} }
VaultScreenScaffold( VaultScreenScaffold(
state = viewModel.stateFlow.collectAsState().value, state = viewModel.stateFlow.collectAsState().value,
vaultFilterTypeSelect = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.VaultFilterTypeSelect(it)) }
},
addItemClickAction = remember(viewModel) { addItemClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.AddItemClick) } { viewModel.trySendAction(VaultAction.AddItemClick) }
}, },
@ -161,6 +168,7 @@ fun VaultScreen(
@Composable @Composable
private fun VaultScreenScaffold( private fun VaultScreenScaffold(
state: VaultState, state: VaultState,
vaultFilterTypeSelect: (VaultFilterType) -> Unit,
addItemClickAction: () -> Unit, addItemClickAction: () -> Unit,
searchIconClickAction: () -> Unit, searchIconClickAction: () -> Unit,
accountLockClickAction: (AccountSummary) -> Unit, accountLockClickAction: (AccountSummary) -> Unit,
@ -230,7 +238,7 @@ private fun VaultScreenScaffold(
BitwardenScaffold( BitwardenScaffold(
topBar = { topBar = {
BitwardenMediumTopAppBar( BitwardenMediumTopAppBar(
title = stringResource(id = R.string.my_vault), title = state.appBarTitle(),
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
actions = { actions = {
BitwardenAccountActionItem( BitwardenAccountActionItem(
@ -283,10 +291,33 @@ private fun VaultScreenScaffold(
}, },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues -> ) { paddingValues ->
val modifier = Modifier Box {
val innerModifier = Modifier
.fillMaxSize()
val outerModifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
Box { 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(),
)
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier
.fillMaxWidth(),
)
}
when (val viewState = state.viewState) { when (val viewState = state.viewState) {
is VaultState.ViewState.Content -> VaultContent( is VaultState.ViewState.Content -> VaultContent(
state = viewState, state = viewState,
@ -298,22 +329,23 @@ private fun VaultScreenScaffold(
identityGroupClick = identityGroupClick, identityGroupClick = identityGroupClick,
secureNoteGroupClick = secureNoteGroupClick, secureNoteGroupClick = secureNoteGroupClick,
trashClick = trashClick, trashClick = trashClick,
modifier = modifier, modifier = innerModifier,
) )
is VaultState.ViewState.Loading -> VaultLoading(modifier = modifier) is VaultState.ViewState.Loading -> VaultLoading(modifier = innerModifier)
is VaultState.ViewState.NoItems -> VaultNoItems( is VaultState.ViewState.NoItems -> VaultNoItems(
modifier = modifier, modifier = innerModifier,
addItemClickAction = addItemClickAction, addItemClickAction = addItemClickAction,
) )
is VaultState.ViewState.Error -> BitwardenErrorContent( is VaultState.ViewState.Error -> BitwardenErrorContent(
message = viewState.message(), message = viewState.message(),
onTryAgainClick = tryAgainClick, onTryAgainClick = tryAgainClick,
modifier = modifier modifier = innerModifier
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
} }
}
BitwardenAccountSwitcher( BitwardenAccountSwitcher(
isVisible = accountMenuVisible, isVisible = accountMenuVisible,
@ -324,7 +356,7 @@ private fun VaultScreenScaffold(
onAddAccountClick = addAccountClickAction, onAddAccountClick = addAccountClickAction,
onDismissRequest = { updateAccountMenuVisibility(false) }, onDismissRequest = { updateAccountMenuVisibility(false) },
topAppBarScrollBehavior = scrollBehavior, topAppBarScrollBehavior = scrollBehavior,
modifier = modifier, modifier = outerModifier,
) )
} }
} }

View file

@ -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.concat
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary 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.initials
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries 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.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.feature.vault.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -45,10 +49,14 @@ class VaultViewModel @Inject constructor(
val userState = requireNotNull(authRepository.userStateFlow.value) val userState = requireNotNull(authRepository.userStateFlow.value)
val accountSummaries = userState.toAccountSummaries() val accountSummaries = userState.toAccountSummaries()
val activeAccountSummary = userState.toActiveAccountSummary() val activeAccountSummary = userState.toActiveAccountSummary()
val vaultFilterData = userState.activeAccount.toVaultFilterData()
val appBarTitle = vaultFilterData.toAppBarTitle()
VaultState( VaultState(
appBarTitle = appBarTitle,
initials = activeAccountSummary.initials, initials = activeAccountSummary.initials,
avatarColorString = activeAccountSummary.avatarColorHex, avatarColorString = activeAccountSummary.avatarColorHex,
accountSummaries = accountSummaries, accountSummaries = accountSummaries,
vaultFilterData = vaultFilterData,
viewState = VaultState.ViewState.Loading, viewState = VaultState.ViewState.Loading,
) )
}, },
@ -96,6 +104,7 @@ class VaultViewModel @Inject constructor(
is VaultAction.SyncClick -> handleSyncClick() is VaultAction.SyncClick -> handleSyncClick()
is VaultAction.LockClick -> handleLockClick() is VaultAction.LockClick -> handleLockClick()
is VaultAction.ExitConfirmationClick -> handleExitConfirmationClick() is VaultAction.ExitConfirmationClick -> handleExitConfirmationClick()
is VaultAction.VaultFilterTypeSelect -> handleVaultFilterTypeSelect(action)
is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick() is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
is VaultAction.TrashClick -> handleTrashClick() is VaultAction.TrashClick -> handleTrashClick()
is VaultAction.VaultItemClick -> handleVaultItemClick(action) is VaultAction.VaultItemClick -> handleVaultItemClick(action)
@ -183,6 +192,16 @@ class VaultViewModel @Inject constructor(
sendEvent(VaultEvent.NavigateOutOfApp) sendEvent(VaultEvent.NavigateOutOfApp)
} }
private fun handleVaultFilterTypeSelect(action: VaultAction.VaultFilterTypeSelect) {
mutableStateFlow.update {
it.copy(
vaultFilterData = it.vaultFilterData?.copy(
selectedVaultFilterType = action.vaultFilterType,
),
)
}
}
private fun handleTrashClick() { private fun handleTrashClick() {
sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Trash)) sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Trash))
} }
@ -214,13 +233,17 @@ class VaultViewModel @Inject constructor(
// navigating. // navigating.
if (state.isSwitchingAccounts) return if (state.isSwitchingAccounts) return
val vaultFilterData = userState.activeAccount.toVaultFilterData()
val appBarTitle = vaultFilterData.toAppBarTitle()
mutableStateFlow.update { mutableStateFlow.update {
val accountSummaries = userState.toAccountSummaries() val accountSummaries = userState.toAccountSummaries()
val activeAccountSummary = userState.toActiveAccountSummary() val activeAccountSummary = userState.toActiveAccountSummary()
it.copy( it.copy(
appBarTitle = appBarTitle,
initials = activeAccountSummary.initials, initials = activeAccountSummary.initials,
avatarColorString = activeAccountSummary.avatarColorHex, avatarColorString = activeAccountSummary.avatarColorHex,
accountSummaries = accountSummaries, accountSummaries = accountSummaries,
vaultFilterData = vaultFilterData,
) )
} }
} }
@ -284,9 +307,11 @@ class VaultViewModel @Inject constructor(
*/ */
@Parcelize @Parcelize
data class VaultState( data class VaultState(
val appBarTitle: Text,
private val avatarColorString: String, private val avatarColorString: String,
val initials: String, val initials: String,
val accountSummaries: List<AccountSummary>, val accountSummaries: List<AccountSummary>,
val vaultFilterData: VaultFilterData? = null,
val viewState: ViewState, val viewState: ViewState,
val dialog: DialogState? = null, val dialog: DialogState? = null,
// Internal-use properties // Internal-use properties
@ -310,12 +335,18 @@ data class VaultState(
*/ */
abstract val hasFab: Boolean 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. * Loading state for the [VaultScreen], signifying that the content is being processed.
*/ */
@Parcelize @Parcelize
data object Loading : ViewState() { data object Loading : ViewState() {
override val hasFab: Boolean get() = false override val hasFab: Boolean get() = false
override val hasVaultFilter: Boolean get() = false
} }
/** /**
@ -324,6 +355,7 @@ data class VaultState(
@Parcelize @Parcelize
data object NoItems : ViewState() { data object NoItems : ViewState() {
override val hasFab: Boolean get() = true override val hasFab: Boolean get() = true
override val hasVaultFilter: Boolean get() = true
} }
/** /**
@ -335,6 +367,7 @@ data class VaultState(
val message: Text, val message: Text,
) : ViewState() { ) : ViewState() {
override val hasFab: Boolean get() = false override val hasFab: Boolean get() = false
override val hasVaultFilter: Boolean get() = false
} }
/** /**
@ -363,6 +396,7 @@ data class VaultState(
val trashItemsCount: Int, val trashItemsCount: Int,
) : ViewState() { ) : ViewState() {
override val hasFab: Boolean get() = true override val hasFab: Boolean get() = true
override val hasVaultFilter: Boolean get() = true
} }
/** /**
@ -621,6 +655,13 @@ sealed class VaultAction {
*/ */
data object ExitConfirmationClick : 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. * Action to trigger when a specific vault item is clicked.
*/ */

View file

@ -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<VaultFilterType>,
) : Parcelable

View file

@ -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()
}
}

View file

@ -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.data.auth.repository.model.UserState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary 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]. * Converts the given [UserState] to a list of [AccountSummary].
@ -39,3 +41,30 @@ fun UserState.Account.toAccountSummary(
isActive = isActive, isActive = isActive,
isVaultUnlocked = this.isVaultUnlocked, 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(),
),
)
}

View file

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

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode 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.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText 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.performLockAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountClick import com.x8bit.bitwarden.ui.util.performLogoutAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountConfirmationClick 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 com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.every import io.mockk.every
import io.mockk.mockk 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") @Suppress("MaxLineLength")
@Test @Test
fun `account icon click should show the account switcher and trigger the nav bar dim request`() { 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, 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( private val DEFAULT_STATE: VaultState = VaultState(
avatarColorString = "#aa00aa", avatarColorString = "#aa00aa",
initials = "AU", initials = "AU",
@ -865,6 +1011,7 @@ private val DEFAULT_STATE: VaultState = VaultState(
ACTIVE_ACCOUNT_SUMMARY, ACTIVE_ACCOUNT_SUMMARY,
LOCKED_ACCOUNT_SUMMARY, LOCKED_ACCOUNT_SUMMARY,
), ),
appBarTitle = R.string.my_vault.asText(),
viewState = VaultState.ViewState.Loading, viewState = VaultState.ViewState.Loading,
) )

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository 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.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.UserState.SpecialCircumstance 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.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary 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 com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
@ -126,13 +129,19 @@ class VaultViewModelTest : BaseViewModelTest() {
environment = Environment.Us, environment = Environment.Us,
isPremium = true, isPremium = true,
isVaultUnlocked = true, isVaultUnlocked = true,
organizations = emptyList(), organizations = listOf(
Organization(
id = "organiationId",
name = "Test Organization",
),
),
), ),
), ),
) )
assertEquals( assertEquals(
DEFAULT_STATE.copy( DEFAULT_STATE.copy(
appBarTitle = R.string.vaults.asText(),
avatarColorString = "#00aaaa", avatarColorString = "#00aaaa",
initials = "OU", initials = "OU",
accountSummaries = listOf( accountSummaries = listOf(
@ -146,6 +155,17 @@ class VaultViewModelTest : BaseViewModelTest() {
isVaultUnlocked = true, isVaultUnlocked = true,
), ),
), ),
vaultFilterData = VaultFilterData(
selectedVaultFilterType = VaultFilterType.AllVaults,
vaultFilterTypes = listOf(
VaultFilterType.AllVaults,
VaultFilterType.MyVault,
VaultFilterType.OrganizationVault(
organizationId = "organiationId",
organizationName = "Test Organization",
),
),
),
), ),
viewModel.stateFlow.value, 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 @Test
fun `vaultDataStateFlow Loaded with items should update state to Content`() = runTest { fun `vaultDataStateFlow Loaded with items should update state to Content`() = runTest {
mutableVaultDataStateFlow.tryEmit( 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 = private val DEFAULT_STATE: VaultState =
createMockVaultState(viewState = VaultState.ViewState.Loading) createMockVaultState(viewState = VaultState.ViewState.Loading)
@ -794,6 +868,7 @@ private fun createMockVaultState(
dialog: VaultState.DialogState? = null, dialog: VaultState.DialogState? = null,
): VaultState = ): VaultState =
VaultState( VaultState(
appBarTitle = R.string.my_vault.asText(),
avatarColorString = "#aa00aa", avatarColorString = "#aa00aa",
initials = "AU", initials = "AU",
accountSummaries = listOf( accountSummaries = listOf(

View file

@ -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.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary 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.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class UserStateExtensionsTest { class UserStateExtensionsTest {
@ -197,4 +200,63 @@ class UserStateExtensionsTest {
.toActiveAccountSummary(), .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(),
)
}
} }

View file

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