Show appropriate empty states for autofill flow (#843)

This commit is contained in:
Brian Yencho 2024-01-28 22:42:13 -06:00 committed by Álison Fernandes
parent 9a8aca9fe1
commit 82ef39e15d
8 changed files with 214 additions and 71 deletions

View file

@ -9,10 +9,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultNoItems import com.x8bit.bitwarden.ui.vault.feature.vault.VaultNoItems
/** /**
@ -20,31 +18,21 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.VaultNoItems
*/ */
@Composable @Composable
fun VaultItemListingEmpty( fun VaultItemListingEmpty(
itemListingType: VaultItemListingState.ItemListingType, state: VaultItemListingState.ViewState.NoItems,
addItemClickAction: () -> Unit, addItemClickAction: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (itemListingType) { if (state.shouldShowAddButton) {
is VaultItemListingState.ItemListingType.Vault.Folder -> { VaultNoItems(
GenericNoItems( message = state.message(),
modifier = modifier, modifier = modifier,
text = stringResource(id = R.string.no_items_folder), addItemClickAction = addItemClickAction,
) )
} } else {
GenericNoItems(
is VaultItemListingState.ItemListingType.Vault.Trash -> { text = state.message(),
GenericNoItems( modifier = modifier,
modifier = modifier, )
text = stringResource(id = R.string.no_items_trash),
)
}
else -> {
VaultNoItems(
modifier = modifier,
addItemClickAction = addItemClickAction,
)
}
} }
} }

View file

@ -242,7 +242,7 @@ private fun VaultItemListingScaffold(
is VaultItemListingState.ViewState.NoItems -> { is VaultItemListingState.ViewState.NoItems -> {
VaultItemListingEmpty( VaultItemListingEmpty(
itemListingType = state.itemListingType, state = state.viewState,
addItemClickAction = vaultItemListingHandlers.addVaultItemClick, addItemClickAction = vaultItemListingHandlers.addVaultItemClick,
modifier = modifier, modifier = modifier,
) )

View file

@ -545,8 +545,10 @@ class VaultItemListingViewModel @Inject constructor(
} }
.toFilteredList(state.vaultFilterType) .toFilteredList(state.vaultFilterType)
.toViewState( .toViewState(
itemListingType = listingType,
baseIconUrl = state.baseIconUrl, baseIconUrl = state.baseIconUrl,
isIconLoadingDisabled = state.isIconLoadingDisabled, isIconLoadingDisabled = state.isIconLoadingDisabled,
autofillSelectionData = state.autofillSelectionData,
) )
} }
@ -702,7 +704,10 @@ data class VaultItemListingState(
/** /**
* Represents a state where the [VaultItemListingScreen] has no items to display. * Represents a state where the [VaultItemListingScreen] has no items to display.
*/ */
data object NoItems : ViewState() { data class NoItems(
val message: Text,
val shouldShowAddButton: Boolean,
) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true override val isPullToRefreshEnabled: Boolean get() = true
} }

View file

@ -8,7 +8,10 @@ import com.bitwarden.core.FolderView
import com.bitwarden.core.SendType import com.bitwarden.core.SendType
import com.bitwarden.core.SendView import com.bitwarden.core.SendView
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.util.subtitle import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.tools.feature.send.util.toLabelIcons import com.x8bit.bitwarden.ui.tools.feature.send.util.toLabelIcons
@ -78,8 +81,10 @@ fun SendView.determineListingPredicate(
* Transforms a list of [CipherView] into [VaultItemListingState.ViewState]. * Transforms a list of [CipherView] into [VaultItemListingState.ViewState].
*/ */
fun List<CipherView>.toViewState( fun List<CipherView>.toViewState(
itemListingType: VaultItemListingState.ItemListingType.Vault,
baseIconUrl: String, baseIconUrl: String,
isIconLoadingDisabled: Boolean, isIconLoadingDisabled: Boolean,
autofillSelectionData: AutofillSelectionData?,
): VaultItemListingState.ViewState = ): VaultItemListingState.ViewState =
if (isNotEmpty()) { if (isNotEmpty()) {
VaultItemListingState.ViewState.Content( VaultItemListingState.ViewState.Content(
@ -89,7 +94,36 @@ fun List<CipherView>.toViewState(
), ),
) )
} else { } else {
VaultItemListingState.ViewState.NoItems // Use the autofill empty message if necessary, otherwise use normal type-specific message
val message = autofillSelectionData
?.uri
?.toHostOrPathOrNull()
?.let { R.string.no_items_for_uri.asText(it) }
?: run {
when (itemListingType) {
is VaultItemListingState.ItemListingType.Vault.Folder -> {
R.string.no_items_folder
}
VaultItemListingState.ItemListingType.Vault.Trash -> {
R.string.no_items_trash
}
else -> R.string.no_items
}
.asText()
}
val shouldShowAddButton = when (itemListingType) {
is VaultItemListingState.ItemListingType.Vault.Folder,
VaultItemListingState.ItemListingType.Vault.Trash,
-> false
else -> true
}
VaultItemListingState.ViewState.NoItems(
message = message,
shouldShowAddButton = shouldShowAddButton,
)
} }
/** /**
@ -107,7 +141,10 @@ fun List<SendView>.toViewState(
), ),
) )
} else { } else {
VaultItemListingState.ViewState.NoItems VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
)
} }
/** * Updates a [VaultItemListingState.ItemListingType] with the given data if necessary. */ /** * Updates a [VaultItemListingState.ItemListingType] with the given data if necessary. */

View file

@ -25,6 +25,7 @@ import com.x8bit.bitwarden.R
fun VaultNoItems( fun VaultNoItems(
addItemClickAction: () -> Unit, addItemClickAction: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: String = stringResource(id = R.string.no_items),
) { ) {
Column( Column(
modifier = modifier, modifier = modifier,
@ -36,7 +37,7 @@ fun VaultNoItems(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
text = stringResource(id = R.string.no_items), text = message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )

View file

@ -300,7 +300,14 @@ class VaultItemListingScreenTest : BaseComposeTest() {
@Test @Test
fun `add an item button click should send AddItemClick action`() { fun `add an item button click should send AddItemClick action`() {
mutableStateFlow.update { it.copy(viewState = VaultItemListingState.ViewState.NoItems) } mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.NoItems(
message = "There are no items in your vault.".asText(),
shouldShowAddButton = true,
),
)
}
composeTestRule composeTestRule
.onNodeWithText("Add an Item") .onNodeWithText("Add an Item")
.performClick() .performClick()
@ -385,7 +392,12 @@ class VaultItemListingScreenTest : BaseComposeTest() {
.assertIsDisplayed() .assertIsDisplayed()
mutableStateFlow.update { mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.NoItems) it.copy(
viewState = VaultItemListingState.ViewState.NoItems(
message = "There are no items in your vault.".asText(),
shouldShowAddButton = true,
),
)
} }
composeTestRule composeTestRule
@ -401,7 +413,14 @@ class VaultItemListingScreenTest : BaseComposeTest() {
.onNodeWithText(message) .onNodeWithText(message)
.assertIsNotDisplayed() .assertIsNotDisplayed()
mutableStateFlow.update { it.copy(viewState = VaultItemListingState.ViewState.NoItems) } mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.NoItems(
message = "There are no items in your vault.".asText(),
shouldShowAddButton = true,
),
)
}
composeTestRule composeTestRule
.onNodeWithText(message) .onNodeWithText(message)
.assertIsNotDisplayed() .assertIsNotDisplayed()
@ -422,23 +441,23 @@ class VaultItemListingScreenTest : BaseComposeTest() {
.assertDoesNotExist() .assertDoesNotExist()
mutableStateFlow.update { mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.NoItems) it.copy(
viewState = VaultItemListingState.ViewState.NoItems(
message = "There are no items in your vault.".asText(),
shouldShowAddButton = true,
),
)
} }
composeTestRule composeTestRule
.onNodeWithText(text = "Add an Item") .onNodeWithText(text = "Add an Item")
.assertIsDisplayed() .assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Vault.Trash)
}
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertDoesNotExist()
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
itemListingType = VaultItemListingState.ItemListingType.Vault.Folder( viewState = VaultItemListingState.ViewState.NoItems(
folderId = null, message = "There are no items in your vault.".asText(),
shouldShowAddButton = false,
), ),
) )
} }
@ -449,29 +468,16 @@ class VaultItemListingScreenTest : BaseComposeTest() {
@Test @Test
fun `empty text should be displayed according to state`() { fun `empty text should be displayed according to state`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(viewState = VaultItemListingState.ViewState.NoItems)
}
composeTestRule
.onNodeWithText(text = "There are no items in your vault.")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Vault.Trash)
}
composeTestRule
.onNodeWithText(text = "There are no items in the trash.")
.assertIsDisplayed()
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
itemListingType = VaultItemListingState.ItemListingType.Vault.Folder( viewState = VaultItemListingState.ViewState.NoItems(
folderId = null, message = "There are no items in your vault.".asText(),
shouldShowAddButton = true,
), ),
) )
} }
composeTestRule composeTestRule
.onNodeWithText(text = "There are no items in this folder.") .onNodeWithText(text = "There are no items in your vault.")
.assertIsDisplayed() .assertIsDisplayed()
} }

View file

@ -621,7 +621,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem()) assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
} }
assertEquals( assertEquals(
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems), createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
} }
@ -645,7 +650,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
} }
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems, viewState = VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
), ),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
@ -708,7 +716,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
val viewModel = createVaultItemListingViewModel() val viewModel = createVaultItemListingViewModel()
assertEquals( assertEquals(
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems), createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
} }
@ -729,7 +742,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
val viewModel = createVaultItemListingViewModel() val viewModel = createVaultItemListingViewModel()
assertEquals( assertEquals(
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems), createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
} }
@ -810,7 +828,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
} }
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems, viewState = VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
), ),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
@ -836,7 +857,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
} }
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems, viewState = VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
), ),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
@ -916,7 +940,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
} }
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems, viewState = VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
), ),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
@ -941,7 +968,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
} }
assertEquals( assertEquals(
createVaultItemListingState( createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems, viewState = VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
), ),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )

View file

@ -4,6 +4,8 @@ import android.net.Uri
import com.bitwarden.core.CipherType import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView import com.bitwarden.core.CipherView
import com.bitwarden.core.SendType import com.bitwarden.core.SendType
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
@ -12,13 +14,15 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import org.junit.Assert.assertEquals import org.junit.jupiter.api.AfterEach
import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
@ -30,6 +34,12 @@ class VaultItemListingDataExtensionsTest {
ZoneOffset.UTC, ZoneOffset.UTC,
) )
@AfterEach
fun tearDown() {
unmockkStatic(Uri::class)
unmockkStatic(CipherView::subtitle)
}
@Test @Test
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for non trash Login cipherView`() { fun `determineListingPredicate should return the correct predicate for non trash Login cipherView`() {
@ -333,8 +343,10 @@ class VaultItemListingDataExtensionsTest {
) )
val result = cipherViewList.toViewState( val result = cipherViewList.toViewState(
itemListingType = VaultItemListingState.ItemListingType.Vault.Login,
isIconLoadingDisabled = false, isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
) )
assertEquals( assertEquals(
@ -364,9 +376,73 @@ class VaultItemListingDataExtensionsTest {
), ),
result, result,
) )
}
unmockkStatic(CipherView::subtitle) @Suppress("MaxLineLength")
unmockkStatic(Uri::class) @Test
fun `toViewState should transform an empty list of CipherViews into a NoItems ViewState with the appropriate data`() {
val cipherViewList = emptyList<CipherView>()
// Trash
assertEquals(
VaultItemListingState.ViewState.NoItems(
message = R.string.no_items_trash.asText(),
shouldShowAddButton = false,
),
cipherViewList.toViewState(
itemListingType = VaultItemListingState.ItemListingType.Vault.Trash,
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
),
)
// Folders
assertEquals(
VaultItemListingState.ViewState.NoItems(
message = R.string.no_items_folder.asText(),
shouldShowAddButton = false,
),
cipherViewList.toViewState(
itemListingType = VaultItemListingState.ItemListingType.Vault.Folder(
folderId = "folderId",
),
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
),
)
// Other ciphers
assertEquals(
VaultItemListingState.ViewState.NoItems(
message = R.string.no_items.asText(),
shouldShowAddButton = true,
),
cipherViewList.toViewState(
itemListingType = VaultItemListingState.ItemListingType.Vault.Login,
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
),
)
// Autofill
assertEquals(
VaultItemListingState.ViewState.NoItems(
message = R.string.no_items_for_uri.asText("www.test.com"),
shouldShowAddButton = true,
),
cipherViewList.toViewState(
itemListingType = VaultItemListingState.ItemListingType.Vault.Login,
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
uri = "https://www.test.com",
),
),
)
} }
@Test @Test