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.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,10 +291,33 @@ private fun VaultScreenScaffold(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
val modifier = Modifier
Box {
val innerModifier = Modifier
.fillMaxSize()
val outerModifier = Modifier
.fillMaxSize()
.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) {
is VaultState.ViewState.Content -> VaultContent(
state = viewState,
@ -298,22 +329,23 @@ private fun VaultScreenScaffold(
identityGroupClick = identityGroupClick,
secureNoteGroupClick = secureNoteGroupClick,
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(
modifier = modifier,
modifier = innerModifier,
addItemClickAction = addItemClickAction,
)
is VaultState.ViewState.Error -> BitwardenErrorContent(
message = viewState.message(),
onTryAgainClick = tryAgainClick,
modifier = modifier
modifier = innerModifier
.padding(horizontal = 16.dp),
)
}
}
BitwardenAccountSwitcher(
isVisible = accountMenuVisible,
@ -324,7 +356,7 @@ private fun VaultScreenScaffold(
onAddAccountClick = addAccountClickAction,
onDismissRequest = { updateAccountMenuVisibility(false) },
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.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<AccountSummary>,
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.
*/

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

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

View file

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

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

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